Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 27675126d0 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-07 16:17:19 +02:00
Maarten Lakerveld 948c07203a 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-07 16:17:19 +02:00
8 changed files with 617 additions and 96 deletions
+58 -18
View File
@@ -19,24 +19,64 @@ const withEnglish = (placement) =>
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
// The OSMF Shortbread tiles carry some places twice: once as the place node
// and once as the centroid of its boundary area, tens of pixels apart. The
// style renders every feature of a kind, so those show as doubled labels.
// VersaTiles' own tiles dedupe at generation and are unaffected.
//
// A style expression only ever sees one feature, so the copies cannot be
// compared to each other; the population tag is the proxy. It lives on the
// place node, and towns and larger are tagged with one nearly without
// exception, so requiring it keeps the node and drops the centroid copy.
// Smaller places often lack the tag, so filtering them would erase real
// labels; they get a collision padding instead, which only ever hides a label
// that overlaps another one.
const POPULATED_PLACE_KINDS = ["town", "city", "state_capital", "capital"];
const SMALL_PLACE_PADDING = 24;
const isPlaceLabel = (layer) => layer["source-layer"] === "place_labels";
// The style filters places as ["==", ["get", "kind"], "<kind>"]
const placeKind = (layer) =>
Array.isArray(layer.filter) && layer.filter[0] === "=="
? layer.filter[2]
: undefined;
const dedupePlaceLabel = (layer) => {
if (!isPlaceLabel(layer)) {
return layer;
}
if (POPULATED_PLACE_KINDS.includes(placeKind(layer))) {
return {
...layer,
filter: ["all", layer.filter, ["has", "population"]],
};
}
return {
...layer,
layout: { ...layer.layout, "text-padding": SMALL_PLACE_PADDING },
};
};
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) =>
isNameLabel(layer)
? {
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
}
: layer
),
layers: style.layers.map((layer) => {
if (!isNameLabel(layer)) {
return layer;
}
return dedupePlaceLabel({
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
});
}),
});
+98
View File
@@ -0,0 +1,98 @@
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
import { getColorByIndex } from "../color/colors";
import { computeDomain } from "../entity/compute_domain";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
/**
* Map colors for entities, by registry creation order per domain, so an
* entity has the same color on every map. Entities without a registry 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"];
let creationIndex: Record<string, number> = {};
let subscribers = 0;
let unsubscribe: UnsubscribeFunc | undefined;
const listeners = new Set<() => void>();
const rebuildIndex = (entries: EntityRegistryEntry[]) => {
const byDomain: Record<string, EntityRegistryEntry[]> = {};
for (const entry of entries) {
const domain = computeDomain(entry.entity_id);
if (ORDERED_DOMAINS.includes(domain)) {
(byDomain[domain] ??= []).push(entry);
}
}
const index: Record<string, number> = {};
for (const domainEntries of Object.values(byDomain)) {
domainEntries
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
// The home zone has a fixed color and does not take a palette slot
.filter((entry) => entry.entity_id !== HOME_ZONE_ENTITY_ID)
.forEach((entry, i) => {
index[entry.entity_id] = i;
});
}
creationIndex = index;
listeners.forEach((listener) => listener());
};
/** Keeps the creation order current while a map is alive; onChange runs when colors may have changed */
export const subscribeEntityMapColors = (
connection: Connection,
onChange: () => void
): UnsubscribeFunc => {
listeners.add(onChange);
subscribers++;
if (!unsubscribe) {
unsubscribe = subscribeEntityRegistry(connection, rebuildIndex);
}
return () => {
listeners.delete(onChange);
subscribers--;
if (subscribers === 0 && unsubscribe) {
unsubscribe();
unsubscribe = undefined;
creationIndex = {};
}
};
};
// 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,
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex[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,
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, computedStyles);
};
+70
View File
@@ -0,0 +1,70 @@
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;
}
.zone-circle.draggable {
cursor: move;
}
`;
+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({ "outline-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(--primary-color);
}
.entity-picture {
background-size: cover;
height: 100%;
+27 -2
View File
@@ -15,6 +15,10 @@ import "./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
@@ -144,7 +148,9 @@ export class HaLocationsEditor extends LitElement {
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: [ICON_SIZE, ICON_SIZE],
elementSize: location.radius
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
@@ -157,21 +163,40 @@ export class HaLocationsEditor extends LitElement {
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const isZone = !!location.radius;
const key = JSON.stringify([
isZone,
location.icon,
location.iconPath,
location.name,
location.radius_color,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
const element = this._createIcon(location);
// 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 {
const element = createZoneMarkerElement({
color:
location.radius_color ||
getComputedStyle(this).getPropertyValue("--accent-color"),
icon: location.icon,
iconPath: location.iconPath,
name: location.name ?? "",
});
element.classList.toggle("draggable", !!location.location_editable);
return element;
}
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
+272 -56
View File
@@ -31,6 +31,16 @@ import type {
} from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { editableCircleStyles } from "../../common/map/editable-circle";
import {
entityMapColor,
subscribeEntityMapColors,
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,
@@ -198,9 +208,35 @@ 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;
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 {
@@ -251,6 +287,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";
@@ -283,6 +321,8 @@ export class HaMap extends ReactiveElement {
private _resizeObserver?: ResizeObserver;
private _unsubscribeColors?: () => void;
private _entityHandles: MapMarkerHandle[] = [];
private _zoneHandles: MapItemHandle[] = [];
@@ -308,6 +348,21 @@ export class HaMap extends ReactiveElement {
super.connectedCallback();
this._loadMap();
this._attachObserver();
this._subscribeColors();
}
private _subscribeColors(): void {
if (this._unsubscribeColors || !this._connection?.connection) {
return;
}
this._unsubscribeColors = subscribeEntityMapColors(
this._connection.connection,
() => {
if (this._loaded) {
this._drawEntities();
}
}
);
}
private _handleVisibilityChange = async () => {
@@ -324,6 +379,8 @@ export class HaMap extends ReactiveElement {
"visibilitychange",
this._handleVisibilityChange
);
this._unsubscribeColors?.();
this._unsubscribeColors = undefined;
this._engine?.destroy();
this._engine = undefined;
// An engine still setting up goes too; its setup notices and stops
@@ -350,6 +407,10 @@ export class HaMap extends ReactiveElement {
protected update(changedProps: PropertyValues) {
super.update(changedProps);
if (changedProps.has("_connection")) {
this._subscribeColors();
}
if (!this._loaded) {
return;
}
@@ -388,6 +449,11 @@ 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("editableLocations")) {
@@ -998,14 +1064,28 @@ export class HaMap extends ReactiveElement {
}
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)];
if (stateObj && computeStateDomain(stateObj) === "zone") {
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)];
@@ -1034,26 +1114,24 @@ 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, 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) => {
@@ -1062,8 +1140,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);
@@ -1071,10 +1149,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,
})
@@ -1084,7 +1161,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);
@@ -1128,19 +1205,40 @@ export class HaMap extends ReactiveElement {
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this._connection.hassUrl(entityPicture)
: "";
const entityColor =
typeof entity !== "string"
? entity.color
: entityMapColor(getEntityId(entity), 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,
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,
})
);
@@ -1152,22 +1250,95 @@ 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.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 {
@@ -1313,17 +1484,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 {
@@ -1374,18 +1586,22 @@ export class HaMap extends ReactiveElement {
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
}
.marker-cluster {
box-sizing: border-box;
.marker-cluster div {
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
width: calc(var(--ha-marker-size, 48px) * 0.667);
height: calc(var(--ha-marker-size, 48px) * 0.667);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
align-content: center;
color: var(--text-primary-color);
font-size: var(--ha-font-size-m);
}
.marker-cluster span {
line-height: var(--ha-line-height-expanded);
}
`;
}
+40 -15
View File
@@ -23,6 +23,10 @@ import type {
} from "../../../components/map/ha-locations-editor";
import { saveCoreConfig } from "../../../data/core";
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
import {
subscribeEntityMapColors,
zoneColor,
} from "../../../common/map/entity-map-colors";
import type {
HomeZoneMutableParams,
Zone,
@@ -69,15 +73,20 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
private _regEntities: string[] = [];
// 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>,
_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,12 +96,11 @@ 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,
radius_color: zoneColor(
entityState.entity_id,
!!entityState.attributes.passive,
computedStyles
),
location_editable:
entityState.entity_id === "zone.home" && this._canEditCore,
radius_editable:
@@ -101,7 +109,11 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
);
const storageLocations: MarkerLocation[] = storageItems.map((zone) => ({
...zone,
radius_color: zone.passive ? passiveRadiusColor : zoneRadiusColor,
radius_color: zoneColor(
zoneEntityIds[zone.id] ?? `zone.${zone.id}`,
!!zone.passive,
computedStyles
),
location_editable: true,
radius_editable: true,
}));
@@ -111,10 +123,21 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityMapColors(this.hass.connection!, () => {
this._colorVersion++;
}),
subscribeEntityRegistry(this.hass.connection!, (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();
}),
];
@@ -269,7 +292,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<ha-locations-editor
.locations=${this._getZones(
this._storageItems,
this._stateItems
this._stateItems,
this._zoneEntityIds,
this._colorVersion
)}
@location-updated=${this._locationUpdated}
@radius-updated=${this._radiusUpdated}
+38 -2
View File
@@ -12,7 +12,16 @@ const layer = (id, layout) => ({ id, type: "symbol", layout });
const STYLE = {
layers: [
layer("label-place-city", { "text-field": ["get", "name"] }),
{
...layer("label-place-city", { "text-field": ["get", "name"] }),
"source-layer": "place_labels",
filter: ["==", ["get", "kind"], "city"],
},
{
...layer("label-place-suburb", { "text-field": ["get", "name"] }),
"source-layer": "place_labels",
filter: ["==", ["get", "kind"], "suburb"],
},
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
@@ -101,6 +110,33 @@ describe("addLatinLabels", () => {
it("does not touch other layers", () => {
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
expect(style.layers[3]).toEqual(STYLE.layers[3]);
expect(style.layers.at(-1)).toEqual(STYLE.layers.at(-1));
});
// The OSMF tiles carry some places twice (node and area centroid). Without
// these, doubled town labels come back and nothing else fails.
describe("deduplicates places", () => {
const byId = (id) => style.layers.find((l) => l.id === id);
it("requires a population on towns and larger, keeping the kind filter", () => {
expect(byId("label-place-city").filter).toEqual([
"all",
["==", ["get", "kind"], "city"],
["has", "population"],
]);
expect(byId("label-place-city").layout["text-padding"]).toBeUndefined();
});
it("pads smaller places instead, which often lack a population", () => {
const suburb = byId("label-place-suburb");
expect(suburb.filter).toEqual(["==", ["get", "kind"], "suburb"]);
expect(suburb.layout["text-padding"]).toBeGreaterThan(0);
});
it("leaves non-place labels alone", () => {
const streetLayer = byId("label-street-primary");
expect(streetLayer.filter).toBeUndefined();
expect(streetLayer.layout["text-padding"]).toBeUndefined();
});
});
});