Add more map styles, add style options

This commit is contained in:
Bram Kragten
2026-09-30 10:55:56 +02:00
parent 682c68c1e3
commit e5b7929ef8
27 changed files with 1480 additions and 246 deletions
+50 -70
View File
@@ -1,10 +1,11 @@
// Generates the MapLibre styles for the vector base map.
// Generates the MapLibre styles for the vector base map. Only the styles:
// glyphs and tiles come from core's proxy, the sprite sheet ships with the
// frontend (see map-sprites.js). They are generated here because core has no
// node toolchain to run @versatiles/style with.
//
// Only the styles. Glyphs and tiles are served by core's proxy, which is what
// lets them be requested with an application User-Agent and without a referrer.
// The sprite sheet ships with the frontend (see map-sprites.js). The styles
// stay here because they come from @versatiles/style and core has no node
// toolchain to regenerate them with.
// Only the default pair is written out; anything else is built in the browser
// from the same builder, which is why the preparation is shared with the
// frontend and why the asset URLs are written alongside the styles.
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
@@ -13,7 +14,11 @@ import { osm } from "@versatiles/style";
import fs from "fs-extra";
import gulp from "gulp";
import paths from "../paths.cjs";
import { addLatinLabels } from "./map-labels.js";
import { finalizeMapStyle } from "../../src/common/map/map-style-transforms.ts";
import {
HA_MAP_COLORS,
HA_MAP_COLORS_DARK,
} from "../../src/common/map/ha-map-palette.ts";
import {
missingSprites,
SHEET_FILES,
@@ -22,44 +27,11 @@ import {
} from "./map-sprites.js";
const PROXY_PATH = "/api/map_tiles";
const TILEJSON_URL = `${PROXY_PATH}/tilejson.json`;
const outputDir = path.resolve(paths.build_dir, "map");
// MapLibre extends the fetched TileJSON with the style's source options, so
// anything left here wins and freezes at build time. Dropping them is what lets
// the proxy move the attribution and zoom range too, not just the URLs.
const TILEJSON_FIELDS = [
"tiles",
"attribution",
"bounds",
"minzoom",
"maxzoom",
"scheme",
];
// The builder can only write a tile URL, so the source is repointed afterwards.
// Keyed on there being exactly one source: any other shape means the builder's
// own default host would ship unnoticed.
const useTileJson = (name, style) => {
const sources = Object.values(style.sources);
if (sources.length !== 1) {
throw new Error(
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
`point at the TileJSON. Check what @versatiles/style emits.`
);
}
for (const field of TILEJSON_FIELDS) {
delete sources[0][field];
}
sources[0].url = TILEJSON_URL;
return style;
};
// Core serves /static with a month of max-age, so a re-vendored sheet would
// otherwise keep being read from cache next to a style that expects the new one.
// otherwise be read from cache next to a style expecting the new one.
const sheetHash = async () => {
const contents = await Promise.all(
SHEET_FILES.map((file) => readFile(path.join(spritesDir, file)))
@@ -69,18 +41,16 @@ const sheetHash = async () => {
return hash.digest("hex").slice(0, 8);
};
const styleOptions = (spriteVersion) => ({
urls: {
// Keeps the generated URLs origin relative.
base: "",
glyphsPattern: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [
{
id: SPRITE_SHEET,
url: `/static/map/sprites/${SPRITE_SHEET}?v=${spriteVersion}`,
},
],
},
const styleUrls = (spriteVersion) => ({
// Keeps the generated URLs origin relative.
base: "",
glyphsPattern: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [
{
id: SPRITE_SHEET,
url: `/static/map/sprites/${SPRITE_SHEET}?v=${spriteVersion}`,
},
],
});
const checkSprites = (name, style, sheet) => {
@@ -95,33 +65,43 @@ const checkSprites = (name, style, sheet) => {
return style;
};
// Both themes up front: dark is a real cartography, not an inverted raster.
const THEMES = [
["light", "colorful"],
["dark", "colorful-dark"],
// The styles a card can pick, each as a light and a dark palette. Kept in sync
// with MAP_STYLES in src/common/map/map-styles.ts.
const STYLES = ["colorful", "natural", "muted", "gray", "toner"];
const PALETTES = STYLES.flatMap((style) => [style, `${style}-dark`]);
// Colors baked in, so the map a dashboard shows without configuration costs
// no builder and no second request.
const SHIPPED = [
["light", "colorful", HA_MAP_COLORS],
["dark", "colorful-dark", HA_MAP_COLORS_DARK],
];
const generateStyles = async () => {
const sheet = await fs.readJson(
path.join(spritesDir, `${SPRITE_SHEET}.json`)
);
const options = styleOptions(await sheetHash());
return THEMES.map(([name, theme]) => [
name,
addLatinLabels(
checkSprites(name, useTileJson(name, osm({ theme, ...options })), sheet)
),
]);
const urls = styleUrls(await sheetHash());
// Built so a bump that needs an icon the sheet lacks fails here, not in a
// browser. Recoloring cannot add icons, so the palettes cover it.
PALETTES.forEach((theme) => checkSprites(theme, osm({ theme, urls }), sheet));
return urls;
};
const buildMapAssets = async () => {
await fs.emptyDir(outputDir);
const styles = await generateStyles();
await Promise.all(
styles.map(([name, style]) =>
writeFile(path.join(outputDir, `${name}.json`), JSON.stringify(style))
)
);
const urls = await generateStyles();
await Promise.all([
// Read by src/common/map/build-map-style.ts, which cannot know the hash
// of the sprite sheet this build vendored.
writeFile(path.join(outputDir, "urls.json"), JSON.stringify(urls)),
...SHIPPED.map(([name, theme, colors]) =>
writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(finalizeMapStyle(name, osm({ theme, colors, urls })))
)
),
]);
};
// Shared so it does not have to be wired into every pipeline separately.
-42
View File
@@ -1,42 +0,0 @@
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME = ["get", "name"];
const NAME_EN = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement) =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
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
),
});
+144
View File
@@ -2,6 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, query } from "lit/decorators";
import type { DemoCardConfig } from "../../components/demo-card";
import { MAP_STYLES } from "../../../../src/common/map/map-styles";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { MapCardConfig } from "../../../../src/panels/lovelace/cards/types";
import "../../components/demo-cards";
@@ -182,6 +183,149 @@ const CONFIGS = [
entities: ["zone.home"],
},
},
...MAP_STYLES.map((mapStyle) => ({
heading: `Map style: ${mapStyle}`,
config: {
type: "map" as const,
map_style: mapStyle,
default_zoom: 14,
entities: ["zone.home"],
},
})),
{
heading: "Map style: recolored",
config: {
type: "map",
map_style: {
base: "colorful",
recolor: {
saturate: -0.6,
rotate_hue: 200,
tint: { amount: 0.3, color: "#2196f3" },
},
},
default_zoom: 14,
entities: ["zone.home"],
},
},
{
heading: "Map style: recolored, on the default style",
config: {
type: "map",
map_style: { recolor: { saturate: -1 } },
default_zoom: 14,
entities: ["zone.home"],
},
},
{
heading: "Map style: individual colors",
config: {
type: "map",
map_style: {
base: "toner",
colors: { water: "#b3e5fc", land: "#fffde7", roadMotorway: "#ffab91" },
},
default_zoom: 14,
entities: ["zone.home"],
},
},
{
// One card for both grounds: the theme mode picks the palette, and
// colors_dark the colors that go with it.
heading: "Map style: colors per theme mode",
config: {
type: "map",
default_zoom: 14,
entities: ["zone.home"],
map_style: {
base: "colorful",
colors: {
background: "#f6f6f4",
land: "#f6f6f4",
water: "#b3ddf6",
labelWater: "#3d7ea6",
natureWood: "#c9e6c4",
natureGrass: "#d6ebd2",
naturePark: "#c3e5bd",
natureLeisure: "#daecd6",
natureAgriculture: "#ebf0dd",
natureWetland: "#d3e7de",
natureSand: "#f0ecd8",
natureRock: "#ecebe7",
glacier: "#ffffff",
areaResidential: "#f1f0ed",
areaCommercial: "#f3efee",
areaIndustrial: "#f0efe9",
areaWaste: "#e6e3d8",
areaBurial: "#e2e7de",
siteParking: "#ebe9e6",
siteSports: "#e4efe1",
building: "#ecebe6",
buildingBg: "#dedcd6",
roadStreet: "#ffffff",
roadStreetBg: "#dcdcda",
roadTrunk: "#fffaf0",
roadTrunkBg: "#ded9cd",
roadMotorway: "#ffd38f",
roadMotorwayBg: "#e0a344",
transitRail: "#dfe2e5",
transitSubway: "#d8dfe6",
transitCycle: "#e8eef2",
transitFoot: "#ebe8ee",
boundary: "#b9bcc4",
boundaryDisputed: "#cdcfd6",
label: "#3c4043",
labelHalo: "rgba(255,255,255,0.85)",
labelSymbol: "#5f6368",
labelPoi: "#70757a",
labelShield: "#ffffff",
labelHousenumber: "rgba(60,64,67,0.35)",
},
colors_dark: {
background: "#1b1e22",
land: "#1b1e22",
water: "#10293b",
labelWater: "#6f93b0",
natureWood: "#22331f",
natureGrass: "#25341f",
naturePark: "#233620",
natureLeisure: "#242b22",
natureAgriculture: "#2a2d1e",
natureWetland: "#20322c",
natureSand: "#2e2b1d",
natureRock: "#2a2a27",
glacier: "#3a4046",
areaResidential: "#232629",
areaCommercial: "#26262b",
areaIndustrial: "#26251f",
areaWaste: "#33312a",
areaBurial: "#272b28",
siteParking: "#27292c",
siteSports: "#243024",
building: "#2a2e33",
buildingBg: "#343940",
roadStreet: "#3c4145",
roadStreetBg: "#262a2e",
roadTrunk: "#3d3a31",
roadTrunkBg: "#2a2823",
roadMotorway: "#4d4229",
roadMotorwayBg: "#312a1a",
transitRail: "#2b3036",
transitSubway: "#28303a",
transitCycle: "#232629",
transitFoot: "#27242b",
boundary: "#5a6270",
boundaryDisputed: "#454b56",
label: "#e3e6ea",
labelHalo: "rgba(0,0,0,0.75)",
labelSymbol: "#9aa0a6",
labelPoi: "#9aa0a6",
labelShield: "#12151a",
labelHousenumber: "rgba(227,230,234,0.35)",
},
},
},
},
] satisfies DemoCardConfig<MapCardConfig>[];
@customElement("demo-lovelace-map-card")
+1 -1
View File
@@ -86,6 +86,7 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.0.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -169,7 +170,6 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:typescript@7.0.2",
"@versatiles/style": "6.0.1",
"@vitest/coverage-v8": "5.0.2",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
+3 -2
View File
@@ -1,6 +1,7 @@
import type { Map } from "leaflet";
import type { MapBaseLayer } from "../map/base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../map/base-layer";
import type { ResolvedMapStyle } from "../map/map-styles";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -18,7 +19,7 @@ export const setupLeafletMap = async (
latitude: number;
longitude: number;
zoom?: number;
darkMode?: boolean;
mapStyle?: ResolvedMapStyle;
token?: string;
}
): Promise<LeafletMapSetup> => {
@@ -61,7 +62,7 @@ export const setupLeafletMap = async (
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false,
initialView?.mapStyle ?? { palette: "colorful" },
initialView?.token
);
+37 -24
View File
@@ -5,7 +5,9 @@ import type {
setWorkerUrl,
StyleSpecification,
} from "maplibre-gl";
import { deepEqual } from "../util/deep-equal";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import type { ResolvedMapStyle } from "./map-styles";
import {
MAP_TILES_PATH,
mapTilesUrl,
@@ -14,9 +16,10 @@ import {
withMapTilesToken,
} from "../../data/map_tiles";
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
export const VECTOR_STYLES = {
// The default style, written out by build-scripts/gulp/map-assets.js; every
// other one is built in the browser. The attribution comes from the TileJSON,
// deliberately: it follows whoever serves the tiles.
const SHIPPED_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
@@ -53,7 +56,7 @@ type TokenTileLayerOptions = TileLayerOptions & { token?: string };
export interface MapBaseLayer {
// A no-op for raster, which has no dark variant and is inverted in CSS.
setDarkMode: (darkMode: boolean) => void;
setMapStyle: (style: ResolvedMapStyle) => void;
}
let webGL2Supported: boolean | undefined;
@@ -103,8 +106,18 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
// MapLibre rejects a relative sprite URL
const absoluteSprite = (url: string) => new URL(url, location.href).href;
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
export const loadStyle = async (
mapStyle: ResolvedMapStyle
): Promise<StyleSpecification> => {
const shipped = mapStyle.shipped && SHIPPED_STYLES[mapStyle.shipped];
// Anything that does not ship is built here, from the same builder: its own
// chunk, so a map on the default style never downloads it.
const style: StyleSpecification = shipped
? await (await fetch(shipped)).json()
: await (
await import("./build-map-style")
).buildMapStyle(mapStyle.palette, mapStyle.options ?? {});
if (typeof style.sprite === "string") {
style.sprite = absoluteSprite(style.sprite);
@@ -147,14 +160,14 @@ const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
mapStyle: ResolvedMapStyle,
token: string | undefined
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
style: await loadStyle(mapStyle),
// Absolute, or the worker fetching tiles cannot resolve them.
transformRequest: (url) => ({
url: withMapTilesToken(url),
@@ -180,9 +193,9 @@ const createVectorLayer = async (
}
// Tracked apart so a failed request rolls back to what is displayed, not to
// the opposite of what it asked - which with several in flight differs.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// whatever was asked for before it - which with several in flight differs.
let appliedStyle = mapStyle;
let requestedStyle = mapStyle;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
@@ -235,19 +248,19 @@ const createVectorLayer = async (
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
const applyStyle = (newDarkMode: boolean) => {
const applyStyle = (newStyle: ResolvedMapStyle) => {
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
loadStyle(newStyle)
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
appliedStyle = newStyle;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
requestedStyle = appliedStyle;
}
});
};
@@ -272,23 +285,23 @@ const createVectorLayer = async (
refreshMapTilesToken();
});
// Only a new token clears the refusal. A theme change in between applies a
// Only a new token clears the refusal. A style change in between applies a
// style that is refused just as the last one was, so it proves nothing.
const unsubscribeToken = subscribeMapTilesToken(() => {
if (vector && refused) {
refused = false;
applyStyle(requestedDarkMode);
applyStyle(requestedStyle);
}
});
map.on("unload", unsubscribeToken);
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
setMapStyle: (newStyle: ResolvedMapStyle) => {
if (!vector || deepEqual(newStyle, requestedStyle)) {
return;
}
requestedDarkMode = newDarkMode;
applyStyle(newDarkMode);
requestedStyle = newStyle;
applyStyle(newStyle);
},
};
};
@@ -318,13 +331,13 @@ const createRasterLayer = (
});
map.on("unload", unsubscribe);
return { setDarkMode: () => undefined };
return { setMapStyle: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
mapStyle: ResolvedMapStyle,
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
@@ -342,7 +355,7 @@ export const createBaseLayer = async (
createLayer,
leaflet,
map,
darkMode,
mapStyle,
token
);
} catch {
+51
View File
@@ -0,0 +1,51 @@
// Builds a style in the browser, from the same @versatiles/style builder the
// shipping pair is generated with: the palettes that are not generated at build
// time, and any style a card recolors.
//
// Its own chunk, so a map on the default style never asks for it.
import type { OsmOptions } from "@versatiles/style";
import type { StyleSpecification } from "maplibre-gl";
import type { MapPalette } from "./map-styles";
import { finalizeMapStyle } from "./map-style-transforms";
/**
* The asset URLs the styles are generated with, written by
* build-scripts/gulp/map-assets.js. Read rather than repeated here because the
* sprite URL carries a hash of the sheet that ships with this build.
*/
const STYLE_URLS_PATH = "/static/map/urls.json";
let urls: Promise<OsmOptions["urls"]> | undefined;
const styleUrls = () => {
urls ??= fetch(STYLE_URLS_PATH)
.then((response) => response.json())
.catch((err) => {
// Nothing to fall back on: without the glyph and sprite URLs the style
// would draw no labels and no icons at all.
urls = undefined;
throw err;
});
return urls;
};
export const buildMapStyle = async (
palette: MapPalette,
options: Record<string, unknown>
): Promise<StyleSpecification> => {
const [{ osm }, resolvedUrls] = await Promise.all([
import("@versatiles/style"),
styleUrls(),
]);
// Theme and URLs come last: the palette is ours to pick from the theme mode,
// and the asset URLs are core's proxy and this build's sprite sheet.
const style = osm({
...(options as OsmOptions),
theme: palette,
urls: resolvedUrls,
});
return finalizeMapStyle(palette, style);
};
+4 -3
View File
@@ -24,6 +24,7 @@ import type {
MapMarkerOptions,
MapPath,
} from "../map-engine";
import type { ResolvedMapStyle } from "../map-styles";
import { setMarkerAccessibility } from "../marker-accessibility";
/** A leaflet marker that knows the engine handle it was created for */
@@ -85,7 +86,7 @@ export class LeafletMapEngine implements MapEngine {
this._baseLayer = await createBaseLayer(
Leaflet,
map,
options.darkMode,
options.mapStyle,
options.token,
options.rasterOnly ?? false
);
@@ -138,8 +139,8 @@ export class LeafletMapEngine implements MapEngine {
return false;
}
public setDarkMode(darkMode: boolean): void {
this._baseLayer?.setDarkMode(darkMode);
public setMapStyle(style: ResolvedMapStyle): void {
this._baseLayer?.setMapStyle(style);
}
public setZoomControlPosition(position: MapControlPosition): void {
+18 -19
View File
@@ -22,13 +22,13 @@ import {
MAP_MAX_ZOOM,
MAP_MIN_ZOOM,
RECOVERY_THROTTLE,
VECTOR_STYLES,
} from "../base-layer";
import {
refreshMapTilesToken,
subscribeMapTilesToken,
withMapTilesToken,
} from "../../../data/map_tiles";
import { deepEqual } from "../../util/deep-equal";
import { isTouch } from "../../../util/is_touch";
import type {
MapCircleOptions,
@@ -51,6 +51,7 @@ import type {
MapPath,
} from "../map-engine";
import { destinationPoint, distanceMeters, pointEastOf } from "../map-engine";
import type { ResolvedMapStyle } from "../map-styles";
import {
createResizeHandleElement,
RADIUS_ARIA_MAX,
@@ -185,10 +186,10 @@ export class MapLibreMapEngine implements MapEngine {
// until the next frame and throws on mutation; layer work queues until then
private _pendingStyleOps: (() => void)[] = [];
// A failed style request rolls back to the applied mode, not the requested one
private _appliedDarkMode = false;
// A failed style request rolls back to the applied style, not the requested one
private _appliedStyle!: ResolvedMapStyle;
private _requestedDarkMode = false;
private _requestedStyle!: ResolvedMapStyle;
private _latestStyleRequest = 0;
@@ -232,13 +233,11 @@ export class MapLibreMapEngine implements MapEngine {
root.appendChild(style);
}
this._appliedDarkMode = options.darkMode;
this._requestedDarkMode = options.darkMode;
this._appliedStyle = options.mapStyle;
this._requestedStyle = options.mapStyle;
this._events = options.events;
const style = await loadStyle(
VECTOR_STYLES[options.darkMode ? "dark" : "light"]
);
const style = await loadStyle(options.mapStyle);
if (this._destroyed) {
return;
}
@@ -285,11 +284,11 @@ export class MapLibreMapEngine implements MapEngine {
this._refused = true;
refreshMapTilesToken();
});
// Only a new token clears a refusal; a theme change in between is refused too
// Only a new token clears a refusal; a style change in between is refused too
this._unsubscribeToken = subscribeMapTilesToken(() => {
if (this._refused) {
this._refused = false;
this._applyStyle(this._requestedDarkMode);
this._applyStyle(this._requestedStyle);
}
});
@@ -416,30 +415,30 @@ export class MapLibreMapEngine implements MapEngine {
}
}
public setDarkMode(darkMode: boolean): void {
if (!this._map || darkMode === this._requestedDarkMode) {
public setMapStyle(mapStyle: ResolvedMapStyle): void {
if (!this._map || deepEqual(mapStyle, this._requestedStyle)) {
return;
}
this._requestedDarkMode = darkMode;
this._applyStyle(darkMode);
this._requestedStyle = mapStyle;
this._applyStyle(mapStyle);
}
private _applyStyle(darkMode: boolean): void {
private _applyStyle(mapStyle: ResolvedMapStyle): void {
const request = ++this._latestStyleRequest;
loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"])
loadStyle(mapStyle)
.then((style) => {
if (request === this._latestStyleRequest && this._map) {
this._map.setStyle(style, {
transformStyle: (previous, next) =>
this._carryCustomLayers(previous, next),
});
this._appliedDarkMode = darkMode;
this._appliedStyle = mapStyle;
}
})
.catch(() => {
if (request === this._latestStyleRequest) {
this._requestedDarkMode = this._appliedDarkMode;
this._requestedStyle = this._appliedStyle;
}
});
}
+96
View File
@@ -0,0 +1,96 @@
// The colors the map is drawn in out of the box: the VersaTiles "colorful"
// cartography repainted to the Open Home Foundation vector map concept, with
// the road hierarchy and the land-use tints worked out against it.
//
// build-scripts/gulp/map-assets.js imports this too, and Node loads it as it
// is: keep the module free of runtime imports, and of TypeScript that is not
// simply erased.
/** Drawn on the light palette */
export const HA_MAP_COLORS = {
background: "#f4efe6",
land: "#f4efe6",
water: "#bcd9e8",
labelWater: "#5b86a3",
natureWood: "#cfe1bf",
natureGrass: "#e2ecda",
naturePark: "#cfe1bf",
natureLeisure: "#e2ecda",
natureAgriculture: "#e8eee2",
natureWetland: "#e5ebe0",
natureSand: "#f1eadf",
natureRock: "#f1eadf",
glacier: "#f9f6f1",
areaResidential: "#efe8dc",
areaCommercial: "#f4e1db",
areaIndustrial: "#ede5ce",
areaWaste: "#ece3d5",
areaBurial: "#efe8dc",
siteParking: "#ede6d8",
siteSports: "#cfe1bf",
building: "#e7ddca",
buildingBg: "#dcccb2",
roadStreet: "#fcfaf8",
roadStreetBg: "#e6dac7",
roadTrunk: "#fbe6c2",
roadTrunkBg: "#e5cea5",
roadMotorway: "#ebd4ab",
roadMotorwayBg: "#deba78",
transitRail: "#e9dfce",
transitSubway: "#eae1d1",
transitCycle: "#f2ede3",
transitFoot: "#f2ede3",
boundary: "#d6c3a4",
boundaryDisputed: "#e2d6c0",
label: "#4a463d",
labelHalo: "#faf8f5",
labelSymbol: "#8d8676",
labelPoi: "#8d8676",
labelShield: "#faf8f4",
labelHousenumber: "#8d8676",
};
/** Drawn on the dark palette, in full: a light value left standing here would
* show through on a dark map. */
export const HA_MAP_COLORS_DARK = {
background: "#191b2c",
land: "#191b2c",
water: "#27357a",
labelWater: "#9cc0f0",
natureWood: "#254948",
natureGrass: "#213b39",
naturePark: "#28504b",
natureLeisure: "#213532",
natureAgriculture: "#203233",
natureWetland: "#253337",
natureSand: "#2a2c3d",
natureRock: "#2a2c3d",
glacier: "#2a2c3d",
areaResidential: "#1d2034",
areaCommercial: "#29213a",
areaIndustrial: "#242431",
areaWaste: "#1d2034",
areaBurial: "#1d2034",
siteParking: "#1d2034",
siteSports: "#254139",
building: "#21243c",
buildingBg: "#2a2e4a",
roadStreet: "#2b3052",
roadStreetBg: "#1a1d33",
roadTrunk: "#383e67",
roadTrunkBg: "#22263f",
roadMotorway: "#454d7d",
roadMotorwayBg: "#2a2f4c",
transitRail: "#2c3150",
transitSubway: "#2c3150",
transitCycle: "#2b3052",
transitFoot: "#2b3052",
boundary: "#3b4068",
boundaryDisputed: "#3b4068",
label: "#e7ecf8",
labelHalo: "#12141f",
labelSymbol: "#959dbd",
labelPoi: "#959dbd",
labelShield: "#191b2c",
labelHousenumber: "#959dbd",
};
+5 -2
View File
@@ -7,6 +7,8 @@
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
import type { ResolvedMapStyle } from "./map-styles";
export type MapLatLng = [latitude: number, longitude: number];
export type MapControlPosition =
@@ -26,7 +28,8 @@ export interface MapEngineEvents {
export interface MapEngineOptions {
center: MapLatLng;
zoom: number;
darkMode: boolean;
/** Cartography to draw; the host resolves the config and theme mode into one */
mapStyle: ResolvedMapStyle;
/** Token for core's tile proxy */
token?: string;
zoomControlPosition: MapControlPosition;
@@ -199,7 +202,7 @@ export interface MapEngine {
/** Whether the map has a non-zero size, re-measuring if needed */
hasUsableSize(): boolean;
setDarkMode(darkMode: boolean): void;
setMapStyle(style: ResolvedMapStyle): void;
setZoomControlPosition(position: MapControlPosition): void;
+105
View File
@@ -0,0 +1,105 @@
// What every vector style goes through before MapLibre sees it, whether it was
// built here or in the browser.
//
// build-scripts/gulp/map-assets.js imports this too and Node loads it as it is:
// keep the module free of runtime imports, and of TypeScript that is not simply
// erased (no enums, no parameter properties).
import type {
ExpressionSpecification,
LayerSpecification,
StyleSpecification,
SymbolLayerSpecification,
} from "maplibre-gl";
/** Where core proxies the tiles and glyphs */
export const MAP_TILES_PATH = "/api/map_tiles";
const TILEJSON_URL = `${MAP_TILES_PATH}/tilejson.json`;
// MapLibre extends the fetched TileJSON with the style's own source options, so
// anything left here would freeze at build time - including attribution and
// zoom range, not just the URLs.
const TILEJSON_FIELDS = [
"tiles",
"attribution",
"bounds",
"minzoom",
"maxzoom",
"scheme",
];
/**
* Repoints the style's one source at the proxy's TileJSON. Keyed on there being
* exactly one: any other shape means the builder's own default host would reach
* a browser unnoticed.
*/
const useTileJson = (name: string, style: StyleSpecification) => {
const sources = Object.values(style.sources) as Record<string, unknown>[];
if (sources.length !== 1) {
throw new Error(
`Style "${name}" has ${sources.length} sources, expected exactly one to ` +
`point at the TileJSON. Check what @versatiles/style emits.`
);
}
for (const field of TILEJSON_FIELDS) {
delete sources[0][field];
}
sources[0].url = TILEJSON_URL;
return style;
};
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME: ExpressionSpecification = ["get", "name"];
const NAME_EN: ExpressionSpecification = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN: ExpressionSpecification = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement: unknown): ExpressionSpecification =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const layoutOf = (layer: LayerSpecification) =>
(layer as SymbolLayerSpecification).layout as
Record<string, unknown> | undefined;
const isNameLabel = (layer: LayerSpecification) =>
JSON.stringify(layoutOf(layer)?.["text-field"]) === JSON.stringify(NAME);
const addLatinLabels = (style: StyleSpecification): StyleSpecification => ({
...style,
layers: style.layers.map((layer) => {
const layout = layoutOf(layer);
if (!layout || !isNameLabel(layer)) {
return layer;
}
const textField: ExpressionSpecification = [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layout["symbol-placement"]),
];
return {
...layer,
layout: { ...layout, "text-field": textField },
} as LayerSpecification;
}),
});
/** Everything a freshly built style needs before it is handed to MapLibre */
export const finalizeMapStyle = (
name: string,
style: StyleSpecification
): StyleSpecification => addLatinLabels(useTileJson(name, style));
+193
View File
@@ -0,0 +1,193 @@
// The vector cartographies the map can draw. Each is a light and a dark
// palette; the theme mode picks the half.
//
// Only the default pair is generated at build time (build-scripts/gulp/
// map-assets.js); everything else is built in the browser from the same
// builder, against the tiles and glyphs core's proxy already serves.
import { HA_MAP_COLORS, HA_MAP_COLORS_DARK } from "./ha-map-palette";
/** Style ids, in the order the card editor offers them */
export const MAP_STYLES = [
"default",
"colorful",
"natural",
"muted",
"gray",
"toner",
] as const;
export type MapStyle = (typeof MAP_STYLES)[number];
export const DEFAULT_MAP_STYLE: MapStyle = "default";
/** @versatiles/style's cartographies, each a light and a dark palette */
type VersatilesPalette = "colorful" | "natural" | "muted" | "gray" | "toner";
export type MapPalette = VersatilesPalette | `${VersatilesPalette}-dark`;
/** A cartography plus the colors painted over it; only the default has any */
const STYLE_PALETTES: Record<
MapStyle,
{
palette: VersatilesPalette;
colors?: Record<string, string>;
colorsDark?: Record<string, string>;
}
> = {
default: {
palette: "colorful",
colors: HA_MAP_COLORS,
colorsDark: HA_MAP_COLORS_DARK,
},
colorful: { palette: "colorful" },
natural: { palette: "natural" },
muted: { palette: "muted" },
gray: { palette: "gray" },
toner: { palette: "toner" },
};
/**
* Color adjustments applied to the whole style, straight from
* @versatiles/style's `recolor`. The tiles.versatiles.org styler writes these.
*/
export interface MapStyleRecolor {
/** Swap light for dark, keeping the hues */
invert_brightness?: boolean;
/** Hue rotation in degrees */
rotate_hue?: number;
/** -1 is grayscale, 0 unchanged, 1 twice as saturated */
saturate?: number;
gamma?: number;
contrast?: number;
brightness?: number;
tint?: { color?: string; amount?: number };
blend?: { color?: string; amount?: number };
}
/** A style with adjustments, rather than one of the styles as it comes */
export interface CustomMapStyleConfig {
/** Cartography the adjustments start from; omit for the default */
base?: MapStyle;
/** Per-feature colors, keyed as @versatiles/style names them (water, land, natureWood, ...) */
colors?: Record<string, string>;
/**
* Colors for the dark palette. Replaces `colors` rather than merging with
* it: a light palette showing through the keys a dark one did not name is
* the mistake this exists to prevent. Without it `colors` is used for both.
*/
colors_dark?: Record<string, string>;
recolor?: MapStyleRecolor;
/** Label options: language, and sizes per kind of label */
text?: Record<string, unknown>;
/** Icon scale and spacing */
icon?: Record<string, unknown>;
/** Which feature groups are drawn, and from which zoom */
layers?: boolean | number | Record<string, unknown>;
}
export type MapStyleConfig = MapStyle | CustomMapStyleConfig;
/** A style ready to be loaded: the palette to draw, plus adjustments if any */
export interface ResolvedMapStyle {
palette: MapPalette;
/** Options for @versatiles/style; absent means the palette as it comes */
options?: Record<string, unknown>;
/** Fetched rather than built: the default, while nothing was added to it */
shipped?: "light" | "dark";
}
const isStyle = (value: unknown): value is MapStyle =>
(MAP_STYLES as readonly unknown[]).includes(value);
export const isCustomMapStyle = (
style: MapStyleConfig | undefined
): style is CustomMapStyleConfig => typeof style === "object" && style !== null;
export const paletteFor = (style: MapStyle, darkMode: boolean): MapPalette => {
const { palette } = STYLE_PALETTES[style];
return darkMode ? `${palette}-dark` : palette;
};
// An allowlist, not a passthrough: the builder also takes the tile, glyph and
// sprite URLs, and those stay ours.
const BUILDER_OPTIONS = ["colors", "recolor", "text", "icon", "layers"];
const camelCase = (key: string) =>
key.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
// Both spellings are accepted so an options object copied from the versatiles
// styler pastes as it is. Deep, because the builder nests its options.
const camelCaseKeys = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(camelCaseKeys);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [
camelCase(key),
camelCaseKeys(entry),
])
);
}
return value;
};
const builderOptions = (style: CustomMapStyleConfig, darkMode: boolean) => {
const config = style as Record<string, unknown>;
const options: Record<string, unknown> = {};
for (const name of BUILDER_OPTIONS) {
const value = config[name] ?? config[camelCase(name)];
if (value !== undefined) {
options[camelCase(name)] = camelCaseKeys(value);
}
}
// `colors_dark` is not an option of the builder's, so the allowlist above
// has already dropped it; on the dark palette it stands in for `colors`.
const colorsDark = config.colors_dark ?? config.colorsDark;
if (darkMode && colorsDark !== undefined) {
options.colors = camelCaseKeys(colorsDark);
}
return options;
};
/** A style that does not exist would leave the map blank, so it falls back */
export const resolveMapStyle = (
style: MapStyleConfig | undefined,
darkMode: boolean,
/** What the active theme paints over the style; see map-theme-colors.ts */
themeColors?: Record<string, string>
): ResolvedMapStyle => {
const custom = isCustomMapStyle(style) ? style : undefined;
const named = custom ? custom.base : style;
const mapStyle = isStyle(named) ? named : DEFAULT_MAP_STYLE;
const palette = paletteFor(mapStyle, darkMode);
const options = custom ? builderOptions(custom, darkMode) : {};
// Bottom up: the style's colors, what the theme repaints, then what the card
// names - so a card that sets one color keeps the rest of the look.
const { colors, colorsDark } = STYLE_PALETTES[mapStyle];
const styleColors = (darkMode ? colorsDark : colors) ?? colors;
if (styleColors || themeColors) {
options.colors = {
...styleColors,
...themeColors,
...(options.colors as Record<string, string> | undefined),
};
}
if (!Object.keys(options).length) {
return { palette };
}
// Anything added on top of the shipped default has to be built here.
const untouched =
!themeColors &&
(!custom || !Object.keys(builderOptions(custom, darkMode)).length);
return mapStyle === DEFAULT_MAP_STYLE && untouched
? { palette, options, shipped: darkMode ? "dark" : "light" }
: { palette, options };
};
+85
View File
@@ -0,0 +1,85 @@
// Lets a Home Assistant theme repaint the map.
//
// MapLibre draws to WebGL, so a CSS custom property cannot reach it the way it
// reaches every other component: the values have to be read here and handed to
// the style builder. Same approach ha-chart-base takes for ECharts.
//
// These tokens override; they do not define. A theme that sets none leaves the
// shipped palette alone, which is also what keeps the generated style file in
// use instead of building one in the browser.
/**
* Theme token -> the @versatiles/style colors it paints. One token covers
* several keys: a theme author sets a handful, not forty-five. A card's own
* `colors` still wins over whatever a theme says.
*/
const MAP_THEME_TOKENS: Record<string, readonly string[]> = {
"--ha-color-map-land": ["background", "land"],
"--ha-color-map-water": ["water"],
"--ha-color-map-green": [
"naturePark",
"natureWood",
"natureGrass",
"natureLeisure",
"natureWetland",
"siteSports",
],
"--ha-color-map-area": [
"areaResidential",
"areaCommercial",
"areaIndustrial",
"areaWaste",
"areaBurial",
"siteParking",
"natureAgriculture",
"natureSand",
"natureRock",
],
"--ha-color-map-building": ["building"],
"--ha-color-map-building-outline": ["buildingBg"],
"--ha-color-map-road": ["roadStreet"],
"--ha-color-map-road-major": ["roadMotorway", "roadTrunk"],
"--ha-color-map-road-outline": [
"roadStreetBg",
"roadTrunkBg",
"roadMotorwayBg",
],
"--ha-color-map-transit": [
"transitRail",
"transitSubway",
"transitCycle",
"transitFoot",
],
"--ha-color-map-boundary": ["boundary", "boundaryDisputed"],
"--ha-color-map-label": ["label"],
"--ha-color-map-label-halo": ["labelHalo"],
"--ha-color-map-label-secondary": [
"labelSymbol",
"labelPoi",
"labelHousenumber",
],
};
/**
* What the active theme says about the map, or undefined when it says nothing -
* the common case, and the one that keeps the map on the generated style.
*/
export const readMapThemeColors = (
element: Element
): Record<string, string> | undefined => {
const style = getComputedStyle(element);
let colors: Record<string, string> | undefined;
for (const [token, keys] of Object.entries(MAP_THEME_TOKENS)) {
const value = style.getPropertyValue(token).trim();
if (!value) {
continue;
}
colors ??= {};
for (const key of keys) {
colors[key] = value;
}
}
return colors;
};
+51 -5
View File
@@ -4,6 +4,7 @@ import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, ReactiveElement, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { formatDateTime } from "../../common/datetime/format_date_time";
import {
@@ -36,6 +37,9 @@ import {
circleBoundsPoints,
distanceMeters,
} from "../../common/map/map-engine";
import type { MapStyleConfig } from "../../common/map/map-styles";
import { resolveMapStyle } from "../../common/map/map-styles";
import { readMapThemeColors } from "../../common/map/map-theme-colors";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
import {
@@ -353,6 +357,10 @@ export class HaMap extends ReactiveElement {
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
/** Cartography to draw; the theme mode picks its light or dark palette */
@property({ attribute: false })
public mapStyle?: MapStyleConfig;
@property({ type: Number }) public zoom = 14;
@property({ attribute: "cluster-markers", type: Boolean })
@@ -560,6 +568,10 @@ export class HaMap extends ReactiveElement {
});
}
if (changedProps.has("mapStyle")) {
this._engine?.setMapStyle(this._resolvedMapStyle);
}
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
@@ -568,7 +580,7 @@ export class HaMap extends ReactiveElement {
return;
}
this._updateMapStyle();
this._updateMapAppearance();
// Marker, trail and circle colors were resolved from the theme when drawn
this._drawEntities();
this._drawPaths();
@@ -585,13 +597,44 @@ export class HaMap extends ReactiveElement {
);
}
private _updateMapStyle(): void {
// Memoized: it is read on every appearance sync, and the engines compare
// what they are handed against what they applied.
private _resolveMapStyle = memoizeOne(resolveMapStyle);
// Read from the live stylesheet, so it is only re-read when the theme could
// have changed - which is exactly when _updateMapAppearance runs.
private _themeColors?: Record<string, string>;
// Kept by identity, not just by value: _resolveMapStyle memoizes on its
// arguments, and a fresh object per read would defeat it.
private _readThemeColors(): void {
const colors = readMapThemeColors(this);
if (!deepEqual(colors, this._themeColors)) {
this._themeColors = colors;
}
}
private get _resolvedMapStyle() {
return this._resolveMapStyle(
this.mapStyle,
this._darkMode,
this._themeColors
);
}
private _updateMapAppearance(): void {
// A theme repaints the map by setting --ha-color-map-* on this element;
// WebGL cannot read those, so they are collected here and rebuilt into
// the style. Undefined when the theme says nothing, which is the common
// case and keeps the map on the style the build generated.
this._readThemeColors();
const map = this._mapElement!;
map.classList.toggle("clickable", this.clickable);
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._engine?.setDarkMode(this._darkMode);
this._engine?.setMapStyle(this._resolvedMapStyle);
}
private _loading = false;
@@ -649,6 +692,9 @@ export class HaMap extends ReactiveElement {
? await ensureMapTilesToken(this._connection.connection)
: undefined;
// Before init, or the first style the engine builds is the unthemed one.
this._readThemeColors();
const rasterOnly = this._forceLeaflet;
engine = await this._createEngine();
if (attempt !== this._setupAttempt) {
@@ -661,7 +707,7 @@ export class HaMap extends ReactiveElement {
this._config?.longitude ?? 4.8903147,
],
zoom: this.zoom,
darkMode: this._darkMode,
mapStyle: this._resolvedMapStyle,
token,
rasterOnly: this._forceLeaflet,
zoomControlPosition: this.zoomPosition,
@@ -689,7 +735,7 @@ export class HaMap extends ReactiveElement {
throw new Error("Map engine failed during setup");
}
this._engine = engine;
this._updateMapStyle();
this._updateMapAppearance();
this._loaded = true;
fireEvent(this, "editing-available-changed", {
available: !!engine.editing,
@@ -304,6 +304,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
: "topleft"
}
.themeMode=${themeMode}
.mapStyle=${this._config.map_style}
.clusterMarkers=${this._clusterMarkers}
.scaleRuler=${this._config.scale_ruler || false}
@map-clicked=${this._handleMapClicked}
+2
View File
@@ -1,5 +1,6 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { EntityNameItem } from "../../../common/entity/compute_entity_name_display";
import type { MapStyleConfig } from "../../../common/map/map-styles";
import type { HaDurationData } from "../../../components/ha-duration-input";
import type { MapCardMarkerLabelMode } from "../../../components/map/ha-map";
import type { EnergySourceByType } from "../../../data/energy";
@@ -435,6 +436,7 @@ export interface MapCardConfig extends LovelaceCardConfig {
geo_location_sources?: (GeoLocationSourceConfig | string)[];
dark_mode?: boolean;
theme_mode?: ThemeMode;
map_style?: MapStyleConfig;
cluster?: boolean;
conditions?: Condition[];
scale_ruler?: boolean;
@@ -12,6 +12,7 @@ import {
number,
object,
optional,
record,
string,
union,
} from "superstruct";
@@ -30,6 +31,17 @@ import type {
} from "../../../../components/ha-form/types";
import "../../../../components/ha-selector/ha-selector-select";
import "../../../../components/ha-switch";
import {
DEFAULT_MAP_STYLE,
isCustomMapStyle,
MAP_STYLES,
} from "../../../../common/map/map-styles";
import type {
CustomMapStyleConfig,
MapStyle,
MapStyleRecolor,
} from "../../../../common/map/map-styles";
import { hex2rgb, rgb2hex } from "../../../../common/color/convert-color";
import { MAP_CARD_MARKER_LABEL_MODES } from "../../../../components/map/ha-map";
import type { SelectSelector } from "../../../../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
@@ -65,6 +77,19 @@ export const mapEntitiesConfigStruct = union([
string(),
]);
const mapStyleConfigStruct = union([
string(),
object({
base: optional(string()),
colors: optional(record(string(), string())),
colors_dark: optional(record(string(), string())),
recolor: optional(record(string(), any())),
text: optional(record(string(), any())),
icon: optional(record(string(), any())),
layers: optional(any()),
}),
]);
const geoSourcesConfigStruct = union([
object({
source: string(),
@@ -91,6 +116,7 @@ const cardConfigStruct = assign(
cluster: optional(boolean()),
dark_mode: optional(boolean()), // legacy option
theme_mode: optional(string()),
map_style: optional(mapStyleConfigStruct),
conditions: optional(any()),
scale_ruler: optional(boolean()),
})
@@ -158,6 +184,21 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
},
},
},
{
name: "map_style",
default: DEFAULT_MAP_STYLE,
selector: {
select: {
mode: "dropdown",
options: MAP_STYLES.map((mapStyle) => ({
value: mapStyle,
label: localize(
`ui.panel.lovelace.editor.card.map.map_styles.${mapStyle}`
),
})),
},
},
},
{
name: "hours_to_show",
default: DEFAULT_HOURS_TO_SHOW,
@@ -169,6 +210,56 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
{ name: "cluster", default: true, selector: { boolean: {} } },
],
},
{
name: "map_style_recolor",
type: "expandable",
title: localize(
`ui.panel.lovelace.editor.card.map.map_style_recolor`
),
schema: [
{
name: "",
type: "grid",
schema: [
{
name: "invert_brightness",
selector: { boolean: {} },
},
{
name: "saturate",
selector: {
number: { min: -1, max: 1, step: 0.05, mode: "slider" },
},
},
{
name: "brightness",
selector: {
number: { min: -1, max: 1, step: 0.05, mode: "slider" },
},
},
{
name: "contrast",
selector: {
number: { min: 0, max: 2, step: 0.05, mode: "slider" },
},
},
{
name: "rotate_hue",
selector: {
number: { min: 0, max: 360, step: 1, mode: "slider" },
},
},
{
name: "tint_amount",
selector: {
number: { min: 0, max: 1, step: 0.05, mode: "slider" },
},
},
],
},
{ name: "tint_color", selector: { color_rgb: {} } },
],
},
],
},
{ name: "show_all", selector: { boolean: {} } },
@@ -246,6 +337,25 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
})
);
// The config holds one `map_style`, a preset name or an object; the form
// shows the cartography and its adjustments apart. Reassembled in
// _valueChanged, so neither of these two keys reaches a config.
private _formData = memoizeOne((config: MapCardConfig) => {
const style = config.map_style;
const custom = isCustomMapStyle(style) ? style : undefined;
const { tint, ...recolor } = custom?.recolor ?? {};
return {
...config,
map_style: (custom ? custom.base : style) ?? DEFAULT_MAP_STYLE,
// The builder nests the tint; the form shows its two halves as fields.
map_style_recolor: {
...recolor,
tint_amount: tint?.amount,
tint_color: tint?.color ? hex2rgb(tint.color) : undefined,
},
};
});
public setConfig(config: MapCardConfig): void {
assert(config, cardConfigStruct);
@@ -315,7 +425,7 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${this._formData(this._config)}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@@ -495,6 +605,14 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
if (config.show_all && config.entities?.length === 0) {
delete config.entities;
}
config.map_style = this._mapStyleConfig(
config.map_style,
config.map_style_recolor
);
delete config.map_style_recolor;
if (config.map_style === undefined) {
delete config.map_style;
}
config = this._orderProperties(config);
fireEvent(this, "config-changed", { config });
}
@@ -529,6 +647,15 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
) => {
switch (schema.name) {
case "theme_mode":
case "map_style":
case "map_style_recolor":
case "invert_brightness":
case "saturate":
case "brightness":
case "contrast":
case "rotate_hue":
case "tint_amount":
case "tint_color":
case "default_zoom":
case "scale_ruler":
case "auto_fit":
@@ -549,6 +676,8 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "map_style":
case "map_style_recolor":
case "show_all":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.map.${schema.name}_helper`
@@ -558,6 +687,58 @@ export class HuiMapCardEditor extends LitElement implements LovelaceCardEditor {
}
};
/**
* The two style fields back into one config value: a bare preset name while
* there is nothing to adjust, an object once there is.
*/
private _mapStyleConfig(
base: string | undefined,
formRecolor: Record<string, unknown> | undefined
): MapCardConfig["map_style"] {
const recolor: MapStyleRecolor = {};
const tint: { color?: string; amount?: number } = {};
for (const [name, value] of Object.entries(formRecolor ?? {})) {
// A cleared field comes back empty rather than absent.
if (value === undefined || value === null || value === "") {
continue;
}
if (name === "tint_color") {
tint.color = rgb2hex(value as [number, number, number]);
} else if (name === "tint_amount") {
tint.amount = value as number;
} else {
(recolor as Record<string, unknown>)[name] = value;
}
}
// A tint color on its own changes nothing and would look like an
// adjustment; the amount is what applies it.
if (tint.amount !== undefined) {
recolor.tint = tint;
}
const style = base ? (base as MapStyle) : undefined;
// Colors, colors_dark, text, icon and layers have no field in this form:
// they can only be written as YAML. Carry them over, or opening the card
// in the editor and touching anything would delete them.
const current = this._config?.map_style;
const carried: Record<string, unknown> = isCustomMapStyle(current)
? { ...current }
: {};
delete carried.base;
delete carried.recolor;
const adjusted = Object.keys(recolor).length > 0;
if (!adjusted && !Object.keys(carried).length) {
return style;
}
return {
...(style && { base: style }),
...carried,
...(adjusted && { recolor }),
} as CustomMapStyleConfig;
}
// remove "label_mode", "attribute" & "unit" options when needed
private _deleteOptions(config: MapEntityConfig): MapEntityConfig {
const { color, label_mode, attribute, unit, ...rest } = config;
+19
View File
@@ -10603,6 +10603,25 @@
"light": "Light",
"dark": "Dark"
},
"map_style": "Map style",
"map_style_helper": "The theme mode picks the light or dark version.",
"map_styles": {
"default": "Default",
"colorful": "Colorful",
"natural": "Natural",
"muted": "Muted",
"gray": "Gray",
"toner": "Toner"
},
"map_style_recolor": "Style adjustments",
"map_style_recolor_helper": "Recolor the selected style. Leave these empty to use it as it comes.",
"invert_brightness": "Invert brightness",
"saturate": "Saturation",
"brightness": "Brightness",
"contrast": "Contrast",
"rotate_hue": "Hue rotation",
"tint_amount": "Tint strength",
"tint_color": "Tint color",
"scale_ruler": "Scale ruler",
"default_zoom": "Default zoom",
"auto_fit": "Auto fit",
+43 -41
View File
@@ -44,6 +44,10 @@ const setWorkerUrl = vi.hoisted(() => vi.fn());
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin, setWorkerUrl }));
// What the build wrote out, which loadStyle fetches instead of building.
const COLORFUL = { palette: "colorful", shipped: "light" } as const;
const DARK = { palette: "colorful-dark", shipped: "dark" } as const;
const STYLE = {
version: 8,
sources: {},
@@ -108,9 +112,7 @@ afterEach(() => {
describe("loadStyle", () => {
const load = async () =>
(await import("../../../src/common/map/base-layer")).loadStyle(
"/static/map/light.json"
);
(await import("../../../src/common/map/base-layer")).loadStyle(COLORFUL);
it("makes the bundled sprite URL absolute against this page", async () => {
const style = await load();
@@ -157,7 +159,7 @@ describe("createBaseLayer", () => {
it("falls back to raster tiles without WebGL2", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
@@ -185,7 +187,7 @@ describe("createBaseLayer", () => {
it("falls back to upstream raster in the demo, with a referrer", async () => {
vi.stubGlobal("__DEMO__", true);
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, undefined);
await createBaseLayer(leaflet, map, COLORFUL, undefined);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
expect(url).toBe("https://tile.openstreetmap.org/{z}/{x}/{y}.png");
@@ -200,7 +202,7 @@ describe("createBaseLayer", () => {
vi.stubGlobal("BigInt", undefined);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
@@ -210,8 +212,8 @@ describe("createBaseLayer", () => {
// Absolute, because on Cast the page is not served from the instance.
it("points MapLibre at the built worker once, before the first map", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(setWorkerUrl).toHaveBeenCalledOnce();
expect(setWorkerUrl).toHaveBeenCalledWith(
@@ -224,8 +226,8 @@ describe("createBaseLayer", () => {
it("registers the RTL text plugin once, lazily, from our own host", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
expect(setRTLTextPlugin).toHaveBeenCalledWith(
@@ -237,7 +239,7 @@ describe("createBaseLayer", () => {
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(maplibreGL).toHaveBeenCalledOnce();
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
@@ -247,7 +249,7 @@ describe("createBaseLayer", () => {
it("renders raster tiles when asked to, even with WebGL2", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN, true);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN, true);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
@@ -262,7 +264,7 @@ describe("createBaseLayer", () => {
})
);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(isRaster()).toBe(true);
});
@@ -275,7 +277,7 @@ describe("createBaseLayer", () => {
throw new Error("Failed to initialize WebGL");
});
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(isRaster()).toBe(true);
expect(maplibreLayer.remove).toHaveBeenCalled();
@@ -290,25 +292,25 @@ describe("createBaseLayer", () => {
throw new Error("nothing to remove");
});
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(isRaster()).toBe(true);
});
});
describe("setDarkMode", () => {
describe("setMapStyle", () => {
beforeEach(() => {
glMap.setStyle.mockClear();
});
it("swaps the style, and ignores a repeat of the current mode", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
baseLayer.setDarkMode(true);
baseLayer.setMapStyle(DARK);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
baseLayer.setDarkMode(true);
baseLayer.setMapStyle(DARK);
expect(glMap.setStyle).toHaveBeenCalledOnce();
});
@@ -317,7 +319,7 @@ describe("setDarkMode", () => {
// the tracked mode, and the next toggle to that mode would do nothing.
it("can retry a mode whose request failed while another was in flight", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
const failing = vi.fn(async () => {
throw new Error("offline");
@@ -325,8 +327,8 @@ describe("setDarkMode", () => {
vi.stubGlobal("fetch", failing);
// Dark is superseded by light, which then fails: the map is still light.
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
baseLayer.setMapStyle(DARK);
baseLayer.setMapStyle(COLORFUL);
await vi.waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
// Both rejections have to land before the retry, or this passes whatever
// the rollback does.
@@ -338,7 +340,7 @@ describe("setDarkMode", () => {
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
baseLayer.setDarkMode(true);
baseLayer.setMapStyle(DARK);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
@@ -364,13 +366,13 @@ describe("setDarkMode", () => {
// The layer only settles once its first style resolves, so let that one
// through before the map exists to switch.
const pending = createBaseLayer(leaflet, map, false, TOKEN);
const pending = createBaseLayer(leaflet, map, COLORFUL, TOKEN);
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
resolvers.shift()!(styleResponse("light"));
const baseLayer = await pending;
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
baseLayer.setMapStyle(DARK);
baseLayer.setMapStyle(COLORFUL);
await vi.waitFor(() => expect(resolvers).toHaveLength(2));
// The dark request, which is no longer the newest, comes back last.
@@ -396,7 +398,7 @@ describe("WebGL context loss", () => {
it("falls back to raster tiles when the context stays lost", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
glHandlers.webglcontextlost();
@@ -408,7 +410,7 @@ describe("WebGL context loss", () => {
it("keeps the vector layer when the context comes back", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
@@ -424,7 +426,7 @@ describe("WebGL context loss", () => {
it("waits for the page to be visible before falling back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glHandlers.webglcontextlost();
vi.runAllTimers();
@@ -440,7 +442,7 @@ describe("WebGL context loss", () => {
it("keeps the vector layer when a hidden page gets its context back", async () => {
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
@@ -455,7 +457,7 @@ describe("WebGL context loss", () => {
it("stops listening for visibility once it has fallen back", async () => {
const remove = vi.spyOn(document, "removeEventListener");
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glHandlers.webglcontextlost();
vi.runAllTimers();
@@ -472,11 +474,11 @@ describe("WebGL context loss", () => {
it("stops answering theme changes once it has fallen back", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glHandlers.webglcontextlost();
vi.runAllTimers();
baseLayer.setDarkMode(true);
baseLayer.setMapStyle(DARK);
expect(glMap.setStyle).not.toHaveBeenCalled();
});
@@ -488,7 +490,7 @@ describe("WebGL context loss", () => {
describe("recovering from a refused token", () => {
it("asks for a new token and re-applies the style once it arrives", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status: 403 } });
@@ -500,7 +502,7 @@ describe("recovering from a refused token", () => {
it("does not keep asking while the proxy refuses for another reason", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
for (let i = 0; i < 5; i++) {
glHandlers.error({ error: { status: 403 } });
@@ -513,11 +515,11 @@ describe("recovering from a refused token", () => {
// the token is still stale says nothing about whether requests get through.
it("still recovers when the theme changes before the token arrives", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
const baseLayer = await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status: 403 } });
baseLayer.setDarkMode(true);
baseLayer.setMapStyle(DARK);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
emitToken("fresh-token");
@@ -530,7 +532,7 @@ describe("recovering from a refused token", () => {
"also recovers from status %s",
async (status) => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glMap.setStyle.mockClear();
glHandlers.error({ error: { status } });
@@ -543,7 +545,7 @@ describe("recovering from a refused token", () => {
it("ignores errors that are not a refusal", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glHandlers.error({ error: { status: 500 } });
@@ -552,7 +554,7 @@ describe("recovering from a refused token", () => {
it("leaves a working map alone when the token is merely refreshed", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
glMap.setStyle.mockClear();
emitToken("fresh-token");
@@ -565,7 +567,7 @@ describe("recovering from a refused token", () => {
it("redraws the raster layer so refused tiles are asked for again", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false, TOKEN);
await createBaseLayer(leaflet, map, COLORFUL, TOKEN);
emitToken("fresh-token");
@@ -2,28 +2,43 @@
* @vitest-environment node
*/
import type { StyleSpecification } from "maplibre-gl";
import { describe, expect, it } from "vitest";
import { addLatinLabels } from "../../build-scripts/gulp/map-labels.js";
import { finalizeMapStyle } from "../../../src/common/map/map-style-transforms";
// The rewrite keys on the exact `text-field` @versatiles/style emits; a bump
// that changed it would silently ship local-only names again.
// The label rewrite keys on the exact `text-field` @versatiles/style emits; a
// bump that changed it would silently ship local-only names again.
const layer = (id, layout) => ({ id, type: "symbol", layout });
const layer = (id: string, layout?: unknown) => ({
id,
type: "symbol",
layout,
});
const STYLE = {
layers: [
layer("label-place-city", { "text-field": ["get", "name"] }),
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
}),
layer("label-motorway-shield", { "text-field": "{ref}" }),
{ id: "water", type: "fill" },
],
};
const style = () =>
({
sources: {
"versatiles-shortbread": {
type: "vector",
tiles: ["https://tiles.example.com/{z}/{x}/{y}"],
attribution: "upstream",
minzoom: 0,
maxzoom: 14,
},
},
layers: [
layer("label-place-city", { "text-field": ["get", "name"] }),
layer("label-street-primary", {
"symbol-placement": "line",
"text-field": ["get", "name"],
}),
layer("label-motorway-shield", { "text-field": "{ref}" }),
{ id: "water", type: "fill" },
],
}) as unknown as StyleSpecification;
// Just enough of the expression language for the expressions built here.
const evaluate = (expression, properties) => {
const evaluate = (expression: any, properties: Record<string, string>): any => {
if (!Array.isArray(expression)) {
return expression;
}
@@ -56,13 +71,31 @@ const evaluate = (expression, properties) => {
}
};
const textField = (style, id) =>
style.layers.find((l) => l.id === id).layout["text-field"];
const textField = (spec: StyleSpecification, id: string) =>
(spec.layers.find((l) => l.id === id) as any).layout["text-field"];
describe("addLatinLabels", () => {
const style = addLatinLabels(STYLE);
const city = textField(style, "label-place-city");
const street = textField(style, "label-street-primary");
describe("finalizeMapStyle", () => {
const finalized = finalizeMapStyle("colorful", style());
const city = textField(finalized, "label-place-city");
const street = textField(finalized, "label-street-primary");
// Whatever host the builder wrote has to be gone: it would be requested by
// every browser showing the map, straight past core's proxy.
it("repoints the source at the proxy's TileJSON", () => {
expect(finalized.sources["versatiles-shortbread"]).toEqual({
type: "vector",
url: "/api/map_tiles/tilejson.json",
});
});
it("refuses a style whose sources it does not recognize", () => {
const twoSources = style();
twoSources.sources.extra = { type: "vector", tiles: ["https://x/{z}"] };
expect(() => finalizeMapStyle("colorful", twoSources)).toThrow(
/expected exactly one/
);
});
it("leaves Latin names alone", () => {
expect(evaluate(city, { name: "Köln", name_en: "Cologne" })).toBe("Köln");
@@ -100,7 +133,7 @@ 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(textField(finalized, "label-motorway-shield")).toBe("{ref}");
expect(finalized.layers[3]).toEqual(style().layers[3]);
});
});
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
HA_MAP_COLORS,
HA_MAP_COLORS_DARK,
} from "../../../src/common/map/ha-map-palette";
import { resolveMapStyle } from "../../../src/common/map/map-styles";
const colorsOf = (
style: Parameters<typeof resolveMapStyle>[0],
dark: boolean
) =>
resolveMapStyle(style, dark).options?.colors as
Record<string, string> | undefined;
describe("resolveMapStyle", () => {
// The map every dashboard shows without configuration: the Home Assistant
// palette, which the build wrote out, so nothing is built in the browser.
it("defaults to the Home Assistant style, and fetches what the build shipped", () => {
expect(resolveMapStyle(undefined, false)).toEqual({
palette: "colorful",
options: { colors: HA_MAP_COLORS },
shipped: "light",
});
expect(resolveMapStyle(undefined, true)).toEqual({
palette: "colorful-dark",
options: { colors: HA_MAP_COLORS_DARK },
shipped: "dark",
});
expect(resolveMapStyle("default", true)).toEqual(
resolveMapStyle(undefined, true)
);
});
// Every style ships a light and a dark palette, so the theme mode picks the
// half whatever style a card names.
it("picks the palette from the theme mode", () => {
expect(resolveMapStyle("toner", false)).toEqual({ palette: "toner" });
expect(resolveMapStyle("toner", true)).toEqual({ palette: "toner-dark" });
});
// Naming a VersaTiles palette asks for that cartography as it comes; the
// Home Assistant colors are the default style's, not colorful's.
it("leaves the VersaTiles palettes unpainted", () => {
expect(resolveMapStyle("colorful", false)).toEqual({ palette: "colorful" });
expect(resolveMapStyle("gray", true)).toEqual({ palette: "gray-dark" });
});
// A style that does not exist has no palette to build, which would leave the
// map blank rather than merely wrong.
it("falls back to the default for a style that does not exist", () => {
expect(resolveMapStyle("eclipse" as never, false).palette).toBe("colorful");
expect(colorsOf("eclipse" as never, false)).toEqual(HA_MAP_COLORS);
});
describe("on top of a style", () => {
// Naming one color should not cost the rest of the look.
it("puts the style's own colors underneath a card's", () => {
const colors = colorsOf({ colors: { water: "#ff0000" } }, false)!;
expect(colors.water).toBe("#ff0000");
expect(colors.land).toBe(HA_MAP_COLORS.land);
});
it("keeps the style's colors under a recolor, and builds it", () => {
const resolved = resolveMapStyle({ recolor: { saturate: -1 } }, true);
expect(resolved.options).toEqual({
recolor: { saturate: -1 },
colors: HA_MAP_COLORS_DARK,
});
// Anything added to the default is no longer what the build wrote out.
expect(resolved.shipped).toBeUndefined();
});
it("adds nothing to a palette that has no colors of its own", () => {
expect(
colorsOf({ base: "toner", colors: { water: "#ff0000" } }, true)
).toEqual({ water: "#ff0000" });
});
});
describe("colors_dark", () => {
const style = {
base: "toner" as const,
colors: { water: "#b3ddf6", land: "#f6f6f4" },
colors_dark: { water: "#10293b" },
};
it("uses colors on the light palette and colors_dark on the dark one", () => {
expect(colorsOf(style, false)).toEqual({
water: "#b3ddf6",
land: "#f6f6f4",
});
expect(colorsOf(style, true)).toEqual({ water: "#10293b" });
});
// Merging is what the option exists to avoid: a light `land` showing
// through on the dark palette is exactly the mistake it prevents.
it("replaces colors rather than merging into it", () => {
expect(colorsOf(style, true)).not.toHaveProperty("land");
});
it("accepts the camelCase spelling too", () => {
const camel = {
base: "toner",
colorsDark: { water: "#10293b" },
} as never;
expect(colorsOf(camel, true)).toEqual({ water: "#10293b" });
});
});
// The builder names its options in camelCase, Home Assistant configs are
// snake_case, and an options object copied out of the versatiles styler is
// already camelCase. Both have to arrive as the builder spells them, at every
// level: v6 nests them several deep.
it("accepts snake_case and camelCase option names, nested", () => {
const snake = resolveMapStyle(
{
base: "muted",
recolor: { invert_brightness: true, rotate_hue: 90 },
text: { language_strict: true, places: { cities: { size: 1.2 } } },
},
false
);
const camel = resolveMapStyle(
{
base: "muted",
recolor: { invertBrightness: true, rotateHue: 90 },
text: { languageStrict: true, places: { cities: { size: 1.2 } } },
} as never,
false
);
expect(snake.options).toEqual({
recolor: { invertBrightness: true, rotateHue: 90 },
text: { languageStrict: true, places: { cities: { size: 1.2 } } },
});
expect(camel.options).toEqual(snake.options);
});
// The builder also takes the tile, glyph and sprite URLs, the projection and
// the elevation features. Those stay ours: a card that could set them would
// send every viewer's address to whatever host it named.
it("drops options a card does not get to set", () => {
const resolved = resolveMapStyle(
{
base: "gray",
urls: { base: "https://example.com" },
projection: "mercator",
features: { terrain: true },
theme: "toner-dark",
recolor: { saturate: -1 },
} as never,
false
);
expect(resolved.options).toEqual({ recolor: { saturate: -1 } });
expect(resolved.palette).toBe("gray");
});
});
describe("theme colors", () => {
const theme = { water: "#ff0000", land: "#00ff00" };
// A theme repaints over the style; a card's own colors still win.
it("layers between the style and the card", () => {
const resolved = resolveMapStyle(
{ colors: { water: "#0000ff" } },
false,
theme
);
const colors = resolved.options?.colors as Record<string, string>;
expect(colors.water).toBe("#0000ff");
expect(colors.land).toBe("#00ff00");
// Untouched by either: still the Home Assistant palette's own value.
expect(colors.building).toBe(HA_MAP_COLORS.building);
});
// The generated file has no theme painted into it, so a themed map has to be
// built in the browser instead of fetched.
it("stops the style from being the one the build shipped", () => {
expect(resolveMapStyle(undefined, false).shipped).toBe("light");
expect(resolveMapStyle(undefined, false, theme).shipped).toBeUndefined();
});
it("changes nothing when the theme says nothing", () => {
expect(resolveMapStyle(undefined, true, undefined)).toEqual(
resolveMapStyle(undefined, true)
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { readMapThemeColors } from "../../../src/common/map/map-theme-colors";
const withTokens = (tokens: Record<string, string>) => {
const el = document.createElement("div");
for (const [name, value] of Object.entries(tokens)) {
el.style.setProperty(name, value);
}
document.body.append(el);
return el;
};
describe("readMapThemeColors", () => {
// The common case. Returning nothing is what keeps a map on the style the
// build generated, instead of building one in the browser for no reason.
it("says nothing when the theme sets no map tokens", () => {
expect(readMapThemeColors(withTokens({}))).toBeUndefined();
});
// One token stands for several of the builder's keys: a theme author sets a
// handful, not forty-five.
it("spreads one token over the colors it covers", () => {
const colors = readMapThemeColors(
withTokens({ "--ha-color-map-green": "#0a0" })
);
expect(colors).toEqual({
naturePark: "#0a0",
natureWood: "#0a0",
natureGrass: "#0a0",
natureLeisure: "#0a0",
natureWetland: "#0a0",
siteSports: "#0a0",
});
});
it("collects only the tokens that are set", () => {
const colors = readMapThemeColors(
withTokens({
"--ha-color-map-land": "#fff",
"--ha-color-map-water": " #00f ",
})
);
expect(colors).toEqual({
background: "#fff",
land: "#fff",
water: "#00f",
});
});
});
+11 -11
View File
@@ -365,7 +365,7 @@ const createEngine = async (events: Partial<MapEngineEvents> = {}) => {
const ready = engine.init(container, {
center: [52, 4],
zoom: 13,
darkMode: false,
mapStyle: { palette: "colorful" as const },
zoomControlPosition: "topleft",
events,
});
@@ -433,7 +433,7 @@ describe("MapLibreMapEngine", () => {
const { engine, map, ready } = await createEngine();
await ready;
engine.setDarkMode(true);
engine.setMapStyle({ palette: "colorful-dark" });
await flush();
expect(map.setStyle).toHaveBeenCalledOnce();
expect(map.isStyleLoaded()).toBe(false);
@@ -462,7 +462,7 @@ describe("MapLibreMapEngine", () => {
const customLayers = layerIds(map).filter((id) => id.startsWith("ha-"));
expect(customLayers).toHaveLength(2);
engine.setDarkMode(true);
engine.setMapStyle({ palette: "colorful-dark" });
await flush();
map.loadStyle();
@@ -480,10 +480,10 @@ describe("MapLibreMapEngine", () => {
await ready;
engine.addCircle([52, 4], { radius: 100, color: "red" });
engine.setDarkMode(true);
engine.setMapStyle({ palette: "colorful-dark" });
await flush();
// The dark style has not loaded, so this swap sees no previous style
engine.setDarkMode(false);
engine.setMapStyle({ palette: "colorful" });
await flush();
expect(map.setStyle).toHaveBeenCalledTimes(2);
@@ -499,18 +499,18 @@ describe("MapLibreMapEngine", () => {
await ready;
expect(loadStyle).toHaveBeenCalledTimes(1);
engine.setDarkMode(false);
engine.setMapStyle({ palette: "colorful" });
await flush();
expect(loadStyle).toHaveBeenCalledTimes(1);
// The dark style fails to fetch: the map stays light and dark can be
// requested again
loadStyle.mockRejectedValueOnce(new Error("offline"));
engine.setDarkMode(true);
engine.setMapStyle({ palette: "colorful-dark" });
await flush();
expect(map.setStyle).not.toHaveBeenCalled();
engine.setDarkMode(true);
engine.setMapStyle({ palette: "colorful-dark" });
await flush();
expect(loadStyle).toHaveBeenCalledTimes(3);
expect(map.setStyle).toHaveBeenCalledOnce();
@@ -521,13 +521,13 @@ describe("MapLibreMapEngine", () => {
await ready;
fakeMap.failNextSetStyle = true;
engine.setDarkMode(true);
engine.setMapStyle({ palette: "colorful-dark" });
await flush();
expect(map.setStyle).toHaveBeenCalledOnce();
expect(map.isStyleLoaded()).toBe(true);
// Dark was not applied, so asking for it again applies it
engine.setDarkMode(true);
engine.setMapStyle({ palette: "colorful-dark" });
await flush();
expect(map.setStyle).toHaveBeenCalledTimes(2);
expect(map.isStyleLoaded()).toBe(false);
@@ -1228,7 +1228,7 @@ describe("MapLibreMapEngine", () => {
it("keeps a circle moved while a swapped style was loading", async () => {
const { engine, map, ready } = await createEngine();
await ready;
engine.setDarkMode(true);
engine.setMapStyle({ palette: "colorful-dark" });
await flush();
expect(map.isStyleLoaded()).toBe(false);
+1 -1
View File
@@ -67,7 +67,7 @@ const fakeEngine = vi.hoisted(() => {
hasUsableSize = () => true;
setDarkMode = vi.fn();
setMapStyle = vi.fn();
setZoomControlPosition = vi.fn();
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import "../../../../../src/panels/lovelace/editor/config-elements/hui-map-card-editor";
import type { MapCardConfig } from "../../../../../src/panels/lovelace/cards/types";
// The form shows the cartography and its recolor sliders; colors, colors_dark,
// text, icon and layers can only be written as YAML. Rebuilding `map_style`
// from the form alone used to drop those, so opening the card in the editor and
// touching anything deleted a hand-written palette without saying so.
const editorWith = (config: MapCardConfig) => {
const editor = document.createElement("hui-map-card-editor") as any;
editor._config = config;
return editor;
};
const styleConfig = (
config: MapCardConfig,
base: string | undefined,
recolor?: Record<string, unknown>
) => (editorWith(config) as any)._mapStyleConfig(base, recolor);
const CUSTOM: MapCardConfig = {
type: "map",
map_style: {
base: "colorful",
colors: { water: "#b3ddf6", land: "#f6f6f4" },
colors_dark: { water: "#10293b" },
text: { language: "nl" },
},
};
describe("hui-map-card-editor map style", () => {
it("carries the YAML-only keys through an edit", () => {
expect(styleConfig(CUSTOM, "colorful", {})).toEqual(CUSTOM.map_style);
});
it("keeps them when the cartography is switched", () => {
expect(styleConfig(CUSTOM, "toner", {})).toEqual({
...(CUSTOM.map_style as object),
base: "toner",
});
});
it("adds recolor alongside them", () => {
expect(styleConfig(CUSTOM, "colorful", { saturate: -0.5 })).toEqual({
...(CUSTOM.map_style as object),
recolor: { saturate: -0.5 },
});
});
it("collapses to a plain style name when nothing is customized", () => {
const plain: MapCardConfig = { type: "map", map_style: "gray" };
expect(styleConfig(plain, "gray", {})).toBe("gray");
expect(styleConfig(plain, undefined, {})).toBeUndefined();
});
// The builder nests the tint; the form shows its two halves as fields, and a
// color without an amount changes nothing.
it("nests the tint, and ignores a tint color with no amount", () => {
const plain: MapCardConfig = { type: "map" };
expect(styleConfig(plain, "colorful", { tint_color: [33, 150, 243] })).toBe(
"colorful"
);
expect(
styleConfig(plain, "colorful", {
tint_amount: 0.4,
tint_color: [33, 150, 243],
})
).toEqual({
base: "colorful",
recolor: { tint: { amount: 0.4, color: "#2196f3" } },
});
});
});
+1
View File
@@ -9,5 +9,6 @@ if (!global.navigator) {
global.__DEMO__ = false;
global.__MAPLIBRE_WORKER_URL__ = "/frontend_latest/maplibre-gl-worker.test.js";
global.__STATIC_PATH__ = "/static/";
global.__DEV__ = false;
global.__HASS_URL__ = "";