Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 7a84c8ecd2 Match the map overview panel to the Figma spacing, radii and fills
Apply the Frame 98 design from "Maps - MBB /ML": 22/24px panel padding with
a 50px radius, a 68px segmented tab bar, 90px rows with 46px rounded-square
avatars, the detail header as a grey pill, and the activity block with a
white history tile. Rows no longer have a resting fill; grey marks hover and
focus only. Subtitles, timestamps and inactive tabs use the primary text
color.

The location history drops the dot-and-connector rail: zone entries show
the zone's colored icon tile, Away entries a short grey line. In a zone's
view arrivals show the person's avatar and departures the line. Font sizes,
panel width and the full-width More info button are left as they were.
2026-10-06 15:35:01 +02:00
Maarten Lakerveld 8c15494b4f Add ha-os spacing and radius tokens for off-grid design values
The OS map design (Figma "Maps - MBB /ML") uses 2, 18, 22, 34 and 46px
spacing and a 50px panel radius, none of which exist on the 4px space scale
or the radius ladder. Add them as ha-os-* variables so components can use
them without px literals.
2026-10-06 15:35:01 +02:00
Maarten Lakerveld a763faad9c Start a new zone at the center of the zones map
Create zone placed the new zone at a random spot up to 500 meters from
home, so it landed somewhere unexpected and often off-screen. When the zones
page has its map open, a new zone now starts at the center of the current
view, so panning to the right place first works as expected. A location
handed in, as from a person's Create zone action, still wins; without a map
the random offset around home remains.
2026-10-06 15:35:01 +02:00
Maarten Lakerveld eb70895aa9 Show a zone's name in a tooltip when hovering its marker on the zones map
The marker only had a native title, which appears late and in the
browser's own style. Every named editable location now gets an ha-tooltip
anchored to its element, so hovering a zone icon on the zones page names
it promptly; the native title is dropped so the two do not stack.
2026-10-06 15:35:01 +02:00
Maarten Lakerveld 1d0260187d Hide a zone's radius handle while the circle is under its icon
Zoomed out on the zones page, every zone's radius handle collapsed onto the
zone icon and covered it. The handle now hides while the radius on screen is
shorter than the icon's half width, so it returns as soon as the circle's
edge shows past the icon, and it stays while the radius is being dragged.
2026-10-06 15:35:01 +02:00
Maarten Lakerveld d06a2ecc2a Leave passive zones out of the zones map fit
Opening the zones page fitted the map around every zone, so a few remote
passive zones pushed the view out to the whole globe and adding a zone took
a lot of zooming. Passive zones are still drawn and editable, but no longer
count toward the fit unless they are the only zones there are. Editable
locations gained a fit flag for this.

Fixes #54595
2026-10-06 15:35:01 +02:00
Maarten Lakerveld 15b91c7436 Float every cluster bubble above its location
Only a bubble attached to a zone had a tail and hung above the zone's
circle; a bubble of nearby markers sat centered on the cluster, hiding the
spot it stood for. Every bubble now has the tail and floats above its
location with the same lift as a single marker's pin, so the two read as
one style. A zone's bubble keeps floating above the zone circle.
2026-10-06 15:35:01 +02:00
Maarten Lakerveld 5b29f96389 Pan to a selected map entity instead of zooming in on it
Focusing a person jumped to zoom 19 and a zone fitted its circle, which
often zoomed much further in than the user had been. Selecting now keeps
the current zoom and pans the entity into the free part of the view. It
only zooms in when another marker sits within 80 screen pixels, and then
just far enough to clear it, capped at zoom 17. Markers at the very same
position cannot be separated by zooming and are left alone. A zone is
fitted as its whole circle with a little room around it under that same
zoom cap, so a zone that fits the view keeps the zoom and a bigger one
zooms out just enough. A marker already on screen eases over; one off
screen flies there in an arc. Fits gain a fly option for this, and every
other fit now eases straight to its bounds on both engines, where
MapLibre used to fly. Map-engine gains pixelDistance, the Web Mercator
screen distance at a zoom level, with a test for its scale.
2026-10-06 15:35:01 +02:00
Maarten Lakerveld 5bc97fb91c Keep the map overview selection in browser history
The card remembered a single camera to restore from the overview's back
button, so after selecting one entity and then another the original view
was lost, and back from the second left the map zoomed on the first while
the overview showed the list. The selected entity now lives in the URL as
an entity_id query parameter; selecting pushes a history entry, and so
does deselecting, whether from the overview's back arrow or a click on
the map, so the browser's back and forward step through every selection.
The overview's arrow itself always returns to the list. Selecting focuses
the entity; deselecting leaves the map where it is. Only selectable
domains are accepted from the URL; anything else is dropped from it. The
saved camera, the animated setView and the autoFit view flag had no
other users.
2026-10-06 15:35:01 +02:00
Maarten Lakerveld 58cc916d93 Float every map marker above its location
Only the selected marker hung above a dot with a tail; the rest sat
centered on their coordinate, so a marker's frame always hid the spot it
marked. Every entity marker is now a pin whose tail points just above its
coordinate. Selecting one scales its frame up and shows the dot at the
exact location. Cluster bubble avatars keep their plain frames.
2026-10-06 14:35:49 +02:00
Maarten Lakerveld d40a36f09d Describe every toggle in the map card editor
Only Show all and Show zone radius explained themselves; Scale ruler, Auto
fit, Fit zones and Cluster markers now have helper text too.
2026-10-06 14:35:49 +02:00
Maarten Lakerveld 855ec46ef2 Always show the selected marker's accuracy circle
The circle only appeared for a GPS fix looser than 100 meters, so most
phones never showed one. The selected marker now floats above a dot at its
coordinate, so even a small circle around that dot reads clearly; it is
never hidden for the selected entity. Focusing still fits the circle into
view only above 100 meters, since a tighter circle fits at the focus zoom.
2026-10-06 14:35:49 +02:00
Maarten Lakerveld f32ef888ed Open more info from a button in the map overview detail
The selected person, device or zone's name was the only way to the more-info
dialog, which made the title read as a link. The name is plain text now and
a filled More info button sits below the activity, where the user expects an
action. Focus still moves to the name when the detail view opens.
2026-10-06 14:35:49 +02:00
Maarten Lakerveld e1a0bea416 Float the selected map marker above a dot at its location
Selecting a person or device marked it with a ring only, and inside a zone
it stayed hidden in the zone's bubble. The selected marker is now excluded
from clustering, drawn a quarter larger, and hangs above a small dot at the
exact coordinate with a tail pointing down to it. The accuracy circle around
the dot carries the entity color, so the ring is dropped; an entity that
reports no accuracy gets a nominal 5 meter circle so the color still shows.
A fix of 10 meters or better draws that circle as a soft disc without an
outline, since a hard edge would claim more precision than is known; map
circles gained an outline option for this. The engine's marker anchor puts
the dot on the coordinate; both engines support it. Bubble avatars keep
their size.
2026-10-06 14:35:49 +02:00
Maarten Lakerveld 403c3f57fd Return the map to its previous view from the overview's back button
Leaving a selected person or zone cleared the selection but left the map
zoomed in on it. The card now remembers where the map was when the overview
drilled in: the exact camera, or the previous entity's fit when one selection
followed another, and the back button eases back there. A camera that was
still auto-fitting resumes auto-fit instead of freezing. Clicking the map to
deselect stays put, since that is the user looking around.

Engines gain getView, and setView can animate.
2026-10-06 13:21:39 +02:00
Maarten Lakerveld 8c82e6df51 Regroup map clusters up front for every fit
Clicking a bubble already regrouped its members at the zoom the fit would
land on, so they parted as the animation started. Any other way of zooming
in, such as picking a person in the map overview, waited for the move to end
before the bubble split. The fit itself now does the regrouping, for all
markers, so every animated fit shows the bubbles as they will end up.
2026-10-06 13:21:39 +02:00
Maarten Lakerveld 708e004159 Add show_zone_radius option to the map card
In panel layout the map hides zone radii except on the Zones tab, for the
selected zone, and while editing. A user asked to always see them, so the
new option draws the radius of every visible zone on all tabs. Other layouts
already show radii and are unaffected.
2026-10-06 09:47:33 +02:00
Maarten Lakerveld 9ae5a3b7e6 Auto-hide compact map attribution and restyle it for dark mode
MapLibre decides compact mode from the map canvas width alone (640px), so
shrinking the window flipped the attribution from compact back to inline
when the sidebar became a drawer and the map got wider. The engine now adds
the control itself and forces compact on a narrow viewport (870px, the app's
own narrow layout), swapping it when the breakpoint changes.

MapLibre only collapsed the opened compact attribution on a drag; it now
also collapses by itself after five seconds, unless the user opened it from
the info button.

