Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 5a927fa059 Address code review: keep the editor alert clear of the zoom control in RTL
The zoom control sits at the physical top-left in either text direction,
but the alert used logical insets, so in RTL its clearance moved to the
right and it covered the control.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld a4dd4344e9 Address code review: recolor the map card from the themes context
The card consumed the theme through hass, whose update gate only lets a
dark mode flip through, so switching between two light or two dark themes
kept the old palette. It now consumes the themes from the UI context, like
ha-map, and rebuilds its entity and trail colors whenever they change.
2026-09-10 17:18:21 +02:00
Maarten Lakerveld 0bd742e106 Address code review: follow theme changes and keep bubble focus
The map card rebuilds its entity colors and trail colors when the theme
changes, ha-map redraws editable circles on a theme change, and the
locations editor rebuilds its cached marker elements. A focused cluster
bubble keeps focus on the bubble that replaces it, and a caller naming a
marker by aria-labelledby keeps that name.
2026-09-10 16:46:07 +02:00
Maarten Lakerveld e57b337bb4 Address code review: recolor history trails when the registry changes
The trail colors are memoized with the registry entries as a key, like the
marker colors, so trails recolor with their markers when the registry
arrives or changes.
2026-09-10 16:12:17 +02:00
Maarten Lakerveld b55912844f Address code review: one palette sequence for zones, persons and trackers
Per-domain sequences all started at slot 0, so the first person and the
first zone shared a color and a person could match the zone it was in.
2026-09-10 15:49:32 +02:00
Maarten Lakerveld 47a789b64f Address code review: take entity colors from the registry context
The color module kept its own shared registry subscription with listener
counting and connection takeover. The lazy fullEntitiesContext already does
that for the whole app, so the module is now pure: it turns the registry
entries into a palette index, memoized per registry update. Maps ask for
the context only once they draw entities, so an editor's map, as in
onboarding, never requests the registry; the map card and the zone page
consume it directly.
2026-09-10 15:28:18 +02:00
Maarten Lakerveld 2ae8e2b777 Address code review: color map card entities from the shared palette
The card kept its own color dictionary, so an entity could have a different
color on every map. It now uses the palette that ha-map and the zone editor
already share, ordered by registry creation; a configured color still wins.
2026-09-10 15:00:09 +02:00
Maarten Lakerveld 6a135af0da Address code review: keep the label mode in cluster bubbles
Bubble avatars only carried the label text and picture, so an entity set to
the icon label mode showed initials once clustered and the attribute mode lost
its unit. The icon flag and unit now travel with the cluster data.
2026-09-10 14:48:18 +02:00
Maarten Lakerveld c263b14629 Remove label dedupe
The doubled place labels are a tile issue, not part of the marker redesign,
and the population filter did nothing on the OSMF tiles, which fill in a
default population for every place. It moves to its own branch.
2026-09-10 13:48:11 +02:00
Maarten Lakerveld 8de9063a09 Address code review: drop settled zone save queues
The per-zone save chain kept its last resolved promise for the life of the panel.
2026-09-10 11:16:39 +02:00
Maarten Lakerveld adc87d7f43 Address code review on the marker redesign, third round
The zone page's updated() override never called its base, so once
willUpdate stopped misusing super.updated the subscribe mixin had no hook
left to run hassSubscribe when hass arrives after connection; the override
calls super.updated now.

Saves for one zone run in order, each looking the entry up when it starts,
so a second response can replace what the first produced, and a failure
only drops the pending values its own request carried. A passive zone that
is not drawn no longer anchors a cluster bubble. A map whose entity list
empties releases the color subscription, not only one whose list is unset.
2026-09-10 10:34:40 +02:00
Maarten Lakerveld 0ebff36c70 Release the entity color subscription when a map stops drawing entities
A map whose entities were unset kept its registry subscription, holding
the shared stream open for nothing. It is released along with the
markers, and the three release sites share one helper.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 87c70bbabd Count entity color subscriptions from the listener set
The listener set deduplicated a reused callback while a separate counter
counted every call, so the two could disagree and the shared registry
stream could leak or be released early. Each subscription is now its own
listener, the count is the set's size, and unsubscribing twice is a no-op.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld dda34bda66 Redraw markers on a theme change, and test the entity color assignment
Marker and trail colors are resolved from theme variables when drawn, but
a theme change only swapped the base style, so the old palette stayed on
the markers until something else redrew them. Any change to the themes
now redraws entities and trails.

