Skip to content

Commit 4cf62a7

Browse files
authored
Add display: pill — zone icon + initials marker (#213)
Opt-in entity display mode. When a person/device_tracker is inside a HA zone, render the marker as a stadium pill with the zone icon + the entity's initials; fall back to the normal initials marker otherwise. Fixes the overlap decision vs #196 by keeping pill as a dedicated display mode.
1 parent 9086e10 commit 4cf62a7

6 files changed

Lines changed: 387 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Either the name of the `entity` or:
9999
| name | Default | note |
100100
|------------------------|---------------------------------------|-----------------------------------------------------------------------------------------------|
101101
| `entity` | | The entity id |
102-
| `display` | `marker` | `icon`, `state`, `attribute` or `marker`. <br/>`marker` will display the picture if available. <br/>`icon` will display the icon if available, otherwise a label composed of first letters of the entity's name |
102+
| `display` | `marker` | `icon`, `state`, `attribute`, `marker` or `pill`. <br/>`marker` will display the picture if available. <br/>`icon` will display the icon if available, otherwise a label composed of first letters of the entity's name. <br/>`pill` shows the entity's **current zone icon + its initials** together when it is inside a Home Assistant zone, and the normal initials marker otherwise. See [Place pill](#place-pill-display-pill). |
103103
| `picture` | | Set a custom picture to use on the marker. |
104104
| `icon` | | Set a custom icon to use if `display` is set to `icon`. e.g. `mdi:cake` |
105105
| `label` | | Set a custom text label to display on the marker (overrides auto-generated initials). Works with picture overlays. |
@@ -124,9 +124,96 @@ Either the name of the `entity` or:
124124
| `z_index_offset` | 1 | z-index value that determines what is displayed on top when markers overlap. (Setting a gap of at least 20 between the values assigned to each entity is recommended.) |
125125
| `use_base_entity_only` | false | When set to `true`, the tracking will use only the base entity without including any associated device trackers. This is useful for scenarios where you want to track the base entity directly and ignore any associated trackers. |
126126
| `position_update_threshold` | 10 | Distance threshold in meters. Marker position only updates if the entity has moved more than this distance. Prevents unnecessary map updates from GPS drift. Useful for clustered markers. |
127+
| `pill_callout_min_zoom` | 15 | Only for `display: pill`. Map zoom at/above which the pill is drawn as an offset callout (up-left of the point, with a thin leader line to a dot on the exact location). Below this zoom the pill simply centres on the point with no leader, so it isn't obtrusive when the whole region is in view. |
127128
| `circle` | | Display a circle around the marker. <br/>More details [Circle options](#circle-options) |
128129
| `geojson` | | Display GeoJSON data from an entity attribute. <br/>More details [GeoJSON options](#geojson-options) |
129130
131+
### Place pill (`display: pill`)
132+
133+
A `pill` marker answers "who is where" in a single glance. When a tracked entity
134+
is inside a Home Assistant zone, its marker is drawn as a small stadium ("pill")
135+
holding two circles: the **zone's icon** on the left and the entity's **initials**
136+
on the right — for example a church icon next to `ML`, or a home icon next to
137+
`MG`. When the entity is not in any zone it falls back to the normal initials
138+
marker. It is opt-in per entity and changes nothing for entities that don't set
139+
`display: pill`.
140+
141+
#### Why
142+
143+
With the stock marker you can see *that* someone is on the map, but to learn
144+
*which named place* they're at you have to recognise the spot or click through.
145+
And when a person sits on top of their home/zone, you either get two overlapping
146+
markers or a cluster bubble showing a count. The pill folds identity and place
147+
into one marker — "Mom is at the church" is readable without interaction — using
148+
data Home Assistant already has (zones + the entity's state).
149+
150+
#### How it resolves the person and the place
151+
152+
There is no extra configuration linking a person to a place; it is derived live:
153+
154+
1. **The entity is the person.** You list a `person.*` or `device_tracker.*` on
155+
the map as usual. The card already places it at its GPS position and derives
156+
its **initials** from the entity's `friendly_name` (first letter of each word,
157+
e.g. `Mom Location` → `ML`). `label:` still overrides the initials if set.
158+
2. **Zones are the named places.** Each HA `zone.*` has a `friendly_name`, an
159+
`icon`, a latitude/longitude and a radius. These already exist whenever you
160+
create a zone (Settings → Areas & Zones).
161+
3. **Home Assistant links them via state.** When a `device_tracker`/`person` is
162+
inside a zone, HA sets that entity's **state to the zone's name** (the Home
163+
zone reports `home`). This is the join between person and place, maintained by
164+
HA core — the card doesn't compute geometry.
165+
4. **The card reads it back.** For a `pill` entity it takes the entity's raw
166+
state, finds the `zone.*` whose `friendly_name` matches it (case-insensitive;
167+
`home` → the Home zone), and uses that zone's `icon` for the left circle. If
168+
the state is `not_home`/`away`/unknown — or matches no zone — there is no
169+
place, so the normal initials marker is shown instead.
170+
5. **Tooltip.** Hovering shows `<person> is at <place>`, e.g. *Mom is at
171+
Extended Family*. The person name is the entity's `friendly_name` with a
172+
trailing `Location`/`Tracker`/`Device`/`Phone`/`GPS` stripped, so
173+
`Mom Location` reads as `Mom`. (This uses a Leaflet tooltip, which opens
174+
immediately, rather than the browser's delayed native `title`.)
175+
176+
The marker is rebuilt automatically when the entity enters or leaves a zone, so
177+
the pill appears, swaps icon, or collapses to initials as the person moves.
178+
179+
#### Zoom-aware callout
180+
181+
At/above `pill_callout_min_zoom` (default `15`) the pill is offset up-and-left of
182+
the exact position and connected by a thin **leader line** to a small dot on the
183+
real location, so the pill doesn't cover what's underneath. Below that zoom — when
184+
the whole town or region is in view and a leader line would just be clutter — the
185+
pill simply centres on the point with no leader. Set `pill_callout_min_zoom` lower
186+
to engage the callout sooner, or very high to effectively disable it.
187+
188+
#### Appearance
189+
190+
The pill reuses the entity's existing `color` (the same per-entity colour the
191+
normal marker uses, or whatever you set with `color:`) for the outline, icon,
192+
initials and leader, and `size` for each circle. Dark mode is honoured.
193+
194+
#### Requirements
195+
196+
- The places must be real HA **zones** with an `icon` and a `friendly_name`
197+
(icons like `mdi:home`, `mdi:church`, `mdi:account-group`).
198+
- The entity must report its zone in its **state** — GPS `device_tracker`s and
199+
`person`s do this automatically; the Home zone reports `home`.
200+
201+
#### Example
202+
203+
```yaml
204+
type: custom:map-card
205+
entities:
206+
- entity: device_tracker.mom_location
207+
display: pill
208+
- entity: person.matt
209+
display: pill
210+
pill_callout_min_zoom: 14 # show the offset callout a bit sooner
211+
```
212+
213+
With `device_tracker.mom_location` in the *Extended Family* zone this renders a
214+
pill of `mdi:account-group` + `ML`, tooltip "Mom is at Extended Family"; when she
215+
leaves it becomes a plain `ML` marker.
216+
130217
### History options
131218

132219
If `history_date_selection:true`, any entities that do not define their own `history_start` and `history_end` configuration will be automatically linked to this. Please ensure a card of `type: energy-date-selection` exists on the page before enabling this.

‎src/components/MapCardEntityMarker.js‎

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export default class MapCardEntityMarker extends LitElement {
1010
'tooltip': {type: String, attribute: 'tooltip'},
1111
'picture': {type: String, attribute: 'picture'},
1212
'icon': {type: String, attribute: 'icon'},
13+
'placeIcon': {type: String, attribute: 'place-icon'},
14+
'callout': {type: String, attribute: 'callout'},
1315
'color': {type: String, attribute: 'color'},
1416
'size': {type: Number},
1517
'tapAction': {type: Object, attribute: 'tap-action'},
@@ -18,12 +20,56 @@ export default class MapCardEntityMarker extends LitElement {
1820
}
1921

2022
render() {
23+
// When the entity is inside a named place, render a horizontal "pill":
24+
// a stadium outline holding two circles - the zone icon (left) and the
25+
// entity's initials (right). Otherwise fall back to the normal marker.
26+
if (this.placeIcon) {
27+
// Pill: zone icon (left) + initials (right). Geometry must match
28+
// Entity.createMapMarker. When `callout` is on (zoomed in) the pill sits
29+
// up-left of the point with a thin leader line down-right to a dot on the
30+
// exact location; otherwise it just centers on the point (no leader).
31+
const s = this.size;
32+
const pillW = 2 * s + 14;
33+
const pillH = s + 8;
34+
const pill = html`
35+
<div class="place-pill" style="border-color: ${this.color}; width: ${pillW}px; height: ${pillH}px;">
36+
<div class="pill-badge" style="border-color: ${this.color}; width: ${s}px; height: ${s}px;">
37+
<ha-icon icon="${this.placeIcon}" style="--icon-primary-color: ${this.color}; --mdc-icon-size: ${s - 14}px;"></ha-icon>
38+
</div>
39+
<div class="pill-badge pill-initials" style="border-color: ${this.color}; color: ${this.color}; width: ${s}px; height: ${s}px; font-size: ${Math.round(s * 0.5)}px;">
40+
${this.title}
41+
</div>
42+
</div>`;
43+
if (this.callout !== "true") {
44+
return html`
45+
<div class="place-pill-wrap ${this.extraCssClasses ? this.extraCssClasses : ""}"
46+
style="width: ${pillW}px; height: ${pillH}px;"
47+
@click=${this._badgeTap}>
48+
${pill}
49+
</div>`;
50+
}
51+
const off = Math.round(s * 0.7);
52+
const boxW = pillW + off;
53+
const boxH = pillH + off;
54+
const dotX = boxW - 2, dotY = boxH - 2;
55+
return html`
56+
<div class="place-pill-wrap ${this.extraCssClasses ? this.extraCssClasses : ""}"
57+
style="width: ${boxW}px; height: ${boxH}px;"
58+
@click=${this._badgeTap}>
59+
<svg class="pill-leader" width="${boxW}" height="${boxH}">
60+
<line x1="${pillW - 2}" y1="${pillH - 2}" x2="${dotX}" y2="${dotY}"
61+
stroke="${this.color}" stroke-width="1.5"></line>
62+
<circle cx="${dotX}" cy="${dotY}" r="2.5" fill="${this.color}"></circle>
63+
</svg>
64+
${pill}
65+
</div>
66+
`;
67+
}
2168
return html`
2269
<div
2370
class="marker ${this.picture ? "picture" : ""} ${this.extraCssClasses ? this.extraCssClasses : ""}"
2471
style="border-color: ${this.color}; height: ${this.size}px; width: ${this.size}px;"
2572
@click=${this._badgeTap}
26-
title="${this.tooltip}"
2773
>
2874
${this._inner()}
2975
</div>
@@ -123,6 +169,53 @@ export default class MapCardEntityMarker extends LitElement {
123169
.suffix {
124170
margin-left: var(--ha-marker-suffix-margin, 2px);
125171
}
172+
.place-pill-wrap {
173+
position: relative;
174+
}
175+
.pill-leader {
176+
position: absolute;
177+
left: 0;
178+
top: 0;
179+
overflow: visible;
180+
pointer-events: none;
181+
}
182+
.place-pill {
183+
position: absolute;
184+
left: 0;
185+
top: 0;
186+
display: inline-flex;
187+
align-items: center;
188+
justify-content: center;
189+
gap: 2px;
190+
box-sizing: border-box;
191+
padding: 3px 5px;
192+
border-radius: 999px;
193+
border: 1px solid var(--ha-marker-color, var(--primary-color));
194+
background-color: var(--card-background-color);
195+
font-size: var(--ha-marker-font-size, 1.5em);
196+
white-space: nowrap;
197+
}
198+
.place-pill .pill-badge {
199+
display: flex;
200+
align-items: center;
201+
justify-content: center;
202+
box-sizing: border-box;
203+
border-radius: 50%;
204+
border: 1px solid var(--ha-marker-color, var(--primary-color));
205+
background-color: var(--card-background-color);
206+
color: var(--primary-text-color);
207+
flex: 0 0 auto;
208+
}
209+
.place-pill .pill-initials {
210+
font-weight: 700;
211+
text-align: center;
212+
line-height: 1;
213+
}
214+
.place-pill.dark,
215+
.place-pill.dark .pill-badge {
216+
color: #ffffff;
217+
background: #1c1c1c;
218+
}
126219
`;
127220
}
128221
}

‎src/configs/EntityConfig.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ export default class EntityConfig {
6767
useBaseEntityOnly;
6868
/** @type {number} */
6969
positionUpdateThreshold;
70+
/**
71+
* @type {number} Min map zoom at which a `display: pill` marker shows the
72+
* offset callout + leader line; below it the pill centers on the point.
73+
*/
74+
pillCalloutMinZoom;
7075

7176
/** @type {CircleConfig} */
7277
circleConfig;
@@ -132,6 +137,7 @@ export default class EntityConfig {
132137

133138
this.useBaseEntityOnly = config.use_base_entity_only ?? false;
134139
this.positionUpdateThreshold = config.position_update_threshold ?? 10;
140+
this.pillCalloutMinZoom = config.pill_callout_min_zoom ?? 15;
135141

136142
this.circleConfig = new CircleConfig(config.circle, this.color);
137143
this.geoJsonConfig = new GeoJsonConfig(config.geojson, this.color);

‎src/configs/EntityConfig.test.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ describe("EntityConfig", () => {
88
historyEnd: null
99
};
1010

11+
describe("pillCalloutMinZoom", () => {
12+
it("defaults to 15", () => {
13+
const entityConfig = new EntityConfig({ entity: "device_tracker.mom" }, defaults);
14+
expect(entityConfig.pillCalloutMinZoom).toBe(15);
15+
});
16+
17+
it("can be overridden via pill_callout_min_zoom", () => {
18+
const entityConfig = new EntityConfig({ entity: "device_tracker.mom", pill_callout_min_zoom: 12 }, defaults);
19+
expect(entityConfig.pillCalloutMinZoom).toBe(12);
20+
});
21+
});
22+
1123
describe("_generateRandomColor", () => {
1224
it("should generate a random color", () => {
1325
const entityConfig = new EntityConfig("sensor.random_entity", defaults);

0 commit comments

Comments
 (0)