mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 11:58:45 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad4fb6161 | ||
|
|
03b01e3b05 | ||
|
|
510ba20b59 | ||
|
|
50b80585f3 | ||
|
|
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",
|
||||
|
||||
@@ -29,7 +29,7 @@ export const computeEntityEntryName = (
|
||||
fallbackStateObj?: HassEntity
|
||||
): string | undefined => {
|
||||
const name =
|
||||
entry.name ??
|
||||
entry.name ||
|
||||
("original_name" in entry && entry.original_name != null
|
||||
? String(entry.original_name)
|
||||
: undefined);
|
||||
|
||||
@@ -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,339 @@
|
||||
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";
|
||||
|
||||
/** 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;
|
||||
|
||||
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
|
||||
icon: this.Leaflet!.divIcon({
|
||||
html: element,
|
||||
iconSize: options.size,
|
||||
iconAnchor: options.anchor,
|
||||
className: "",
|
||||
}),
|
||||
interactive: options.interactive ?? true,
|
||||
keyboard: options.interactive ?? true,
|
||||
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,
|
||||
]);
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,931 @@
|
||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||
import type {
|
||||
IControl,
|
||||
LayerSpecification,
|
||||
Map as MapLibreMap,
|
||||
MapLayerMouseEvent,
|
||||
Marker as MapLibreMarker,
|
||||
StyleSpecification,
|
||||
} from "maplibre-gl";
|
||||
import type maplibregl from "maplibre-gl";
|
||||
import {
|
||||
CONTEXT_RESTORE_GRACE,
|
||||
ensureRTLTextPlugin,
|
||||
loadStyle,
|
||||
MAP_MAX_ZOOM,
|
||||
MAP_MIN_ZOOM,
|
||||
RECOVERY_THROTTLE,
|
||||
VECTOR_STYLES,
|
||||
} from "../base-layer";
|
||||
import {
|
||||
refreshMapTilesToken,
|
||||
subscribeMapTilesToken,
|
||||
withMapTilesToken,
|
||||
} from "../../../data/map_tiles";
|
||||
import { isTouch } from "../../../util/is_touch";
|
||||
import type {
|
||||
MapCircleOptions,
|
||||
MapClusterOptions,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapEngineEvents,
|
||||
MapEngineOptions,
|
||||
MapFitOptions,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapMarkerOptions,
|
||||
MapPath,
|
||||
} from "../map-engine";
|
||||
|
||||
type MapLibreModule = typeof maplibregl;
|
||||
|
||||
// MapLibre zoom is one level below Leaflet's, which the interface uses
|
||||
const ZOOM_OFFSET = 1;
|
||||
|
||||
// Marks the sources and layers this engine owns, to carry them over style swaps
|
||||
const CUSTOM_PREFIX = "ha-map-";
|
||||
|
||||
const POSITIONS: Record<
|
||||
MapControlPosition,
|
||||
"top-left" | "top-right" | "bottom-left" | "bottom-right"
|
||||
> = {
|
||||
topleft: "top-left",
|
||||
topright: "top-right",
|
||||
bottomleft: "bottom-left",
|
||||
bottomright: "bottom-right",
|
||||
};
|
||||
|
||||
const MAPLIBRE_CSS_URL = "/static/map/maplibre-gl.css";
|
||||
|
||||
// Roughly one zoom level per two wheel notches (MapLibre's default is 1/450)
|
||||
const WHEEL_ZOOM_RATE = 1 / 200;
|
||||
|
||||
// Regroup clusters once continuous zooming settles, not on every wheel notch
|
||||
const CLUSTER_REBUILD_DELAY = 120;
|
||||
|
||||
// Without these MapLibre sets aria-label "Map marker" and role button
|
||||
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");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// A meter-radius circle as a polygon (spherical approximation)
|
||||
const circlePolygon = (
|
||||
center: MapLatLng,
|
||||
radiusMeters: number
|
||||
): Feature<Polygon> => {
|
||||
const steps = 64;
|
||||
const latOffset = radiusMeters / 111320;
|
||||
const lngOffset =
|
||||
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const theta = (2 * Math.PI * i) / steps;
|
||||
ring.push([
|
||||
center[1] + lngOffset * Math.sin(theta),
|
||||
center[0] + latOffset * Math.cos(theta),
|
||||
]);
|
||||
}
|
||||
return {
|
||||
type: "Feature",
|
||||
properties: {},
|
||||
geometry: { type: "Polygon", coordinates: [ring] },
|
||||
};
|
||||
};
|
||||
|
||||
interface ManagedMarker {
|
||||
element: HTMLElement;
|
||||
location: MapLatLng;
|
||||
options: MapMarkerOptions;
|
||||
handle: MapMarkerHandle;
|
||||
mlMarker?: MapLibreMarker;
|
||||
decoration?: MapItemHandle;
|
||||
removed?: boolean;
|
||||
}
|
||||
|
||||
interface ClusterGroup {
|
||||
members: ManagedMarker[];
|
||||
center: MapLatLng;
|
||||
iconMarker?: MapLibreMarker;
|
||||
}
|
||||
|
||||
/**
|
||||
* The MapLibre GL engine: native vector rendering, requires WebGL2. The host
|
||||
* falls back to Leaflet without it or after a fatal context loss.
|
||||
*/
|
||||
export class MapLibreMapEngine implements MapEngine {
|
||||
private _maplibre?: MapLibreModule;
|
||||
|
||||
private _map?: MapLibreMap;
|
||||
|
||||
private _events: Partial<MapEngineEvents> = {};
|
||||
|
||||
private _zoomControl?: IControl;
|
||||
|
||||
private _scaleControl?: IControl;
|
||||
|
||||
private _markers: ManagedMarker[] = [];
|
||||
|
||||
private _clusterOptions: MapClusterOptions | null = null;
|
||||
|
||||
private _clusterGroups: ClusterGroup[] = [];
|
||||
|
||||
private _clusterRebuildTimeout?: number;
|
||||
|
||||
private _idCounter = 0;
|
||||
|
||||
// Carried over style swaps, which replace all sources and layers
|
||||
private _customSources = new Set<string>();
|
||||
|
||||
private _customLayers = new Set<string>();
|
||||
|
||||
// The only custom layers that take clicks (hover tooltips)
|
||||
private _pathPointLayers = new Set<string>();
|
||||
|
||||
// A style swap the differ cannot apply rebuilds the style, which is unloaded
|
||||
// until the next frame and throws on mutation; layer work queues until then
|
||||
private _pendingStyleOps: (() => void)[] = [];
|
||||
|
||||
// A failed style request rolls back to the applied mode, not the requested one
|
||||
private _appliedDarkMode = false;
|
||||
|
||||
private _requestedDarkMode = false;
|
||||
|
||||
private _latestStyleRequest = 0;
|
||||
|
||||
private _contextLost = false;
|
||||
|
||||
private _fallbackTimeout?: number;
|
||||
|
||||
private _destroyed = false;
|
||||
|
||||
private _refused = false;
|
||||
|
||||
private _unsubscribeToken?: () => void;
|
||||
|
||||
private _resizing = false;
|
||||
|
||||
public async init(
|
||||
container: HTMLElement,
|
||||
options: MapEngineOptions
|
||||
): Promise<void> {
|
||||
if (options.rasterOnly) {
|
||||
throw new Error("The MapLibre engine cannot render without WebGL");
|
||||
}
|
||||
const maplibre = (await import("maplibre-gl")).default;
|
||||
this._maplibre = maplibre;
|
||||
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
|
||||
|
||||
// MapLibre's stylesheet for controls and popups; one link per root
|
||||
const root = container.parentNode;
|
||||
if (root && !root.querySelector(`link[href="${MAPLIBRE_CSS_URL}"]`)) {
|
||||
const style = document.createElement("link");
|
||||
style.setAttribute("href", MAPLIBRE_CSS_URL);
|
||||
style.setAttribute("rel", "stylesheet");
|
||||
root.appendChild(style);
|
||||
}
|
||||
|
||||
this._appliedDarkMode = options.darkMode;
|
||||
this._requestedDarkMode = options.darkMode;
|
||||
this._events = options.events;
|
||||
|
||||
const style = await loadStyle(
|
||||
VECTOR_STYLES[options.darkMode ? "dark" : "light"]
|
||||
);
|
||||
if (this._destroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = new maplibre.Map({
|
||||
container,
|
||||
style,
|
||||
center: [options.center[1], options.center[0]],
|
||||
zoom: options.zoom - ZOOM_OFFSET,
|
||||
minZoom: MAP_MIN_ZOOM - ZOOM_OFFSET,
|
||||
maxZoom: MAP_MAX_ZOOM - ZOOM_OFFSET,
|
||||
// Dashboards are north-up; no rotate or pitch gestures
|
||||
dragRotate: false,
|
||||
pitchWithRotate: false,
|
||||
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: {},
|
||||
// Proxied by core behind a token; absolute so the worker can resolve them
|
||||
transformRequest: (url) => ({
|
||||
url: withMapTilesToken(url),
|
||||
referrerPolicy: __DEMO__ ? "origin" : undefined,
|
||||
}),
|
||||
});
|
||||
map.touchZoomRotate.disableRotation();
|
||||
map.keyboard.disableRotation();
|
||||
map.scrollZoom.setWheelZoomRate(WHEEL_ZOOM_RATE);
|
||||
this._map = map;
|
||||
|
||||
// A refused TileJSON is never retried, so the style is applied again with
|
||||
// a new token. 403: stale token, 404: proxy not registered yet during a
|
||||
// restart, no status: network. Throttled so another refusal cannot loop.
|
||||
let lastRecovery = 0;
|
||||
map.on("error", (event) => {
|
||||
const status = (event.error as { status?: number } | undefined)?.status;
|
||||
if (status !== undefined && status !== 403 && status !== 404) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
lastRecovery = Date.now();
|
||||
this._refused = true;
|
||||
refreshMapTilesToken();
|
||||
});
|
||||
// Only a new token clears a refusal; a theme change in between is refused too
|
||||
this._unsubscribeToken = subscribeMapTilesToken(() => {
|
||||
if (this._refused) {
|
||||
this._refused = false;
|
||||
this._applyStyle(this._requestedDarkMode);
|
||||
}
|
||||
});
|
||||
|
||||
this._zoomControl = new maplibre.NavigationControl({ showCompass: false });
|
||||
map.addControl(this._zoomControl, POSITIONS[options.zoomControlPosition]);
|
||||
|
||||
map.on("click", (ev) => {
|
||||
// Clicks on path points (they have tooltips) are not map clicks
|
||||
const pathHit = map
|
||||
.queryRenderedFeatures(ev.point)
|
||||
.some((feature) => this._pathPointLayers.has(feature.layer.id));
|
||||
if (!pathHit) {
|
||||
this._events.click?.([ev.lngLat.lat, ev.lngLat.lng]);
|
||||
}
|
||||
});
|
||||
map.on("zoomstart", () => this._events.zoomStart?.());
|
||||
map.on("movestart", () => {
|
||||
// resize() fires movestart even when nothing changed (see _resize)
|
||||
if (!this._resizing) {
|
||||
this._events.moveStart?.();
|
||||
}
|
||||
});
|
||||
// Grouping depends on screen distances; regroup when the camera settles
|
||||
map.on("moveend", () => {
|
||||
if (this._clusterOptions) {
|
||||
clearTimeout(this._clusterRebuildTimeout);
|
||||
this._clusterRebuildTimeout = window.setTimeout(() => {
|
||||
this._rebuildClusters();
|
||||
}, CLUSTER_REBUILD_DELAY);
|
||||
}
|
||||
});
|
||||
|
||||
// A lost WebGL context gets a grace period to come back before falling back
|
||||
map.on("webglcontextlost", () => {
|
||||
this._contextLost = true;
|
||||
this._scheduleFatal();
|
||||
});
|
||||
map.on("webglcontextrestored", () => {
|
||||
this._contextLost = false;
|
||||
clearTimeout(this._fallbackTimeout);
|
||||
});
|
||||
document.addEventListener("visibilitychange", this._handleVisibility);
|
||||
|
||||
// Sources and layers can be added once the style is applied; it was
|
||||
// fetched above, so this cannot fail
|
||||
await new Promise<void>((resolve) => {
|
||||
if (map.isStyleLoaded()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
map.once("styledata", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
private _handleVisibility = () => {
|
||||
if (this._contextLost) {
|
||||
this._scheduleFatal();
|
||||
}
|
||||
};
|
||||
|
||||
private _scheduleFatal(): void {
|
||||
clearTimeout(this._fallbackTimeout);
|
||||
// Backgrounding drops the context too, and there it comes back on return
|
||||
if (this._destroyed || document.hidden) {
|
||||
return;
|
||||
}
|
||||
this._fallbackTimeout = window.setTimeout(() => {
|
||||
this._events.fatal?.();
|
||||
}, CONTEXT_RESTORE_GRACE);
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this._destroyed = true;
|
||||
this._unsubscribeToken?.();
|
||||
clearTimeout(this._fallbackTimeout);
|
||||
clearTimeout(this._clusterRebuildTimeout);
|
||||
document.removeEventListener("visibilitychange", this._handleVisibility);
|
||||
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
|
||||
this._clusterGroups = [];
|
||||
this._markers = [];
|
||||
this._pendingStyleOps = [];
|
||||
this._map?.remove();
|
||||
this._map = undefined;
|
||||
}
|
||||
|
||||
public invalidateSize(): void {
|
||||
this._resize();
|
||||
}
|
||||
|
||||
public hasUsableSize(): boolean {
|
||||
if (!this._map) {
|
||||
return false;
|
||||
}
|
||||
const container = this._map.getContainer();
|
||||
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
||||
this._resize();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// MapLibre's resize fires movestart/move/moveend synchronously even when the
|
||||
// size did not change; hosts must not read them as camera movement
|
||||
private _resize(): void {
|
||||
if (!this._map) {
|
||||
return;
|
||||
}
|
||||
this._resizing = true;
|
||||
try {
|
||||
this._map.resize();
|
||||
} finally {
|
||||
this._resizing = false;
|
||||
}
|
||||
}
|
||||
|
||||
public setDarkMode(darkMode: boolean): void {
|
||||
if (!this._map || darkMode === this._requestedDarkMode) {
|
||||
return;
|
||||
}
|
||||
this._requestedDarkMode = darkMode;
|
||||
this._applyStyle(darkMode);
|
||||
}
|
||||
|
||||
private _applyStyle(darkMode: boolean): void {
|
||||
const request = ++this._latestStyleRequest;
|
||||
|
||||
loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"])
|
||||
.then((style) => {
|
||||
if (request === this._latestStyleRequest && this._map) {
|
||||
this._map.setStyle(style, {
|
||||
transformStyle: (previous, next) =>
|
||||
this._carryCustomLayers(previous, next),
|
||||
});
|
||||
this._appliedDarkMode = darkMode;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (request === this._latestStyleRequest) {
|
||||
this._requestedDarkMode = this._appliedDarkMode;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _carryCustomLayers(
|
||||
previous: StyleSpecification | undefined,
|
||||
next: StyleSpecification
|
||||
): StyleSpecification {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
const sources = { ...next.sources };
|
||||
for (const [id, source] of Object.entries(previous.sources ?? {})) {
|
||||
if (this._customSources.has(id)) {
|
||||
sources[id] = source;
|
||||
}
|
||||
}
|
||||
const nextIds = new Set(next.layers.map((layer) => layer.id));
|
||||
const customLayers = (previous.layers ?? []).filter(
|
||||
(layer) => this._customLayers.has(layer.id) && !nextIds.has(layer.id)
|
||||
);
|
||||
const layers = [...next.layers];
|
||||
const symbolIndex = layers.findIndex((layer) => layer.type === "symbol");
|
||||
layers.splice(
|
||||
symbolIndex === -1 ? layers.length : symbolIndex,
|
||||
0,
|
||||
...customLayers
|
||||
);
|
||||
return { ...next, sources, layers };
|
||||
}
|
||||
|
||||
public setZoomControlPosition(position: MapControlPosition): void {
|
||||
if (!this._map || !this._zoomControl) {
|
||||
return;
|
||||
}
|
||||
this._map.removeControl(this._zoomControl);
|
||||
this._map.addControl(this._zoomControl, POSITIONS[position]);
|
||||
}
|
||||
|
||||
public setScaleRuler(options: { metric: boolean } | null): void {
|
||||
if (this._scaleControl) {
|
||||
this._map?.removeControl(this._scaleControl);
|
||||
this._scaleControl = undefined;
|
||||
}
|
||||
if (!options || !this._map || !this._maplibre) {
|
||||
return;
|
||||
}
|
||||
this._scaleControl = new this._maplibre.ScaleControl({
|
||||
unit: options.metric ? "metric" : "imperial",
|
||||
});
|
||||
this._map.addControl(this._scaleControl, "bottom-left");
|
||||
}
|
||||
|
||||
public setView(center: MapLatLng, zoom?: number): void {
|
||||
this._map?.jumpTo({
|
||||
center: [center[1], center[0]],
|
||||
zoom: zoom !== undefined ? zoom - ZOOM_OFFSET : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
public setZoom(zoom: number): void {
|
||||
this._map?.easeTo({ zoom: zoom - ZOOM_OFFSET });
|
||||
}
|
||||
|
||||
private _getMaxZoom(): number {
|
||||
return (this._map?.getMaxZoom() ?? 0) + ZOOM_OFFSET;
|
||||
}
|
||||
|
||||
private _project(location: MapLatLng): { x: number; y: number } {
|
||||
const point = this._map!.project([location[1], location[0]]);
|
||||
return { x: point.x, y: point.y };
|
||||
}
|
||||
|
||||
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
|
||||
if (!this._map || !this._maplibre || !points.length) {
|
||||
return;
|
||||
}
|
||||
let minLat = points[0][0];
|
||||
let maxLat = points[0][0];
|
||||
let minLng = points[0][1];
|
||||
let maxLng = points[0][1];
|
||||
for (const [lat, lng] of points) {
|
||||
minLat = Math.min(minLat, lat);
|
||||
maxLat = Math.max(maxLat, lat);
|
||||
minLng = Math.min(minLng, lng);
|
||||
maxLng = Math.max(maxLng, lng);
|
||||
}
|
||||
const maxZoom =
|
||||
options?.maxZoom !== undefined
|
||||
? options.maxZoom - ZOOM_OFFSET
|
||||
: undefined;
|
||||
if (minLat === maxLat && minLng === maxLng) {
|
||||
// Zero-area bounds: center on the point
|
||||
this._map.easeTo({
|
||||
center: [minLng, minLat],
|
||||
zoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pad = options?.pad ?? 0.5;
|
||||
const latPad = (maxLat - minLat) * pad;
|
||||
const lngPad = (maxLng - minLng) * pad;
|
||||
this._map.fitBounds(
|
||||
[
|
||||
[minLng - lngPad, minLat - latPad],
|
||||
[maxLng + lngPad, maxLat + latPad],
|
||||
],
|
||||
{ maxZoom, animate: options?.animate }
|
||||
);
|
||||
}
|
||||
|
||||
public addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle {
|
||||
element.style.width = `${options.size[0]}px`;
|
||||
element.style.height = `${options.size[1]}px`;
|
||||
if (options.title) {
|
||||
element.title = options.title;
|
||||
}
|
||||
if (options.interactive ?? true) {
|
||||
element.tabIndex = 0;
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, options.interactive ?? true);
|
||||
|
||||
const managed: ManagedMarker = {
|
||||
element,
|
||||
location,
|
||||
options,
|
||||
handle: undefined as unknown as MapMarkerHandle,
|
||||
};
|
||||
managed.handle = {
|
||||
location,
|
||||
clusterData: options.clusterData,
|
||||
remove: () => {
|
||||
managed.removed = true;
|
||||
this._hideMarker(managed);
|
||||
const index = this._markers.indexOf(managed);
|
||||
if (index !== -1) {
|
||||
this._markers.splice(index, 1);
|
||||
}
|
||||
},
|
||||
};
|
||||
this._markers.push(managed);
|
||||
|
||||
if (!options.cluster) {
|
||||
// Clusterable markers appear on the next setClustering call
|
||||
this._showMarker(managed);
|
||||
}
|
||||
return managed.handle;
|
||||
}
|
||||
|
||||
private _showMarker(managed: ManagedMarker): void {
|
||||
if (!this._map || !this._maplibre || managed.removed) {
|
||||
return;
|
||||
}
|
||||
if (!managed.mlMarker) {
|
||||
const { options } = managed;
|
||||
managed.mlMarker = new this._maplibre.Marker({
|
||||
element: managed.element,
|
||||
...(options.anchor
|
||||
? {
|
||||
anchor: "top-left" as const,
|
||||
offset: [-options.anchor[0], -options.anchor[1]] as [
|
||||
number,
|
||||
number,
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.setLngLat([managed.location[1], managed.location[0]])
|
||||
.addTo(this._map);
|
||||
}
|
||||
if (managed.options.decoration && !managed.decoration) {
|
||||
managed.decoration = this.addCircle(
|
||||
managed.location,
|
||||
managed.options.decoration
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private _hideMarker(managed: ManagedMarker): void {
|
||||
managed.mlMarker?.remove();
|
||||
managed.mlMarker = undefined;
|
||||
managed.decoration?.remove();
|
||||
managed.decoration = undefined;
|
||||
}
|
||||
|
||||
public addCircle(
|
||||
center: MapLatLng,
|
||||
options: MapCircleOptions
|
||||
): MapItemHandle {
|
||||
if (!this._map) {
|
||||
return { remove: () => undefined };
|
||||
}
|
||||
const id = `${CUSTOM_PREFIX}circle-${this._idCounter++}`;
|
||||
this._addCustomSource(id, circlePolygon(center, options.radius));
|
||||
this._addCustomLayer({
|
||||
id: `${id}-fill`,
|
||||
type: "fill",
|
||||
source: id,
|
||||
paint: { "fill-color": options.color, "fill-opacity": 0.2 },
|
||||
});
|
||||
this._addCustomLayer({
|
||||
id: `${id}-line`,
|
||||
type: "line",
|
||||
source: id,
|
||||
paint: { "line-color": options.color, "line-width": 3 },
|
||||
});
|
||||
return {
|
||||
remove: () => {
|
||||
this._removeCustomLayer(`${id}-fill`);
|
||||
this._removeCustomLayer(`${id}-line`);
|
||||
this._removeCustomSource(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public addPath(path: MapPath): MapItemHandle {
|
||||
if (!this._map || !this._maplibre) {
|
||||
return { remove: () => undefined };
|
||||
}
|
||||
const id = `${CUSTOM_PREFIX}path-${this._idCounter++}`;
|
||||
|
||||
const lines: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: path.segments.map((segment) => ({
|
||||
type: "Feature",
|
||||
properties: { opacity: segment.opacity ?? 1 },
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: segment.points.map((point) => [point[1], point[0]]),
|
||||
},
|
||||
})),
|
||||
};
|
||||
const points: FeatureCollection = {
|
||||
type: "FeatureCollection",
|
||||
features: path.markers.map((pathMarker) => ({
|
||||
type: "Feature",
|
||||
properties: {
|
||||
opacity: pathMarker.opacity ?? 1,
|
||||
tooltip: pathMarker.tooltipHtml,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [pathMarker.location[1], pathMarker.location[0]],
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
this._addCustomSource(`${id}-lines`, lines);
|
||||
this._addCustomSource(`${id}-points`, points);
|
||||
this._addCustomLayer({
|
||||
id: `${id}-lines`,
|
||||
type: "line",
|
||||
source: `${id}-lines`,
|
||||
paint: {
|
||||
"line-color": path.color,
|
||||
"line-width": 3,
|
||||
"line-opacity": ["get", "opacity"],
|
||||
},
|
||||
});
|
||||
this._addCustomLayer({
|
||||
id: `${id}-points`,
|
||||
type: "circle",
|
||||
source: `${id}-points`,
|
||||
paint: {
|
||||
"circle-radius": isTouch ? 8 : 3,
|
||||
"circle-color": path.color,
|
||||
"circle-opacity": ["get", "opacity"],
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": path.color,
|
||||
"circle-stroke-opacity": ["get", "opacity"],
|
||||
},
|
||||
});
|
||||
|
||||
const map = this._map;
|
||||
const popup = new this._maplibre.Popup({
|
||||
closeButton: false,
|
||||
closeOnClick: false,
|
||||
offset: 10,
|
||||
});
|
||||
const layerId = `${id}-points`;
|
||||
const onEnter = (ev: MapLayerMouseEvent) => {
|
||||
const feature = ev.features?.[0];
|
||||
if (!feature || feature.geometry.type !== "Point") {
|
||||
return;
|
||||
}
|
||||
map.getCanvas().style.cursor = "pointer";
|
||||
popup
|
||||
.setLngLat(feature.geometry.coordinates as [number, number])
|
||||
.setHTML(feature.properties?.tooltip ?? "")
|
||||
.addTo(map);
|
||||
};
|
||||
const onLeave = () => {
|
||||
map.getCanvas().style.cursor = "";
|
||||
popup.remove();
|
||||
};
|
||||
map.on("mouseenter", layerId, onEnter);
|
||||
map.on("mouseleave", layerId, onLeave);
|
||||
this._pathPointLayers.add(layerId);
|
||||
|
||||
return {
|
||||
remove: () => {
|
||||
map.off("mouseenter", layerId, onEnter);
|
||||
map.off("mouseleave", layerId, onLeave);
|
||||
this._pathPointLayers.delete(layerId);
|
||||
popup.remove();
|
||||
this._removeCustomLayer(`${id}-lines`);
|
||||
this._removeCustomLayer(`${id}-points`);
|
||||
this._removeCustomSource(`${id}-lines`);
|
||||
this._removeCustomSource(`${id}-points`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Runs now, or once the style has loaded; getStyle() is undefined until then
|
||||
private _whenStyleLoaded(operation: () => void): void {
|
||||
const map = this._map;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
if (map.getStyle()) {
|
||||
operation();
|
||||
return;
|
||||
}
|
||||
if (!this._pendingStyleOps.length) {
|
||||
map.once("style.load", () => {
|
||||
const operations = this._pendingStyleOps;
|
||||
this._pendingStyleOps = [];
|
||||
operations.forEach((pending) => pending());
|
||||
});
|
||||
}
|
||||
this._pendingStyleOps.push(operation);
|
||||
}
|
||||
|
||||
private _addCustomSource(
|
||||
id: string,
|
||||
data: Feature<Polygon> | FeatureCollection
|
||||
): void {
|
||||
this._whenStyleLoaded(() => {
|
||||
this._map!.addSource(id, { type: "geojson", data });
|
||||
this._customSources.add(id);
|
||||
});
|
||||
}
|
||||
|
||||
private _addCustomLayer(layer: LayerSpecification): void {
|
||||
this._whenStyleLoaded(() => {
|
||||
// Under the labels, over the base cartography
|
||||
const symbolLayer = this._map!.getStyle().layers.find(
|
||||
(styleLayer) =>
|
||||
styleLayer.type === "symbol" && !this._customLayers.has(styleLayer.id)
|
||||
);
|
||||
this._map!.addLayer(layer, symbolLayer?.id);
|
||||
this._customLayers.add(layer.id);
|
||||
});
|
||||
}
|
||||
|
||||
private _removeCustomLayer(id: string): void {
|
||||
this._whenStyleLoaded(() => {
|
||||
if (this._map!.getLayer(id)) {
|
||||
this._map!.removeLayer(id);
|
||||
}
|
||||
this._customLayers.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
private _removeCustomSource(id: string): void {
|
||||
this._whenStyleLoaded(() => {
|
||||
if (this._map!.getSource(id)) {
|
||||
this._map!.removeSource(id);
|
||||
}
|
||||
this._customSources.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
public setClustering(options: MapClusterOptions | null): void {
|
||||
this._clusterOptions = options;
|
||||
this._rebuildClusters();
|
||||
}
|
||||
|
||||
public refreshClusters(): void {
|
||||
this._rebuildClusters(false);
|
||||
}
|
||||
|
||||
// Groups clusterable markers by screen distance
|
||||
private _rebuildClusters(regroup = true): void {
|
||||
if (!this._map) {
|
||||
return;
|
||||
}
|
||||
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
|
||||
|
||||
const clusterable = this._markers.filter(
|
||||
(managed) => managed.options.cluster && !managed.removed
|
||||
);
|
||||
|
||||
if (!this._clusterOptions) {
|
||||
this._clusterGroups = [];
|
||||
clusterable.forEach((managed) => this._showMarker(managed));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!regroup) {
|
||||
// Markers removed since the last grouping leave their groups
|
||||
this._clusterGroups = this._clusterGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
members: group.members.filter((managed) => !managed.removed),
|
||||
}))
|
||||
.filter((group) => group.members.length);
|
||||
}
|
||||
|
||||
if (regroup || !this._clusterGroups.length) {
|
||||
const { radius, groupKey, groupRadius } = this._clusterOptions;
|
||||
const groups: {
|
||||
seed: { x: number; y: number };
|
||||
members: ManagedMarker[];
|
||||
}[] = [];
|
||||
|
||||
// Keyed groups first; one spread too wide falls through to proximity
|
||||
const ungrouped: ManagedMarker[] = [];
|
||||
if (groupKey) {
|
||||
const byKey: Record<string, ManagedMarker[]> = {};
|
||||
for (const managed of clusterable) {
|
||||
const key = groupKey(managed.handle);
|
||||
if (key === undefined) {
|
||||
ungrouped.push(managed);
|
||||
} else {
|
||||
(byKey[key] ??= []).push(managed);
|
||||
}
|
||||
}
|
||||
for (const members of Object.values(byKey)) {
|
||||
const points = members.map((m) => this._project(m.location));
|
||||
const xs = points.map((p) => p.x);
|
||||
const ys = points.map((p) => p.y);
|
||||
const spread = Math.hypot(
|
||||
Math.max(...xs) - Math.min(...xs),
|
||||
Math.max(...ys) - Math.min(...ys)
|
||||
);
|
||||
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members });
|
||||
} else {
|
||||
ungrouped.push(...members);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ungrouped.push(...clusterable);
|
||||
}
|
||||
|
||||
for (const managed of ungrouped) {
|
||||
const point = this._project(managed.location);
|
||||
const group = groups.find(
|
||||
(candidate) =>
|
||||
Math.hypot(
|
||||
candidate.seed.x - point.x,
|
||||
candidate.seed.y - point.y
|
||||
) <= radius
|
||||
);
|
||||
if (group) {
|
||||
group.members.push(managed);
|
||||
} else {
|
||||
groups.push({ seed: point, members: [managed] });
|
||||
}
|
||||
}
|
||||
this._clusterGroups = groups.map((group) => ({
|
||||
members: group.members,
|
||||
center: [
|
||||
group.members.reduce((sum, m) => sum + m.location[0], 0) /
|
||||
group.members.length,
|
||||
group.members.reduce((sum, m) => sum + m.location[1], 0) /
|
||||
group.members.length,
|
||||
] as MapLatLng,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const group of this._clusterGroups) {
|
||||
group.iconMarker = undefined;
|
||||
if (group.members.length === 1) {
|
||||
this._showMarker(group.members[0]);
|
||||
continue;
|
||||
}
|
||||
group.members.forEach((managed) => this._hideMarker(managed));
|
||||
|
||||
const icon = this._clusterOptions.iconBuilder(
|
||||
group.members.map((managed) => managed.handle),
|
||||
group.center
|
||||
);
|
||||
icon.element.style.width = `${icon.size[0]}px`;
|
||||
icon.element.style.height = `${icon.size[1]}px`;
|
||||
// Clicking a bubble zooms in on its members
|
||||
const zoomToMembers = () => {
|
||||
this.fitBounds(
|
||||
group.members.map((managed) => managed.location),
|
||||
{ pad: 0.3, maxZoom: this._getMaxZoom() }
|
||||
);
|
||||
};
|
||||
icon.element.tabIndex = 0;
|
||||
setMarkerAccessibility(
|
||||
icon.element,
|
||||
group.members
|
||||
.map((managed) => managed.options.title)
|
||||
.filter(Boolean)
|
||||
.join(", ") || undefined,
|
||||
true
|
||||
);
|
||||
icon.element.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
zoomToMembers();
|
||||
});
|
||||
icon.element.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
zoomToMembers();
|
||||
}
|
||||
});
|
||||
const location = icon.location ?? group.center;
|
||||
group.iconMarker = new this._maplibre!.Marker({
|
||||
element: icon.element,
|
||||
...(icon.anchor
|
||||
? {
|
||||
anchor: "top-left" as const,
|
||||
offset: [-icon.anchor[0], -icon.anchor[1]] as [number, number],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.setLngLat([location[1], location[0]])
|
||||
.addTo(this._map);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
+382
-314
@@ -1,18 +1,7 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { isToday } from "date-fns";
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
Circle,
|
||||
CircleMarker,
|
||||
Control,
|
||||
LatLngExpression,
|
||||
LatLngTuple,
|
||||
Layer,
|
||||
Map,
|
||||
Marker,
|
||||
MarkerClusterGroup,
|
||||
Polyline,
|
||||
} from "leaflet";
|
||||
import type { Layer } from "leaflet";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, ReactiveElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -21,16 +10,25 @@ import {
|
||||
formatTimeWeekday,
|
||||
formatTimeWithSeconds,
|
||||
} from "../../common/datetime/format_time";
|
||||
import { UNIT_KM } from "../../common/const";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
|
||||
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
|
||||
import type { MapBaseLayer } from "../../common/map/base-layer";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import { DecoratedMarker } from "../../common/map/decorated_marker";
|
||||
import { supportsWebGL2 } from "../../common/map/base-layer";
|
||||
import type { LeafletMapEngine } from "../../common/map/engines/leaflet-map-engine";
|
||||
import type {
|
||||
MapClusterIcon,
|
||||
MapEngine,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapPath,
|
||||
MapPathMarker,
|
||||
MapPathSegment,
|
||||
} from "../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../common/map/map-engine";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
configContext,
|
||||
@@ -40,6 +38,7 @@ import {
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantConnection,
|
||||
@@ -48,10 +47,7 @@ import type {
|
||||
HomeAssistantUI,
|
||||
ThemeMode,
|
||||
} from "../../types";
|
||||
import { isTouch } from "../../util/is_touch";
|
||||
import "../ha-icon-button";
|
||||
import "./ha-entity-marker";
|
||||
import { UNIT_KM } from "../../common/const";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -66,7 +62,7 @@ const getEntityId = (entity: string | HaMapEntity): string =>
|
||||
typeof entity === "string" ? entity : entity.entity_id;
|
||||
|
||||
export interface HaMapPathPoint {
|
||||
point: LatLngTuple;
|
||||
point: MapLatLng;
|
||||
timestamp: Date;
|
||||
}
|
||||
export interface HaMapPaths {
|
||||
@@ -96,6 +92,8 @@ export interface HaMapEntity {
|
||||
focus?: boolean;
|
||||
}
|
||||
|
||||
const CLUSTER_RADIUS = 40;
|
||||
|
||||
@customElement("ha-map")
|
||||
export class HaMap extends ReactiveElement {
|
||||
@state()
|
||||
@@ -129,8 +127,18 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
@property({ attribute: false }) public paths?: HaMapPaths[];
|
||||
|
||||
/**
|
||||
* Raw Leaflet layers, for ha-locations-editor only. Requires
|
||||
* engine="leaflet".
|
||||
*/
|
||||
@property({ attribute: false }) public layers?: Layer[];
|
||||
|
||||
/**
|
||||
* Which map engine to use. "leaflet" is required by embedders that manage
|
||||
* raw Leaflet layers (ha-locations-editor); "auto" picks the best engine.
|
||||
*/
|
||||
@property() public engine: "auto" | "leaflet" = "auto";
|
||||
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
|
||||
@property({ attribute: "auto-fit", type: Boolean }) public autoFit = false;
|
||||
@@ -158,27 +166,25 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
@query("#map") private _mapElement?: HTMLElement;
|
||||
|
||||
public leafletMap?: Map;
|
||||
private _engine?: MapEngine;
|
||||
|
||||
private Leaflet?: LeafletModuleType;
|
||||
|
||||
private _baseLayer?: MapBaseLayer;
|
||||
/** Escape hatch for ha-locations-editor; only set with engine="leaflet" */
|
||||
public get leafletMap() {
|
||||
// Only the Leaflet engine has this property
|
||||
return (this._engine as Partial<LeafletMapEngine> | undefined)?.leafletMap;
|
||||
}
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _mapItems: (Marker | Circle)[] = [];
|
||||
private _entityHandles: MapMarkerHandle[] = [];
|
||||
|
||||
private _mapFocusItems: (Marker | Circle)[] = [];
|
||||
private _zoneHandles: MapItemHandle[] = [];
|
||||
|
||||
private _mapZones: DecoratedMarker[] = [];
|
||||
private _pathHandles: MapItemHandle[] = [];
|
||||
|
||||
private _mapFocusZones: (Marker | Circle)[] = [];
|
||||
private _focusPoints: MapLatLng[] = [];
|
||||
|
||||
private _mapCluster: MarkerClusterGroup | undefined;
|
||||
|
||||
private _scaleRulerControl?: Control.Scale;
|
||||
|
||||
private _mapPaths: (Polyline | CircleMarker)[] = [];
|
||||
private _focusZonePoints: MapLatLng[] = [];
|
||||
|
||||
private _clickCount = 0;
|
||||
|
||||
@@ -211,16 +217,16 @@ export class HaMap extends ReactiveElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
if (this.leafletMap) {
|
||||
this.leafletMap.remove();
|
||||
this.leafletMap = undefined;
|
||||
this.Leaflet = undefined;
|
||||
this._baseLayer = undefined;
|
||||
}
|
||||
this._engine?.destroy();
|
||||
this._engine = undefined;
|
||||
this._entityHandles = [];
|
||||
this._zoneHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._focusPoints = [];
|
||||
this._focusZonePoints = [];
|
||||
|
||||
// the control went away with the map, so don't hold on to it
|
||||
this._scaleRulerControl = undefined;
|
||||
this._pendingFit = undefined;
|
||||
this._hasFitted = false;
|
||||
this._loaded = false;
|
||||
|
||||
if (this._resizeObserver) {
|
||||
@@ -281,11 +287,9 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
|
||||
if (changedProps.has("zoom")) {
|
||||
this._isProgrammaticFit = true;
|
||||
this.leafletMap!.setZoom(this.zoom);
|
||||
setTimeout(() => {
|
||||
this._isProgrammaticFit = false;
|
||||
}, PROGRAMMITIC_FIT_DELAY);
|
||||
this._withProgrammaticFit(() => {
|
||||
this._engine!.setZoom(this.zoom);
|
||||
});
|
||||
}
|
||||
|
||||
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
|
||||
@@ -313,76 +317,139 @@ export class HaMap extends ReactiveElement {
|
||||
map.classList.toggle("dark", this._darkMode);
|
||||
map.classList.toggle("forced-dark", this.themeMode === "dark");
|
||||
map.classList.toggle("forced-light", this.themeMode === "light");
|
||||
this._baseLayer?.setDarkMode(this._darkMode);
|
||||
this._engine?.setDarkMode(this._darkMode);
|
||||
}
|
||||
|
||||
private _loading = false;
|
||||
|
||||
private async _loadMap(): Promise<void> {
|
||||
if (this._loading) return;
|
||||
let map = this.shadowRoot!.getElementById("map");
|
||||
if (!map) {
|
||||
map = document.createElement("div");
|
||||
map.id = "map";
|
||||
this.shadowRoot!.append(map);
|
||||
private _forceLeaflet = false;
|
||||
|
||||
// Each engine is its own chunk; a map only downloads the one it uses
|
||||
private async _createEngine(): Promise<MapEngine> {
|
||||
if (this.engine === "leaflet" || this._forceLeaflet || !supportsWebGL2()) {
|
||||
const leaflet =
|
||||
await import("../../common/map/engines/leaflet-map-engine");
|
||||
return new leaflet.LeafletMapEngine();
|
||||
}
|
||||
const maplibre =
|
||||
await import("../../common/map/engines/maplibre-map-engine");
|
||||
return new maplibre.MapLibreMapEngine();
|
||||
}
|
||||
|
||||
// An engine that cannot start hands over to the Leaflet fallback
|
||||
private async _loadMap(): Promise<void> {
|
||||
try {
|
||||
await this._setUpEngine();
|
||||
} catch (err) {
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (this._forceLeaflet || !supportsWebGL2()) {
|
||||
// Already on the fallback; nothing left to try
|
||||
throw err;
|
||||
}
|
||||
this._forceLeaflet = true;
|
||||
await this._loadMap();
|
||||
}
|
||||
}
|
||||
|
||||
private async _setUpEngine(): Promise<void> {
|
||||
if (this._loading) return;
|
||||
// A fresh container per engine; engines leave state on the element they used
|
||||
this.shadowRoot!.getElementById("map")?.remove();
|
||||
const map = document.createElement("div");
|
||||
map.id = "map";
|
||||
this.shadowRoot!.append(map);
|
||||
this._loading = true;
|
||||
try {
|
||||
// The tiles are proxied by core behind a token, so nothing loads without
|
||||
// one. A host that provides no connection, or a backend without the
|
||||
// proxy, leaves the map without tiles rather than failing to set up.
|
||||
// Without a connection or the tile proxy the map sets up without tiles
|
||||
const token = this._connection
|
||||
? await ensureMapTilesToken(this._connection.connection)
|
||||
: undefined;
|
||||
|
||||
const setup = await setupLeafletMap(map, {
|
||||
latitude: this._config?.latitude ?? 52.3731339,
|
||||
longitude: this._config?.longitude ?? 4.8903147,
|
||||
const engine = await this._createEngine();
|
||||
await engine.init(map, {
|
||||
center: [
|
||||
this._config?.latitude ?? 52.3731339,
|
||||
this._config?.longitude ?? 4.8903147,
|
||||
],
|
||||
zoom: this.zoom,
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
rasterOnly: this._forceLeaflet,
|
||||
zoomControlPosition: "topleft",
|
||||
events: {
|
||||
click: (location) => this._handleEngineClick(location),
|
||||
zoomStart: () => {
|
||||
if (!this._isProgrammaticFit) {
|
||||
this._pauseAutoFit = true;
|
||||
}
|
||||
},
|
||||
moveStart: () => {
|
||||
if (!this._isProgrammaticFit) {
|
||||
this._pauseAutoFit = true;
|
||||
}
|
||||
},
|
||||
fatal: () => this._handleEngineFatal(),
|
||||
},
|
||||
});
|
||||
// Setting up fetches a style, so the element can be gone by now.
|
||||
// `disconnectedCallback` had no map to tear down, and keeping this one
|
||||
// would leave a live map - and its WebGL context - on a detached host,
|
||||
// and its container too initialized to set up again on reconnect.
|
||||
// Disconnected while the style was loading; disconnectedCallback had
|
||||
// nothing to tear down yet
|
||||
if (!this.isConnected) {
|
||||
setup.map.remove();
|
||||
engine.destroy();
|
||||
return;
|
||||
}
|
||||
this.leafletMap = setup.map;
|
||||
this.Leaflet = setup.leaflet;
|
||||
this._baseLayer = setup.baseLayer;
|
||||
this._engine = engine;
|
||||
this._updateMapStyle();
|
||||
this.leafletMap.on("click", (ev) => {
|
||||
if (this._clickCount === 0) {
|
||||
setTimeout(() => {
|
||||
if (this._clickCount === 1) {
|
||||
fireEvent(this, "map-clicked", {
|
||||
location: [ev.latlng.lat, ev.latlng.lng],
|
||||
});
|
||||
}
|
||||
this._clickCount = 0;
|
||||
}, 250);
|
||||
}
|
||||
this._clickCount++;
|
||||
});
|
||||
this.leafletMap.on("zoomstart", () => {
|
||||
if (!this._isProgrammaticFit) {
|
||||
this._pauseAutoFit = true;
|
||||
}
|
||||
});
|
||||
this.leafletMap.on("movestart", () => {
|
||||
if (!this._isProgrammaticFit) {
|
||||
this._pauseAutoFit = true;
|
||||
}
|
||||
});
|
||||
this._loaded = true;
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild on the Leaflet fallback after a fatal engine failure
|
||||
private _handleEngineFatal(): void {
|
||||
if (this._forceLeaflet) {
|
||||
return;
|
||||
}
|
||||
this._forceLeaflet = true;
|
||||
this._engine?.destroy();
|
||||
this._engine = undefined;
|
||||
this._entityHandles = [];
|
||||
this._zoneHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._focusPoints = [];
|
||||
this._focusZonePoints = [];
|
||||
this._pendingFit = undefined;
|
||||
this._hasFitted = false;
|
||||
this._loaded = false;
|
||||
this._loadMap();
|
||||
}
|
||||
|
||||
private _handleEngineClick(location: MapLatLng): void {
|
||||
// Fire only for single clicks, not for the two of a double-click zoom
|
||||
if (this._clickCount === 0) {
|
||||
setTimeout(() => {
|
||||
if (this._clickCount === 1) {
|
||||
fireEvent(this, "map-clicked", { location });
|
||||
}
|
||||
this._clickCount = 0;
|
||||
}, 250);
|
||||
}
|
||||
this._clickCount++;
|
||||
}
|
||||
|
||||
// The first fit after load jumps to the content; later fits animate
|
||||
private _hasFitted = false;
|
||||
|
||||
private _withProgrammaticFit(fit: () => void): void {
|
||||
this._isProgrammaticFit = true;
|
||||
fit();
|
||||
setTimeout(() => {
|
||||
this._isProgrammaticFit = false;
|
||||
}, PROGRAMMITIC_FIT_DELAY);
|
||||
}
|
||||
|
||||
public fitMap(options?: {
|
||||
zoom?: number;
|
||||
pad?: number;
|
||||
@@ -391,7 +458,7 @@ export class HaMap extends ReactiveElement {
|
||||
if (options?.unpause_autofit) {
|
||||
this._pauseAutoFit = false;
|
||||
}
|
||||
if (!this.leafletMap || !this.Leaflet || !this._config) {
|
||||
if (!this._engine || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -400,60 +467,53 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
|
||||
if (
|
||||
!this._mapFocusItems.length &&
|
||||
!this._mapFocusZones.length &&
|
||||
!this._focusPoints.length &&
|
||||
!this._focusZonePoints.length &&
|
||||
!this.layers?.length
|
||||
) {
|
||||
this._isProgrammaticFit = true;
|
||||
this.leafletMap.setView(
|
||||
new this.Leaflet.LatLng(this._config.latitude, this._config.longitude),
|
||||
options?.zoom || this.zoom
|
||||
);
|
||||
setTimeout(() => {
|
||||
this._isProgrammaticFit = false;
|
||||
}, PROGRAMMITIC_FIT_DELAY);
|
||||
this._withProgrammaticFit(() => {
|
||||
this._engine!.setView(
|
||||
[this._config.latitude, this._config.longitude],
|
||||
options?.zoom || this.zoom
|
||||
);
|
||||
});
|
||||
this._hasFitted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
let bounds = this.Leaflet.latLngBounds(
|
||||
this._mapFocusItems
|
||||
? this._mapFocusItems.map((item) => item.getLatLng())
|
||||
: []
|
||||
);
|
||||
const points = [...this._focusPoints, ...this._focusZonePoints];
|
||||
|
||||
this._mapFocusZones?.forEach((zone) => {
|
||||
bounds.extend("getBounds" in zone ? zone.getBounds() : zone.getLatLng());
|
||||
// Raw Leaflet layers (ha-locations-editor) contribute their bounds
|
||||
const leafletMap = this.leafletMap;
|
||||
if (this.layers?.length && leafletMap) {
|
||||
this.layers.forEach((layer: any) => {
|
||||
if ("getBounds" in layer) {
|
||||
const layerBounds = layer.getBounds();
|
||||
points.push(
|
||||
[layerBounds.getSouth(), layerBounds.getWest()],
|
||||
[layerBounds.getNorth(), layerBounds.getEast()]
|
||||
);
|
||||
} else if ("getLatLng" in layer) {
|
||||
const latLng = layer.getLatLng();
|
||||
points.push([latLng.lat, latLng.lng]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._withProgrammaticFit(() => {
|
||||
this._engine!.fitBounds(points, {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
animate: this._hasFitted,
|
||||
});
|
||||
});
|
||||
|
||||
this.layers?.forEach((layer: any) => {
|
||||
bounds.extend(
|
||||
"getBounds" in layer ? layer.getBounds() : layer.getLatLng()
|
||||
);
|
||||
});
|
||||
|
||||
bounds = bounds.pad(options?.pad ?? 0.5);
|
||||
this._isProgrammaticFit = true;
|
||||
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
|
||||
setTimeout(() => {
|
||||
this._isProgrammaticFit = false;
|
||||
}, PROGRAMMITIC_FIT_DELAY);
|
||||
this._hasFitted = true;
|
||||
}
|
||||
|
||||
// Leaflet derives the zoom level that fits given bounds from the current
|
||||
// size of the map container. When the container has not been laid out yet,
|
||||
// that size is 0x0 and the computed zoom collapses to the minimum, leaving
|
||||
// the map zoomed out to the world even after the container gets its size.
|
||||
// Defer fitting until the resize observer reports a usable size.
|
||||
// Fitting uses the container size; before layout it is 0x0 and the zoom
|
||||
// collapses to the minimum, so defer until the resize observer reports one
|
||||
private _deferIfUnsized(fit: () => void): boolean {
|
||||
const size = this.leafletMap!.getSize();
|
||||
if (size.x > 0 && size.y > 0) {
|
||||
this._pendingFit = undefined;
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
if (this._engine!.hasUsableSize()) {
|
||||
this._pendingFit = undefined;
|
||||
return false;
|
||||
}
|
||||
@@ -462,11 +522,10 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _runPendingFit(): void {
|
||||
if (!this._pendingFit || !this.leafletMap) {
|
||||
if (!this._pendingFit || !this._engine) {
|
||||
return;
|
||||
}
|
||||
const size = this.leafletMap.getSize();
|
||||
if (size.x > 0 && size.y > 0) {
|
||||
if (this._engine.hasUsableSize()) {
|
||||
const pendingFit = this._pendingFit;
|
||||
this._pendingFit = undefined;
|
||||
pendingFit();
|
||||
@@ -474,23 +533,23 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: LatLngExpression[],
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
) {
|
||||
if (!this.leafletMap || !this.Leaflet) {
|
||||
if (!this._engine) {
|
||||
return;
|
||||
}
|
||||
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
|
||||
return;
|
||||
}
|
||||
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
|
||||
options?.pad ?? 0.5
|
||||
);
|
||||
this._isProgrammaticFit = true;
|
||||
this.leafletMap.fitBounds(bounds, { maxZoom: options?.zoom || this.zoom });
|
||||
setTimeout(() => {
|
||||
this._isProgrammaticFit = false;
|
||||
}, PROGRAMMITIC_FIT_DELAY);
|
||||
this._withProgrammaticFit(() => {
|
||||
this._engine!.fitBounds(boundingbox, {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
animate: this._hasFitted,
|
||||
});
|
||||
});
|
||||
this._hasFitted = true;
|
||||
}
|
||||
|
||||
private _drawLayers(prevLayers: Layer[] | undefined): void {
|
||||
@@ -500,9 +559,12 @@ export class HaMap extends ReactiveElement {
|
||||
if (!this.layers) {
|
||||
return;
|
||||
}
|
||||
const map = this.leafletMap!;
|
||||
const leafletMap = this.leafletMap;
|
||||
if (!leafletMap) {
|
||||
return;
|
||||
}
|
||||
this.layers.forEach((layer) => {
|
||||
map.addLayer(layer);
|
||||
leafletMap.addLayer(layer);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -531,16 +593,12 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _drawPaths(): void {
|
||||
const map = this.leafletMap;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const Leaflet = this.Leaflet;
|
||||
|
||||
if (!this._i18n || !this._config || !map || !Leaflet) {
|
||||
if (!this._i18n || !this._config || !this._engine) {
|
||||
return;
|
||||
}
|
||||
if (this._mapPaths.length) {
|
||||
this._mapPaths.forEach((marker) => marker.remove());
|
||||
this._mapPaths = [];
|
||||
if (this._pathHandles.length) {
|
||||
this._pathHandles.forEach((handle) => handle.remove());
|
||||
this._pathHandles = [];
|
||||
}
|
||||
if (!this.paths) {
|
||||
return;
|
||||
@@ -558,6 +616,9 @@ export class HaMap extends ReactiveElement {
|
||||
baseOpacity = 1 - path.gradualOpacity;
|
||||
}
|
||||
|
||||
const segments: MapPathSegment[] = [];
|
||||
const markers: MapPathMarker[] = [];
|
||||
|
||||
for (
|
||||
let pointIndex = 0;
|
||||
pointIndex < path.points.length - 1;
|
||||
@@ -570,30 +631,19 @@ export class HaMap extends ReactiveElement {
|
||||
const thisPoint = path.points[pointIndex];
|
||||
const nextPoint = path.points[pointIndex + 1];
|
||||
|
||||
// DRAW point
|
||||
this._mapPaths.push(
|
||||
Leaflet.circleMarker(thisPoint.point, {
|
||||
radius: isTouch ? 8 : 3,
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
fillOpacity: opacity,
|
||||
interactive: true,
|
||||
}).bindTooltip(this._computePathTooltip(path, thisPoint), {
|
||||
direction: "top",
|
||||
})
|
||||
);
|
||||
markers.push({
|
||||
location: thisPoint.point,
|
||||
opacity,
|
||||
tooltipHtml: this._computePathTooltip(path, thisPoint),
|
||||
});
|
||||
|
||||
// DRAW line between this and next point
|
||||
if (Math.abs(thisPoint.point[1] - nextPoint.point[1]) <= 180) {
|
||||
// if the path does not cross the antimeridian, draw a simple line
|
||||
// between the two points
|
||||
this._mapPaths.push(
|
||||
Leaflet.polyline([thisPoint.point, nextPoint.point], {
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
interactive: false,
|
||||
})
|
||||
);
|
||||
segments.push({
|
||||
points: [thisPoint.point, nextPoint.point],
|
||||
opacity,
|
||||
});
|
||||
} else {
|
||||
// if the path crosses the antimeridian, split the line into two, to
|
||||
// avoid it being drawn across the entire map
|
||||
@@ -614,29 +664,23 @@ export class HaMap extends ReactiveElement {
|
||||
longitudeDifference;
|
||||
}
|
||||
|
||||
const intersectionPoint1: LatLngTuple = [
|
||||
const intersectionPoint1: MapLatLng = [
|
||||
intersectionLatitude,
|
||||
thisPoint.point[1] > 0 ? 180 : -180,
|
||||
];
|
||||
const intersectionPoint2: LatLngTuple = [
|
||||
const intersectionPoint2: MapLatLng = [
|
||||
intersectionLatitude,
|
||||
nextPoint.point[1] > 0 ? 180 : -180,
|
||||
];
|
||||
|
||||
this._mapPaths.push(
|
||||
Leaflet.polyline([thisPoint.point, intersectionPoint1], {
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
interactive: false,
|
||||
})
|
||||
);
|
||||
this._mapPaths.push(
|
||||
Leaflet.polyline([intersectionPoint2, nextPoint.point], {
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
interactive: false,
|
||||
})
|
||||
);
|
||||
segments.push({
|
||||
points: [thisPoint.point, intersectionPoint1],
|
||||
opacity,
|
||||
});
|
||||
segments.push({
|
||||
points: [intersectionPoint2, nextPoint.point],
|
||||
opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
const pointIndex = path.points.length - 1;
|
||||
@@ -644,52 +688,40 @@ export class HaMap extends ReactiveElement {
|
||||
const opacity = path.gradualOpacity
|
||||
? baseOpacity! + pointIndex * opacityStep!
|
||||
: undefined;
|
||||
// DRAW end path point
|
||||
this._mapPaths.push(
|
||||
Leaflet.circleMarker(path.points[pointIndex].point, {
|
||||
radius: isTouch ? 8 : 3,
|
||||
color: path.color || darkPrimaryColor,
|
||||
opacity,
|
||||
fillOpacity: opacity,
|
||||
interactive: true,
|
||||
}).bindTooltip(
|
||||
this._computePathTooltip(path, path.points[pointIndex]),
|
||||
{ direction: "top" }
|
||||
)
|
||||
);
|
||||
markers.push({
|
||||
location: path.points[pointIndex].point,
|
||||
opacity,
|
||||
tooltipHtml: this._computePathTooltip(path, path.points[pointIndex]),
|
||||
});
|
||||
}
|
||||
this._mapPaths.forEach((marker) => map.addLayer(marker));
|
||||
|
||||
const enginePath: MapPath = {
|
||||
color: path.color || darkPrimaryColor,
|
||||
segments,
|
||||
markers,
|
||||
};
|
||||
this._pathHandles.push(this._engine!.addPath(enginePath));
|
||||
});
|
||||
}
|
||||
|
||||
private _drawEntities(): void {
|
||||
const states = this._states;
|
||||
const map = this.leafletMap;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const Leaflet = this.Leaflet;
|
||||
const engine = this._engine;
|
||||
|
||||
if (!states || !map || !Leaflet) {
|
||||
if (!states || !engine) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._mapItems.length) {
|
||||
this._mapItems.forEach((marker) => marker.remove());
|
||||
this._mapItems = [];
|
||||
this._mapFocusItems = [];
|
||||
}
|
||||
this._entityHandles.forEach((handle) => handle.remove());
|
||||
this._entityHandles = [];
|
||||
this._focusPoints = [];
|
||||
|
||||
if (this._mapZones.length) {
|
||||
this._mapZones.forEach((marker) => marker.remove());
|
||||
this._mapZones = [];
|
||||
this._mapFocusZones = [];
|
||||
}
|
||||
|
||||
if (this._mapCluster) {
|
||||
this._mapCluster.remove();
|
||||
this._mapCluster = undefined;
|
||||
}
|
||||
this._zoneHandles.forEach((handle) => handle.remove());
|
||||
this._zoneHandles = [];
|
||||
this._focusZonePoints = [];
|
||||
|
||||
if (!this.entities) {
|
||||
engine.setClustering(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -703,8 +735,6 @@ export class HaMap extends ReactiveElement {
|
||||
"--dark-primary-color"
|
||||
);
|
||||
|
||||
const className = this._darkMode ? "dark" : "light";
|
||||
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
if (!stateObj) {
|
||||
@@ -724,6 +754,7 @@ export class HaMap extends ReactiveElement {
|
||||
continue;
|
||||
}
|
||||
const { latitude, longitude, gpsAccuracy } = location;
|
||||
const position: MapLatLng = [latitude, longitude];
|
||||
|
||||
if (computeStateDomain(stateObj) === "zone") {
|
||||
// DRAW ZONE
|
||||
@@ -731,42 +762,61 @@ export class HaMap extends ReactiveElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
|
||||
|
||||
if (radius) {
|
||||
this._zoneHandles.push(
|
||||
engine.addCircle(position, { radius, color: zoneMarkerColor })
|
||||
);
|
||||
}
|
||||
|
||||
// create icon
|
||||
let iconHTML: string;
|
||||
const iconEl = document.createElement("div");
|
||||
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
|
||||
if (icon) {
|
||||
const el = document.createElement("ha-icon");
|
||||
el.setAttribute("icon", icon);
|
||||
iconHTML = el.outerHTML;
|
||||
iconEl.appendChild(el);
|
||||
} else {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = title;
|
||||
iconHTML = el.outerHTML;
|
||||
iconEl.appendChild(el);
|
||||
}
|
||||
|
||||
// create circle around it
|
||||
const circle = Leaflet.circle([latitude, longitude], {
|
||||
interactive: false,
|
||||
color: passive ? passiveZoneColor : zoneColor,
|
||||
radius,
|
||||
});
|
||||
if (this.interactiveZones) {
|
||||
const openMoreInfo = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "hass-more-info", {
|
||||
entityId: stateObj.entity_id,
|
||||
});
|
||||
};
|
||||
iconEl.addEventListener("click", openMoreInfo);
|
||||
iconEl.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
openMoreInfo(ev);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const markerIconSize = this._getMarkerSize(computedStyles) / 2;
|
||||
const marker = new DecoratedMarker([latitude, longitude], circle, {
|
||||
icon: Leaflet.divIcon({
|
||||
html: iconHTML,
|
||||
iconSize: [markerIconSize, markerIconSize],
|
||||
className,
|
||||
}),
|
||||
interactive: this.interactiveZones,
|
||||
title,
|
||||
});
|
||||
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
|
||||
this._zoneHandles.push(
|
||||
engine.addMarker(iconEl, position, {
|
||||
size: [zoneIconSize, zoneIconSize],
|
||||
interactive: this.interactiveZones,
|
||||
title,
|
||||
})
|
||||
);
|
||||
|
||||
this._mapZones.push(marker);
|
||||
if (
|
||||
this.fitZones &&
|
||||
(typeof entity === "string" || entity.focus !== false)
|
||||
) {
|
||||
this._mapFocusZones.push(circle);
|
||||
if (radius) {
|
||||
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
|
||||
} else {
|
||||
this._focusZonePoints.push(position);
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
@@ -810,64 +860,50 @@ export class HaMap extends ReactiveElement {
|
||||
entityMarker.entityColor = entity.color;
|
||||
}
|
||||
|
||||
// create marker with the icon
|
||||
const markerSize = this._getMarkerSize(computedStyles);
|
||||
const marker = new DecoratedMarker([latitude, longitude], undefined, {
|
||||
icon: Leaflet.divIcon({
|
||||
html: entityMarker,
|
||||
iconSize: [markerSize, markerSize],
|
||||
className: "",
|
||||
}),
|
||||
title: title,
|
||||
});
|
||||
this._entityHandles.push(
|
||||
engine.addMarker(entityMarker, position, {
|
||||
size: [markerSize, markerSize],
|
||||
title,
|
||||
cluster: true,
|
||||
// create circle around if entity has accuracy
|
||||
decoration: gpsAccuracy
|
||||
? { radius: gpsAccuracy, color: darkPrimaryColor }
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
|
||||
if (typeof entity === "string" || entity.focus !== false) {
|
||||
this._mapFocusItems.push(marker);
|
||||
this._focusPoints.push(position);
|
||||
}
|
||||
|
||||
// create circle around if entity has accuracy
|
||||
if (gpsAccuracy) {
|
||||
marker.decorationLayer = Leaflet.circle([latitude, longitude], {
|
||||
interactive: false,
|
||||
color: darkPrimaryColor,
|
||||
radius: gpsAccuracy,
|
||||
});
|
||||
}
|
||||
|
||||
this._mapItems.push(marker);
|
||||
}
|
||||
|
||||
if (this.clusterMarkers) {
|
||||
this._mapCluster = Leaflet.markerClusterGroup({
|
||||
showCoverageOnHover: false,
|
||||
removeOutsideVisibleBounds: false,
|
||||
maxClusterRadius: 40,
|
||||
});
|
||||
this._mapCluster.addLayers(this._mapItems);
|
||||
map.addLayer(this._mapCluster);
|
||||
} else {
|
||||
this._mapItems.forEach((marker) => map.addLayer(marker));
|
||||
}
|
||||
|
||||
this._mapZones.forEach((marker) => map.addLayer(marker));
|
||||
engine.setClustering(
|
||||
this.clusterMarkers
|
||||
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// Renders a marker cluster as a circle with the member count
|
||||
private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => {
|
||||
const size = Math.round(
|
||||
this._getMarkerSize(getComputedStyle(this)) * (2 / 3)
|
||||
);
|
||||
const element = document.createElement("div");
|
||||
element.className = "marker-cluster";
|
||||
const count = document.createElement("span");
|
||||
count.textContent = String(members.length);
|
||||
element.appendChild(count);
|
||||
return { element, size: [size, size] };
|
||||
};
|
||||
|
||||
private _drawScaleRuler(): void {
|
||||
if (this._scaleRulerControl) {
|
||||
this.leafletMap?.removeControl(this._scaleRulerControl);
|
||||
this._scaleRulerControl = undefined;
|
||||
}
|
||||
|
||||
if (!this.scaleRuler || !this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metric = this._config?.unit_system?.length === UNIT_KM;
|
||||
this._scaleRulerControl = this.Leaflet.control.scale({
|
||||
position: "bottomleft",
|
||||
metric,
|
||||
imperial: !metric,
|
||||
});
|
||||
this._scaleRulerControl.addTo(this.leafletMap);
|
||||
this._engine?.setScaleRuler(
|
||||
this.scaleRuler
|
||||
? { metric: this._config?.unit_system?.length === UNIT_KM }
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
private _getMarkerSize(computedStyles: CSSStyleDeclaration): number {
|
||||
@@ -880,7 +916,7 @@ export class HaMap extends ReactiveElement {
|
||||
private async _attachObserver(): Promise<void> {
|
||||
if (!this._resizeObserver) {
|
||||
this._resizeObserver = new ResizeObserver(() => {
|
||||
this.leafletMap?.invalidateSize({ debounceMoveend: true });
|
||||
this._engine?.invalidateSize();
|
||||
this._runPendingFit();
|
||||
});
|
||||
}
|
||||
@@ -920,8 +956,8 @@ export class HaMap extends ReactiveElement {
|
||||
.leaflet-tile-pane .leaflet-tile {
|
||||
filter: var(--map-filter);
|
||||
}
|
||||
/* The only two rules the MapLibre canvas needs from its stylesheet, the
|
||||
rest of it styles controls and popups we do not render. */
|
||||
/* The adapter path (engine="leaflet" with WebGL2) loads no MapLibre
|
||||
stylesheet; these are the only two rules its canvas needs. */
|
||||
.maplibregl-map {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -931,6 +967,31 @@ export class HaMap extends ReactiveElement {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
.dark .maplibregl-ctrl-group button + button {
|
||||
border-top-color: #313131;
|
||||
}
|
||||
.dark .maplibregl-ctrl button .maplibregl-ctrl-icon {
|
||||
filter: invert(1);
|
||||
}
|
||||
.maplibregl-popup-content {
|
||||
padding: 8px;
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-family: var(--ha-font-family-body);
|
||||
background: rgba(80, 80, 80, 0.9);
|
||||
color: white;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
box-shadow: none;
|
||||
text-align: center;
|
||||
}
|
||||
.maplibregl-popup-anchor-bottom .maplibregl-popup-tip {
|
||||
border-top-color: rgba(80, 80, 80, 0.9);
|
||||
}
|
||||
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
|
||||
border-bottom-color: rgba(80, 80, 80, 0.9);
|
||||
}
|
||||
.dark .leaflet-bar a {
|
||||
background-color: #1c1c1c;
|
||||
color: #ffffff;
|
||||
@@ -956,6 +1017,17 @@ export class HaMap extends ReactiveElement {
|
||||
.leaflet-pane {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
/* Zone icons, sized like the Leaflet divIcon they replaced */
|
||||
.zone-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.zone-icon.dark {
|
||||
color: #ffffff;
|
||||
}
|
||||
.leaflet-control,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
@@ -1006,22 +1078,18 @@ export class HaMap extends ReactiveElement {
|
||||
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
|
||||
}
|
||||
|
||||
.marker-cluster div {
|
||||
.marker-cluster {
|
||||
box-sizing: border-box;
|
||||
background-clip: padding-box;
|
||||
background-color: var(--primary-color);
|
||||
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
|
||||
width: calc(var(--ha-marker-size, 48px) * 0.667);
|
||||
height: calc(var(--ha-marker-size, 48px) * 0.667);
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
|
||||
.marker-cluster span {
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { mdiContentCopy, mdiPencil, mdiRestore } from "@mdi/js";
|
||||
import { mdiContentCopy, mdiRestore } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -8,10 +8,7 @@ import { until } from "lit/directives/until";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "@lit/context";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeEntityEntryName } from "../../../common/entity/compute_entity_name";
|
||||
import { computeObjectId } from "../../../common/entity/compute_object_id";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import { formatNumber } from "../../../common/number/format_number";
|
||||
@@ -24,13 +21,10 @@ import type {
|
||||
import { copyToClipboard } from "../../../common/util/copy-clipboard";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-area-picker";
|
||||
import "../../../components/ha-checkbox";
|
||||
import type { HaCheckbox } from "../../../components/ha-checkbox";
|
||||
import "../../../components/ha-color-picker";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-button-next";
|
||||
import "../../../components/ha-icon-picker";
|
||||
import "../../../components/ha-labels-picker";
|
||||
@@ -199,8 +193,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
@state() private _name!: string;
|
||||
|
||||
@state() private _useDeviceName = false;
|
||||
|
||||
@state() private _icon!: string;
|
||||
|
||||
@state() private _entityId!: EntitySettingsState["entityId"];
|
||||
@@ -262,9 +254,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
super.willUpdate(changedProperties);
|
||||
this._device = this.entry.device_id
|
||||
? this.hass.devices[this.entry.device_id]
|
||||
: undefined;
|
||||
if (
|
||||
!changedProperties.has("entry") ||
|
||||
changedProperties.get("entry")?.id === this.entry.id
|
||||
@@ -273,8 +262,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
}
|
||||
|
||||
this._name = this.entry.name || "";
|
||||
this._useDeviceName =
|
||||
!!this._device && !computeEntityEntryName(this.entry, this.hass.devices);
|
||||
this._icon = this.entry.icon || "";
|
||||
this._deviceClass =
|
||||
this.entry.device_class || this.entry.original_device_class;
|
||||
@@ -284,6 +271,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
this._entityId = this.entry.entity_id;
|
||||
this._disabledBy = this.entry.disabled_by;
|
||||
this._hiddenBy = this.entry.hidden_by;
|
||||
this._device = this.entry.device_id
|
||||
? this.hass.devices[this.entry.device_id]
|
||||
: undefined;
|
||||
this._switchAsInvert = this.entry.options?.switch_as_x?.invert === true;
|
||||
|
||||
const domain = computeDomain(this.entry.entity_id);
|
||||
@@ -396,7 +386,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
this._dirtyState?.setState(
|
||||
{
|
||||
name: this._computeName(),
|
||||
name: this._name || null,
|
||||
icon: this._icon || null,
|
||||
entityId: this._entityId,
|
||||
areaId: this._areaId ?? null,
|
||||
@@ -474,74 +464,41 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
|
||||
const defaultPrecision =
|
||||
this.entry.options?.sensor?.suggested_display_precision ?? undefined;
|
||||
const deviceName =
|
||||
this._device && this._useDeviceName
|
||||
? computeDeviceNameDisplay(
|
||||
this._device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
${
|
||||
this.hideName
|
||||
? nothing
|
||||
: html`<div
|
||||
class="name-field"
|
||||
role="group"
|
||||
aria-label=${this.hass.localize(
|
||||
: html`<ha-input
|
||||
inset-label
|
||||
class="name"
|
||||
.value=${this._name}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.name"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
@input=${this._nameChanged}
|
||||
>
|
||||
${
|
||||
deviceName !== undefined
|
||||
? html`<div class="device-name" id="entity-name">
|
||||
<span class="device-name-label">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.name"
|
||||
)}
|
||||
</span>
|
||||
<span class="device-name-value" title=${deviceName}>
|
||||
${deviceName}
|
||||
</span>
|
||||
<ha-icon-button
|
||||
.path=${mdiPencil}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.edit_device_name"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openDeviceSettings}
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
: html`<ha-input
|
||||
inset-label
|
||||
class="name"
|
||||
id="entity-name"
|
||||
.value=${this._name}
|
||||
.placeholder=${this.entry.original_name || ""}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.name"
|
||||
)}
|
||||
.disabled=${this.disabled}
|
||||
@input=${this._nameChanged}
|
||||
></ha-input>`
|
||||
}
|
||||
${
|
||||
this._device
|
||||
? html`<ha-checkbox
|
||||
.checked=${this._useDeviceName}
|
||||
.disabled=${this.disabled}
|
||||
aria-controls="entity-name"
|
||||
@change=${this._useDeviceNameChanged}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.use_device_name"
|
||||
)}
|
||||
</ha-checkbox>`
|
||||
? html`<span slot="hint"
|
||||
>${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.device_name_tip",
|
||||
{
|
||||
link: html`<button
|
||||
class="link"
|
||||
@click=${this._resetNameAndOpenDeviceSettings}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.open_device_settings"
|
||||
)}
|
||||
</button>`,
|
||||
}
|
||||
)}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
</ha-input>`
|
||||
}
|
||||
${
|
||||
this.hideIcon
|
||||
@@ -1288,7 +1245,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
}
|
||||
|
||||
const params: Partial<EntityRegistryEntryUpdateParams> = {
|
||||
name: this._computeName(),
|
||||
name: this._name.trim() || null,
|
||||
icon: this._icon.trim() || null,
|
||||
area_id: this._areaId || null,
|
||||
labels: this._labels || [],
|
||||
@@ -1735,23 +1692,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _useDeviceNameChanged(
|
||||
ev: HASSDomCurrentTargetEvent<HaCheckbox>
|
||||
): void {
|
||||
this._useDeviceName = ev.currentTarget.checked;
|
||||
}
|
||||
|
||||
private _computeName(): string | null {
|
||||
if (this.hideName) {
|
||||
return this.entry.name;
|
||||
}
|
||||
if (this._device && this._useDeviceName) {
|
||||
// Preserve entities that already use the device name without an override.
|
||||
return computeEntityEntryName(this.entry, this.hass.devices)
|
||||
? ""
|
||||
: this.entry.name;
|
||||
}
|
||||
return this._name.trim() || null;
|
||||
private _resetNameAndOpenDeviceSettings() {
|
||||
this._name = this.entry.name || "";
|
||||
this._openDeviceSettings();
|
||||
}
|
||||
|
||||
private _openDeviceSettings() {
|
||||
@@ -1855,8 +1798,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
}
|
||||
|
||||
ha-input.entityId,
|
||||
ha-input.name,
|
||||
.device-name {
|
||||
ha-input.name {
|
||||
--ha-icon-button-size: 36px;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
@@ -1864,60 +1806,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
ha-input.name {
|
||||
--ha-input-start-max-width: 35%;
|
||||
}
|
||||
.name-field {
|
||||
margin: var(--ha-space-2) 0;
|
||||
}
|
||||
.name-field ha-input {
|
||||
margin: 0;
|
||||
--ha-input-padding-bottom: 0;
|
||||
}
|
||||
.device-name {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
padding-inline: var(--ha-space-4);
|
||||
background: var(--ha-color-form-background);
|
||||
border-radius: var(--ha-border-radius-sm) var(--ha-border-radius-sm) 0
|
||||
0;
|
||||
box-shadow: inset 0 -1px var(--ha-color-border-neutral-quiet);
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
cursor: default;
|
||||
}
|
||||
.device-name-label {
|
||||
position: absolute;
|
||||
top: var(--ha-space-3);
|
||||
inset-inline-start: var(--ha-space-4);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
.device-name-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-top: var(--ha-space-3);
|
||||
color: var(--primary-text-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
user-select: text;
|
||||
}
|
||||
.device-name ha-icon-button {
|
||||
flex: none;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.name-field ha-checkbox {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
padding-inline: var(--ha-space-4);
|
||||
--wa-form-control-label-font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.name-field ha-checkbox::part(base) {
|
||||
min-height: 36px;
|
||||
}
|
||||
ha-input.entityId ha-icon-button:last-child {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1939,8 +1939,6 @@
|
||||
"faq": "documentation",
|
||||
"editor": {
|
||||
"name": "Name",
|
||||
"use_device_name": "Use device name",
|
||||
"edit_device_name": "Edit device name",
|
||||
"icon": "Icon",
|
||||
"icon_error": "Icons should be in the format 'prefix:iconname', like 'mdi:home'",
|
||||
"default_code": "Default code",
|
||||
|
||||
@@ -8,7 +8,6 @@ import * as computeStateNameModule from "../../../src/common/entity/compute_stat
|
||||
import * as stripPrefixModule from "../../../src/common/entity/strip_prefix_from_entity_name";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import {
|
||||
mockDevice,
|
||||
mockEntity,
|
||||
mockEntityEntry,
|
||||
mockStateObj,
|
||||
@@ -133,20 +132,6 @@ describe("computeEntityEntryName", () => {
|
||||
expect(computeEntityEntryName(entry, hass.devices)).toBe("Old Name");
|
||||
});
|
||||
|
||||
it("preserves an explicitly empty name instead of the integration name", () => {
|
||||
const entry = mockEntityEntry({
|
||||
device_id: "dev1",
|
||||
name: "",
|
||||
original_name: "Temperature",
|
||||
});
|
||||
const devices = { dev1: mockDevice({ id: "dev1", name: "Living room" }) };
|
||||
|
||||
expect(computeEntityEntryName(entry, devices)).toBe("");
|
||||
expect(computeEntityEntryName({ ...entry, name: null }, devices)).toBe(
|
||||
"Temperature"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined if no name, original_name, or device", () => {
|
||||
const entry = mockEntity({ entity_id: "light.kitchen" });
|
||||
const hass = {
|
||||
|
||||
@@ -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,203 @@
|
||||
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;
|
||||
|
||||
options?: MapEngineOptions;
|
||||
|
||||
constructor() {
|
||||
FakeMapLibreEngine.instances.push(this);
|
||||
}
|
||||
|
||||
init = vi.fn(async (_container: HTMLElement, options: MapEngineOptions) => {
|
||||
this.options = options;
|
||||
if (FakeMapLibreEngine.failInit) {
|
||||
throw new Error("WebGL context refused");
|
||||
}
|
||||
});
|
||||
|
||||
destroy = vi.fn();
|
||||
|
||||
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.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, then abandoned
|
||||
expect(fakeEngine.instances).toHaveLength(1);
|
||||
expect(fakeEngine.instances[0].init).toHaveBeenCalledOnce();
|
||||
expect(leafletMap(el)).toBeDefined();
|
||||
// Entities are drawn on the fallback
|
||||
expect(entityHandles(el)).toHaveLength(2);
|
||||
});
|
||||
|
||||
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