mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-07 11:58:45 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aab0644c6c | ||
|
|
d518a4102c | ||
|
|
cb56cf7154 | ||
|
|
2ed55ce7a5 | ||
|
|
1f27c4f95e | ||
|
|
11292ab98c |
@@ -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"]),
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -127,11 +127,12 @@ export class HaLocationSelector extends LitElement {
|
||||
? this.hass.config.longitude
|
||||
: value.longitude,
|
||||
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
|
||||
radius_color: zoneRadiusColor,
|
||||
radius_color: selector.location?.color || zoneRadiusColor,
|
||||
icon:
|
||||
selector.location?.icon || selector.location?.radius
|
||||
selector.location?.icon ||
|
||||
(selector.location?.radius
|
||||
? "mdi:map-marker-radius"
|
||||
: "mdi:map-marker",
|
||||
: "mdi:map-marker"),
|
||||
location_editable: true,
|
||||
radius_editable:
|
||||
!!selector.location?.radius && !selector.location?.radius_readonly,
|
||||
|
||||
@@ -24,6 +24,8 @@ 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
|
||||
@@ -76,13 +78,20 @@ 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, 1px) solid
|
||||
var(--ha-marker-color, var(--primary-color));
|
||||
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;
|
||||
}
|
||||
:host([selected]) .marker {
|
||||
border-width: 3px;
|
||||
}
|
||||
.entity-picture {
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+291
-57
@@ -18,6 +18,7 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { supportsWebGL2 } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapClusterIcon,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
@@ -31,6 +32,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 +209,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 +288,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
|
||||
|
||||
@property({ attribute: "zoom-position" })
|
||||
public zoomPosition: MapControlPosition = "topleft";
|
||||
|
||||
private _zonePositions: Record<string, MapLatLng> = {};
|
||||
|
||||
@property({ attribute: "theme-mode", type: String })
|
||||
public themeMode: ThemeMode = "auto";
|
||||
|
||||
@@ -283,6 +325,8 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
private _unsubscribeColors?: () => void;
|
||||
|
||||
private _entityHandles: MapMarkerHandle[] = [];
|
||||
|
||||
private _zoneHandles: MapItemHandle[] = [];
|
||||
@@ -308,6 +352,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 +383,8 @@ export class HaMap extends ReactiveElement {
|
||||
"visibilitychange",
|
||||
this._handleVisibilityChange
|
||||
);
|
||||
this._unsubscribeColors?.();
|
||||
this._unsubscribeColors = undefined;
|
||||
this._engine?.destroy();
|
||||
this._engine = undefined;
|
||||
this._entityHandles = [];
|
||||
@@ -345,6 +406,10 @@ export class HaMap extends ReactiveElement {
|
||||
protected update(changedProps: PropertyValues) {
|
||||
super.update(changedProps);
|
||||
|
||||
if (changedProps.has("_connection")) {
|
||||
this._subscribeColors();
|
||||
}
|
||||
|
||||
if (!this._loaded) {
|
||||
return;
|
||||
}
|
||||
@@ -371,6 +436,10 @@ export class HaMap extends ReactiveElement {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
if (changedProps.has("zoomPosition")) {
|
||||
this._engine?.setZoomControlPosition(this.zoomPosition);
|
||||
}
|
||||
|
||||
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
|
||||
if (
|
||||
changedProps.has("_loaded") ||
|
||||
@@ -383,6 +452,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")) {
|
||||
@@ -493,7 +567,7 @@ export class HaMap extends ReactiveElement {
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
rasterOnly: this._forceLeaflet,
|
||||
zoomControlPosition: "topleft",
|
||||
zoomControlPosition: this.zoomPosition,
|
||||
events: {
|
||||
click: (location) => this._handleEngineClick(location),
|
||||
zoomStart: () => {
|
||||
@@ -673,6 +747,8 @@ export class HaMap extends ReactiveElement {
|
||||
if (this._deferIfUnsized(() => this.fitBounds(boundingbox, options))) {
|
||||
return;
|
||||
}
|
||||
// An explicit fit is user intent; the reset focus control resumes auto-fit
|
||||
this._pauseAutoFit = true;
|
||||
this._withProgrammaticFit(() => {
|
||||
this._engine!.fitBounds(boundingbox, {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
@@ -965,14 +1041,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)];
|
||||
@@ -1001,26 +1091,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) => {
|
||||
@@ -1029,8 +1117,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);
|
||||
@@ -1038,10 +1126,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,
|
||||
})
|
||||
@@ -1051,7 +1138,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);
|
||||
@@ -1095,19 +1182,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,
|
||||
})
|
||||
);
|
||||
@@ -1119,22 +1227,96 @@ 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 ?? "";
|
||||
if (showColors || member?.selected) {
|
||||
avatar.entityColor = member?.color;
|
||||
if (!member?.color) {
|
||||
avatar.style.setProperty("--ha-marker-color", "var(--primary-color)");
|
||||
}
|
||||
}
|
||||
if (showColors) {
|
||||
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 {
|
||||
@@ -1207,6 +1389,11 @@ export class HaMap extends ReactiveElement {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.maplibregl-ctrl-bottom-left,
|
||||
.maplibregl-ctrl-bottom-right {
|
||||
/* Lets a card keep the attribution and scale clear of an overlay */
|
||||
margin-bottom: var(--ha-map-bottom-inset, 0);
|
||||
}
|
||||
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
@@ -1254,16 +1441,59 @@ 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-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-bottom {
|
||||
/* Lets a card keep the attribution and scale clear of an overlay */
|
||||
margin-bottom: var(--ha-map-bottom-inset, 0);
|
||||
}
|
||||
.leaflet-control,
|
||||
.leaflet-top,
|
||||
@@ -1315,18 +1545,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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -93,14 +93,14 @@ export interface HistoryStreamMessage {
|
||||
}
|
||||
|
||||
export const entityIdHistoryNeedsAttributes = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "states">,
|
||||
entityId: string
|
||||
) =>
|
||||
!hass.states[entityId] ||
|
||||
NEED_ATTRIBUTE_DOMAINS.includes(computeDomain(entityId));
|
||||
|
||||
export const fetchDateWS = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "states" | "callWS">,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
entityIds: string[]
|
||||
|
||||
@@ -368,6 +368,8 @@ export interface LocationSelector {
|
||||
radius?: boolean;
|
||||
radius_readonly?: boolean;
|
||||
icon?: string;
|
||||
/** Marker and radius color; defaults to the theme's zone color */
|
||||
color?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,21 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
|
||||
import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true } },
|
||||
},
|
||||
];
|
||||
const SCHEMA = memoizeOne(
|
||||
(icon: string, color: string) =>
|
||||
[
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true, icon, color } },
|
||||
},
|
||||
] as const
|
||||
);
|
||||
|
||||
@customElement("dialog-home-zone-detail")
|
||||
class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams>()(
|
||||
@@ -81,7 +88,11 @@ class DialogHomeZoneDetail extends DirtyStateProviderMixin<HomeZoneMutableParams
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.schema=${SCHEMA}
|
||||
.schema=${SCHEMA(
|
||||
this.hass.states[HOME_ZONE_ENTITY_ID]?.attributes.icon ||
|
||||
"mdi:home",
|
||||
zoneColor(HOME_ZONE_ENTITY_ID, false, getComputedStyle(this))
|
||||
)}
|
||||
.data=${this._formData(this._data)}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mi
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { ZoneDetailDialogParams } from "./show-dialog-zone-detail";
|
||||
import { zoneColor } from "../../../common/map/entity-map-colors";
|
||||
|
||||
@customElement("dialog-zone-detail")
|
||||
class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
@@ -105,7 +106,16 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.schema=${this._schema(this._data.icon)}
|
||||
.schema=${this._schema(
|
||||
this._data.icon,
|
||||
this._params?.entityId
|
||||
? zoneColor(
|
||||
this._params.entityId,
|
||||
!!this._data.passive,
|
||||
getComputedStyle(this)
|
||||
)
|
||||
: undefined
|
||||
)}
|
||||
.data=${this._formData(this._data)}
|
||||
.error=${this._error}
|
||||
.computeLabel=${this._computeLabel}
|
||||
@@ -153,7 +163,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
}
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(icon?: string) =>
|
||||
(icon?: string, color?: string) =>
|
||||
[
|
||||
{
|
||||
name: "name",
|
||||
@@ -172,7 +182,7 @@ class DialogZoneDetail extends DirtyStateProviderMixin<ZoneMutableParams>()(
|
||||
{
|
||||
name: "location",
|
||||
required: true,
|
||||
selector: { location: { radius: true, icon } },
|
||||
selector: { location: { radius: true, icon, color } },
|
||||
},
|
||||
{ name: "passive_note", type: "constant" },
|
||||
{ name: "passive", selector: { boolean: {} } },
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
|
||||
@@ -23,6 +24,15 @@ import type {
|
||||
} from "../../../components/map/ha-locations-editor";
|
||||
import { saveCoreConfig } from "../../../data/core";
|
||||
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
HOME_ZONE_ENTITY_ID,
|
||||
subscribeEntityMapColors,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import {
|
||||
contrastingZoneContent,
|
||||
zoneInitials,
|
||||
} from "../../../common/map/zone-marker";
|
||||
import type {
|
||||
HomeZoneMutableParams,
|
||||
Zone,
|
||||
@@ -69,15 +79,165 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
|
||||
private _regEntities: string[] = [];
|
||||
|
||||
private _getZones = memoizeOne(
|
||||
(storageItems: Zone[], stateItems: HassEntity[]): MarkerLocation[] => {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const zoneRadiusColor = computedStyles.getPropertyValue("--accent-color");
|
||||
const passiveRadiusColor = computedStyles.getPropertyValue(
|
||||
"--secondary-text-color"
|
||||
// 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;
|
||||
|
||||
// Home first, then alphabetical, for UI and YAML zones alike
|
||||
private _sortedItems = memoizeOne(
|
||||
(
|
||||
storageItems: Zone[],
|
||||
stateItems: HassEntity[],
|
||||
language: string
|
||||
): ({ entry: Zone } | { stateObject: HassEntity })[] => {
|
||||
const items = [
|
||||
...storageItems.map((entry) => ({
|
||||
entry,
|
||||
name: entry.name,
|
||||
home: false,
|
||||
})),
|
||||
...stateItems.map((stateObject) => ({
|
||||
stateObject,
|
||||
name: stateObject.attributes.friendly_name || stateObject.entity_id,
|
||||
home: stateObject.entity_id === HOME_ZONE_ENTITY_ID,
|
||||
})),
|
||||
];
|
||||
return items.sort((a, b) =>
|
||||
a.home !== b.home
|
||||
? a.home
|
||||
? -1
|
||||
: 1
|
||||
: stringCompare(a.name, b.name, language)
|
||||
);
|
||||
const homeRadiusColor =
|
||||
computedStyles.getPropertyValue("--primary-color");
|
||||
}
|
||||
);
|
||||
|
||||
private _renderStorageItem(entry: Zone) {
|
||||
const hass = this.hass;
|
||||
return html`
|
||||
<ha-list-item
|
||||
.entry=${entry}
|
||||
.id=${this.narrow ? entry.id : ""}
|
||||
graphic="avatar"
|
||||
.hasMeta=${!this.narrow}
|
||||
@request-selected=${this._itemClicked}
|
||||
.value=${entry.id}
|
||||
?selected=${this._activeEntry === entry.id}
|
||||
>
|
||||
${this._renderZoneGraphic(
|
||||
this._zoneEntityIds[entry.id] ?? `zone.${entry.id}`,
|
||||
!!entry.passive,
|
||||
entry.icon,
|
||||
entry.name
|
||||
)}
|
||||
${entry.name}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
<div slot="meta">
|
||||
<ha-icon-button
|
||||
.id=${entry.id}
|
||||
.entry=${entry}
|
||||
@click=${this._openEditEntry}
|
||||
.path=${mdiPencil}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: entry.name,
|
||||
})}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderStateItem(stateObject: HassEntity) {
|
||||
const hass = this.hass;
|
||||
return html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.id=${this.narrow ? stateObject.entity_id : ""}
|
||||
.hasMeta=${!this.narrow || stateObject.entity_id !== "zone.home"}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
?selected=${this._activeEntry === stateObject.entity_id}
|
||||
.noEdit=${stateObject.entity_id !== "zone.home" || !this._canEditCore}
|
||||
>
|
||||
${this._renderZoneGraphic(
|
||||
stateObject.entity_id,
|
||||
!!stateObject.attributes.passive,
|
||||
stateObject.attributes.icon,
|
||||
stateObject.attributes.friendly_name || stateObject.entity_id
|
||||
)}
|
||||
${stateObject.attributes.friendly_name || stateObject.entity_id}
|
||||
${
|
||||
this.narrow &&
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
!this._canEditCore
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.id="zone-${slugify(stateObject.entity_id)}"
|
||||
.entityId=${stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" || !this._canEditCore
|
||||
}
|
||||
.path=${
|
||||
stateObject.entity_id === "zone.home" && this._canEditCore
|
||||
? mdiPencil
|
||||
: mdiPencilOff
|
||||
}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: hass.config.location_name,
|
||||
})}
|
||||
@click=${this._editHomeZone}
|
||||
slot="meta"
|
||||
></ha-icon-button>
|
||||
<ha-tooltip
|
||||
.for="zone-${slugify(stateObject.entity_id)}"
|
||||
placement="left"
|
||||
.disabled=${stateObject.entity_id === "zone.home"}
|
||||
hoist
|
||||
>
|
||||
${hass.localize("ui.panel.config.zone.configured_in_yaml")}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
// The zone as it looks on the map: its color, with its icon or initials
|
||||
private _renderZoneGraphic(
|
||||
entityId: string,
|
||||
passive: boolean,
|
||||
icon: string | undefined,
|
||||
name: string
|
||||
) {
|
||||
const color = zoneColor(entityId, passive, getComputedStyle(this));
|
||||
return html`
|
||||
<div
|
||||
slot="graphic"
|
||||
class="zone-avatar"
|
||||
style=${styleMap({
|
||||
background: color,
|
||||
color: contrastingZoneContent(color),
|
||||
})}
|
||||
>
|
||||
${icon ? html`<ha-icon .icon=${icon}></ha-icon>` : zoneInitials(name)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getZones = memoizeOne(
|
||||
(
|
||||
storageItems: Zone[],
|
||||
stateItems: HassEntity[],
|
||||
zoneEntityIds: Record<string, string>,
|
||||
_colorVersion: number
|
||||
): MarkerLocation[] => {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
|
||||
const stateLocations: MarkerLocation[] = stateItems.map(
|
||||
(entityState) => ({
|
||||
@@ -87,12 +247,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 +260,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 +274,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();
|
||||
}),
|
||||
];
|
||||
@@ -142,102 +316,14 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
`
|
||||
: html`
|
||||
<ha-list>
|
||||
${this._storageItems.map(
|
||||
(entry) => html`
|
||||
<ha-list-item
|
||||
.entry=${entry}
|
||||
.id=${this.narrow ? entry.id : ""}
|
||||
graphic="icon"
|
||||
.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}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
<div slot="meta">
|
||||
<ha-icon-button
|
||||
.id=${entry.id}
|
||||
.entry=${entry}
|
||||
@click=${this._openEditEntry}
|
||||
.path=${mdiPencil}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: entry.name,
|
||||
})}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
)}
|
||||
${this._stateItems.map(
|
||||
(stateObject) => html`
|
||||
<ha-list-item
|
||||
graphic="icon"
|
||||
.id=${this.narrow ? stateObject.entity_id : ""}
|
||||
.hasMeta=${
|
||||
!this.narrow || stateObject.entity_id !== "zone.home"
|
||||
}
|
||||
.value=${stateObject.entity_id}
|
||||
@request-selected=${this._stateItemClicked}
|
||||
?selected=${this._activeEntry === stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
}
|
||||
>
|
||||
<ha-icon
|
||||
.icon=${stateObject.attributes.icon}
|
||||
slot="graphic"
|
||||
>
|
||||
</ha-icon>
|
||||
|
||||
${
|
||||
stateObject.attributes.friendly_name ||
|
||||
stateObject.entity_id
|
||||
}
|
||||
${
|
||||
this.narrow &&
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
!this._canEditCore
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.id="zone-${slugify(stateObject.entity_id)}"
|
||||
.entityId=${stateObject.entity_id}
|
||||
.noEdit=${
|
||||
stateObject.entity_id !== "zone.home" ||
|
||||
!this._canEditCore
|
||||
}
|
||||
.path=${
|
||||
stateObject.entity_id === "zone.home" &&
|
||||
this._canEditCore
|
||||
? mdiPencil
|
||||
: mdiPencilOff
|
||||
}
|
||||
.label=${hass.localize("ui.common.edit_item", {
|
||||
name: hass.config.location_name,
|
||||
})}
|
||||
@click=${this._editHomeZone}
|
||||
slot="meta"
|
||||
></ha-icon-button>
|
||||
<ha-tooltip
|
||||
.for="zone-${slugify(stateObject.entity_id)}"
|
||||
placement="left"
|
||||
.disabled=${stateObject.entity_id === "zone.home"}
|
||||
hoist
|
||||
>
|
||||
${hass.localize(
|
||||
"ui.panel.config.zone.configured_in_yaml"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
${this._sortedItems(
|
||||
this._storageItems,
|
||||
this._stateItems,
|
||||
hass.locale.language
|
||||
).map((item) =>
|
||||
"entry" in item
|
||||
? this._renderStorageItem(item.entry)
|
||||
: this._renderStateItem(item.stateObject)
|
||||
)}
|
||||
</ha-list>
|
||||
`;
|
||||
@@ -269,7 +355,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}
|
||||
@@ -551,6 +639,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
private async _openDialog(entry?: Zone) {
|
||||
showZoneDetailDialog(this, {
|
||||
entry,
|
||||
entityId: entry ? this._zoneEntityIds[entry.id] : undefined,
|
||||
createEntry: (values) => this._createEntry(values),
|
||||
updateEntry: entry
|
||||
? (values) => this._updateEntry(entry, values, true)
|
||||
@@ -578,6 +667,19 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
|
||||
ha-icon-button:not([disabled]) {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.zone-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.zone-avatar ha-icon {
|
||||
color: inherit;
|
||||
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
ha-icon-button {
|
||||
--mdc-theme-text-disabled-on-light: var(--disabled-text-color);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Zone, ZoneMutableParams } from "../../../data/zone";
|
||||
|
||||
export interface ZoneDetailDialogParams {
|
||||
entry?: Zone;
|
||||
/** The zone's entity, when it exists, for its map color */
|
||||
entityId?: string;
|
||||
createEntry: (values: ZoneMutableParams) => Promise<unknown>;
|
||||
updateEntry?: (updates: Partial<ZoneMutableParams>) => Promise<unknown>;
|
||||
removeEntry?: () => Promise<boolean>;
|
||||
|
||||
@@ -3,14 +3,15 @@ import {
|
||||
mdiGoogleCirclesCommunities,
|
||||
mdiImageFilterCenterFocus,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getColorByIndex } from "../../../common/color/colors";
|
||||
import { resolveThemeColor } from "../../../common/color/compute-color";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
@@ -29,9 +30,16 @@ import type {
|
||||
MapCardMarkerLabelMode,
|
||||
} from "../../../components/map/ha-map";
|
||||
import type { MapLatLng } from "../../../common/map/map-engine";
|
||||
import {
|
||||
entityMapColor,
|
||||
subscribeEntityMapColors,
|
||||
zoneColor,
|
||||
} from "../../../common/map/entity-map-colors";
|
||||
import type { HistoryStates } from "../../../data/history";
|
||||
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { PANEL_VIEW_LAYOUT } from "../views/const";
|
||||
import { findEntities } from "../common/find-entities";
|
||||
import {
|
||||
hasConfigChanged,
|
||||
@@ -48,6 +56,12 @@ import {
|
||||
export const DEFAULT_HOURS_TO_SHOW = 0;
|
||||
export const DEFAULT_ZOOM = 14;
|
||||
|
||||
// GPS accuracy (meters) above which the selected person's circle is shown
|
||||
const IMPRECISE_GPS_ACCURACY = 100;
|
||||
|
||||
const FOCUS_PERSON_ZOOM = 19;
|
||||
const FOCUS_ZONE_MAX_ZOOM = 18;
|
||||
|
||||
interface GeoEntity {
|
||||
entity_id: string;
|
||||
label_mode?: MapCardMarkerLabelMode;
|
||||
@@ -62,6 +76,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@property({ attribute: false }) public layout?: string;
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
@state() private _stateHistory?: HistoryStates;
|
||||
|
||||
@state()
|
||||
@@ -76,14 +92,17 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
private _filteredMapEntities: HaMapEntity[] = [];
|
||||
|
||||
private _colorDict: Record<string, string> = {};
|
||||
|
||||
private _colorIndex = 0;
|
||||
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
@state() private _clusterMarkers = true;
|
||||
|
||||
@state() private _overviewSelected?: string;
|
||||
|
||||
// Height of the overview drawer when it sits over the bottom of the map
|
||||
@state() private _overviewHeight = 0;
|
||||
|
||||
private _overviewLoaded = false;
|
||||
|
||||
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
|
||||
|
||||
private _getAllEntities(): string[] {
|
||||
@@ -209,18 +228,35 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
return html`
|
||||
<ha-card id="card" .header=${this._config.title}>
|
||||
<div id="root">
|
||||
<div
|
||||
id="root"
|
||||
class=${this.layout === PANEL_VIEW_LAYOUT ? "panel-layout" : ""}
|
||||
@hass-more-info=${this._handleMapMoreInfo}
|
||||
>
|
||||
<ha-map
|
||||
style=${styleMap({
|
||||
"--overview-height": `${this._overviewHeight}px`,
|
||||
})}
|
||||
.entities=${this._filteredMapEntities}
|
||||
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
|
||||
.paths=${this._getHistoryPaths(this._config, this._stateHistory)}
|
||||
.autoFit=${this._config.auto_fit || false}
|
||||
.fitZones=${this._config.fit_zones || false}
|
||||
.zoomPosition=${
|
||||
this.layout === PANEL_VIEW_LAYOUT &&
|
||||
!computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "topright"
|
||||
: "topleft"
|
||||
}
|
||||
.themeMode=${themeMode}
|
||||
.clusterMarkers=${this._clusterMarkers}
|
||||
.scaleRuler=${this._config.scale_ruler || false}
|
||||
@map-clicked=${this._handleMapClicked}
|
||||
interactive-zones
|
||||
render-passive
|
||||
.renderPassive=${this.layout !== PANEL_VIEW_LAYOUT}
|
||||
></ha-map>
|
||||
<div id="buttons">
|
||||
${
|
||||
@@ -252,6 +288,17 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
tabindex="0"
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
${
|
||||
this.layout === PANEL_VIEW_LAYOUT
|
||||
? html`<hui-map-overview
|
||||
id="overview"
|
||||
.entities=${this._filteredMapEntities}
|
||||
.selected=${this._overviewSelected}
|
||||
@map-overview-select=${this._handleOverviewSelect}
|
||||
@map-overview-resize=${this._handleOverviewResize}
|
||||
></hui-map-overview>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
@@ -299,6 +346,9 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("hass")) {
|
||||
this._subscribeColors();
|
||||
}
|
||||
if (
|
||||
this._config?.show_all &&
|
||||
!this._config?.entities &&
|
||||
@@ -334,18 +384,75 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
} else {
|
||||
this._filteredMapEntities = this._mapEntities;
|
||||
}
|
||||
|
||||
if (this.layout === PANEL_VIEW_LAYOUT) {
|
||||
if (!this._overviewLoaded) {
|
||||
this._overviewLoaded = true;
|
||||
void import("./map/hui-map-overview");
|
||||
}
|
||||
this._filteredMapEntities = this._decorateOverviewEntities(
|
||||
this._filteredMapEntities,
|
||||
this._overviewSelected,
|
||||
this._overviewSelected
|
||||
? this.hass.states[this._overviewSelected]
|
||||
: undefined,
|
||||
this.preview
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// In panel layout, only the selected zone shows its radius (all of them
|
||||
// while editing) and only an imprecise selected person its accuracy circle
|
||||
private _decorateOverviewEntities = memoizeOne(
|
||||
(
|
||||
entities: HaMapEntity[],
|
||||
selectedId: string | undefined,
|
||||
selectedStateObj: HassEntity | undefined,
|
||||
preview: boolean
|
||||
): HaMapEntity[] => {
|
||||
const selectedLocation = selectedStateObj
|
||||
? getEntityLocation(selectedStateObj, this.hass.states)
|
||||
: undefined;
|
||||
const showSelectedAccuracy =
|
||||
(selectedLocation?.gpsAccuracy ?? 0) > IMPRECISE_GPS_ACCURACY;
|
||||
return entities.map((entity) => ({
|
||||
...entity,
|
||||
hide_accuracy: !(
|
||||
showSelectedAccuracy && entity.entity_id === selectedId
|
||||
),
|
||||
hide_radius: !preview && entity.entity_id !== selectedId,
|
||||
selected: entity.entity_id === selectedId,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _unsubscribeColors?: () => void;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated && this._configEntities?.length) {
|
||||
this._subscribeHistory();
|
||||
}
|
||||
this._subscribeColors();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeHistory();
|
||||
this._unsubscribeColors?.();
|
||||
this._unsubscribeColors = undefined;
|
||||
}
|
||||
|
||||
private _subscribeColors(): void {
|
||||
if (this._unsubscribeColors || !this.hass?.connection) {
|
||||
return;
|
||||
}
|
||||
this._unsubscribeColors = subscribeEntityMapColors(
|
||||
this.hass.connection,
|
||||
() => {
|
||||
this._mapEntities = this._getMapEntities();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _subscribeHistory() {
|
||||
@@ -428,22 +535,90 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
this._map?.fitMap({ unpause_autofit: true });
|
||||
}
|
||||
|
||||
private _handleMapClicked() {
|
||||
// A click on the map itself (not on a marker) deselects
|
||||
if (this._overviewSelected) {
|
||||
this._overviewSelected = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMapMoreInfo(ev: HASSDomEvent<{ entityId: string | null }>) {
|
||||
if ((ev.target as HTMLElement)?.localName === "hui-map-overview") {
|
||||
// The overview asks for the dialog itself, so let it through
|
||||
return;
|
||||
}
|
||||
const entityId = ev.detail.entityId;
|
||||
if (
|
||||
this.layout !== PANEL_VIEW_LAYOUT ||
|
||||
!entityId ||
|
||||
!["person", "device_tracker", "zone"].includes(computeDomain(entityId)) ||
|
||||
(computeDomain(entityId) !== "zone" &&
|
||||
!this._filteredMapEntities.some(
|
||||
(entity) => entity.entity_id === entityId
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
this._overviewSelected = entityId;
|
||||
this._focusEntity(entityId);
|
||||
}
|
||||
|
||||
private _handleOverviewResize(ev: HASSDomEvent<{ height: number }>) {
|
||||
this._overviewHeight = ev.detail.height;
|
||||
}
|
||||
|
||||
private _handleOverviewSelect(ev: HASSDomEvent<{ entityId?: string }>) {
|
||||
this._overviewSelected = ev.detail.entityId;
|
||||
if (ev.detail.entityId) {
|
||||
this._focusEntity(ev.detail.entityId);
|
||||
}
|
||||
}
|
||||
|
||||
private _focusEntity(entityId: string) {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) {
|
||||
return;
|
||||
}
|
||||
if (computeStateDomain(stateObj) === "zone") {
|
||||
const { latitude, longitude, radius } = stateObj.attributes;
|
||||
// Convert the zone radius (meters) to a degree offset for a bounding box
|
||||
const latOffset = (radius ?? 100) / 111320;
|
||||
const lngOffset =
|
||||
latOffset / Math.max(Math.cos((latitude * Math.PI) / 180), 0.01);
|
||||
this._map?.fitBounds(
|
||||
[
|
||||
[latitude - latOffset, longitude - lngOffset],
|
||||
[latitude + latOffset, longitude + lngOffset],
|
||||
],
|
||||
{ pad: 0.2, zoom: FOCUS_ZONE_MAX_ZOOM }
|
||||
);
|
||||
return;
|
||||
}
|
||||
const location = getEntityLocation(stateObj, this.hass.states);
|
||||
if (location) {
|
||||
this._map?.fitBounds([[location.latitude, location.longitude]], {
|
||||
zoom: FOCUS_PERSON_ZOOM,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleClusterMarkers() {
|
||||
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,
|
||||
computedStyles
|
||||
);
|
||||
}
|
||||
return color;
|
||||
return entityMapColor(entityId, computedStyles);
|
||||
}
|
||||
|
||||
private _getSourceEntities(states?: HassEntities): GeoEntity[] {
|
||||
@@ -595,10 +770,45 @@ class HuiMapCard extends LitElement implements LovelaceCard {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* The overview panel covers the start side in panel layout */
|
||||
#root.panel-layout #buttons {
|
||||
left: auto;
|
||||
inset-inline-start: auto;
|
||||
inset-inline-end: 3px;
|
||||
}
|
||||
|
||||
#root {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#overview {
|
||||
position: absolute;
|
||||
top: var(--ha-space-3);
|
||||
inset-inline-start: var(--ha-space-3);
|
||||
width: min(360px, calc(100% - 2 * var(--ha-space-3)));
|
||||
max-height: calc(100% - 2 * var(--ha-space-3));
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
#overview {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: auto;
|
||||
max-height: 70%;
|
||||
}
|
||||
|
||||
/* Keep the attribution and scale ruler above the drawer */
|
||||
#root.panel-layout ha-map {
|
||||
--ha-map-bottom-inset: calc(
|
||||
var(--overview-height, 0px) + var(--ha-space-2)
|
||||
);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9321,7 +9321,20 @@
|
||||
},
|
||||
"map": {
|
||||
"reset_focus": "Reset focus",
|
||||
"toggle_grouping": "Toggle grouping"
|
||||
"toggle_grouping": "Toggle grouping",
|
||||
"overview": {
|
||||
"people": "People",
|
||||
"devices": "Devices",
|
||||
"zones": "Zones",
|
||||
"activity": "Activity",
|
||||
"no_activity": "No recent activity",
|
||||
"person_arrived": "{name} arrived",
|
||||
"person_left": "{name} left",
|
||||
"show_list": "Show list",
|
||||
"show_more_info": "Show more info",
|
||||
"hide_list": "Hide list",
|
||||
"people_in_zone": "{count, plural, =0 {No one here} one {{count} person} other {{count} people}}"
|
||||
}
|
||||
},
|
||||
"energy": {
|
||||
"loading": "Loading…",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user