mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 20:07:26 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6dba7777 | ||
|
|
7e2fd53b68 | ||
|
|
ecb36c6144 | ||
|
|
eea77277cd | ||
|
|
7494c2b917 | ||
|
|
c94227e830 | ||
|
|
d43178310b | ||
|
|
e8f7cc545e | ||
|
|
9bffae3ee4 | ||
|
|
a287aacd98 |
@@ -81,6 +81,8 @@ async function copyMapPanel(staticDir) {
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
// Controls and popups of the native MapLibre engine
|
||||
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
|
||||
}
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.2",
|
||||
"@codemirror/state": "6.7.3",
|
||||
"@codemirror/view": "6.43.11",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
|
||||
@@ -12,14 +12,14 @@ import {
|
||||
|
||||
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
|
||||
// TileJSON, deliberately: it follows whoever serves the tiles.
|
||||
const VECTOR_STYLES = {
|
||||
export const VECTOR_STYLES = {
|
||||
light: "/static/map/light.json",
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
|
||||
// worker, hence a URL rather than an import.
|
||||
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
|
||||
|
||||
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
|
||||
// OSM serves no @2x variant.
|
||||
@@ -33,8 +33,8 @@ const OSM_ATTRIBUTION =
|
||||
|
||||
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
|
||||
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
|
||||
const CONTEXT_RESTORE_GRACE = 2000;
|
||||
const RECOVERY_THROTTLE = 30000;
|
||||
export const CONTEXT_RESTORE_GRACE = 2000;
|
||||
export const RECOVERY_THROTTLE = 30000;
|
||||
|
||||
// On the map, not the layer: marker clustering throws without a maximum. The
|
||||
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
|
||||
@@ -55,7 +55,7 @@ export interface MapBaseLayer {
|
||||
let webGL2Supported: boolean | undefined;
|
||||
|
||||
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
|
||||
const supportsWebGL2 = (): boolean => {
|
||||
export const supportsWebGL2 = (): boolean => {
|
||||
if (webGL2Supported === undefined) {
|
||||
try {
|
||||
const context = document.createElement("canvas").getContext("webgl2");
|
||||
@@ -91,7 +91,7 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
|
||||
return style;
|
||||
};
|
||||
|
||||
const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
const style: StyleSpecification = await (await fetch(url)).json();
|
||||
|
||||
if (__DEMO__) {
|
||||
@@ -111,7 +111,7 @@ const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
|
||||
// Global to MapLibre, and it throws when set twice.
|
||||
let rtlTextPluginRequested = false;
|
||||
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
|
||||
if (rtlTextPluginRequested) {
|
||||
return;
|
||||
}
|
||||
@@ -303,9 +303,11 @@ export const createBaseLayer = async (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean,
|
||||
token: string | undefined
|
||||
token: string | undefined,
|
||||
// Skip the vector layer, e.g. after a permanent WebGL context loss
|
||||
rasterOnly = false
|
||||
): Promise<MapBaseLayer> => {
|
||||
if (supportsWebGL2()) {
|
||||
if (!rasterOnly && supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
try {
|
||||
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import type {
|
||||
CircleMarker,
|
||||
Control,
|
||||
Map,
|
||||
MarkerClusterGroup,
|
||||
Polyline,
|
||||
} from "leaflet";
|
||||
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
|
||||
import type { MapBaseLayer } from "../base-layer";
|
||||
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
|
||||
import { DecoratedMarker } from "../decorated_marker";
|
||||
import { isTouch } from "../../../util/is_touch";
|
||||
import type {
|
||||
MapClusterOptions,
|
||||
MapCircleOptions,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapEngineOptions,
|
||||
MapFitOptions,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapMarkerOptions,
|
||||
MapPath,
|
||||
} from "../map-engine";
|
||||
import { setMarkerAccessibility } from "../marker-accessibility";
|
||||
|
||||
/** A leaflet marker that knows the engine handle it was created for */
|
||||
interface HandledMarker extends DecoratedMarker {
|
||||
engineHandle?: LeafletMarkerHandle;
|
||||
}
|
||||
|
||||
interface LeafletMarkerHandle extends MapMarkerHandle {
|
||||
marker: HandledMarker;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Leaflet implementation of MapEngine. Renders vector tiles through the
|
||||
* maplibre-gl-leaflet adapter when WebGL2 is available and raster tiles
|
||||
* otherwise (see createBaseLayer), so it is both the non-WebGL2 fallback and
|
||||
* the engine ha-locations-editor requires for leaflet-draw.
|
||||
*/
|
||||
export class LeafletMapEngine implements MapEngine {
|
||||
/**
|
||||
* Escape hatch for ha-locations-editor, which manages its own Leaflet
|
||||
* layers (leaflet-draw). Not for use anywhere else.
|
||||
*/
|
||||
public leafletMap?: Map;
|
||||
|
||||
public Leaflet?: LeafletModuleType;
|
||||
|
||||
private _baseLayer?: MapBaseLayer;
|
||||
|
||||
private _clusterable: HandledMarker[] = [];
|
||||
|
||||
private _cluster?: MarkerClusterGroup;
|
||||
|
||||
private _clusterOptions: MapClusterOptions | null = null;
|
||||
|
||||
private _scaleControl?: Control.Scale;
|
||||
|
||||
public async init(
|
||||
container: HTMLElement,
|
||||
options: MapEngineOptions
|
||||
): Promise<void> {
|
||||
const root = container.parentNode;
|
||||
if (!root) {
|
||||
throw new Error("Cannot set up a Leaflet map on a detached element");
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
|
||||
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
|
||||
await import("leaflet.markercluster");
|
||||
|
||||
const map = Leaflet.map(container, {
|
||||
minZoom: MAP_MIN_ZOOM,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
map.attributionControl.setPrefix("");
|
||||
for (const href of [
|
||||
"/static/images/leaflet/leaflet.css",
|
||||
"/static/images/leaflet/MarkerCluster.css",
|
||||
]) {
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", href);
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
root.appendChild(style);
|
||||
}
|
||||
map.setView(options.center, options.zoom);
|
||||
|
||||
// The base layer adds itself; a vector layer may still fall back to raster
|
||||
this._baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
options.darkMode,
|
||||
options.token,
|
||||
options.rasterOnly ?? false
|
||||
);
|
||||
this.leafletMap = map;
|
||||
this.Leaflet = Leaflet;
|
||||
map.zoomControl?.setPosition(options.zoomControlPosition);
|
||||
|
||||
const { events } = options;
|
||||
if (events.click) {
|
||||
map.on("click", (ev) => {
|
||||
events.click!([ev.latlng.lat, ev.latlng.lng]);
|
||||
});
|
||||
}
|
||||
if (events.zoomStart) {
|
||||
map.on("zoomstart", () => events.zoomStart!());
|
||||
}
|
||||
if (events.moveStart) {
|
||||
map.on("movestart", () => events.moveStart!());
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.leafletMap?.remove();
|
||||
this.leafletMap = undefined;
|
||||
this.Leaflet = undefined;
|
||||
this._baseLayer = undefined;
|
||||
this._cluster = undefined;
|
||||
this._clusterable = [];
|
||||
this._scaleControl = undefined;
|
||||
}
|
||||
|
||||
public invalidateSize(): void {
|
||||
this.leafletMap?.invalidateSize({ debounceMoveend: true });
|
||||
}
|
||||
|
||||
public hasUsableSize(): boolean {
|
||||
if (!this.leafletMap) {
|
||||
return false;
|
||||
}
|
||||
const size = this.leafletMap.getSize();
|
||||
if (size.x > 0 && size.y > 0) {
|
||||
return true;
|
||||
}
|
||||
const container = this.leafletMap.getContainer();
|
||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
// The container was laid out since Leaflet last measured it
|
||||
this.leafletMap.invalidateSize(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public setDarkMode(darkMode: boolean): void {
|
||||
this._baseLayer?.setDarkMode(darkMode);
|
||||
}
|
||||
|
||||
public setZoomControlPosition(position: MapControlPosition): void {
|
||||
this.leafletMap?.zoomControl?.setPosition(position);
|
||||
}
|
||||
|
||||
public setScaleRuler(options: { metric: boolean } | null): void {
|
||||
if (this._scaleControl) {
|
||||
this.leafletMap?.removeControl(this._scaleControl);
|
||||
this._scaleControl = undefined;
|
||||
}
|
||||
if (!options || !this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
this._scaleControl = this.Leaflet.control.scale({
|
||||
position: "bottomleft",
|
||||
metric: options.metric,
|
||||
imperial: !options.metric,
|
||||
});
|
||||
this._scaleControl.addTo(this.leafletMap!);
|
||||
}
|
||||
|
||||
public setView(center: MapLatLng, zoom?: number): void {
|
||||
this.leafletMap?.setView(center, zoom);
|
||||
}
|
||||
|
||||
public setZoom(zoom: number): void {
|
||||
this.leafletMap?.setZoom(zoom);
|
||||
}
|
||||
|
||||
private _getZoom(): number {
|
||||
return this.leafletMap?.getZoom() ?? 0;
|
||||
}
|
||||
|
||||
private _project(location: MapLatLng): { x: number; y: number } {
|
||||
const point = this.leafletMap!.project(location, this._getZoom());
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
|
||||
if (!this.leafletMap || !this.Leaflet || !points.length) {
|
||||
return;
|
||||
}
|
||||
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
|
||||
this.leafletMap.fitBounds(bounds, {
|
||||
maxZoom: options?.maxZoom,
|
||||
animate: options?.animate,
|
||||
});
|
||||
}
|
||||
|
||||
public addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle {
|
||||
const decoration = options.decoration
|
||||
? this.Leaflet!.circle(location, {
|
||||
interactive: false,
|
||||
color: options.decoration.color,
|
||||
radius: options.decoration.radius,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// Leaflet's keyboard support focuses its own wrapper, where the element's
|
||||
// activation handlers never hear a key; the element itself takes focus
|
||||
const interactive = options.interactive ?? true;
|
||||
if (interactive) {
|
||||
element.tabIndex = 0;
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, interactive);
|
||||
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
|
||||
icon: this.Leaflet!.divIcon({
|
||||
html: element,
|
||||
iconSize: options.size,
|
||||
iconAnchor: options.anchor,
|
||||
className: "",
|
||||
}),
|
||||
interactive,
|
||||
keyboard: false,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
const handle: LeafletMarkerHandle = {
|
||||
marker,
|
||||
location,
|
||||
clusterData: options.clusterData,
|
||||
remove: () => {
|
||||
this._cluster?.removeLayer(marker);
|
||||
marker.remove();
|
||||
const index = this._clusterable.indexOf(marker);
|
||||
if (index !== -1) {
|
||||
this._clusterable.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
marker.engineHandle = handle;
|
||||
|
||||
if (options.cluster) {
|
||||
// Placed on the map by the next setClustering call
|
||||
this._clusterable.push(marker);
|
||||
} else {
|
||||
marker.addTo(this.leafletMap!);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
public addCircle(
|
||||
center: MapLatLng,
|
||||
options: MapCircleOptions
|
||||
): MapItemHandle {
|
||||
const circle = this.Leaflet!.circle(center, {
|
||||
interactive: false,
|
||||
color: options.color,
|
||||
radius: options.radius,
|
||||
}).addTo(this.leafletMap!);
|
||||
return { remove: () => circle.remove() };
|
||||
}
|
||||
|
||||
public addPath(path: MapPath): MapItemHandle {
|
||||
const items: (Polyline | CircleMarker)[] = [];
|
||||
for (const segment of path.segments) {
|
||||
items.push(
|
||||
this.Leaflet!.polyline(segment.points, {
|
||||
color: path.color,
|
||||
opacity: segment.opacity,
|
||||
interactive: false,
|
||||
})
|
||||
);
|
||||
}
|
||||
for (const pathMarker of path.markers) {
|
||||
items.push(
|
||||
this.Leaflet!.circleMarker(pathMarker.location, {
|
||||
radius: isTouch ? 8 : 3,
|
||||
color: path.color,
|
||||
opacity: pathMarker.opacity,
|
||||
fillOpacity: pathMarker.opacity,
|
||||
interactive: true,
|
||||
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
|
||||
);
|
||||
}
|
||||
items.forEach((item) => item.addTo(this.leafletMap!));
|
||||
return { remove: () => items.forEach((item) => item.remove()) };
|
||||
}
|
||||
|
||||
public setClustering(options: MapClusterOptions | null): void {
|
||||
if (this._cluster) {
|
||||
this._cluster.remove();
|
||||
this._cluster = undefined;
|
||||
}
|
||||
this._clusterOptions = options;
|
||||
if (!this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
if (!options) {
|
||||
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
|
||||
return;
|
||||
}
|
||||
// markercluster groups by proximity only; groupKey is not supported here
|
||||
this._cluster = this.Leaflet.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
removeOutsideVisibleBounds: false,
|
||||
maxClusterRadius: options.radius,
|
||||
iconCreateFunction: (cluster) => {
|
||||
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
|
||||
(marker) => marker.engineHandle!
|
||||
);
|
||||
const latLng = cluster.getLatLng();
|
||||
const icon = this._clusterOptions!.iconBuilder(members, [
|
||||
latLng.lat,
|
||||
latLng.lng,
|
||||
]);
|
||||
// The element fills the divIcon wrapper, which gets the size
|
||||
icon.element.style.width = `${icon.size[0]}px`;
|
||||
icon.element.style.height = `${icon.size[1]}px`;
|
||||
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
|
||||
let anchor = icon.anchor;
|
||||
if (icon.location) {
|
||||
const clusterPoint = this._project([latLng.lat, latLng.lng]);
|
||||
const targetPoint = this._project(icon.location);
|
||||
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
|
||||
anchor = [
|
||||
base[0] - (targetPoint.x - clusterPoint.x),
|
||||
base[1] - (targetPoint.y - clusterPoint.y),
|
||||
];
|
||||
}
|
||||
return this.Leaflet!.divIcon({
|
||||
html: icon.element,
|
||||
iconSize: icon.size,
|
||||
iconAnchor: anchor,
|
||||
className: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
this._cluster.addLayers(this._clusterable);
|
||||
this.leafletMap!.addLayer(this._cluster!);
|
||||
}
|
||||
|
||||
public refreshClusters(): void {
|
||||
this._cluster?.refreshClusters();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Engine abstraction for ha-map.
|
||||
*
|
||||
* ha-map keeps all Home Assistant semantics (entities, zones, cluster bubble
|
||||
* DOM, history path math, fit policy) and delegates the primitive map
|
||||
* operations to a MapEngine, so the engine can be selected at runtime:
|
||||
* MapLibre GL native where WebGL2 is available, Leaflet otherwise (and always
|
||||
* for ha-locations-editor, which edits with leaflet-draw).
|
||||
*
|
||||
* The interface exposes no engine types: positions are [latitude, longitude]
|
||||
* tuples and marker content is caller-owned HTML elements.
|
||||
*
|
||||
* Zoom levels use Leaflet semantics (zoom 0 = one world tile), the historical
|
||||
* convention across Home Assistant map configs. The MapLibre engine converts
|
||||
* internally (MapLibre zoom = Leaflet zoom - 1).
|
||||
*/
|
||||
|
||||
export type MapLatLng = [latitude: number, longitude: number];
|
||||
|
||||
export interface MapPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type MapControlPosition =
|
||||
"topleft" | "topright" | "bottomleft" | "bottomright";
|
||||
|
||||
export interface MapEngineEvents {
|
||||
/** Click on the map surface, not on a marker */
|
||||
click(location: MapLatLng): void;
|
||||
/** Zoom is starting, programmatic or not */
|
||||
zoomStart(): void;
|
||||
/** The map starts moving, programmatic or not */
|
||||
moveStart(): void;
|
||||
/** The engine can no longer render; the host switches to the fallback */
|
||||
fatal(): void;
|
||||
}
|
||||
|
||||
export interface MapEngineOptions {
|
||||
center: MapLatLng;
|
||||
zoom: number;
|
||||
darkMode: boolean;
|
||||
/** Token for core's tile proxy */
|
||||
token?: string;
|
||||
zoomControlPosition: MapControlPosition;
|
||||
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
|
||||
rasterOnly?: boolean;
|
||||
events: Partial<MapEngineEvents>;
|
||||
}
|
||||
|
||||
export interface MapFitOptions {
|
||||
/** Do not zoom in beyond this level even if the bounds would allow it */
|
||||
maxZoom?: number;
|
||||
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
|
||||
pad?: number;
|
||||
/** Ease the camera to the bounds instead of jumping; defaults to true */
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
export interface MapMarkerOptions {
|
||||
/** Rendered size of the element in pixels */
|
||||
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];
|
||||
/** Takes pointer input and keyboard focus; defaults to true */
|
||||
interactive?: boolean;
|
||||
/** Accessible name */
|
||||
title?: string;
|
||||
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
|
||||
decoration?: MapCircleOptions;
|
||||
/** Cluster this marker; it appears once setClustering is called */
|
||||
cluster?: boolean;
|
||||
/** Caller data handed back to the cluster icon builder */
|
||||
clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapCircleOptions {
|
||||
/** Radius in meters */
|
||||
radius: number;
|
||||
/** Stroke color; the fill is derived from it, translucent */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface MapPathSegment {
|
||||
points: MapLatLng[];
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
export interface MapPathMarker {
|
||||
location: MapLatLng;
|
||||
opacity?: number;
|
||||
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
|
||||
tooltipHtml: string;
|
||||
}
|
||||
|
||||
export interface MapPath {
|
||||
color: string;
|
||||
segments: MapPathSegment[];
|
||||
markers: MapPathMarker[];
|
||||
}
|
||||
|
||||
/** Handle to anything placed on the map; remove() must be idempotent */
|
||||
export interface MapItemHandle {
|
||||
remove(): void;
|
||||
}
|
||||
|
||||
export interface MapMarkerHandle extends MapItemHandle {
|
||||
readonly location: MapLatLng;
|
||||
readonly clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapClusterIcon {
|
||||
element: HTMLElement;
|
||||
size: [width: number, height: number];
|
||||
/** Like MapMarkerOptions.anchor; defaults to the element's center */
|
||||
anchor?: [x: number, y: number];
|
||||
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
|
||||
location?: MapLatLng;
|
||||
}
|
||||
|
||||
export interface MapClusterOptions {
|
||||
/** Cluster markers closer than this many screen pixels */
|
||||
radius: number;
|
||||
/**
|
||||
* Markers sharing a key (e.g. their zone) form one group while they span
|
||||
* at most groupRadius pixels; beyond that, and without a key, they cluster
|
||||
* by proximity.
|
||||
*/
|
||||
groupKey?(marker: MapMarkerHandle): string | undefined;
|
||||
groupRadius?: number;
|
||||
/** Builds a cluster's element; called when its members change and on refreshClusters() */
|
||||
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
|
||||
}
|
||||
|
||||
export interface MapEngine {
|
||||
/** Create the map in the container; call once */
|
||||
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
|
||||
|
||||
/** Tear down the map and release its resources (DOM, workers, WebGL) */
|
||||
destroy(): void;
|
||||
|
||||
/** Re-measure the container after a size change */
|
||||
invalidateSize(): void;
|
||||
|
||||
/** Whether the map has a non-zero size, re-measuring if needed */
|
||||
hasUsableSize(): boolean;
|
||||
|
||||
setDarkMode(darkMode: boolean): void;
|
||||
|
||||
setZoomControlPosition(position: MapControlPosition): void;
|
||||
|
||||
/** Show a scale ruler (bottom start); null hides it */
|
||||
setScaleRuler(options: { metric: boolean } | null): void;
|
||||
|
||||
setView(center: MapLatLng, zoom?: number): void;
|
||||
|
||||
setZoom(zoom: number): void;
|
||||
|
||||
/** Fit the given points into view; a single point centers on it */
|
||||
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
|
||||
|
||||
// Content ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Place an HTML element on the map. The element is owned by the caller;
|
||||
* the engine positions it and, for interactive markers, makes it
|
||||
* focusable.
|
||||
*/
|
||||
addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle;
|
||||
|
||||
/** Draw a meter-radius circle (zone radius) */
|
||||
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
|
||||
|
||||
/** Draw one history trail (points with tooltips, connecting segments) */
|
||||
addPath(path: MapPath): MapItemHandle;
|
||||
|
||||
/**
|
||||
* Enable or disable clustering of the markers added with cluster: true.
|
||||
* Must be called after each batch of addMarker calls to place clusterable
|
||||
* markers on the map; null places them unclustered.
|
||||
*/
|
||||
setClustering(options: MapClusterOptions | null): void;
|
||||
|
||||
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
|
||||
refreshClusters(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding box corners of a circle, for fitting a radius into view without
|
||||
* engine-specific circle bounds.
|
||||
*/
|
||||
export const circleBoundsPoints = (
|
||||
center: MapLatLng,
|
||||
radiusMeters: number
|
||||
): MapLatLng[] => {
|
||||
const latOffset = radiusMeters / 111320;
|
||||
const lngOffset =
|
||||
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
|
||||
return [
|
||||
[center[0] - latOffset, center[1] - lngOffset],
|
||||
[center[0] + latOffset, center[1] + lngOffset],
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Marker elements are focusable buttons on both engines, so the caller's
|
||||
* element is the keyboard target and needs a name and a role. MapLibre would
|
||||
* otherwise label it "Map marker".
|
||||
*/
|
||||
export const setMarkerAccessibility = (
|
||||
element: HTMLElement,
|
||||
title: string | undefined,
|
||||
interactive: boolean
|
||||
): void => {
|
||||
if (title && !element.hasAttribute("aria-label")) {
|
||||
element.setAttribute("aria-label", title);
|
||||
}
|
||||
if (!element.hasAttribute("role")) {
|
||||
if (interactive) {
|
||||
element.setAttribute("role", "button");
|
||||
} else if (title) {
|
||||
element.setAttribute("role", "img");
|
||||
} else {
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -58,6 +58,24 @@ class HaEntityMarker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("keydown", this._handleKeydown);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("keydown", this._handleKeydown);
|
||||
}
|
||||
|
||||
// The map engines make the marker a focusable button
|
||||
private _handleKeydown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._badgeTap(ev);
|
||||
}
|
||||
};
|
||||
|
||||
private _badgeTap(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
if (this.entityId) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type {
|
||||
DivIcon,
|
||||
DragEndEvent,
|
||||
LatLng,
|
||||
LatLngExpression,
|
||||
Marker,
|
||||
MarkerOptions,
|
||||
} from "leaflet";
|
||||
@@ -13,6 +12,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
|
||||
import type { MapLatLng } from "../../common/map/map-engine";
|
||||
import type { ThemeMode } from "../../types";
|
||||
import "../ha-input-helper-text";
|
||||
import "./ha-map";
|
||||
@@ -87,7 +87,7 @@ export class HaLocationsEditor extends LitElement {
|
||||
}
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: LatLngExpression[],
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
) {
|
||||
this.map.fitBounds(boundingbox, options);
|
||||
@@ -126,6 +126,7 @@ export class HaLocationsEditor extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-map
|
||||
engine="leaflet"
|
||||
.layers=${this._getLayers(this._circles, this._locationMarkers)}
|
||||
.zoom=${this.zoom}
|
||||
.autoFit=${this.autoFit}
|
||||
|
||||
+444
-317
File diff suppressed because it is too large
Load Diff
@@ -221,7 +221,19 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._unsubscribeEvents) {
|
||||
// Hidden-tab panel suspend detaches an ancestor and later reattaches
|
||||
// this same instance. Direct removal (route change) has no parentNode,
|
||||
// so we still tear down even if the tab is hidden — e.g. saving a new
|
||||
// scene remounts the itemId editor.
|
||||
// Leave the live subscription in place on suspend so the websocket
|
||||
// library can restore it after reconnect; a fresh subscribe here races
|
||||
// the closed socket.
|
||||
if (document.hidden && this.parentNode) {
|
||||
return;
|
||||
}
|
||||
if (this._mode === "live" && this.hass) {
|
||||
this._exitLiveMode();
|
||||
} else if (this._unsubscribeEvents) {
|
||||
this._unsubscribeEvents();
|
||||
this._unsubscribeEvents = undefined;
|
||||
}
|
||||
@@ -911,11 +923,14 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
private async _subscribeEvents() {
|
||||
if (this._unsubscribeEvents) {
|
||||
return;
|
||||
}
|
||||
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
|
||||
(event) => this._stateChanged(event),
|
||||
"state_changed"
|
||||
);
|
||||
if (!this.isConnected || this._mode !== "live") {
|
||||
if (!this.isConnected || this._mode !== "live" || this._unsubscribeEvents) {
|
||||
unsubscribe();
|
||||
return;
|
||||
}
|
||||
@@ -1092,9 +1107,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
};
|
||||
|
||||
private _goBack(): void {
|
||||
if (this._mode === "live") {
|
||||
applyScene(this.hass, this._storedStates);
|
||||
}
|
||||
afterNextRender(() => goBack("/config/scene/dashboard"));
|
||||
}
|
||||
|
||||
@@ -1119,9 +1131,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
return;
|
||||
}
|
||||
await deleteScene(this.hass, this.sceneId);
|
||||
if (this._mode === "live") {
|
||||
applyScene(this.hass, this._storedStates);
|
||||
}
|
||||
goBack("/config/scene/dashboard");
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
mdiImageFilterCenterFocus,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { LatLngTuple } from "leaflet";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -29,6 +28,7 @@ import type {
|
||||
HaMapPaths,
|
||||
MapCardMarkerLabelMode,
|
||||
} from "../../../components/map/ha-map";
|
||||
import type { MapLatLng } from "../../../common/map/map-engine";
|
||||
import type { HistoryStates } from "../../../data/history";
|
||||
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -528,7 +528,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
continue;
|
||||
}
|
||||
const p = {} as HaMapPathPoint;
|
||||
p.point = [latitude, longitude] as LatLngTuple;
|
||||
p.point = [latitude, longitude] as MapLatLng;
|
||||
p.timestamp = new Date(entityState.lu * 1000);
|
||||
points.push(p);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,15 @@ describe("createBaseLayer", () => {
|
||||
expect(leaflet.tileLayer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders raster tiles when asked to, even with WebGL2", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
|
||||
await createBaseLayer(leaflet, map, false, TOKEN, true);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
expect(maplibreGL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to raster tiles when the style cannot be fetched", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -0,0 +1,789 @@
|
||||
import type { LayerSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONTEXT_RESTORE_GRACE } from "../../../src/common/map/base-layer";
|
||||
import { MapLibreMapEngine } from "../../../src/common/map/engines/maplibre-map-engine";
|
||||
import type {
|
||||
MapEngineEvents,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
} from "../../../src/common/map/map-engine";
|
||||
|
||||
// The MapLibre engine cannot run in jsdom (no WebGL2), so maplibre-gl is
|
||||
// replaced by a fake map that models the parts the engine depends on: the
|
||||
// style lifecycle (a swap leaves the style unloaded until it loads), sources
|
||||
// and layers, screen projection for clustering, and WebGL context events.
|
||||
|
||||
type Listener = (ev?: any) => void;
|
||||
|
||||
const fakes = vi.hoisted(() => {
|
||||
const baseStyle = (): StyleSpecification => ({
|
||||
version: 8,
|
||||
sources: {},
|
||||
layers: [
|
||||
{ id: "land", type: "background" },
|
||||
{ id: "labels", type: "symbol", source: "osm", "source-layer": "place" },
|
||||
],
|
||||
});
|
||||
|
||||
class FakeMarker {
|
||||
static all: FakeMarker[] = [];
|
||||
|
||||
lngLat?: [number, number];
|
||||
|
||||
options: any;
|
||||
|
||||
onMap = false;
|
||||
|
||||
constructor(options: any) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
setLngLat(lngLat: [number, number]) {
|
||||
this.lngLat = lngLat;
|
||||
return this;
|
||||
}
|
||||
|
||||
getLngLat() {
|
||||
return { lng: this.lngLat![0], lat: this.lngLat![1] };
|
||||
}
|
||||
|
||||
addTo() {
|
||||
this.onMap = true;
|
||||
FakeMarker.all.push(this);
|
||||
document.body.appendChild(this.options.element);
|
||||
return this;
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.onMap = false;
|
||||
const index = FakeMarker.all.indexOf(this);
|
||||
if (index !== -1) {
|
||||
FakeMarker.all.splice(index, 1);
|
||||
}
|
||||
(this.options.element as HTMLElement).remove();
|
||||
return this;
|
||||
}
|
||||
|
||||
getElement() {
|
||||
return this.options.element as HTMLElement;
|
||||
}
|
||||
|
||||
on() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class FakePopup {
|
||||
setLngLat() {
|
||||
return this;
|
||||
}
|
||||
|
||||
setHTML() {
|
||||
return this;
|
||||
}
|
||||
|
||||
addTo() {
|
||||
return this;
|
||||
}
|
||||
|
||||
remove() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeMap {
|
||||
static instances: FakeMap[] = [];
|
||||
|
||||
/** Whether new maps start with their style loaded */
|
||||
static startLoaded = true;
|
||||
|
||||
/** Makes the next setStyle call throw, like an invalid style would */
|
||||
static failNextSetStyle = false;
|
||||
|
||||
style: StyleSpecification;
|
||||
|
||||
styleLoaded: boolean;
|
||||
|
||||
listeners: Record<string, { layer?: string; handler: Listener }[]> = {};
|
||||
|
||||
fitBounds = vi.fn();
|
||||
|
||||
easeTo = vi.fn();
|
||||
|
||||
jumpTo = vi.fn();
|
||||
|
||||
resize = vi.fn(() => {
|
||||
this.fire("movestart");
|
||||
this.fire("move");
|
||||
this.fire("moveend");
|
||||
});
|
||||
|
||||
remove = vi.fn();
|
||||
|
||||
addControl = vi.fn();
|
||||
|
||||
removeControl = vi.fn();
|
||||
|
||||
setStyle = vi.fn(
|
||||
(
|
||||
style: StyleSpecification,
|
||||
options?: {
|
||||
transformStyle?: (
|
||||
previous: StyleSpecification | undefined,
|
||||
next: StyleSpecification
|
||||
) => StyleSpecification;
|
||||
}
|
||||
) => {
|
||||
if (FakeMap.failNextSetStyle) {
|
||||
FakeMap.failNextSetStyle = false;
|
||||
throw new Error("Invalid style");
|
||||
}
|
||||
// MapLibre hands over the current style only once it has loaded
|
||||
this.style = options?.transformStyle
|
||||
? options.transformStyle(
|
||||
this.styleLoaded ? this.style : undefined,
|
||||
style
|
||||
)
|
||||
: style;
|
||||
// A rebuilt style is unloaded until the next frame
|
||||
this.styleLoaded = false;
|
||||
}
|
||||
);
|
||||
|
||||
touchZoomRotate = { disableRotation: vi.fn() };
|
||||
|
||||
keyboard = { disableRotation: vi.fn() };
|
||||
|
||||
scrollZoom = { setWheelZoomRate: vi.fn() };
|
||||
|
||||
private _container: HTMLElement;
|
||||
|
||||
constructor(options: {
|
||||
container: HTMLElement;
|
||||
style: StyleSpecification;
|
||||
}) {
|
||||
this._container = options.container;
|
||||
this.style = options.style;
|
||||
this.styleLoaded = FakeMap.startLoaded;
|
||||
FakeMap.instances.push(this);
|
||||
}
|
||||
|
||||
/** The style finished loading, as MapLibre reports a frame later */
|
||||
loadStyle() {
|
||||
this.styleLoaded = true;
|
||||
this.fire("style.load");
|
||||
this.fire("styledata");
|
||||
}
|
||||
|
||||
on(type: string, layerOrHandler: string | Listener, handler?: Listener) {
|
||||
const entry =
|
||||
typeof layerOrHandler === "string"
|
||||
? { layer: layerOrHandler, handler: handler! }
|
||||
: { handler: layerOrHandler };
|
||||
(this.listeners[type] ??= []).push(entry);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(type: string, handler: Listener) {
|
||||
const wrapped: Listener = (ev) => {
|
||||
this.off(type, wrapped);
|
||||
handler(ev);
|
||||
};
|
||||
return this.on(type, wrapped);
|
||||
}
|
||||
|
||||
off(type: string, layerOrHandler: string | Listener, handler?: Listener) {
|
||||
const target =
|
||||
typeof layerOrHandler === "string" ? handler : layerOrHandler;
|
||||
this.listeners[type] = (this.listeners[type] ?? []).filter(
|
||||
(entry) => entry.handler !== target
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
fire(type: string, ev: any = {}) {
|
||||
[...(this.listeners[type] ?? [])].forEach((entry) => entry.handler(ev));
|
||||
}
|
||||
|
||||
isStyleLoaded() {
|
||||
return this.styleLoaded;
|
||||
}
|
||||
|
||||
// Like MapLibre: Style.serialize() returns undefined until the style has
|
||||
// loaded, and getStyle() is that serialization
|
||||
getStyle() {
|
||||
return this.styleLoaded ? this.style : undefined;
|
||||
}
|
||||
|
||||
private _assertLoaded() {
|
||||
if (!this.styleLoaded) {
|
||||
throw new Error("Style is not done loading");
|
||||
}
|
||||
}
|
||||
|
||||
addSource(id: string, source: any) {
|
||||
this._assertLoaded();
|
||||
this.style.sources[id] = source;
|
||||
}
|
||||
|
||||
getSource(id: string) {
|
||||
return this.style.sources[id];
|
||||
}
|
||||
|
||||
removeSource(id: string) {
|
||||
this._assertLoaded();
|
||||
delete this.style.sources[id];
|
||||
}
|
||||
|
||||
addLayer(layer: LayerSpecification, beforeId?: string) {
|
||||
this._assertLoaded();
|
||||
const index = beforeId
|
||||
? this.style.layers.findIndex((candidate) => candidate.id === beforeId)
|
||||
: -1;
|
||||
this.style.layers.splice(
|
||||
index === -1 ? this.style.layers.length : index,
|
||||
0,
|
||||
layer
|
||||
);
|
||||
}
|
||||
|
||||
getLayer(id: string) {
|
||||
return this.style.layers.find((layer) => layer.id === id);
|
||||
}
|
||||
|
||||
removeLayer(id: string) {
|
||||
this._assertLoaded();
|
||||
this.style.layers = this.style.layers.filter((layer) => layer.id !== id);
|
||||
}
|
||||
|
||||
// 0.001 degrees is 10 screen pixels
|
||||
project([lng, lat]: [number, number]) {
|
||||
return { x: lng * 10000, y: -lat * 10000 };
|
||||
}
|
||||
|
||||
zoom = 12;
|
||||
|
||||
getZoom() {
|
||||
return this.zoom;
|
||||
}
|
||||
|
||||
getMaxZoom() {
|
||||
return 19;
|
||||
}
|
||||
|
||||
getContainer() {
|
||||
return this._container;
|
||||
}
|
||||
|
||||
getCanvas() {
|
||||
return { style: {} as CSSStyleDeclaration };
|
||||
}
|
||||
|
||||
getBounds() {
|
||||
return { contains: () => true };
|
||||
}
|
||||
|
||||
queryRenderedFeatures() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return { baseStyle, FakeMap, FakeMarker, FakePopup };
|
||||
});
|
||||
|
||||
vi.mock("maplibre-gl", () => ({
|
||||
default: {
|
||||
Map: fakes.FakeMap,
|
||||
Marker: fakes.FakeMarker,
|
||||
Popup: fakes.FakePopup,
|
||||
NavigationControl: vi.fn(),
|
||||
ScaleControl: vi.fn(),
|
||||
setRTLTextPlugin: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const loadStyle = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
loadStyle,
|
||||
ensureRTLTextPlugin: vi.fn(),
|
||||
}));
|
||||
|
||||
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
|
||||
const refreshMapTilesToken = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../src/data/map_tiles", () => ({
|
||||
MAP_TILES_PATH: "/api/map_tiles",
|
||||
mapTilesUrl: (path: string) => path,
|
||||
withMapTilesToken: (url: string) => url,
|
||||
refreshMapTilesToken,
|
||||
subscribeMapTilesToken: (listener: (token: string) => void) => {
|
||||
tokenListeners.add(listener);
|
||||
return () => tokenListeners.delete(listener);
|
||||
},
|
||||
}));
|
||||
|
||||
const fakeMap = fakes.FakeMap;
|
||||
const fakeMarker = fakes.FakeMarker;
|
||||
const { baseStyle } = fakes;
|
||||
|
||||
const flush = () =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
const createEngine = async (events: Partial<MapEngineEvents> = {}) => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const engine = new MapLibreMapEngine();
|
||||
const ready = engine.init(container, {
|
||||
center: [52, 4],
|
||||
zoom: 13,
|
||||
darkMode: false,
|
||||
zoomControlPosition: "topleft",
|
||||
events,
|
||||
});
|
||||
await vi.waitUntil(() => fakeMap.instances.length > 0);
|
||||
const map = fakeMap.instances[fakeMap.instances.length - 1];
|
||||
return { engine, map, ready };
|
||||
};
|
||||
|
||||
const customSourceIds = (map: InstanceType<typeof fakeMap>) =>
|
||||
Object.keys(map.style.sources).filter((id) => id.startsWith("ha-"));
|
||||
|
||||
const layerIds = (map: InstanceType<typeof fakeMap>) =>
|
||||
map.style.layers.map((layer) => layer.id);
|
||||
|
||||
describe("MapLibreMapEngine", () => {
|
||||
beforeEach(() => {
|
||||
fakeMap.instances.length = 0;
|
||||
fakeMap.startLoaded = true;
|
||||
fakeMap.failNextSetStyle = false;
|
||||
fakeMarker.all.length = 0;
|
||||
tokenListeners.clear();
|
||||
refreshMapTilesToken.mockClear();
|
||||
loadStyle.mockReset();
|
||||
loadStyle.mockImplementation(async () => baseStyle());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("style lifecycle", () => {
|
||||
it("waits for the initial style before resolving init", async () => {
|
||||
fakeMap.startLoaded = false;
|
||||
const { map, ready } = await createEngine();
|
||||
let resolved = false;
|
||||
ready.then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(resolved).toBe(false);
|
||||
map.loadStyle();
|
||||
await ready;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("settles a pending init when destroyed", async () => {
|
||||
fakeMap.startLoaded = false;
|
||||
const { engine, ready } = await createEngine();
|
||||
let resolved = false;
|
||||
ready.then(() => {
|
||||
resolved = true;
|
||||
});
|
||||
|
||||
await flush();
|
||||
expect(resolved).toBe(false);
|
||||
engine.destroy();
|
||||
await ready;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it("queues sources and layers while a swapped style is loading", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
engine.setDarkMode(true);
|
||||
await flush();
|
||||
expect(map.setStyle).toHaveBeenCalledOnce();
|
||||
expect(map.isStyleLoaded()).toBe(false);
|
||||
|
||||
// Adding to an unloaded style would throw; the engine must hold it
|
||||
const circle = engine.addCircle([52, 4], { radius: 100, color: "red" });
|
||||
expect(customSourceIds(map)).toHaveLength(0);
|
||||
|
||||
map.loadStyle();
|
||||
expect(customSourceIds(map)).toHaveLength(1);
|
||||
expect(layerIds(map)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringMatching(/-fill$/),
|
||||
expect.stringMatching(/-line$/),
|
||||
])
|
||||
);
|
||||
|
||||
circle.remove();
|
||||
expect(customSourceIds(map)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("carries its own sources and layers over a style swap, under the labels", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
engine.addCircle([52, 4], { radius: 100, color: "red" });
|
||||
const customLayers = layerIds(map).filter((id) => id.startsWith("ha-"));
|
||||
expect(customLayers).toHaveLength(2);
|
||||
|
||||
engine.setDarkMode(true);
|
||||
await flush();
|
||||
map.loadStyle();
|
||||
|
||||
// The new style is a fresh copy from loadStyle; the circle survived it
|
||||
expect(customSourceIds(map)).toHaveLength(1);
|
||||
const ids = layerIds(map);
|
||||
for (const id of customLayers) {
|
||||
expect(ids.indexOf(id)).toBeGreaterThan(ids.indexOf("land"));
|
||||
expect(ids.indexOf(id)).toBeLessThan(ids.indexOf("labels"));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps its sources and layers when a swap arrives while another is loading", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
engine.addCircle([52, 4], { radius: 100, color: "red" });
|
||||
|
||||
engine.setDarkMode(true);
|
||||
await flush();
|
||||
// The dark style has not loaded, so this swap sees no previous style
|
||||
engine.setDarkMode(false);
|
||||
await flush();
|
||||
expect(map.setStyle).toHaveBeenCalledTimes(2);
|
||||
|
||||
map.loadStyle();
|
||||
expect(customSourceIds(map)).toHaveLength(1);
|
||||
expect(layerIds(map).filter((id) => id.startsWith("ha-"))).toHaveLength(
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
it("does not swap twice for the same mode, and retries after a failed swap", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
expect(loadStyle).toHaveBeenCalledTimes(1);
|
||||
|
||||
engine.setDarkMode(false);
|
||||
await flush();
|
||||
expect(loadStyle).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The dark style fails to fetch: the map stays light and dark can be
|
||||
// requested again
|
||||
loadStyle.mockRejectedValueOnce(new Error("offline"));
|
||||
engine.setDarkMode(true);
|
||||
await flush();
|
||||
expect(map.setStyle).not.toHaveBeenCalled();
|
||||
|
||||
engine.setDarkMode(true);
|
||||
await flush();
|
||||
expect(loadStyle).toHaveBeenCalledTimes(3);
|
||||
expect(map.setStyle).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not record a mode whose style could not be applied", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
fakeMap.failNextSetStyle = true;
|
||||
engine.setDarkMode(true);
|
||||
await flush();
|
||||
expect(map.setStyle).toHaveBeenCalledOnce();
|
||||
expect(map.isStyleLoaded()).toBe(true);
|
||||
|
||||
// Dark was not applied, so asking for it again applies it
|
||||
engine.setDarkMode(true);
|
||||
await flush();
|
||||
expect(map.setStyle).toHaveBeenCalledTimes(2);
|
||||
expect(map.isStyleLoaded()).toBe(false);
|
||||
});
|
||||
|
||||
it("applies the style again once a refused token is replaced", async () => {
|
||||
const { map, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
map.fire("error", { error: { status: 403 } });
|
||||
expect(refreshMapTilesToken).toHaveBeenCalledOnce();
|
||||
expect(loadStyle).toHaveBeenCalledTimes(1);
|
||||
|
||||
tokenListeners.forEach((listener) => listener("new-token"));
|
||||
await flush();
|
||||
expect(loadStyle).toHaveBeenCalledTimes(2);
|
||||
expect(map.setStyle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebGL context loss", () => {
|
||||
it("reports a fatal failure when a lost context is not restored in time", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fatal = vi.fn();
|
||||
const { map, ready } = await createEngine({ fatal });
|
||||
await ready;
|
||||
|
||||
map.fire("webglcontextlost");
|
||||
vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE - 1);
|
||||
expect(fatal).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(fatal).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stays on the engine when the context comes back within the grace period", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fatal = vi.fn();
|
||||
const { map, ready } = await createEngine({ fatal });
|
||||
await ready;
|
||||
|
||||
map.fire("webglcontextlost");
|
||||
vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE / 2);
|
||||
map.fire("webglcontextrestored");
|
||||
vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE);
|
||||
expect(fatal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not report a failure after being destroyed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fatal = vi.fn();
|
||||
const { engine, map, ready } = await createEngine({ fatal });
|
||||
await ready;
|
||||
|
||||
map.fire("webglcontextlost");
|
||||
engine.destroy();
|
||||
vi.advanceTimersByTime(CONTEXT_RESTORE_GRACE);
|
||||
expect(fatal).not.toHaveBeenCalled();
|
||||
expect(map.remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("camera events", () => {
|
||||
it("does not report a resize as the map moving", async () => {
|
||||
const moveStart = vi.fn();
|
||||
const { engine, map, ready } = await createEngine({ moveStart });
|
||||
await ready;
|
||||
|
||||
engine.invalidateSize();
|
||||
expect(map.resize).toHaveBeenCalledOnce();
|
||||
expect(moveStart).not.toHaveBeenCalled();
|
||||
|
||||
map.fire("movestart");
|
||||
expect(moveStart).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("markers", () => {
|
||||
it("lets input through non-interactive markers", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
const interactive = document.createElement("div");
|
||||
engine.addMarker(interactive, [52, 4], { size: [36, 36] });
|
||||
const passive = document.createElement("div");
|
||||
engine.addMarker(passive, [52, 4], {
|
||||
size: [36, 36],
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
expect(interactive.tabIndex).toBe(0);
|
||||
expect(interactive.style.pointerEvents).toBe("");
|
||||
expect(passive.style.pointerEvents).toBe("none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clustering", () => {
|
||||
const addMarker = (
|
||||
engine: MapLibreMapEngine,
|
||||
location: MapLatLng,
|
||||
clusterData?: unknown
|
||||
) =>
|
||||
engine.addMarker(document.createElement("div"), location, {
|
||||
size: [48, 48],
|
||||
cluster: true,
|
||||
clusterData,
|
||||
});
|
||||
|
||||
const iconBuilder = vi.fn((members: MapMarkerHandle[]) => ({
|
||||
element: Object.assign(document.createElement("div"), {
|
||||
textContent: String(members.length),
|
||||
}),
|
||||
size: [40, 40] as [number, number],
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
iconBuilder.mockClear();
|
||||
});
|
||||
|
||||
it("shows clusterable markers only once clustering is configured", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
addMarker(engine, [52, 4]);
|
||||
expect(fakeMarker.all).toHaveLength(0);
|
||||
|
||||
engine.setClustering(null);
|
||||
expect(fakeMarker.all).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("groups markers within the radius on screen and leaves the rest alone", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
// 20px apart, then 100px further
|
||||
addMarker(engine, [52, 4.0]);
|
||||
addMarker(engine, [52, 4.002]);
|
||||
addMarker(engine, [52, 4.012]);
|
||||
engine.setClustering({ radius: 40, iconBuilder });
|
||||
|
||||
expect(iconBuilder).toHaveBeenCalledOnce();
|
||||
expect(iconBuilder.mock.calls[0][0]).toHaveLength(2);
|
||||
// One cluster icon and one lone marker
|
||||
expect(fakeMarker.all).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("groups markers sharing a key while they fit the group radius", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
// 100px apart: too far for proximity, close enough for the zone group
|
||||
addMarker(engine, [52, 4.0], { zone: "home" });
|
||||
addMarker(engine, [52, 4.01], { zone: "home" });
|
||||
// Same distance, no key: clusters by proximity, so stays alone
|
||||
addMarker(engine, [52.01, 4.0]);
|
||||
engine.setClustering({
|
||||
radius: 40,
|
||||
groupRadius: 160,
|
||||
groupKey: (marker) =>
|
||||
(marker.clusterData as { zone?: string } | undefined)?.zone,
|
||||
iconBuilder,
|
||||
});
|
||||
|
||||
expect(iconBuilder).toHaveBeenCalledOnce();
|
||||
expect(iconBuilder.mock.calls[0][0]).toHaveLength(2);
|
||||
expect(fakeMarker.all).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to proximity when a keyed group is spread too wide", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
addMarker(engine, [52, 4.0], { zone: "home" });
|
||||
addMarker(engine, [52, 4.05], { zone: "home" });
|
||||
engine.setClustering({
|
||||
radius: 40,
|
||||
groupRadius: 160,
|
||||
groupKey: (marker) =>
|
||||
(marker.clusterData as { zone?: string } | undefined)?.zone,
|
||||
iconBuilder,
|
||||
});
|
||||
|
||||
expect(iconBuilder).not.toHaveBeenCalled();
|
||||
expect(fakeMarker.all).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("drops removed markers from their cluster on refresh", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
addMarker(engine, [52, 4.0]);
|
||||
const second = addMarker(engine, [52, 4.002]);
|
||||
engine.setClustering({ radius: 40, iconBuilder });
|
||||
expect(iconBuilder.mock.calls[0][0]).toHaveLength(2);
|
||||
|
||||
second.remove();
|
||||
engine.refreshClusters();
|
||||
// A cluster of one is the marker itself
|
||||
expect(iconBuilder).toHaveBeenCalledOnce();
|
||||
expect(fakeMarker.all).toHaveLength(1);
|
||||
});
|
||||
|
||||
const openBubble = () => {
|
||||
expect(fakeMarker.all).toHaveLength(1);
|
||||
const [bubble] = fakeMarker.all;
|
||||
expect(bubble.options.anchor).toBe("bottom");
|
||||
return (bubble.options.element as HTMLElement).querySelectorAll(
|
||||
".cluster-open-members > *"
|
||||
);
|
||||
};
|
||||
|
||||
it("opens a cluster whose members share a spot in a bubble", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
const elements = [
|
||||
addMarker(engine, [52, 4]),
|
||||
addMarker(engine, [52, 4]),
|
||||
addMarker(engine, [52, 4]),
|
||||
];
|
||||
engine.setClustering({ radius: 40, iconBuilder });
|
||||
expect(fakeMarker.all).toHaveLength(1);
|
||||
|
||||
(iconBuilder.mock.results[0].value.element as HTMLElement).click();
|
||||
// Zooming would change nothing; the members themselves sit in a
|
||||
// bubble pointing at the spot
|
||||
expect(map.fitBounds).not.toHaveBeenCalled();
|
||||
expect(openBubble()).toHaveLength(elements.length);
|
||||
|
||||
// A refresh keeps it open; regrouping after the map moves closes it
|
||||
engine.refreshClusters();
|
||||
expect(openBubble()).toHaveLength(elements.length);
|
||||
engine.setClustering({ radius: 40, iconBuilder });
|
||||
expect(iconBuilder).toHaveBeenCalledTimes(2);
|
||||
expect(fakeMarker.all).toHaveLength(1);
|
||||
expect(fakeMarker.all[0].options.anchor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves keyboard focus into an opened cluster and back to its icon", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
const first = document.createElement("div");
|
||||
engine.addMarker(first, [52, 4], { size: [48, 48], cluster: true });
|
||||
addMarker(engine, [52, 4]);
|
||||
engine.setClustering({ radius: 40, iconBuilder });
|
||||
const icon = iconBuilder.mock.results[0].value.element as HTMLElement;
|
||||
|
||||
icon.focus();
|
||||
icon.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
// The bubble closes on the next regroup; focus lands on the new icon
|
||||
engine.setClustering({ radius: 40, iconBuilder });
|
||||
const reopened = iconBuilder.mock.results[1].value.element as HTMLElement;
|
||||
expect(document.activeElement).toBe(reopened);
|
||||
});
|
||||
|
||||
it("opens a cluster at maximum zoom in a bubble", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
map.zoom = map.getMaxZoom();
|
||||
|
||||
addMarker(engine, [52, 4.0]);
|
||||
addMarker(engine, [52, 4.002]);
|
||||
engine.setClustering({ radius: 40, iconBuilder });
|
||||
|
||||
(iconBuilder.mock.results[0].value.element as HTMLElement).click();
|
||||
expect(map.fitBounds).not.toHaveBeenCalled();
|
||||
expect(openBubble()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("zooms in on a cluster's members when it is activated", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
|
||||
addMarker(engine, [52, 4.0]);
|
||||
addMarker(engine, [52, 4.002]);
|
||||
engine.setClustering({ radius: 40, iconBuilder });
|
||||
const icon = iconBuilder.mock.results[0].value.element as HTMLElement;
|
||||
|
||||
icon.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
|
||||
expect(map.fitBounds).toHaveBeenCalledOnce();
|
||||
icon.click();
|
||||
expect(map.fitBounds).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
MapEngine,
|
||||
MapEngineOptions,
|
||||
MapMarkerHandle,
|
||||
} from "../../../src/common/map/map-engine";
|
||||
import "../../../src/components/map/ha-map";
|
||||
import type { HaMap } from "../../../src/components/map/ha-map";
|
||||
|
||||
// ha-map picks its engine at runtime: MapLibre GL where WebGL2 is available,
|
||||
// Leaflet otherwise or after MapLibre fails. jsdom has no WebGL2 and cannot
|
||||
// run MapLibre, so the probe is stubbed and the MapLibre engine is replaced
|
||||
// by a fake that records what ha-map asks of it. The Leaflet fallback is
|
||||
// real, as in the ha-map fit tests.
|
||||
|
||||
const webgl2 = vi.hoisted(() => ({ supported: true }));
|
||||
|
||||
vi.mock("../../../src/common/map/base-layer", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
supportsWebGL2: () => webgl2.supported,
|
||||
}));
|
||||
|
||||
const fakeEngine = vi.hoisted(() => {
|
||||
class FakeMapLibreEngine implements MapEngine {
|
||||
static instances: FakeMapLibreEngine[] = [];
|
||||
|
||||
static failInit = false;
|
||||
|
||||
/** When set, init waits for it, so events can be fired mid-setup */
|
||||
static initGate?: Promise<void>;
|
||||
|
||||
options?: MapEngineOptions;
|
||||
|
||||
constructor() {
|
||||
FakeMapLibreEngine.instances.push(this);
|
||||
}
|
||||
|
||||
/** Settles a pending init, as the real engine's destroy does */
|
||||
private _settleInit?: () => void;
|
||||
|
||||
init = vi.fn(async (_container: HTMLElement, options: MapEngineOptions) => {
|
||||
this.options = options;
|
||||
await Promise.race([
|
||||
FakeMapLibreEngine.initGate,
|
||||
new Promise<void>((resolve) => {
|
||||
this._settleInit = resolve;
|
||||
}),
|
||||
]);
|
||||
if (FakeMapLibreEngine.failInit) {
|
||||
throw new Error("WebGL context refused");
|
||||
}
|
||||
});
|
||||
|
||||
destroy = vi.fn(() => {
|
||||
this._settleInit?.();
|
||||
});
|
||||
|
||||
invalidateSize = vi.fn();
|
||||
|
||||
hasUsableSize = () => true;
|
||||
|
||||
setDarkMode = vi.fn();
|
||||
|
||||
setZoomControlPosition = vi.fn();
|
||||
|
||||
setScaleRuler = vi.fn();
|
||||
|
||||
setView = vi.fn();
|
||||
|
||||
setZoom = vi.fn();
|
||||
|
||||
fitBounds = vi.fn();
|
||||
|
||||
panTo = vi.fn();
|
||||
|
||||
containsLocation = () => true;
|
||||
|
||||
addMarker = vi.fn((_element, location, options): MapMarkerHandle => ({
|
||||
location,
|
||||
clusterData: options.clusterData,
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
|
||||
addCircle = vi.fn(() => ({ remove: vi.fn() }));
|
||||
|
||||
addPath = vi.fn(() => ({ remove: vi.fn() }));
|
||||
|
||||
setClustering = vi.fn();
|
||||
|
||||
refreshClusters = vi.fn();
|
||||
}
|
||||
return FakeMapLibreEngine;
|
||||
});
|
||||
|
||||
vi.mock("../../../src/common/map/engines/maplibre-map-engine", () => ({
|
||||
MapLibreMapEngine: fakeEngine,
|
||||
}));
|
||||
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn();
|
||||
|
||||
unobserve = vi.fn();
|
||||
|
||||
disconnect = vi.fn();
|
||||
}
|
||||
|
||||
const STATES = {
|
||||
"device_tracker.paulus": {
|
||||
entity_id: "device_tracker.paulus",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
friendly_name: "Paulus",
|
||||
latitude: 52.372,
|
||||
longitude: 4.89,
|
||||
},
|
||||
context: { id: "1", user_id: null, parent_id: null },
|
||||
last_changed: "2026-01-01T00:00:00Z",
|
||||
last_updated: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
"device_tracker.anne_therese": {
|
||||
entity_id: "device_tracker.anne_therese",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
friendly_name: "Anne Therese",
|
||||
latitude: 52.377,
|
||||
longitude: 4.895,
|
||||
},
|
||||
context: { id: "2", user_id: null, parent_id: null },
|
||||
last_changed: "2026-01-01T00:00:00Z",
|
||||
last_updated: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
} as unknown as HassEntities;
|
||||
|
||||
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
|
||||
const isLoaded = (el: HaMap) => (el as any)._loaded as boolean;
|
||||
const entityHandles = (el: HaMap) =>
|
||||
(el as any)._entityHandles as MapMarkerHandle[];
|
||||
|
||||
const createMap = async (): Promise<HaMap> => {
|
||||
const el = document.createElement("ha-map");
|
||||
el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"];
|
||||
el.clusterMarkers = false;
|
||||
(el as any)._states = STATES;
|
||||
(el as any)._config = {
|
||||
config: { latitude: 52.3731339, longitude: 4.8903147 },
|
||||
};
|
||||
document.body.appendChild(el);
|
||||
await vi.waitUntil(() => isLoaded(el));
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
};
|
||||
|
||||
describe("ha-map engine selection", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
webgl2.supported = true;
|
||||
fakeEngine.failInit = false;
|
||||
fakeEngine.initGate = undefined;
|
||||
fakeEngine.instances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("runs on the MapLibre engine when WebGL2 is available", async () => {
|
||||
const el = await createMap();
|
||||
|
||||
expect(fakeEngine.instances).toHaveLength(1);
|
||||
const engine = fakeEngine.instances[0];
|
||||
expect(engine.init).toHaveBeenCalledOnce();
|
||||
expect(engine.options?.rasterOnly).toBeFalsy();
|
||||
expect(leafletMap(el)).toBeUndefined();
|
||||
// Entities are drawn through the engine
|
||||
expect(engine.addMarker).toHaveBeenCalledTimes(2);
|
||||
expect(entityHandles(el)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("runs on Leaflet when WebGL2 is not available", async () => {
|
||||
webgl2.supported = false;
|
||||
const el = await createMap();
|
||||
|
||||
expect(fakeEngine.instances).toHaveLength(0);
|
||||
expect(leafletMap(el)).toBeDefined();
|
||||
expect(entityHandles(el)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to Leaflet when the MapLibre engine cannot start", async () => {
|
||||
fakeEngine.failInit = true;
|
||||
const el = await createMap();
|
||||
|
||||
// MapLibre was tried once, torn down, then abandoned
|
||||
expect(fakeEngine.instances).toHaveLength(1);
|
||||
expect(fakeEngine.instances[0].init).toHaveBeenCalledOnce();
|
||||
expect(fakeEngine.instances[0].destroy).toHaveBeenCalledOnce();
|
||||
expect(leafletMap(el)).toBeDefined();
|
||||
// Entities are drawn on the fallback
|
||||
expect(entityHandles(el)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("falls back to Leaflet when the engine fails while still setting up", async () => {
|
||||
let openGate!: () => void;
|
||||
fakeEngine.initGate = new Promise<void>((resolve) => {
|
||||
openGate = resolve;
|
||||
});
|
||||
const el = document.createElement("ha-map");
|
||||
el.entities = ["device_tracker.paulus", "device_tracker.anne_therese"];
|
||||
el.clusterMarkers = false;
|
||||
(el as any)._states = STATES;
|
||||
(el as any)._config = {
|
||||
config: { latitude: 52.3731339, longitude: 4.8903147 },
|
||||
};
|
||||
document.body.appendChild(el);
|
||||
await vi.waitUntil(() => fakeEngine.instances[0]?.options);
|
||||
const engine = fakeEngine.instances[0];
|
||||
|
||||
// The context is lost before init has resolved, and init never would
|
||||
// resolve on its own: tearing the engine down is what settles it
|
||||
engine.options!.events.fatal!();
|
||||
expect(isLoaded(el)).toBe(false);
|
||||
|
||||
await vi.waitUntil(() => leafletMap(el) !== undefined && isLoaded(el));
|
||||
await el.updateComplete;
|
||||
// The failed engine was never installed, and the fallback drew the map
|
||||
expect(engine.destroy).toHaveBeenCalled();
|
||||
expect(fakeEngine.instances).toHaveLength(1);
|
||||
expect(entityHandles(el)).toHaveLength(2);
|
||||
openGate();
|
||||
});
|
||||
|
||||
it("tears down an engine still setting up when disconnected, and sets up again on reconnect", async () => {
|
||||
let openGate!: () => void;
|
||||
fakeEngine.initGate = new Promise<void>((resolve) => {
|
||||
openGate = resolve;
|
||||
});
|
||||
const el = document.createElement("ha-map");
|
||||
el.entities = ["device_tracker.paulus"];
|
||||
el.clusterMarkers = false;
|
||||
(el as any)._states = STATES;
|
||||
(el as any)._config = {
|
||||
config: { latitude: 52.3731339, longitude: 4.8903147 },
|
||||
};
|
||||
document.body.appendChild(el);
|
||||
await vi.waitUntil(() => fakeEngine.instances[0]?.options);
|
||||
|
||||
el.remove();
|
||||
expect(fakeEngine.instances[0].destroy).toHaveBeenCalledOnce();
|
||||
openGate();
|
||||
fakeEngine.initGate = undefined;
|
||||
|
||||
document.body.appendChild(el);
|
||||
await vi.waitUntil(() => isLoaded(el));
|
||||
// The abandoned engine was not installed; a fresh one was set up
|
||||
expect(fakeEngine.instances).toHaveLength(2);
|
||||
expect(fakeEngine.instances[1].init).toHaveBeenCalledOnce();
|
||||
expect(fakeEngine.instances[1].destroy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rebuilds on Leaflet after a fatal engine failure", async () => {
|
||||
const el = await createMap();
|
||||
const engine = fakeEngine.instances[0];
|
||||
const handlesBefore = entityHandles(el);
|
||||
|
||||
engine.options!.events.fatal!();
|
||||
await vi.waitUntil(() => leafletMap(el) !== undefined && isLoaded(el));
|
||||
await el.updateComplete;
|
||||
|
||||
expect(engine.destroy).toHaveBeenCalledOnce();
|
||||
// Only one MapLibre attempt; the rebuild went straight to Leaflet
|
||||
expect(fakeEngine.instances).toHaveLength(1);
|
||||
// Entities are redrawn on the new engine, not carried over
|
||||
expect(entityHandles(el)).toHaveLength(2);
|
||||
expect(entityHandles(el)).not.toBe(handlesBefore);
|
||||
});
|
||||
});
|
||||
@@ -2430,12 +2430,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@codemirror/state@npm:6.7.2, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.7.0":
|
||||
version: 6.7.2
|
||||
resolution: "@codemirror/state@npm:6.7.2"
|
||||
"@codemirror/state@npm:6.7.3, @codemirror/state@npm:^6.0.0, @codemirror/state@npm:^6.7.0":
|
||||
version: 6.7.3
|
||||
resolution: "@codemirror/state@npm:6.7.3"
|
||||
dependencies:
|
||||
"@marijn/find-cluster-break": "npm:^1.0.0"
|
||||
checksum: 10/363a6217e6d34ce406abca93ae7b87181e93e308be5ee037bec911b9d8c7bf6559de7e85620460bc1b7c1a590c05d75bc64e3b09d76fd82110ab829237e3fde2
|
||||
checksum: 10/04e6b758d5d45e6e6b343d9f1916d0d1f7577c7c5842a81354ebf2412f160235ee736d0d2258241e3a8ba2457276c5cc8793fa385cff0d6d7032d81991652eba
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -10350,7 +10350,7 @@ __metadata:
|
||||
"@codemirror/language": "npm:6.12.4"
|
||||
"@codemirror/lint": "npm:6.9.7"
|
||||
"@codemirror/search": "npm:6.7.2"
|
||||
"@codemirror/state": "npm:6.7.2"
|
||||
"@codemirror/state": "npm:6.7.3"
|
||||
"@codemirror/view": "npm:6.43.11"
|
||||
"@date-fns/tz": "npm:1.5.0"
|
||||
"@egjs/hammerjs": "npm:2.0.17"
|
||||
|
||||
Reference in New Issue
Block a user