mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 11:58:45 +00:00
Compare commits
4
Commits
dev
..
map-engine
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad4fb6161 | ||
|
|
03b01e3b05 | ||
|
|
510ba20b59 | ||
|
|
50b80585f3 |
@@ -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) {
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export const isExternalHassUrl = (): boolean =>
|
||||
Boolean(__HASS_URL__) && __HASS_URL__ !== location.origin;
|
||||
|
||||
export const resolveHassUrl = (path: string): string =>
|
||||
isExternalHassUrl() ? new URL(path, __HASS_URL__).toString() : path;
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { fetchHassioAddonsInfo } from "../data/hassio/addon";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-app-icon";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
@@ -19,22 +18,13 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.repository", weight: 5 },
|
||||
];
|
||||
|
||||
interface AddonPickerItem extends PickerComboBoxItem {
|
||||
slug: string;
|
||||
hasIcon: boolean;
|
||||
}
|
||||
|
||||
const rowRenderer: RenderItemFunction<AddonPickerItem> = (item) => html`
|
||||
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
<span slot="headline">${item.primary}</span>
|
||||
<span slot="supporting-text">${item.secondary}</span>
|
||||
${
|
||||
item.hasIcon
|
||||
? html`<ha-app-icon
|
||||
slot="start"
|
||||
.slug=${item.slug}
|
||||
.hasIcon=${item.hasIcon}
|
||||
></ha-app-icon>`
|
||||
item.icon
|
||||
? html` <img alt="" slot="start" .src=${item.icon} /> `
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
@@ -50,7 +40,7 @@ class HaAddonPicker extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@state() private _addons?: AddonPickerItem[];
|
||||
@state() private _addons?: PickerComboBoxItem[];
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@@ -112,10 +102,11 @@ class HaAddonPicker extends LitElement {
|
||||
.filter((addon) => addon.version)
|
||||
.map((addon) => ({
|
||||
id: addon.slug,
|
||||
slug: addon.slug,
|
||||
hasIcon: addon.icon,
|
||||
primary: addon.name,
|
||||
secondary: addon.slug,
|
||||
icon: addon.icon
|
||||
? `/api/hassio/addons/${addon.slug}/icon`
|
||||
: undefined,
|
||||
search_labels: {
|
||||
description: addon.description || null,
|
||||
repository: addon.repository || null,
|
||||
@@ -160,22 +151,15 @@ class HaAddonPicker extends LitElement {
|
||||
private _valueRenderer = (itemId: string) => {
|
||||
const item = this._addons!.find((addon) => addon.id === itemId);
|
||||
return html`${
|
||||
item?.hasIcon
|
||||
? html`<ha-app-icon
|
||||
item?.icon
|
||||
? html`<img
|
||||
slot="start"
|
||||
.slug=${item.slug}
|
||||
.hasIcon=${item.hasIcon}
|
||||
.alt=${item.primary ?? "Unknown"}
|
||||
></ha-app-icon>`
|
||||
alt=${item.primary ?? "Unknown"}
|
||||
.src=${item.icon}
|
||||
/>`
|
||||
: nothing
|
||||
}<span slot="headline">${item?.primary || "Unknown"}</span>`;
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
ha-app-icon {
|
||||
--ha-app-icon-size: var(--ha-space-8);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
|
||||
import { isExternalHassUrl } from "../common/url/hass-url";
|
||||
import { supervisorUrl } from "../data/hassio/common";
|
||||
|
||||
@customElement("ha-app-icon")
|
||||
export class HaAppIcon extends LitElement {
|
||||
@property() public slug = "";
|
||||
|
||||
@property({ attribute: "has-icon", type: Boolean })
|
||||
public hasIcon?: boolean;
|
||||
|
||||
@property() public alt = "";
|
||||
|
||||
@property() public loading: "eager" | "lazy" = "eager";
|
||||
|
||||
@state() private _failedSrc?: string;
|
||||
|
||||
@query("img") private _image?: HTMLImageElement;
|
||||
|
||||
protected render() {
|
||||
const src = supervisorUrl(`addons/${this.slug}/icon`);
|
||||
|
||||
if (!this.slug || this.hasIcon === false || this._failedSrc === src) {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
|
||||
return keyed(
|
||||
src,
|
||||
html`
|
||||
<img
|
||||
src=${src}
|
||||
alt=${this.alt}
|
||||
loading=${this.loading}
|
||||
crossorigin=${ifDefined(
|
||||
isExternalHassUrl() ? undefined : "anonymous"
|
||||
)}
|
||||
referrerpolicy="no-referrer"
|
||||
@error=${this._handleError}
|
||||
/>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
private _handleError(ev: HASSDomCurrentTargetEvent<HTMLImageElement>) {
|
||||
if (ev.currentTarget !== this._image) {
|
||||
return;
|
||||
}
|
||||
this._failedSrc = ev.currentTarget.getAttribute("src") ?? undefined;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--ha-app-icon-size, var(--mdc-icon-size));
|
||||
height: var(--ha-app-icon-size, var(--mdc-icon-size));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-app-icon": HaAppIcon;
|
||||
}
|
||||
}
|
||||
@@ -51,19 +51,10 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
white-space: normal;
|
||||
}
|
||||
::slotted(state-badge),
|
||||
::slotted(img),
|
||||
::slotted(ha-app-icon) {
|
||||
::slotted(img) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
::slotted(ha-app-icon.colored) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
background-color: var(--app-icon-background-color);
|
||||
color: var(--white-color);
|
||||
}
|
||||
::slotted(.code) {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { atLeastVersion } from "../common/config/version";
|
||||
import type { HomeAssistant, LogFileDisabledReason } from "../types";
|
||||
import type { HassioAddonInfo } from "./hassio/addon";
|
||||
import { supervisorUrl } from "./hassio/common";
|
||||
|
||||
export interface LogProvider {
|
||||
key: string;
|
||||
@@ -19,7 +18,7 @@ export const fetchErrorLog = (hass: HomeAssistant) =>
|
||||
|
||||
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
|
||||
hasSupervisorCoreLogDownload(hass)
|
||||
? supervisorUrl("core/logs/latest")
|
||||
? "/api/hassio/core/logs/latest"
|
||||
: "/api/error_log";
|
||||
|
||||
export const getCoreLogFileDownloadUnavailableReason = (
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { resolveHassUrl } from "../../common/url/hass-url";
|
||||
import type { CallWS } from "../../types";
|
||||
|
||||
export const supervisorUrl = (path: string): string =>
|
||||
resolveHassUrl(`/api/hassio/${path}`);
|
||||
|
||||
export interface HassioResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HomeAssistant, PanelInfo } from "../../types";
|
||||
import type { SupervisorArch } from "../supervisor/supervisor";
|
||||
import { supervisorUrl, type HassioResponse } from "./common";
|
||||
import type { HassioResponse } from "./common";
|
||||
|
||||
export interface HassioHomeAssistantInfo {
|
||||
arch: SupervisorArch;
|
||||
@@ -198,20 +198,18 @@ export const fetchHassioLogsFollowSkip = async (
|
||||
);
|
||||
|
||||
export const getHassioLogDownloadUrl = (provider: string) =>
|
||||
supervisorUrl(
|
||||
`${provider.includes("_") ? `addons/${provider}` : provider}/logs`
|
||||
);
|
||||
`/api/hassio/${
|
||||
provider.includes("_") ? `addons/${provider}` : provider
|
||||
}/logs`;
|
||||
|
||||
export const getHassioLogDownloadLinesUrl = (
|
||||
provider: string,
|
||||
lines: number,
|
||||
boot = 0
|
||||
) =>
|
||||
supervisorUrl(
|
||||
`${
|
||||
provider.includes("_") ? `addons/${provider}` : provider
|
||||
}/logs${boot !== 0 ? `/boots/${boot}` : ""}?lines=${lines}`
|
||||
);
|
||||
`/api/hassio/${
|
||||
provider.includes("_") ? `addons/${provider}` : provider
|
||||
}/logs${boot !== 0 ? `/boots/${boot}` : ""}?lines=${lines}`;
|
||||
|
||||
export const setSupervisorOption = async (
|
||||
hass: HomeAssistant,
|
||||
@@ -225,4 +223,4 @@ export const setSupervisorOption = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const coreLatestLogsUrl = supervisorUrl("core/logs/latest");
|
||||
export const coreLatestLogsUrl = "/api/hassio/core/logs/latest";
|
||||
|
||||
+10
-7
@@ -28,7 +28,6 @@ export interface NavigationComboBoxItem extends PickerComboBoxItem {
|
||||
path: string;
|
||||
image?: string;
|
||||
iconColor?: string;
|
||||
app?: Pick<HassioAddonInfo, "slug" | "icon">;
|
||||
}
|
||||
|
||||
export interface BaseNavigationCommand {
|
||||
@@ -39,7 +38,6 @@ export interface BaseNavigationCommand {
|
||||
iconPath?: string;
|
||||
iconColor?: string;
|
||||
image?: string;
|
||||
app?: Pick<HassioAddonInfo, "slug" | "icon">;
|
||||
}
|
||||
|
||||
export interface ActionCommandComboBoxItem extends PickerComboBoxItem {
|
||||
@@ -64,14 +62,19 @@ const generateNavigationPanelCommands = (
|
||||
|
||||
const primary = localize(translationKey) || panel.title || panel.url_path;
|
||||
|
||||
const panelApp = apps?.find(({ slug }) => slug === panel.url_path);
|
||||
let image: string | undefined;
|
||||
|
||||
if (apps) {
|
||||
const app = apps.find(({ slug }) => slug === panel.url_path);
|
||||
if (app) {
|
||||
image = app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
primary,
|
||||
icon,
|
||||
app: panelApp
|
||||
? { slug: panelApp.slug, icon: panelApp.icon }
|
||||
: undefined,
|
||||
image,
|
||||
path: `/${panel.url_path}`,
|
||||
};
|
||||
});
|
||||
@@ -168,7 +171,7 @@ export const generateNavigationCommands = (
|
||||
for (const app of apps.filter((a) => a.version)) {
|
||||
appItems.push({
|
||||
path: `/config/app/${app.slug}`,
|
||||
app: { slug: app.slug, icon: app.icon },
|
||||
image: app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined,
|
||||
primary: hass.localize(
|
||||
"ui.dialogs.quick-bar.commands.navigation.app_info",
|
||||
{ app: app.name }
|
||||
|
||||
@@ -12,7 +12,6 @@ import { navigate } from "../../common/navigate";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import "../../components/entity/state-badge";
|
||||
import "../../components/ha-adaptive-dialog";
|
||||
import "../../components/ha-app-icon";
|
||||
import "../../components/ha-combo-box-item";
|
||||
import "../../components/ha-domain-icon";
|
||||
import "../../components/ha-icon";
|
||||
@@ -311,7 +310,6 @@ export class QuickBar extends LitElement {
|
||||
}
|
||||
|
||||
const iconPath = item.icon_path || mdiDevices;
|
||||
const iconColor = "iconColor" in item ? item.iconColor : undefined;
|
||||
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
@@ -336,65 +334,44 @@ export class QuickBar extends LitElement {
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
`
|
||||
: "app" in item && item.app
|
||||
: "image" in item && item.image
|
||||
? html`
|
||||
<ha-app-icon
|
||||
<img
|
||||
slot="start"
|
||||
.slug=${item.app.slug}
|
||||
.hasIcon=${item.app.icon}
|
||||
.alt=${item.primary ?? "Unknown"}
|
||||
class=${iconColor ? "colored" : nothing}
|
||||
alt=${item.primary ?? "Unknown"}
|
||||
.src=${item.image}
|
||||
style=${
|
||||
iconColor
|
||||
? `--app-icon-background-color: ${iconColor}`
|
||||
: nothing
|
||||
"iconColor" in item && item.iconColor
|
||||
? `background-color: ${item.iconColor}; padding: 4px; border-radius: var(--ha-border-radius-circle); width: 24px; height: 24px`
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
item.icon
|
||||
? html`<ha-icon .icon=${item.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon .path=${iconPath}></ha-svg-icon>`
|
||||
}
|
||||
</ha-app-icon>
|
||||
/>
|
||||
`
|
||||
: "image" in item && item.image
|
||||
? html`
|
||||
<img
|
||||
slot="start"
|
||||
alt=${item.primary ?? "Unknown"}
|
||||
.src=${item.image}
|
||||
style=${
|
||||
"iconColor" in item && item.iconColor
|
||||
? `background-color: ${item.iconColor}; padding: 4px; border-radius: var(--ha-border-radius-circle); width: 24px; height: 24px`
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
`
|
||||
: item.icon
|
||||
? html`<ha-icon
|
||||
style="margin: var(--ha-space-1);"
|
||||
slot="start"
|
||||
.icon=${item.icon}
|
||||
></ha-icon>`
|
||||
: "iconColor" in item && item.iconColor
|
||||
? html`
|
||||
<div
|
||||
slot="start"
|
||||
style=${`padding: 4px; border-radius: var(--ha-border-radius-circle); background-color: ${item.iconColor};`}
|
||||
>
|
||||
<ha-svg-icon
|
||||
style="color: var(--white-color); --mdc-icon-size: 24px;"
|
||||
.path=${iconPath}
|
||||
></ha-svg-icon>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
: item.icon
|
||||
? html`<ha-icon
|
||||
style="margin: var(--ha-space-1);"
|
||||
slot="start"
|
||||
.icon=${item.icon}
|
||||
></ha-icon>`
|
||||
: "iconColor" in item && item.iconColor
|
||||
? html`
|
||||
<div
|
||||
slot="start"
|
||||
style=${`padding: 4px; border-radius: var(--ha-border-radius-circle); background-color: ${item.iconColor};`}
|
||||
>
|
||||
<ha-svg-icon
|
||||
style="margin: var(--ha-space-1);"
|
||||
slot="start"
|
||||
style="color: var(--white-color); --mdc-icon-size: 24px;"
|
||||
.path=${iconPath}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<ha-svg-icon
|
||||
style="margin: var(--ha-space-1);"
|
||||
slot="start"
|
||||
.path=${iconPath}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
|
||||
@@ -86,7 +86,6 @@ import type { HassioStats } from "../../../../../data/hassio/common";
|
||||
import {
|
||||
extractApiErrorMessage,
|
||||
fetchHassioStats,
|
||||
supervisorUrl,
|
||||
} from "../../../../../data/hassio/common";
|
||||
import type { StoreAddonDetails } from "../../../../../data/supervisor/store";
|
||||
import {
|
||||
@@ -206,9 +205,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
<img
|
||||
class="logo"
|
||||
alt=""
|
||||
src=${supervisorUrl(
|
||||
`addons/${this._currentAddon.slug}/logo`
|
||||
)}
|
||||
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
|
||||
@@ -3,7 +3,6 @@ import { mdiCheckCircle, mdiHelpCircleOutline } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../components/ha-app-icon";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { AddonStage, AddonState } from "../../../../data/hassio/addon";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
@@ -42,28 +41,21 @@ class SupervisorAppsCardContent extends LitElement {
|
||||
|
||||
@property() public icon = mdiHelpCircleOutline;
|
||||
|
||||
@property({ attribute: false }) public appSlug?: string;
|
||||
|
||||
@property({ attribute: false }) public hasAppIcon?: boolean;
|
||||
@property({ attribute: false }) public iconImage?: string;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="app">
|
||||
<div class="icon-wrapper">
|
||||
${
|
||||
this.appSlug
|
||||
this.iconImage
|
||||
? html`
|
||||
<ha-app-icon
|
||||
.slug=${this.appSlug}
|
||||
.hasIcon=${this.hasAppIcon}
|
||||
.alt=${this.iconTitle ?? ""}
|
||||
.title=${this.iconTitle ?? ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
class="app-icon"
|
||||
.path=${this.icon}
|
||||
></ha-svg-icon>
|
||||
</ha-app-icon>
|
||||
<img
|
||||
class="icon-image"
|
||||
src=${this.iconImage}
|
||||
.title=${this.iconTitle}
|
||||
alt=${this.iconTitle ?? ""}
|
||||
/>
|
||||
`
|
||||
: html`
|
||||
<ha-svg-icon
|
||||
@@ -142,15 +134,15 @@ class SupervisorAppsCardContent extends LitElement {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.app-icon {
|
||||
margin-left: var(--ha-space-2);
|
||||
margin-top: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-app-icon {
|
||||
--ha-app-icon-size: 40px;
|
||||
.icon-image {
|
||||
max-height: 40px;
|
||||
max-width: 40px;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
|
||||
@@ -151,8 +151,11 @@ export class HaConfigAppsInstalled extends LitElement {
|
||||
"ui.panel.config.apps.installed.app_running"
|
||||
)
|
||||
}
|
||||
.appSlug=${addon.slug}
|
||||
.hasAppIcon=${addon.icon}
|
||||
.iconImage=${
|
||||
addon.icon
|
||||
? `/api/hassio/addons/${addon.slug}/icon`
|
||||
: undefined
|
||||
}
|
||||
></supervisor-apps-card-content>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
@@ -116,8 +116,11 @@ export class SupervisorAppsRepositoryEl extends LitElement {
|
||||
? "not_available"
|
||||
: ""
|
||||
}
|
||||
.appSlug=${addon.slug}
|
||||
.hasAppIcon=${addon.icon}
|
||||
.iconImage=${
|
||||
addon.icon
|
||||
? `/api/hassio/addons/${addon.slug}/icon`
|
||||
: undefined
|
||||
}
|
||||
.showTopbar=${addon.installed || !addon.available}
|
||||
.topbarClass=${
|
||||
addon.installed
|
||||
|
||||
@@ -4,10 +4,8 @@ import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import "../../../../components/ha-app-icon";
|
||||
import "../../../../components/ha-checkbox";
|
||||
import type { HaCheckbox } from "../../../../components/ha-checkbox";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "./ha-backup-formfield-label";
|
||||
|
||||
@@ -52,17 +50,13 @@ export class HaBackupAddonsPicker extends LitElement {
|
||||
<ha-backup-formfield-label
|
||||
.label=${item.name}
|
||||
.version=${this.hideVersion ? undefined : item.version}
|
||||
.iconPath=${item.iconPath || mdiPuzzle}
|
||||
.imageUrl=${
|
||||
this.addons?.find((a) => a.slug === item.slug)?.icon
|
||||
? `/api/hassio/addons/${item.slug}/icon`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ha-app-icon
|
||||
slot="icon"
|
||||
.slug=${item.slug}
|
||||
.hasIcon=${item.icon}
|
||||
loading="lazy"
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${item.iconPath || mdiPuzzle}
|
||||
></ha-svg-icon>
|
||||
</ha-app-icon>
|
||||
</ha-backup-formfield-label>
|
||||
</ha-checkbox>
|
||||
`
|
||||
@@ -92,10 +86,6 @@ export class HaBackupAddonsPicker extends LitElement {
|
||||
padding-inline-start: var(--ha-space-2);
|
||||
padding-bottom: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-app-icon {
|
||||
--ha-app-icon-size: var(--ha-space-6);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,25 +15,20 @@ class SupervisorFormfieldLabel extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<slot name="icon">
|
||||
${
|
||||
this.imageUrl
|
||||
? html`<img
|
||||
loading="lazy"
|
||||
alt=""
|
||||
src=${this.imageUrl}
|
||||
class="icon"
|
||||
/>`
|
||||
: this.iconPath
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${this.iconPath}
|
||||
class="icon"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</slot>
|
||||
${
|
||||
this.imageUrl
|
||||
? html`<img
|
||||
loading="lazy"
|
||||
alt=""
|
||||
src=${this.imageUrl}
|
||||
class="icon"
|
||||
/>`
|
||||
: this.iconPath
|
||||
? html`
|
||||
<ha-svg-icon .path=${this.iconPath} class="icon"></ha-svg-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<span class="label">
|
||||
${this.label}
|
||||
${
|
||||
|
||||
+21
-18
@@ -17,7 +17,6 @@ import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../../../common/config/is_component_loaded";
|
||||
import { caseInsensitiveStringCompare } from "../../../../../common/string/compare";
|
||||
import "../../../../../components/ha-alert";
|
||||
import "../../../../../components/ha-app-icon";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
@@ -205,12 +204,23 @@ export class SerialConfigDashboard extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _renderConsumerIcon(src: string, alt: string): TemplateResult {
|
||||
return html`<img
|
||||
slot="start"
|
||||
.src=${src}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${alt}
|
||||
/>`;
|
||||
}
|
||||
|
||||
// The panel the integration behind this consumer is configured in, if it has
|
||||
// one. A stopped consumer has no panel loaded to send the user to.
|
||||
private _consumerPanel(consumer: SerialPortConsumer): string | undefined {
|
||||
if (!consumer.active) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const domain =
|
||||
consumer.kind === "config_entry"
|
||||
? consumer.domain
|
||||
@@ -243,28 +253,22 @@ export class SerialConfigDashboard extends LitElement {
|
||||
<ha-md-list-item type="link" href=${href} class="consumer">
|
||||
${
|
||||
consumer.kind === "config_entry"
|
||||
? html`<img
|
||||
slot="start"
|
||||
.src=${brandsUrl(
|
||||
? this._renderConsumerIcon(
|
||||
brandsUrl(
|
||||
{
|
||||
domain: consumer.domain!,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
alt=${consumer.domain!}
|
||||
/>`
|
||||
),
|
||||
consumer.domain!
|
||||
)
|
||||
: consumer.kind === "app"
|
||||
? html`<ha-app-icon
|
||||
slot="start"
|
||||
.slug=${consumer.slug!}
|
||||
.alt=${consumer.title || consumer.slug!}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPuzzle}></ha-svg-icon>
|
||||
</ha-app-icon>`
|
||||
? this._renderConsumerIcon(
|
||||
`/api/hassio/addons/${consumer.slug}/icon`,
|
||||
consumer.slug!
|
||||
)
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiPuzzle}
|
||||
@@ -669,8 +673,7 @@ export class SerialConfigDashboard extends LitElement {
|
||||
--md-list-item-leading-space: var(--ha-space-14);
|
||||
}
|
||||
|
||||
ha-md-list-item.consumer img[slot="start"],
|
||||
ha-md-list-item.consumer ha-app-icon[slot="start"] {
|
||||
ha-md-list-item.consumer img[slot="start"] {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { stringCompare } from "../../../common/string/compare";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-app-icon";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
|
||||
@@ -25,10 +24,7 @@ import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
import type { LogProvider } from "../../../data/error_log";
|
||||
import {
|
||||
fetchHassioAddonsInfo,
|
||||
type HassioAddonInfo,
|
||||
} from "../../../data/hassio/addon";
|
||||
import { fetchHassioAddonsInfo } from "../../../data/hassio/addon";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { mdiHomeAssistant } from "../../../resources/home-assistant-logo-svg";
|
||||
@@ -65,11 +61,6 @@ const logProviders: LogProvider[] = [
|
||||
},
|
||||
];
|
||||
|
||||
interface LogProviderPickerItem extends PickerComboBoxItem {
|
||||
addon?: HassioAddonInfo;
|
||||
hasAppIcon?: boolean;
|
||||
}
|
||||
|
||||
@customElement("ha-config-logs")
|
||||
export class HaConfigLogs extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -147,9 +138,18 @@ export class HaConfigLogs extends LitElement {
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
${
|
||||
selectedProvider
|
||||
? this._renderProviderIcon(selectedProvider)
|
||||
: nothing
|
||||
selectedProvider?.icon
|
||||
? html`<img
|
||||
src=${selectedProvider.icon}
|
||||
alt=${selectedProvider.primary}
|
||||
slot="start"
|
||||
/>`
|
||||
: selectedProvider?.icon_path
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${selectedProvider.icon_path}
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${selectedProvider?.primary}
|
||||
<ha-svg-icon
|
||||
@@ -264,40 +264,33 @@ export class HaConfigLogs extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _getLogProviderItems = (): LogProviderPickerItem[] =>
|
||||
private _getLogProviderItems = (): PickerComboBoxItem[] =>
|
||||
this._logProviders.map((provider) => ({
|
||||
id: provider.key,
|
||||
primary: provider.name,
|
||||
addon: provider.addon,
|
||||
hasAppIcon: provider.addon
|
||||
icon: provider.addon
|
||||
? atLeastVersion(this.hass.config.version, 0, 105) &&
|
||||
provider.addon.icon
|
||||
? `/api/hassio/addons/${provider.addon.slug}/icon`
|
||||
: undefined
|
||||
: undefined,
|
||||
icon_path: provider.addon
|
||||
? mdiPuzzle
|
||||
: this._getProviderIconPath(provider.key),
|
||||
}));
|
||||
|
||||
private _renderProviderIcon(item: LogProviderPickerItem) {
|
||||
if (item.addon) {
|
||||
return html`<ha-app-icon
|
||||
slot="start"
|
||||
.alt=${item.primary}
|
||||
.hasIcon=${item.hasAppIcon}
|
||||
.slug=${item.addon.slug}
|
||||
>
|
||||
<ha-svg-icon .path=${item.icon_path}></ha-svg-icon>
|
||||
</ha-app-icon>`;
|
||||
}
|
||||
|
||||
return item.icon_path
|
||||
? html`<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _providerRenderer = (item: LogProviderPickerItem) => html`
|
||||
private _providerRenderer = (item: PickerComboBoxItem) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
${this._renderProviderIcon(item)}
|
||||
${
|
||||
item.icon
|
||||
? html`<img src=${item.icon} alt=${item.primary} slot="start" />`
|
||||
: item.icon_path
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.icon_path}
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
@@ -315,10 +308,11 @@ export class HaConfigLogs extends LitElement {
|
||||
return {
|
||||
id: provider.key,
|
||||
primary: provider.name,
|
||||
addon: provider.addon,
|
||||
hasAppIcon: provider.addon
|
||||
icon: provider.addon
|
||||
? atLeastVersion(this.hass.config.version, 0, 105) &&
|
||||
provider.addon.icon
|
||||
? `/api/hassio/addons/${provider.addon.slug}/icon`
|
||||
: undefined
|
||||
: undefined,
|
||||
icon_path: provider.addon
|
||||
? mdiPuzzle
|
||||
@@ -371,8 +365,8 @@ export class HaConfigLogs extends LitElement {
|
||||
--mdc-icon-size: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-app-icon {
|
||||
--ha-app-icon-size: 32px;
|
||||
img {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
@media all and (max-width: 870px) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { resolveHassUrl } from "../common/url/hass-url";
|
||||
import { isExternalAndroid } from "../data/external";
|
||||
|
||||
// 10 seconds gives the Android WebView download listener enough time
|
||||
@@ -10,7 +9,7 @@ const BLOB_REVOKE_DELAY_MS = 10_000;
|
||||
export const fileDownload = (href: string, filename = ""): void => {
|
||||
const element = document.createElement("a");
|
||||
element.target = "_blank";
|
||||
element.href = resolveHassUrl(href);
|
||||
element.href = href;
|
||||
element.download = filename;
|
||||
element.style.display = "none";
|
||||
document.body.appendChild(element);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { HaAppIcon } from "../../src/components/ha-app-icon";
|
||||
import "../../src/components/ha-app-icon";
|
||||
|
||||
let appIcon: HaAppIcon | undefined;
|
||||
|
||||
const mountAppIcon = async (properties: Partial<HaAppIcon> = {}) => {
|
||||
appIcon = document.createElement("ha-app-icon");
|
||||
Object.assign(appIcon, properties);
|
||||
appIcon.append(document.createElement("ha-svg-icon"));
|
||||
document.body.append(appIcon);
|
||||
await appIcon.updateComplete;
|
||||
return appIcon;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
appIcon?.remove();
|
||||
appIcon = undefined;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ha-app-icon", () => {
|
||||
it("does not request an unavailable icon", async () => {
|
||||
const element = await mountAppIcon({ slug: "example", hasIcon: false });
|
||||
|
||||
expect(element.shadowRoot!.querySelector("img")).toBeNull();
|
||||
expect(element.shadowRoot!.querySelector("slot")).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([true, undefined])(
|
||||
"requests the icon when availability is %s",
|
||||
async (hasIcon) => {
|
||||
const element = await mountAppIcon({ slug: "example", hasIcon });
|
||||
|
||||
expect(
|
||||
element.shadowRoot!.querySelector("img")!.getAttribute("src")
|
||||
).toBe("/api/hassio/addons/example/icon");
|
||||
}
|
||||
);
|
||||
|
||||
it("requests the icon from the configured Home Assistant URL", async () => {
|
||||
vi.stubGlobal("__HASS_URL__", "http://homeassistant.local:8123");
|
||||
const element = await mountAppIcon({ slug: "example", hasIcon: true });
|
||||
const image = element.shadowRoot!.querySelector("img")!;
|
||||
|
||||
expect(image.getAttribute("src")).toBe(
|
||||
"http://homeassistant.local:8123/api/hassio/addons/example/icon"
|
||||
);
|
||||
expect(image.hasAttribute("crossorigin")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders the fallback after an image error", async () => {
|
||||
const element = await mountAppIcon({ slug: "example", hasIcon: true });
|
||||
|
||||
element.shadowRoot!.querySelector("img")!.dispatchEvent(new Event("error"));
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.shadowRoot!.querySelector("img")).toBeNull();
|
||||
expect(element.shadowRoot!.querySelector("slot")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("tries a new source after the slug changes", async () => {
|
||||
const element = await mountAppIcon({ slug: "first", hasIcon: true });
|
||||
element.shadowRoot!.querySelector("img")!.dispatchEvent(new Event("error"));
|
||||
await element.updateComplete;
|
||||
|
||||
element.slug = "second";
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
|
||||
"/api/hassio/addons/second/icon"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an error from a previous source", async () => {
|
||||
const element = await mountAppIcon({ slug: "first", hasIcon: true });
|
||||
const firstImage = element.shadowRoot!.querySelector("img")!;
|
||||
|
||||
element.slug = "second";
|
||||
await element.updateComplete;
|
||||
firstImage.dispatchEvent(new Event("error"));
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
|
||||
"/api/hassio/addons/second/icon"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores an old error after returning to the same source", async () => {
|
||||
const element = await mountAppIcon({ slug: "first", hasIcon: true });
|
||||
const firstImage = element.shadowRoot!.querySelector("img")!;
|
||||
|
||||
element.slug = "second";
|
||||
await element.updateComplete;
|
||||
element.slug = "first";
|
||||
await element.updateComplete;
|
||||
firstImage.dispatchEvent(new Event("error"));
|
||||
await element.updateComplete;
|
||||
|
||||
expect(element.shadowRoot!.querySelector("img")!.getAttribute("src")).toBe(
|
||||
"/api/hassio/addons/first/icon"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -36,7 +36,6 @@ describe("fileDownload", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
delete (window as any).externalApp;
|
||||
delete (window as any).externalAppV2;
|
||||
});
|
||||
@@ -59,16 +58,6 @@ describe("fileDownload", () => {
|
||||
expect(URL.revokeObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves relative URLs against the configured Home Assistant URL", async () => {
|
||||
vi.stubGlobal("__HASS_URL__", "http://homeassistant.local:8123");
|
||||
await loadFileDownload();
|
||||
fileDownload("/api/hassio/core/logs/follow");
|
||||
|
||||
expect(createdElement.href).toBe(
|
||||
"http://homeassistant.local:8123/api/hassio/core/logs/follow"
|
||||
);
|
||||
});
|
||||
|
||||
it("revokes blob URLs immediately outside Android", async () => {
|
||||
await loadFileDownload();
|
||||
fileDownload("blob:http://localhost/abc-123", "file.json");
|
||||
|
||||
Reference in New Issue
Block a user