The color assignment gets unit tests: creation order per domain, ties
broken by registry id, the home zone kept out of the palette, muted
passive zones, a stable fallback for entities outside the registry, and
one shared registry stream released with the last subscriber.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld e99b1b38a1 Fall back to the palette color for entities without one, recolor zones on theme change
An entity object without a color reached the accuracy circle's paint
with an undefined color; the map's own palette color fills in. The zone
page's markers are memoized on their data, while their colors come from
theme variables, so a theme change now bumps the color version and
recolors them at once.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 78b0c8e59a Drop the numeric cluster badge styles, follow a replaced connection for colors
Clusters are avatar bubbles now, so the styles for the old count badge
had nothing left to style. The shared entity color subscription is a
module-level singleton keyed to whichever connection subscribed first; a
map arriving with a different connection now takes the stream over
instead of reading colors from the old one.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 74d87f3861 Tell the user when a dragged zone edit fails to save
A failed save dropped the pending edit and rethrew, which no caller
awaited, leaving an unhandled rejection and a marker that silently moved
back. The page now shows the error in a dialog instead.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 3cd2422cce Address code review on the marker redesign
The selected ring's color came from an inline outline-color that beat the
rule's outline shorthand by precedence, which reads as if the shorthand
won; the inline style now sets a variable the rule consumes, with the
primary color as fallback. A map whose connection context changes
resubscribes to the entity colors instead of staying bound to the old
connection. The zone page's willUpdate called the wrong lifecycle hook on
its base class.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 36b95468d6 Subscribe to entity colors only for maps that draw entities
Every map with a connection subscribed to the entity registry for the
creation-order colors, including editor maps that never draw an entity.
Onboarding's location picker is one, and its mocked server rejects the
registry request, failing the onboarding end-to-end tests. The
subscription now starts when a map first draws entities.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld b297f369dd Keep dragged values on the zone page until they are saved
The engine used to guess at the host's save state: after a drag it
skipped one update that matched the pre-drag values, taking it for a
re-render with data the save had not yet replaced. That guess is wrong
when a save fails, since the old values are then the truth and the
marker stayed at an unsaved position. The engine now applies every host
update except while a drag is in progress.

The zone page, which knows when it is saving, keeps the dragged values
as pending edits and shows them until the saved data carries them; a
failed save drops them and the marker returns.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld e3411c0be5 Drop the editor's drag classes
The engine sets the move cursor on the elements it actually drags, so the
zone marker and the named icon no longer carry a class for it, and the
editor's element cache no longer varies with editability.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 1fd57d19e3 Frame person and device markers, ring the selected one
Markers get a card-colored frame with a soft shadow instead of a thin
border in the entity color, and the selected marker gets a primary-color
ring outside that frame. With history trails shown the frame still takes
the entity color, so a marker can be matched to its trail, and the
markers redraw when trails appear or disappear.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 655d809603 Redesign map markers with zone circles and avatar cluster bubbles
Zones are drawn as colored circles containing the zone icon, using the
entity's color with the zone radius and GPS accuracy circles matching
it. Marker clusters render as a bubble of up to three rounded square
avatars with a +N chip; when all members are in the same zone and the
bubble would overlap that zone's circle, it floats above the circle,
pinned to the zone's coordinate, with a tail pointing at it. Individual
markers are rounded squares, and while history trails are shown bubble
avatars get borders in their entity color so trails can be matched.
Avatars in bubbles are clickable and open the entity, and the whole
bubble zooms in on its members.

ha-map gains per-entity hide_radius, hide_accuracy, and selected
options for hosts that manage a selection; ha-entity-marker exposes its
background and border width via CSS variables and a selected state with
a thicker border. The OSMF vector tiles carry some places twice (node
and boundary centroid); towns and larger now require a population,
keeping the node, and smaller places get a collision padding.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 99f5f0c428 Own the accessibility attributes the engine sets, and use spherical circle geometry
setMarkerAccessibility only filled in what was missing and nothing removed
it, so a cached element rebuilt with a new title or a different role kept
the old attributes. The helper now records what it set, replaces exactly
that on a rebuild, and clears it when the element leaves the map, while a
caller's own attributes stay untouched.

