diff --git a/build-scripts/gulp/gather-static.js b/build-scripts/gulp/gather-static.js index 5b9a668675..ab3984552e 100644 --- a/build-scripts/gulp/gather-static.js +++ b/build-scripts/gulp/gather-static.js @@ -90,6 +90,14 @@ function copyMapPanel(staticDir) { npmPath("leaflet/dist/leaflet.css"), staticPath("images/leaflet/") ); + copyFileDir( + npmPath("leaflet.markercluster/dist/MarkerCluster.css"), + staticPath("images/leaflet/") + ); + copyFileDir( + npmPath("leaflet.markercluster/dist/MarkerCluster.Default.css"), + staticPath("images/leaflet/") + ); fs.copySync( npmPath("leaflet/dist/images"), staticPath("images/leaflet/images/") diff --git a/package.json b/package.json index ab80dc1aa4..b6fa4fdd4b 100644 --- a/package.json +++ b/package.json @@ -120,6 +120,7 @@ "js-yaml": "4.1.0", "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": "2.8.0", "lit-html": "2.8.0", "luxon": "3.5.0", @@ -176,6 +177,7 @@ "@types/js-yaml": "4.0.9", "@types/leaflet": "1.9.16", "@types/leaflet-draw": "1.0.11", + "@types/leaflet.markercluster": "1.5.5", "@types/lodash.merge": "4.6.9", "@types/luxon": "3.4.2", "@types/mocha": "10.0.10", diff --git a/src/common/dom/setup-leaflet-map.ts b/src/common/dom/setup-leaflet-map.ts index 21de1f050b..d94ebfa846 100644 --- a/src/common/dom/setup-leaflet-map.ts +++ b/src/common/dom/setup-leaflet-map.ts @@ -16,11 +16,30 @@ export const setupLeafletMap = async ( const Leaflet = (await import("leaflet")).default as LeafletModuleType; Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/"; + await import("leaflet.markercluster"); + const map = Leaflet.map(mapElement); const style = document.createElement("link"); style.setAttribute("href", "/static/images/leaflet/leaflet.css"); style.setAttribute("rel", "stylesheet"); mapElement.parentNode.appendChild(style); + + const markerClusterStyle = document.createElement("link"); + markerClusterStyle.setAttribute( + "href", + "/static/images/leaflet/MarkerCluster.css" + ); + markerClusterStyle.setAttribute("rel", "stylesheet"); + mapElement.parentNode.appendChild(markerClusterStyle); + + const defaultMarkerClusterStyle = document.createElement("link"); + defaultMarkerClusterStyle.setAttribute( + "href", + "/static/images/leaflet/MarkerCluster.Default.css" + ); + defaultMarkerClusterStyle.setAttribute("rel", "stylesheet"); + mapElement.parentNode.appendChild(defaultMarkerClusterStyle); + map.setView([52.3731339, 4.8903147], 13); const tileLayer = createTileLayer(Leaflet).addTo(map); diff --git a/src/common/map/decorated_marker.ts b/src/common/map/decorated_marker.ts new file mode 100644 index 0000000000..69d4cb4cce --- /dev/null +++ b/src/common/map/decorated_marker.ts @@ -0,0 +1,32 @@ +import type { LatLngExpression, Layer, Map, MarkerOptions } from "leaflet"; +import { Marker } from "leaflet"; + +export class DecoratedMarker extends Marker { + decorationLayer: Layer | undefined; + + constructor( + latlng: LatLngExpression, + decorationLayer?: Layer, + options?: MarkerOptions + ) { + super(latlng, options); + + this.decorationLayer = decorationLayer; + } + + onAdd(map: Map) { + super.onAdd(map); + + // If decoration has been provided, add it to the map as well + this.decorationLayer?.addTo(map); + + return this; + } + + onRemove(map: Map) { + // If decoration has been provided, remove it from the map as well + this.decorationLayer?.remove(); + + return super.onRemove(map); + } +} diff --git a/src/components/map/ha-map.ts b/src/components/map/ha-map.ts index 13020419b5..b6c6ea9888 100644 --- a/src/components/map/ha-map.ts +++ b/src/components/map/ha-map.ts @@ -8,9 +8,10 @@ import type { Map, Marker, Polyline, + MarkerClusterGroup, } from "leaflet"; import type { PropertyValues } from "lit"; -import { ReactiveElement, css } from "lit"; +import { css, ReactiveElement } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; import { formatDateTime } from "../../common/datetime/format_date_time"; @@ -26,6 +27,7 @@ import type { HomeAssistant, ThemeMode } from "../../types"; import { isTouch } from "../../util/is_touch"; import "../ha-icon-button"; import "./ha-entity-marker"; +import { DecoratedMarker } from "../../common/map/decorated_marker"; declare global { // for fire event @@ -84,6 +86,9 @@ export class HaMap extends ReactiveElement { @property({ type: Number }) public zoom = 14; + @property({ attribute: "cluster-markers", type: Boolean }) + public clusterMarkers = true; + @state() private _loaded = false; public leafletMap?: Map; @@ -96,10 +101,12 @@ export class HaMap extends ReactiveElement { private _mapFocusItems: (Marker | Circle)[] = []; - private _mapZones: (Marker | Circle)[] = []; + private _mapZones: DecoratedMarker[] = []; private _mapFocusZones: (Marker | Circle)[] = []; + private _mapCluster: MarkerClusterGroup | undefined; + private _mapPaths: (Polyline | CircleMarker)[] = []; private _clickCount = 0; @@ -151,6 +158,10 @@ export class HaMap extends ReactiveElement { } } + if (changedProps.has("clusterMarkers")) { + this._drawEntities(); + } + if (changedProps.has("_loaded") || changedProps.has("paths")) { this._drawPaths(); } @@ -175,6 +186,7 @@ export class HaMap extends ReactiveElement { ) { return; } + this._updateMapStyle(); } @@ -426,6 +438,11 @@ export class HaMap extends ReactiveElement { this._mapFocusZones = []; } + if (this._mapCluster) { + this._mapCluster.remove(); + this._mapCluster = undefined; + } + if (!this.entities) { return; } @@ -481,26 +498,24 @@ export class HaMap extends ReactiveElement { iconHTML = el.outerHTML; } - // create marker with the icon - this._mapZones.push( - Leaflet.marker([latitude, longitude], { - icon: Leaflet.divIcon({ - html: iconHTML, - iconSize: [24, 24], - className, - }), - interactive: this.interactiveZones, - title, - }) - ); - // create circle around it const circle = Leaflet.circle([latitude, longitude], { interactive: false, color: passive ? passiveZoneColor : zoneColor, radius, }); - this._mapZones.push(circle); + + const marker = new DecoratedMarker([latitude, longitude], circle, { + icon: Leaflet.divIcon({ + html: iconHTML, + iconSize: [24, 24], + className, + }), + interactive: this.interactiveZones, + title, + }); + + this._mapZones.push(marker); if ( this.fitZones && (typeof entity === "string" || entity.focus !== false) @@ -538,7 +553,7 @@ export class HaMap extends ReactiveElement { } // create marker with the icon - const marker = Leaflet.marker([latitude, longitude], { + const marker = new DecoratedMarker([latitude, longitude], undefined, { icon: Leaflet.divIcon({ html: entityMarker, iconSize: [48, 48], @@ -546,24 +561,33 @@ export class HaMap extends ReactiveElement { }), title: title, }); - this._mapItems.push(marker); if (typeof entity === "string" || entity.focus !== false) { this._mapFocusItems.push(marker); } // create circle around if entity has accuracy if (gpsAccuracy) { - this._mapItems.push( - Leaflet.circle([latitude, longitude], { - interactive: false, - color: darkPrimaryColor, - radius: gpsAccuracy, - }) - ); + marker.decorationLayer = Leaflet.circle([latitude, longitude], { + interactive: false, + color: darkPrimaryColor, + radius: gpsAccuracy, + }); } + + this._mapItems.push(marker); + } + + if (this.clusterMarkers) { + this._mapCluster = Leaflet.markerClusterGroup({ + showCoverageOnHover: false, + removeOutsideVisibleBounds: false, + }); + this._mapCluster.addLayers(this._mapItems); + map.addLayer(this._mapCluster); + } else { + this._mapItems.forEach((marker) => map.addLayer(marker)); } - this._mapItems.forEach((marker) => map.addLayer(marker)); this._mapZones.forEach((marker) => map.addLayer(marker)); } diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index ee74d074f9..cd2c94a83e 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -1,4 +1,8 @@ -import { mdiImageFilterCenterFocus } from "@mdi/js"; +import { + mdiDotsHexagon, + mdiGoogleCirclesCommunities, + mdiImageFilterCenterFocus, +} from "@mdi/js"; import type { HassEntities } from "home-assistant-js-websocket"; import type { LatLngTuple } from "leaflet"; import type { PropertyValues } from "lit"; @@ -72,6 +76,8 @@ class HuiMapCard extends LitElement implements LovelaceCard { @state() private _error?: { code: string; message: string }; + @state() private _clusterMarkers = true; + private _subscribed?: Promise<(() => Promise) | undefined>; public setConfig(config: MapCardConfig): void { @@ -170,18 +176,32 @@ class HuiMapCard extends LitElement implements LovelaceCard { .autoFit=${this._config.auto_fit || false} .fitZones=${this._config.fit_zones} .themeMode=${themeMode} + .clusterMarkers=${this._clusterMarkers} interactive-zones render-passive > - +
+ + +
`; @@ -320,6 +340,10 @@ class HuiMapCard extends LitElement implements LovelaceCard { this._map?.fitMap(); } + private _toggleClusterMarkers() { + this._clusterMarkers = !this._clusterMarkers; + } + private _getColor(entityId: string): string { let color = this._colorDict[entityId]; if (color) { @@ -464,11 +488,12 @@ class HuiMapCard extends LitElement implements LovelaceCard { overflow: hidden; } - ha-icon-button { + #buttons { position: absolute; top: 75px; left: 3px; - outline: none; + display: flex; + flex-direction: column; } #root { diff --git a/src/translations/en.json b/src/translations/en.json index 0afa5e133b..583f2aafb0 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6310,7 +6310,8 @@ "description": "Home Assistant is starting, please wait…" }, "map": { - "reset_focus": "Reset focus" + "reset_focus": "Reset focus", + "toggle_grouping": "Toggle grouping" }, "energy": { "loading": "Loading…", diff --git a/yarn.lock b/yarn.lock index d0e9bb11e9..fb64df3d49 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4737,6 +4737,15 @@ __metadata: languageName: node linkType: hard +"@types/leaflet.markercluster@npm:1.5.5": + version: 1.5.5 + resolution: "@types/leaflet.markercluster@npm:1.5.5" + dependencies: + "@types/leaflet": "npm:*" + checksum: 10/17647d187ed8c9c38124005c3c45c0c7998c6359d8783e2ea162f9649b151862750c813eba2373054e90156a11a37af2b220429f937b302889b9d6e2105bf2ca + languageName: node + linkType: hard + "@types/leaflet@npm:*, @types/leaflet@npm:1.9.16": version: 1.9.16 resolution: "@types/leaflet@npm:1.9.16" @@ -9380,6 +9389,7 @@ __metadata: "@types/js-yaml": "npm:4.0.9" "@types/leaflet": "npm:1.9.16" "@types/leaflet-draw": "npm:1.0.11" + "@types/leaflet.markercluster": "npm:1.5.5" "@types/lodash.merge": "npm:4.6.9" "@types/luxon": "npm:3.4.2" "@types/mocha": "npm:10.0.10" @@ -9444,6 +9454,7 @@ __metadata: 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" lint-staged: "npm:15.4.3" lit: "npm:2.8.0" lit-analyzer: "npm:2.0.3" @@ -10748,6 +10759,15 @@ __metadata: languageName: node linkType: hard +"leaflet.markercluster@npm:1.5.3": + version: 1.5.3 + resolution: "leaflet.markercluster@npm:1.5.3" + peerDependencies: + leaflet: ^1.3.1 + checksum: 10/28dc441de7012b19628144407bde89576f758dbea31ecb86e3412d1fb3a46723fb47d2ba45d9b858c2e65592479a127474fb1cbf7ff33b3023c4d14f851e5fe4 + languageName: node + linkType: hard + "leaflet@npm:1.9.4": version: 1.9.4 resolution: "leaflet@npm:1.9.4"