MapLibre's stylesheet hardcodes a white attribution with black text, which
Home Assistant never overrode, so it stood out on a dark map. The dark mode
rules now match the zoom control styling, and the inline text color is set
explicitly instead of inherited from a page that may be in the other mode.
2026-10-06 09:47:33 +02:00
18 changed files with 980 additions and 371 deletions
+23 -3
View File
@@ -24,6 +24,7 @@ import type {
MapMarkerOptions,
MapPath,
MapPathHandle,
MapView,
} from "../map-engine";
import type { ResolvedMapStyle } from "../map-styles";
import { setMarkerAccessibility } from "../marker-accessibility";
@@ -171,6 +172,14 @@ export class LeafletMapEngine implements MapEngine {
this.leafletMap?.setView(center, zoom);
}
public getView(): MapView | undefined {
if (!this.leafletMap) {
return undefined;
}
const center = this.leafletMap.getCenter();
return { center: [center.lat, center.lng], zoom: this._getZoom() };
}
public setZoom(zoom: number): void {
this.leafletMap?.setZoom(zoom);
}
@@ -189,7 +198,10 @@ export class LeafletMapEngine implements MapEngine {
return;
}
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, {
const fit = options?.fly
? this.leafletMap.flyToBounds
: this.leafletMap.fitBounds;
fit.call(this.leafletMap, bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
@@ -217,6 +229,7 @@ export class LeafletMapEngine implements MapEngine {
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
stroke: options.decoration.outline !== false,
radius: options.decoration.radius,
})
: undefined;
@@ -225,8 +238,12 @@ export class LeafletMapEngine implements MapEngine {
// activation handlers never hear a key; the element itself takes focus
const interactive = options.interactive ?? true;
const focusable = options.focusable ?? interactive;
if (options.title && options.nativeTitle !== false) {
element.title = options.title;
}
setMarkerAccessibility(element, options.title, focusable);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
zIndexOffset: options.raised ? 1000 : 0,
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
@@ -235,7 +252,6 @@ export class LeafletMapEngine implements MapEngine {
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
@@ -269,6 +285,7 @@ export class LeafletMapEngine implements MapEngine {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
stroke: options.outline !== false,
radius: options.radius,
}).addTo(this.leafletMap!);
return {
@@ -276,7 +293,10 @@ export class LeafletMapEngine implements MapEngine {
circle
.setLatLng(newCenter)
.setRadius(newOptions.radius)
.setStyle({ color: newOptions.color });
.setStyle({
color: newOptions.color,
stroke: newOptions.outline !== false,
});
},
remove: () => circle.remove(),
};
+153 -39
View File
@@ -51,6 +51,7 @@ import type {
MapMarkerOptions,
MapPath,
MapPathHandle,
MapView,
} from "../map-engine";
import { destinationPoint, distanceMeters, pointEastOf } from "../map-engine";
import type { ResolvedMapStyle } from "../map-styles";
@@ -88,6 +89,11 @@ const WHEEL_ZOOM_RATE = 1 / 200;
// Regroup clusters once continuous zooming settles, not on every wheel notch
export const CLUSTER_REBUILD_DELAY = 120;
const ATTRIBUTION_SHOW_DURATION = 5000;
const ATTRIBUTION_SHOWN_CLASS = "maplibregl-compact-show";
// The app's narrow-layout breakpoint (home-assistant-main)
const NARROW_VIEWPORT_QUERY = "(max-width: 870px)";
type GeoJSONSourceSpecification = Extract<
Parameters<MapLibreMap["addSource"]>[1],
{ type: "geojson" }
@@ -128,7 +134,7 @@ const coloredCircle = (
options: MapCircleOptions
): Feature<Polygon> => ({
...circlePolygon(center, options.radius),
properties: { color: options.color },
properties: { color: options.color, outline: options.outline !== false },
});
const pathLines = (path: MapPath): FeatureCollection => ({
@@ -192,10 +198,15 @@ const iconFootprint = (icon: MapClusterIcon, center: MapLatLng): Footprint => ({
half: Math.max(...icon.size) / 2,
});
const markerFootprint = (managed: ManagedMarker): Footprint => ({
location: managed.location,
offset: [0, 0],
half: Math.max(...managed.options.size) / 2,
const markerFootprint = ({ location, options }: ManagedMarker): Footprint => ({
location,
offset: options.anchor
? [
options.size[0] / 2 - options.anchor[0],
options.size[1] / 2 - options.anchor[1],
]
: [0, 0],
half: Math.max(...options.size) / 2,
});
interface ClusterGroup {
@@ -232,6 +243,14 @@ export class MapLibreMapEngine implements MapEngine {
private _scaleControl?: IControl;
private _attributionControl?: IControl;
private _attributionObserver?: MutationObserver;
private _attributionTimeout?: number;
private _narrowViewport = window.matchMedia(NARROW_VIEWPORT_QUERY);
private _markers: ManagedMarker[] = [];
private _clusterOptions: MapClusterOptions | null = null;
@@ -334,8 +353,7 @@ export class MapLibreMapEngine implements MapEngine {
touchPitch: false,
// Rendered with a device font, so these glyphs are never requested
localIdeographFontFamily: "sans-serif",
// Inline on wide maps, collapsible (open by default) on narrow ones
attributionControl: {},
attributionControl: false,
// Proxied by core behind a token; absolute so the worker can resolve them
transformRequest: (url) => ({
...withMapTilesToken(url),
@@ -373,6 +391,8 @@ export class MapLibreMapEngine implements MapEngine {
this._zoomControl = new maplibre.NavigationControl({ showCompass: false });
map.addControl(this._zoomControl, POSITIONS[options.zoomControlPosition]);
this._setUpAttribution();
this._narrowViewport.addEventListener("change", this._setUpAttribution);
map.on("click", (ev) => {
// Clicks on path points (they have tooltips) are not map clicks
@@ -436,6 +456,62 @@ export class MapLibreMapEngine implements MapEngine {
}
};
// Compact on a narrow viewport too, not only on a narrow map
private _setUpAttribution = (): void => {
const map = this._map;
if (!map || !this._maplibre) {
return;
}
if (this._attributionControl) {
map.removeControl(this._attributionControl);
}
this._attributionControl = new this._maplibre.AttributionControl({
compact: this._narrowViewport.matches ? true : undefined,
});
map.addControl(this._attributionControl, "bottom-right");
const attribution = map
.getContainer()
.querySelector<HTMLElement>(".maplibregl-ctrl-attrib");
if (attribution) {
this._autoHideAttribution(attribution);
}
};
private _autoHideAttribution(attribution: HTMLElement): void {
clearTimeout(this._attributionTimeout);
this._attributionObserver?.disconnect();
let byUser = false;
attribution
.querySelector(".maplibregl-ctrl-attrib-button")
?.addEventListener("click", () => {
// MapLibre's click handler already ran; the observer sees this flag
byUser = true;
queueMicrotask(() => {
byUser = false;
});
});
let shown = false;
const sync = () => {
const nowShown = attribution.classList.contains(ATTRIBUTION_SHOWN_CLASS);
if (nowShown === shown) {
return;
}
shown = nowShown;
clearTimeout(this._attributionTimeout);
if (shown && !byUser) {
this._attributionTimeout = window.setTimeout(() => {
attribution.classList.remove(ATTRIBUTION_SHOWN_CLASS);
}, ATTRIBUTION_SHOW_DURATION);
}
};
this._attributionObserver = new MutationObserver(sync);
this._attributionObserver.observe(attribution, {
attributes: true,
attributeFilter: ["class"],
});
sync();
}
private _scheduleFatal(): void {
clearTimeout(this._fallbackTimeout);
// Backgrounding drops the context too, and there it comes back on return
@@ -454,6 +530,10 @@ export class MapLibreMapEngine implements MapEngine {
this._unsubscribeToken?.();
clearTimeout(this._fallbackTimeout);
clearTimeout(this._clusterRebuildTimeout);
clearTimeout(this._attributionTimeout);
this._attributionObserver?.disconnect();
this._attributionObserver = undefined;
this._narrowViewport.removeEventListener("change", this._setUpAttribution);
document.removeEventListener("visibilitychange", this._handleVisibility);
this._revealEmerged(true);
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
@@ -580,6 +660,17 @@ export class MapLibreMapEngine implements MapEngine {
});
}
public getView(): MapView | undefined {
if (!this._map) {
return undefined;
}
const center = this._map.getCenter();
return {
center: [center.lat, center.lng],
zoom: this._map.getZoom() + ZOOM_OFFSET,
};
}
public setZoom(zoom: number): void {
this._map?.easeTo({ zoom: zoom - ZOOM_OFFSET });
}
@@ -607,21 +698,27 @@ export class MapLibreMapEngine implements MapEngine {
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
const fit = this._fitFor(points, options);
if (fit) {
this._map!.fitBounds(fit.bounds, {
...fit.options,
animate: options?.animate,
});
if (!fit) {
return;
}
}
// The zoom a fit would land on, without moving the map
private _zoomAfterFit(points: MapLatLng[], options?: MapFitOptions): number {
const fit = this._fitFor(points, options);
return (
(fit && this._map!.cameraForBounds(fit.bounds, fit.options)?.zoom) ??
this._map!.getZoom()
);
const map = this._map!;
// Regroup now for the zoom the fit lands on
const zoom = this._clusterOptions
? map.cameraForBounds(fit.bounds, fit.options)?.zoom
: undefined;
if (zoom !== undefined) {
this._groupingZoom = zoom;
try {
this._rebuildClusters(true);
} finally {
this._groupingZoom = undefined;
}
}
map.fitBounds(fit.bounds, {
...fit.options,
animate: options?.animate,
linear: !options?.fly,
});
}
private _fitFor(points: MapLatLng[], options?: MapFitOptions) {
@@ -703,7 +800,7 @@ export class MapLibreMapEngine implements MapEngine {
): MapEditableMarkerHandle {
element.style.width = `${options.size[0]}px`;
element.style.height = `${options.size[1]}px`;
if (options.title) {
if (options.title && options.nativeTitle !== false) {
element.title = options.title;
}
const interactive = options.interactive ?? true;
@@ -713,6 +810,7 @@ export class MapLibreMapEngine implements MapEngine {
element.style.pointerEvents = "none";
}
setMarkerAccessibility(element, options.title, focusable);
element.style.zIndex = options.raised ? "1" : "";
if (draggable) {
// The engine, not the host, knows whether this element really drags
element.style.cursor = "move";
@@ -831,7 +929,10 @@ export class MapLibreMapEngine implements MapEngine {
id: `${id}-line`,
type: "line",
source: id,
paint: { "line-color": ["get", "color"], "line-width": 3 },
paint: {
"line-color": ["get", "color"],
"line-width": ["case", ["get", "outline"], 3, 0],
},
});
return {
update: (newCenter, newOptions) => {
@@ -898,7 +999,7 @@ export class MapLibreMapEngine implements MapEngine {
}
centerEl.style.width = `${centerSize[0]}px`;
centerEl.style.height = `${centerSize[1]}px`;
if (options.title) {
if (options.title && options.nativeTitle !== false) {
centerEl.title = options.title;
}
setMarkerAccessibility(centerEl, options.title, !!options.onClick);
@@ -916,9 +1017,26 @@ export class MapLibreMapEngine implements MapEngine {
let resizeMarker: MapLibreMarker | undefined;
let resizeHandle: HTMLElement | undefined;
// The user's hand wins while dragging; the host is the truth otherwise
let dragging = false;
// Hidden while the circle is under the center element
const syncResizeHandleVisibility = () => {
if (!resizeHandle || dragging) {
return;
}
const east = pointEastOf(currentCenter, currentRadius);
const centerPoint = map.project([currentCenter[1], currentCenter[0]]);
const eastPoint = map.project([east[1], east[0]]);
const covered =
Math.hypot(eastPoint.x - centerPoint.x, eastPoint.y - centerPoint.y) <
centerSize[0] / 2;
resizeHandle.style.display =
covered && !resizeHandle.matches(":focus-visible") ? "none" : "";
};
const placeResizeHandle = () => {
const east = pointEastOf(currentCenter, currentRadius);
resizeMarker?.setLngLat([east[1], east[0]]);
syncResizeHandleVisibility();
const radiusText = String(Math.round(currentRadius));
resizeHandle?.setAttribute(
"aria-valuemax",
@@ -929,10 +1047,8 @@ export class MapLibreMapEngine implements MapEngine {
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
// The user's hand wins while dragging; the host is the truth otherwise
let dragging = false;
if (options.resizable) {
map.on("zoom", syncResizeHandleVisibility);
const east = pointEastOf(center, options.radius);
resizeHandle = createResizeHandleElement(options.resizeLabel);
resizeMarker = new maplibre.Marker({
@@ -992,7 +1108,10 @@ export class MapLibreMapEngine implements MapEngine {
};
resizeHandle.addEventListener("keyup", commitKeyboardResize);
// Focus can leave while a key is still held
resizeHandle.addEventListener("blur", commitKeyboardResize);
resizeHandle.addEventListener("blur", () => {
commitKeyboardResize();
syncResizeHandleVisibility();
});
// Like the other markers: a click on the handle is not a map click
resizeHandle.addEventListener("click", (ev) => ev.stopPropagation());
}
@@ -1003,6 +1122,7 @@ export class MapLibreMapEngine implements MapEngine {
});
handleMarker?.on("dragend", () => {
dragging = false;
syncResizeHandleVisibility();
});
});
@@ -1076,6 +1196,7 @@ export class MapLibreMapEngine implements MapEngine {
resetMarkerElement(centerEl);
clearMarkerAccessibility(centerEl);
this._placedElements.delete(centerEl);
map.off("zoom", syncResizeHandleVisibility);
resizeMarker?.remove();
this._removeCustomLayer(`${id}-fill`);
this._removeCustomLayer(`${id}-line`);
@@ -1478,21 +1599,14 @@ export class MapLibreMapEngine implements MapEngine {
}
const { icon } = group;
this._placeIcon(group, icon);
// Zooms in on the members, regrouped up front as they will sit once the
// zoom lands: leavers part now and the bubble opens for the rest
const zoomToMembers = () => {
const { members } = group;
const locations = members.map((managed) => managed.location);
const fit = { pad: 0.3, maxZoom: this._getMaxZoom() };
this._openAfterRegroup = members;
this._groupingZoom = this._zoomAfterFit(locations, fit);
try {
this._rebuildClusters(true, group);
} finally {
this._groupingZoom = undefined;
}
this.fitBounds(
members.map((managed) => managed.location),
{ pad: 0.3, maxZoom: this._getMaxZoom() }
);
this._hideEmerging(members);
this.fitBounds(locations, fit);
};
setMarkerAccessibility(icon.element, this._groupTitle(group), true);
icon.element.addEventListener("click", (ev) => {
+40
View File
@@ -11,6 +11,11 @@ import type { ResolvedMapStyle } from "./map-styles";
export type MapLatLng = [latitude: number, longitude: number];
export interface MapView {
center: MapLatLng;
zoom: number;
}
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
@@ -50,6 +55,8 @@ export interface MapFitOptions {
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
/** Fly in an arc instead of easing straight there; defaults to false */
fly?: boolean;
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
padding?: MapFitPadding;
}
@@ -66,12 +73,16 @@ export interface MapMarkerOptions {
size: [width: number, height: number];
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
anchor?: [x: number, y: number];
/** Drawn above the other markers */
raised?: boolean;
/** Takes pointer input; defaults to true */
interactive?: boolean;
/** A keyboard-focusable button, for markers that act on activation; defaults to interactive */
focusable?: boolean;
/** Accessible name */
title?: string;
/** Also show the title as the browser's tooltip; defaults to true */
nativeTitle?: boolean;
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
decoration?: MapCircleOptions;
/** Cluster this marker; it appears once setClustering is called */
@@ -85,6 +96,8 @@ export interface MapCircleOptions {
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
/** Stroke the circle; defaults to true */
outline?: boolean;
}
export interface MapPathSegment {
@@ -138,6 +151,7 @@ export interface MapEditableCircleOptions {
centerElement?: HTMLElement;
centerSize?: [width: number, height: number];
title?: string;
nativeTitle?: boolean;
/** The center can be dragged */
moveable?: boolean;
/** A handle on the edge can be dragged to change the radius */
@@ -230,6 +244,8 @@ export interface MapEngine {
setView(center: MapLatLng, zoom?: number): void;
getView(): MapView | undefined;
setZoom(zoom: number): void;
/** Fit the given points into view; a single point centers on it */
@@ -335,3 +351,27 @@ export const circleBoundsPoints = (
[latMax, center[1] + dLng],
];
};
const MERCATOR_MAX_LAT = 85.051129;
const projectMercator = ([lat, lng]: MapLatLng, scale: number) => {
const sinLat = Math.sin(
toRadians(Math.max(-MERCATOR_MAX_LAT, Math.min(MERCATOR_MAX_LAT, lat)))
);
return {
x: (lng / 360 + 0.5) * scale,
y: (0.5 - Math.log((1 + sinLat) / (1 - sinLat)) / (4 * Math.PI)) * scale,
};
};
export const pixelDistance = (
a: MapLatLng,
b: MapLatLng,
zoom: number
): number => {
const scale = 256 * 2 ** zoom;
const pa = projectMercator(a, scale);
const pb = projectMercator(b, scale);
const dx = Math.abs(pa.x - pb.x);
return Math.hypot(Math.min(dx, scale - dx), pa.y - pb.y);
};
+86 -6
View File
@@ -1,11 +1,33 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, html, css } from "lit";
import { LitElement, html, css, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-state-icon";
export const SELECTED_MARKER_SCALE = 1.25;
// Floating: the frame hangs above a dot, a rotated-square tail between
const FLOATING_TAIL_SIZE = 12;
const FLOATING_TAIL_REACH = Math.round((FLOATING_TAIL_SIZE * Math.SQRT2) / 2);
const FLOATING_GAP = 4;
const FLOATING_DOT_SIZE = 10;
export const FLOATING_LIFT = FLOATING_GAP + FLOATING_DOT_SIZE / 2;
export const floatingMarkerFootprint = (
markerSize: number,
selected: boolean
): { size: [number, number]; anchor: [number, number] } => {
const frame = selected
? Math.round(markerSize * SELECTED_MARKER_SCALE)
: markerSize;
const height = frame + FLOATING_TAIL_REACH + FLOATING_GAP + FLOATING_DOT_SIZE;
return {
size: [frame, height],
anchor: [frame / 2, height - FLOATING_DOT_SIZE / 2],
};
};
/**
* @csspart marker - The framed avatar.
* @csspart picture - The entity picture inside the frame.
@@ -30,13 +52,14 @@ class HaEntityMarker extends LitElement {
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean, reflect: true }) public floating = false;
protected render() {
return html`
<div
part="marker"
class="marker ${this.entityPicture ? "picture" : ""}"
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
@click=${this._badgeTap}
>
${
this.entityPicture
@@ -63,16 +86,24 @@ class HaEntityMarker extends LitElement {
`
}
</div>
${
this.floating
? html`<div class="tail"></div>
<div class="dot"></div>`
: nothing
}
`;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("click", this._badgeTap);
this.addEventListener("keydown", this._handleKeydown);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("click", this._badgeTap);
this.removeEventListener("keydown", this._handleKeydown);
}
@@ -84,14 +115,35 @@ class HaEntityMarker extends LitElement {
}
};
private _badgeTap(ev: Event) {
private _badgeTap = (ev: Event) => {
ev.stopPropagation();
if (this.entityId) {
fireEvent(this, "hass-more-info", { entityId: this.entityId });
}
}
};
static styles = css`
:host([floating]) {
display: flex;
flex-direction: column;
align-items: center;
/* One shadow for the whole pin, so the frame casts none onto the tail */
filter: var(
--ha-cluster-shadow,
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12))
);
}
:host([floating]) {
pointer-events: none;
}
:host([floating]) .marker,
:host([floating]) .tail {
pointer-events: auto;
}
:host([floating]) .marker {
position: relative;
z-index: 1;
}
.marker {
display: flex;
justify-content: center;
@@ -104,7 +156,6 @@ class HaEntityMarker extends LitElement {
border-radius: var(--ha-marker-border-radius, 50%);
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(
--ha-marker-background,
@@ -114,10 +165,39 @@ class HaEntityMarker extends LitElement {
.marker.picture {
overflow: hidden;
}
/* A ring in the entity color outside the frame marks the selected marker */
:host([selected]) .marker {
width: calc(
var(--ha-marker-size, 48px) *
var(--ha-marker-selected-scale, ${SELECTED_MARKER_SCALE})
);
height: calc(
var(--ha-marker-size, 48px) *
var(--ha-marker-selected-scale, ${SELECTED_MARKER_SCALE})
);
}
/* Floating, the accuracy circle carries the color instead of a ring */
:host([selected]:not([floating])) .marker {
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
}
.tail {
width: ${FLOATING_TAIL_SIZE}px;
height: ${FLOATING_TAIL_SIZE}px;
margin-top: ${-FLOATING_TAIL_SIZE / 2}px;
margin-bottom: ${FLOATING_TAIL_REACH - FLOATING_TAIL_SIZE / 2}px;
border-radius: 2px;
background: var(--ha-marker-color, var(--card-background-color, #fff));
transform: rotate(45deg);
}
.dot {
width: ${FLOATING_DOT_SIZE}px;
height: ${FLOATING_DOT_SIZE}px;
margin-top: ${FLOATING_GAP}px;
border-radius: 50%;
background: var(--ha-marker-color, var(--card-background-color, #fff));
}
:host(:not([selected])) .dot {
visibility: hidden;
}
.entity-picture {
background-size: cover;
height: 100%;
+14 -1
View File
@@ -8,7 +8,7 @@ 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 type { MapLatLng, MapView } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext, uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
@@ -47,6 +47,8 @@ export interface MarkerLocation {
radius_editable?: boolean;
/** Clicking or activating the marker fires marker-clicked */
clickable?: boolean;
/** Counts toward the map fit; defaults to true */
fit?: boolean;
}
const ICON_SIZE = 24;
@@ -92,6 +94,16 @@ export class HaLocationsEditor extends LitElement {
this.map.fitMap(options);
}
public getView(): MapView | undefined {
const view = this.map.getView();
return (
view && {
...view,
center: [view.center[0], this._normalizeLongitude(view.center[1])],
}
);
}
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number }
@@ -186,6 +198,7 @@ export class HaLocationsEditor extends LitElement {
locationEditable: location.location_editable,
radiusEditable: location.radius_editable,
activatable: location.clickable,
fit: location.fit,
}));
}
);
+134 -67
View File
@@ -36,6 +36,7 @@ import type {
MapEditableCircleHandle,
MapEditableMarkerHandle,
MapEditingSupport,
MapView,
} from "../../common/map/map-engine";
import {
circleBoundsPoints,
@@ -68,6 +69,7 @@ import {
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { ensureMapTilesToken } from "../../data/map_tiles";
import "../ha-tooltip";
import type {
HomeAssistantConfig,
HomeAssistantConnection,
@@ -76,7 +78,7 @@ import type {
HomeAssistantUI,
ThemeMode,
} from "../../types";
import "./ha-entity-marker";
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
declare global {
// for fire event
@@ -128,6 +130,8 @@ export interface HaMapEditableLocation {
radiusEditable?: boolean;
/** Activating the marker fires editable-location-clicked; otherwise it is not a button */
activatable?: boolean;
/** Counts toward the map fit; defaults to true */
fit?: boolean;
}
// Geometry is updated in place; a change to anything else rebuilds the marker
@@ -199,6 +203,7 @@ const staticEditing = (engine: MapEngine): MapEditingSupport => ({
size: options.centerSize ?? [16, 16],
interactive: !!options.onClick,
title: options.title,
nativeTitle: options.nativeTitle,
}),
];
};
@@ -276,6 +281,10 @@ 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;
// Circle radius (meters) for a selected marker without a reported accuracy
const NOMINAL_ACCURACY = 2.5;
// Fixes this good (meters) draw a soft disc without an outline
const PRECISE_GPS_ACCURACY = 10;
type EntityMarkerElement = HTMLElementTagNameMap["ha-entity-marker"];
@@ -414,6 +423,8 @@ export class HaMap extends ReactiveElement {
private _entityHandles: MapMarkerHandle[] = [];
private _tooltipCount = 0;
// Marker elements survive redraws so unchanged entities keep their DOM
private _entityMarkers = new Map<string, EntityMarkerElement>();
@@ -904,8 +915,10 @@ export class HaMap extends ReactiveElement {
const points = [...this._focusPoints, ...zonePoints];
// Editable locations contribute their bounds, radius included
this.editableLocations?.forEach((editable) => {
// Opted-out locations only count when nothing else would be fitted
const editables = this.editableLocations ?? [];
const fitted = editables.filter((editable) => editable.fit !== false);
(fitted.length ? fitted : editables).forEach((editable) => {
if (editable.radius) {
points.push(...circleBoundsPoints(editable.location, editable.radius));
} else {
@@ -963,9 +976,18 @@ export class HaMap extends ReactiveElement {
this._engine.setView(center, zoom);
}
public getView(): MapView | undefined {
return this._engine?.getView();
}
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
options?: {
zoom?: number;
pad?: number;
padding?: MapFitPadding;
fly?: boolean;
}
) {
// 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
@@ -984,6 +1006,7 @@ export class HaMap extends ReactiveElement {
pad: options?.pad ?? 0.5,
animate: this._hasFitted,
padding: options?.padding,
fly: options?.fly,
});
});
this._hasFitted = true;
@@ -1017,6 +1040,7 @@ export class HaMap extends ReactiveElement {
// Markers are buttons, so an unnamed location still gets a name
const title =
editable.title ?? this._i18n?.localize("ui.components.map.location");
const tooltip = !!editable.element && !!editable.title;
const existing = this._editableHandles.get(id);
const kind = editable.radius ? "circle" : "marker";
@@ -1041,30 +1065,35 @@ export class HaMap extends ReactiveElement {
}
if (kind === "circle") {
const handle = editing.addEditableCircle(editable.location, {
radius: editable.radius!,
color: editable.color || defaultColor,
centerElement: editable.element,
centerSize: editable.elementSize,
title,
nativeTitle: !tooltip,
moveable: editable.locationEditable,
resizable: editable.radiusEditable,
resizeLabel: editable.title
? this._i18n?.localize("ui.components.map.radius_of", {
name: editable.title,
})
: this._i18n?.localize("ui.components.map.radius"),
onMove: (location) =>
fireEvent(this, "editable-location-moved", { id, location }),
onResize: (radius) =>
fireEvent(this, "editable-location-resized", { id, radius }),
onClick: editable.activatable
? () => fireEvent(this, "editable-location-clicked", { id })
: undefined,
});
this._editableHandles.set(id, {
kind,
source: editable,
handle: editing.addEditableCircle(editable.location, {
radius: editable.radius!,
color: editable.color || defaultColor,
centerElement: editable.element,
centerSize: editable.elementSize,
title,
moveable: editable.locationEditable,
resizable: editable.radiusEditable,
resizeLabel: editable.title
? this._i18n?.localize("ui.components.map.radius_of", {
name: editable.title,
})
: this._i18n?.localize("ui.components.map.radius"),
onMove: (location) =>
fireEvent(this, "editable-location-moved", { id, location }),
onResize: (radius) =>
fireEvent(this, "editable-location-resized", { id, radius }),
onClick: editable.activatable
? () => fireEvent(this, "editable-location-clicked", { id })
: undefined,
}),
handle,
cleanup: tooltip
? this._attachTooltip(editable.element!, editable.title!)
: undefined,
});
continue;
}
@@ -1103,25 +1132,42 @@ export class HaMap extends ReactiveElement {
}
// A location that cannot be dragged is static on any engine
const support = editable.locationEditable ? editing : staticSupport;
const handle = support.addDraggableMarker(element, editable.location, {
size: editable.elementSize ?? [16, 16],
interactive: true,
focusable: !!editable.activatable,
title,
nativeTitle: !tooltip,
onDragEnd: (location) => {
dragged = true;
fireEvent(this, "editable-location-moved", { id, location });
},
});
const removeTooltip = tooltip
? this._attachTooltip(editable.element!, editable.title!)
: undefined;
this._editableHandles.set(id, {
kind,
source: editable,
cleanup,
handle: support.addDraggableMarker(element, editable.location, {
size: editable.elementSize ?? [16, 16],
interactive: true,
focusable: !!editable.activatable,
title,
onDragEnd: (location) => {
dragged = true;
fireEvent(this, "editable-location-moved", { id, location });
},
}),
cleanup: () => {
cleanup?.();
removeTooltip?.();
},
handle,
});
}
return changed;
}
private _attachTooltip(element: HTMLElement, title: string): () => void {
element.id ||= `ha-map-editable-${this._tooltipCount++}`;
const tooltip = document.createElement("ha-tooltip");
tooltip.for = element.id;
tooltip.textContent = title;
this.shadowRoot!.append(tooltip);
return () => tooltip.remove();
}
// One by one, so the listeners on the caller's elements are detached too
private _removeEditableLocations(): void {
for (const entry of this._editableHandles.values()) {
@@ -1470,6 +1516,7 @@ export class HaMap extends ReactiveElement {
entityMarker.entityColor = entityColor;
entityMarker.selected =
typeof entity !== "string" && (entity.selected ?? false);
entityMarker.floating = true;
const clusterData: ClusterData = {
entityId,
@@ -1487,18 +1534,28 @@ export class HaMap extends ReactiveElement {
: undefined,
};
const accuracy =
gpsAccuracy || (entityMarker.selected ? NOMINAL_ACCURACY : 0);
const showAccuracy =
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
accuracy > 0 && !(typeof entity !== "string" && entity.hide_accuracy);
const markerSize = this._getMarkerSize(computedStyles);
this._entityHandles.push(
engine.addMarker(entityMarker, position, {
size: [markerSize, markerSize],
...floatingMarkerFootprint(
this._getMarkerSize(computedStyles),
entityMarker.selected
),
title,
cluster: true,
// Selected, it leaves its bubble
cluster: !entityMarker.selected,
raised: entityMarker.selected,
clusterData,
decoration: showAccuracy
? { radius: gpsAccuracy!, color: entityColor }
? {
radius: accuracy,
color: entityColor,
outline: accuracy > PRECISE_GPS_ACCURACY,
}
: undefined,
})
);
@@ -1631,37 +1688,30 @@ export class HaMap extends ReactiveElement {
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
}
// A cluster of one zone's occupants attaches to that zone's marker
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
const atZone = !!zonePosition;
let height =
const height =
rows * CLUSTER_AVATAR_SIZE +
(rows - 1) * CLUSTER_BUBBLE_GAP +
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;
}
2 * CLUSTER_BUBBLE_PADDING +
CLUSTER_TAIL_HEIGHT;
const root = document.createElement("div");
root.className = "cluster-marker";
const tail = document.createElement("div");
tail.className = "cluster-bubble-tail";
root.append(bubble, tail);
// A cluster of one zone's occupants floats above that zone's circle
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
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],
}
: {}),
location: zonePosition,
anchor: [
width / 2,
height +
(zonePosition
? ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING
: FLOATING_LIFT),
],
};
};
@@ -1828,6 +1878,23 @@ export class HaMap extends ReactiveElement {
.dark .maplibregl-ctrl button .maplibregl-ctrl-icon {
filter: invert(1);
}
/* Not inherited from the page, which may be in the other mode */
.maplibregl-ctrl.maplibregl-ctrl-attrib {
color: #000000;
}
.dark .maplibregl-ctrl.maplibregl-ctrl-attrib {
background-color: rgba(28, 28, 28, 0.6);
color: #ffffff;
}
.dark .maplibregl-ctrl-attrib.maplibregl-compact {
background-color: #1c1c1c;
}
.dark .maplibregl-ctrl-attrib a {
color: rgba(255, 255, 255, 0.85);
}
.dark .maplibregl-ctrl-attrib-button {
filter: invert(1);
}
/* MapLibre's stylesheet, linked into this root, wins on equal specificity */
.maplibregl-popup-content {
padding: 8px !important;
@@ -1900,9 +1967,9 @@ export class HaMap extends ReactiveElement {
border-radius: 14px;
filter: var(--ha-cluster-shadow);
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
--ha-marker-selected-scale: 1;
--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);
+12 -10
View File
@@ -51,17 +51,19 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
this._data = this._params.entry;
} else {
const initConfig = getZoneEditorInitData();
let movedHomeLocation;
if (!initConfig?.latitude || !initConfig?.longitude) {
movedHomeLocation = addDistanceToCoord(
[this.hass.config.latitude, this.hass.config.longitude],
Math.random() * 500 * (Math.random() < 0.5 ? -1 : 1),
Math.random() * 500 * (Math.random() < 0.5 ? -1 : 1)
);
}
const [latitude, longitude] =
initConfig?.latitude !== undefined &&
initConfig?.longitude !== undefined
? [initConfig.latitude, initConfig.longitude]
: (this._params.location ??
addDistanceToCoord(
[this.hass.config.latitude, this.hass.config.longitude],
Math.random() * 500 * (Math.random() < 0.5 ? -1 : 1),
Math.random() * 500 * (Math.random() < 0.5 ? -1 : 1)
));
this._data = {
latitude: initConfig?.latitude || movedHomeLocation[0],
longitude: initConfig?.longitude || movedHomeLocation[1],
latitude,
longitude,
name: initConfig?.name || "",
icon: initConfig?.icon || "mdi:map-marker",
passive: false,
+3
View File
@@ -282,6 +282,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
radius_editable:
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
fit: !entityState.attributes.passive,
})
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
@@ -295,6 +296,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
),
location_editable: true,
radius_editable: true,
fit: !zone.passive,
}));
return storageLocations.concat(stateLocations);
}
@@ -720,6 +722,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
private async _openDialog(entry?: Zone) {
showZoneDetailDialog(this, {
entry,
location: entry ? undefined : this._map?.getView()?.center,
createEntry: (values) => this._createEntry(values),
updateEntry: entry
? (values) => this._updateEntry(entry, values, true)
@@ -3,6 +3,8 @@ import type { Zone, ZoneMutableParams } from "../../../data/zone";
export interface ZoneDetailDialogParams {
entry?: Zone;
/** Where a new zone starts when nothing else names a location */
location?: [latitude: number, longitude: number];
createEntry: (values: ZoneMutableParams) => Promise<unknown>;
updateEntry?: (updates: Partial<ZoneMutableParams>) => Promise<unknown>;
removeEntry?: () => Promise<boolean>;
+130 -64
View File
@@ -3,7 +3,7 @@ import {
mdiGoogleCirclesCommunities,
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { HassEntities } 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";
@@ -36,7 +36,10 @@ import type {
MapCardMarkerLabelMode,
} from "../../../components/map/ha-map";
import type { MapFitPadding, MapLatLng } from "../../../common/map/map-engine";
import { circleBoundsPoints } from "../../../common/map/map-engine";
import {
circleBoundsPoints,
pixelDistance,
} from "../../../common/map/map-engine";
import {
entityMapColor,
zoneColor,
@@ -51,6 +54,15 @@ import type { HomeAssistant } from "../../../types";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import type { OverviewTab } from "./map/hui-map-overview";
import { PANEL_VIEW_LAYOUT } from "../views/const";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { mainWindow } from "../../../common/dom/get_main_window";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { currentPath } from "../../../common/url/current-path";
import {
addSearchParam,
extractSearchParam,
removeSearchParam,
} from "../../../common/url/search-params";
import { findEntities } from "../common/find-entities";
import {
hasConfigChanged,
@@ -67,14 +79,13 @@ 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;
const FOCUS_MAX_ZOOM = 17;
const FOCUS_CLEARANCE_PX = 80;
const SELECTED_ENTITY_PARAM = "entity_id";
const SELECTABLE_DOMAINS = ["person", "device_tracker", "zone"];
interface GeoEntity {
entity_id: string;
@@ -136,6 +147,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@state() private _overviewSelected?: string;
private _path?: string;
@state() private _overviewTab: OverviewTab = "people";
// Height of the overview drawer when it sits over the bottom of the map
@@ -402,6 +415,9 @@ class HuiMapCard extends LitElement implements LovelaceCard {
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("layout")) {
this._syncSelection();
}
if (changedProps.has("preview") && this.preview) {
this._overviewSize = { width: 0, height: 0 };
}
@@ -454,15 +470,18 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._overviewEntities = this._decorateOverviewEntities(
entities,
this._overviewSelected,
this._overviewSelected
? this.hass.states[this._overviewSelected]
: undefined,
this.preview || this._overviewTab === "zones"
this.preview ||
this._config?.show_zone_radius ||
this._overviewTab === "zones"
);
// Without the overview, while editing, every marker shows
this._filteredMapEntities = this.preview
? this._overviewEntities
: this._filterByOverviewTab(this._overviewEntities, this._overviewTab);
: this._filterByOverviewTab(
this._overviewEntities,
this._overviewTab,
this._overviewSelected
);
}
}
@@ -517,34 +536,32 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
// In panel layout, only the selected zone shows its radius (all of them on
// the Zones tab and while editing) and only an imprecise selected person
// its accuracy circle
// the Zones tab, while editing, or when configured) and only the selected
// person its accuracy circle
private _decorateOverviewEntities = memoizeOne(
(
entities: HaMapEntity[],
selectedId: string | undefined,
selectedStateObj: HassEntity | undefined,
showRadii: boolean
): HaMapEntity[] => {
const selectedLocation = selectedStateObj
? getEntityLocation(selectedStateObj, this.hass.states)
: undefined;
const showSelectedAccuracy =
(selectedLocation?.gpsAccuracy ?? 0) > IMPRECISE_GPS_ACCURACY;
return entities.map((entity) => ({
): HaMapEntity[] =>
entities.map((entity) => ({
...entity,
hide_accuracy: !(
showSelectedAccuracy && entity.entity_id === selectedId
),
hide_accuracy: entity.entity_id !== selectedId,
hide_radius: !showRadii && entity.entity_id !== selectedId,
selected: entity.entity_id === selectedId,
}));
}
}))
);
private _filterByOverviewTab = memoizeOne(
(entities: HaMapEntity[], tab: OverviewTab): HaMapEntity[] =>
(
entities: HaMapEntity[],
tab: OverviewTab,
selected?: string
): HaMapEntity[] =>
entities.filter((entity) => {
if (entity.entity_id === selected) {
return true;
}
const domain = computeDomain(entity.entity_id);
if (domain === "person") {
return tab === "people";
@@ -558,6 +575,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
public connectedCallback() {
super.connectedCallback();
this._path = currentPath();
mainWindow.addEventListener("popstate", this._syncSelection);
mainWindow.addEventListener("location-changed", this._syncSelection);
this._syncSelection();
if (this.hasUpdated && this._configEntities?.length) {
this._subscribeHistory();
}
@@ -565,9 +586,26 @@ class HuiMapCard extends LitElement implements LovelaceCard {
public disconnectedCallback() {
super.disconnectedCallback();
mainWindow.removeEventListener("popstate", this._syncSelection);
mainWindow.removeEventListener("location-changed", this._syncSelection);
this._unsubscribeHistory();
}
private _syncSelection = () => {
if (this.layout !== PANEL_VIEW_LAYOUT || currentPath() !== this._path) {
return;
}
const entityId = extractSearchParam(SELECTED_ENTITY_PARAM);
const selectable =
!!entityId && SELECTABLE_DOMAINS.includes(computeDomain(entityId));
if (entityId && !selectable) {
replaceCurrentUrl(
constructUrlCurrentPath(removeSearchParam(SELECTED_ENTITY_PARAM))
);
}
this._overviewSelected = selectable ? entityId : undefined;
};
private _subscribeHistory() {
if (
!isComponentLoaded(this.hass!.config, "history") ||
@@ -619,6 +657,13 @@ class HuiMapCard extends LitElement implements LovelaceCard {
if (changedProps.has("_config")) {
this._computePadding();
}
if (
changedProps.has("_overviewSelected") &&
this._overviewSelected &&
this.layout === PANEL_VIEW_LAYOUT
) {
this._focusEntity(this._overviewSelected);
}
}
private _computePadding(): void {
@@ -649,9 +694,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
private _handleMapClicked() {
// A click on the map itself (not on a marker) deselects
if (this._overviewSelected) {
this._overviewSelected = undefined;
this._deselect();
}
}
@@ -664,7 +708,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
if (
this.layout !== PANEL_VIEW_LAYOUT ||
!entityId ||
!["person", "device_tracker", "zone"].includes(computeDomain(entityId)) ||
!SELECTABLE_DOMAINS.includes(computeDomain(entityId)) ||
(computeDomain(entityId) !== "zone" &&
!this._filteredMapEntities.some(
(entity) => entity.entity_id === entityId
@@ -673,8 +717,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return;
}
ev.stopPropagation();
this._overviewSelected = entityId;
this._focusEntity(entityId);
this._select(entityId);
}
private _handleOverviewResize(
@@ -697,48 +740,71 @@ class HuiMapCard extends LitElement implements LovelaceCard {
}
private _handleOverviewSelect(ev: HASSDomEvent<{ entityId?: string }>) {
this._overviewSelected = ev.detail.entityId;
if (ev.detail.entityId) {
this._focusEntity(ev.detail.entityId);
this._select(ev.detail.entityId);
} else {
this._deselect();
}
}
private _deselect() {
this._navigateSelection(removeSearchParam(SELECTED_ENTITY_PARAM));
}
private _select(entityId: string) {
if (entityId === this._overviewSelected) {
this._focusEntity(entityId);
return;
}
this._navigateSelection(
addSearchParam({ [SELECTED_ENTITY_PARAM]: entityId })
);
}
private _navigateSelection(searchParams: string) {
navigate(constructUrlCurrentPath(searchParams));
}
private _focusEntity(entityId: string) {
const stateObj = this.hass.states[entityId];
if (!stateObj) {
const center = this._entityCenter(entityId);
if (!stateObj || !center) {
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), {
const zoom = this._map?.getView()?.zoom;
const zone = computeStateDomain(stateObj) === "zone";
this._map?.fitBounds(
zone
? circleBoundsPoints(center, stateObj.attributes.radius ?? 100)
: [center],
{
pad: 0.2,
zoom: FOCUS_PERSON_ZOOM,
zoom: zoom === undefined || zone ? zoom : this._focusZoom(center, zoom),
padding: this._overviewPadding(),
});
return;
fly: !this._map.containsLocation(center),
}
);
}
private _entityCenter(entityId: string): MapLatLng | undefined {
const stateObj = this.hass.states[entityId];
const location = stateObj && getEntityLocation(stateObj, this.hass.states);
return location && [location.latitude, location.longitude];
}
// Zoom in only as far as it takes to clear the nearest other marker
private _focusZoom(center: MapLatLng, zoom: number): number {
let nearest = Infinity;
for (const entity of this._filteredMapEntities) {
const other = this._entityCenter(entity.entity_id);
if (other && (other[0] !== center[0] || other[1] !== center[1])) {
nearest = Math.min(nearest, pixelDistance(center, other, zoom));
}
}
this._map?.fitBounds([center], {
zoom: FOCUS_PERSON_ZOOM,
padding: this._overviewPadding(),
});
return Math.max(
zoom,
Math.min(FOCUS_MAX_ZOOM, zoom + Math.log2(FOCUS_CLEARANCE_PX / nearest))
);
}
// The part of the map the overview covers, so fitted markers land next to
+200 -172
View File
@@ -14,6 +14,7 @@ import {
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../../../common/decorators/consume";
import { transform } from "../../../../common/decorators/transform";
@@ -497,16 +498,9 @@ export class HuiMapOverview extends LitElement {
@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}
>
<span class="detail-name" tabindex="-1">
${this._personName(stateObj)}
</button>
</span>
<span class="detail-state">
${
isZone
@@ -524,17 +518,20 @@ export class HuiMapOverview extends LitElement {
}
</span>
</div>
${isZone ? nothing : this._renderAvatar(stateObj)}
</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>
<div class="activity-header">
<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>
</div>
${
!this._activity
? html`<div class="activity-loading">
@@ -551,26 +548,35 @@ export class HuiMapOverview extends LitElement {
: this._renderTimeline(stateObj, this._activity)
}
</div>
<ha-button
appearance="filled"
class="more-info"
@click=${this._handleMoreInfo}
>
${this._i18n.localize(
"ui.panel.lovelace.cards.map.overview.more_info"
)}
</ha-button>
</div>
`;
}
// The color of the zone a person's state names; undefined when away or unknown
private _zoneColorForState(entityState: string): string | undefined {
// The zone a person's state names; undefined when away or unknown
private _zoneForState(entityState: string): HassEntity | undefined {
if (entityState === "not_home" || entityState === "unknown") {
return undefined;
}
const zone =
entityState === "home"
? this._states[HOME_ZONE_ENTITY_ID]
: Object.values(this._states).find(
(candidate) =>
computeStateDomain(candidate) === "zone" &&
computeStateName(candidate) === entityState
);
if (!zone) {
return undefined;
}
return entityState === "home"
? this._states[HOME_ZONE_ENTITY_ID]
: Object.values(this._states).find(
(candidate) =>
computeStateDomain(candidate) === "zone" &&
computeStateName(candidate) === entityState
);
}
// The same color as the zone's marker on the map
private _zoneColor(zone: HassEntity): string {
return zoneColor(
zone.entity_id,
!!zone.attributes.passive,
@@ -585,9 +591,7 @@ export class HuiMapOverview extends LitElement {
: activity.slice(0, ACTIVITY_INITIAL_ENTRIES);
return html`
<div class="timeline">
${shown.map((entry, index) =>
this._renderActivityEntry(stateObj, entry, index === shown.length - 1)
)}
${shown.map((entry) => this._renderActivityEntry(stateObj, entry))}
</div>
${
activity.length > shown.length
@@ -606,43 +610,41 @@ export class HuiMapOverview extends LitElement {
`;
}
private _renderActivityEntry(
stateObj: HassEntity,
entry: ActivityEntry,
last: boolean
) {
private _renderActivityEntry(stateObj: HassEntity, entry: ActivityEntry) {
const person = entry.personId ? this._states[entry.personId] : undefined;
const headline = entry.personId
? person
? this._personName(person)
: entry.personId
: this._formatters.formatEntityState(stateObj, entry.state);
const color =
entry.personId && !entry.arrived
? undefined
: this._zoneColorForState(entry.state);
// Being somewhere shows as a tile (the place, or who arrived); being
// nowhere, or having left, as a plain line
const zone = entry.personId ? undefined : this._zoneForState(entry.state);
const tile = zone
? this._renderZoneAvatar(zone)
: entry.arrived && person
? this._renderAvatar(person)
: html`<span class="rail" aria-hidden="true"></span>`;
return html`
<div class=${classMap({ entry: true, last })}>
<span
class="dot"
style=${styleMap({ "--dot-color": color })}
aria-hidden="true"
></span>
<span class="entry-headline">${headline}</span>
<span class="entry-when">
${
entry.personId
? html`${this._i18n.localize(
entry.arrived
? "ui.panel.lovelace.cards.map.overview.entered"
: "ui.panel.lovelace.cards.map.overview.left"
)}
· `
: nothing
}
${formatTime(entry.when, this._i18n.locale, this._config)} ·
<ha-relative-time .datetime=${entry.when}></ha-relative-time>
</span>
<div class="entry">
${tile}
<div class="entry-text">
<span class="entry-headline">${headline}</span>
<span class="entry-when">
${
entry.personId
? html`${this._i18n.localize(
entry.arrived
? "ui.panel.lovelace.cards.map.overview.entered"
: "ui.panel.lovelace.cards.map.overview.left"
)}
· `
: nothing
}
${formatTime(entry.when, this._i18n.locale, this._config)} ·
<ha-relative-time .datetime=${entry.when}></ha-relative-time>
</span>
</div>
</div>
`;
}
@@ -740,7 +742,6 @@ export class HuiMapOverview extends LitElement {
private _renderEntity(stateObj: HassEntity) {
const name = this._personName(stateObj);
const location = getEntityLocation(stateObj, this._states);
const picture = stateObj.attributes.entity_picture;
return html`
<ha-md-list-item
@@ -749,21 +750,7 @@ export class HuiMapOverview extends LitElement {
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(computeStateName(stateObj))}</span
>`
: html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
}
</div>
${this._renderAvatar(stateObj, "start")}
<span slot="headline">${name}</span>
<span slot="supporting-text">
${this._formatters.formatEntityState(stateObj)} ·
@@ -778,13 +765,6 @@ export class HuiMapOverview extends LitElement {
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
@@ -792,16 +772,7 @@ export class HuiMapOverview extends LitElement {
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>
${this._renderZoneAvatar(stateObj, "start")}
<span slot="headline">${computeStateName(stateObj)}</span>
<span slot="supporting-text">
${
@@ -817,6 +788,43 @@ export class HuiMapOverview extends LitElement {
`;
}
private _renderAvatar(stateObj: HassEntity, slot?: string) {
const picture = stateObj.attributes.entity_picture;
return html`
<div class="avatar" slot=${ifDefined(slot)}>
${
picture
? html`<img
src=${this._connection.hassUrl(picture)}
alt=""
loading="lazy"
/>`
: computeStateDomain(stateObj) === "person"
? html`<span class="initials"
>${computeUserInitials(computeStateName(stateObj))}</span
>`
: html`<ha-state-icon .stateObj=${stateObj}></ha-state-icon>`
}
</div>
`;
}
private _renderZoneAvatar(zone: HassEntity, slot?: string) {
const color = this._zoneColor(zone);
return html`
<div
class="avatar zone"
slot=${ifDefined(slot)}
style=${styleMap({
background: color,
color: contrastingZoneContent(color),
})}
>
<ha-state-icon .stateObj=${zone}></ha-state-icon>
</div>
`;
}
private _handleTabClick(ev: HASSDomCurrentTargetEvent<HTMLButtonElement>) {
fireEvent(this, "map-overview-tab", {
tab: ev.currentTarget.dataset.tab as OverviewTab,
@@ -875,8 +883,8 @@ export class HuiMapOverview extends LitElement {
max-height: 100%;
display: flex;
flex-direction: column;
padding: var(--ha-space-3);
border-radius: var(--ha-border-radius-xl);
padding: var(--ha-os-space-22) var(--ha-space-6) var(--ha-space-6);
border-radius: var(--ha-os-border-radius-50);
background: var(--card-background-color);
color: var(--primary-text-color);
/* Floats over the map, so it keeps its shadow where cards lose theirs */
@@ -918,7 +926,7 @@ export class HuiMapOverview extends LitElement {
position: relative;
flex: none;
padding: var(--ha-space-1);
border-radius: var(--ha-border-radius-lg);
border-radius: var(--ha-border-radius-6xl);
background: var(--ha-color-fill-neutral-quiet-resting);
}
@@ -938,7 +946,7 @@ export class HuiMapOverview extends LitElement {
(var(--segment-width) + var(--ha-space-1))
);
width: var(--segment-width);
border-radius: calc(var(--ha-border-radius-lg) - var(--ha-space-1));
border-radius: var(--ha-border-radius-5xl);
background: var(--card-background-color);
box-shadow: var(--ha-box-shadow-s);
transition: inset-inline-start 200ms ease;
@@ -947,20 +955,17 @@ export class HuiMapOverview extends LitElement {
.tabs button {
position: relative;
flex: 1;
height: var(--ha-space-15);
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));
padding: 0 var(--ha-space-5);
border-radius: var(--ha-border-radius-5xl);
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"] {
/* The sliding pill alone marks the selected tab */
color: var(--primary-text-color);
}
@@ -992,40 +997,53 @@ export class HuiMapOverview extends LitElement {
min-height: 0;
overflow-y: auto;
padding: 0;
margin-top: var(--ha-space-2);
margin-top: var(--ha-os-space-22);
background: none;
margin-bottom: calc(-1 * var(--sheet-bottom-space, 0px));
padding-bottom: var(--sheet-bottom-space, 0px);
--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;
--row-height: calc(var(--ha-os-space-46) + 2 * var(--ha-os-space-22));
--md-list-item-leading-space: var(--ha-space-6);
--md-list-item-trailing-space: var(--ha-space-6);
--md-list-item-one-line-container-height: var(--row-height);
--md-list-item-two-line-container-height: var(--row-height);
}
ha-md-list-item {
border-radius: var(--ha-border-radius-lg);
border-radius: var(--ha-border-radius-6xl);
--md-list-item-supporting-text-size: var(--ha-font-size-s);
--md-list-item-supporting-text-color: var(--primary-text-color);
--md-list-item-label-text-weight: var(--ha-font-weight-medium);
--ha-md-list-item-gap: var(--ha-space-3);
/* Grey marks hover and focus only, never a resting row. It goes on the
row itself: the ripple's hover layer sits above the content */
--ha-ripple-hover-opacity: 0;
}
ha-md-list-item:hover,
ha-md-list-item:focus-within {
background: var(--ha-color-fill-neutral-quiet-resting);
}
ha-md-list-item.no-location {
--md-sys-color-on-surface: var(--secondary-text-color);
--md-list-item-supporting-text-color: var(--secondary-text-color);
}
.avatar {
position: relative;
width: 48px;
height: 48px;
border-radius: var(--ha-border-radius-lg);
flex: none;
width: var(--ha-os-space-46);
height: var(--ha-os-space-46);
border-radius: var(--ha-border-radius-xl);
overflow: visible;
display: flex;
align-items: center;
justify-content: center;
background: var(--ha-color-fill-neutral-quiet-resting);
color: var(--secondary-text-color);
background: var(--ha-color-fill-neutral-loud-resting);
color: var(--text-primary-color);
font-weight: var(--ha-font-weight-medium);
box-sizing: border-box;
--mdc-icon-size: var(--ha-os-space-34);
}
.avatar img {
@@ -1036,7 +1054,6 @@ export class HuiMapOverview extends LitElement {
}
.avatar.zone {
border-radius: 50%;
border: none;
background: var(--accent-color);
color: #fff;
@@ -1052,66 +1069,70 @@ export class HuiMapOverview extends LitElement {
.detail-header {
flex: none;
box-sizing: border-box;
display: flex;
align-items: center;
height: var(--ha-space-17);
/* The avatar on the end sits as far from the edge as from top and bottom */
padding: var(--ha-space-1) var(--ha-space-3);
gap: var(--ha-space-1);
border-radius: var(--ha-border-radius-6xl);
background: var(--ha-color-fill-neutral-quiet-resting);
color: var(--primary-text-color);
}
.detail-header ha-icon-button-prev {
--ha-icon-button-size: var(--ha-space-11);
}
.detail-title {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--ha-os-space-2);
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);
color: var(--primary-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);
margin-top: var(--ha-os-space-22);
padding: var(--ha-os-space-22) var(--ha-space-6);
border-radius: var(--ha-border-radius-6xl);
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-header {
display: flex;
align-items: center;
gap: var(--ha-space-3);
}
.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;
flex: none;
width: var(--ha-os-space-46);
height: var(--ha-os-space-46);
border-radius: var(--ha-border-radius-xl);
background: var(--card-background-color);
color: var(--primary-color);
--mdc-icon-size: var(--ha-os-space-34);
}
.activity-title {
display: block;
margin: var(--ha-space-2) 0;
font-weight: var(--ha-font-weight-medium);
}
@@ -1122,60 +1143,67 @@ export class HuiMapOverview extends LitElement {
}
.activity-empty {
display: block;
margin-top: var(--ha-os-space-18);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.timeline {
margin-top: var(--ha-space-2);
--rail-size: 10px;
display: flex;
flex-direction: column;
gap: var(--ha-os-space-18);
margin-top: var(--ha-os-space-18);
}
.entry {
position: relative;
display: flex;
flex-direction: column;
padding-inline-start: calc(var(--rail-size) + var(--ha-space-3));
padding-bottom: var(--ha-space-4);
align-items: center;
gap: var(--ha-space-3);
}
.entry.last {
padding-bottom: 0;
}
.entry::before {
content: "";
position: absolute;
inset-inline-start: calc(var(--rail-size) / 2 - 1px);
top: var(--rail-size);
bottom: 0;
width: 2px;
/* Stands in for a tile, centered under the tiles around it */
.rail {
flex: none;
align-self: stretch;
width: var(--ha-border-width-md);
margin-inline: calc(
(var(--ha-os-space-46) - var(--ha-border-width-md)) / 2
);
border-radius: var(--ha-border-radius-pill);
background: var(--divider-color);
}
.entry.last::before {
display: none;
}
.dot {
position: absolute;
inset-inline-start: 0;
top: 4px;
width: var(--rail-size);
height: var(--rail-size);
border-radius: var(--ha-border-radius-circle);
background: var(--dot-color, var(--secondary-text-color));
.entry-text {
display: flex;
flex-direction: column;
gap: var(--ha-os-space-2);
min-width: 0;
}
.entry-headline {
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
}
.entry-when {
color: var(--secondary-text-color);
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
}
.show-more {
display: block;
margin-top: var(--ha-space-3);
padding-top: var(--ha-space-2);
border-top: 1px solid var(--divider-color);
}
.more-info {
flex: none;
width: 100%;
margin-top: var(--ha-os-space-22);
--ha-button-height: var(--ha-os-space-46);
}
`;
}
+1
View File
@@ -429,6 +429,7 @@ export interface MapCardConfig extends LovelaceCardConfig {
aspect_ratio?: string;
auto_fit?: boolean;
fit_zones?: boolean;
show_zone_radius?: boolean;
default_zoom?: number;
show_all?: boolean;
entities?: (MapEntityConfig | string)[];
@@ -108,6 +108,7 @@ const cardConfigStruct = assign(
default_zoom: optional(number()),
auto_fit: optional(boolean()),
fit_zones: optional(boolean()),
show_zone_radius: optional(boolean()),
cluster: optional(boolean()),
dark_mode: optional(boolean()), // legacy option
theme_mode: optional(string()),
@@ -202,6 +203,10 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
{ name: "scale_ruler", selector: { boolean: {} } },
{ name: "auto_fit", selector: { boolean: {} } },
{ name: "fit_zones", selector: { boolean: {} } },
{
name: "show_zone_radius",
selector: { boolean: {} },
},
{ name: "cluster", default: true, selector: { boolean: {} } },
],
},
@@ -589,6 +594,7 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
case "scale_ruler":
case "auto_fit":
case "fit_zones":
case "show_zone_radius":
case "cluster":
case "show_all":
return this.hass!.localize(
@@ -606,6 +612,11 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
) => {
switch (schema.name) {
case "map_style":
case "scale_ruler":
case "auto_fit":
case "fit_zones":
case "show_zone_radius":
case "cluster":
case "show_all":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.map.${schema.name}_helper`
+8
View File
@@ -42,6 +42,14 @@ export const coreStyles = css`
--ha-space-19: 76px;
--ha-space-20: 80px;
/* Off-grid values from the OS design (Figma "Maps - MBB /ML") */
--ha-os-space-2: 2px;
--ha-os-space-18: 18px;
--ha-os-space-22: 22px;
--ha-os-space-34: 34px;
--ha-os-space-46: 46px;
--ha-os-border-radius-50: 50px;
--ha-animation-duration-none: 1ms;
--ha-animation-duration-instant: 75ms;
--ha-animation-duration-fast: 150ms;
+7
View File
@@ -9565,6 +9565,7 @@
"me": "Me",
"activity": "Activity",
"show_more": "Show more",
"more_info": "More info",
"no_activity": "No recent activity",
"activity_unavailable": "Activity couldn't be loaded",
"entered": "Entered",
@@ -10721,10 +10722,16 @@
"toner": "Toner"
},
"scale_ruler": "Scale ruler",
"scale_ruler_helper": "Show a distance scale in the corner of the map",
"default_zoom": "Default zoom",
"auto_fit": "Auto fit",
"auto_fit_helper": "Keep all entities in view by zooming and panning as they move",
"fit_zones": "Fit zones",
"fit_zones_helper": "Include zones when fitting the map, not only entities",
"show_zone_radius": "Show zone radius",
"show_zone_radius_helper": "In the panel layout, always show the radius of every zone, not only on the Zones tab",
"cluster": "Cluster markers",
"cluster_helper": "Group markers that are close together into one bubble",
"show_all": "Show all",
"show_all_helper": "Automatically add all entities with coordinates",
"source": "Source",
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { pixelDistance } from "../../../src/common/map/map-engine";
describe("pixelDistance", () => {
it("spans half the 256px world at zoom 0", () => {
expect(pixelDistance([0, -90], [0, 90], 0)).toBeCloseTo(128);
});
it("takes the short way across the antimeridian", () => {
expect(pixelDistance([0, -179.5], [0, 179.5], 0)).toBeCloseTo(256 / 360);
});
it("doubles with every zoom level", () => {
const base = pixelDistance([52.37, 4.9], [52.38, 4.91], 10);
expect(pixelDistance([52.37, 4.9], [52.38, 4.91], 12)).toBeCloseTo(
base * 4
);
});
it("is zero for the same position", () => {
expect(pixelDistance([52.37, 4.9], [52.37, 4.9], 15)).toBe(0);
});
it("stays finite at the poles", () => {
expect(pixelDistance([90, 0], [90, 90], 3)).toBeCloseTo(512);
});
});
+127 -9
View File
@@ -112,6 +112,36 @@ const fakes = vi.hoisted(() => {
}
}
class FakeAttributionControl {
static all: FakeAttributionControl[] = [];
element?: HTMLElement;
constructor(public options: { compact?: boolean }) {
FakeAttributionControl.all.push(this);
}
onAdd() {
const element = document.createElement("details");
element.className = "maplibregl-ctrl maplibregl-ctrl-attrib";
const button = document.createElement("summary");
button.className = "maplibregl-ctrl-attrib-button";
button.addEventListener("click", () =>
element.classList.toggle("maplibregl-compact-show")
);
element.append(button);
if (this.options.compact) {
element.classList.add("maplibregl-compact", "maplibregl-compact-show");
}
this.element = element;
return element;
}
onRemove() {
this.element?.remove();
}
}
class FakeMap {
static instances: FakeMap[] = [];
@@ -144,9 +174,16 @@ const fakes = vi.hoisted(() => {
remove = vi.fn();
addControl = vi.fn();
addControl = vi.fn((control: { onAdd?: () => HTMLElement }) => {
const element = control.onAdd?.();
if (element) {
this._container.append(element);
}
});
removeControl = vi.fn();
removeControl = vi.fn((control: { onRemove?: () => void }) =>
control.onRemove?.()
);
setStyle = vi.fn(
(
@@ -321,13 +358,14 @@ const fakes = vi.hoisted(() => {
}
}
return { baseStyle, FakeMap, FakeMarker, FakePopup };
return { baseStyle, FakeMap, FakeMarker, FakePopup, FakeAttributionControl };
});
vi.mock("maplibre-gl", () => ({
Map: fakes.FakeMap,
Marker: fakes.FakeMarker,
Popup: fakes.FakePopup,
AttributionControl: fakes.FakeAttributionControl,
NavigationControl: vi.fn(),
ScaleControl: vi.fn(),
setRTLTextPlugin: vi.fn(),
@@ -364,6 +402,19 @@ const flush = () =>
setTimeout(resolve, 0);
});
const fakeAttribution = fakes.FakeAttributionControl;
const viewport = {
matches: false,
listeners: new Set<Listener>(),
addEventListener: (_type: string, listener: Listener) => {
viewport.listeners.add(listener);
},
removeEventListener: (_type: string, listener: Listener) => {
viewport.listeners.delete(listener);
},
};
const createEngine = async (events: Partial<MapEngineEvents> = {}) => {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -392,12 +443,73 @@ describe("MapLibreMapEngine", () => {
fakeMap.startLoaded = true;
fakeMap.failNextSetStyle = false;
fakeMarker.all.length = 0;
fakeAttribution.all.length = 0;
viewport.matches = false;
viewport.listeners.clear();
vi.stubGlobal(
"matchMedia",
vi.fn(() => viewport)
);
tokenListeners.clear();
refreshMapTilesToken.mockClear();
loadStyle.mockReset();
loadStyle.mockImplementation(async () => baseStyle());
});
describe("attribution", () => {
const attribution = (map: InstanceType<typeof fakeMap>) =>
map.getContainer().querySelector(".maplibregl-ctrl-attrib")!;
const shown = (map: InstanceType<typeof fakeMap>) =>
attribution(map).classList.contains("maplibregl-compact-show");
const narrowWithFakeTimers = async () => {
const created = await createEngine();
await created.ready;
vi.useFakeTimers();
viewport.matches = true;
viewport.listeners.forEach((listener) => listener());
return created.map;
};
it("collapses the opened compact attribution by itself", async () => {
const map = await narrowWithFakeTimers();
expect(shown(map)).toBe(true);
vi.advanceTimersByTime(4999);
expect(shown(map)).toBe(true);
vi.advanceTimersByTime(1);
expect(shown(map)).toBe(false);
});
it("leaves an attribution the user opened alone", async () => {
const map = await narrowWithFakeTimers();
vi.advanceTimersByTime(5000);
expect(shown(map)).toBe(false);
attribution(map)
.querySelector<HTMLElement>(".maplibregl-ctrl-attrib-button")!
.click();
await Promise.resolve();
expect(shown(map)).toBe(true);
vi.advanceTimersByTime(5000);
expect(shown(map)).toBe(true);
});
it("forces a compact attribution on a narrow viewport only", async () => {
const { map, ready } = await createEngine();
await ready;
expect(fakeAttribution.all).toHaveLength(1);
expect(fakeAttribution.all[0].options.compact).toBeUndefined();
viewport.matches = true;
viewport.listeners.forEach((listener) => listener());
expect(map.removeControl).toHaveBeenCalledWith(fakeAttribution.all[0]);
expect(fakeAttribution.all).toHaveLength(2);
expect(fakeAttribution.all[1].options.compact).toBe(true);
expect(attribution(map)).toBe(fakeAttribution.all[1].element);
});
});
afterEach(() => {
document.body.innerHTML = "";
vi.useRealTimers();
@@ -1161,7 +1273,7 @@ describe("MapLibreMapEngine", () => {
).toBe(true);
});
it("leaves clusters elsewhere on the map alone when one is activated", async () => {
it("regroups clusters elsewhere on the map too when one is activated", async () => {
vi.useFakeTimers();
const { engine, map, ready } = await createEngine();
await ready;
@@ -1177,11 +1289,17 @@ describe("MapLibreMapEngine", () => {
(iconBuilder.mock.results[0].value.element as HTMLElement).click();
expect(map.fitBounds).toHaveBeenCalledOnce();
// The activated pair parted; the other bubble is the very same marker
expect(fakeMarker.all).toHaveLength(3);
expect(fakeMarker.all).toContain(other);
expect(other.onMap).toBe(true);
expect((other.options.element as HTMLElement).style.visibility).toBe("");
// Both pairs part: the fit regroups everything for the landing zoom
expect(fakeMarker.all).toHaveLength(4);
expect(fakeMarker.all).not.toContain(other);
const far = fakeMarker.all.filter((marker) => marker.lngLat![0] >= 5);
expect(far).toHaveLength(2);
far.forEach((marker) => {
expect(marker.onMap).toBe(true);
expect((marker.options.element as HTMLElement).style.visibility).toBe(
""
);
});
expect(iconBuilder).toHaveBeenCalledTimes(2);
});
@@ -75,6 +75,8 @@ const fakeEngine = vi.hoisted(() => {
setView = vi.fn();
getView = () => undefined;
setZoom = vi.fn();
fitBounds = vi.fn();