Compare commits

...
12 Commits
Author SHA1 Message Date
Maarten Lakerveld b3ad317c22 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 14:51:13 +02:00
Maarten Lakerveld bca3b11772 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 14:51:13 +02:00
Maarten Lakerveld 135283720c 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 14:51:13 +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
13 changed files with 644 additions and 167 deletions
+20 -2
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;
@@ -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(),
};
+127 -33
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) {
@@ -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) => {
+37
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,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);
};
+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%;
+69 -33
View File
@@ -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);
+113 -62
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,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);
}
`;
}
+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`
+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();