Compare commits

..
Author SHA1 Message Date
Maarten Lakerveld 5a927fa059 Address code review: keep the editor alert clear of the zoom control in RTL
The zoom control sits at the physical top-left in either text direction,
but the alert used logical insets, so in RTL its clearance moved to the
right and it covered the control.
2026-09-10 17:19:20 +02:00
Maarten Lakerveld a4dd4344e9 Address code review: recolor the map card from the themes context
The card consumed the theme through hass, whose update gate only lets a
dark mode flip through, so switching between two light or two dark themes
kept the old palette. It now consumes the themes from the UI context, like
ha-map, and rebuilds its entity and trail colors whenever they change.
2026-09-10 17:18:21 +02:00
10 changed files with 40 additions and 1382 deletions
@@ -186,11 +186,6 @@ export class LeafletMapEngine implements MapEngine {
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
paddingBottomRight: [
options?.padding?.right ?? 0,
options?.padding?.bottom ?? 0,
],
});
}
+7 -20
View File
@@ -516,26 +516,13 @@ export class MapLibreMapEngine implements MapEngine {
options?.maxZoom !== undefined
? options.maxZoom - ZOOM_OFFSET
: undefined;
// Passed per fit: easeTo's padding would stick to the map
const padding = {
top: options?.padding?.top ?? 0,
right: options?.padding?.right ?? 0,
bottom: options?.padding?.bottom ?? 0,
left: options?.padding?.left ?? 0,
};
if (minLat === maxLat && minLng === maxLng) {
// Zero-area bounds: center on the point, keeping the zoom unless given
this._map.fitBounds(
[
[minLng, minLat],
[minLng, minLat],
],
{
maxZoom: maxZoom ?? this._map.getZoom(),
animate: options?.animate,
padding,
}
);
// Zero-area bounds: center on the point
this._map.easeTo({
center: [minLng, minLat],
zoom: maxZoom ?? this._map.getZoom(),
animate: options?.animate,
});
return;
}
const pad = options?.pad ?? 0.5;
@@ -546,7 +533,7 @@ export class MapLibreMapEngine implements MapEngine {
[minLng - lngPad, minLat - latPad],
[maxLng + lngPad, maxLat + latPad],
],
{ maxZoom, animate: options?.animate, padding }
{ maxZoom, animate: options?.animate }
);
}
-9
View File
@@ -42,15 +42,6 @@ export interface MapFitOptions {
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
padding?: MapFitPadding;
}
export interface MapFitPadding {
top?: number;
right?: number;
bottom?: number;
left?: number;
}
export interface MapMarkerOptions {
+4 -3
View File
@@ -337,12 +337,13 @@ export class HaLocationsEditor extends LitElement {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
/* Over the map, clear of the zoom control, so a fixed-height host shows it.
The control sits at the physical top-left in either direction. */
ha-alert {
position: absolute;
top: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
left: 56px;
right: var(--ha-space-2);
z-index: 1;
}
`;
+2 -24
View File
@@ -18,9 +18,7 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
import { supportsWebGL2 } from "../../common/map/base-layer";
import type {
MapClusterIcon,
MapControlPosition,
MapEngine,
MapFitPadding,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
@@ -302,9 +300,6 @@ 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 })
@@ -452,10 +447,6 @@ 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") ||
@@ -606,7 +597,7 @@ export class HaMap extends ReactiveElement {
darkMode: this._darkMode,
token,
rasterOnly: this._forceLeaflet,
zoomControlPosition: this.zoomPosition,
zoomControlPosition: "topleft",
events: {
click: (location) => this._handleEngineClick(location),
zoomStart: () => {
@@ -791,11 +782,8 @@ export class HaMap extends ReactiveElement {
public fitBounds(
boundingbox: MapLatLng[],
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
options?: { zoom?: number; pad?: number }
) {
// An explicit fit is user intent, even while it waits for the engine or
// a size; an auto-fit must not take its place in the meantime
this._pauseAutoFit = true;
if (!this._engine) {
// Engine still loading (see _loadMap); runs once it is
this._pendingFit = () => this.fitBounds(boundingbox, options);
@@ -809,7 +797,6 @@ export class HaMap extends ReactiveElement {
maxZoom: options?.zoom || this.zoom,
pad: options?.pad ?? 0.5,
animate: this._hasFitted,
padding: options?.padding,
});
});
this._hasFitted = true;
@@ -1503,11 +1490,6 @@ 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;
}
@@ -1605,10 +1587,6 @@ export class HaMap extends ReactiveElement {
--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,
.leaflet-bottom {
+2 -2
View File
@@ -93,14 +93,14 @@ export interface HistoryStreamMessage {
}
export const entityIdHistoryNeedsAttributes = (
hass: Pick<HomeAssistant, "states">,
hass: HomeAssistant,
entityId: string
) =>
!hass.states[entityId] ||
NEED_ATTRIBUTE_DOMAINS.includes(computeDomain(entityId));
export const fetchDateWS = (
hass: Pick<HomeAssistant, "states" | "callWS">,
hass: HomeAssistant,
startTime: Date,
endTime: Date,
entityIds: string[]
+24 -235
View File
@@ -3,16 +3,19 @@ import {
mdiGoogleCirclesCommunities,
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { HassEntities } 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 { ContextConsumer } from "@lit/context";
import type { ContextType } from "@lit/context";
import { consume, ContextConsumer } from "@lit/context";
import { resolveThemeColor } from "../../../common/color/compute-color";
import {
entityMapColor,
zoneColor,
} from "../../../common/map/entity-map-colors";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { 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";
@@ -30,19 +33,14 @@ import type {
HaMapPaths,
MapCardMarkerLabelMode,
} from "../../../components/map/ha-map";
import type { MapFitPadding, MapLatLng } from "../../../common/map/map-engine";
import {
entityMapColor,
zoneColor,
} from "../../../common/map/entity-map-colors";
import type { MapLatLng } from "../../../common/map/map-engine";
import type { HistoryStates } from "../../../data/history";
import { subscribeHistoryStatesTimeWindow } from "../../../data/history";
import type { Themes } from "../../../data/ws-themes";
import { fullEntitiesContext } from "../../../data/context";
import { fullEntitiesContext, uiContext } from "../../../data/context";
import { transform } from "../../../common/decorators/transform";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../types";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { PANEL_VIEW_LAYOUT } from "../views/const";
import { findEntities } from "../common/find-entities";
import {
hasConfigChanged,
@@ -59,15 +57,6 @@ 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;
// Margin around the overview (--ha-space-3), in pixels
const OVERVIEW_GAP = 12;
const FOCUS_PERSON_ZOOM = 19;
const FOCUS_ZONE_MAX_ZOOM = 18;
interface GeoEntity {
entity_id: string;
label_mode?: MapCardMarkerLabelMode;
@@ -94,8 +83,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@property({ attribute: false }) public layout?: string;
@property({ type: Boolean }) public preview = false;
@state() private _stateHistory?: HistoryStates;
@state()
@@ -115,15 +102,16 @@ class HuiMapCard extends LitElement implements LovelaceCard {
// Registry creation order decides the palette colors
@state() private _entityReg: EntityRegistryEntry[] = [];
// Palette colors are read from the theme when the entities are built
@state()
@consume({ context: uiContext, subscribe: true })
@transform<ContextType<typeof uiContext>, Themes>({
transformer: ({ themes }) => themes,
})
private _themes?: Themes;
@state() private _clusterMarkers = true;
@state() private _overviewSelected?: string;
// Height of the overview drawer when it sits over the bottom of the map
@state() private _overviewSize = { width: 0, height: 0 };
private _overviewLoaded = false;
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
private _getAllEntities(): string[] {
@@ -249,40 +237,23 @@ class HuiMapCard extends LitElement implements LovelaceCard {
return html`
<ha-card id="card" .header=${this._config.title}>
<div
id="root"
class=${this.layout === PANEL_VIEW_LAYOUT ? "panel-layout" : ""}
@hass-more-info=${this._handleMapMoreInfo}
>
<div id="root">
<ha-map
style=${styleMap({
"--overview-height": `${this._overviewSize.height}px`,
})}
.entities=${this._filteredMapEntities}
.zoom=${this._config.default_zoom ?? DEFAULT_ZOOM}
.paths=${this._getHistoryPaths(
this._config,
this._stateHistory,
this._entityReg,
this.hass.themes
this._themes
)}
.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
.renderPassive=${this.layout !== PANEL_VIEW_LAYOUT}
render-passive
></ha-map>
<div id="buttons">
${
@@ -314,17 +285,6 @@ 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>
`;
@@ -394,9 +354,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
) {
this._mapEntities = this._getMapEntities();
}
// Palette colors are read from the theme when the entities are built
const oldThemes = changedProps.get("hass")?.themes;
if (oldThemes && oldThemes !== this.hass.themes) {
// Private state is not in keyof this
if ((changedProps as PropertyValues).has("_themes") && this.hasUpdated) {
this._mapEntities = this._getMapEntities();
}
@@ -412,48 +371,8 @@ 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,
}));
}
);
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated && this._configEntities?.length) {
@@ -546,101 +465,6 @@ 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<{ width: number; height: number }>
) {
this._overviewSize = ev.detail;
}
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,
padding: this._overviewPadding(),
}
);
return;
}
const location = getEntityLocation(stateObj, this.hass.states);
if (location) {
this._map?.fitBounds([[location.latitude, location.longitude]], {
zoom: FOCUS_PERSON_ZOOM,
padding: this._overviewPadding(),
});
}
}
// The part of the map the overview covers, so a focused marker lands next
// to it rather than under it: the bottom sheet on phones, the start side
// otherwise (see the #overview styles)
private _overviewPadding(): MapFitPadding | undefined {
const { width, height } = this._overviewSize;
if (!width || !height) {
return undefined;
}
if (window.matchMedia("(max-width: 600px)").matches) {
return { bottom: height + OVERVIEW_GAP };
}
const side = width + 2 * OVERVIEW_GAP;
return computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? { right: side }
: { left: side };
}
private _toggleClusterMarkers() {
this._clusterMarkers = !this._clusterMarkers;
}
@@ -720,7 +544,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
history: HistoryStates | undefined,
// Trail colors follow the registry order and the theme like the markers
_entityReg: EntityRegistryEntry[],
_themes: Themes
_themes: Themes | undefined
): HaMapPaths[] | undefined => {
if (!history || !(config.hours_to_show ?? DEFAULT_HOURS_TO_SHOW)) {
return undefined;
@@ -812,45 +636,10 @@ 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
+1 -14
View File
@@ -9358,20 +9358,7 @@
},
"map": {
"reset_focus": "Reset focus",
"toggle_grouping": "Toggle grouping",
"overview": {
"people": "People",
"devices": "Devices",
"zones": "Zones",
"activity": "Activity",
"no_activity": "No recent activity",
"activity_unavailable": "Activity couldn't be loaded",
"person_arrived": "{name} arrived",
"person_left": "{name} left",
"show_list": "Show list",
"hide_list": "Hide list",
"people_in_zone": "{count, plural, =0 {No one here} one {{count} person} other {{count} people}}"
}
"toggle_grouping": "Toggle grouping"
},
"energy": {
"loading": "Loading…",
@@ -604,27 +604,6 @@ describe("MapLibreMapEngine", () => {
});
});
describe("fitting", () => {
it("keeps overlays clear of the fitted bounds", async () => {
const { engine, map, ready } = await createEngine();
await ready;
engine.fitBounds([[52, 4]], { maxZoom: 15, padding: { bottom: 200 } });
expect(map.fitBounds).toHaveBeenCalledOnce();
const [bounds, options] = map.fitBounds.mock.calls[0];
// A single point centers on itself at the requested zoom
expect(bounds[0]).toEqual([4, 52]);
expect(bounds[1]).toEqual([4, 52]);
expect(options.maxZoom).toBe(14);
expect(options.padding).toEqual({
top: 0,
right: 0,
bottom: 200,
left: 0,
});
});
});
describe("markers", () => {
it("hands a removed element back without MapLibre's positioning", async () => {
const { engine, ready } = await createEngine();