Skip to content

Commit fef4d2f

Browse files
jesmakclaude
andcommitted
Added hover mode for labels and made it the default
labels: false hides them, hover shows them on mouse-over, true always shows them, and a zoom level shows them always from that level up and on mouse-over below it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 7ff81b2 commit fef4d2f

8 files changed

Lines changed: 149 additions & 61 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -205,18 +205,18 @@ plugins:
205205

206206
### Options
207207

208-
| Name | Type | Default | Description |
209-
| ------------------ | ---------------- | ---------------- | ---------------------------------------------------------------------------------------------------- |
210-
| `entity` | string | **Required** | The map feed entity |
211-
| `show_area` | boolean | `false` | Draw the feed's area, if it has one |
212-
| `area_color` | string | `#1565c0` | Colour of the area outline: a name, hex or `rgb()` value. CSS variables do not work here |
213-
| `labels` | boolean / number | off | Labels next to markers. `true` always, a number only from that zoom level up |
214-
| `color_by` | enum | `kind` | `kind`, or `delay` for items with a delay (e.g. trains) |
215-
| `delay_thresholds` | list | `[3, 10]` | Minutes late from which an item is shown as late (yellow) and very late (red) |
216-
| `max_age_minutes` | number | none | Hide items whose position is older than this |
217-
| `layer_control` | boolean | `true` | Show a checkbox for the feed in the map's layer control. The plugin's `name` is its label |
218-
| `visible` | boolean | `true` | Show the layer when the map opens. Set to `false` to start with it switched off in the layer control |
219-
| `language` | enum | browser language | `fi`, `sv` or `en`, for the texts the plugin writes itself (ages, speeds, delays) |
208+
| Name | Type | Default | Description |
209+
| ------------------ | -------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
210+
| `entity` | string | **Required** | The map feed entity |
211+
| `show_area` | boolean | `false` | Draw the feed's area, if it has one |
212+
| `area_color` | string | `#1565c0` | Colour of the area outline: a name, hex or `rgb()` value. CSS variables do not work here |
213+
| `labels` | boolean / `hover` / number | `hover` | Labels next to markers: `false` off, `hover` on mouse-over, `true` always, or a zoom level (e.g. `12`) to show them always from that level up and on mouse-over below it |
214+
| `color_by` | enum | `kind` | `kind`, or `delay` for items with a delay (e.g. trains) |
215+
| `delay_thresholds` | list | `[3, 10]` | Minutes late from which an item is shown as late (yellow) and very late (red) |
216+
| `max_age_minutes` | number | none | Hide items whose position is older than this |
217+
| `layer_control` | boolean | `true` | Show a checkbox for the feed in the map's layer control. The plugin's `name` is its label |
218+
| `visible` | boolean | `true` | Show the layer when the map opens. Set to `false` to start with it switched off in the layer control |
219+
| `language` | enum | browser language | `fi`, `sv` or `en`, for the texts the plugin writes itself (ages, speeds, delays) |
220220

221221
Popup rows written by the feed are shown in the feed's own language.
222222

‎dist/ha-map-card-plugin-map-feed.js‎

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ function shapeColor(shape) {
499499
}
500500

