mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-10 21:59:45 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f45689676 | ||
|
|
6ccc78114c | ||
|
|
ec4bfc9255 | ||
|
|
3b9e026aff | ||
|
|
569bcc01da | ||
|
|
8ea28508c3 |
File diff suppressed because one or more lines are too long
@@ -319,6 +319,7 @@ const SCHEMAS: {
|
||||
selector: { config_entry: {} },
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
offset: { name: "Offset", selector: { offset: { enable_day: true } } },
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
number_box: {
|
||||
name: "Number Box",
|
||||
|
||||
@@ -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,31 @@
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
|
||||
export const durationValueToData = (
|
||||
value?: HaDurationData | string | number
|
||||
): HaDurationData | undefined => {
|
||||
if (typeof value === "number") {
|
||||
return { seconds: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const negative = value.trim()[0] === "-";
|
||||
const parts = value
|
||||
.split(":")
|
||||
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { seconds: parts[0] };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { hours: parts[0], minutes: parts[1] };
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
hours: parts[0],
|
||||
minutes: parts[1],
|
||||
seconds: parts[2],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -12,13 +12,24 @@ import { getEntityContext } from "./context/get_entity_context";
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
export const ENTITY_NAME_TYPES = [
|
||||
"floor",
|
||||
"area",
|
||||
"parent_device",
|
||||
"device",
|
||||
"entity",
|
||||
] as const;
|
||||
|
||||
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: "entity" | "device" | "area" | "floor";
|
||||
type: EntityNameType;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
@@ -91,7 +102,7 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, area, floor } = getEntityContext(
|
||||
const { device, parentDevice, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
@@ -105,6 +116,8 @@ export const computeEntityNameList = (
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "parent_device":
|
||||
return parentDevice ? computeDeviceName(parentDevice) : undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
@@ -136,14 +149,20 @@ export const computeEntityPickerDisplay = (
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
@@ -152,7 +171,7 @@ export const computeEntityPickerDisplay = (
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDeviceAreaId } from "./get_device_context";
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
device: DeviceRegistryEntry | null;
|
||||
parentDevice: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
@@ -31,6 +32,7 @@ export const getEntityContext = (
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
@@ -65,6 +67,9 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
@@ -74,6 +79,7 @@ export const getEntityEntryContext = (
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
parentDevice: parentDevice || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
|
||||
@@ -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,12 @@ interface ManagedMarker {
|
||||
element: HTMLElement;
|
||||
location: MapLatLng;
|
||||
options: MapMarkerOptions;
|
||||
handle: MapMarkerHandle;
|
||||
handle: MapEditableMarkerHandle;
|
||||
draggable?: boolean;
|
||||
onDragEnd?: (location: MapLatLng) => void;
|
||||
dragging?: boolean;
|
||||
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
|
||||
staleLocation?: MapLatLng;
|
||||
mlMarker?: MapLibreMarker;
|
||||
decoration?: MapItemHandle;
|
||||
removed?: boolean;
|
||||
@@ -183,6 +198,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 +367,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 +435,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 +539,91 @@ 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) => {
|
||||
if (managed.dragging) {
|
||||
return;
|
||||
}
|
||||
// Skip one update echoing the pre-drag location (the save has not returned yet)
|
||||
const stale = managed.staleLocation;
|
||||
managed.staleLocation = undefined;
|
||||
if (
|
||||
stale &&
|
||||
newLocation[0] === stale[0] &&
|
||||
newLocation[1] === stale[1]
|
||||
) {
|
||||
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 +647,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 +660,18 @@ 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.staleLocation = managed.location;
|
||||
});
|
||||
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 +718,261 @@ 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);
|
||||
};
|
||||
|
||||
// Skip one update echoing the pre-edit values (the save has not returned yet)
|
||||
let dragging = false;
|
||||
let staleCenter: MapLatLng | undefined;
|
||||
let staleRadius: number | undefined;
|
||||
|
||||
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;
|
||||
staleCenter = currentCenter;
|
||||
staleRadius = currentRadius;
|
||||
}
|
||||
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;
|
||||
staleCenter = currentCenter;
|
||||
staleRadius = currentRadius;
|
||||
});
|
||||
handleMarker?.on("dragend", () => {
|
||||
dragging = false;
|
||||
});
|
||||
});
|
||||
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
|
||||
staleCenter !== undefined &&
|
||||
staleRadius !== undefined &&
|
||||
candidateCenter[0] === staleCenter[0] &&
|
||||
candidateCenter[1] === staleCenter[1] &&
|
||||
candidateRadius === staleRadius;
|
||||
|
||||
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;
|
||||
}
|
||||
const stale = isStale(newCenter, newRadius);
|
||||
staleCenter = undefined;
|
||||
staleRadius = undefined;
|
||||
if (stale) {
|
||||
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 +1109,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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -973,7 +1340,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
group.iconMarker?.remove();
|
||||
this._openGroup(group);
|
||||
};
|
||||
icon.element.tabIndex = 0;
|
||||
setMarkerAccessibility(
|
||||
icon.element,
|
||||
group.members
|
||||
|
||||
+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,53 @@
|
||||
/**
|
||||
* 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 => {
|
||||
clearMarkerAccessibility(element);
|
||||
const owned: string[] = [];
|
||||
if (title && !element.hasAttribute("aria-label")) {
|
||||
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];
|
||||
};
|
||||
|
||||
@@ -31,8 +31,6 @@ export type FormatEntityAttributeNameFunc = (
|
||||
attribute: string
|
||||
) => string;
|
||||
|
||||
export type EntityNameType = "entity" | "device" | "area" | "floor";
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
|
||||
@@ -23,6 +23,8 @@ import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
// Taller rows when the name is drawn under the bar instead of in a column.
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
const GRID_BOTTOM = 30;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
@@ -41,6 +43,11 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
// Render each row's name inside the plot (under its bar) instead of in a
|
||||
// left-hand category-label column. Opt-in; used by the history panel.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -70,7 +77,11 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
|
||||
.height=${`${
|
||||
this.data.length *
|
||||
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
|
||||
GRID_BOTTOM
|
||||
}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -185,6 +196,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
@@ -196,9 +208,11 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -227,31 +241,47 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
// Draw the name inside the plot, under each row's bar, matching
|
||||
// the line charts whose legend sits under the plot. The taller
|
||||
// rows keep a name clear of the next row's bar.
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
padding: [18, 0, 0, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "top",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
|
||||
@@ -79,6 +79,11 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
// Render timeline row names inside the plot (under each bar) instead of in a
|
||||
// left-hand column. Opt-in; used by the history panel.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -227,6 +232,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -444,6 +450,10 @@ export class StateHistoryCharts extends LitElement {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.container,
|
||||
lit-virtualizer {
|
||||
height: 100%;
|
||||
|
||||
@@ -7,9 +7,12 @@ import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
@@ -35,9 +38,7 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
@@ -170,6 +171,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
no-sort
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
@@ -387,6 +389,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
);
|
||||
|
||||
if (context.device) options.add("device");
|
||||
if (context.parentDevice) options.add("parent_device");
|
||||
if (context.area) options.add("area");
|
||||
if (context.floor) options.add("floor");
|
||||
return options;
|
||||
@@ -399,8 +402,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
const items = ENTITY_NAME_TYPES.filter(
|
||||
(name) => name !== "parent_device" || types.has(name)
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
@@ -464,7 +467,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const excludedValues = new Set(
|
||||
this._items
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.filter((item) => KNOWN_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
|
||||
@@ -178,6 +178,17 @@ export class HaStateContentPicker extends LitElement {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.parentDevice) {
|
||||
contextItems.push({
|
||||
id: "parent_device_name",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
sorting_label: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.area) {
|
||||
contextItems.push({
|
||||
id: "area_name",
|
||||
|
||||
@@ -52,6 +52,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "statisticId", weight: 3 },
|
||||
@@ -292,17 +293,27 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || id;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -318,6 +329,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
@@ -395,14 +407,20 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -410,7 +428,11 @@ export class HaStatisticPicker extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
@@ -427,6 +449,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
|
||||
@@ -197,17 +197,27 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -761,6 +761,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
@@ -795,6 +798,15 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (parentDeviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.device-picker.parent_device"
|
||||
),
|
||||
value: parentDeviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (areaName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.area-picker.area"),
|
||||
|
||||
@@ -42,6 +42,7 @@ const SELECTOR_FALLBACK_VALUES = {
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
offset: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
|
||||
@@ -74,6 +74,7 @@ const SELECTOR_INITIAL_VALUES = {
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
offset: () => ({ type: "none" }),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
@@ -26,35 +27,7 @@ export class HaTimeDuration extends LitElement {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private _data = memoizeOne(
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined => {
|
||||
if (typeof value === "number") {
|
||||
return { seconds: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const negative = value.trim()[0] === "-";
|
||||
const parts = value
|
||||
.split(":")
|
||||
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { seconds: parts[0] };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { hours: parts[0], minutes: parts[1] };
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
hours: parts[0],
|
||||
minutes: parts[1],
|
||||
seconds: parts[2],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
);
|
||||
private _data = memoizeOne(durationValueToData);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
mdiClockMinusOutline,
|
||||
mdiClockOutline,
|
||||
mdiClockPlusOutline,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type {
|
||||
OffsetSelector,
|
||||
OffsetSelectorValue,
|
||||
OffsetType,
|
||||
} from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-select";
|
||||
|
||||
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
|
||||
{ value: "none", iconPath: mdiClockOutline },
|
||||
{ value: "before", iconPath: mdiClockMinusOutline },
|
||||
{ value: "after", iconPath: mdiClockPlusOutline },
|
||||
];
|
||||
|
||||
const DEFAULT_DURATION: HaDurationData = { hours: 0, minutes: 0, seconds: 0 };
|
||||
|
||||
const isOffsetValue = (value: unknown): value is OffsetSelectorValue =>
|
||||
typeof value === "object" && value !== null && "type" in value;
|
||||
|
||||
@customElement("ha-selector-offset")
|
||||
export class HaOffsetSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: OffsetSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: OffsetSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@query("ha-duration-input", true) private _durationInput?: HaDurationInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._durationInput?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private get _value(): OffsetSelectorValue | undefined {
|
||||
return isOffsetValue(this.value) ? this.value : undefined;
|
||||
}
|
||||
|
||||
private _duration = memoizeOne(durationValueToData);
|
||||
|
||||
private _typeOptions = memoizeOne((localize: LocalizeFunc) =>
|
||||
OFFSET_TYPES.map(({ value, iconPath }) => ({
|
||||
value,
|
||||
iconPath,
|
||||
label: localize(`ui.components.selectors.offset.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const type = this._value?.type ?? "none";
|
||||
return html`
|
||||
<div class="container">
|
||||
${
|
||||
this.label
|
||||
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
|
||||
: nothing
|
||||
}
|
||||
<div class="inputs">
|
||||
<ha-select
|
||||
.value=${type}
|
||||
.options=${this._typeOptions(this.hass.localize)}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._typeChanged}
|
||||
></ha-select>
|
||||
${
|
||||
type !== "none"
|
||||
? html`<div class="value-row">
|
||||
<span class="value-label"
|
||||
>${this.hass.localize(
|
||||
"ui.components.selectors.offset.duration"
|
||||
)}${this.required ? "*" : ""}</span
|
||||
>
|
||||
<ha-duration-input
|
||||
.data=${this._duration(this._value?.duration)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.enableDay=${this.selector.offset?.enable_day}
|
||||
.enableMillisecond=${
|
||||
this.selector.offset?.enable_millisecond
|
||||
}
|
||||
@value-changed=${this._durationChanged}
|
||||
></ha-duration-input>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _durationChanged(ev: CustomEvent<{ value?: HaDurationData }>) {
|
||||
ev.stopPropagation();
|
||||
const type = this._value?.type;
|
||||
if (!type || type === "none") {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { type, duration: ev.detail.value ?? DEFAULT_DURATION },
|
||||
});
|
||||
}
|
||||
|
||||
private _typeChanged(ev: CustomEvent<{ value?: string }>) {
|
||||
ev.stopPropagation();
|
||||
const type = ev.detail.value as OffsetType | undefined;
|
||||
if (!type || type === this._value?.type) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value:
|
||||
type === "none"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
duration:
|
||||
this._duration(this._value?.duration) ?? DEFAULT_DURATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.inputs,
|
||||
.value-row {
|
||||
--ha-input-padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-offset": HaOffsetSelector;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ const LOAD_ELEMENTS = {
|
||||
number: () => import("./ha-selector-number"),
|
||||
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
|
||||
object: () => import("./ha-selector-object"),
|
||||
offset: () => import("./ha-selector-offset"),
|
||||
period: () => import("./ha-selector-period"),
|
||||
qr_code: () => import("./ha-selector-qr-code"),
|
||||
select: () => import("./ha-selector-select"),
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
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 } from "../../data/context";
|
||||
import type { HomeAssistantInternationalization, 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";
|
||||
|
||||
@@ -41,8 +38,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 +64,13 @@ 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>;
|
||||
|
||||
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 +87,50 @@ 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)}
|
||||
.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 +139,102 @@ export class HaLocationsEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _getLayers = 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));
|
||||
private _editableLocations = memoizeOne(
|
||||
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
|
||||
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 layers;
|
||||
return (locations ?? []).map((location) => ({
|
||||
id: location.id,
|
||||
location: [location.latitude, location.longitude],
|
||||
radius: location.radius,
|
||||
element: this._elementFor(location),
|
||||
elementSize: [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 _elementFor(location: MarkerLocation): HTMLElement | undefined {
|
||||
const key = JSON.stringify([
|
||||
location.icon,
|
||||
location.iconPath,
|
||||
location.name,
|
||||
location.location_editable,
|
||||
]);
|
||||
const cached = this._elements.get(location.id);
|
||||
if (cached?.key === key) {
|
||||
return cached.element;
|
||||
}
|
||||
const element = this._createIcon(location);
|
||||
this._elements.set(location.id, { key, element });
|
||||
return element;
|
||||
}
|
||||
|
||||
if (changedProps.has("locations")) {
|
||||
this._updateMarkers();
|
||||
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
|
||||
if (!location.icon && !location.iconPath) {
|
||||
return undefined;
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
el.className = `named-icon ${
|
||||
location.location_editable ? "draggable" : ""
|
||||
}`;
|
||||
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 +243,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 +282,28 @@ 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 */
|
||||
ha-alert {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
inset-inline-start: 56px;
|
||||
inset-inline-end: var(--ha-space-2);
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+330
-61
@@ -1,9 +1,8 @@
|
||||
import { consume } 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,8 +25,12 @@ 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 { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
configContext,
|
||||
@@ -82,6 +84,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;
|
||||
@@ -127,17 +248,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;
|
||||
|
||||
@@ -168,11 +281,18 @@ 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;
|
||||
|
||||
@@ -227,6 +347,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._entityHandles = [];
|
||||
this._zoneHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._removeEditableLocations();
|
||||
this._focusPoints = [];
|
||||
this._focusZonePoints = [];
|
||||
|
||||
@@ -282,12 +403,24 @@ export class HaMap extends ReactiveElement {
|
||||
this._drawPaths();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -336,7 +469,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 +555,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 +587,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._entityHandles = [];
|
||||
this._zoneHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._removeEditableLocations();
|
||||
this._focusPoints = [];
|
||||
this._focusZonePoints = [];
|
||||
this._pendingFit = undefined;
|
||||
@@ -502,7 +639,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 +653,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 +694,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 +733,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 {
|
||||
@@ -1015,8 +1289,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 +1334,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;
|
||||
|
||||
@@ -129,21 +129,31 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -157,6 +167,7 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
@@ -62,17 +62,27 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
isRTL: boolean,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import {
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { AreaRegistryEntry } from "../../data/area/area_registry";
|
||||
@@ -132,10 +132,35 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
private _loadedConfigEntryId?: string;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (!this.subEntry && changedProps.has("itemId")) {
|
||||
this._updateItemData();
|
||||
}
|
||||
if (
|
||||
changedProps.has("itemId") ||
|
||||
changedProps.has("type") ||
|
||||
changedProps.has("hass")
|
||||
) {
|
||||
this._updateDomain();
|
||||
}
|
||||
}
|
||||
|
||||
private _updateDomain() {
|
||||
if (this.type === "entity") {
|
||||
this._setDomainName(computeDomain(this.itemId));
|
||||
return;
|
||||
}
|
||||
if (this.type !== "device") {
|
||||
return;
|
||||
}
|
||||
const configEntryId =
|
||||
this.hass.devices?.[this.itemId]?.primary_config_entry;
|
||||
if (configEntryId && configEntryId !== this._loadedConfigEntryId) {
|
||||
this._loadedConfigEntryId = configEntryId;
|
||||
this._getDeviceDomain(configEntryId);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -674,7 +699,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, item: string) => {
|
||||
private _itemData(type: TargetType, item: string) {
|
||||
if (type === "floor") {
|
||||
const floor: FloorRegistryEntry | undefined = this.hass.floors?.[item];
|
||||
return {
|
||||
@@ -697,9 +722,25 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
if (type === "device") {
|
||||
const device: DeviceRegistryEntry | undefined = this.hass.devices?.[item];
|
||||
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const context = [
|
||||
area ? computeAreaName(area) : undefined,
|
||||
parentDevice ? computeDeviceName(parentDevice) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ "
|
||||
);
|
||||
|
||||
return {
|
||||
name: device
|
||||
@@ -709,19 +750,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.states
|
||||
)
|
||||
: item,
|
||||
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
|
||||
context,
|
||||
fallbackIconPath: mdiDevices,
|
||||
notFound: !device,
|
||||
};
|
||||
}
|
||||
if (type === "entity") {
|
||||
this._setDomainName(computeDomain(item));
|
||||
|
||||
const stateObject: HassEntity | undefined = this.hass.states[item];
|
||||
const entityName = stateObject
|
||||
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
|
||||
: item;
|
||||
const { area, device } = stateObject
|
||||
const { area, device, parentDevice } = stateObject
|
||||
? getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
@@ -729,10 +768,17 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: undefined, device: undefined };
|
||||
: { area: undefined, device: undefined, parentDevice: undefined };
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
@@ -760,7 +806,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
fallbackIconPath: mdiLabel,
|
||||
notFound: !label,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
|
||||
@@ -31,6 +31,10 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.deviceName",
|
||||
weight: 7,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 6,
|
||||
},
|
||||
{
|
||||
name: "search_labels.areaName",
|
||||
weight: 6,
|
||||
@@ -129,14 +133,20 @@ export const getEntities = (
|
||||
const stateObj = hass.states[entityId];
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
let domainName = domainNames.get(domain);
|
||||
@@ -146,7 +156,11 @@ export const getEntities = (
|
||||
}
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
@@ -159,6 +173,7 @@ export const getEntities = (
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
|
||||
+33
-17
@@ -1,14 +1,15 @@
|
||||
import type {
|
||||
HassConfig,
|
||||
HassEntities,
|
||||
HassEntity,
|
||||
HassEntityAttributeBase,
|
||||
MessageBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
|
||||
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { isNumericSensorDeviceClass } from "./sensor";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
@@ -336,7 +337,7 @@ const processTimelineEntity = (
|
||||
localize: LocalizeFunc,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig,
|
||||
entities: HomeAssistant["entities"],
|
||||
hass: HomeAssistant,
|
||||
entityId: string,
|
||||
states: EntityHistoryState[],
|
||||
current_state: HassEntity | undefined
|
||||
@@ -358,7 +359,7 @@ const processTimelineEntity = (
|
||||
localize,
|
||||
locale,
|
||||
config,
|
||||
entities[entityId],
|
||||
hass.entities[entityId],
|
||||
entityId,
|
||||
{
|
||||
...(state.a || first.a),
|
||||
@@ -374,10 +375,16 @@ const processTimelineEntity = (
|
||||
}
|
||||
|
||||
return {
|
||||
name: computeStateNameFromEntityAttributes(
|
||||
entityId,
|
||||
current_state?.attributes || first.a
|
||||
),
|
||||
name: current_state
|
||||
? hass.formatEntityName(current_state, DEFAULT_ENTITY_NAME, {
|
||||
separator: computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ ",
|
||||
}) || current_state.entity_id
|
||||
: computeStateNameFromEntityAttributes(entityId, first.a),
|
||||
entity_id: entityId,
|
||||
data,
|
||||
};
|
||||
@@ -387,9 +394,15 @@ const processLineChartEntities = (
|
||||
unit: string,
|
||||
device_class: string | undefined,
|
||||
entities: HistoryStates,
|
||||
hassEntities: HassEntities
|
||||
hass: HomeAssistant
|
||||
): LineChartUnit => {
|
||||
const data: LineChartEntity[] = [];
|
||||
const nameSeparator = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ ";
|
||||
|
||||
const entityIds = Object.keys(entities);
|
||||
entityIds.forEach((entityId) => {
|
||||
@@ -436,16 +449,19 @@ const processLineChartEntities = (
|
||||
processedStates.push(processedState);
|
||||
}
|
||||
|
||||
const attributes =
|
||||
entityId in hassEntities
|
||||
? hassEntities[entityId].attributes
|
||||
: "friendly_name" in first.a
|
||||
? first.a
|
||||
: undefined;
|
||||
const name =
|
||||
entityId in hass.states
|
||||
? hass.formatEntityName(hass.states[entityId], DEFAULT_ENTITY_NAME, {
|
||||
separator: nameSeparator,
|
||||
}) || entityId
|
||||
: computeStateNameFromEntityAttributes(
|
||||
entityId,
|
||||
"friendly_name" in first.a ? first.a : {}
|
||||
);
|
||||
|
||||
data.push({
|
||||
domain,
|
||||
name: computeStateNameFromEntityAttributes(entityId, attributes || {}),
|
||||
name,
|
||||
entity_id: entityId,
|
||||
states: processedStates,
|
||||
});
|
||||
@@ -610,7 +626,7 @@ export const computeHistory = (
|
||||
localize,
|
||||
hass.locale,
|
||||
hass.config,
|
||||
hass.entities,
|
||||
hass,
|
||||
entityId,
|
||||
stateInfo,
|
||||
currentState
|
||||
@@ -638,7 +654,7 @@ export const computeHistory = (
|
||||
unit,
|
||||
deviceClass,
|
||||
lineChartDevices[key],
|
||||
hass.states
|
||||
hass
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
EntityRegistryEntry,
|
||||
} from "./entity/entity_registry";
|
||||
import type { EntitySources } from "./entity/entity_sources";
|
||||
import type { HaDurationData } from "../components/ha-duration-input";
|
||||
|
||||
export type ThresholdMode = "crossed" | "changed" | "is";
|
||||
|
||||
@@ -63,6 +64,7 @@ export type Selector =
|
||||
| NumberSelector
|
||||
| NumericThresholdSelector
|
||||
| ObjectSelector
|
||||
| OffsetSelector
|
||||
| PeriodSelector
|
||||
| AssistPipelineSelector
|
||||
| QRCodeSelector
|
||||
@@ -446,6 +448,20 @@ export interface ObjectSelector {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type OffsetType = "none" | "before" | "after";
|
||||
|
||||
export interface OffsetSelector {
|
||||
offset: {
|
||||
enable_day?: boolean;
|
||||
enable_millisecond?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface OffsetSelectorValue {
|
||||
type: OffsetType;
|
||||
duration?: HaDurationData | string | number;
|
||||
}
|
||||
|
||||
export type PeriodKey =
|
||||
| "today"
|
||||
| "yesterday"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { formatDurationLong } from "../../common/datetime/format_duration";
|
||||
import { DEFAULT_ENTITY_NAME } from "../../common/entity/compute_entity_name_display";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { Selector } from "../selector";
|
||||
import type { OffsetSelectorValue, Selector } from "../selector";
|
||||
|
||||
export const formatSelectorValue = (
|
||||
hass: HomeAssistant,
|
||||
@@ -82,10 +85,7 @@ export const formatSelectorValue = (
|
||||
if (!stateObj) {
|
||||
return entityId;
|
||||
}
|
||||
const name = hass.formatEntityName(stateObj, [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
]);
|
||||
const name = hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
return name || entityId;
|
||||
})
|
||||
.join(", ");
|
||||
@@ -125,6 +125,21 @@ export const formatSelectorValue = (
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
if ("offset" in selector) {
|
||||
const { type, duration } = value as OffsetSelectorValue;
|
||||
const durationData = durationValueToData(duration);
|
||||
if (type === "none" || !durationData) {
|
||||
return "";
|
||||
}
|
||||
const formattedDuration = formatDurationLong(hass.locale, durationData);
|
||||
if (!formattedDuration) {
|
||||
return "";
|
||||
}
|
||||
return hass.localize(`ui.components.selectors.offset.summary.${type}`, {
|
||||
duration: formattedDuration,
|
||||
});
|
||||
}
|
||||
|
||||
return ensureArray(value)
|
||||
.map((v) =>
|
||||
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { durationDataToSeconds } from "../../common/datetime/duration_to_seconds";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
import type { PlatformTrigger } from "../automation";
|
||||
import { TRIGGER_ROW_CONFIG_KEYS } from "../automation";
|
||||
import type { OffsetSelectorValue } from "../selector";
|
||||
import type { TriggerDescription } from "../trigger";
|
||||
|
||||
const TRIGGER_KEYS: (keyof PlatformTrigger)[] = [
|
||||
...TRIGGER_ROW_CONFIG_KEYS,
|
||||
"trigger",
|
||||
"target",
|
||||
"options",
|
||||
];
|
||||
|
||||
const isOffsetSelectorValue = (value: unknown): value is OffsetSelectorValue =>
|
||||
typeof value === "object" && value !== null && "type" in value;
|
||||
|
||||
const absDuration = (duration: HaDurationData): HaDurationData =>
|
||||
Object.fromEntries(
|
||||
Object.entries(duration).map(([field, amount]) => [
|
||||
field,
|
||||
Math.abs(amount ?? 0),
|
||||
])
|
||||
);
|
||||
|
||||
const migrateLegacyOffsetOptions = (
|
||||
options: Record<string, unknown>,
|
||||
fields: TriggerDescription["fields"]
|
||||
): Record<string, unknown> => {
|
||||
let migrated: Record<string, unknown> | undefined;
|
||||
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
if (!field.selector || !("offset" in field.selector)) {
|
||||
continue;
|
||||
}
|
||||
const typeKey = `${key}_type`;
|
||||
const value = options[key] as OffsetSelectorValue["duration"] | undefined;
|
||||
const hasLegacyType = typeKey in options;
|
||||
if (
|
||||
!hasLegacyType &&
|
||||
(value === undefined || isOffsetSelectorValue(value))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
migrated ??= { ...options };
|
||||
delete migrated[typeKey];
|
||||
if (isOffsetSelectorValue(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const duration = durationValueToData(value ?? 0);
|
||||
if (!duration || Object.values(duration).some((amount) => isNaN(amount))) {
|
||||
continue;
|
||||
}
|
||||
let seconds = durationDataToSeconds(duration);
|
||||
// The released trigger schema defaulted offset_type to before
|
||||
if (options[typeKey] !== "after") {
|
||||
seconds = -seconds;
|
||||
}
|
||||
migrated[key] =
|
||||
seconds === 0
|
||||
? { type: "none" }
|
||||
: {
|
||||
type: seconds < 0 ? "before" : "after",
|
||||
duration: absDuration(duration),
|
||||
};
|
||||
}
|
||||
|
||||
return migrated ?? options;
|
||||
};
|
||||
|
||||
const moveStrayKeysToOptions = (
|
||||
trigger: PlatformTrigger
|
||||
): PlatformTrigger | undefined => {
|
||||
let migrated: PlatformTrigger | undefined;
|
||||
for (const key in trigger) {
|
||||
if (TRIGGER_KEYS.includes(key as keyof PlatformTrigger)) {
|
||||
continue;
|
||||
}
|
||||
migrated ??= { ...trigger, options: { ...trigger.options } };
|
||||
migrated.options![key] = trigger[key];
|
||||
delete migrated[key];
|
||||
}
|
||||
return migrated;
|
||||
};
|
||||
|
||||
export const migratePlatformTrigger = (
|
||||
trigger: PlatformTrigger,
|
||||
description?: TriggerDescription
|
||||
): PlatformTrigger | undefined => {
|
||||
let migrated = moveStrayKeysToOptions(trigger);
|
||||
|
||||
const options = (migrated ?? trigger).options;
|
||||
if (options && description?.fields) {
|
||||
const migratedOptions = migrateLegacyOffsetOptions(
|
||||
options,
|
||||
description.fields
|
||||
);
|
||||
if (migratedOptions !== options) {
|
||||
migrated = { ...(migrated ?? trigger), options: migratedOptions };
|
||||
}
|
||||
}
|
||||
|
||||
return migrated;
|
||||
};
|
||||
@@ -591,11 +591,17 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
const addToMenuItem = this.hass.localize(
|
||||
"ui.dialogs.more_info_control.add_to.item"
|
||||
);
|
||||
|
||||
@@ -82,7 +82,7 @@ class HaMoreInfoRelated extends LitElement {
|
||||
? (domain as ItemType)
|
||||
: "entity";
|
||||
|
||||
const { floor, area, device } = getEntityContext(
|
||||
const { floor, area, device, parentDevice } = getEntityContext(
|
||||
this._stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
@@ -91,6 +91,13 @@ class HaMoreInfoRelated extends LitElement {
|
||||
);
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceNameDisplay(
|
||||
parentDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
const deviceName = device
|
||||
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
|
||||
: undefined;
|
||||
@@ -124,6 +131,14 @@ class HaMoreInfoRelated extends LitElement {
|
||||
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
|
||||
});
|
||||
}
|
||||
if (parentDevice && parentDeviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.dialogs.more_info_control.parent_device",
|
||||
value: parentDeviceName,
|
||||
href: `/config/devices/device/${parentDevice.id}`,
|
||||
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
|
||||
});
|
||||
}
|
||||
if (device && deviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.components.related-items.device",
|
||||
|
||||
@@ -267,6 +267,7 @@ class OnboardingLocation extends LitElement {
|
||||
? location[1]
|
||||
: Number(place.lon),
|
||||
location_editable: place.place_id === highlightedMarker,
|
||||
clickable: true,
|
||||
}))
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ import type { HASSDomTargetEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { mainWindow } from "../../../common/dom/get_main_window";
|
||||
import { computeAreaName } from "../../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeEntityNameList } from "../../../common/entity/compute_entity_name_display";
|
||||
import { computeFloorName } from "../../../common/entity/compute_floor_name";
|
||||
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
|
||||
import { isNumericState } from "../../../common/number/format_number";
|
||||
import { stringCompare } from "../../../common/string/compare";
|
||||
import type {
|
||||
@@ -901,6 +903,12 @@ class DialogAddAutomationElement
|
||||
const [targetType, targetId] = this._extractTypeAndIdFromTarget(
|
||||
this._selectedTarget
|
||||
);
|
||||
const separator = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ ";
|
||||
|
||||
if (targetId) {
|
||||
if (targetType === "area") {
|
||||
@@ -913,11 +921,21 @@ class DialogAddAutomationElement
|
||||
);
|
||||
}
|
||||
} else if (targetType === "device") {
|
||||
const areaId = this.hass.devices[targetId]?.area_id;
|
||||
if (areaId) {
|
||||
subtitle = computeAreaName(this.hass.areas[areaId]) || areaId;
|
||||
const device = this.hass.devices[targetId];
|
||||
const area = device
|
||||
? getDeviceArea(device, this.hass.areas, this.hass.devices)
|
||||
: undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? this.hass.devices[device.parent_device_id]
|
||||
: undefined;
|
||||
if (area) {
|
||||
subtitle = [
|
||||
computeAreaName(area) || area.area_id,
|
||||
parentDevice ? computeDeviceName(parentDevice) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
} else {
|
||||
const device = this.hass.devices[targetId];
|
||||
subtitle = this.hass.localize(
|
||||
`ui.panel.config.automation.editor.${device?.entry_type === "service" ? "services" : "unassigned_devices"}`
|
||||
);
|
||||
@@ -933,25 +951,28 @@ class DialogAddAutomationElement
|
||||
);
|
||||
} else {
|
||||
const stateObj = this.hass.states[targetId];
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
subtitle = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ "
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
subtitle = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-
|
||||
import "../../../../../components/ha-selector/ha-selector";
|
||||
import "../../../../../components/ha-settings-row";
|
||||
import type { PlatformTrigger } from "../../../../../data/automation";
|
||||
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
|
||||
import type { IntegrationManifest } from "../../../../../data/integration";
|
||||
import { fetchIntegrationManifest } from "../../../../../data/integration";
|
||||
import type { TargetSelector } from "../../../../../data/selector";
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
getTriggerObjectId,
|
||||
type TriggerDescription,
|
||||
} from "../../../../../data/trigger";
|
||||
import { migratePlatformTrigger } from "../../../../../data/trigger/migrate_platform_trigger";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { documentationUrl } from "../../../../../util/documentation-url";
|
||||
|
||||
@@ -28,13 +28,6 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
|
||||
!field.required &&
|
||||
!("boolean" in field.selector && field.default);
|
||||
|
||||
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
|
||||
...TRIGGER_ROW_CONFIG_KEYS,
|
||||
"trigger",
|
||||
"target",
|
||||
"options",
|
||||
];
|
||||
|
||||
@customElement("ha-automation-trigger-platform")
|
||||
export class HaPlatformTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -61,34 +54,21 @@ export class HaPlatformTrigger extends LitElement {
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
this.hass.loadBackendTranslation("selector");
|
||||
}
|
||||
if (
|
||||
changedProperties.has("trigger") ||
|
||||
changedProperties.has("description")
|
||||
) {
|
||||
const migrated = migratePlatformTrigger(this.trigger, this.description);
|
||||
if (migrated) {
|
||||
fireEvent(this, "value-changed", { value: migrated });
|
||||
this.trigger = migrated;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newValue: PlatformTrigger | undefined;
|
||||
|
||||
for (const key in this.trigger) {
|
||||
// Migrate old options to `options`
|
||||
if (DEFAULT_KEYS.includes(key as keyof PlatformTrigger)) {
|
||||
continue;
|
||||
}
|
||||
if (newValue === undefined) {
|
||||
newValue = {
|
||||
...this.trigger,
|
||||
options: { [key]: this.trigger[key] },
|
||||
};
|
||||
} else {
|
||||
newValue.options![key] = this.trigger[key];
|
||||
}
|
||||
delete newValue[key];
|
||||
}
|
||||
if (newValue !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
this.trigger = newValue;
|
||||
}
|
||||
|
||||
const oldValue = changedProperties.get("trigger") as
|
||||
undefined | this["trigger"];
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "id", weight: 2 },
|
||||
@@ -64,14 +65,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
@@ -89,6 +96,7 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
|
||||
@@ -160,11 +160,17 @@ export class DialogVacuumSegmentMapping
|
||||
const deviceName = context?.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context?.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context?.area ? computeAreaName(context.area) : undefined;
|
||||
|
||||
const breadcrumb = [areaName, deviceName, entityName].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
);
|
||||
const breadcrumb = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
deviceName,
|
||||
entityName,
|
||||
].filter((v): v is string => Boolean(v));
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
|
||||
@@ -86,6 +86,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const entities = this._filterEntities(
|
||||
this.params.exposedEntities,
|
||||
this._registries,
|
||||
this._filter
|
||||
);
|
||||
|
||||
@@ -180,6 +181,7 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
private _filterEntities = memoizeOne(
|
||||
(
|
||||
exposedEntities: Record<string, ExposeEntitySettings>,
|
||||
registries: ContextType<typeof registriesContext>,
|
||||
filter?: string
|
||||
): FilteredEntity[] => {
|
||||
const lowerFilter = filter?.toLowerCase();
|
||||
@@ -196,11 +198,16 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
const nameList = computeEntityNameList(
|
||||
entity,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this._registries.entities,
|
||||
this._registries.devices,
|
||||
this._registries.areas,
|
||||
this._registries.floors
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
registries.entities,
|
||||
registries.devices,
|
||||
registries.areas,
|
||||
registries.floors
|
||||
);
|
||||
|
||||
if (!lowerFilter) {
|
||||
@@ -219,13 +226,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, deviceName, areaName] = nameList;
|
||||
const [, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
if (deviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parentDeviceName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (areaName?.toLowerCase().includes(lowerFilter)) {
|
||||
result.push({ entity, nameList });
|
||||
continue;
|
||||
@@ -238,14 +250,18 @@ class DialogExposeEntity extends DirtyStateProviderMixin<string[]>()(
|
||||
|
||||
private _renderItem = (item: FilteredEntity) => {
|
||||
const { entity: entityState, nameList } = item;
|
||||
const [entityName, deviceName, areaName] = nameList;
|
||||
const [entityName, deviceName, parentDeviceName, areaName] = nameList;
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
const primary = entityName || deviceName || entityState.entity_id;
|
||||
const context = [areaName, entityName ? deviceName : undefined]
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const showEntityId = this._config?.userData?.showEntityIdPicker;
|
||||
|
||||
@@ -61,8 +61,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _stateItems?: HassEntity[];
|
||||
|
||||
@state() private _activeEntry = "";
|
||||
|
||||
@state() private _canEditCore = false;
|
||||
|
||||
@query("ha-locations-editor") private _map?: HaLocationsEditor;
|
||||
@@ -151,7 +149,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 +182,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
|
||||
@@ -273,7 +269,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
@location-updated=${this._locationUpdated}
|
||||
@radius-updated=${this._radiusUpdated}
|
||||
@marker-clicked=${this._markerClicked}
|
||||
></ha-locations-editor>
|
||||
<div class="overflow">${listBox}</div>
|
||||
</div>
|
||||
@@ -365,7 +360,6 @@ 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],
|
||||
@@ -384,7 +378,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -400,10 +393,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _markerClicked(ev: CustomEvent) {
|
||||
this._activeEntry = ev.detail.id;
|
||||
}
|
||||
|
||||
private _createZone() {
|
||||
this._openDialog();
|
||||
}
|
||||
@@ -417,9 +406,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) {
|
||||
@@ -435,7 +422,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
this._zoomZone(entryId);
|
||||
this._activeEntry = entryId;
|
||||
}
|
||||
|
||||
private async _zoomZone(id: string) {
|
||||
@@ -478,7 +464,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);
|
||||
@@ -505,7 +490,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);
|
||||
|
||||
@@ -270,6 +270,7 @@ class HaPanelHistory extends LitElement {
|
||||
.endTime=${this._endDate}
|
||||
.narrow=${this.narrow}
|
||||
sync-charts
|
||||
inside-labels
|
||||
>
|
||||
</state-history-charts>
|
||||
`
|
||||
@@ -812,7 +813,13 @@ class HaPanelHistory extends LitElement {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden auto;
|
||||
padding: 16px;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* Narrow screens: keep the vertical rhythm but give the charts the
|
||||
horizontal space back. */
|
||||
:host([narrow]) .results {
|
||||
padding-inline: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
|
||||
@@ -41,8 +41,8 @@ export const isRunRow = (item: LogbookEntry): boolean =>
|
||||
classifyLogbookEntry(item) === "automation";
|
||||
|
||||
// How much naming detail an entity row shows, from least to most. The value is
|
||||
// the broadest part shown: `none` (name hidden), `entity`, `device` (device ▸
|
||||
// entity), `area` (area ▸ device ▸ entity).
|
||||
// the broadest part shown: `none` (name hidden), `entity`, `device` (parent
|
||||
// device ▸ device ▸ entity), `area` (area ▸ parent device ▸ device ▸ entity).
|
||||
export type LogbookNameDetail = "none" | "entity" | "device" | "area";
|
||||
|
||||
export interface EntityDisplay {
|
||||
@@ -60,14 +60,20 @@ export const entityDisplay = (
|
||||
return {};
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
|
||||
@@ -82,11 +88,11 @@ export const entityDisplay = (
|
||||
parts = [];
|
||||
break;
|
||||
case "device":
|
||||
parts = [deviceQualifier];
|
||||
parts = [parentDeviceName, deviceQualifier];
|
||||
break;
|
||||
case "area":
|
||||
default:
|
||||
parts = [areaName, deviceQualifier];
|
||||
parts = [areaName, parentDeviceName, deviceQualifier];
|
||||
}
|
||||
|
||||
const filtered = parts.filter(Boolean) as string[];
|
||||
|
||||
@@ -36,9 +36,14 @@ import { hasConfigChanged } from "../../common/has-changed";
|
||||
import type { LovelaceCard } from "../../types";
|
||||
import type { EnergyDistributionCardConfig } from "../types";
|
||||
import { formatNumber } from "../../../../common/number/format_number";
|
||||
import { round } from "../../../../common/number/round";
|
||||
|
||||
const CIRCLE_CIRCUMFERENCE = 238.76104;
|
||||
|
||||
// Flows are differences of sums; anything that rounds to 0 Wh is noise
|
||||
const hasFlow = (value: number | null): value is number =>
|
||||
round(value ?? 0, 3) > 0;
|
||||
|
||||
const periodIncludesNow = (data: EnergyData): boolean =>
|
||||
!data.end || data.end.getTime() >= Date.now();
|
||||
|
||||
@@ -843,8 +848,8 @@ class HuiEnergyDistrubutionCard
|
||||
? svg`<path
|
||||
id="battery-grid"
|
||||
class=${classMap({
|
||||
"battery-from-grid": Boolean(batteryFromGrid),
|
||||
"battery-to-grid": Boolean(batteryToGrid),
|
||||
"battery-from-grid": hasFlow(batteryFromGrid),
|
||||
"battery-to-grid": hasFlow(batteryToGrid),
|
||||
})}
|
||||
d="M45,100 v-15 c0,-35 -10,-30 -30,-30 h-20"
|
||||
vector-effect="non-scaling-stroke"
|
||||
@@ -875,14 +880,14 @@ class HuiEnergyDistrubutionCard
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
solarToGrid && this._animate
|
||||
hasFlow(solarToGrid) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="return"
|
||||
vector-effect="non-scaling-stroke"
|
||||
>
|
||||
<animateMotion
|
||||
dur="${6 - (solarToGrid / totalLines) * 6}s"
|
||||
dur="${6 - (solarToGrid / totalLines) * 5}s"
|
||||
repeatCount="indefinite"
|
||||
calcMode="linear"
|
||||
>
|
||||
@@ -892,7 +897,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
solarConsumption && this._animate
|
||||
hasFlow(solarConsumption) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="solar"
|
||||
@@ -909,7 +914,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
gridConsumption && this._animate
|
||||
hasFlow(gridConsumption) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="grid"
|
||||
@@ -926,7 +931,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
solarToBattery && this._animate
|
||||
hasFlow(solarToBattery) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="battery-solar"
|
||||
@@ -943,7 +948,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
batteryConsumption && this._animate
|
||||
hasFlow(batteryConsumption) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="battery-house"
|
||||
@@ -960,7 +965,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
batteryFromGrid && this._animate
|
||||
hasFlow(batteryFromGrid) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="battery-from-grid"
|
||||
@@ -978,7 +983,7 @@ class HuiEnergyDistrubutionCard
|
||||
: ""
|
||||
}
|
||||
${
|
||||
batteryToGrid && this._animate
|
||||
hasFlow(batteryToGrid) && this._animate
|
||||
? svg`<circle
|
||||
r="1"
|
||||
class="battery-to-grid"
|
||||
|
||||
@@ -63,8 +63,8 @@ export class HuiEntityEditor extends LitElement {
|
||||
this.hass.formatEntityName(
|
||||
stateObj,
|
||||
useDeviceName
|
||||
? [{ type: "area" }]
|
||||
: [{ type: "area" }, { type: "device" }],
|
||||
? [{ type: "area" }, { type: "parent_device" }]
|
||||
: [{ type: "area" }, { type: "parent_device" }, { type: "device" }],
|
||||
{
|
||||
separator: isRTL ? " ◂ " : " ▸ ",
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { computeDeviceName } from "../../../../common/entity/compute_device_name
|
||||
import { computeDomain } from "../../../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
|
||||
import { computeStateName } from "../../../../common/entity/compute_state_name";
|
||||
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
|
||||
import { stringCompare } from "../../../../common/string/compare";
|
||||
import { entityComboBoxKeys } from "../../../../data/entity/entity_picker";
|
||||
import { domainToName } from "../../../../data/integration";
|
||||
@@ -53,12 +54,14 @@ export interface SearchableEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
area: string;
|
||||
parentDevice: string;
|
||||
device: string;
|
||||
domain: string;
|
||||
search_labels: {
|
||||
entityName: string | null;
|
||||
friendlyName: string | null;
|
||||
deviceName: string | null;
|
||||
parentDeviceName: string | null;
|
||||
areaName: string | null;
|
||||
domainName: string | null;
|
||||
entityId: string;
|
||||
@@ -136,14 +139,22 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
|
||||
const entry = entityReg[entityId];
|
||||
if (entry?.hidden) continue;
|
||||
|
||||
const device = entry?.device_id ? deviceReg[entry.device_id] : undefined;
|
||||
const areaId = entry?.area_id ?? device?.area_id;
|
||||
const area = areaId ? areaReg[areaId] : undefined;
|
||||
const { device, parentDevice, area } = getEntityContext(
|
||||
stateObj,
|
||||
entityReg,
|
||||
deviceReg,
|
||||
areaReg,
|
||||
floorReg
|
||||
);
|
||||
const areaId = area?.area_id;
|
||||
const domain = computeDomain(entityId);
|
||||
|
||||
const entityName = computeEntityName(stateObj, entityReg, deviceReg);
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const domainName = domainToName(localize, domain);
|
||||
|
||||
@@ -151,12 +162,14 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
|
||||
id: entityId,
|
||||
name: entityName || friendlyName || entityId,
|
||||
area: areaName ?? "",
|
||||
parentDevice: parentDeviceName ?? "",
|
||||
device: deviceName ?? "",
|
||||
domain: domainName,
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
domainName: domainName || null,
|
||||
entityId,
|
||||
|
||||
@@ -32,6 +32,7 @@ interface EntityPickerTableRowData extends DataTableRowData {
|
||||
name: string;
|
||||
entity_name?: string;
|
||||
device_name?: string;
|
||||
parent_device_name?: string;
|
||||
area_name?: string;
|
||||
domain_name: string;
|
||||
last_changed: string;
|
||||
@@ -53,21 +54,31 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
private _data = memoizeOne(
|
||||
(
|
||||
states: HomeAssistant["states"],
|
||||
entityRegistry: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
localize: LocalizeFunc,
|
||||
entities?: string[]
|
||||
): EntityPickerTableRowData[] =>
|
||||
(entities || Object.keys(states)).map<EntityPickerTableRowData>(
|
||||
(entity) => {
|
||||
const stateObj = this.hass.states[entity];
|
||||
const stateObj = states[entity];
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
entityRegistry,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
const name = [deviceName, entityName].filter(Boolean).join(" ");
|
||||
const domain = computeDomain(entity);
|
||||
|
||||
@@ -78,6 +89,7 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
name: name,
|
||||
entity_name: entityName,
|
||||
device_name: deviceName,
|
||||
parent_device_name: parentDeviceName,
|
||||
area_name: areaName,
|
||||
domain_name: domainToName(localize, domain),
|
||||
last_changed: stateObj!.last_changed,
|
||||
@@ -89,6 +101,10 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
const data = this._data(
|
||||
this.hass.states,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors,
|
||||
this.hass.localize,
|
||||
this.entities
|
||||
);
|
||||
@@ -152,6 +168,7 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
entity.entity_name || entity.device_name || entity.entity_id;
|
||||
const secondary = [
|
||||
entity.area_name,
|
||||
entity.parent_device_name,
|
||||
entity.entity_name ? entity.device_name : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -191,6 +208,12 @@ export class HuiEntityPickerTable extends LitElement {
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
columns.parent_device_name = {
|
||||
title: "parent_device_name",
|
||||
filterable: true,
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
columns.area_name = {
|
||||
title: "area_name",
|
||||
filterable: true,
|
||||
|
||||
@@ -287,7 +287,9 @@ export class HuiSuggestionEntityTree extends LitElement {
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
const separator = rtl ? " ◂ " : " ▸ ";
|
||||
const secondary = [item.area, item.device].filter(Boolean).join(separator);
|
||||
const secondary = [item.area, item.parentDevice, item.device]
|
||||
.filter(Boolean)
|
||||
.join(separator);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
|
||||
@@ -172,14 +172,20 @@ export class HuiHeadingBadgesEditor extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -187,7 +193,11 @@ export class HuiHeadingBadgesEditor extends LitElement {
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [entityName ? deviceName : undefined, areaName]
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { array, literal, object, string, union } from "superstruct";
|
||||
import { array, enums, literal, object, string, union } from "superstruct";
|
||||
import { ENTITY_NAME_TYPES } from "../../../../common/entity/compute_entity_name_display";
|
||||
|
||||
const entityNameItemStruct = union([
|
||||
object({
|
||||
@@ -6,12 +7,7 @@ const entityNameItemStruct = union([
|
||||
text: string(),
|
||||
}),
|
||||
object({
|
||||
type: union([
|
||||
literal("entity"),
|
||||
literal("device"),
|
||||
literal("area"),
|
||||
literal("floor"),
|
||||
]),
|
||||
type: enums(ENTITY_NAME_TYPES),
|
||||
}),
|
||||
string(),
|
||||
]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { join } from "lit/directives/join";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import type { EntityNameType } from "../common/entity/compute_entity_name_display";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import {
|
||||
STRINGS_SEPARATOR_DOT,
|
||||
@@ -56,6 +57,13 @@ export const DEFAULT_STATE_CONTENT_DOMAINS: Record<string, StateContent> = {
|
||||
valve: ["state", "current_position"],
|
||||
};
|
||||
|
||||
const NAME_CONTENT_TYPES = new Map<string, EntityNameType>([
|
||||
["device_name", "device"],
|
||||
["parent_device_name", "parent_device"],
|
||||
["area_name", "area"],
|
||||
["floor_name", "floor"],
|
||||
]);
|
||||
|
||||
const TIMESTAMP_STATE_PROPS = ["last_updated", "last_changed"];
|
||||
|
||||
const TIMESTAMP_CONTENTS = [...TIMESTAMP_STATE_PROPS, "last_triggered"];
|
||||
@@ -190,13 +198,11 @@ class StateDisplay extends LitElement {
|
||||
if (content === "entity-id") {
|
||||
return stateObj.entity_id;
|
||||
}
|
||||
if (
|
||||
content === "device_name" ||
|
||||
content === "area_name" ||
|
||||
content === "floor_name"
|
||||
) {
|
||||
const type = content.replace("_name", "") as "device" | "area" | "floor";
|
||||
return this.hass.formatEntityName(stateObj, { type }) || undefined;
|
||||
const nameType = NAME_CONTENT_TYPES.get(content);
|
||||
if (nameType) {
|
||||
return (
|
||||
this.hass.formatEntityName(stateObj, { type: nameType }) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
let relativeDateTime: string | number | undefined;
|
||||
|
||||
@@ -660,6 +660,16 @@
|
||||
"outside": "Outside range"
|
||||
}
|
||||
},
|
||||
"offset": {
|
||||
"none": "No offset",
|
||||
"before": "Before",
|
||||
"after": "After",
|
||||
"duration": "Duration",
|
||||
"summary": {
|
||||
"before": "{duration} before",
|
||||
"after": "{duration} after"
|
||||
}
|
||||
},
|
||||
"automation_behavior": {
|
||||
"trigger": {
|
||||
"options": {
|
||||
@@ -771,6 +781,7 @@
|
||||
"types": {
|
||||
"floor": "Floor",
|
||||
"area": "Area",
|
||||
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
|
||||
"device": "Device",
|
||||
"entity": "Entity",
|
||||
"area_missing": "No area assigned",
|
||||
@@ -929,6 +940,7 @@
|
||||
"no_devices": "No devices available",
|
||||
"no_match": "No devices found for {term}",
|
||||
"device": "Device",
|
||||
"parent_device": "Parent device",
|
||||
"unnamed_device": "Unnamed device",
|
||||
"no_area": "No area",
|
||||
"placeholder": "Select a device",
|
||||
@@ -1154,7 +1166,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…",
|
||||
@@ -1473,6 +1489,7 @@
|
||||
"remaining_time": "Remaining time",
|
||||
"install_status": "Install status",
|
||||
"device_name": "Device name",
|
||||
"parent_device_name": "Parent device name",
|
||||
"area_name": "Area name",
|
||||
"floor_name": "Floor name"
|
||||
},
|
||||
@@ -1702,6 +1719,7 @@
|
||||
"state": "State",
|
||||
"context": "Context",
|
||||
"floor": "Floor",
|
||||
"parent_device": "[%key:ui::components::device-picker::parent_device%]",
|
||||
"entity_id": "ID",
|
||||
"copy_value": "Copy {label}: {value}",
|
||||
"labels": "Labels",
|
||||
|
||||
@@ -245,6 +245,43 @@ describe("computeEntityNameDisplay", () => {
|
||||
expect(result).toBe("Kitchen Smart Light");
|
||||
});
|
||||
|
||||
it("returns parent device name for an entity on a child device", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "switch.outlet_1" });
|
||||
const hass = {
|
||||
entities: {
|
||||
"switch.outlet_1": mockEntity({
|
||||
entity_id: "switch.outlet_1",
|
||||
name: "Switch",
|
||||
device_id: "child_1",
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
child_1: mockDevice({
|
||||
id: "child_1",
|
||||
name: "Outlet 1",
|
||||
parent_device_id: "parent_1",
|
||||
}),
|
||||
parent_1: mockDevice({
|
||||
id: "parent_1",
|
||||
name: "Power strip",
|
||||
}),
|
||||
},
|
||||
areas: {},
|
||||
floors: {},
|
||||
} as unknown as HomeAssistant;
|
||||
|
||||
const result = computeEntityNameDisplay(
|
||||
stateObj,
|
||||
[{ type: "parent_device" }, { type: "device" }, { type: "entity" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
expect(result).toBe("Power strip Outlet 1 Switch");
|
||||
});
|
||||
|
||||
it("returns floor name", () => {
|
||||
const stateObj = mockStateObj({ entity_id: "light.kitchen" });
|
||||
const hass = {
|
||||
|
||||
@@ -37,6 +37,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
});
|
||||
@@ -88,6 +89,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -144,6 +146,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: childDevice,
|
||||
parentDevice,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -184,6 +187,7 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor,
|
||||
});
|
||||
@@ -223,8 +227,38 @@ describe("getEntityContext", () => {
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area,
|
||||
floor: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not resolve a parent device for an entity on a main device", () => {
|
||||
const entity = mockEntity({
|
||||
entity_id: "switch.strip",
|
||||
device_id: "parent_1",
|
||||
});
|
||||
const parentDevice = mockDevice({
|
||||
id: "parent_1",
|
||||
});
|
||||
const stateObj = mockStateObj({
|
||||
entity_id: "switch.strip",
|
||||
});
|
||||
|
||||
const result = getEntityContext(
|
||||
stateObj,
|
||||
{ "switch.strip": entity },
|
||||
{ parent_1: parentDevice },
|
||||
{},
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
entity,
|
||||
device: parentDevice,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,276 @@ 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" }));
|
||||
// The host echoes the old radius once before its save returns
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.radius).toBeCloseTo(110, 5);
|
||||
// A later identical update is a real change
|
||||
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 one update echoing the values from before a drag", async () => {
|
||||
const { engine, ready } = await createEngine();
|
||||
await ready;
|
||||
const { handle, center } = addCircle(engine);
|
||||
|
||||
center.lngLat = [4.01, 52];
|
||||
center.fire("dragstart");
|
||||
// Nothing moves while a drag is in progress
|
||||
handle.update([53, 5], 500);
|
||||
expect(handle.center).toEqual([52, 4]);
|
||||
center.fire("drag");
|
||||
center.fire("dragend");
|
||||
|
||||
// The host saves and echoes the old values once; that is not a move back
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.center).toEqual([52, 4.01]);
|
||||
// A later identical update is a real change
|
||||
handle.update([52, 4], 100);
|
||||
expect(handle.center).toEqual([52, 4]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
+152
-2
@@ -1,6 +1,12 @@
|
||||
import { describe, it, assert, vi } from "vitest";
|
||||
import { HistoryStream } from "../../src/data/history";
|
||||
import { describe, it, assert, expect, vi } from "vitest";
|
||||
import { HistoryStream, computeHistory } from "../../src/data/history";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { mockDevice, mockEntity } from "../common/entity/context/context-mock";
|
||||
import {
|
||||
createMockEntityState,
|
||||
createMockHass,
|
||||
mockLocalize,
|
||||
} from "../fixtures/hass";
|
||||
|
||||
const mockHass = {} as HomeAssistant;
|
||||
|
||||
@@ -108,3 +114,147 @@ describe("HistoryStream.processMessage", () => {
|
||||
assert.equal(firstState.lu, purgeBeforePythonTime + 100);
|
||||
});
|
||||
});
|
||||
|
||||
const namedHistoryHass = (options: {
|
||||
entityId: string;
|
||||
entityName?: string;
|
||||
deviceName: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
rtl?: boolean;
|
||||
}): HomeAssistant => {
|
||||
const hass = createMockHass(
|
||||
{
|
||||
[options.entityId]: createMockEntityState(
|
||||
options.entityId,
|
||||
"on",
|
||||
options.attributes ?? { friendly_name: "Caméra Allée Battery state" }
|
||||
),
|
||||
},
|
||||
{
|
||||
entities: {
|
||||
[options.entityId]: mockEntity({
|
||||
entity_id: options.entityId,
|
||||
name: options.entityName,
|
||||
device_id: "device_1",
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
device_1: mockDevice({
|
||||
id: "device_1",
|
||||
name: options.deviceName,
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
if (options.rtl) {
|
||||
hass.language = "he";
|
||||
hass.translationMetadata = {
|
||||
fragments: [],
|
||||
translations: {
|
||||
he: { nativeName: "Hebrew", isRTL: true, hash: "" },
|
||||
},
|
||||
};
|
||||
}
|
||||
return hass;
|
||||
};
|
||||
|
||||
const binaryHistory = (entityId: string, friendlyName?: string) => ({
|
||||
[entityId]: [
|
||||
{
|
||||
s: "on",
|
||||
a: friendlyName ? { friendly_name: friendlyName } : {},
|
||||
lu: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const numericHistory = (entityId: string, friendlyName?: string) => ({
|
||||
[entityId]: [
|
||||
{
|
||||
s: "12",
|
||||
a: {
|
||||
unit_of_measurement: "W",
|
||||
...(friendlyName ? { friendly_name: friendlyName } : {}),
|
||||
},
|
||||
lu: 1_700_000_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("computeHistory entity names", () => {
|
||||
it("labels a timeline entity as Device ▸ Entity when the entity has its own name", () => {
|
||||
const entityId = "binary_sensor.allee_battery";
|
||||
const result = computeHistory(
|
||||
namedHistoryHass({
|
||||
entityId,
|
||||
entityName: "Battery state",
|
||||
deviceName: "Caméra Allée",
|
||||
}),
|
||||
binaryHistory(entityId),
|
||||
[],
|
||||
mockLocalize
|
||||
);
|
||||
expect(result.timeline[0]?.name).toBe("Caméra Allée ▸ Battery state");
|
||||
});
|
||||
|
||||
it("labels a line entity as Device ▸ Entity when the entity has its own name", () => {
|
||||
const entityId = "sensor.allee_power";
|
||||
const result = computeHistory(
|
||||
namedHistoryHass({
|
||||
entityId,
|
||||
entityName: "Power",
|
||||
deviceName: "Caméra Allée",
|
||||
attributes: {
|
||||
unit_of_measurement: "W",
|
||||
friendly_name: "Caméra Allée Power",
|
||||
},
|
||||
}),
|
||||
numericHistory(entityId),
|
||||
[],
|
||||
mockLocalize
|
||||
);
|
||||
expect(result.line[0]?.data[0]?.name).toBe("Caméra Allée ▸ Power");
|
||||
});
|
||||
|
||||
it("uses just the device name when the entity has no name of its own", () => {
|
||||
const entityId = "binary_sensor.desk";
|
||||
const result = computeHistory(
|
||||
namedHistoryHass({
|
||||
entityId,
|
||||
deviceName: "Desk",
|
||||
attributes: { friendly_name: "Desk" },
|
||||
}),
|
||||
binaryHistory(entityId),
|
||||
[],
|
||||
mockLocalize
|
||||
);
|
||||
expect(result.timeline[0]?.name).toBe("Desk");
|
||||
});
|
||||
|
||||
it("falls back to friendly_name when the entity has no current state", () => {
|
||||
const entityId = "binary_sensor.removed";
|
||||
const result = computeHistory(
|
||||
createMockHass(),
|
||||
binaryHistory(entityId, "Gone sensor"),
|
||||
[],
|
||||
mockLocalize
|
||||
);
|
||||
expect(result.timeline[0]?.name).toBe("Gone sensor");
|
||||
});
|
||||
|
||||
it("uses the RTL separator when the language is RTL", () => {
|
||||
const entityId = "binary_sensor.allee_battery";
|
||||
const result = computeHistory(
|
||||
namedHistoryHass({
|
||||
entityId,
|
||||
entityName: "Battery state",
|
||||
deviceName: "Caméra Allée",
|
||||
rtl: true,
|
||||
}),
|
||||
binaryHistory(entityId),
|
||||
[],
|
||||
mockLocalize
|
||||
);
|
||||
expect(result.timeline[0]?.name).toBe("Caméra Allée ◂ Battery state");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,6 +159,57 @@ describe("entityDisplay", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("with a parent device", () => {
|
||||
const h = baseHass({
|
||||
states: {
|
||||
"sensor.outlet_1_power": mockStateObj({
|
||||
entity_id: "sensor.outlet_1_power",
|
||||
}),
|
||||
},
|
||||
entities: {
|
||||
"sensor.outlet_1_power": mockEntity({
|
||||
entity_id: "sensor.outlet_1_power",
|
||||
name: "Power",
|
||||
device_id: "outlet_1",
|
||||
}),
|
||||
},
|
||||
devices: {
|
||||
outlet_1: mockDevice({
|
||||
id: "outlet_1",
|
||||
name: "Outlet 1",
|
||||
parent_device_id: "strip",
|
||||
}),
|
||||
strip: mockDevice({
|
||||
id: "strip",
|
||||
name: "Power strip",
|
||||
area_id: "area_1",
|
||||
}),
|
||||
},
|
||||
areas: { area_1: mockArea({ area_id: "area_1", name: "Garage" }) },
|
||||
});
|
||||
|
||||
it("shows 'Area ▸ Parent ▸ Device' by default", () => {
|
||||
expect(entityDisplay(h, "sensor.outlet_1_power")).toEqual({
|
||||
primary: "Power",
|
||||
secondary: "Garage ▸ Power strip ▸ Outlet 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows 'Parent ▸ Device' for the 'device' name detail", () => {
|
||||
expect(entityDisplay(h, "sensor.outlet_1_power", "device")).toEqual({
|
||||
primary: "Power",
|
||||
secondary: "Power strip ▸ Outlet 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows no context for the 'entity' name detail", () => {
|
||||
expect(entityDisplay(h, "sensor.outlet_1_power", "entity")).toEqual({
|
||||
primary: "Power",
|
||||
secondary: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty display for a deleted entity", () => {
|
||||
expect(entityDisplay(baseHass({}), "light.removed")).toEqual({});
|
||||
});
|
||||
|
||||
@@ -263,11 +263,20 @@ describe("searchEntities", () => {
|
||||
"light.kitchen_ceiling": state("light.kitchen_ceiling"),
|
||||
"light.living_lamp": state("light.living_lamp"),
|
||||
"sensor.orphan": state("sensor.orphan"),
|
||||
"sensor.outlet_1_power": state("sensor.outlet_1_power"),
|
||||
},
|
||||
entities: {
|
||||
"light.kitchen_ceiling": entity({ area_id: "kitchen" }),
|
||||
"light.living_lamp": entity({ area_id: "living" }),
|
||||
"sensor.orphan": entity({}),
|
||||
"sensor.outlet_1_power": entity({ device_id: "outlet_1" }),
|
||||
},
|
||||
devices: {
|
||||
outlet_1: device("outlet_1", {
|
||||
name: "Outlet 1",
|
||||
parent_device_id: "strip",
|
||||
}),
|
||||
strip: device("strip", { name: "Power strip", area_id: "kitchen" }),
|
||||
},
|
||||
areas: {
|
||||
kitchen: area("kitchen", { floor_id: "ground", name: "Kitchen" }),
|
||||
@@ -299,6 +308,12 @@ describe("searchEntities", () => {
|
||||
expect(ids).toContain("light.living_lamp");
|
||||
});
|
||||
|
||||
it("matches on parent device name", () => {
|
||||
const { entities, index } = buildSample();
|
||||
const ids = searchEntities(entities, index, "Power strip").map((s) => s.id);
|
||||
expect(ids).toContain("sensor.outlet_1_power");
|
||||
});
|
||||
|
||||
it("respects the result limit", () => {
|
||||
const { entities, index } = buildSample();
|
||||
expect(searchEntities(entities, index, "light", 1)).toHaveLength(1);
|
||||
|
||||
@@ -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