mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-11 22:31:45 +00:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a927fa059 | ||
|
|
a4dd4344e9 | ||
|
|
0bd742e106 | ||
|
|
e57b337bb4 | ||
|
|
b55912844f | ||
|
|
47a789b64f | ||
|
|
2ae8e2b777 | ||
|
|
6a135af0da | ||
|
|
c263b14629 | ||
|
|
8de9063a09 | ||
|
|
adc87d7f43 | ||
|
|
0ebff36c70 | ||
|
|
87c70bbabd | ||
|
|
dda34bda66 | ||
|
|
e99b1b38a1 | ||
|
|
78b0c8e59a | ||
|
|
74d87f3861 | ||
|
|
3cd2422cce | ||
|
|
36b95468d6 | ||
|
|
b297f369dd | ||
|
|
e3411c0be5 | ||
|
|
1fd57d19e3 | ||
|
|
655d809603 | ||
|
|
99f5f0c428 | ||
|
|
ccd284b91b | ||
|
|
ad6df7ac81 | ||
|
|
7cec2a2d6f | ||
|
|
5f1254983a | ||
|
|
017ce0dd1e | ||
|
|
8e5d5e1311 | ||
|
|
b72f7529e1 | ||
|
|
d960c67884 | ||
|
|
dd61d9934b | ||
|
|
21855a80c1 | ||
|
|
5f1a10f844 | ||
|
|
ed87bd316d | ||
|
|
e270ba79a2 | ||
|
|
f651afe1fe | ||
|
|
f4398b688d |
File diff suppressed because one or more lines are too long
@@ -110,7 +110,6 @@
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.4.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
@@ -163,7 +162,6 @@
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.5",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
|
||||
|
||||
/** Hit target for the radius handle; comfortably above touch minimums */
|
||||
export const RESIZE_HANDLE_SIZE = 24;
|
||||
|
||||
/** The visible dot inside the hit target */
|
||||
export const RESIZE_HANDLE_DOT_SIZE = 12;
|
||||
|
||||
/** Relative radius change per arrow key press on the handle */
|
||||
export const RESIZE_KEY_STEP = 0.1;
|
||||
|
||||
/** Advertised slider maximum; a larger radius raises it (see the engine) */
|
||||
export const RADIUS_ARIA_MAX = 100000;
|
||||
|
||||
export const createResizeHandleElement = (label?: string): HTMLElement => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "editable-circle-resize";
|
||||
element.tabIndex = 0;
|
||||
element.setAttribute("role", "slider");
|
||||
element.setAttribute("aria-valuemin", "1");
|
||||
element.setAttribute("aria-valuemax", String(RADIUS_ARIA_MAX));
|
||||
if (label) {
|
||||
element.setAttribute("aria-label", label);
|
||||
}
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "editable-circle-resize-dot";
|
||||
element.appendChild(dot);
|
||||
return element;
|
||||
};
|
||||
|
||||
/** Styles for the handles, included by ha-map for both engines */
|
||||
export const editableCircleStyles = `
|
||||
.editable-circle-center {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
border: 2px solid var(--card-background-color, #fff);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
.editable-circle-resize {
|
||||
width: ${RESIZE_HANDLE_SIZE}px;
|
||||
height: ${RESIZE_HANDLE_SIZE}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.editable-circle-resize-dot {
|
||||
width: ${RESIZE_HANDLE_DOT_SIZE}px;
|
||||
height: ${RESIZE_HANDLE_DOT_SIZE}px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-background-color, #fff);
|
||||
border: 2px solid var(--primary-color);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
`;
|
||||
@@ -34,17 +34,9 @@ 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.
|
||||
*/
|
||||
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
|
||||
export class LeafletMapEngine implements MapEngine {
|
||||
/**
|
||||
* Escape hatch for ha-locations-editor, which manages its own Leaflet
|
||||
* layers (leaflet-draw). Not for use anywhere else.
|
||||
*/
|
||||
/** For the ha-map jsdom tests only */
|
||||
public leafletMap?: Map;
|
||||
|
||||
public Leaflet?: LeafletModuleType;
|
||||
@@ -197,6 +189,14 @@ export class LeafletMapEngine implements MapEngine {
|
||||
});
|
||||
}
|
||||
|
||||
public panTo(location: MapLatLng): void {
|
||||
this.leafletMap?.panTo(location);
|
||||
}
|
||||
|
||||
public containsLocation(location: MapLatLng): boolean {
|
||||
return this.leafletMap?.getBounds().contains(location) ?? false;
|
||||
}
|
||||
|
||||
public addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
@@ -213,10 +213,8 @@ export class LeafletMapEngine implements MapEngine {
|
||||
// Leaflet's keyboard support focuses its own wrapper, where the element's
|
||||
// activation handlers never hear a key; the element itself takes focus
|
||||
const interactive = options.interactive ?? true;
|
||||
if (interactive) {
|
||||
element.tabIndex = 0;
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, interactive);
|
||||
const focusable = options.focusable ?? interactive;
|
||||
setMarkerAccessibility(element, options.title, focusable);
|
||||
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
|
||||
icon: this.Leaflet!.divIcon({
|
||||
html: element,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||
import type {
|
||||
GeoJSONSource,
|
||||
IControl,
|
||||
LayerSpecification,
|
||||
Map as MapLibreMap,
|
||||
@@ -9,7 +10,10 @@ import type {
|
||||
StyleSpecification,
|
||||
} from "maplibre-gl";
|
||||
import type maplibregl from "maplibre-gl";
|
||||
import { setMarkerAccessibility } from "../marker-accessibility";
|
||||
import {
|
||||
clearMarkerAccessibility,
|
||||
setMarkerAccessibility,
|
||||
} from "../marker-accessibility";
|
||||
import {
|
||||
CONTEXT_RESTORE_GRACE,
|
||||
ensureRTLTextPlugin,
|
||||
@@ -29,16 +33,27 @@ import type {
|
||||
MapCircleOptions,
|
||||
MapClusterOptions,
|
||||
MapControlPosition,
|
||||
MapDraggableMarkerOptions,
|
||||
MapEditableCircleHandle,
|
||||
MapEditableCircleOptions,
|
||||
MapEditableMarkerHandle,
|
||||
MapEditingSupport,
|
||||
MapEngine,
|
||||
MapMarkerHandle,
|
||||
MapEngineEvents,
|
||||
MapEngineOptions,
|
||||
MapFitOptions,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapMarkerOptions,
|
||||
MapPath,
|
||||
} from "../map-engine";
|
||||
import { destinationPoint, distanceMeters, pointEastOf } from "../map-engine";
|
||||
import {
|
||||
createResizeHandleElement,
|
||||
RADIUS_ARIA_MAX,
|
||||
RESIZE_KEY_STEP,
|
||||
} from "../editable-circle";
|
||||
|
||||
type MapLibreModule = typeof maplibregl;
|
||||
|
||||
@@ -79,6 +94,7 @@ const resetMarkerElement = (element: HTMLElement): void => {
|
||||
element.style.transform = "";
|
||||
element.style.opacity = "";
|
||||
element.style.pointerEvents = "";
|
||||
element.style.cursor = "";
|
||||
};
|
||||
|
||||
// A meter-radius circle as a polygon (spherical approximation)
|
||||
@@ -87,16 +103,10 @@ const circlePolygon = (
|
||||
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),
|
||||
]);
|
||||
const point = destinationPoint(center, radiusMeters, (360 * i) / steps);
|
||||
ring.push([point[1], point[0]]);
|
||||
}
|
||||
return {
|
||||
type: "Feature",
|
||||
@@ -109,7 +119,10 @@ interface ManagedMarker {
|
||||
element: HTMLElement;
|
||||
location: MapLatLng;
|
||||
options: MapMarkerOptions;
|
||||
handle: MapMarkerHandle;
|
||||
handle: MapEditableMarkerHandle;
|
||||
draggable?: boolean;
|
||||
onDragEnd?: (location: MapLatLng) => void;
|
||||
dragging?: boolean;
|
||||
mlMarker?: MapLibreMarker;
|
||||
decoration?: MapItemHandle;
|
||||
removed?: boolean;
|
||||
@@ -183,6 +196,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
private _settleInit?: () => void;
|
||||
|
||||
// Caller-owned elements on the map, reset when they leave it: a host may
|
||||
// reuse them on another engine after a fallback
|
||||
private _placedElements = new Set<HTMLElement>();
|
||||
|
||||
public async init(
|
||||
container: HTMLElement,
|
||||
options: MapEngineOptions
|
||||
@@ -348,6 +365,11 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
this._clusterGroups = [];
|
||||
this._markers = [];
|
||||
this._pendingStyleOps = [];
|
||||
this._placedElements.forEach((element) => {
|
||||
resetMarkerElement(element);
|
||||
clearMarkerAccessibility(element);
|
||||
});
|
||||
this._placedElements.clear();
|
||||
this._map?.remove();
|
||||
this._map = undefined;
|
||||
}
|
||||
@@ -411,12 +433,14 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
}
|
||||
|
||||
private _carryCustomLayers(
|
||||
previous: StyleSpecification | undefined,
|
||||
_previous: StyleSpecification | undefined,
|
||||
next: StyleSpecification
|
||||
): StyleSpecification {
|
||||
const sources = { ...next.sources };
|
||||
// Our record, not MapLibre's serialization: an update made while the
|
||||
// style was unloaded only reached the record
|
||||
for (const [id, source] of this._customSources) {
|
||||
sources[id] = previous?.sources?.[id] ?? source;
|
||||
sources[id] = source;
|
||||
}
|
||||
const nextIds = new Set(next.layers.map((layer) => layer.id));
|
||||
const customLayers = [...this._customLayers.values()].filter(
|
||||
@@ -513,36 +537,82 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
);
|
||||
}
|
||||
|
||||
public panTo(location: MapLatLng): void {
|
||||
this._map?.panTo([location[1], location[0]]);
|
||||
}
|
||||
|
||||
public containsLocation(location: MapLatLng): boolean {
|
||||
return this._map?.getBounds().contains([location[1], location[0]]) ?? false;
|
||||
}
|
||||
|
||||
public addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle {
|
||||
return this._addMarker(element, location, options);
|
||||
}
|
||||
|
||||
public editing: MapEditingSupport = {
|
||||
addDraggableMarker: (element, location, options) =>
|
||||
this._addMarker(element, location, options, true),
|
||||
addEditableCircle: (center, options) =>
|
||||
this._addEditableCircle(center, options),
|
||||
};
|
||||
|
||||
private _addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapDraggableMarkerOptions,
|
||||
draggable = false
|
||||
): MapEditableMarkerHandle {
|
||||
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;
|
||||
} else {
|
||||
const interactive = options.interactive ?? true;
|
||||
const focusable = options.focusable ?? interactive;
|
||||
if (!interactive) {
|
||||
// Leaflet lets input through non-interactive markers; MapLibre does not
|
||||
element.style.pointerEvents = "none";
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, options.interactive ?? true);
|
||||
setMarkerAccessibility(element, options.title, focusable);
|
||||
if (draggable) {
|
||||
// The engine, not the host, knows whether this element really drags
|
||||
element.style.cursor = "move";
|
||||
}
|
||||
this._placedElements.add(element);
|
||||
|
||||
const managed: ManagedMarker = {
|
||||
element,
|
||||
location,
|
||||
options,
|
||||
handle: undefined as unknown as MapMarkerHandle,
|
||||
draggable,
|
||||
onDragEnd: options.onDragEnd,
|
||||
handle: undefined as unknown as MapEditableMarkerHandle,
|
||||
};
|
||||
managed.handle = {
|
||||
location,
|
||||
get location() {
|
||||
return managed.location;
|
||||
},
|
||||
clusterData: options.clusterData,
|
||||
setLocation: (newLocation) => {
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
if (managed.dragging) {
|
||||
return;
|
||||
}
|
||||
managed.location = newLocation;
|
||||
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
|
||||
},
|
||||
remove: () => {
|
||||
managed.removed = true;
|
||||
this._hideMarker(managed);
|
||||
// Still inside an open cluster bubble otherwise
|
||||
element.remove();
|
||||
resetMarkerElement(element);
|
||||
clearMarkerAccessibility(element);
|
||||
this._placedElements.delete(element);
|
||||
const index = this._markers.indexOf(managed);
|
||||
if (index !== -1) {
|
||||
this._markers.splice(index, 1);
|
||||
@@ -566,6 +636,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const { options } = managed;
|
||||
managed.mlMarker = new this._maplibre.Marker({
|
||||
element: managed.element,
|
||||
draggable: managed.draggable ?? false,
|
||||
...(options.anchor
|
||||
? {
|
||||
anchor: "top-left" as const,
|
||||
@@ -578,6 +649,17 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
})
|
||||
.setLngLat([managed.location[1], managed.location[0]])
|
||||
.addTo(this._map);
|
||||
if (managed.draggable) {
|
||||
managed.mlMarker.on("dragstart", () => {
|
||||
managed.dragging = true;
|
||||
});
|
||||
managed.mlMarker.on("dragend", () => {
|
||||
managed.dragging = false;
|
||||
const lngLat = managed.mlMarker!.getLngLat();
|
||||
managed.location = [lngLat.lat, lngLat.lng];
|
||||
managed.onDragEnd?.(managed.location);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (managed.options.decoration && !managed.decoration) {
|
||||
managed.decoration = this.addCircle(
|
||||
@@ -624,6 +706,243 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
};
|
||||
}
|
||||
|
||||
private _addEditableCircle(
|
||||
center: MapLatLng,
|
||||
options: MapEditableCircleOptions
|
||||
): MapEditableCircleHandle {
|
||||
if (!this._map || !this._maplibre) {
|
||||
return {
|
||||
center,
|
||||
radius: options.radius,
|
||||
update: () => undefined,
|
||||
remove: () => undefined,
|
||||
};
|
||||
}
|
||||
const map = this._map;
|
||||
const maplibre = this._maplibre;
|
||||
let currentCenter = center;
|
||||
let currentRadius = options.radius;
|
||||
|
||||
const id = `${CUSTOM_PREFIX}editable-${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 },
|
||||
});
|
||||
// Redraw at most once per frame while dragging
|
||||
let frame: number | undefined;
|
||||
const redraw = () => {
|
||||
if (frame !== undefined) {
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined;
|
||||
this._setCustomSourceData(
|
||||
id,
|
||||
circlePolygon(currentCenter, currentRadius)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const centerSize = options.centerSize ?? [16, 16];
|
||||
const centerEl = options.centerElement ?? document.createElement("div");
|
||||
if (!options.centerElement) {
|
||||
centerEl.className = "editable-circle-center";
|
||||
}
|
||||
centerEl.style.width = `${centerSize[0]}px`;
|
||||
centerEl.style.height = `${centerSize[1]}px`;
|
||||
if (options.title) {
|
||||
centerEl.title = options.title;
|
||||
}
|
||||
setMarkerAccessibility(centerEl, options.title, !!options.onClick);
|
||||
if (options.moveable) {
|
||||
centerEl.style.cursor = "move";
|
||||
}
|
||||
this._placedElements.add(centerEl);
|
||||
const centerMarker = new maplibre.Marker({
|
||||
element: centerEl,
|
||||
draggable: options.moveable ?? false,
|
||||
})
|
||||
.setLngLat([center[1], center[0]])
|
||||
.addTo(map);
|
||||
|
||||
let resizeMarker: MapLibreMarker | undefined;
|
||||
let resizeHandle: HTMLElement | undefined;
|
||||
const placeResizeHandle = () => {
|
||||
const east = pointEastOf(currentCenter, currentRadius);
|
||||
resizeMarker?.setLngLat([east[1], east[0]]);
|
||||
const radiusText = String(Math.round(currentRadius));
|
||||
resizeHandle?.setAttribute(
|
||||
"aria-valuemax",
|
||||
String(Math.max(RADIUS_ARIA_MAX, Math.round(currentRadius)))
|
||||
);
|
||||
resizeHandle?.setAttribute("aria-valuenow", radiusText);
|
||||
// Without a value text the value is read as a percentage of the range
|
||||
resizeHandle?.setAttribute("aria-valuetext", radiusText);
|
||||
};
|
||||
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
let dragging = false;
|
||||
|
||||
if (options.resizable) {
|
||||
const east = pointEastOf(center, options.radius);
|
||||
resizeHandle = createResizeHandleElement(options.resizeLabel);
|
||||
resizeMarker = new maplibre.Marker({
|
||||
element: resizeHandle,
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat([east[1], east[0]])
|
||||
.addTo(map);
|
||||
placeResizeHandle();
|
||||
resizeMarker.on("drag", () => {
|
||||
const lngLat = resizeMarker!.getLngLat();
|
||||
currentRadius = Math.max(
|
||||
1,
|
||||
distanceMeters(currentCenter, [lngLat.lat, lngLat.lng])
|
||||
);
|
||||
redraw();
|
||||
});
|
||||
resizeMarker.on("dragend", () => {
|
||||
// Snap the handle back onto the east edge
|
||||
placeResizeHandle();
|
||||
options.onResize?.(currentRadius);
|
||||
});
|
||||
// Arrow keys resize in steps, committed on key release
|
||||
let keyboardRadius: number | undefined;
|
||||
resizeHandle.addEventListener("keydown", (ev) => {
|
||||
const direction =
|
||||
ev.key === "ArrowRight" || ev.key === "ArrowUp"
|
||||
? 1
|
||||
: ev.key === "ArrowLeft" || ev.key === "ArrowDown"
|
||||
? -1
|
||||
: 0;
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
// MapLibre pans on arrow keys reaching the map
|
||||
ev.stopPropagation();
|
||||
if (keyboardRadius === undefined) {
|
||||
// Host updates treat a key resize like a drag
|
||||
dragging = true;
|
||||
}
|
||||
currentRadius = Math.max(
|
||||
1,
|
||||
currentRadius * (1 + direction * RESIZE_KEY_STEP)
|
||||
);
|
||||
keyboardRadius = currentRadius;
|
||||
placeResizeHandle();
|
||||
redraw();
|
||||
});
|
||||
const commitKeyboardResize = () => {
|
||||
if (keyboardRadius !== undefined) {
|
||||
keyboardRadius = undefined;
|
||||
dragging = false;
|
||||
options.onResize?.(currentRadius);
|
||||
}
|
||||
};
|
||||
resizeHandle.addEventListener("keyup", commitKeyboardResize);
|
||||
// Focus can leave while a key is still held
|
||||
resizeHandle.addEventListener("blur", commitKeyboardResize);
|
||||
// Like the other markers: a click on the handle is not a map click
|
||||
resizeHandle.addEventListener("click", (ev) => ev.stopPropagation());
|
||||
}
|
||||
|
||||
[centerMarker, resizeMarker].forEach((handleMarker) => {
|
||||
handleMarker?.on("dragstart", () => {
|
||||
dragging = true;
|
||||
});
|
||||
handleMarker?.on("dragend", () => {
|
||||
dragging = false;
|
||||
});
|
||||
});
|
||||
|
||||
if (options.moveable) {
|
||||
centerMarker.on("drag", () => {
|
||||
const lngLat = centerMarker.getLngLat();
|
||||
currentCenter = [lngLat.lat, lngLat.lng];
|
||||
placeResizeHandle();
|
||||
redraw();
|
||||
});
|
||||
centerMarker.on("dragend", () => options.onMove?.(currentCenter));
|
||||
}
|
||||
|
||||
// The center element may be reused for a rebuilt circle; its listeners go with this one
|
||||
let removeCenterListeners: (() => void) | undefined;
|
||||
if (options.onClick) {
|
||||
// A drag can end in a click; only one with its own pointer down counts
|
||||
let dragged = false;
|
||||
centerMarker.on("dragstart", () => {
|
||||
dragged = true;
|
||||
});
|
||||
const onPointerDown = () => {
|
||||
dragged = false;
|
||||
};
|
||||
const onClick = (ev: MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (dragged) {
|
||||
return;
|
||||
}
|
||||
options.onClick!();
|
||||
};
|
||||
const onKeydown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
options.onClick!();
|
||||
}
|
||||
};
|
||||
centerEl.addEventListener("pointerdown", onPointerDown);
|
||||
centerEl.addEventListener("click", onClick);
|
||||
centerEl.addEventListener("keydown", onKeydown);
|
||||
removeCenterListeners = () => {
|
||||
centerEl.removeEventListener("pointerdown", onPointerDown);
|
||||
centerEl.removeEventListener("click", onClick);
|
||||
centerEl.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get center() {
|
||||
return currentCenter;
|
||||
},
|
||||
get radius() {
|
||||
return currentRadius;
|
||||
},
|
||||
update: (newCenter, newRadius) => {
|
||||
if (dragging) {
|
||||
return;
|
||||
}
|
||||
currentCenter = newCenter;
|
||||
currentRadius = newRadius;
|
||||
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
|
||||
placeResizeHandle();
|
||||
redraw();
|
||||
},
|
||||
remove: () => {
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
removeCenterListeners?.();
|
||||
centerMarker.remove();
|
||||
resetMarkerElement(centerEl);
|
||||
clearMarkerAccessibility(centerEl);
|
||||
this._placedElements.delete(centerEl);
|
||||
resizeMarker?.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 };
|
||||
@@ -760,40 +1079,58 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
id: string,
|
||||
data: Feature<Polygon> | FeatureCollection
|
||||
): void {
|
||||
// Recorded now, so an update while the style loads reaches the record and
|
||||
// a swap in between carries it; the map itself may already have it then
|
||||
const source: GeoJSONSourceSpecification = { type: "geojson", data };
|
||||
this._customSources.set(id, source);
|
||||
this._whenStyleLoaded(() => {
|
||||
const source: GeoJSONSourceSpecification = { type: "geojson", data };
|
||||
this._map!.addSource(id, source);
|
||||
this._customSources.set(id, source);
|
||||
if (!this._map!.getSource(id)) {
|
||||
this._map!.addSource(id, source);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _setCustomSourceData(
|
||||
id: string,
|
||||
data: Feature<Polygon> | FeatureCollection
|
||||
): void {
|
||||
const source = this._customSources.get(id);
|
||||
if (source) {
|
||||
source.data = data;
|
||||
}
|
||||
(this._map?.getSource(id) as GeoJSONSource | undefined)?.setData(data);
|
||||
}
|
||||
|
||||
private _addCustomLayer(layer: LayerSpecification): void {
|
||||
this._customLayers.set(layer.id, layer);
|
||||
this._whenStyleLoaded(() => {
|
||||
if (this._map!.getLayer(layer.id)) {
|
||||
return;
|
||||
}
|
||||
// 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.set(layer.id, layer);
|
||||
});
|
||||
}
|
||||
|
||||
private _removeCustomLayer(id: string): void {
|
||||
this._customLayers.delete(id);
|
||||
this._whenStyleLoaded(() => {
|
||||
if (this._map!.getLayer(id)) {
|
||||
this._map!.removeLayer(id);
|
||||
}
|
||||
this._customLayers.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
private _removeCustomSource(id: string): void {
|
||||
this._customSources.delete(id);
|
||||
this._whenStyleLoaded(() => {
|
||||
if (this._map!.getSource(id)) {
|
||||
this._map!.removeSource(id);
|
||||
}
|
||||
this._customSources.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -852,13 +1189,16 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const clusterable = this._markers.filter(
|
||||
(managed) => managed.options.cluster && !managed.removed
|
||||
);
|
||||
// A member that had focus hands it to the icon replacing it; read before
|
||||
// the open bubble holding it is removed
|
||||
// A member or bubble that had focus hands it to the icon replacing it;
|
||||
// read before the bubble holding it is removed
|
||||
const active = (
|
||||
this._map.getContainer().getRootNode() as Document | ShadowRoot
|
||||
).activeElement;
|
||||
const focusedMember = active
|
||||
? clusterable.find((managed) => managed.element.contains(active))
|
||||
? (clusterable.find((managed) => managed.element.contains(active)) ??
|
||||
this._clusterGroups.find((group) =>
|
||||
group.iconMarker?.getElement().contains(active)
|
||||
)?.members[0])
|
||||
: undefined;
|
||||
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
|
||||
|
||||
@@ -973,7 +1313,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
group.iconMarker?.remove();
|
||||
this._openGroup(group);
|
||||
};
|
||||
icon.element.tabIndex = 0;
|
||||
setMarkerAccessibility(
|
||||
icon.element,
|
||||
group.members
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getColorByIndex } from "../color/colors";
|
||||
import { computeDomain } from "../entity/compute_domain";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
|
||||
/**
|
||||
* Map colors for entities, by registry creation order, so an entity has the
|
||||
* same color on every map. The registry comes from the fullEntitiesContext;
|
||||
* entities without an entry (e.g. YAML zones) get a color derived from their
|
||||
* id.
|
||||
*/
|
||||
|
||||
export const HOME_ZONE_ENTITY_ID = "zone.home";
|
||||
|
||||
/** Domains whose entities are colored by creation order */
|
||||
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
|
||||
|
||||
// One index per registry update, shared by every map on the page. Zones,
|
||||
// persons and trackers share one sequence, so a person never has the color
|
||||
// of the zone it is in.
|
||||
const creationIndex = memoizeOne(
|
||||
(entries: EntityRegistryEntry[]): Record<string, number> => {
|
||||
const index: Record<string, number> = {};
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
|
||||
// The home zone has a fixed color and does not take a palette slot
|
||||
entry.entity_id !== HOME_ZONE_ENTITY_ID
|
||||
)
|
||||
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
|
||||
.forEach((entry, i) => {
|
||||
index[entry.entity_id] = i;
|
||||
});
|
||||
return index;
|
||||
}
|
||||
);
|
||||
|
||||
// For entities without a registry entry
|
||||
const hashIndex = (entityId: string): number => {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < entityId.length; i++) {
|
||||
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
/** The palette color of an entity on a map */
|
||||
export const entityMapColor = (
|
||||
entityId: string,
|
||||
entries: EntityRegistryEntry[],
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string =>
|
||||
getColorByIndex(
|
||||
creationIndex(entries)[entityId] ?? hashIndex(entityId),
|
||||
computedStyles
|
||||
);
|
||||
|
||||
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
|
||||
export const zoneColor = (
|
||||
entityId: string,
|
||||
passive: boolean,
|
||||
entries: EntityRegistryEntry[],
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string => {
|
||||
if (entityId === HOME_ZONE_ENTITY_ID) {
|
||||
return computedStyles.getPropertyValue("--primary-color");
|
||||
}
|
||||
if (passive) {
|
||||
return computedStyles.getPropertyValue("--secondary-text-color");
|
||||
}
|
||||
return entityMapColor(entityId, entries, computedStyles);
|
||||
};
|
||||
+132
-37
@@ -1,27 +1,14 @@
|
||||
/**
|
||||
* Engine abstraction for ha-map.
|
||||
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
|
||||
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
|
||||
* contract; new capabilities go on MapLibre only, as optional members like
|
||||
* `editing`.
|
||||
*
|
||||
* 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).
|
||||
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
|
||||
*/
|
||||
|
||||
export type MapLatLng = [latitude: number, longitude: number];
|
||||
|
||||
export interface MapPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type MapControlPosition =
|
||||
"topleft" | "topright" | "bottomleft" | "bottomright";
|
||||
|
||||
@@ -62,8 +49,10 @@ export interface MapMarkerOptions {
|
||||
size: [width: number, height: number];
|
||||
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
|
||||
anchor?: [x: number, y: number];
|
||||
/** Takes pointer input and keyboard focus; defaults to true */
|
||||
/** Takes pointer input; defaults to true */
|
||||
interactive?: boolean;
|
||||
/** A keyboard-focusable button, for markers that act on activation; defaults to interactive */
|
||||
focusable?: boolean;
|
||||
/** Accessible name */
|
||||
title?: string;
|
||||
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
|
||||
@@ -109,6 +98,58 @@ export interface MapMarkerHandle extends MapItemHandle {
|
||||
readonly clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
|
||||
onDragEnd?(location: MapLatLng): void;
|
||||
}
|
||||
|
||||
export interface MapEditableCircleOptions {
|
||||
/** Radius in meters */
|
||||
radius: number;
|
||||
/** Stroke color; the fill is derived from it, translucent */
|
||||
color: string;
|
||||
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
|
||||
centerElement?: HTMLElement;
|
||||
centerSize?: [width: number, height: number];
|
||||
title?: string;
|
||||
/** The center can be dragged */
|
||||
moveable?: boolean;
|
||||
/** A handle on the edge can be dragged to change the radius */
|
||||
resizable?: boolean;
|
||||
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
|
||||
resizeLabel?: string;
|
||||
onMove?(center: MapLatLng): void;
|
||||
onResize?(radius: number): void;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
export interface MapEditingSupport {
|
||||
/** Place a draggable HTML element marker */
|
||||
addDraggableMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapDraggableMarkerOptions
|
||||
): MapEditableMarkerHandle;
|
||||
|
||||
/** Draw a circle whose center and radius can be dragged */
|
||||
addEditableCircle(
|
||||
center: MapLatLng,
|
||||
options: MapEditableCircleOptions
|
||||
): MapEditableCircleHandle;
|
||||
}
|
||||
|
||||
/** A circle with drag handles for its center and radius */
|
||||
export interface MapEditableCircleHandle extends MapItemHandle {
|
||||
readonly center: MapLatLng;
|
||||
readonly radius: number;
|
||||
/** Move and resize without recreating (no-op mid-drag) */
|
||||
update(center: MapLatLng, radius: number): void;
|
||||
}
|
||||
|
||||
export interface MapEditableMarkerHandle extends MapMarkerHandle {
|
||||
/** Move without recreating (no-op mid-drag) */
|
||||
setLocation(location: MapLatLng): void;
|
||||
}
|
||||
|
||||
export interface MapClusterIcon {
|
||||
element: HTMLElement;
|
||||
size: [width: number, height: number];
|
||||
@@ -159,13 +200,13 @@ export interface MapEngine {
|
||||
/** Fit the given points into view; a single point centers on it */
|
||||
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
|
||||
|
||||
// Content ------------------------------------------------------------
|
||||
/** Pan to the location, keeping the zoom */
|
||||
panTo(location: MapLatLng): void;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Whether the location is inside the current viewport */
|
||||
containsLocation(location: MapLatLng): boolean;
|
||||
|
||||
/** Place a caller-owned element on the map */
|
||||
addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
@@ -175,33 +216,87 @@ export interface MapEngine {
|
||||
/** Draw a meter-radius circle (zone radius) */
|
||||
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
|
||||
|
||||
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
|
||||
editing?: MapEditingSupport;
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
|
||||
setClustering(options: MapClusterOptions | null): void;
|
||||
|
||||
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
|
||||
refreshClusters(): void;
|
||||
}
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
|
||||
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const toDegrees = (radians: number) => (radians * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* Bounding box corners of a circle, for fitting a radius into view without
|
||||
* engine-specific circle bounds.
|
||||
* The point a distance away from center along a bearing (degrees clockwise
|
||||
* from north), on the great circle. Longitude is left unwrapped so a ring of
|
||||
* points stays continuous across the antimeridian.
|
||||
*/
|
||||
export const destinationPoint = (
|
||||
center: MapLatLng,
|
||||
distanceInMeters: number,
|
||||
bearingDegrees: number
|
||||
): MapLatLng => {
|
||||
const angular = distanceInMeters / EARTH_RADIUS;
|
||||
const lat = toRadians(center[0]);
|
||||
const bearing = toRadians(bearingDegrees);
|
||||
const destLat = Math.asin(
|
||||
Math.sin(lat) * Math.cos(angular) +
|
||||
Math.cos(lat) * Math.sin(angular) * Math.cos(bearing)
|
||||
);
|
||||
const dLng = Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(angular) * Math.cos(lat),
|
||||
Math.cos(angular) - Math.sin(lat) * Math.sin(destLat)
|
||||
);
|
||||
return [toDegrees(destLat), center[1] + toDegrees(dLng)];
|
||||
};
|
||||
|
||||
/** Great-circle distance in meters */
|
||||
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
|
||||
const dLat = toRadians(b[0] - a[0]);
|
||||
const dLng = toRadians(b[1] - a[1]);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRadians(a[0])) *
|
||||
Math.cos(toRadians(b[0])) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
/** The point the given distance due east of center, e.g. for a resize handle */
|
||||
export const pointEastOf = (
|
||||
center: MapLatLng,
|
||||
distanceInMeters: number
|
||||
): MapLatLng => destinationPoint(center, distanceInMeters, 90);
|
||||
|
||||
/**
|
||||
* Bounding box corners of a circle, for fitting a radius into view. A circle
|
||||
* that reaches a pole spans every longitude.
|
||||
*/
|
||||
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);
|
||||
const angular = radiusMeters / EARTH_RADIUS;
|
||||
const latMin = Math.max(-90, center[0] - toDegrees(angular));
|
||||
const latMax = Math.min(90, center[0] + toDegrees(angular));
|
||||
const sinRatio = Math.sin(angular) / Math.cos(toRadians(center[0]));
|
||||
if (latMin <= -90 || latMax >= 90 || Math.abs(sinRatio) >= 1) {
|
||||
return [
|
||||
[latMin, -180],
|
||||
[latMax, 180],
|
||||
];
|
||||
}
|
||||
const dLng = toDegrees(Math.asin(sinRatio));
|
||||
return [
|
||||
[center[0] - latOffset, center[1] - lngOffset],
|
||||
[center[0] + latOffset, center[1] + lngOffset],
|
||||
[latMin, center[1] - dLng],
|
||||
[latMax, center[1] + dLng],
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
/**
|
||||
* Marker elements are focusable buttons on both engines, so the caller's
|
||||
* Marker elements may be focusable buttons on both engines, so the caller's
|
||||
* element is the keyboard target and needs a name and a role. MapLibre would
|
||||
* otherwise label it "Map marker".
|
||||
*
|
||||
* What the engine sets is recorded on the element, so a rebuilt marker on a
|
||||
* reused element gets fresh attributes while the caller's own are left alone.
|
||||
*/
|
||||
|
||||
const OWNED = "haMapA11y";
|
||||
|
||||
export const setMarkerAccessibility = (
|
||||
element: HTMLElement,
|
||||
title: string | undefined,
|
||||
interactive: boolean
|
||||
focusable: boolean
|
||||
): void => {
|
||||
if (title && !element.hasAttribute("aria-label")) {
|
||||
clearMarkerAccessibility(element);
|
||||
const owned: string[] = [];
|
||||
if (
|
||||
title &&
|
||||
!element.hasAttribute("aria-label") &&
|
||||
!element.hasAttribute("aria-labelledby")
|
||||
) {
|
||||
element.setAttribute("aria-label", title);
|
||||
owned.push("aria-label");
|
||||
}
|
||||
if (!element.hasAttribute("role")) {
|
||||
if (interactive) {
|
||||
if (focusable) {
|
||||
element.setAttribute("role", "button");
|
||||
owned.push("role");
|
||||
} else if (title) {
|
||||
element.setAttribute("role", "img");
|
||||
owned.push("role");
|
||||
} else {
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
owned.push("aria-hidden");
|
||||
}
|
||||
}
|
||||
if (focusable && !element.hasAttribute("tabindex")) {
|
||||
element.tabIndex = 0;
|
||||
owned.push("tabindex");
|
||||
}
|
||||
element.dataset[OWNED] = owned.join(" ");
|
||||
};
|
||||
|
||||
/** Removes what setMarkerAccessibility set on the element */
|
||||
export const clearMarkerAccessibility = (element: HTMLElement): void => {
|
||||
const owned = element.dataset[OWNED];
|
||||
if (owned === undefined) {
|
||||
return;
|
||||
}
|
||||
owned
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.forEach((name) => element.removeAttribute(name));
|
||||
delete element.dataset[OWNED];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getContrastedColorHex } from "../color/rgb";
|
||||
|
||||
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
|
||||
|
||||
export const ZONE_CIRCLE_SIZE = 36;
|
||||
|
||||
// Content color contrasting the fill; not every theme color parses
|
||||
export const contrastingZoneContent = (color: string): string => {
|
||||
try {
|
||||
return getContrastedColorHex(color.trim());
|
||||
} catch {
|
||||
return "#ffffff";
|
||||
}
|
||||
};
|
||||
|
||||
export const zoneInitials = (name: string): string =>
|
||||
name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2);
|
||||
|
||||
export const createZoneMarkerElement = (options: {
|
||||
color: string;
|
||||
icon?: string;
|
||||
/** Path for an ha-svg-icon, when there is no icon name */
|
||||
iconPath?: string;
|
||||
name: string;
|
||||
}): HTMLElement => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "zone-circle";
|
||||
element.style.backgroundColor = options.color;
|
||||
element.style.color = contrastingZoneContent(options.color);
|
||||
if (options.icon) {
|
||||
const icon = document.createElement("ha-icon");
|
||||
icon.setAttribute("icon", options.icon);
|
||||
element.appendChild(icon);
|
||||
} else if (options.iconPath) {
|
||||
const icon = document.createElement("ha-svg-icon");
|
||||
icon.setAttribute("path", options.iconPath);
|
||||
element.appendChild(icon);
|
||||
} else {
|
||||
const initials = document.createElement("span");
|
||||
initials.textContent = zoneInitials(options.name);
|
||||
element.appendChild(initials);
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
/** Styles for the zone marker, included by ha-map */
|
||||
export const zoneMarkerStyles = `
|
||||
.zone-circle {
|
||||
width: ${ZONE_CIRCLE_SIZE}px;
|
||||
height: ${ZONE_CIRCLE_SIZE}px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--card-background-color, #fff);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
overflow: hidden;
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
|
||||
}
|
||||
`;
|
||||
@@ -24,11 +24,13 @@ class HaEntityMarker extends LitElement {
|
||||
|
||||
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public selected = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
class="marker ${this.entityPicture ? "picture" : ""}"
|
||||
style=${styleMap({ "border-color": this.entityColor })}
|
||||
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
|
||||
@click=${this._badgeTap}
|
||||
>
|
||||
${
|
||||
@@ -94,13 +96,22 @@ class HaEntityMarker extends LitElement {
|
||||
height: var(--ha-marker-size, 48px);
|
||||
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
|
||||
border-radius: var(--ha-marker-border-radius, 50%);
|
||||
border: 1px solid var(--ha-marker-color, var(--primary-color));
|
||||
border: var(--ha-marker-border-width, 3px) solid
|
||||
var(--ha-marker-color, var(--card-background-color, #fff));
|
||||
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
background-color: var(
|
||||
--ha-marker-background,
|
||||
var(--card-background-color)
|
||||
);
|
||||
}
|
||||
.marker.picture {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* A ring in the entity color outside the frame marks the selected marker */
|
||||
:host([selected]) .marker {
|
||||
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
|
||||
}
|
||||
.entity-picture {
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import type {
|
||||
Circle,
|
||||
DivIcon,
|
||||
DragEndEvent,
|
||||
LatLng,
|
||||
Marker,
|
||||
MarkerOptions,
|
||||
} from "leaflet";
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
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 { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
|
||||
import type { MapLatLng } from "../../common/map/map-engine";
|
||||
import type { ThemeMode } from "../../types";
|
||||
import { circleBoundsPoints } from "../../common/map/map-engine";
|
||||
import { internationalizationContext, uiContext } from "../../data/context";
|
||||
import type {
|
||||
HomeAssistantInternationalization,
|
||||
HomeAssistantUI,
|
||||
ThemeMode,
|
||||
} from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-input-helper-text";
|
||||
import "./ha-map";
|
||||
import type { HaMap } from "./ha-map";
|
||||
import type { HaMap, HaMapEditableLocation } from "./ha-map";
|
||||
import type { HaIcon } from "../ha-icon";
|
||||
import type { HaSvgIcon } from "../ha-svg-icon";
|
||||
import {
|
||||
createZoneMarkerElement,
|
||||
ZONE_CIRCLE_SIZE,
|
||||
} from "../../common/map/zone-marker";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -41,8 +46,16 @@ export interface MarkerLocation {
|
||||
radius_color?: string;
|
||||
location_editable?: boolean;
|
||||
radius_editable?: boolean;
|
||||
/** Clicking or activating the marker fires marker-clicked */
|
||||
clickable?: boolean;
|
||||
}
|
||||
|
||||
const ICON_SIZE = 24;
|
||||
|
||||
/**
|
||||
* A map with draggable markers and zone circles, drawn by ha-map. Without
|
||||
* editing support (the Leaflet fallback) they are static, with a notice.
|
||||
*/
|
||||
@customElement("ha-locations-editor")
|
||||
export class HaLocationsEditor extends LitElement {
|
||||
@property({ attribute: false }) public locations?: MarkerLocation[];
|
||||
@@ -59,28 +72,18 @@ export class HaLocationsEditor extends LitElement {
|
||||
@property({ type: Boolean, attribute: "pin-on-click" })
|
||||
public pinOnClick = false;
|
||||
|
||||
@state() private _locationMarkers?: Record<string, Marker | Circle>;
|
||||
|
||||
@state() private _circles: Record<string, Circle> = {};
|
||||
|
||||
@query("ha-map", true) private map!: HaMap;
|
||||
|
||||
private Leaflet?: LeafletModuleType;
|
||||
@state() private _editingAvailable = true;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
private _loadPromise: Promise<boolean | undefined | void>;
|
||||
// Marker elements bake in theme colors, so they are rebuilt on a theme change
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
private _ui?: HomeAssistantUI;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._loadPromise = import("leaflet").then((module) =>
|
||||
import("leaflet-draw").then(() => {
|
||||
this.Leaflet = module.default as LeafletModuleType;
|
||||
this._updateMarkers();
|
||||
return this.updateComplete.then(() => this.fitMap());
|
||||
})
|
||||
);
|
||||
}
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
public fitMap(options?: { zoom?: number; pad?: number }): void {
|
||||
this.map.fitMap(options);
|
||||
@@ -97,43 +100,53 @@ export class HaLocationsEditor extends LitElement {
|
||||
id: string,
|
||||
options?: { zoom?: number }
|
||||
): Promise<void> {
|
||||
if (!this.Leaflet) {
|
||||
await this._loadPromise;
|
||||
}
|
||||
if (!this.map.leafletMap || !this._locationMarkers) {
|
||||
await this.updateComplete;
|
||||
const location = this.locations?.find((loc) => loc.id === id);
|
||||
if (!location) {
|
||||
return;
|
||||
}
|
||||
const marker = this._locationMarkers[id];
|
||||
if (!marker) {
|
||||
return;
|
||||
}
|
||||
if ("getBounds" in marker) {
|
||||
this.map.leafletMap.fitBounds(marker.getBounds());
|
||||
(marker as Circle).bringToFront();
|
||||
const center: MapLatLng = [location.latitude, location.longitude];
|
||||
if (location.radius) {
|
||||
// Only the map's maximum caps the zoom, however small the zone
|
||||
this.map.fitBounds(circleBoundsPoints(center, location.radius), {
|
||||
pad: 0,
|
||||
zoom: MAP_MAX_ZOOM,
|
||||
});
|
||||
} else {
|
||||
const circle = this._circles[id];
|
||||
if (circle) {
|
||||
this.map.leafletMap.fitBounds(circle.getBounds());
|
||||
} else {
|
||||
this.map.leafletMap.setView(
|
||||
marker.getLatLng(),
|
||||
options?.zoom || this.zoom
|
||||
);
|
||||
}
|
||||
this.map.setView(center, options?.zoom || this.zoom);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-map
|
||||
engine="leaflet"
|
||||
.layers=${this._getLayers(this._circles, this._locationMarkers)}
|
||||
.zoom=${this.zoom}
|
||||
.autoFit=${this.autoFit}
|
||||
.themeMode=${this.themeMode}
|
||||
.clickable=${this.pinOnClick}
|
||||
@map-clicked=${this._mapClicked}
|
||||
></ha-map>
|
||||
<div class="map">
|
||||
<ha-map
|
||||
.editableLocations=${this._editableLocations(
|
||||
this.locations,
|
||||
this._ui?.themes
|
||||
)}
|
||||
.zoom=${this.zoom}
|
||||
.autoFit=${this.autoFit}
|
||||
.themeMode=${this.themeMode}
|
||||
.clickable=${this.pinOnClick}
|
||||
@map-clicked=${this._mapClicked}
|
||||
@editable-location-moved=${this._locationMoved}
|
||||
@editable-location-resized=${this._radiusChanged}
|
||||
@editable-location-clicked=${this._markerClicked}
|
||||
@editing-available-changed=${this._editingAvailableChanged}
|
||||
></ha-map>
|
||||
${
|
||||
!this._editingAvailable &&
|
||||
this._i18n &&
|
||||
this.locations?.some(
|
||||
(location) => location.location_editable || location.radius_editable
|
||||
)
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this._i18n.localize("ui.components.map.editing_unavailable")}
|
||||
</ha-alert>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
@@ -142,63 +155,127 @@ export class HaLocationsEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _getLayers = memoizeOne(
|
||||
private _editableLocations = memoizeOne(
|
||||
(
|
||||
circles: Record<string, Circle>,
|
||||
markers?: Record<string, Marker | Circle>
|
||||
): (Marker | Circle)[] => {
|
||||
const layers: (Marker | Circle)[] = [];
|
||||
Array.prototype.push.apply(layers, Object.values(circles));
|
||||
if (markers) {
|
||||
Array.prototype.push.apply(layers, Object.values(markers));
|
||||
locations: MarkerLocation[] | undefined,
|
||||
themes: HomeAssistantUI["themes"] | undefined
|
||||
): HaMapEditableLocation[] => {
|
||||
if (themes !== this._elementsThemes) {
|
||||
this._elementsThemes = themes;
|
||||
this._elements.clear();
|
||||
}
|
||||
return layers;
|
||||
const ids = new Set((locations ?? []).map((location) => location.id));
|
||||
for (const id of this._elements.keys()) {
|
||||
if (!ids.has(id)) {
|
||||
this._elements.delete(id);
|
||||
}
|
||||
}
|
||||
return (locations ?? []).map((location) => ({
|
||||
id: location.id,
|
||||
location: [location.latitude, location.longitude],
|
||||
radius: location.radius,
|
||||
element: this._elementFor(location),
|
||||
elementSize: location.radius
|
||||
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
|
||||
: [ICON_SIZE, ICON_SIZE],
|
||||
title: location.name,
|
||||
color: location.radius_color,
|
||||
locationEditable: location.location_editable,
|
||||
radiusEditable: location.radius_editable,
|
||||
activatable: location.clickable,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
|
||||
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
|
||||
|
||||
// Still loading.
|
||||
if (!this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
private _elementsThemes?: HomeAssistantUI["themes"];
|
||||
|
||||
if (changedProps.has("locations")) {
|
||||
this._updateMarkers();
|
||||
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
|
||||
const isZone = !!location.radius;
|
||||
const key = JSON.stringify([
|
||||
isZone,
|
||||
location.icon,
|
||||
location.iconPath,
|
||||
location.name,
|
||||
location.radius_color,
|
||||
]);
|
||||
const cached = this._elements.get(location.id);
|
||||
if (cached?.key === key) {
|
||||
return cached.element;
|
||||
}
|
||||
// The zone marker doubles as the circle's draggable center
|
||||
const element = isZone
|
||||
? this._createZoneMarker(location)
|
||||
: this._createIcon(location);
|
||||
this._elements.set(location.id, { key, element });
|
||||
return element;
|
||||
}
|
||||
|
||||
private _createZoneMarker(location: MarkerLocation): HTMLElement {
|
||||
return createZoneMarkerElement({
|
||||
color:
|
||||
location.radius_color ||
|
||||
getComputedStyle(this).getPropertyValue("--accent-color"),
|
||||
icon: location.icon,
|
||||
iconPath: location.iconPath,
|
||||
name: location.name ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
|
||||
if (!location.icon && !location.iconPath) {
|
||||
return undefined;
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
el.className = "named-icon";
|
||||
if (location.name !== undefined) {
|
||||
el.innerText = location.name;
|
||||
}
|
||||
let iconEl: HaIcon | HaSvgIcon;
|
||||
if (location.icon) {
|
||||
iconEl = document.createElement("ha-icon");
|
||||
iconEl.setAttribute("icon", location.icon);
|
||||
} else {
|
||||
iconEl = document.createElement("ha-svg-icon");
|
||||
iconEl.setAttribute("path", location.iconPath!);
|
||||
}
|
||||
el.prepend(iconEl);
|
||||
return el;
|
||||
}
|
||||
|
||||
public updated(changedProps: PropertyValues): void {
|
||||
// Still loading.
|
||||
if (!this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedProps.has("locations")) {
|
||||
const oldLocations = changedProps.get("locations");
|
||||
fireEvent(this, "markers-updated");
|
||||
|
||||
// Follow a location that was edited out of view
|
||||
const oldLocations = changedProps.get("locations") as
|
||||
MarkerLocation[] | undefined;
|
||||
const movedLocations = this.locations?.filter(
|
||||
(loc, idx) =>
|
||||
!oldLocations[idx] ||
|
||||
!oldLocations?.[idx] ||
|
||||
((loc.latitude !== oldLocations[idx].latitude ||
|
||||
loc.longitude !== oldLocations[idx].longitude) &&
|
||||
this.map.leafletMap?.getBounds().contains({
|
||||
lat: oldLocations[idx].latitude,
|
||||
lng: oldLocations[idx].longitude,
|
||||
}) &&
|
||||
!this.map.leafletMap
|
||||
?.getBounds()
|
||||
.contains({ lat: loc.latitude, lng: loc.longitude }))
|
||||
this.map.containsLocation([
|
||||
oldLocations[idx].latitude,
|
||||
oldLocations[idx].longitude,
|
||||
]) &&
|
||||
!this.map.containsLocation([loc.latitude, loc.longitude]))
|
||||
);
|
||||
if (movedLocations?.length === 1) {
|
||||
this.map.leafletMap?.panTo({
|
||||
lat: movedLocations[0].latitude,
|
||||
lng: movedLocations[0].longitude,
|
||||
});
|
||||
this.map.panTo([
|
||||
movedLocations[0].latitude,
|
||||
movedLocations[0].longitude,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _editingAvailableChanged(ev: HASSDomEvent<{ available: boolean }>) {
|
||||
this._editingAvailable = ev.detail.available;
|
||||
}
|
||||
|
||||
private _normalizeLongitude(longitude: number): number {
|
||||
if (Math.abs(longitude) > 180.0) {
|
||||
// Normalize longitude if map provides values beyond -180 to +180 degrees.
|
||||
@@ -207,40 +284,37 @@ export class HaLocationsEditor extends LitElement {
|
||||
return longitude;
|
||||
}
|
||||
|
||||
private _updateLocation(ev: DragEndEvent) {
|
||||
const marker = ev.target;
|
||||
const latlng: LatLng = marker.getLatLng();
|
||||
const location: [number, number] = [
|
||||
latlng.lat,
|
||||
this._normalizeLongitude(latlng.lng),
|
||||
];
|
||||
private _locationMoved(
|
||||
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
|
||||
) {
|
||||
const [latitude, longitude] = ev.detail.location;
|
||||
fireEvent(
|
||||
this,
|
||||
"location-updated",
|
||||
{ id: marker.id, location },
|
||||
{
|
||||
id: ev.detail.id,
|
||||
location: [latitude, this._normalizeLongitude(longitude)],
|
||||
},
|
||||
{ bubbles: false }
|
||||
);
|
||||
}
|
||||
|
||||
private _updateRadius(ev: DragEndEvent) {
|
||||
const marker = ev.target;
|
||||
const circle = this._locationMarkers![marker.id] as Circle;
|
||||
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
|
||||
fireEvent(
|
||||
this,
|
||||
"radius-updated",
|
||||
{ id: marker.id, radius: circle.getRadius() },
|
||||
{ id: ev.detail.id, radius: ev.detail.radius },
|
||||
{ bubbles: false }
|
||||
);
|
||||
}
|
||||
|
||||
private _markerClicked(ev: DragEndEvent) {
|
||||
const marker = ev.target;
|
||||
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
|
||||
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
|
||||
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
|
||||
}
|
||||
|
||||
private _mapClicked(ev) {
|
||||
if (this.pinOnClick && this._locationMarkers) {
|
||||
const id = Object.keys(this._locationMarkers)[0];
|
||||
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
|
||||
if (this.pinOnClick && this.locations?.length) {
|
||||
const id = this.locations[0].id;
|
||||
const location: [number, number] = [
|
||||
ev.detail.location[0],
|
||||
this._normalizeLongitude(ev.detail.location[1]),
|
||||
@@ -249,136 +323,29 @@ export class HaLocationsEditor extends LitElement {
|
||||
|
||||
// If the normalized longitude wraps around the globe, pan to the new location.
|
||||
if (location[1] !== ev.detail.location[1]) {
|
||||
this.map.leafletMap?.panTo({ lat: location[0], lng: location[1] });
|
||||
this.map.panTo(location);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _updateMarkers(): void {
|
||||
if (!this.locations || !this.locations.length) {
|
||||
this._circles = {};
|
||||
this._locationMarkers = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const locationMarkers = {};
|
||||
const circles = {};
|
||||
|
||||
const defaultZoneRadiusColor =
|
||||
getComputedStyle(this).getPropertyValue("--accent-color");
|
||||
|
||||
this.locations.forEach((location: MarkerLocation) => {
|
||||
let icon: DivIcon | undefined;
|
||||
if (location.icon || location.iconPath) {
|
||||
// create icon
|
||||
const el = document.createElement("div");
|
||||
el.className = "named-icon";
|
||||
if (location.name !== undefined) {
|
||||
el.innerText = location.name;
|
||||
}
|
||||
let iconEl: HaIcon | HaSvgIcon;
|
||||
if (location.icon) {
|
||||
iconEl = document.createElement("ha-icon");
|
||||
iconEl.setAttribute("icon", location.icon);
|
||||
} else {
|
||||
iconEl = document.createElement("ha-svg-icon");
|
||||
iconEl.setAttribute("path", location.iconPath!);
|
||||
}
|
||||
el.prepend(iconEl);
|
||||
|
||||
icon = this.Leaflet!.divIcon({
|
||||
html: el.outerHTML,
|
||||
iconSize: [24, 24],
|
||||
className: "light",
|
||||
});
|
||||
}
|
||||
if (location.radius) {
|
||||
const circle = this.Leaflet!.circle(
|
||||
[location.latitude, location.longitude],
|
||||
{
|
||||
color: location.radius_color || defaultZoneRadiusColor,
|
||||
radius: location.radius,
|
||||
}
|
||||
);
|
||||
if (location.radius_editable || location.location_editable) {
|
||||
// @ts-ignore
|
||||
circle.editing.enable();
|
||||
circle.addEventListener("add", () => {
|
||||
// @ts-ignore
|
||||
const moveMarker = circle.editing._moveMarker;
|
||||
// @ts-ignore
|
||||
const resizeMarker = circle.editing._resizeMarkers[0];
|
||||
if (icon) {
|
||||
moveMarker.setIcon(icon);
|
||||
}
|
||||
resizeMarker.id = moveMarker.id = location.id;
|
||||
moveMarker
|
||||
.addEventListener(
|
||||
"dragend",
|
||||
// @ts-ignore
|
||||
(ev: DragEndEvent) => this._updateLocation(ev)
|
||||
)
|
||||
.addEventListener(
|
||||
"click",
|
||||
// @ts-ignore
|
||||
(ev: MouseEvent) => this._markerClicked(ev)
|
||||
);
|
||||
if (location.radius_editable) {
|
||||
resizeMarker.addEventListener(
|
||||
"dragend",
|
||||
// @ts-ignore
|
||||
(ev: DragEndEvent) => this._updateRadius(ev)
|
||||
);
|
||||
} else {
|
||||
resizeMarker.remove();
|
||||
}
|
||||
});
|
||||
locationMarkers[location.id] = circle;
|
||||
} else {
|
||||
circles[location.id] = circle;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!location.radius ||
|
||||
(!location.radius_editable && !location.location_editable)
|
||||
) {
|
||||
const options: MarkerOptions = {
|
||||
title: location.name,
|
||||
draggable: location.location_editable,
|
||||
};
|
||||
|
||||
if (icon) {
|
||||
options.icon = icon;
|
||||
}
|
||||
|
||||
const marker = this.Leaflet!.marker(
|
||||
[location.latitude, location.longitude],
|
||||
options
|
||||
)
|
||||
.addEventListener("dragend", (ev: DragEndEvent) =>
|
||||
this._updateLocation(ev)
|
||||
)
|
||||
.addEventListener(
|
||||
// @ts-ignore
|
||||
"click",
|
||||
// @ts-ignore
|
||||
(ev: MouseEvent) => this._markerClicked(ev)
|
||||
);
|
||||
(marker as any).id = location.id;
|
||||
|
||||
locationMarkers[location.id] = marker;
|
||||
}
|
||||
});
|
||||
this._circles = circles;
|
||||
this._locationMarkers = locationMarkers;
|
||||
fireEvent(this, "markers-updated");
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.map {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
ha-map {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* Over the map, clear of the zoom control, so a fixed-height host shows it.
|
||||
The control sits at the physical top-left in either direction. */
|
||||
ha-alert {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
left: 56px;
|
||||
right: var(--ha-space-2);
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+615
-129
@@ -1,9 +1,8 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
import { isToday } from "date-fns";
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { Layer } from "leaflet";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, ReactiveElement } from "lit";
|
||||
import { css, ReactiveElement, unsafeCSS } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { formatDateTime } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
@@ -17,7 +16,6 @@ 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 { supportsWebGL2 } from "../../common/map/base-layer";
|
||||
import type { LeafletMapEngine } from "../../common/map/engines/leaflet-map-engine";
|
||||
import type {
|
||||
MapClusterIcon,
|
||||
MapEngine,
|
||||
@@ -27,17 +25,29 @@ import type {
|
||||
MapPath,
|
||||
MapPathMarker,
|
||||
MapPathSegment,
|
||||
MapEditableCircleHandle,
|
||||
MapEditableMarkerHandle,
|
||||
MapEditingSupport,
|
||||
} from "../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../common/map/map-engine";
|
||||
import { editableCircleStyles } from "../../common/map/editable-circle";
|
||||
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
|
||||
import {
|
||||
createZoneMarkerElement,
|
||||
ZONE_CIRCLE_SIZE,
|
||||
zoneMarkerStyles,
|
||||
} from "../../common/map/zone-marker";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
formattersContext,
|
||||
fullEntitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
@@ -82,6 +92,125 @@ export const MAP_CARD_MARKER_LABEL_MODES = [
|
||||
export type MapCardMarkerLabelMode =
|
||||
(typeof MAP_CARD_MARKER_LABEL_MODES)[number];
|
||||
|
||||
/** A location drawn for editing: a draggable marker, or a circle with a moveable center and radius */
|
||||
export interface HaMapEditableLocation {
|
||||
id: string;
|
||||
location: MapLatLng;
|
||||
radius?: number;
|
||||
/** Element shown at the location, e.g. a named icon */
|
||||
element?: HTMLElement;
|
||||
elementSize?: [width: number, height: number];
|
||||
title?: string;
|
||||
color?: string;
|
||||
locationEditable?: boolean;
|
||||
radiusEditable?: boolean;
|
||||
/** Activating the marker fires editable-location-clicked; otherwise it is not a button */
|
||||
activatable?: boolean;
|
||||
}
|
||||
|
||||
// Geometry is updated in place; a change to anything else rebuilds the marker
|
||||
const sameAppearance = (
|
||||
a: HaMapEditableLocation,
|
||||
b: HaMapEditableLocation
|
||||
): boolean =>
|
||||
a.element === b.element &&
|
||||
a.title === b.title &&
|
||||
a.color === b.color &&
|
||||
a.locationEditable === b.locationEditable &&
|
||||
a.radiusEditable === b.radiusEditable &&
|
||||
a.activatable === b.activatable &&
|
||||
a.elementSize?.[0] === b.elementSize?.[0] &&
|
||||
a.elementSize?.[1] === b.elementSize?.[1];
|
||||
|
||||
// Without editing support (the Leaflet fallback) locations are static and redrawn on edits
|
||||
const staticEditing = (engine: MapEngine): MapEditingSupport => ({
|
||||
addDraggableMarker: (element, location, options) => {
|
||||
let current = location;
|
||||
let marker = engine.addMarker(element, current, options);
|
||||
return {
|
||||
get location() {
|
||||
return current;
|
||||
},
|
||||
clusterData: options.clusterData,
|
||||
setLocation: (newLocation) => {
|
||||
marker.remove();
|
||||
current = newLocation;
|
||||
marker = engine.addMarker(element, current, options);
|
||||
},
|
||||
remove: () => marker.remove(),
|
||||
};
|
||||
},
|
||||
addEditableCircle: (center, options) => {
|
||||
const centerEl = options.centerElement ?? document.createElement("div");
|
||||
if (!options.centerElement) {
|
||||
centerEl.className = "editable-circle-center";
|
||||
}
|
||||
// The center element may be reused for a rebuilt circle; the listeners go with this one
|
||||
let removeListeners: (() => void) | undefined;
|
||||
if (options.onClick) {
|
||||
const onClick = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
options.onClick!();
|
||||
};
|
||||
const onKeydown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
options.onClick!();
|
||||
}
|
||||
};
|
||||
centerEl.addEventListener("click", onClick);
|
||||
centerEl.addEventListener("keydown", onKeydown);
|
||||
removeListeners = () => {
|
||||
centerEl.removeEventListener("click", onClick);
|
||||
centerEl.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
}
|
||||
let current = { center, radius: options.radius };
|
||||
let items: MapItemHandle[] = [];
|
||||
const draw = () => {
|
||||
items = [
|
||||
engine.addCircle(current.center, {
|
||||
radius: current.radius,
|
||||
color: options.color,
|
||||
}),
|
||||
engine.addMarker(centerEl, current.center, {
|
||||
size: options.centerSize ?? [16, 16],
|
||||
interactive: !!options.onClick,
|
||||
title: options.title,
|
||||
}),
|
||||
];
|
||||
};
|
||||
draw();
|
||||
return {
|
||||
get center() {
|
||||
return current.center;
|
||||
},
|
||||
get radius() {
|
||||
return current.radius;
|
||||
},
|
||||
update: (newCenter, newRadius) => {
|
||||
items.forEach((item) => item.remove());
|
||||
current = { center: newCenter, radius: newRadius };
|
||||
draw();
|
||||
},
|
||||
remove: () => {
|
||||
removeListeners?.();
|
||||
items.forEach((item) => item.remove());
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"editable-location-moved": { id: string; location: MapLatLng };
|
||||
"editable-location-resized": { id: string; radius: number };
|
||||
"editable-location-clicked": { id: string };
|
||||
/** Whether the loaded engine can edit; false on the Leaflet fallback */
|
||||
"editing-available-changed": { available: boolean };
|
||||
}
|
||||
}
|
||||
|
||||
export interface HaMapEntity {
|
||||
entity_id: string;
|
||||
color: string;
|
||||
@@ -90,9 +219,37 @@ export interface HaMapEntity {
|
||||
unit?: string;
|
||||
name?: string;
|
||||
focus?: boolean;
|
||||
hide_accuracy?: boolean;
|
||||
hide_radius?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// Data carried by entity markers for rendering cluster bubbles
|
||||
interface ClusterData {
|
||||
entityId: string;
|
||||
picture?: string;
|
||||
label: string;
|
||||
showIcon: boolean;
|
||||
unit: string;
|
||||
color?: string;
|
||||
selected: boolean;
|
||||
zoneId?: string;
|
||||
}
|
||||
|
||||
const CLUSTER_AVATAR_SIZE = 32;
|
||||
const CLUSTER_BUBBLE_PADDING = 6;
|
||||
const CLUSTER_BUBBLE_GAP = 4;
|
||||
const CLUSTER_MAX_AVATARS = 3;
|
||||
const CLUSTER_MORE_WIDTH = 28;
|
||||
const CLUSTER_MORE_MAX = 99;
|
||||
const CLUSTER_TAIL_SIZE = 10;
|
||||
// The tail is a rotated square on the bubble's bottom edge, reaching half its diagonal below
|
||||
const CLUSTER_TAIL_HEIGHT = Math.round((CLUSTER_TAIL_SIZE * Math.SQRT2) / 2);
|
||||
const CLUSTER_ZONE_SPACING = 2;
|
||||
const CLUSTER_RADIUS = 40;
|
||||
// Same-zone markers share the zone's bubble while they span at most this many
|
||||
// pixels; further apart they show their actual positions
|
||||
const ZONE_GROUP_RADIUS = 160;
|
||||
|
||||
@customElement("ha-map")
|
||||
export class HaMap extends ReactiveElement {
|
||||
@@ -127,17 +284,9 @@ 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";
|
||||
/** Locations drawn with drag handles for editing (ha-locations-editor) */
|
||||
@property({ attribute: false })
|
||||
public editableLocations?: HaMapEditableLocation[];
|
||||
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
|
||||
@@ -151,6 +300,8 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
|
||||
|
||||
private _zonePositions: Record<string, MapLatLng> = {};
|
||||
|
||||
@property({ attribute: "theme-mode", type: String })
|
||||
public themeMode: ThemeMode = "auto";
|
||||
|
||||
@@ -168,14 +319,26 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _engine?: MapEngine;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
// Reconciled by id, so updates move handles instead of recreating them
|
||||
private _editableHandles = new Map<
|
||||
string,
|
||||
(
|
||||
| { kind: "circle"; handle: MapEditableCircleHandle }
|
||||
| { kind: "marker"; handle: MapEditableMarkerHandle }
|
||||
) & {
|
||||
source: HaMapEditableLocation;
|
||||
/** Detaches what ha-map itself put on the caller's element */
|
||||
cleanup?: () => void;
|
||||
}
|
||||
>();
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
// Registry creation order decides the palette colors
|
||||
@state() private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
private _registryConsumer?: ContextConsumer<typeof fullEntitiesContext, this>;
|
||||
|
||||
private _entityHandles: MapMarkerHandle[] = [];
|
||||
|
||||
private _zoneHandles: MapItemHandle[] = [];
|
||||
@@ -203,6 +366,21 @@ export class HaMap extends ReactiveElement {
|
||||
this._attachObserver();
|
||||
}
|
||||
|
||||
// Only maps that draw entities ask for the registry; an editor's map, as in
|
||||
// onboarding, never does. The context provider shares one subscription.
|
||||
private _watchRegistry(): void {
|
||||
if (this._registryConsumer || !this.entities?.length) {
|
||||
return;
|
||||
}
|
||||
this._registryConsumer = new ContextConsumer(this, {
|
||||
context: fullEntitiesContext,
|
||||
subscribe: true,
|
||||
callback: (entries) => {
|
||||
this._entityReg = entries;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _handleVisibilityChange = async () => {
|
||||
if (!document.hidden) {
|
||||
setTimeout(() => {
|
||||
@@ -227,6 +405,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._entityHandles = [];
|
||||
this._zoneHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._removeEditableLocations();
|
||||
this._focusPoints = [];
|
||||
this._focusZonePoints = [];
|
||||
|
||||
@@ -264,7 +443,7 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("clusterMarkers")) {
|
||||
if (changedProps.has("clusterMarkers") || changedProps.has("_entityReg")) {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
@@ -280,14 +459,31 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("paths")) {
|
||||
this._drawPaths();
|
||||
// Cluster bubbles show entity colors only while trails are visible
|
||||
const oldPaths = changedProps.get("paths") as HaMapPaths[] | undefined;
|
||||
if (!!oldPaths?.length !== !!this.paths?.length) {
|
||||
this._engine?.refreshClusters();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("layers")) {
|
||||
this._drawLayers(changedProps.get("layers") as Layer[] | undefined);
|
||||
autoFitRequired = true;
|
||||
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
|
||||
// Added or removed locations refit; edits keep the view
|
||||
if (this._drawEditableLocations()) {
|
||||
autoFitRequired = true;
|
||||
}
|
||||
} else if (changedProps.has("_i18n") && this._editableHandles.size) {
|
||||
// Titles and handle labels are localized when drawn
|
||||
this._removeEditableLocations();
|
||||
this._drawEditableLocations();
|
||||
}
|
||||
|
||||
if (changedProps.has("_loaded") || (this.autoFit && autoFitRequired)) {
|
||||
if (changedProps.has("_loaded") && this._pendingFit) {
|
||||
// A fit requested before the engine was ready wins over the default
|
||||
this._runPendingFit();
|
||||
} else if (
|
||||
changedProps.has("_loaded") ||
|
||||
(this.autoFit && autoFitRequired)
|
||||
) {
|
||||
this.fitMap();
|
||||
}
|
||||
|
||||
@@ -300,13 +496,19 @@ export class HaMap extends ReactiveElement {
|
||||
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
|
||||
if (
|
||||
!changedProps.has("themeMode") &&
|
||||
(!changedProps.has("_ui") ||
|
||||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
|
||||
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateMapStyle();
|
||||
// Marker, trail and circle colors were resolved from the theme when drawn
|
||||
this._drawEntities();
|
||||
this._drawPaths();
|
||||
if (this._editableHandles.size) {
|
||||
this._removeEditableLocations();
|
||||
this._drawEditableLocations();
|
||||
}
|
||||
}
|
||||
|
||||
private get _darkMode() {
|
||||
@@ -336,7 +538,7 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
// 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()) {
|
||||
if (this._forceLeaflet || !supportsWebGL2()) {
|
||||
const leaflet =
|
||||
await import("../../common/map/engines/leaflet-map-engine");
|
||||
return new leaflet.LeafletMapEngine();
|
||||
@@ -422,6 +624,9 @@ export class HaMap extends ReactiveElement {
|
||||
this._engine = engine;
|
||||
this._updateMapStyle();
|
||||
this._loaded = true;
|
||||
fireEvent(this, "editing-available-changed", {
|
||||
available: !!engine.editing,
|
||||
});
|
||||
} finally {
|
||||
if (attempt === this._setupAttempt) {
|
||||
this._loading = false;
|
||||
@@ -451,6 +656,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._entityHandles = [];
|
||||
this._zoneHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._removeEditableLocations();
|
||||
this._focusPoints = [];
|
||||
this._focusZonePoints = [];
|
||||
this._pendingFit = undefined;
|
||||
@@ -502,7 +708,7 @@ export class HaMap extends ReactiveElement {
|
||||
if (
|
||||
!this._focusPoints.length &&
|
||||
!this._focusZonePoints.length &&
|
||||
!this.layers?.length
|
||||
!this.editableLocations?.length
|
||||
) {
|
||||
this._withProgrammaticFit(() => {
|
||||
this._engine!.setView(
|
||||
@@ -516,22 +722,14 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
const points = [...this._focusPoints, ...this._focusZonePoints];
|
||||
|
||||
// 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]);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Editable locations contribute their bounds, radius included
|
||||
this.editableLocations?.forEach((editable) => {
|
||||
if (editable.radius) {
|
||||
points.push(...circleBoundsPoints(editable.location, editable.radius));
|
||||
} else {
|
||||
points.push(editable.location);
|
||||
}
|
||||
});
|
||||
|
||||
this._withProgrammaticFit(() => {
|
||||
this._engine!.fitBounds(points, {
|
||||
@@ -565,11 +763,30 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
public panTo(location: MapLatLng): void {
|
||||
this._engine?.panTo(location);
|
||||
}
|
||||
|
||||
public containsLocation(location: MapLatLng): boolean {
|
||||
return this._engine?.containsLocation(location) ?? false;
|
||||
}
|
||||
|
||||
public setView(center: MapLatLng, zoom?: number): void {
|
||||
if (!this._engine) {
|
||||
this._pendingFit = () => this.setView(center, zoom);
|
||||
return;
|
||||
}
|
||||
this._pendingFit = undefined;
|
||||
this._engine.setView(center, zoom);
|
||||
}
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
) {
|
||||
if (!this._engine) {
|
||||
// Engine still loading (see _loadMap); runs once it is
|
||||
this._pendingFit = () => this.fitBounds(boundingbox, options);
|
||||
return;
|
||||
}
|
||||
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
|
||||
@@ -585,20 +802,146 @@ export class HaMap extends ReactiveElement {
|
||||
this._hasFitted = true;
|
||||
}
|
||||
|
||||
private _drawLayers(prevLayers: Layer[] | undefined): void {
|
||||
if (prevLayers) {
|
||||
prevLayers.forEach((layer) => layer.remove());
|
||||
// Returns whether locations were added or removed
|
||||
private _drawEditableLocations(): boolean {
|
||||
const engine = this._engine;
|
||||
if (!engine) {
|
||||
return false;
|
||||
}
|
||||
if (!this.layers) {
|
||||
return;
|
||||
const staticSupport = staticEditing(engine);
|
||||
const editing = engine.editing ?? staticSupport;
|
||||
const wanted = new Set((this.editableLocations ?? []).map((e) => e.id));
|
||||
let changed = false;
|
||||
for (const [id, entry] of this._editableHandles) {
|
||||
if (!wanted.has(id)) {
|
||||
entry.cleanup?.();
|
||||
entry.handle.remove();
|
||||
this._editableHandles.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
const leafletMap = this.leafletMap;
|
||||
if (!leafletMap) {
|
||||
return;
|
||||
if (!this.editableLocations) {
|
||||
return changed;
|
||||
}
|
||||
this.layers.forEach((layer) => {
|
||||
leafletMap.addLayer(layer);
|
||||
});
|
||||
const defaultColor =
|
||||
getComputedStyle(this).getPropertyValue("--accent-color");
|
||||
for (const editable of this.editableLocations) {
|
||||
const { id } = editable;
|
||||
// Markers are buttons, so an unnamed location still gets a name
|
||||
const title =
|
||||
editable.title ?? this._i18n?.localize("ui.components.map.location");
|
||||
const existing = this._editableHandles.get(id);
|
||||
const kind = editable.radius ? "circle" : "marker";
|
||||
|
||||
if (
|
||||
existing &&
|
||||
existing.kind === kind &&
|
||||
sameAppearance(existing.source, editable)
|
||||
) {
|
||||
if (existing.kind === "circle") {
|
||||
existing.handle.update(editable.location, editable.radius!);
|
||||
} else {
|
||||
existing.handle.setLocation(editable.location);
|
||||
}
|
||||
existing.source = editable;
|
||||
continue;
|
||||
}
|
||||
if (existing) {
|
||||
existing.cleanup?.();
|
||||
existing.handle.remove();
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (kind === "circle") {
|
||||
this._editableHandles.set(id, {
|
||||
kind,
|
||||
source: editable,
|
||||
handle: editing.addEditableCircle(editable.location, {
|
||||
radius: editable.radius!,
|
||||
color: editable.color || defaultColor,
|
||||
centerElement: editable.element,
|
||||
centerSize: editable.elementSize,
|
||||
title,
|
||||
moveable: editable.locationEditable,
|
||||
resizable: editable.radiusEditable,
|
||||
resizeLabel: editable.title
|
||||
? this._i18n?.localize("ui.components.map.radius_of", {
|
||||
name: editable.title,
|
||||
})
|
||||
: this._i18n?.localize("ui.components.map.radius"),
|
||||
onMove: (location) =>
|
||||
fireEvent(this, "editable-location-moved", { id, location }),
|
||||
onResize: (radius) =>
|
||||
fireEvent(this, "editable-location-resized", { id, radius }),
|
||||
onClick: editable.activatable
|
||||
? () => fireEvent(this, "editable-location-clicked", { id })
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const element = editable.element ?? document.createElement("div");
|
||||
if (!editable.element) {
|
||||
element.className = "editable-circle-center";
|
||||
}
|
||||
let dragged = false;
|
||||
let cleanup: (() => void) | undefined;
|
||||
if (editable.activatable) {
|
||||
// A drag can end in a click; only one with its own pointer down counts
|
||||
const onPointerDown = () => {
|
||||
dragged = false;
|
||||
};
|
||||
const onClick = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
if (dragged) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "editable-location-clicked", { id });
|
||||
};
|
||||
const onKeydown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
fireEvent(this, "editable-location-clicked", { id });
|
||||
}
|
||||
};
|
||||
element.addEventListener("pointerdown", onPointerDown);
|
||||
element.addEventListener("click", onClick);
|
||||
element.addEventListener("keydown", onKeydown);
|
||||
cleanup = () => {
|
||||
element.removeEventListener("pointerdown", onPointerDown);
|
||||
element.removeEventListener("click", onClick);
|
||||
element.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
}
|
||||
// A location that cannot be dragged is static on any engine
|
||||
const support = editable.locationEditable ? editing : staticSupport;
|
||||
this._editableHandles.set(id, {
|
||||
kind,
|
||||
source: editable,
|
||||
cleanup,
|
||||
handle: support.addDraggableMarker(element, editable.location, {
|
||||
size: editable.elementSize ?? [16, 16],
|
||||
interactive: true,
|
||||
focusable: !!editable.activatable,
|
||||
title,
|
||||
onDragEnd: (location) => {
|
||||
dragged = true;
|
||||
fireEvent(this, "editable-location-moved", { id, location });
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
// One by one, so the listeners on the caller's elements are detached too
|
||||
private _removeEditableLocations(): void {
|
||||
for (const entry of this._editableHandles.values()) {
|
||||
entry.cleanup?.();
|
||||
entry.handle.remove();
|
||||
}
|
||||
this._editableHandles.clear();
|
||||
}
|
||||
|
||||
private _computePathTooltip(path: HaMapPaths, point: HaMapPathPoint): string {
|
||||
@@ -757,16 +1100,36 @@ export class HaMap extends ReactiveElement {
|
||||
engine.setClustering(null);
|
||||
return;
|
||||
}
|
||||
this._watchRegistry();
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const zoneColor = computedStyles.getPropertyValue("--accent-color");
|
||||
const passiveZoneColor = computedStyles.getPropertyValue(
|
||||
"--secondary-text-color"
|
||||
);
|
||||
|
||||
const darkPrimaryColor = computedStyles.getPropertyValue(
|
||||
"--dark-primary-color"
|
||||
);
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const zoneByState: Record<string, string> = {};
|
||||
this._zonePositions = {};
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
// A zone that is not drawn cannot anchor a bubble either
|
||||
if (
|
||||
stateObj &&
|
||||
computeStateDomain(stateObj) === "zone" &&
|
||||
(this.renderPassive || !stateObj.attributes.passive)
|
||||
) {
|
||||
zoneByState[
|
||||
stateObj.entity_id === "zone.home"
|
||||
? "home"
|
||||
: computeStateName(stateObj)
|
||||
] = stateObj.entity_id;
|
||||
if (
|
||||
typeof stateObj.attributes.latitude === "number" &&
|
||||
typeof stateObj.attributes.longitude === "number"
|
||||
) {
|
||||
this._zonePositions[stateObj.entity_id] = [
|
||||
stateObj.attributes.latitude,
|
||||
stateObj.attributes.longitude,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
@@ -795,26 +1158,29 @@ export class HaMap extends ReactiveElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
|
||||
const hideRadius = typeof entity !== "string" && entity.hide_radius;
|
||||
// A host-set color wins, except passive zones are always muted
|
||||
const markerColor =
|
||||
!passive && typeof entity !== "string" && entity.color
|
||||
? entity.color
|
||||
: zoneColor(
|
||||
stateObj.entity_id,
|
||||
!!passive,
|
||||
this._entityReg,
|
||||
computedStyles
|
||||
);
|
||||
|
||||
if (radius) {
|
||||
if (!hideRadius && radius) {
|
||||
this._zoneHandles.push(
|
||||
engine.addCircle(position, { radius, color: zoneMarkerColor })
|
||||
engine.addCircle(position, { radius, color: markerColor })
|
||||
);
|
||||
}
|
||||
|
||||
// create icon
|
||||
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);
|
||||
iconEl.appendChild(el);
|
||||
} else {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = title;
|
||||
iconEl.appendChild(el);
|
||||
}
|
||||
const circleEl = createZoneMarkerElement({
|
||||
color: markerColor,
|
||||
icon,
|
||||
name: title,
|
||||
});
|
||||
|
||||
if (this.interactiveZones) {
|
||||
const openMoreInfo = (ev: Event) => {
|
||||
@@ -823,8 +1189,8 @@ export class HaMap extends ReactiveElement {
|
||||
entityId: stateObj.entity_id,
|
||||
});
|
||||
};
|
||||
iconEl.addEventListener("click", openMoreInfo);
|
||||
iconEl.addEventListener("keydown", (ev) => {
|
||||
circleEl.addEventListener("click", openMoreInfo);
|
||||
circleEl.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
openMoreInfo(ev);
|
||||
@@ -832,10 +1198,9 @@ export class HaMap extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
|
||||
this._zoneHandles.push(
|
||||
engine.addMarker(iconEl, position, {
|
||||
size: [zoneIconSize, zoneIconSize],
|
||||
engine.addMarker(circleEl, position, {
|
||||
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
|
||||
interactive: this.interactiveZones,
|
||||
title,
|
||||
})
|
||||
@@ -845,7 +1210,7 @@ export class HaMap extends ReactiveElement {
|
||||
this.fitZones &&
|
||||
(typeof entity === "string" || entity.focus !== false)
|
||||
) {
|
||||
if (radius) {
|
||||
if (!hideRadius && radius) {
|
||||
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
|
||||
} else {
|
||||
this._focusZonePoints.push(position);
|
||||
@@ -889,19 +1254,42 @@ export class HaMap extends ReactiveElement {
|
||||
entityPicture && (typeof entity === "string" || !entity.label_mode)
|
||||
? this._connection.hassUrl(entityPicture)
|
||||
: "";
|
||||
// A host may leave the color to the map
|
||||
const entityColor =
|
||||
(typeof entity !== "string" ? entity.color : undefined) ||
|
||||
entityMapColor(getEntityId(entity), this._entityReg, computedStyles);
|
||||
entityMarker.entityColor = entityColor;
|
||||
if (typeof entity !== "string") {
|
||||
entityMarker.entityColor = entity.color;
|
||||
entityMarker.selected = entity.selected ?? false;
|
||||
}
|
||||
|
||||
const clusterData: ClusterData = {
|
||||
entityId: getEntityId(entity),
|
||||
picture: entityMarker.entityPicture || undefined,
|
||||
label: entityName,
|
||||
showIcon: entityMarker.showIcon,
|
||||
unit: entityMarker.entityUnit ?? "",
|
||||
color: entityColor,
|
||||
selected: typeof entity !== "string" && (entity.selected ?? false),
|
||||
zoneId: ["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
)
|
||||
? zoneByState[stateObj.state]
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const showAccuracy =
|
||||
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
|
||||
|
||||
const markerSize = this._getMarkerSize(computedStyles);
|
||||
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 }
|
||||
clusterData,
|
||||
decoration: showAccuracy
|
||||
? { radius: gpsAccuracy!, color: entityColor }
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
@@ -913,22 +1301,97 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
engine.setClustering(
|
||||
this.clusterMarkers
|
||||
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
|
||||
? {
|
||||
radius: CLUSTER_RADIUS,
|
||||
iconBuilder: this._createClusterBubble,
|
||||
// Everyone in a zone shares its bubble until zooming spreads them
|
||||
groupKey: (marker) => (marker.clusterData as ClusterData)?.zoneId,
|
||||
groupRadius: ZONE_GROUP_RADIUS,
|
||||
}
|
||||
: 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] };
|
||||
// Renders a marker cluster as a bubble of its members' avatars
|
||||
private _createClusterBubble = (
|
||||
members: MapMarkerHandle[]
|
||||
): MapClusterIcon => {
|
||||
const data = members.map((member) => member.clusterData as ClusterData);
|
||||
const shown = data.slice(0, CLUSTER_MAX_AVATARS);
|
||||
const hidden = data.length - shown.length;
|
||||
|
||||
// With history trails shown, colored borders match avatars to trails
|
||||
const showColors = !!this.paths?.length;
|
||||
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "cluster-bubble";
|
||||
for (const member of shown) {
|
||||
const avatar = document.createElement("ha-entity-marker");
|
||||
avatar.entityId = member?.entityId;
|
||||
avatar.entityName = member?.label ?? "";
|
||||
avatar.entityUnit = member?.unit ?? "";
|
||||
avatar.showIcon = member?.showIcon ?? false;
|
||||
avatar.entityPicture = member?.picture ?? "";
|
||||
avatar.entityColor = member?.color;
|
||||
if (showColors) {
|
||||
avatar.style.setProperty(
|
||||
"--ha-marker-color",
|
||||
member?.color ?? "var(--primary-color)"
|
||||
);
|
||||
avatar.style.setProperty("--ha-marker-border-width", "2px");
|
||||
}
|
||||
if (member?.selected) {
|
||||
avatar.selected = true;
|
||||
}
|
||||
bubble.appendChild(avatar);
|
||||
}
|
||||
|
||||
let width =
|
||||
shown.length * CLUSTER_AVATAR_SIZE +
|
||||
(shown.length - 1) * CLUSTER_BUBBLE_GAP +
|
||||
2 * CLUSTER_BUBBLE_PADDING;
|
||||
if (hidden > 0) {
|
||||
const more = document.createElement("span");
|
||||
more.className = "more";
|
||||
more.textContent =
|
||||
hidden > CLUSTER_MORE_MAX ? `${CLUSTER_MORE_MAX}+` : `+${hidden}`;
|
||||
bubble.appendChild(more);
|
||||
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
|
||||
}
|
||||
|
||||
// A cluster of one zone's occupants attaches to that zone's marker
|
||||
const zoneId = data[0]?.zoneId;
|
||||
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
|
||||
const atZone =
|
||||
!!zoneId &&
|
||||
!!zonePosition &&
|
||||
data.every((member) => member?.zoneId === zoneId);
|
||||
|
||||
let height = CLUSTER_AVATAR_SIZE + 2 * CLUSTER_BUBBLE_PADDING;
|
||||
let root: HTMLElement = bubble;
|
||||
if (atZone) {
|
||||
root = document.createElement("div");
|
||||
root.className = "cluster-marker";
|
||||
const tail = document.createElement("div");
|
||||
tail.className = "cluster-bubble-tail";
|
||||
root.append(bubble, tail);
|
||||
height += CLUSTER_TAIL_HEIGHT;
|
||||
}
|
||||
|
||||
return {
|
||||
element: root,
|
||||
size: [width, height],
|
||||
// Float above the zone circle, tail pointing at it
|
||||
...(atZone && zonePosition
|
||||
? {
|
||||
location: zonePosition,
|
||||
anchor: [
|
||||
width / 2,
|
||||
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
|
||||
] as [number, number],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
private _drawScaleRuler(): void {
|
||||
@@ -1015,8 +1478,9 @@ export class HaMap extends ReactiveElement {
|
||||
.leaflet-tile-pane .leaflet-tile {
|
||||
filter: var(--map-filter);
|
||||
}
|
||||
/* The adapter path (engine="leaflet" with WebGL2) loads no MapLibre
|
||||
stylesheet; these are the only two rules its canvas needs. */
|
||||
/* The Leaflet fallback with WebGL2 renders vectors through the adapter
|
||||
without MapLibre's stylesheet; these are the only two rules its canvas
|
||||
needs. */
|
||||
.maplibregl-map {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -1059,13 +1523,7 @@ export class HaMap extends ReactiveElement {
|
||||
.dark .leaflet-bar a:hover {
|
||||
background-color: #313131;
|
||||
}
|
||||
.leaflet-marker-draggable {
|
||||
cursor: move !important;
|
||||
}
|
||||
.leaflet-edit-resize {
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
cursor: nesw-resize !important;
|
||||
}
|
||||
${unsafeCSS(editableCircleStyles)}
|
||||
.named-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1077,17 +1535,58 @@ export class HaMap extends ReactiveElement {
|
||||
.leaflet-pane {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
/* Zone icons, sized like the Leaflet divIcon they replaced */
|
||||
.zone-icon {
|
||||
.cluster-marker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.cluster-bubble-tail {
|
||||
width: ${CLUSTER_TAIL_SIZE}px;
|
||||
height: ${CLUSTER_TAIL_SIZE}px;
|
||||
margin-top: ${-CLUSTER_TAIL_SIZE / 2}px;
|
||||
border-radius: 2px;
|
||||
background: var(--card-background-color, #fff);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.cluster-bubble {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${CLUSTER_BUBBLE_GAP}px;
|
||||
padding: ${CLUSTER_BUBBLE_PADDING}px;
|
||||
box-sizing: border-box;
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
|
||||
--ha-marker-color: transparent;
|
||||
--ha-marker-border-width: 1px;
|
||||
--ha-marker-shadow: none;
|
||||
--ha-marker-font-size: var(--ha-font-size-s);
|
||||
/* distinguish letter tiles from the bubble background */
|
||||
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
|
||||
}
|
||||
.cluster-bubble .more {
|
||||
flex: none;
|
||||
width: ${CLUSTER_MORE_WIDTH}px;
|
||||
height: ${CLUSTER_AVATAR_SIZE}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 10px;
|
||||
background: var(--ha-color-fill-neutral-quiet-resting, #f0f0f0);
|
||||
color: var(--primary-text-color, #212121);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.zone-icon.dark {
|
||||
color: #ffffff;
|
||||
/* Markers are rounded squares to match the cluster bubble avatars */
|
||||
ha-entity-marker {
|
||||
--ha-marker-border-radius: var(--ha-border-radius-lg);
|
||||
}
|
||||
.cluster-bubble ha-entity-marker {
|
||||
flex: none;
|
||||
--ha-marker-border-radius: 10px;
|
||||
}
|
||||
${unsafeCSS(zoneMarkerStyles)}
|
||||
.leaflet-control,
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
@@ -1137,19 +1636,6 @@ export class HaMap extends ReactiveElement {
|
||||
ha-icon {
|
||||
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
|
||||
}
|
||||
|
||||
.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);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -267,6 +267,7 @@ class OnboardingLocation extends LitElement {
|
||||
? location[1]
|
||||
: Number(place.lon),
|
||||
location_editable: place.place_id === highlightedMarker,
|
||||
clickable: true,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -22,7 +22,12 @@ import type {
|
||||
MarkerLocation,
|
||||
} from "../../../components/map/ha-locations-editor";
|
||||
import { saveCoreConfig } from "../../../data/core";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import type {
|
||||
HomeZoneMutableParams,
|
||||
Zone,
|
||||
@@ -47,6 +52,19 @@ import { configSections } from "../config-sections";
|
||||
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
|
||||
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
|
||||
|
||||
interface PendingEdit {
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
// How close the saved value must come to a pending one to count as saved
|
||||
const PENDING_TOLERANCE: Record<keyof PendingEdit, number> = {
|
||||
latitude: 1e-7,
|
||||
longitude: 1e-7,
|
||||
radius: 0.5,
|
||||
};
|
||||
|
||||
@customElement("ha-config-zone")
|
||||
export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -61,7 +79,10 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _stateItems?: HassEntity[];
|
||||
|
||||
@state() private _activeEntry = "";
|
||||
// Values dragged on the map, shown until the saved data reflects them, so
|
||||
// a re-render while the save is in flight does not move the marker back.
|
||||
// A failed save drops them and the marker returns to the saved values.
|
||||
@state() private _pendingEdits: Record<string, PendingEdit> = {};
|
||||
|
||||
@state() private _canEditCore = false;
|
||||
|
||||
@@ -69,15 +90,25 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _regEntities: string[] = [];
|
||||
|
||||
// Registry creation order decides the zone colors
|
||||
@state() private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
// Storage zone id (its unique id) to entity id
|
||||
@state() private _zoneEntityIds: Record<string, string> = {};
|
||||
|
||||
// Bumped when entity map colors change to recompute the memoized locations
|
||||
@state() private _colorVersion = 0;
|
||||
|
||||
private _getZones = memoizeOne(
|
||||
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
|
||||
(
|
||||
storageItems: Zone[],
|
||||
stateItems: HassEntity[],
|
||||
zoneEntityIds: Record<string, string>,
|
||||
pendingEdits: Record<string, PendingEdit>,
|
||||
entityReg: EntityRegistryEntry[],
|
||||
_colorVersion: number
|
||||
): MarkerLocation[] => {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
|
||||
const passiveRadiusColor = computedStyles.getPropertyValue(
|
||||
"--secondary-text-color"
|
||||
);
|
||||
const homeRadiusColor =
|
||||
computedStyles.getPropertyValue("--primary-color");
|
||||
|
||||
const stateLocations: MarkerLocation[] = stateItems.map(
|
||||
(entityState) => ({
|
||||
@@ -87,21 +118,28 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
latitude: entityState.attributes.latitude,
|
||||
longitude: entityState.attributes.longitude,
|
||||
radius: entityState.attributes.radius,
|
||||
radius_color:
|
||||
entityState.entity_id === "zone.home"
|
||||
? homeRadiusColor
|
||||
: entityState.attributes.passive
|
||||
? passiveRadiusColor
|
||||
: zoneRadiusColor,
|
||||
...pendingEdits[entityState.entity_id],
|
||||
radius_color: zoneColor(
|
||||
entityState.entity_id,
|
||||
!!entityState.attributes.passive,
|
||||
entityReg,
|
||||
computedStyles
|
||||
),
|
||||
location_editable:
|
||||
entityState.entity_id === "zone.home" && this._canEditCore,
|
||||
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
|
||||
radius_editable:
|
||||
entityState.entity_id === "zone.home" && this._canEditCore,
|
||||
entityState.entity_id === HOME_ZONE_ENTITY_ID && this._canEditCore,
|
||||
})
|
||||
);
|
||||
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
|
||||
...zone,
|
||||
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
|
||||
...pendingEdits[zone.id],
|
||||
radius_color: zoneColor(
|
||||
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
|
||||
!!zone.passive,
|
||||
entityReg,
|
||||
computedStyles
|
||||
),
|
||||
location_editable: true,
|
||||
radius_editable: true,
|
||||
}));
|
||||
@@ -112,9 +150,18 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
public hassSubscribe(): UnsubscribeFunc[] {
|
||||
return [
|
||||
subscribeEntityRegistry(this.hass.connection!, (entities) => {
|
||||
this._entityReg = entities;
|
||||
this._regEntities = entities.map(
|
||||
(registryEntry) => registryEntry.entity_id
|
||||
);
|
||||
this._zoneEntityIds = Object.fromEntries(
|
||||
entities
|
||||
.filter((registryEntry) => registryEntry.platform === "zone")
|
||||
.map((registryEntry) => [
|
||||
registryEntry.unique_id,
|
||||
registryEntry.entity_id,
|
||||
])
|
||||
);
|
||||
this._filterStates();
|
||||
}),
|
||||
];
|
||||
@@ -151,7 +198,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
?selected=${this._activeEntry === entry.id}
|
||||
>
|
||||
<ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
|
||||
${entry.name}
|
||||
@@ -185,7 +231,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
?selected=${this._activeEntry === stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
@@ -269,11 +314,14 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
<ha-locations-editor
|
||||
.locations=${this._getZones(
|
||||
this._storageItems,
|
||||
this._stateItems
|
||||
this._stateItems,
|
||||
this._zoneEntityIds,
|
||||
this._pendingEdits,
|
||||
this._entityReg,
|
||||
this._colorVersion
|
||||
)}
|
||||
@location-updated=${this._locationUpdated}
|
||||
@radius-updated=${this._radiusUpdated}
|
||||
@marker-clicked=${this._markerClicked}
|
||||
></ha-locations-editor>
|
||||
<div class="overflow">${listBox}</div>
|
||||
</div>
|
||||
@@ -300,7 +348,8 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (
|
||||
!this.route.path.startsWith("/edit/") ||
|
||||
!this._stateItems ||
|
||||
@@ -318,11 +367,99 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
super.willUpdate(changedProps);
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (oldHass && this._stateItems) {
|
||||
this._getStates(oldHass);
|
||||
}
|
||||
// Zone colors come from theme variables
|
||||
if (oldHass && oldHass.themes !== this.hass.themes) {
|
||||
this._colorVersion++;
|
||||
}
|
||||
this._settlePendingEdits();
|
||||
}
|
||||
|
||||
// A pending edit is done once the saved data carries its values
|
||||
private _settlePendingEdits() {
|
||||
for (const [id, pending] of Object.entries(this._pendingEdits)) {
|
||||
const saved =
|
||||
this._storageItems?.find((zone) => zone.id === id) ??
|
||||
this.hass.states[id]?.attributes;
|
||||
if (
|
||||
saved &&
|
||||
(Object.keys(pending) as (keyof PendingEdit)[]).every(
|
||||
(key) =>
|
||||
Math.abs((saved[key] as number) - pending[key]!) <
|
||||
PENDING_TOLERANCE[key]
|
||||
)
|
||||
) {
|
||||
this._dropPendingEdit(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _dropPendingEdit(id: string) {
|
||||
const { [id]: _done, ...rest } = this._pendingEdits;
|
||||
this._pendingEdits = rest;
|
||||
}
|
||||
|
||||
// Saves for one zone run in order, so each sees the entry the previous one
|
||||
// produced and a failure only drops the values its own request carried
|
||||
private _saveQueue: Record<string, Promise<void>> = {};
|
||||
|
||||
private _saveEdit(id: string, pending: PendingEdit): Promise<void> {
|
||||
this._pendingEdits = {
|
||||
...this._pendingEdits,
|
||||
[id]: { ...this._pendingEdits[id], ...pending },
|
||||
};
|
||||
const save = (this._saveQueue[id] ?? Promise.resolve())
|
||||
.then(() => this._performSave(id, pending))
|
||||
.finally(() => {
|
||||
// Only the last save in the chain removes the queue entry
|
||||
if (this._saveQueue[id] === save) {
|
||||
delete this._saveQueue[id];
|
||||
}
|
||||
});
|
||||
this._saveQueue[id] = save;
|
||||
return save;
|
||||
}
|
||||
|
||||
private async _performSave(id: string, pending: PendingEdit) {
|
||||
try {
|
||||
if (id === HOME_ZONE_ENTITY_ID) {
|
||||
await saveCoreConfig(this.hass, pending);
|
||||
return;
|
||||
}
|
||||
const entry = this._storageItems!.find((item) => item.id === id);
|
||||
if (entry) {
|
||||
await this._updateEntry(entry, pending);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// The saved values are the truth again for what this request changed;
|
||||
// a later edit of other values stays pending for its own save
|
||||
this._dropPendingValues(id, pending);
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.zone.can_not_edit"),
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _dropPendingValues(id: string, failed: PendingEdit) {
|
||||
const current = this._pendingEdits[id];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const rest = Object.fromEntries(
|
||||
Object.entries(current).filter(
|
||||
([key, value]) => failed[key as keyof PendingEdit] !== value
|
||||
)
|
||||
) as PendingEdit;
|
||||
if (Object.keys(rest).length) {
|
||||
this._pendingEdits = { ...this._pendingEdits, [id]: rest };
|
||||
} else {
|
||||
this._dropPendingEdit(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchData() {
|
||||
@@ -364,46 +501,22 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
private async _locationUpdated(ev: CustomEvent) {
|
||||
this._activeEntry = ev.detail.id;
|
||||
if (ev.detail.id === "zone.home" && this._canEditCore) {
|
||||
await saveCoreConfig(this.hass, {
|
||||
latitude: ev.detail.location[0],
|
||||
longitude: ev.detail.location[1],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this._updateEntry(entry, {
|
||||
private _locationUpdated(ev: CustomEvent) {
|
||||
this._saveEdit(ev.detail.id, {
|
||||
latitude: ev.detail.location[0],
|
||||
longitude: ev.detail.location[1],
|
||||
});
|
||||
}
|
||||
|
||||
private async _radiusUpdated(ev: CustomEvent) {
|
||||
this._activeEntry = ev.detail.id;
|
||||
if (ev.detail.id === "zone.home" && this._canEditCore) {
|
||||
await saveCoreConfig(this.hass, {
|
||||
radius: Math.round(ev.detail.radius),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = this._storageItems!.find((item) => item.id === ev.detail.id);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
this._updateEntry(entry, {
|
||||
radius: ev.detail.radius,
|
||||
private _radiusUpdated(ev: CustomEvent) {
|
||||
this._saveEdit(ev.detail.id, {
|
||||
radius:
|
||||
ev.detail.id === HOME_ZONE_ENTITY_ID
|
||||
? Math.round(ev.detail.radius)
|
||||
: ev.detail.radius,
|
||||
});
|
||||
}
|
||||
|
||||
private _markerClicked(ev: CustomEvent) {
|
||||
this._activeEntry = ev.detail.id;
|
||||
}
|
||||
|
||||
private _createZone() {
|
||||
this._openDialog();
|
||||
}
|
||||
@@ -417,9 +530,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
this._openEditEntry(ev);
|
||||
return;
|
||||
}
|
||||
const entryId: string = (ev.currentTarget! as any).value;
|
||||
this._zoomZone(entryId);
|
||||
this._activeEntry = entryId;
|
||||
this._zoomZone((ev.currentTarget! as any).value);
|
||||
}
|
||||
|
||||
private _stateItemClicked(ev: CustomEvent) {
|
||||
@@ -429,13 +540,12 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
const entryId: string = (ev.currentTarget! as any).value;
|
||||
|
||||
if (this.narrow && entryId === "zone.home") {
|
||||
if (this.narrow && entryId === HOME_ZONE_ENTITY_ID) {
|
||||
this._editHomeZone(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
this._zoomZone(entryId);
|
||||
this._activeEntry = entryId;
|
||||
}
|
||||
|
||||
private async _zoomZone(id: string) {
|
||||
@@ -478,7 +588,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
if (this.narrow) {
|
||||
return;
|
||||
}
|
||||
this._activeEntry = created.id;
|
||||
await this.updateComplete;
|
||||
await this._map?.updateComplete;
|
||||
this._map?.fitMarker(created.id);
|
||||
@@ -490,7 +599,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
longitude: values.longitude,
|
||||
radius: values.radius,
|
||||
});
|
||||
this._zoomZone("zone.home");
|
||||
this._zoomZone(HOME_ZONE_ENTITY_ID);
|
||||
}
|
||||
|
||||
private async _updateEntry(
|
||||
@@ -505,7 +614,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
if (this.narrow || !fitMap) {
|
||||
return;
|
||||
}
|
||||
this._activeEntry = entry.id;
|
||||
await this.updateComplete;
|
||||
await this._map?.updateComplete;
|
||||
this._map?.fitMarker(entry.id);
|
||||
|
||||
@@ -8,8 +8,13 @@ import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getColorByIndex } from "../../../common/color/colors";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
import { resolveThemeColor } from "../../../common/color/compute-color";
|
||||
import {
|
||||
entityMapColor,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
@@ -31,6 +36,10 @@ import type {
|
||||
import type { MapLatLng } from "../../../common/map/map-engine";
|
||||
import type { HistoryStates } from "../../../data/history";
|
||||
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
|
||||
import type { Themes } from "../../../data/ws-themes";
|
||||
import { fullEntitiesContext, uiContext } from "../../../data/context";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import {
|
||||
@@ -58,6 +67,18 @@ interface GeoEntity {
|
||||
|
||||
@customElement("hui-map-card")
|
||||
class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
constructor() {
|
||||
super();
|
||||
new ContextConsumer(this, {
|
||||
context: fullEntitiesContext,
|
||||
subscribe: true,
|
||||
callback: (entries) => {
|
||||
this._entityReg = entries;
|
||||
this._mapEntities = this._getMapEntities();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
@@ -76,12 +97,19 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _filteredMapEntities: HaMapEntity[] = [];
|
||||
|
||||
private _colorDict: Record<string, string> = {};
|
||||
|
||||
private _colorIndex = 0;
|
||||
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
// Registry creation order decides the palette colors
|
||||
@state() private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
// Palette colors are read from the theme when the entities are built
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<ContextType<typeof uiContext>, Themes>({
|
||||
transformer: ({ themes }) => themes,
|
||||
})
|
||||
private _themes?: Themes;
|
||||
|
||||
@state() private _clusterMarkers = true;
|
||||
|
||||
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
|
||||
@@ -213,7 +241,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
<ha-map
|
||||
.entities=${this._filteredMapEntities}
|
||||
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
|
||||
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
|
||||
.paths=${this._getHistoryPaths(
|
||||
this._config,
|
||||
this._stateHistory,
|
||||
this._entityReg,
|
||||
this._themes
|
||||
)}
|
||||
.autoFit=${this._config.auto_fit || false}
|
||||
.fitZones=${this._config.fit_zones || false}
|
||||
.themeMode=${themeMode}
|
||||
@@ -321,6 +354,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
) {
|
||||
this._mapEntities = this._getMapEntities();
|
||||
}
|
||||
// Private state is not in keyof this
|
||||
if ((changedProps as PropertyValues).has("_themes") && this.hasUpdated) {
|
||||
this._mapEntities = this._getMapEntities();
|
||||
}
|
||||
|
||||
// Filter entities by conditions
|
||||
if (this._config?.conditions && this._mapEntities) {
|
||||
@@ -432,18 +469,19 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
this._clusterMarkers = !this._clusterMarkers;
|
||||
}
|
||||
|
||||
// The same color for an entity on every map; a config color still wins
|
||||
private _getColor(entityId: string): string {
|
||||
let color = this._colorDict[entityId];
|
||||
if (color) {
|
||||
return color;
|
||||
}
|
||||
const computedStyles = getComputedStyle(this);
|
||||
color = getColorByIndex(this._colorIndex, computedStyles);
|
||||
if (color) {
|
||||
this._colorIndex++;
|
||||
this._colorDict[entityId] = color;
|
||||
if (computeDomain(entityId) === "zone") {
|
||||
const stateObj = this.hass?.states[entityId];
|
||||
return zoneColor(
|
||||
entityId,
|
||||
!!stateObj?.attributes.passive,
|
||||
this._entityReg,
|
||||
computedStyles
|
||||
);
|
||||
}
|
||||
return color;
|
||||
return entityMapColor(entityId, this._entityReg, computedStyles);
|
||||
}
|
||||
|
||||
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
|
||||
@@ -503,7 +541,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
private _getHistoryPaths = memoizeOne(
|
||||
(
|
||||
config: MapCardConfig,
|
||||
history?: HistoryStates
|
||||
history: HistoryStates | undefined,
|
||||
// Trail colors follow the registry order and the theme like the markers
|
||||
_entityReg: EntityRegistryEntry[],
|
||||
_themes: Themes | undefined
|
||||
): HaMapPaths[] | undefined => {
|
||||
if (!history || !(config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW)) {
|
||||
return undefined;
|
||||
|
||||
@@ -1154,7 +1154,11 @@
|
||||
"category": "Category"
|
||||
},
|
||||
"map": {
|
||||
"error": "Unable to load map"
|
||||
"error": "Unable to load map",
|
||||
"editing_unavailable": "Dragging markers and resizing zones aren't available on this map.",
|
||||
"location": "Location",
|
||||
"radius": "Radius in meters",
|
||||
"radius_of": "Radius of {name} in meters"
|
||||
},
|
||||
"statistics_charts": {
|
||||
"loading_statistics": "Loading statistics…",
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import {
|
||||
entityMapColor,
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../src/common/map/entity-map-colors";
|
||||
|
||||
// Palette slot N reads back as "color-N", so an index is visible in the result
|
||||
const styles = {
|
||||
getPropertyValue: (name: string) => name.replace("--", ""),
|
||||
} as unknown as CSSStyleDeclaration;
|
||||
|
||||
const entry = (entityId: string, createdAt: number, id = entityId) =>
|
||||
({
|
||||
entity_id: entityId,
|
||||
created_at: createdAt,
|
||||
id,
|
||||
}) as EntityRegistryEntry;
|
||||
|
||||
describe("entity map colors", () => {
|
||||
it("colors zones, persons and trackers in one creation order", () => {
|
||||
const entries = [
|
||||
entry("zone.work", 40),
|
||||
entry("zone.school", 10),
|
||||
entry("person.anne", 20),
|
||||
entry("device_tracker.phone", 30),
|
||||
entry("light.kitchen", 25),
|
||||
];
|
||||
|
||||
expect(entityMapColor("zone.school", entries, styles)).toBe("color-1");
|
||||
// A person takes the next slot, never a zone's color
|
||||
expect(entityMapColor("person.anne", entries, styles)).toBe("color-2");
|
||||
expect(entityMapColor("device_tracker.phone", entries, styles)).toBe(
|
||||
"color-3"
|
||||
);
|
||||
expect(entityMapColor("zone.work", entries, styles)).toBe("color-4");
|
||||
});
|
||||
|
||||
it("breaks creation ties by registry id", () => {
|
||||
const entries = [entry("zone.b", 10, "b"), entry("zone.a", 10, "a")];
|
||||
|
||||
expect(entityMapColor("zone.a", entries, styles)).toBe("color-1");
|
||||
expect(entityMapColor("zone.b", entries, styles)).toBe("color-2");
|
||||
});
|
||||
|
||||
it("keeps the home zone out of the palette", () => {
|
||||
const entries = [entry(HOME_ZONE_ENTITY_ID, 10), entry("zone.work", 20)];
|
||||
|
||||
expect(entityMapColor("zone.work", entries, styles)).toBe("color-1");
|
||||
expect(zoneColor(HOME_ZONE_ENTITY_ID, false, entries, styles)).toBe(
|
||||
"primary-color"
|
||||
);
|
||||
});
|
||||
|
||||
it("mutes passive zones", () => {
|
||||
const entries = [entry("zone.quiet", 10)];
|
||||
|
||||
expect(zoneColor("zone.quiet", true, entries, styles)).toBe(
|
||||
"secondary-text-color"
|
||||
);
|
||||
expect(zoneColor("zone.quiet", false, entries, styles)).toBe("color-1");
|
||||
});
|
||||
|
||||
it("gives entities outside the registry a stable color", () => {
|
||||
const entries = [entry("zone.work", 10)];
|
||||
|
||||
const color = entityMapColor("zone.yaml_zone", entries, styles);
|
||||
expect(entityMapColor("zone.yaml_zone", entries, styles)).toBe(color);
|
||||
expect(entityMapColor("zone.other_yaml_zone", entries, styles)).not.toBe(
|
||||
color
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { LayerSpecification, StyleSpecification } from "maplibre-gl";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CONTEXT_RESTORE_GRACE } from "../../../src/common/map/base-layer";
|
||||
import {
|
||||
circleBoundsPoints,
|
||||
distanceMeters,
|
||||
pointEastOf,
|
||||
} from "../../../src/common/map/map-engine";
|
||||
import { MapLibreMapEngine } from "../../../src/common/map/engines/maplibre-map-engine";
|
||||
import type {
|
||||
MapEngineEvents,
|
||||
@@ -34,10 +39,16 @@ const fakes = vi.hoisted(() => {
|
||||
|
||||
onMap = false;
|
||||
|
||||
handlers: Record<string, Listener[]> = {};
|
||||
|
||||
constructor(options: any) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
fire(type: string) {
|
||||
(this.handlers[type] ?? []).forEach((handler) => handler());
|
||||
}
|
||||
|
||||
setLngLat(lngLat: [number, number]) {
|
||||
this.lngLat = lngLat;
|
||||
return this;
|
||||
@@ -50,7 +61,13 @@ const fakes = vi.hoisted(() => {
|
||||
addTo() {
|
||||
this.onMap = true;
|
||||
FakeMarker.all.push(this);
|
||||
document.body.appendChild(this.options.element);
|
||||
const element = this.options.element as HTMLElement;
|
||||
element.classList.add(
|
||||
"maplibregl-marker",
|
||||
"maplibregl-marker-anchor-center"
|
||||
);
|
||||
element.style.transform = "translate(10px, 10px)";
|
||||
document.body.appendChild(element);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -68,7 +85,8 @@ const fakes = vi.hoisted(() => {
|
||||
return this.options.element as HTMLElement;
|
||||
}
|
||||
|
||||
on() {
|
||||
on(type: string, handler: Listener) {
|
||||
(this.handlers[type] ??= []).push(handler);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -226,8 +244,17 @@ const fakes = vi.hoisted(() => {
|
||||
this.style.sources[id] = source;
|
||||
}
|
||||
|
||||
// A live source updates the spec it was created from, as MapLibre's does
|
||||
getSource(id: string) {
|
||||
return this.style.sources[id];
|
||||
const spec = this.style.sources[id] as { data?: unknown } | undefined;
|
||||
return spec
|
||||
? {
|
||||
...spec,
|
||||
setData: (data: unknown) => {
|
||||
spec.data = data;
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
removeSource(id: string) {
|
||||
@@ -368,6 +395,7 @@ describe("MapLibreMapEngine", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("style lifecycle", () => {
|
||||
@@ -577,6 +605,99 @@ describe("MapLibreMapEngine", () => {
|
||||
});
|
||||
|
||||
describe("markers", () => {
|
||||
it("hands a removed element back without MapLibre's positioning", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const element = document.createElement("div");
|
||||
const handle = engine.addMarker(element, [52, 4], { size: [36, 36] });
|
||||
expect(element.classList.contains("maplibregl-marker")).toBe(true);
|
||||
|
||||
handle.remove();
|
||||
expect(element.className).toBe("");
|
||||
expect(element.style.transform).toBe("");
|
||||
});
|
||||
|
||||
it("resets every element it placed when destroyed", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const element = document.createElement("div");
|
||||
engine.addMarker(element, [52, 4], { size: [36, 36] });
|
||||
const center = document.createElement("div");
|
||||
engine.editing.addEditableCircle([52, 4], {
|
||||
radius: 100,
|
||||
color: "red",
|
||||
centerElement: center,
|
||||
});
|
||||
|
||||
engine.destroy();
|
||||
expect(element.className).toBe("");
|
||||
expect(center.className).toBe("");
|
||||
expect(center.style.transform).toBe("");
|
||||
});
|
||||
|
||||
it("shows a move cursor only on elements it made draggable", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const plain = document.createElement("div");
|
||||
engine.addMarker(plain, [52, 4], { size: [36, 36] });
|
||||
const draggable = document.createElement("div");
|
||||
const handle = engine.editing.addDraggableMarker(draggable, [52, 4], {
|
||||
size: [36, 36],
|
||||
});
|
||||
const center = document.createElement("div");
|
||||
engine.editing.addEditableCircle([52, 4], {
|
||||
radius: 100,
|
||||
color: "red",
|
||||
centerElement: center,
|
||||
moveable: true,
|
||||
});
|
||||
|
||||
expect(plain.style.cursor).toBe("");
|
||||
expect(draggable.style.cursor).toBe("move");
|
||||
expect(center.style.cursor).toBe("move");
|
||||
handle.remove();
|
||||
expect(draggable.style.cursor).toBe("");
|
||||
});
|
||||
|
||||
it("relabels a reused element and leaves the caller's own attributes alone", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const element = document.createElement("div");
|
||||
engine
|
||||
.addMarker(element, [52, 4], { size: [36, 36], title: "Thuis" })
|
||||
.remove();
|
||||
expect(element.hasAttribute("aria-label")).toBe(false);
|
||||
expect(element.tabIndex).toBe(-1);
|
||||
|
||||
engine.addMarker(element, [52, 4], {
|
||||
size: [36, 36],
|
||||
title: "Home",
|
||||
focusable: false,
|
||||
});
|
||||
expect(element.getAttribute("aria-label")).toBe("Home");
|
||||
expect(element.getAttribute("role")).toBe("img");
|
||||
|
||||
const named = document.createElement("div");
|
||||
named.setAttribute("aria-label", "Mine");
|
||||
engine.addMarker(named, [52, 4], { size: [36, 36], title: "Engine" });
|
||||
expect(named.getAttribute("aria-label")).toBe("Mine");
|
||||
});
|
||||
|
||||
it("gives button semantics only to focusable markers", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const element = document.createElement("div");
|
||||
engine.addMarker(element, [52, 4], {
|
||||
size: [36, 36],
|
||||
title: "Pin",
|
||||
focusable: false,
|
||||
});
|
||||
|
||||
expect(element.tabIndex).toBe(-1);
|
||||
expect(element.getAttribute("role")).toBe("img");
|
||||
expect(element.style.pointerEvents).toBe("");
|
||||
});
|
||||
|
||||
it("lets input through non-interactive markers", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
@@ -786,4 +907,272 @@ describe("MapLibreMapEngine", () => {
|
||||
expect(map.fitBounds).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editing", () => {
|
||||
const circleSource = (map: InstanceType<typeof fakeMap>) =>
|
||||
Object.entries(map.style.sources).find(([id]) =>
|
||||
id.startsWith("ha-map-editable")
|
||||
)?.[1] as { data: unknown } | undefined;
|
||||
|
||||
const addCircle = (
|
||||
engine: MapLibreMapEngine,
|
||||
overrides: Partial<
|
||||
Parameters<typeof engine.editing.addEditableCircle>[1]
|
||||
> = {}
|
||||
) => {
|
||||
const callbacks = {
|
||||
onMove: vi.fn(),
|
||||
onResize: vi.fn(),
|
||||
onClick: vi.fn(),
|
||||
};
|
||||
const handle = engine.editing.addEditableCircle([52, 4], {
|
||||
radius: 100,
|
||||
color: "red",
|
||||
moveable: true,
|
||||
resizable: true,
|
||||
title: "Home",
|
||||
...callbacks,
|
||||
...overrides,
|
||||
});
|
||||
const [center, resize] = fakeMarker.all;
|
||||
return { handle, center, resize, ...callbacks };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// The circle redraws once per frame; run frames synchronously
|
||||
// Returning a frame id here would land in the engine's pending frame
|
||||
// after the callback already cleared it, suppressing later redraws
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => undefined);
|
||||
});
|
||||
|
||||
it("draws a circle with a draggable center and a resize handle", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
const { center, resize } = addCircle(engine);
|
||||
|
||||
expect(fakeMarker.all).toHaveLength(2);
|
||||
expect(center.options.draggable).toBe(true);
|
||||
expect(center.options.element.getAttribute("role")).toBe("button");
|
||||
expect(resize.options.draggable).toBe(true);
|
||||
expect(resize.options.element.getAttribute("role")).toBe("slider");
|
||||
expect(resize.options.element.getAttribute("aria-valuenow")).toBe("100");
|
||||
expect(circleSource(map)).toBeDefined();
|
||||
expect(
|
||||
layerIds(map).filter((id) => id.startsWith("ha-map-editable"))
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("moves with its center and reports the drop", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, center, resize, onMove } = addCircle(engine);
|
||||
const before = circleSource(map)!.data;
|
||||
|
||||
center.lngLat = [4.01, 52];
|
||||
center.fire("dragstart");
|
||||
center.fire("drag");
|
||||
expect(onMove).not.toHaveBeenCalled();
|
||||
// The circle and the handle follow while dragging
|
||||
expect(circleSource(map)!.data).not.toBe(before);
|
||||
expect(resize.lngLat![0]).toBeCloseTo(pointEastOf([52, 4.01], 100)[1], 6);
|
||||
|
||||
center.fire("dragend");
|
||||
expect(onMove).toHaveBeenCalledWith([52, 4.01]);
|
||||
expect(handle.center).toEqual([52, 4.01]);
|
||||
});
|
||||
|
||||
it("resizes from the handle and snaps it back onto the edge", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, resize, onResize } = addCircle(engine);
|
||||
|
||||
const east = pointEastOf([52, 4], 250);
|
||||
resize.lngLat = [east[1], east[0]];
|
||||
resize.fire("dragstart");
|
||||
resize.fire("drag");
|
||||
resize.fire("dragend");
|
||||
|
||||
expect(onResize).toHaveBeenCalledOnce();
|
||||
expect(onResize.mock.calls[0][0]).toBeCloseTo(250, 0);
|
||||
expect(handle.radius).toBeCloseTo(250, 0);
|
||||
expect(resize.options.element.getAttribute("aria-valuenow")).toBe("250");
|
||||
});
|
||||
|
||||
it("resizes in steps with the arrow keys, committing on key release", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, resize, onResize } = addCircle(engine);
|
||||
const element = resize.options.element as HTMLElement;
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" }));
|
||||
element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" }));
|
||||
expect(handle.radius).toBeCloseTo(121, 5);
|
||||
expect(onResize).not.toHaveBeenCalled();
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
|
||||
expect(onResize).toHaveBeenCalledOnce();
|
||||
expect(onResize.mock.calls[0][0]).toBeCloseTo(121, 5);
|
||||
});
|
||||
|
||||
it("holds a keyboard resize against host updates like a drag", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, resize } = addCircle(engine);
|
||||
const element = resize.options.element as HTMLElement;
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp" }));
|
||||
// A re-render with the old radius while the key is held changes nothing
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.radius).toBeCloseTo(110, 5);
|
||||
|
||||
element.dispatchEvent(new KeyboardEvent("keyup", { key: "ArrowUp" }));
|
||||
// Once committed, the host is the truth again
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.radius).toBe(100);
|
||||
});
|
||||
|
||||
it("keeps arrow keys on the resize handle from reaching the map", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, resize } = addCircle(engine);
|
||||
const reachedMap = vi.fn();
|
||||
document.body.addEventListener("keydown", reachedMap);
|
||||
|
||||
(resize.options.element as HTMLElement).dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })
|
||||
);
|
||||
expect(handle.radius).toBeCloseTo(110, 5);
|
||||
expect(reachedMap).not.toHaveBeenCalled();
|
||||
document.body.removeEventListener("keydown", reachedMap);
|
||||
});
|
||||
|
||||
it("places the handle on the drawn circle at any latitude", () => {
|
||||
for (const [latitude, radius] of [
|
||||
[0, 1000],
|
||||
[52, 1000],
|
||||
[89.9, 1000],
|
||||
[89.9, 100000],
|
||||
]) {
|
||||
const center: [number, number] = [latitude, 4];
|
||||
const east = pointEastOf(center, radius);
|
||||
expect(distanceMeters(center, east)).toBeCloseTo(radius, 0);
|
||||
const [southWest, northEast] = circleBoundsPoints(center, radius);
|
||||
// Valid bounds that contain the handle
|
||||
expect(southWest[0]).toBeGreaterThanOrEqual(-90);
|
||||
expect(northEast[0]).toBeLessThanOrEqual(90);
|
||||
expect(east[1]).toBeGreaterThanOrEqual(southWest[1] - 1e-9);
|
||||
expect(east[1]).toBeLessThanOrEqual(northEast[1] + 1e-9);
|
||||
}
|
||||
// A circle over the pole covers every longitude
|
||||
expect(circleBoundsPoints([89.9, 4], 100000)[1]).toEqual([90, 180]);
|
||||
});
|
||||
|
||||
it("keeps a click on the resize handle from reaching the map", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { resize } = addCircle(engine);
|
||||
const reachedMap = vi.fn();
|
||||
document.body.addEventListener("click", reachedMap);
|
||||
|
||||
(resize.options.element as HTMLElement).dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(reachedMap).not.toHaveBeenCalled();
|
||||
document.body.removeEventListener("click", reachedMap);
|
||||
});
|
||||
|
||||
it("raises the advertised maximum above a larger radius", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, resize } = addCircle(engine);
|
||||
const element = resize.options.element as HTMLElement;
|
||||
expect(element.getAttribute("aria-valuemax")).toBe("100000");
|
||||
|
||||
handle.update([52, 4], 150000);
|
||||
expect(element.getAttribute("aria-valuemax")).toBe("150000");
|
||||
expect(element.getAttribute("aria-valuenow")).toBe("150000");
|
||||
});
|
||||
|
||||
it("ignores host updates only while a drag is in progress", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, center } = addCircle(engine);
|
||||
|
||||
center.lngLat = [4.01, 52];
|
||||
center.fire("dragstart");
|
||||
// The user's hand wins while dragging
|
||||
handle.update([53, 5], 500);
|
||||
expect(handle.center).toEqual([52, 4]);
|
||||
center.fire("drag");
|
||||
center.fire("dragend");
|
||||
expect(handle.center).toEqual([52, 4.01]);
|
||||
|
||||
// Afterwards the host is the truth, even when it moves the circle back
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.center).toEqual([52, 4]);
|
||||
expect(handle.radius).toBe(100);
|
||||
});
|
||||
|
||||
it("activates the center by click, Enter and Space, but not after a drag", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { center, onClick } = addCircle(engine);
|
||||
const element = center.options.element as HTMLElement;
|
||||
|
||||
element.dispatchEvent(new MouseEvent("pointerdown"));
|
||||
element.click();
|
||||
element.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
||||
element.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
|
||||
expect(onClick).toHaveBeenCalledTimes(3);
|
||||
|
||||
// A drag ends with a click on the element; that one is not an activation
|
||||
element.dispatchEvent(new MouseEvent("pointerdown"));
|
||||
center.fire("dragstart");
|
||||
center.fire("dragend");
|
||||
element.click();
|
||||
expect(onClick).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("keeps a circle moved while a swapped style was loading", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
engine.setDarkMode(true);
|
||||
await flush();
|
||||
expect(map.isStyleLoaded()).toBe(false);
|
||||
|
||||
const handle = engine.editing.addEditableCircle([52, 4], {
|
||||
radius: 100,
|
||||
color: "red",
|
||||
});
|
||||
handle.update([53, 5], 200);
|
||||
map.loadStyle();
|
||||
|
||||
const source = circleSource(map)!;
|
||||
const ring = (source.data as { geometry: { coordinates: number[][][] } })
|
||||
.geometry.coordinates[0];
|
||||
// The polygon sits around the moved center, not the original one
|
||||
expect(ring[0][1]).toBeCloseTo(53, 1);
|
||||
expect(ring[0][0]).toBeCloseTo(5, 1);
|
||||
});
|
||||
|
||||
it("removes its markers, layers, and listeners", async () => {
|
||||
const { engine, map, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, center, onClick } = addCircle(engine);
|
||||
const element = center.options.element as HTMLElement;
|
||||
|
||||
handle.remove();
|
||||
expect(fakeMarker.all).toHaveLength(0);
|
||||
expect(circleSource(map)).toBeUndefined();
|
||||
expect(
|
||||
layerIds(map).filter((id) => id.startsWith("ha-map-editable"))
|
||||
).toHaveLength(0);
|
||||
element.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
MapEditableCircleHandle,
|
||||
MapEditableCircleOptions,
|
||||
MapEditableMarkerHandle,
|
||||
MapEditingSupport,
|
||||
MapEngine,
|
||||
MapEngineOptions,
|
||||
MapMarkerHandle,
|
||||
} from "../../../src/common/map/map-engine";
|
||||
import "../../../src/components/map/ha-map";
|
||||
import type { HaMap } from "../../../src/components/map/ha-map";
|
||||
import type {
|
||||
HaMap,
|
||||
HaMapEditableLocation,
|
||||
} 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
|
||||
@@ -89,6 +96,47 @@ const fakeEngine = vi.hoisted(() => {
|
||||
setClustering = vi.fn();
|
||||
|
||||
refreshClusters = vi.fn();
|
||||
|
||||
/** Editable circles and markers, with the callbacks ha-map passed */
|
||||
circles: (MapEditableCircleHandle & {
|
||||
options: MapEditableCircleOptions;
|
||||
})[] = [];
|
||||
|
||||
draggables: (MapEditableMarkerHandle & {
|
||||
element: HTMLElement;
|
||||
options: { onDragEnd?: (location: [number, number]) => void };
|
||||
})[] = [];
|
||||
|
||||
editing: MapEditingSupport = {
|
||||
addDraggableMarker: vi.fn((element, location, options) => {
|
||||
const handle = {
|
||||
location,
|
||||
element,
|
||||
options,
|
||||
clusterData: options.clusterData,
|
||||
setLocation: vi.fn((next: [number, number]) => {
|
||||
handle.location = next;
|
||||
}),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
this.draggables.push(handle);
|
||||
return handle;
|
||||
}),
|
||||
addEditableCircle: vi.fn((center, options) => {
|
||||
const handle = {
|
||||
center,
|
||||
radius: options.radius,
|
||||
options,
|
||||
update: vi.fn((nextCenter: [number, number], nextRadius: number) => {
|
||||
handle.center = nextCenter;
|
||||
handle.radius = nextRadius;
|
||||
}),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
this.circles.push(handle);
|
||||
return handle;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return FakeMapLibreEngine;
|
||||
});
|
||||
@@ -275,3 +323,177 @@ describe("ha-map engine selection", () => {
|
||||
expect(entityHandles(el)).not.toBe(handlesBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ha-map editable locations", () => {
|
||||
const HOME: HaMapEditableLocation = {
|
||||
id: "home",
|
||||
location: [52, 4],
|
||||
radius: 100,
|
||||
title: "Home",
|
||||
locationEditable: true,
|
||||
radiusEditable: true,
|
||||
activatable: true,
|
||||
};
|
||||
const PIN: HaMapEditableLocation = {
|
||||
id: "pin",
|
||||
location: [52, 5],
|
||||
locationEditable: true,
|
||||
activatable: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
||||
webgl2.supported = true;
|
||||
fakeEngine.failInit = false;
|
||||
fakeEngine.initGate = undefined;
|
||||
fakeEngine.instances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const createEditor = async (locations: HaMapEditableLocation[]) => {
|
||||
const el = document.createElement("ha-map");
|
||||
el.editableLocations = locations;
|
||||
(el as any)._config = {
|
||||
config: { latitude: 52.3731339, longitude: 4.8903147 },
|
||||
};
|
||||
const availability: boolean[] = [];
|
||||
el.addEventListener("editing-available-changed", (ev) => {
|
||||
availability.push((ev as CustomEvent).detail.available);
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
await vi.waitUntil(() => isLoaded(el));
|
||||
await el.updateComplete;
|
||||
return { el, engine: fakeEngine.instances[0], availability };
|
||||
};
|
||||
|
||||
it("draws circles and markers through the engine's editing support", async () => {
|
||||
const { engine, availability } = await createEditor([HOME, PIN]);
|
||||
|
||||
expect(availability).toEqual([true]);
|
||||
expect(engine.circles).toHaveLength(1);
|
||||
expect(engine.circles[0].center).toEqual([52, 4]);
|
||||
expect(engine.circles[0].radius).toBe(100);
|
||||
expect(engine.circles[0].options.title).toBe("Home");
|
||||
expect(engine.circles[0].options.moveable).toBe(true);
|
||||
expect(engine.circles[0].options.resizable).toBe(true);
|
||||
expect(engine.draggables).toHaveLength(1);
|
||||
expect(engine.draggables[0].location).toEqual([52, 5]);
|
||||
});
|
||||
|
||||
it("moves existing handles instead of recreating them", async () => {
|
||||
const { el, engine } = await createEditor([HOME, PIN]);
|
||||
|
||||
el.editableLocations = [
|
||||
{ ...HOME, location: [52.1, 4.1], radius: 250 },
|
||||
{ ...PIN, location: [52.2, 5.2] },
|
||||
];
|
||||
await el.updateComplete;
|
||||
|
||||
expect(engine.editing.addEditableCircle).toHaveBeenCalledOnce();
|
||||
expect(engine.circles[0].update).toHaveBeenCalledWith([52.1, 4.1], 250);
|
||||
expect(engine.editing.addDraggableMarker).toHaveBeenCalledOnce();
|
||||
expect(engine.draggables[0].setLocation).toHaveBeenCalledWith([52.2, 5.2]);
|
||||
});
|
||||
|
||||
it("rebuilds a handle whose appearance changed and removes dropped ones", async () => {
|
||||
const { el, engine } = await createEditor([HOME, PIN]);
|
||||
|
||||
el.editableLocations = [{ ...HOME, title: "Work" }];
|
||||
await el.updateComplete;
|
||||
|
||||
expect(engine.circles[0].remove).toHaveBeenCalledOnce();
|
||||
expect(engine.circles).toHaveLength(2);
|
||||
expect(engine.circles[1].options.title).toBe("Work");
|
||||
expect(engine.draggables[0].remove).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("forwards moves, resizes, and activation with the location id", async () => {
|
||||
const { el, engine } = await createEditor([HOME, PIN]);
|
||||
const events: { type: string; detail: unknown }[] = [];
|
||||
for (const type of [
|
||||
"editable-location-moved",
|
||||
"editable-location-resized",
|
||||
"editable-location-clicked",
|
||||
]) {
|
||||
el.addEventListener(type, (ev) => {
|
||||
events.push({ type, detail: (ev as CustomEvent).detail });
|
||||
});
|
||||
}
|
||||
|
||||
engine.circles[0].options.onMove!([52.1, 4.1]);
|
||||
engine.circles[0].options.onResize!(250);
|
||||
engine.circles[0].options.onClick!();
|
||||
engine.draggables[0].options.onDragEnd!([52.2, 5.2]);
|
||||
engine.draggables[0].element.dispatchEvent(new MouseEvent("pointerdown"));
|
||||
engine.draggables[0].element.click();
|
||||
engine.draggables[0].element.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: " " })
|
||||
);
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "editable-location-moved",
|
||||
detail: { id: "home", location: [52.1, 4.1] },
|
||||
},
|
||||
{
|
||||
type: "editable-location-resized",
|
||||
detail: { id: "home", radius: 250 },
|
||||
},
|
||||
{ type: "editable-location-clicked", detail: { id: "home" } },
|
||||
{
|
||||
type: "editable-location-moved",
|
||||
detail: { id: "pin", location: [52.2, 5.2] },
|
||||
},
|
||||
{ type: "editable-location-clicked", detail: { id: "pin" } },
|
||||
{ type: "editable-location-clicked", detail: { id: "pin" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("makes markers buttons only when they act on activation", async () => {
|
||||
const { el, engine } = await createEditor([
|
||||
{ ...HOME, activatable: false },
|
||||
{ ...PIN, activatable: false },
|
||||
]);
|
||||
const clicked = vi.fn();
|
||||
el.addEventListener("editable-location-clicked", clicked);
|
||||
|
||||
expect(engine.circles[0].options.onClick).toBeUndefined();
|
||||
expect((engine.draggables[0].options as any).focusable).toBe(false);
|
||||
engine.draggables[0].element.click();
|
||||
engine.draggables[0].element.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter" })
|
||||
);
|
||||
expect(clicked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("relabels its handles when the language changes", async () => {
|
||||
const { el, engine } = await createEditor([PIN]);
|
||||
expect((engine.draggables[0].options as any).title).toBeUndefined();
|
||||
|
||||
(el as any)._i18n = { localize: (key: string) => `nl:${key}` };
|
||||
await el.updateComplete;
|
||||
|
||||
expect(engine.draggables[0].remove).toHaveBeenCalledOnce();
|
||||
expect((engine.draggables[1].options as any).title).toBe(
|
||||
"nl:ui.components.map.location"
|
||||
);
|
||||
});
|
||||
|
||||
it("shows locations statically on an engine without editing support", async () => {
|
||||
webgl2.supported = false;
|
||||
const { el, availability } = await createEditor([HOME, PIN]);
|
||||
|
||||
expect(availability).toEqual([false]);
|
||||
// Drawn with the plain viewing primitives: a circle plus two markers
|
||||
expect(leafletMap(el)).toBeDefined();
|
||||
let layers = 0;
|
||||
leafletMap(el).eachLayer(() => {
|
||||
layers++;
|
||||
});
|
||||
expect(layers).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,10 @@ const STATES = {
|
||||
},
|
||||
} as unknown as HassEntities;
|
||||
|
||||
// jsdom has no WebGL2, so ha-map runs its Leaflet fallback engine here; the
|
||||
// Leaflet map underneath is what the fit assertions read.
|
||||
const leafletMap = (el: HaMap) => (el as any)._engine?.leafletMap;
|
||||
|
||||
const createMap = async (): Promise<HaMap> => {
|
||||
const el = document.createElement("ha-map");
|
||||
el.entities = [
|
||||
@@ -61,7 +65,7 @@ const createMap = async (): Promise<HaMap> => {
|
||||
config: { latitude: 52.3731339, longitude: 4.8903147 },
|
||||
};
|
||||
document.body.appendChild(el);
|
||||
await vi.waitUntil(() => el.leafletMap !== undefined && (el as any)._loaded);
|
||||
await vi.waitUntil(() => leafletMap(el) !== undefined && (el as any)._loaded);
|
||||
await el.updateComplete;
|
||||
return el;
|
||||
};
|
||||
@@ -104,7 +108,7 @@ describe("ha-map", () => {
|
||||
fireResizeObservers();
|
||||
await el.updateComplete;
|
||||
|
||||
const map = el.leafletMap!;
|
||||
const map = leafletMap(el)!;
|
||||
// The map should be fitted to the markers, not zoomed out to the world.
|
||||
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
|
||||
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
|
||||
@@ -131,7 +135,7 @@ describe("ha-map", () => {
|
||||
|
||||
try {
|
||||
const el = await createMap();
|
||||
const map = el.leafletMap!;
|
||||
const map = leafletMap(el)!;
|
||||
expect(map.getZoom()).toBeGreaterThanOrEqual(10);
|
||||
expect(map.getCenter().lat).toBeCloseTo(52.3745, 2);
|
||||
expect(map.getCenter().lng).toBeCloseTo(4.8925, 2);
|
||||
|
||||
@@ -6063,15 +6063,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/leaflet-draw@npm:1.0.13":
|
||||
version: 1.0.13
|
||||
resolution: "@types/leaflet-draw@npm:1.0.13"
|
||||
dependencies:
|
||||
"@types/leaflet": "npm:^1.9"
|
||||
checksum: 10/1a6c3a8b3011f15362108b522fa6d17cca888a7f866e2e582dac921e2c748d9e24685d83b2a5ed1d97e3a1448b0f5b1879a42f1143ffdeb36b71c7fb9b94e9f5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/leaflet.markercluster@npm:1.5.6":
|
||||
version: 1.5.6
|
||||
resolution: "@types/leaflet.markercluster@npm:1.5.6"
|
||||
@@ -10430,7 +10421,6 @@ __metadata:
|
||||
"@types/culori": "npm:4.0.1"
|
||||
"@types/html-minifier-terser": "npm:7.0.2"
|
||||
"@types/leaflet": "npm:1.9.22"
|
||||
"@types/leaflet-draw": "npm:1.0.13"
|
||||
"@types/leaflet.markercluster": "npm:1.5.6"
|
||||
"@types/lodash.merge": "npm:4.6.9"
|
||||
"@types/luxon": "npm:3.7.5"
|
||||
@@ -10488,7 +10478,6 @@ __metadata:
|
||||
jsdom: "npm:30.0.1"
|
||||
jszip: "npm:3.10.1"
|
||||
leaflet: "npm:1.9.4"
|
||||
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
|
||||
leaflet.markercluster: "npm:1.5.3"
|
||||
license-checker-rseidelsohn: "npm:5.0.1"
|
||||
lightningcss: "npm:1.33.0"
|
||||
@@ -11688,20 +11677,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"leaflet-draw@npm:1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "leaflet-draw@npm:1.0.4"
|
||||
checksum: 10/180cc222a2d63de9d1ea197bca107ba5b977e1ce323dd714028be0ba30d0b8a2963619b2555bff5b2efb7befd240a45ad973c0e1947ca355ee4e7e2d11231cbe
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"leaflet-draw@patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch::locator=home-assistant-frontend%40workspace%3A.":
|
||||
version: 1.0.4
|
||||
resolution: "leaflet-draw@patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch::version=1.0.4&hash=790842&locator=home-assistant-frontend%40workspace%3A."
|
||||
checksum: 10/70e43ef0307dd3995dc392609a627b349070f25a57b908a4ab07e17b579878fdd0ff090847ca42826b933c547e324a4e560f35a8a1417f8209e702c3512e4d8a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"leaflet.markercluster@npm:1.5.3":
|
||||
version: 1.5.3
|
||||
resolution: "leaflet.markercluster@npm:1.5.3"
|
||||
|
||||
Reference in New Issue
Block a user