diff --git a/build-scripts/gulp/map-assets.js b/build-scripts/gulp/map-assets.js index f34920cada..cf88331dbf 100644 --- a/build-scripts/gulp/map-assets.js +++ b/build-scripts/gulp/map-assets.js @@ -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. diff --git a/build-scripts/gulp/map-labels.js b/build-scripts/gulp/map-labels.js deleted file mode 100644 index 2f64d990d5..0000000000 --- a/build-scripts/gulp/map-labels.js +++ /dev/null @@ -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 - ), -}); diff --git a/gallery/src/pages/lovelace/map-card.ts b/gallery/src/pages/lovelace/map-card.ts index 7f8a6d8ac2..ed7a46f622 100644 --- a/gallery/src/pages/lovelace/map-card.ts +++ b/gallery/src/pages/lovelace/map-card.ts @@ -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[]; @customElement("demo-lovelace-map-card") diff --git a/package.json b/package.json index b085b7baf9..829e10909e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/dom/setup-leaflet-map.ts b/src/common/dom/setup-leaflet-map.ts index 860bab68f4..f92b7a5111 100644 --- a/src/common/dom/setup-leaflet-map.ts +++ b/src/common/dom/setup-leaflet-map.ts @@ -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 => { @@ -61,7 +62,7 @@ export const setupLeafletMap = async ( const baseLayer = await createBaseLayer( Leaflet, map, - initialView?.darkMode ?? false, + initialView?.mapStyle ?? { palette: "colorful" }, initialView?.token ); diff --git a/src/common/map/base-layer.ts b/src/common/map/base-layer.ts index 1a83d9963b..c54e150154 100644 --- a/src/common/map/base-layer.ts +++ b/src/common/map/base-layer.ts @@ -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 => { - const style: StyleSpecification = await (await fetch(url)).json(); +export const loadStyle = async ( + mapStyle: ResolvedMapStyle +): Promise => { + 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 => { let layer: ReturnType | 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 { diff --git a/src/common/map/build-map-style.ts b/src/common/map/build-map-style.ts new file mode 100644 index 0000000000..014b57fe44 --- /dev/null +++ b/src/common/map/build-map-style.ts @@ -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 | 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 +): Promise => { + 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); +}; diff --git a/src/common/map/engines/leaflet-map-engine.ts b/src/common/map/engines/leaflet-map-engine.ts index ba187fe765..6abcdf64c1 100644 --- a/src/common/map/engines/leaflet-map-engine.ts +++ b/src/common/map/engines/leaflet-map-engine.ts @@ -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 { diff --git a/src/common/map/engines/maplibre-map-engine.ts b/src/common/map/engines/maplibre-map-engine.ts index 57edf3179d..62888d4308 100644 --- a/src/common/map/engines/maplibre-map-engine.ts +++ b/src/common/map/engines/maplibre-map-engine.ts @@ -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; } }); } diff --git a/src/common/map/ha-map-palette.ts b/src/common/map/ha-map-palette.ts new file mode 100644 index 0000000000..39e5bef78c --- /dev/null +++ b/src/common/map/ha-map-palette.ts @@ -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", +}; diff --git a/src/common/map/map-engine.ts b/src/common/map/map-engine.ts index d975b05f99..bd501f7ac5 100644 --- a/src/common/map/map-engine.ts +++ b/src/common/map/map-engine.ts @@ -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; diff --git a/src/common/map/map-style-transforms.ts b/src/common/map/map-style-transforms.ts new file mode 100644 index 0000000000..d4be431cbd --- /dev/null +++ b/src/common/map/map-style-transforms.ts @@ -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[]; + + 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 | 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)); diff --git a/src/common/map/map-styles.ts b/src/common/map/map-styles.ts new file mode 100644 index 0000000000..3b95a1ada5 --- /dev/null +++ b/src/common/map/map-styles.ts @@ -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; + colorsDark?: Record; + } +> = { + 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; + /** + * 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; + recolor?: MapStyleRecolor; + /** Label options: language, and sizes per kind of label */ + text?: Record; + /** Icon scale and spacing */ + icon?: Record; + /** Which feature groups are drawn, and from which zoom */ + layers?: boolean | number | Record; +} + +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; + /** 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; + const options: Record = {}; + 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 +): 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 | 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 }; +}; diff --git a/src/common/map/map-theme-colors.ts b/src/common/map/map-theme-colors.ts new file mode 100644 index 0000000000..bd7dacd7ba --- /dev/null +++ b/src/common/map/map-theme-colors.ts @@ -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 = { + "--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 | undefined => { + const style = getComputedStyle(element); + let colors: Record | 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; +}; diff --git a/src/components/map/ha-map.ts b/src/components/map/ha-map.ts index 3d840b9f1d..320ccc5818 100644 --- a/src/components/map/ha-map.ts +++ b/src/components/map/ha-map.ts @@ -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; + + // 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, diff --git a/src/panels/lovelace/cards/hui-map-card.ts b/src/panels/lovelace/cards/hui-map-card.ts index d892038374..117c2f19e6 100644 --- a/src/panels/lovelace/cards/hui-map-card.ts +++ b/src/panels/lovelace/cards/hui-map-card.ts @@ -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} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 50d2759325..cb125c3d05 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -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; diff --git a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts index 155894bf53..536a08ed4a 100644 --- a/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-map-card-editor.ts @@ -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` { 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> ) => { 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 | 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)[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 = 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; diff --git a/src/translations/en.json b/src/translations/en.json index fb508789d2..2b4643222b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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", diff --git a/test/common/map/base-layer.test.ts b/test/common/map/base-layer.test.ts index c3d8e2af09..3e56e9461e 100644 --- a/test/common/map/base-layer.test.ts +++ b/test/common/map/base-layer.test.ts @@ -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"); diff --git a/test/build-scripts/map-labels.test.js b/test/common/map/map-style-transforms.test.ts similarity index 51% rename from test/build-scripts/map-labels.test.js rename to test/common/map/map-style-transforms.test.ts index 25ee9d17f9..1d7694185a 100644 --- a/test/build-scripts/map-labels.test.js +++ b/test/common/map/map-style-transforms.test.ts @@ -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): 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]); }); }); diff --git a/test/common/map/map-styles.test.ts b/test/common/map/map-styles.test.ts new file mode 100644 index 0000000000..4f85712850 --- /dev/null +++ b/test/common/map/map-styles.test.ts @@ -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[0], + dark: boolean +) => + resolveMapStyle(style, dark).options?.colors as + Record | 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; + + 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) + ); + }); +}); diff --git a/test/common/map/map-theme-colors.test.ts b/test/common/map/map-theme-colors.test.ts new file mode 100644 index 0000000000..59c01d8d8a --- /dev/null +++ b/test/common/map/map-theme-colors.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { readMapThemeColors } from "../../../src/common/map/map-theme-colors"; + +const withTokens = (tokens: Record) => { + 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", + }); + }); +}); diff --git a/test/common/map/maplibre-map-engine.test.ts b/test/common/map/maplibre-map-engine.test.ts index 25770144db..77b169d119 100644 --- a/test/common/map/maplibre-map-engine.test.ts +++ b/test/common/map/maplibre-map-engine.test.ts @@ -365,7 +365,7 @@ const createEngine = async (events: Partial = {}) => { 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); diff --git a/test/components/map/ha-map-engine.test.ts b/test/components/map/ha-map-engine.test.ts index d8d1412086..22a7c6add9 100644 --- a/test/components/map/ha-map-engine.test.ts +++ b/test/components/map/ha-map-engine.test.ts @@ -67,7 +67,7 @@ const fakeEngine = vi.hoisted(() => { hasUsableSize = () => true; - setDarkMode = vi.fn(); + setMapStyle = vi.fn(); setZoomControlPosition = vi.fn(); diff --git a/test/panels/lovelace/editor/card-editor/hui-map-card-editor.test.ts b/test/panels/lovelace/editor/card-editor/hui-map-card-editor.test.ts new file mode 100644 index 0000000000..d7e5f320f8 --- /dev/null +++ b/test/panels/lovelace/editor/card-editor/hui-map-card-editor.test.ts @@ -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 +) => (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" } }, + }); + }); +}); diff --git a/test/setup.ts b/test/setup.ts index c7b193d2fd..62bba17650 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -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__ = "";