mirror of
https://github.com/home-assistant/frontend.git
synced 2026-10-06 13:16:31 +00:00
Compare commits
12
Commits
dev
...
map-improvements
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3ad317c22 | ||
|
|
bca3b11772 | ||
|
|
135283720c | ||
|
|
58cc916d93 | ||
|
|
d40a36f09d | ||
|
|
855ec46ef2 | ||
|
|
f32ef888ed | ||
|
|
e1a0bea416 | ||
|
|
403c3f57fd | ||
|
|
8c82e6df51 | ||
|
|
708e004159 | ||
|
|
9ae5a3b7e6 |
@@ -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;
|
||||
@@ -227,6 +240,7 @@ export class LeafletMapEngine implements MapEngine {
|
||||
const focusable = options.focusable ?? interactive;
|
||||
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,
|
||||
@@ -269,6 +283,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 +291,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(),
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
@@ -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) => {
|
||||
@@ -1478,21 +1579,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) => {
|
||||
|
||||
@@ -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,6 +73,8 @@ 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 */
|
||||
@@ -85,6 +94,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 {
|
||||
@@ -230,6 +241,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 +348,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);
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
MapEditableCircleHandle,
|
||||
MapEditableMarkerHandle,
|
||||
MapEditingSupport,
|
||||
MapView,
|
||||
} from "../../common/map/map-engine";
|
||||
import {
|
||||
circleBoundsPoints,
|
||||
@@ -76,7 +77,7 @@ import type {
|
||||
HomeAssistantUI,
|
||||
ThemeMode,
|
||||
} from "../../types";
|
||||
import "./ha-entity-marker";
|
||||
import { FLOATING_LIFT, floatingMarkerFootprint } from "./ha-entity-marker";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -276,6 +277,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"];
|
||||
|
||||
@@ -963,9 +968,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 +998,7 @@ export class HaMap extends ReactiveElement {
|
||||
pad: options?.pad ?? 0.5,
|
||||
animate: this._hasFitted,
|
||||
padding: options?.padding,
|
||||
fly: options?.fly,
|
||||
});
|
||||
});
|
||||
this._hasFitted = true;
|
||||
@@ -1470,6 +1485,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 +1503,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 +1657,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 +1847,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 +1936,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);
|
||||
|
||||
@@ -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,14 @@ 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 {
|
||||
addSearchParam,
|
||||
extractSearchParam,
|
||||
removeSearchParam,
|
||||
} from "../../../common/url/search-params";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import {
|
||||
hasConfigChanged,
|
||||
@@ -67,14 +78,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;
|
||||
@@ -402,6 +412,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,10 +467,9 @@ 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
|
||||
@@ -517,29 +529,20 @@ 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(
|
||||
@@ -558,6 +561,9 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
mainWindow.addEventListener("popstate", this._syncSelection);
|
||||
mainWindow.addEventListener("location-changed", this._syncSelection);
|
||||
this._syncSelection();
|
||||
if (this.hasUpdated && this._configEntities?.length) {
|
||||
this._subscribeHistory();
|
||||
}
|
||||
@@ -565,9 +571,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) {
|
||||
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 +642,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 +679,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 +693,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 +702,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this._overviewSelected = entityId;
|
||||
this._focusEntity(entityId);
|
||||
this._select(entityId);
|
||||
}
|
||||
|
||||
private _handleOverviewResize(
|
||||
@@ -697,48 +725,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
|
||||
|
||||
@@ -497,16 +497,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
|
||||
@@ -551,6 +544,15 @@ 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>
|
||||
`;
|
||||
}
|
||||
@@ -1065,23 +1067,10 @@ export class HuiMapOverview extends LitElement {
|
||||
}
|
||||
|
||||
.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);
|
||||
@@ -1176,6 +1165,12 @@ export class HuiMapOverview extends LitElement {
|
||||
padding-top: var(--ha-space-2);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.more-info {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user