501501
// src/options.ts
502+
var DEFAULT_LABELS = { alwaysFromZoom: null, onHover: true };
502503
var COLOR_BY = ["kind", "delay"];
503504
var DEFAULT_DELAY_THRESHOLDS = [3, 10];
504505
var ENTITY_ID = /^[a-z_]+\.[a-z0-9_]+$/;
@@ -511,7 +512,7 @@ function parseOptions(raw) {
511512
areaColor: parseAreaColor(options.area_color, errors),
512513
layerControl: options.layer_control !== false,
513514
visible: options.visible !== false,
514-
labelMinZoom: parseLabels(options.labels),
515+
labels: parseLabels(options.labels, errors),
515516
language: resolveLanguage(options.language),
516517
colorBy: parseColorBy(options.color_by, errors),
517518
delayThresholds: parseDelayThresholds(options.delay_thresholds, errors),
@@ -538,9 +539,13 @@ function parseAreaColor(value, errors) {
538539
);
539540
return AREA_OUTLINE_COLOR;
540541
}
541-
function parseLabels(value) {
542-
if (value === true) return 0;
543-
return isNumeric(value) ? Number(value) : null;
542+
function parseLabels(value, errors) {
543+
if (value === void 0 || value === "hover") return { ...DEFAULT_LABELS };
544+
if (value === false) return { alwaysFromZoom: null, onHover: false };
545+
if (value === true) return { alwaysFromZoom: 0, onHover: true };
546+
if (isNumeric(value)) return { alwaysFromZoom: Number(value), onHover: true };
547+
errors.push("labels must be false, hover, true or a zoom level such as 12");
548+
return { ...DEFAULT_LABELS };
544549
}
545550
function parseColorBy(value, errors) {
546551
if (value === void 0) return "kind";
@@ -620,12 +625,13 @@ function responseOf(result) {
620625
}
621626

622627
// src/map/marker-registry.ts
628+
var LABEL_OPTIONS = { direction: "right", offset: [10, 0], opacity: 0.9 };
623629
var MarkerRegistry = class {
624-
constructor(L, map, group, labelMinZoom) {
630+
constructor(L, map, group, labels) {
625631
__publicField(this, "L", L);
626632
__publicField(this, "map", map);
627633
__publicField(this, "group", group);
628-
__publicField(this, "labelMinZoom", labelMinZoom);
634+
__publicField(this, "labels", labels);
629635
__publicField(this, "entries", /* @__PURE__ */ new Map());
630636
}
631637
get size() {
@@ -646,7 +652,7 @@ var MarkerRegistry = class {
646652
entry.label = spec.label;
647653
} else {
648654
const marker = this.L.marker(spec.latLng, { icon: spec.icon, keyboard: false, riseOnHover: true });
649-
entry = { marker, popup: spec.popup, label: spec.label };
655+
entry = { marker, popup: spec.popup, label: spec.label, labelMode: null };
650656
const current = entry;
651657
marker.bindPopup(() => current.popup(), POPUP_OPTIONS);
652658
marker.addTo(this.group);
@@ -673,25 +679,26 @@ var MarkerRegistry = class {
673679
clear() {
674680
return this.removeExcept(/* @__PURE__ */ new Set());
675681
}
676-
/** Shows or hides name labels after the zoom level changes. */
682+
/** Switches labels between always shown and on hover after the zoom level changes. */
677683
refreshLabels() {
678684
for (const entry of this.entries.values()) this.applyLabel(entry);
679685
}
686+
labelMode(label) {
687+
if (label === "") return null;
688+
const { alwaysFromZoom, onHover } = this.labels;
689+
if (alwaysFromZoom !== null && this.map.getZoom() >= alwaysFromZoom) return "always";
690+
return onHover ? "hover" : null;
691+
}
680692
applyLabel(entry) {
681-
const show = this.labelMinZoom !== null && entry.label !== "" && this.map.getZoom() >= this.labelMinZoom;
693+
const mode = this.labelMode(entry.label);
682694
const text2 = escapeHtml(entry.label);
683-
if (show) {
684-
if (entry.marker.getTooltip()) entry.marker.setTooltipContent(text2);
685-
else
686-
entry.marker.bindTooltip(text2, {
687-
permanent: true,
688-
direction: "right",
689-
offset: [10, 0],
690-
opacity: 0.9
691-
});
692-
} else if (entry.marker.getTooltip()) {
693-
entry.marker.unbindTooltip();
695+
if (mode === entry.labelMode) {
696+
if (mode) entry.marker.setTooltipContent(text2);
697+
return;
694698
}
699+
if (entry.labelMode) entry.marker.unbindTooltip();
700+
if (mode) entry.marker.bindTooltip(text2, { ...LABEL_OPTIONS, permanent: mode === "always" });
701+
entry.labelMode = mode;
695702
}
696703
};
697704

@@ -934,7 +941,7 @@ var FeedRenderer = class {
934941
__publicField(this, "areaKey", "");
935942
const { L, map, group, config, log } = context;
936943
this.shapes = new ShapeRegistry(L, L.layerGroup().addTo(group));
937-
this.markers = new MarkerRegistry(L, map, group, config.labelMinZoom);
944+
this.markers = new MarkerRegistry(L, map, group, config.labels);
938945
this.details = new DetailLoader(log);
939946
this.attribution = new Attribution(map);
940947
}

‎src/map/marker-registry.ts‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type * as Leaflet from 'leaflet';
2+
import type { LabelConfig } from '../options';
23
import type { LeafletApi } from '../types/host';
34
import { escapeHtml, POPUP_OPTIONS } from '../ui/html';
45

@@ -13,12 +14,18 @@ export interface MarkerSpec {
1314
label: string;
1415
}
1516

17+
type LabelMode = 'always' | 'hover';
18+
1619
interface Entry {
1720
marker: Leaflet.Marker;
1821
popup: () => string;
1922
label: string;
23+
/** How the label is bound right now; null = not bound. */
24+
labelMode: LabelMode | null;
2025
}
2126

27+
const LABEL_OPTIONS: Leaflet.TooltipOptions = { direction: 'right', offset: [10, 0], opacity: 0.9 };
28+
2229
/**
2330
* The markers a layer shows. Markers are updated in place between refreshes,
2431
* so an open popup stays open and is redrawn with fresh data.
@@ -30,8 +37,7 @@ export class MarkerRegistry {
3037
private readonly L: LeafletApi,
3138
private readonly map: Leaflet.Map,
3239
private readonly group: Leaflet.LayerGroup,
33-
/** Zoom level from which name labels are shown; null = never. */
34-
private readonly labelMinZoom: number | null,
40+
private readonly labels: LabelConfig,
3541
) {}
3642

3743
get size(): number {
@@ -54,7 +60,7 @@ export class MarkerRegistry {
5460
entry.label = spec.label;
5561
} else {
5662
const marker = this.L.marker(spec.latLng, { icon: spec.icon, keyboard: false, riseOnHover: true });
57-
entry = { marker, popup: spec.popup, label: spec.label };
63+
entry = { marker, popup: spec.popup, label: spec.label, labelMode: null };
5864
const current = entry;
5965
marker.bindPopup(() => current.popup(), POPUP_OPTIONS);
6066
marker.addTo(this.group);
@@ -86,25 +92,28 @@ export class MarkerRegistry {
8692
return this.removeExcept(new Set());
8793
}
8894

89-
/** Shows or hides name labels after the zoom level changes. */
95+
/** Switches labels between always shown and on hover after the zoom level changes. */
9096
refreshLabels(): void {
9197
for (const entry of this.entries.values()) this.applyLabel(entry);
9298
}
9399

100+
private labelMode(label: string): LabelMode | null {
101+
if (label === '') return null;
102+
const { alwaysFromZoom, onHover } = this.labels;
103+
if (alwaysFromZoom !== null && this.map.getZoom() >= alwaysFromZoom) return 'always';
104+
return onHover ? 'hover' : null;
105+
}
106+
94107
private applyLabel(entry: Entry): void {
95-
const show = this.labelMinZoom !== null && entry.label !== '' && this.map.getZoom() >= this.labelMinZoom;
108+
const mode = this.labelMode(entry.label);
96109
const text = escapeHtml(entry.label);
97-
if (show) {
98-
if (entry.marker.getTooltip()) entry.marker.setTooltipContent(text);
99-
else
100-
entry.marker.bindTooltip(text, {
101-
permanent: true,
102-
direction: 'right',
103-
offset: [10, 0],
104-
opacity: 0.9,
105-
});
106-
} else if (entry.marker.getTooltip()) {
107-
entry.marker.unbindTooltip();
110+
// Leaflet can't switch an existing tooltip between permanent and hover, so a new mode means a new tooltip.
111+
if (mode === entry.labelMode) {
112+
if (mode) entry.marker.setTooltipContent(text);
113+
return;
108114
}
115+
if (entry.labelMode) entry.marker.unbindTooltip();
116+
if (mode) entry.marker.bindTooltip(text, { ...LABEL_OPTIONS, permanent: mode === 'always' });
117+
entry.labelMode = mode;
109118
}
110119
}

‎src/options.ts‎

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ import { AREA_OUTLINE_COLOR } from './ui/colors';
88

99
export type ColorBy = 'kind' | 'delay';
1010

11+
/** When the labels next to markers are shown. */
12+
export interface LabelConfig {
13+
/** Zoom level from which labels are always shown; null = never always. */
14+
alwaysFromZoom: number | null;
15+
/** Whether a label shows on hover where it isn't always shown. */
16+
onHover: boolean;
17+
}
18+
19+
const DEFAULT_LABELS: LabelConfig = { alwaysFromZoom: null, onHover: true };
20+
1121
const COLOR_BY: readonly ColorBy[] = ['kind', 'delay'];
1222
const DEFAULT_DELAY_THRESHOLDS: [number, number] = [3, 10];
1323
const ENTITY_ID = /^[a-z_]+\.[a-z0-9_]+$/;
@@ -21,8 +31,7 @@ export interface PluginConfig {
2131
layerControl: boolean;
2232
/** Whether the layer is shown when the map opens. A hidden layer can be switched on from the layer control. */
2333
visible: boolean;
24-
/** Zoom level from which labels are shown; null = labels off. */
25-
labelMinZoom: number | null;
34+
labels: LabelConfig;
2635
language: Language;
2736
colorBy: ColorBy;
2837
/** Minutes late from which an item is [late, very late]. */
@@ -43,7 +52,7 @@ export function parseOptions(raw: unknown): PluginConfig {
4352
areaColor: parseAreaColor(options.area_color, errors),
4453
layerControl: options.layer_control !== false,
4554
visible: options.visible !== false,
46-
labelMinZoom: parseLabels(options.labels),
55+
labels: parseLabels(options.labels, errors),
4756
language: resolveLanguage(options.language),
4857
colorBy: parseColorBy(options.color_by, errors),
4958
delayThresholds: parseDelayThresholds(options.delay_thresholds, errors),
@@ -77,10 +86,14 @@ function parseAreaColor(value: unknown, errors: string[]): string {
7786
return AREA_OUTLINE_COLOR;
7887
}
7988

80-
/** labels: true = always, a number = from that zoom level up, anything else = off. */
81-
function parseLabels(value: unknown): number | null {
82-
if (value === true) return 0;
83-
return isNumeric(value) ? Number(value) : null;
89+
/** labels: false = off, hover = on hover, true = always, a number = always from that zoom level up and on hover below it. */
90+
function parseLabels(value: unknown, errors: string[]): LabelConfig {
91+
if (value === undefined || value === 'hover') return { ...DEFAULT_LABELS };
92+
if (value === false) return { alwaysFromZoom: null, onHover: false };
93+
if (value === true) return { alwaysFromZoom: 0, onHover: true };
94+
if (isNumeric(value)) return { alwaysFromZoom: Number(value), onHover: true };
95+
errors.push('labels must be false, hover, true or a zoom level such as 12');
96+
return { ...DEFAULT_LABELS };
8497
}
8598

8699
function parseColorBy(value: unknown, errors: string[]): ColorBy {

‎src/renderer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export class FeedRenderer {
4040
const { L, map, group, config, log } = context;
4141
// Shapes go in their own group, added first, so they sit under the area outline and markers.
4242
this.shapes = new ShapeRegistry(L, L.layerGroup().addTo(group));
43-
this.markers = new MarkerRegistry(L, map, group, config.labelMinZoom);
43+
this.markers = new MarkerRegistry(L, map, group, config.labels);
4444
this.details = new DetailLoader(log);
4545
this.attribution = new Attribution(map);
4646
}

‎tests/options.test.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe('parseOptions', () => {
1010
areaColor: '#1565c0',
1111
layerControl: true,
1212
visible: true,
13-
labelMinZoom: null,
13+
labels: { alwaysFromZoom: null, onHover: true },
1414
colorBy: 'kind',
1515
delayThresholds: [3, 10],
1616
maxAgeMinutes: null,
@@ -40,7 +40,7 @@ describe('parseOptions', () => {
4040
expect(config).toMatchObject({
4141
showArea: true,
4242
areaColor: 'orange',
43-
labelMinZoom: 12,
43+
labels: { alwaysFromZoom: 12, onHover: true },
4444
colorBy: 'delay',
4545
delayThresholds: [5, 15],
4646
maxAgeMinutes: 20,
@@ -49,7 +49,6 @@ describe('parseOptions', () => {
4949
language: 'fi',
5050
errors: [],
5151
});
52-
expect(parseOptions({ entity: 'sensor.x', labels: true }).labelMinZoom).toBe(0);
5352
});
5453

5554
it('reports invalid display options and keeps the defaults', () => {
@@ -65,6 +64,20 @@ describe('parseOptions', () => {
6564
expect(config.errors).toHaveLength(3);
6665
});
6766

67+
it('reads every labels form', () => {
68+
const labels = (value: unknown) => parseOptions({ entity: 'sensor.x', labels: value }).labels;
69+
expect(labels(false)).toEqual({ alwaysFromZoom: null, onHover: false });
70+
expect(labels('hover')).toEqual({ alwaysFromZoom: null, onHover: true });
71+
expect(labels(true)).toEqual({ alwaysFromZoom: 0, onHover: true });
72+
expect(labels('14')).toEqual({ alwaysFromZoom: 14, onHover: true });
73+
});
74+
75+
it('reports invalid labels and shows them on hover', () => {
76+
const config = parseOptions({ entity: 'sensor.x', labels: 'always' });
77+
expect(config.labels).toEqual({ alwaysFromZoom: null, onHover: true });
78+
expect(config.errors[0]).toMatch(/labels must be false, hover, true or a zoom level/);
79+
});
80+
6881
it('rejects area colours that cannot be drawn', () => {
6982
for (const area_color of ['var(--primary-color)', '', 12]) {
7083
const config = parseOptions({ entity: 'sensor.x', area_color });

‎tests/plugin.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,3 +242,44 @@ describe('where hass comes from', () => {
242242
);
243243
});
244244
});
245+
246+
describe('labels', () => {
247+
const trainLabel = async (map: FakeMap, labels?: unknown) => {
248+
hass = fakeHass({ 'sensor.trains': feedState('sensor.trains', [trainFeature()]) });
249+
const options = labels === undefined ? { entity: 'sensor.trains' } : { entity: 'sensor.trains', labels };
250+
const plugin = await addPlugin(map, options);
251+
return { plugin, train: () => asFakeMarker(plugin.markers?.get('train:2026-09-14/8')) };
252+
};
253+
254+
it('shows labels on hover by default', async () => {
255+
const { train } = await trainLabel(new FakeMap());
256+
expect(train().tooltip).toBe('→ Helsinki');
257+
expect(train().tooltipPermanent).toBe(false);
258+
});
259+
260+
it('shows no labels with labels: false', async () => {
261+
const { train } = await trainLabel(new FakeMap(), false);
262+
expect(train().tooltip).toBeNull();
263+
});
264+
265+
it('always shows labels with labels: true', async () => {
266+
const { train } = await trainLabel(new FakeMap(), true);
267+
expect(train().tooltipPermanent).toBe(true);
268+
});
269+
270+
it('with a zoom level, shows labels always from it up and on hover below it', async () => {
271+
const map = new FakeMap();
272+
map.zoom = 10;
273+
const { train } = await trainLabel(map, 12);
274+
expect(train().tooltipPermanent).toBe(false);
275+
276+
map.zoom = 12;
277+
map.handlers.get('zoomend')?.();
278+
expect(train().tooltipPermanent).toBe(true);
279+
expect(train().tooltip).toBe('→ Helsinki');
280+
281+
map.zoom = 11;
282+
map.handlers.get('zoomend')?.();
283+
expect(train().tooltipPermanent).toBe(false);
284+
});
285+
});

0 commit comments

Comments
 (0)