The flat meters-to-degrees conversion broke down for large circles near
the poles, wrapping longitude and producing latitudes beyond 90. Handle
placement and the polygon now use the great-circle destination, and the
fitted bounds a spherical box that spans all longitudes once the circle
reaches a pole, so all three agree with distanceMeters everywhere.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld ccd284b91b Make marker activation opt-in, and relabel handles on language change
Every editable marker was a focusable button whose activation fired an
event, whether or not the host did anything with it: the zone page and
the location selector do not. Activation is now opt-in per location; the
onboarding search results opt in, and a marker that does not act on it is
draggable without button semantics. The engine contract separates taking
pointer input from being a keyboard-focusable button for that.

Marker titles and handle labels are localized when drawn, so a language
change, or localization arriving after the first draw, redraws them.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld ad6df7ac81 Drop the zone page's active entry
It only re-selected the row the user had just clicked, after the shared
request-selected helper had reset it, which fired the selection event a
second time and zoomed to the zone twice. The row highlight comes from
the list itself and nothing else read the value, so the list no longer
mirrors a selection at all.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 7cec2a2d6f Share one circle geometry, and keep arrow keys on the handle
Drawing, fitting, and handle placement each converted meters to degrees
on their own, with different pole guards, so above 89.4 degrees the
resize handle left the drawn circle. They now share one conversion that
also agrees with distanceMeters; only the pole itself is guarded.

Arrow keys on the resize handle also reached MapLibre's keyboard handler
and panned the map while resizing; the handle keeps them.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 5f1254983a Show the fallback notice over the map, and keep handle clicks off the map
The notice followed a map that fills the editor's fixed height, so it
landed outside the box: clipped in onboarding, spilling into the form in
the location selector. It now sits over the top of the map, clear of the
zoom control.

The resize handle was the one marker whose click reached MapLibre's map
click, which a pin-on-click host takes as a request to move the location
there; the handle stops it like the other markers do.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 017ce0dd1e Announce the fallback notice, and let test frames redraw
The notice appears after the engine has been chosen, so helper text
would not reach a screen reader; it is an alert now. The engine test's
frame stub returned an id that landed in the engine's pending frame after
the callback had cleared it, so every redraw after the first was skipped.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 8e5d5e1311 Drop the named icon's move cursor, and describe the fallback as a map state
The Leaflet fallback draws named location icons statically, but the
stylesheet still gave the ones marked editable a move cursor. The engine
sets that cursor on the elements it actually drags, so the rule goes.

The fallback notice blamed the browser, while MapLibre can also be
unavailable after a failed start or a lost WebGL context; it now
describes what the map offers. The resize handle's east point only guards
the pole itself, so its distance agrees with distanceMeters wherever a
zone can be.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld b72f7529e1 Show the fallback notice only when a location is editable
The Leaflet fallback showed the notice for every editor, including ones
whose locations are all read-only, where nothing is missing.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld d960c67884 Let the engine own the drag cursor, and tidy the fallback notice
The move cursor came from stylesheets keyed on whether a location may be
edited, so it showed on circles that cannot move and on the static
Leaflet fallback. The MapLibre engine now sets it on the elements it
actually made draggable and clears it when handing them back.

The fallback notice said the browser cannot edit locations, while
clicking the map to place one still works there; it now names what is
missing, dragging markers and resizing zones. The editor's
internationalization context is reactive, like its neighbours.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld dd61d9934b Guard a keyboard resize against host updates like a drag
Arrow-key resizing changed the radius without arming the pre-edit guard
that pointer drags use, so a host re-render with the old radius while a
key was held, or its echo right after the commit, snapped the circle
back. A key resize now counts as a drag from the first key press until
the commit.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 21855a80c1 Hand marker elements back clean, and record sources before the style loads
MapLibre leaves its positioning class and transform on a marker element
after removal. Hosts like ha-locations-editor cache their elements and
hand them to whatever engine draws next: after a fatal WebGL failure that
is the Leaflet fallback, which shows the locations statically. A removed
element, and every element still on the map when the engine is destroyed,
is reset so it arrives as a plain element. The editable circle's center
gets the same treatment.

