mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-30 16:59:17 +01:00
Add more map styles, add style options
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
),
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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));
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
+57
-24
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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__ = "";
|
||||
|
||||
Reference in New Issue
Block a user