Custom sources and layers are now recorded when added and only inserted
into the map once the style has loaded. A circle moved while a swapped
style was still loading therefore reaches the record, and a swap in that
window carries it over; the deferred insert skips what the swap already
placed.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld 5f1a10f844 Leave zone row selection to the user
Clicking or dragging a zone on the map, and saving one in the dialog,
selected its row in code. That selection fires the same request-selected
event a click ends with, so the page had to tell the two apart, and the
row could not be shown selected and stay clickable at the same time: the
highlight never actually appeared. The list now only follows the user's
own clicks, which removes that bookkeeping along with the zoom that a
radius drag used to trigger.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld ed87bd316d Test the map editing path
The MapLibre engine's editable circle against the fake map: dragging the
center moves the circle and the handle and reports the drop, dragging the
handle resizes and snaps it back, arrow keys resize in steps committed on
release, the advertised maximum follows a larger radius, one update
echoing pre-drag values is ignored, the center activates by click, Enter
and Space but not after a drag, and removal clears markers, layers and
listeners.

ha-map's reconciliation of editable locations through a fake engine with
editing support: circles and markers are drawn, moved in place, rebuilt
when their appearance changes and removed when dropped, move, resize and
activation events carry the location id, and an engine without editing
support shows the locations statically.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld e270ba79a2 Address code review on map editing
Editable markers, the circle center on both engines, and the static
fallback center activate on Enter and Space, as their button role
promises. Locations without a name get a localized one, since onboarding
and the location selector pass none. The resize handle's advertised
maximum follows the radius instead of being exceeded by it, and the
handle stays near the circle at high latitudes.

Clicking the active zone row zoomed to it again upstream but was
swallowed by the check that told a map-driven selection from a click; a
one-shot flag on selections made in code replaces that inference. The
engine rebuilds its sources from its own record on a style swap, so a
radius changed while the style was unloaded is not lost.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld f651afe1fe Keep the retained circle data current while resizing
The engine keeps its source specifications to rebuild them after a style
swap; the editable circle's redraw updates that copy along with the map.
2026-09-10 09:55:41 +02:00
Maarten Lakerveld f4398b688d Edit zones and locations on the map engine instead of leaflet-draw
ha-locations-editor (zone config, the location selector, onboarding)
used raw Leaflet layers and leaflet-draw, which pinned it to the Leaflet
engine. Its interaction surface is three gestures - drag a marker, drag
a zone center, drag a zone radius handle - so no editing library is
needed.

MapEngine gains an optional editing capability (draggable markers and an
addEditableCircle primitive) plus panTo and containsLocation. Only the
MapLibre engine implements editing: the circle geometry plus a draggable
center marker and a draggable radius handle on the east edge, the radius
derived from the great-circle distance between them, redrawn per frame
while dragging. The handle has a touch-sized hit target and is a
keyboard-operable slider with an accessible name. The Leaflet engine
stays a viewing fallback: without WebGL2 an editor shows its locations
as static markers and a notice that this browser cannot edit on the map.

ha-map exposes editableLocations and fires
editable-location-moved/resized/clicked and editing-available-changed.
Locations are reconciled by id: position and radius changes move the
existing handles, while a changed icon, name, color, or editability
rebuilds the marker, so edits made in the form fields show up on the
map on either engine. ha-locations-editor becomes a thin layer over
that with its public contract unchanged, so its consumers need no
changes; it reads localize from the internationalization context instead
of taking hass.

The zone panel no longer zooms the map when a selection comes from the
map itself. Clicking or dragging a marker selects its list item, and
mwc-list reports that programmatic selection with the same "property"
request-selected event a click ends with. The two are told apart by
timing: a selection from the map has already set the active entry when
its event arrives, while a click's event arrives before the handler
sets it. Skipping the programmatic event also keeps the item selected,
which the shared request-selected helper would otherwise reset.

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