Compare commits

..
Author SHA1 Message Date
Paul Bottein c37fa57e12 Fix weather forecast tabs in the more info dialog 2026-08-27 16:06:53 +02:00
86 changed files with 767 additions and 4388 deletions
-3
View File
@@ -74,6 +74,3 @@ test/e2e/app/dist/
.serena
test/benchmarks/results/
# Downloaded map glyph and sprite archives
.map-assets/
+6 -15
View File
@@ -4,7 +4,6 @@ import fs from "fs-extra";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
const npmPath = (...parts) =>
path.resolve(paths.root_dir, "node_modules", ...parts);
@@ -90,7 +89,7 @@ function copyQrScannerWorker(staticDir) {
copyFileDir(npmPath("qr-scanner/qr-scanner-worker.min.js"), staticPath("js"));
}
async function copyMapPanel(staticDir) {
function copyMapPanel(staticDir) {
const staticPath = genStaticPath(staticDir);
copyFileDir(
npmPath("leaflet/dist/leaflet.css"),
@@ -104,14 +103,6 @@ async function copyMapPanel(staticDir) {
npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/")
);
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
}
function copyZXingWasm(staticDir) {
@@ -148,7 +139,7 @@ gulp.task("copy-static-app", async () => {
copyMdiIcons(staticDir);
// Panel assets
await copyMapPanel(staticDir);
copyMapPanel(staticDir);
// Qr Scanner assets
copyZXingWasm(staticDir);
@@ -164,7 +155,7 @@ gulp.task("copy-static-demo", async () => {
// Copy demo static files
fs.copySync(path.resolve(paths.demo_dir, "public"), paths.demo_output_root);
copyPolyfills(paths.demo_output_static);
await copyMapPanel(paths.demo_output_static);
copyMapPanel(paths.demo_output_static);
copyFonts(paths.demo_output_static);
copyTranslations(paths.demo_output_static);
copyLocaleData(paths.demo_output_static);
@@ -177,7 +168,7 @@ gulp.task("copy-static-cast", async () => {
// Copy cast static files
fs.copySync(path.resolve(paths.cast_dir, "public"), paths.cast_output_root);
copyPolyfills(paths.cast_output_static);
await copyMapPanel(paths.cast_output_static);
copyMapPanel(paths.cast_output_static);
copyFonts(paths.cast_output_static);
copyTranslations(paths.cast_output_static);
copyLocaleData(paths.cast_output_static);
@@ -193,7 +184,7 @@ gulp.task("copy-static-gallery", async () => {
paths.gallery_output_root
);
await copyMapPanel(paths.gallery_output_static);
copyMapPanel(paths.gallery_output_static);
copyFonts(paths.gallery_output_static);
copyTranslations(paths.gallery_output_static);
copyLocaleData(paths.gallery_output_static);
@@ -224,7 +215,7 @@ gulp.task("copy-static-e2e-test-app", async () => {
}
copyPolyfills(paths.e2eTestApp_output_static);
await copyMapPanel(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
-1
View File
@@ -14,7 +14,6 @@ import "./gen-icons-json.js";
import "./gen-sensor-entity-constants.js";
import "./landing-page.js";
import "./locale-data.js";
import "./map-assets.js";
import "./rspack.js";
import "./service-worker.js";
import "./translations.js";
-218
View File
@@ -1,218 +0,0 @@
// Assembles the static assets of the vector base map - style, SDF glyphs and
// icon sprites - into /static/map/. vector.openstreetmap.org sets CORS headers
// on its tiles only, so these cannot be loaded from there by a browser.
//
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
// verified against a digest.
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { colorful, eclipse } from "@versatiles/style";
import fs from "fs-extra";
import gulp from "gulp";
import { extract } from "tar";
import paths from "../paths.cjs";
import { addLatinLabels } from "./map-labels.js";
// The tile URL is deliberately never named here: the OSMF asks consumers to
// resolve it through the TileJSON so they can move the tiles without every
// client needing a release. https://operations.osmfoundation.org/policies/vector/
const TILEJSON_URL =
"https://vector.openstreetmap.org/shortbread_v1/tilejson.json";
const ASSET_PATH = "/static/map";
const ARCHIVES = {
fonts: {
url: "https://github.com/versatiles-org/versatiles-fonts/releases/download/v2.2.0/noto_sans.tar.gz",
sha256: "a2dac39f4096722bc420367ffd4a36687cce7229e8aa760bc12cf657072eea6b",
},
sprites: {
url: "https://github.com/versatiles-org/versatiles-style/releases/download/v5.13.1/sprites.tar.gz",
sha256: "efffd0ee4cb9591bd52f16ff5b269d9618c7dd1db159cd6511943965560ddea5",
},
};
// Rendered with a device font through `localIdeographFontFamily`, so these are
// never downloaded - and they are 90% of the Noto Sans SDF set.
const LOCAL_IDEOGRAPH_BLOCKS = [
[0x2e80, 0x9fff], // CJK radicals through CJK Unified Ideographs, incl. kana
[0xac00, 0xd7ff], // Hangul syllables
[0xf900, 0xfaff], // CJK compatibility ideographs
[0xfe30, 0xfe4f], // CJK compatibility forms
];
// Our styles use bold only for motorway shields, so Latin, Greek and Cyrillic
// cover every ref and the rest of bold - half the glyph set - is dropped.
// `assertBoldStaysOnRefs` guards the assumption.
const BOLD_MAX_CODEPOINT = 0x04ff;
const BOLD_TEXT_FIELD = "{ref}";
const cacheDir = path.resolve(paths.root_dir, ".map-assets");
const outputDir = path.resolve(paths.build_dir, "map");
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
// Downloads an archive into the cache, or reuses it when the digest matches.
const cachedArchive = async (name, { url, sha256: expected }) => {
const file = path.join(cacheDir, `${name}.tar.gz`);
if (await fs.pathExists(file)) {
if (sha256(await readFile(file)) === expected) {
return file;
}
console.warn("Cached map %s archive is stale, downloading again", name);
}
console.log("Downloading map %s from %s", name, url);
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to download map ${name}: ${response.status} ${response.statusText}`
);
}
const body = Buffer.from(await response.arrayBuffer());
const digest = sha256(body);
if (digest !== expected) {
throw new Error(
`Digest mismatch for map ${name}: expected ${expected}, got ${digest}`
);
}
await fs.outputFile(file, body);
return file;
};
const glyphRange = (entryPath) => {
const match = /^(?<font>[^/]+)\/(?<start>\d+)-(?<end>\d+)\.pbf$/.exec(
entryPath
);
return match
? { font: match.groups.font, start: Number(match.groups.start) }
: undefined;
};
const keepGlyph = (entryPath) => {
const range = glyphRange(entryPath);
if (!range) {
return false;
}
if (range.font.endsWith("_bold") && range.start > BOLD_MAX_CODEPOINT) {
return false;
}
return !LOCAL_IDEOGRAPH_BLOCKS.some(
([from, to]) => range.start >= from && range.start <= to
);
};
const keepSprite = (entryPath) =>
/^basics\/sprites(@2x)?\.(json|png)$/.test(entryPath);
// `neutrino`, for one, sets country and state labels in bold - names in any
// script, which would turn to tofu. Fail the build rather than ship that.
const assertBoldStaysOnRefs = (name, style) => {
const offenders = style.layers
.filter((layer) =>
(layer.layout?.["text-font"] ?? []).some((font) => font.endsWith("_bold"))
)
.filter((layer) => layer.layout["text-field"] !== BOLD_TEXT_FIELD)
.map((layer) => layer.id);
if (offenders.length) {
throw new Error(
`Style "${name}" uses bold for ${offenders.join(", ")}, which can hold ` +
`names in any script. Raise BOLD_MAX_CODEPOINT to cover the full set ` +
`before shipping this style.`
);
}
};
// 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
// a remote switch 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;
};
const styleOptions = {
// Keeps the generated URLs origin relative.
baseUrl: "",
glyphs: `${ASSET_PATH}/fonts/{fontstack}/{range}.pbf`,
sprite: [{ id: "basics", url: `${ASSET_PATH}/sprites/basics/sprites` }],
};
const buildMapAssets = async () => {
const [fontArchive, spriteArchive] = await Promise.all([
cachedArchive("fonts", ARCHIVES.fonts),
cachedArchive("sprites", ARCHIVES.sprites),
]);
await fs.emptyDir(outputDir);
await Promise.all([
fs.ensureDir(path.join(outputDir, "fonts")),
fs.ensureDir(path.join(outputDir, "sprites")),
]);
await Promise.all([
extract({
file: fontArchive,
cwd: path.join(outputDir, "fonts"),
filter: keepGlyph,
}),
extract({
file: spriteArchive,
cwd: path.join(outputDir, "sprites"),
filter: keepSprite,
}),
// Both themes up front: dark is a real cartography, not an inverted raster.
...[
["light", colorful],
["dark", eclipse],
].map(([name, builder]) => {
const style = addLatinLabels(useTileJson(name, builder(styleOptions)));
assertBoldStaysOnRefs(name, style);
return writeFile(
path.join(outputDir, `${name}.json`),
JSON.stringify(style)
);
}),
]);
};
// Shared so it does not have to be wired into every pipeline separately.
let pending;
export const ensureMapAssets = () => {
pending ??= buildMapAssets();
return pending;
};
gulp.task("build-map-assets", ensureMapAssets);
export const mapAssetsDir = outputDir;
-42
View File
@@ -1,42 +0,0 @@
// Adds the English name to labels whose local name is not in Latin script.
// Shortbread tiles carry `name`, `name_en` and `name_de` only.
const NAME = ["get", "name"];
const NAME_EN = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
// Streets are line-placed and cannot break lines.
const withEnglish = (placement) =>
placement === "line"
? ["concat", NAME, " (", NAME_EN, ")"]
: ["format", NAME, {}, "\n", {}, NAME_EN, { "font-scale": ENGLISH_SCALE }];
const isNameLabel = (layer) =>
JSON.stringify(layer.layout?.["text-field"]) === JSON.stringify(NAME);
export const addLatinLabels = (style) => ({
...style,
layers: style.layers.map((layer) =>
isNameLabel(layer)
? {
...layer,
layout: {
...layer.layout,
"text-field": [
"case",
IS_LATIN,
NAME,
["!", ["has", "name_en"]],
NAME,
withEnglish(layer.layout["symbol-placement"]),
],
},
}
: layer
),
});
+4 -8
View File
@@ -75,8 +75,6 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.5.0",
@@ -110,15 +108,14 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.14",
"js-yaml": "5.4.0",
"js-yaml": "5.3.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "5.24.0",
"marked": "18.0.11",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -173,14 +170,13 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@versatiles/style": "5.13.1",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.9.1",
"eslint": "10.9.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
@@ -217,7 +213,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.68.0",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
-1
View File
@@ -27,7 +27,6 @@ const ALLOWED_LICENSES = new Set([
"0BSD",
"CC0-1.0",
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
+20 -29
View File
@@ -1,26 +1,13 @@
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 { Map, TileLayer } from "leaflet";
// Sets up a Leaflet map on the provided DOM element
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
export type LeafletModuleType = typeof import("leaflet");
export interface LeafletMapSetup {
map: Map;
leaflet: LeafletModuleType;
baseLayer: MapBaseLayer;
}
export const setupLeafletMap = async (
mapElement: HTMLElement,
initialView?: {
latitude: number;
longitude: number;
zoom?: number;
darkMode?: boolean;
}
): Promise<LeafletMapSetup> => {
initialView?: { latitude: number; longitude: number; zoom?: number }
): Promise<[Map, LeafletModuleType, TileLayer]> => {
if (!mapElement.parentNode) {
throw new Error("Cannot setup Leaflet map on disconnected element");
}
@@ -30,11 +17,7 @@ export const setupLeafletMap = async (
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
const map = Leaflet.map(mapElement);
const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet");
@@ -55,13 +38,21 @@ export const setupLeafletMap = async (
);
}
// The base layer adds itself: the vector layer only builds its MapLibre map
// once it is on the map, and that failing has to fall back to raster.
const baseLayer = await createBaseLayer(
Leaflet,
map,
initialView?.darkMode ?? false
);
const tileLayer = createTileLayer(Leaflet).addTo(map);
return { map, leaflet: Leaflet, baseLayer };
return [map, Leaflet, tileLayer];
};
const createTileLayer = (leaflet: LeafletModuleType): TileLayer =>
leaflet.tileLayer(
`https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}${
leaflet.Browser.retina ? "@2x.png" : ".png"
}`,
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: "abcd",
minZoom: 0,
maxZoom: 20,
}
);
-251
View File
@@ -1,251 +0,0 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap } from "leaflet";
import type { setRTLTextPlugin, StyleSpecification } from "maplibre-gl";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
// Shortbread vector tiles from the OpenStreetMap Foundation. Only their tile
// endpoint sends CORS headers, so the style, glyphs and sprites are ours to
// serve - see build-scripts/gulp/map-assets.js. The credit comes from the
// TileJSON rather than from here, deliberately: it follows whoever serves the
// tiles.
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// Fallback for browsers without WebGL2, which MapLibre needs even for raster,
// so it stays a Leaflet tile layer. Still CARTO, and temporarily so: OSM's
// raster blocks a browser that sends no Referer, and the only referrer a browser
// can send is its origin, which identifies a Nabu Casa installation.
const RASTER_TILE_URL = "https://basemaps.cartocdn.com/rastertiles/voyager";
const CARTO_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
'&copy; <a href="https://carto.com/attributions">CARTO</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, so a dashboard
// full of map cards loses its first ones for good - nothing frees a slot for
// MapLibre to reclaim. A transient loss does get restored, hence the grace.
const CONTEXT_RESTORE_GRACE = 2000;
// On the map, not the layer: only Leaflet tile layers report their own limits,
// and marker clustering throws without a maximum. The floor is 1 because at
// Leaflet zoom 0 the adapter drives MapLibre to -1, outside its range.
export const MAP_MIN_ZOOM = 1;
export const MAP_MAX_ZOOM = 20;
export interface MapBaseLayer {
// A no-op for raster, which has no dark variant and is inverted in CSS.
setDarkMode: (darkMode: boolean) => void;
}
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
webGL2Supported = Boolean(context);
// Contexts are scarce; the probe must not keep one.
context?.getExtension("WEBGL_lose_context")?.loseContext();
} catch {
webGL2Supported = false;
}
}
return webGL2Supported;
};
// Asset URLs are stored origin relative so they follow the instance's host, but
// MapLibre rejects a relative sprite URL. The glyph URL is left alone: URL
// encoding would mangle its {fontstack} and {range} placeholders.
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (typeof style.sprite === "string") {
style.sprite = new URL(style.sprite, location.href).href;
} else if (Array.isArray(style.sprite)) {
style.sprite = style.sprite.map((sprite) => ({
...sprite,
url: new URL(sprite.url, location.href).href,
}));
}
return style;
};
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
rtlTextPluginRequested = true;
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
() => {
// RTL labels stay reversed; everything else still renders.
}
);
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean
): Promise<MapBaseLayer | undefined> => {
let layer: ReturnType<typeof maplibreGL> | undefined;
try {
layer = createLayer({
style: await loadStyle(VECTOR_STYLES[darkMode ? "dark" : "light"]),
// Draws CJK, kana and hangul with a device font, which is why we ship a
// tenth of the glyph set. No referrer is set: the endpoint serves without
// one, and the only one a browser can send identifies the installation.
localIdeographFontFamily: "sans-serif",
});
// The plugin builds the MapLibre map in `onAdd`, so a refused context, a
// blocked worker or a rejected blob URL throws here. Keep it inside the
// guard or those lose the raster fallback.
layer.addTo(map);
} catch {
if (layer) {
try {
layer.remove();
} catch {
// May never have finished being added.
}
}
return undefined;
}
// Tracked apart because a failed request must roll back to what is displayed,
// not to the opposite of what it asked for - with several in flight those are
// different, and guessing wrong makes the next toggle a permanent no-op.
let appliedDarkMode = darkMode;
let requestedDarkMode = darkMode;
// Styles are fetched, so only the newest request may touch the map.
let latestRequest = 0;
let vector = true;
const glMap = layer.getMaplibreMap();
let fallbackTimeout: number | undefined;
let contextLost = false;
// Declared first, but only ever called once all three exist.
const handleVisibilityChange = () => {
if (contextLost) {
scheduleSwap();
}
};
const swapToRaster = () => {
vector = false;
document.removeEventListener("visibilitychange", handleVisibilityChange);
try {
layer.remove();
} catch {
// Nothing left to detach.
}
createRasterLayer(leaflet, map);
};
const scheduleSwap = () => {
clearTimeout(fallbackTimeout);
// Backgrounding also drops the context, and there it comes back on return.
// Running the clock then would make switching apps enough to lose vector.
if (!vector || document.hidden) {
return;
}
fallbackTimeout = window.setTimeout(swapToRaster, CONTEXT_RESTORE_GRACE);
};
glMap.on("webglcontextlost", () => {
contextLost = true;
scheduleSwap();
});
glMap.on("webglcontextrestored", () => {
contextLost = false;
clearTimeout(fallbackTimeout);
});
document.addEventListener("visibilitychange", handleVisibilityChange);
map.on("unload", () => {
// Otherwise the timer revives a map that is already gone.
clearTimeout(fallbackTimeout);
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
return {
setDarkMode: (newDarkMode: boolean) => {
if (!vector || newDarkMode === requestedDarkMode) {
return;
}
requestedDarkMode = newDarkMode;
const request = ++latestRequest;
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
.then((style) => {
if (request === latestRequest) {
appliedDarkMode = newDarkMode;
layer.getMaplibreMap()?.setStyle(style);
}
})
.catch(() => {
if (request === latestRequest) {
requestedDarkMode = appliedDarkMode;
}
});
},
};
};
const createRasterLayer = (
leaflet: LeafletModuleType,
map: LeafletMap
): MapBaseLayer => {
leaflet
.tileLayer(
// These are the old retina tablets, and CARTO serves @2x - sharp tiles at
// the same request count, where `detectRetina` would fetch a zoom deeper
// at four times as many.
`${RASTER_TILE_URL}/{z}/{x}/{y}${leaflet.Browser.retina ? "@2x" : ""}.png`,
{
attribution: CARTO_ATTRIBUTION,
maxZoom: MAP_MAX_ZOOM,
}
)
.addTo(map);
return { setDarkMode: () => undefined };
};
export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean
): Promise<MapBaseLayer> => {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
import("@maplibre/maplibre-gl-leaflet"),
import("maplibre-gl"),
]);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
map,
darkMode
);
} catch {
// No chunk, no vector map - but still a map.
}
if (vectorLayer) {
return vectorLayer;
}
}
return createRasterLayer(leaflet, map);
};
+1 -10
View File
@@ -10,18 +10,15 @@ const VIEW_PARAM = "more-info-view";
export interface MoreInfoUrlData {
entityId?: string;
view?: MoreInfoView;
hash: URLSearchParams;
}
export interface CreateMoreInfoUrlData {
entityId: string;
view: MoreInfoView;
hash?: URLSearchParams;
}
export const decodeMoreInfoUrl = (
search: SearchParamsSource,
hash = ""
search: SearchParamsSource
): MoreInfoUrlData => {
const params =
typeof search === "string"
@@ -35,9 +32,6 @@ export const decodeMoreInfoUrl = (
return {
entityId,
view: isMoreInfoView(view) ? view : undefined,
hash: new URLSearchParams(
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
),
};
};
@@ -48,9 +42,6 @@ export const createMoreInfoUrl = (
const url = new URL(base, window.location.origin);
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
url.searchParams.set(VIEW_PARAM, data.view);
if (!__DEMO__ && data.hash !== undefined) {
url.hash = data.hash.toString();
}
return `${url.pathname}${url.search}${url.hash}`;
};
+14 -43
View File
@@ -3,27 +3,6 @@ import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
const offsetFromCursor = (
cursorX: number,
dom: unknown,
viewW: number,
tipW: number
) => {
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
return Math.max(0, Math.min(x, viewW - tipW));
};
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
@@ -41,29 +20,21 @@ export const sideTooltipPosition: TooltipPositionCallback = (
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};
/**
* Offsets the tooltip horizontally from the cursor and keeps it level with it.
* For item-trigger tooltips where the cursor's row is what the tooltip shows.
*/
export const itemTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX, cursorY] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const x = offsetFromCursor(cursorX, dom, viewW, tipW);
const y = Math.max(0, Math.min(cursorY - tipH / 2, viewH - tipH));
return [x, y];
};
@@ -328,7 +328,6 @@ export class StateHistoryChartLine extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
onFractionDigits: (digits) => {
if (digits !== this._yAxisFractionDigits) {
this._yAxisFractionDigits = digits;
@@ -1,3 +1,4 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -12,7 +13,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -23,6 +24,7 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
const ROW_HEIGHT = 30;
const ROW_HEIGHT_INSIDE_LABELS = 64;
const GRID_BOTTOM = 30;
@customElement("state-history-chart-timeline")
@@ -41,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw each row's name above its bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -63,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _yWidth = 0;
private _width = 0;
private _resize = new ResizeController(this, {
skipInitial: true,
callback: (entries) => entries[0]?.contentRect.width,
});
private _chartTime: Date = new Date();
protected render() {
@@ -70,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
<ha-chart-base
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@@ -180,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
this._generateData();
}
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
const widthChanged = width !== this._width;
this._width = width;
if (
!this.hasUpdated ||
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("showNames") ||
changedProps.has("insideLabels") ||
changedProps.has("paddingYAxis") ||
changedProps.has("_yWidth")
changedProps.has("_yWidth") ||
widthChanged
) {
this._createOptions();
}
@@ -196,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
const narrow = this.narrow;
const showNames = this.chunked || this.showNames;
const maxInternalLabelWidth = narrow ? 105 : 185;
const labelWidth = showNames
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const insideLabels = this.insideLabels;
const labelWidth =
showNames && !insideLabels
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
// Keeps the plot aligned with the line charts sharing the y-axis padding.
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
// A zero width hides the labels instead of truncating them.
const insideLabelWidth = this._width
? Math.max(0, this._width - plotPadding - labelMargin)
: undefined;
this._chartOptions = {
xAxis: {
type: "time",
@@ -227,41 +254,56 @@ export class StateHistoryChartTimeline extends LitElement {
axisLine: {
show: false,
},
axisLabel: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
axisLabel: insideLabels
? {
show: showNames,
inside: true,
margin: 0,
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
align: rtl ? "right" : "left",
verticalAlign: "bottom",
width: insideLabelWidth,
overflow: "truncate",
formatter: (id: string) =>
(this._chartData.find((d) => d.id === id)?.name as string) ??
"",
hideOverlap: true,
}
return label;
},
hideOverlap: true,
},
: {
show: showNames,
width: labelWidth,
overflow: "truncate",
margin: labelMargin,
formatter: (id: string) => {
const label = this._chartData.find((d) => d.id === id)
?.name as string;
const width = label
? Math.min(
measureTextWidth(label, 12) + labelMargin,
maxInternalLabelWidth
)
: 0;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
hideOverlap: true,
},
},
grid: {
top: 10,
top: insideLabels ? 20 : 10,
bottom: GRID_BOTTOM,
left: rtl ? 1 : labelWidth,
right: rtl ? labelWidth : 1,
left: rtl ? 1 : plotPadding,
right: rtl ? plotPadding : 1,
},
tooltip: {
renderMode: "html",
position: itemTooltipPosition,
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -401,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
}
static styles = css`
:host {
display: block;
}
ha-chart-base {
--chart-max-height: none;
}
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
/** Draw timeline row names above their bar instead of in a label column. */
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
public insideLabels = false;
@property({ attribute: "click-for-more-info", type: Boolean })
public clickForMoreInfo = true;
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
.startTime=${this._computedStartTime}
.endTime=${this._computedEndTime}
.showNames=${this.showNames}
.insideLabels=${this.insideLabels}
.names=${this.names}
.narrow=${this.narrow}
.chunked=${this.virtualize}
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
padding-top: 8px;
}
/* Names inside the plot sit close to the chart above them, so the groups
need more room between them to stay apart. */
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
margin-top: var(--ha-space-8);
}
.entry-container:hover {
z-index: 1;
}
-1
View File
@@ -446,7 +446,6 @@ export class StatisticsChart extends LitElement {
...createYAxisPrecisionBounds({
min: this._clampYAxis(minYAxis),
max: this._clampYAxis(maxYAxis),
unit: this.unit,
// Bar charts stay anchored at 0, so precision must reflect the
// 0-based range that is actually rendered.
includeZero: !yAxisScale,
+5 -32
View File
@@ -18,11 +18,6 @@ const SPLIT_NUMBER = 5;
// headroom out to a full tick, and leaves the rest where they are.
const GAP_FRACTION_OF_SPAN = 0.02;
// A percentage has a real ceiling the way zero is a real floor, so the gap must
// not push the axis past it. Not every `%` sensor is bounded — power factor is
// signed and can read over 100 — so this only applies while the data stays under.
const PERCENT_MAX = 100;
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range, by asking ECharts for the same tick interval it will
// render. This matches the precision it actually draws, so labels are neither
@@ -86,9 +81,6 @@ export function createYAxisPrecisionBounds(options: {
// Such an axis also gets no gap: pushing it below zero would defeat the zero
// anchoring and leave the bars floating above the axis.
includeZero?: boolean;
// Used to recognise a bounded quantity, so the gap cannot widen the axis past
// a limit the data itself never crosses.
unit?: string;
onFractionDigits: (digits: number) => void;
}): {
min: (values: YAxisExtentValues) => number | undefined;
@@ -96,8 +88,7 @@ export function createYAxisPrecisionBounds(options: {
boundaryGap: [number, number];
splitNumber: number;
} {
const { min, max, includeZero, unit, onFractionDigits } = options;
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
const { min, max, includeZero, onFractionDigits } = options;
const resolveBounds = (values: YAxisExtentValues) => {
const resolvedMin = resolveYAxisBound(min, values);
@@ -114,36 +105,18 @@ export function createYAxisPrecisionBounds(options: {
resolvedMin !== undefined,
resolvedMax !== undefined,
]);
// The expansion is a fraction of the magnitude, so a constant series near
// the ceiling would otherwise overshoot it too.
const flatMax =
naturalMax !== undefined && values.max <= naturalMax
? Math.min(flat.max, naturalMax)
: flat.max;
return {
min: resolvedMin ?? flat.min,
max: resolvedMax ?? flatMax,
max: resolvedMax ?? flat.max,
gap: 0,
};
}
const gap = (values.max - values.min) * GAP_FRACTION_OF_SPAN;
// Never let the gap carry a series past a boundary it does not itself cross.
const floor = values.min >= 0 ? 0 : undefined;
const ceiling =
naturalMax !== undefined && values.max <= naturalMax
? naturalMax
: values.max <= 0
? 0
: undefined;
// Never let the gap carry a single-signed series across zero.
return {
min:
resolvedMin ??
(floor !== undefined && values.min - floor < gap ? floor : undefined),
min: resolvedMin ?? (values.min >= 0 && values.min < gap ? 0 : undefined),
max:
resolvedMax ??
(ceiling !== undefined && ceiling - values.max < gap
? ceiling
: undefined),
resolvedMax ?? (values.max <= 0 && -values.max < gap ? 0 : undefined),
gap,
};
};
@@ -12,6 +12,7 @@ import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
deviceAutomationsEqual,
deviceAutomationsSimilar,
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -179,15 +180,12 @@ export abstract class HaDeviceAutomationPicker<
(a, idx) => value === `${a.device_id}_${idx}`
);
const described =
automation ?? (this.value?.domain ? this.value : undefined);
const text = described
const text = automation
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
described
automation
)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
@@ -197,24 +195,29 @@ export abstract class HaDeviceAutomationPicker<
};
private async _updateDeviceInfo() {
// Asking a removed device for its automations fails rather than returning
// an empty list.
this._automations = this.deviceId
? (
await this._fetchDeviceAutomations(
this.hass.callWS,
this.deviceId
).catch(() => [] as T[])
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
).sort(sortDeviceAutomations)
: // No device, clear the list of automations
[];
// If there is no value, or if we have changed the device ID, reset the value.
// If there is no value, or if we have changed the device ID, reset the
// value. When the device changed (for example after replacing a removed
// device), try to keep the same automation type/subtype on the new device
// before falling back to the first available automation.
if (!this.value || this.value.device_id !== this.deviceId) {
const equivalent =
this.value && this.deviceId
? this._automations.find((automation) =>
deviceAutomationsSimilar(automation, this.value!)
)
: undefined;
this._setValue(
this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId)
equivalent ||
(this._automations.length
? this._automations[0]
: this._createNoAutomation(this.deviceId))
);
}
this._renderEmpty = true;
+4 -19
View File
@@ -105,14 +105,6 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
/**
* The split devices that can actually replace the current value, when the
* caller knows better than this picker. Narrows the replacement candidates,
* so the user is only asked to choose when there is a real choice.
*/
@property({ attribute: false })
public replacementDeviceIds?: string[];
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@@ -196,8 +188,7 @@ export class HaDevicePicker extends LitElement {
value: string | undefined,
_devices: HomeAssistant["devices"],
compositeSplits: DeviceCompositeSplits | undefined,
items: (DevicePickerItem | string)[],
replacementDeviceIds: string[] | undefined
items: (DevicePickerItem | string)[]
) => {
if (!value || !compositeSplits || this.hass.devices[value]) {
return undefined;
@@ -213,11 +204,7 @@ export class HaDevicePicker extends LitElement {
.filter((item): item is DevicePickerItem => typeof item !== "string")
.map((item) => item.id)
);
const candidates = split.split_ids.filter(
(id) =>
selectableIds.has(id) &&
(!replacementDeviceIds || replacementDeviceIds.includes(id))
);
const candidates = split.split_ids.filter((id) => selectableIds.has(id));
return { candidates, primaryId: split.primary_id };
}
);
@@ -413,8 +400,7 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems(),
this.replacementDeviceIds
this._getItems()
)
: undefined;
@@ -521,8 +507,7 @@ export class HaDevicePicker extends LitElement {
this.value,
this.hass.devices,
this._compositeSplits,
this._getItems(),
this.replacementDeviceIds
this._getItems()
);
if (!replacement?.candidates.length) {
return;
-4
View File
@@ -95,10 +95,6 @@ export class HaDrawer extends LitElement {
}
private _handleAfterHide(ev: Event) {
// Ignore wa-after-hide from nested Web Awesome components (e.g. tooltips)
if (ev.target !== ev.currentTarget) {
return;
}
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
@@ -1,13 +1,6 @@
import {
getSelectorInitialValue,
getSelectorInitialValueOrUndefined,
} from "./get-selector-initial-value";
import type { Selector } from "../../data/selector";
import type { HaFormData, HaFormSchema } from "./types";
interface ComputeInitialHaFormDataOptions {
skipUnsupportedSelectors?: boolean;
}
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
@@ -25,8 +18,7 @@ const setDefaultValue = (
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[],
options?: ComputeInitialHaFormDataOptions
schema: HaFormSchema[] | readonly HaFormSchema[]
): Record<string, any> => {
const data = {};
schema.forEach((field) => {
@@ -41,7 +33,7 @@ export const computeInitialHaFormData = (
} else if ("default" in field) {
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema, options);
const expandableData = computeInitialHaFormData(field.schema);
if (field.required || Object.keys(expandableData).length) {
// Only add expandable data if it's required or any of its children have initial values.
data[field.name] = expandableData;
@@ -70,11 +62,104 @@ export const computeInitialHaFormData = (
seconds: 0,
};
} else if ("selector" in field) {
const initialValue = options?.skipUnsupportedSelectors
? getSelectorInitialValueOrUndefined(field.selector)
: getSelectorInitialValue(field.selector);
if (initialValue !== undefined) {
data[field.name] = initialValue;
const selector: Selector = field.selector;
if ("device" in selector) {
data[field.name] = selector.device?.multiple ? [] : "";
} else if ("entity" in selector) {
data[field.name] = selector.entity?.multiple ? [] : "";
} else if ("area" in selector) {
data[field.name] = selector.area?.multiple ? [] : "";
} else if ("label" in selector) {
data[field.name] = selector.label?.multiple ? [] : "";
} else if ("boolean" in selector) {
data[field.name] = false;
} else if (
"addon" in selector ||
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"serial_port" in selector ||
"template" in selector ||
"text" in selector ||
"theme" in selector ||
"object" in selector
) {
data[field.name] = "";
} else if ("number" in selector) {
data[field.name] = selector.number?.min ?? 0;
} else if ("select" in selector) {
if (selector.select?.options.length) {
const firstOption = selector.select.options[0];
const val =
typeof firstOption === "string" ? firstOption : firstOption.value;
data[field.name] = selector.select.multiple ? [val] : val;
}
} else if ("country" in selector) {
if (selector.country?.countries?.length) {
data[field.name] = selector.country.countries[0];
}
} else if ("language" in selector) {
if (selector.language?.languages?.length) {
data[field.name] = selector.language.languages[0];
}
} else if ("duration" in selector) {
data[field.name] = {
hours: 0,
minutes: 0,
seconds: 0,
};
} else if ("time" in selector) {
data[field.name] = "00:00:00";
} else if ("date" in selector || "datetime" in selector) {
const now = new Date().toISOString().slice(0, 10);
data[field.name] = `${now}T00:00:00`;
} else if ("color_rgb" in selector) {
data[field.name] = [0, 0, 0];
} else if ("color_temp" in selector) {
data[field.name] = selector.color_temp?.min_mireds ?? 153;
} else if (
"action" in selector ||
"trigger" in selector ||
"condition" in selector
) {
data[field.name] = [];
} else if ("media" in selector || "target" in selector) {
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else if ("numeric_threshold" in selector) {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
data[field.name] =
type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`
);
}
}
});
@@ -1,89 +1,25 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorFallbackValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_FALLBACK_VALUES = {
action: undefined,
addon: undefined,
automation_behavior: undefined,
app: undefined,
area: undefined,
areas_display: undefined,
attribute: undefined,
assist_pipeline: undefined,
boolean: () => false,
choose: undefined,
color_rgb: undefined,
condition: undefined,
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: undefined,
date: undefined,
datetime: undefined,
device: undefined,
device_class: undefined,
duration: undefined,
entity: undefined,
entity_name: undefined,
statistic: undefined,
file: undefined,
floor: undefined,
label: undefined,
language: undefined,
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: undefined,
object: undefined,
period: undefined,
qr_code: undefined,
select: undefined,
selector: undefined,
serial_port: undefined,
state: undefined,
backup_location: undefined,
stt: undefined,
target: undefined,
template: undefined,
text: undefined,
time: undefined,
icon: undefined,
media: undefined,
theme: undefined,
timezone: undefined,
button_toggle: undefined,
trigger: undefined,
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
},
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorFallbackValues;
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const fallbackValue = SELECTOR_FALLBACK_VALUES[type];
return fallbackValue?.(selector as never);
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
@@ -1,133 +0,0 @@
import type {
Selector,
SelectorForType,
SelectorType,
} from "../../data/selector";
type SelectorInitialValues = {
[T in SelectorType]: ((selector: SelectorForType<T>) => unknown) | undefined;
};
const SELECTOR_INITIAL_VALUES = {
action: () => [],
addon: () => "",
automation_behavior: undefined,
app: undefined,
area: (selector) => (selector.area?.multiple ? [] : ""),
areas_display: undefined,
attribute: () => "",
assist_pipeline: undefined,
boolean: () => false,
choose: (selector) => {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
return {};
}
const childValue = getSelectorInitialValueOrUndefined(
selector.choose.choices[firstChoice].selector
);
return childValue === undefined
? { active_choice: firstChoice }
: { active_choice: firstChoice, [firstChoice]: childValue };
},
color_rgb: () => [0, 0, 0],
condition: () => [],
config_entry: undefined,
conversation_agent: undefined,
constant: (selector) => selector.constant?.value,
country: (selector) => selector.country?.countries?.[0],
date: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
datetime: () => `${new Date().toISOString().slice(0, 10)}T00:00:00`,
device: (selector) => (selector.device?.multiple ? [] : ""),
device_class: (selector) =>
selector.device_class?.multiple ? [] : undefined,
duration: () => ({
hours: 0,
minutes: 0,
seconds: 0,
}),
entity: (selector) => (selector.entity?.multiple ? [] : ""),
entity_name: undefined,
statistic: undefined,
file: () => "",
floor: undefined,
label: (selector) => (selector.label?.multiple ? [] : ""),
language: (selector) => selector.language?.languages?.[0],
navigation: undefined,
number: (selector) => selector.number?.min ?? 0,
numeric_threshold: (selector) => {
const mode = selector.numeric_threshold?.mode ?? "crossed";
const type = mode === "changed" ? "any" : "above";
return type === "any"
? { type }
: {
type,
value: {
number: selector.numeric_threshold?.number?.min ?? 0,
active_choice: "number",
},
};
},
object: (selector) => (selector.object?.multiple ? [] : ""),
period: undefined,
qr_code: undefined,
select: (selector) => {
const select = selector.select;
if (!select?.options.length) {
return undefined;
}
const firstOption = select.options[0];
const value =
typeof firstOption === "string" ? firstOption : firstOption.value;
return select.multiple ? [value] : value;
},
selector: undefined,
serial_port: () => "",
state: (selector) => (selector.state?.multiple ? [] : ""),
backup_location: undefined,
stt: undefined,
target: () => ({}),
template: () => "",
text: (selector) => (selector.text?.multiple ? [] : ""),
time: () => "00:00:00",
icon: () => "",
media: () => ({}),
theme: () => "",
timezone: undefined,
button_toggle: undefined,
trigger: () => [],
tts: undefined,
tts_voice: undefined,
location: undefined,
color_temp: (selector) => selector.color_temp?.min_mireds ?? 153,
ui_action: undefined,
ui_clock_date_format: undefined,
ui_color: undefined,
ui_state_content: undefined,
ui_time_format: undefined,
} satisfies SelectorInitialValues;
export const getSelectorInitialValueOrUndefined = (
selector: Selector
): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
return SELECTOR_INITIAL_VALUES[type]?.(selector as never);
};
export const getSelectorInitialValue = (selector: Selector): unknown => {
const type = Object.keys(selector)[0] as SelectorType;
const initialValue = SELECTOR_INITIAL_VALUES[type];
if (!initialValue) {
throw new Error(`Selector ${type} not supported in initial form data`);
}
return initialValue(selector as never);
};
@@ -67,16 +67,15 @@ export class HaNumberSelector extends LitElement {
}
}
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key.
// Leaving inputmode unset on a number input gives the "Numbers and
// Punctuation" keyboard there, which does include a minus. Other platforms
// include a minus on their number keypads, so restrict this workaround to
// Safari/WebKit and only when the selector allows negatives: either an
// explicit negative min, or no min at all (e.g. the numeric threshold
// selector used by the power triggers).
const useSafariNegativeKeyboard =
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
// so negatives can only be typed with the full "text" keyboard. Other
// platforms include a minus on their number keypads, so restrict this
// workaround to Safari/WebKit and only when the selector allows negatives
// (e.g. numeric_state triggers/conditions).
const useTextInputMode =
isSafari &&
(this.selector.number?.min === undefined || this.selector.number.min < 0);
this.selector.number?.min !== undefined &&
this.selector.number.min < 0;
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
@@ -113,8 +112,8 @@ export class HaNumberSelector extends LitElement {
}
<ha-input
.inputmode=${
useSafariNegativeKeyboard
? undefined
useTextInputMode
? "text"
: this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
@@ -13,7 +13,6 @@ import type { ObjectSelector } from "../../data/selector";
import { formatSelectorValue } from "../../data/selector/format_selector_value";
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
import { computeInitialHaFormData } from "../ha-form/compute-initial-ha-form-data";
import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
import "../ha-md-list";
@@ -238,14 +237,10 @@ export class HaObjectSelector extends LitElement {
private async _addItem(ev) {
ev.stopPropagation();
const schema = this._schema(this.selector);
const newItem = await showFormDialog(this, {
title: this.hass.localize("ui.common.add"),
schema,
data: computeInitialHaFormData(schema, {
skipUnsupportedSelectors: true,
}),
schema: this._schema(this.selector),
data: {},
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
@@ -298,7 +293,7 @@ export class HaObjectSelector extends LitElement {
const index = ev.currentTarget.index;
if (!this.selector.object!.multiple) {
fireEvent(this, "value-changed", { value: "" });
fireEvent(this, "value-changed", { value: undefined });
return;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { Selector, SelectorType } from "../../data/selector";
import type { Selector } from "../../data/selector";
import {
handleLegacyDeviceSelector,
handleLegacyEntitySelector,
@@ -70,7 +70,7 @@ const LOAD_ELEMENTS = {
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
ui_time_format: () => import("./ha-selector-ui-time-format"),
} satisfies Record<SelectorType, () => Promise<unknown>>;
};
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
+2 -32
View File
@@ -25,7 +25,6 @@ import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import type { MapBaseLayer } from "../../common/map/base-layer";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityLocation } from "../../common/entity/get_entity_location";
@@ -161,8 +160,6 @@ export class HaMap extends ReactiveElement {
private Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _resizeObserver?: ResizeObserver;
private _mapItems: (Marker | Circle)[] = [];
@@ -214,7 +211,6 @@ export class HaMap extends ReactiveElement {
this.leafletMap.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
}
// the control went away with the map, so don't hold on to it
@@ -312,7 +308,6 @@ export class HaMap extends ReactiveElement {
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
this._baseLayer?.setDarkMode(this._darkMode);
}
private _loading = false;
@@ -327,23 +322,11 @@ export class HaMap extends ReactiveElement {
}
this._loading = true;
try {
const setup = await setupLeafletMap(map, {
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
latitude: this._config?.latitude ?? 52.3731339,
longitude: this._config?.longitude ?? 4.8903147,
zoom: this.zoom,
darkMode: this._darkMode,
});
// Setting up fetches a style, so the element can be gone by now.
// `disconnectedCallback` had no map to tear down, and keeping this one
// would leave a live map - and its WebGL context - on a detached host,
// and its container too initialized to set up again on reconnect.
if (!this.isConnected) {
setup.map.remove();
return;
}
this.leafletMap = setup.map;
this.Leaflet = setup.leaflet;
this._baseLayer = setup.baseLayer;
this._updateMapStyle();
this.leafletMap.on("click", (ev) => {
if (this._clickCount === 0) {
@@ -908,22 +891,9 @@ export class HaMap extends ReactiveElement {
cursor: -moz-grabbing;
cursor: -webkit-grabbing;
}
/* Only the raster fallback is inverted for dark mode, the vector style
ships its own dark cartography. */
.leaflet-tile-pane .leaflet-tile {
.leaflet-tile-pane {
filter: var(--map-filter);
}
/* The only two rules the MapLibre canvas needs from its stylesheet, the
rest of it styles controls and popups we do not render. */
.maplibregl-map {
position: relative;
overflow: hidden;
}
.maplibregl-canvas {
position: absolute;
top: 0;
left: 0;
}
.dark .leaflet-bar a {
background-color: #1c1c1c;
color: #ffffff;
-34
View File
@@ -480,40 +480,6 @@ export const migrateAutomationConfig = <
return config;
};
// The fields of the row holding a trigger, condition or action, as opposed to
// the configuration of its type. The UI editors of some types build their value
// from scratch, so these have to be carried over explicitly.
export const TRIGGER_ROW_CONFIG_KEYS = [
"alias",
"note",
"id",
"enabled",
"variables",
] as const;
export const CONDITION_ROW_CONFIG_KEYS = ["alias", "note", "enabled"] as const;
export const ACTION_ROW_CONFIG_KEYS = [
"alias",
"note",
"enabled",
"continue_on_error",
] as const;
export const pickRowConfig = <T extends object>(
row: T,
keys: readonly string[]
): Partial<T> => {
const source = row as Record<string, unknown>;
const config: Record<string, unknown> = {};
for (const key of keys) {
if (key in source) {
config[key] = source[key];
}
}
return config as Partial<T>;
};
export const migrateAutomationTrigger = (
trigger: Trigger | Trigger[],
report?: AutomationMigrationReport
+15 -58
View File
@@ -182,69 +182,26 @@ export const deviceAutomationEditorMode = (
: "unknown-device";
};
const deviceAutomationsSameType = (a: DeviceAutomation, b: DeviceAutomation) =>
deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => Object.is(a[property], b[property]));
// An entity can be referenced by its registry id or by its entity id, and the
// two sides do not have to agree.
const deviceAutomationsSameEntity = (
entityRegistry: EntityRegistryEntry[],
// Like deviceAutomationsEqual, but ignores device_id and entity_id so an
// automation can be matched to the equivalent one on a different device (for
// example when a referenced device was replaced by a split device).
export const deviceAutomationsSimilar = (
a: DeviceAutomation,
b: DeviceAutomation
) => {
if (!a.entity_id && !b.entity_id) {
return true;
}
if (!a.entity_id || !b.entity_id) {
if (typeof a !== typeof b) {
return false;
}
return (
a.entity_id === b.entity_id ||
compareEntityIdWithEntityRegId(entityRegistry, a.entity_id, b.entity_id)
);
};
// A device exposes the same automation type once per entity, so the same type
// on another entity is a different automation, not an equivalent one.
export const findEquivalentDeviceAutomation = <T extends DeviceAutomation>(
entityRegistry: EntityRegistryEntry[],
automations: T[],
automation: DeviceAutomation
): T | undefined =>
automations.find(
(candidate) =>
deviceAutomationsSameType(candidate, automation) &&
deviceAutomationsSameEntity(entityRegistry, candidate, automation)
);
// Among the split devices that replaced a removed device, the ones that offer
// the given automation. Nothing in the registry says which of them took it over,
// so each candidate has to be asked.
export const fetchReplacementDevices = async <T extends DeviceAutomation>(
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
automation: DeviceAutomation,
compositeSplits: DeviceCompositeSplits,
fetchDeviceAutomations: (callWS: CallWS, deviceId: string) => Promise<T[]>
): Promise<string[]> => {
const candidates =
compositeSplits[automation.device_id]?.split_ids.filter(
(id) => id in hass.devices
) ?? [];
const automationsPerCandidate = await Promise.all(
candidates.map((id) =>
fetchDeviceAutomations(hass.callWS, id).catch(() => [] as T[])
)
);
return candidates.filter((_id, index) =>
findEquivalentDeviceAutomation(
entityRegistry,
automationsPerCandidate[index],
automation
)
);
return deviceAutomationIdentifiers
.filter((property) => property !== "device_id" && property !== "entity_id")
.every((property) => {
const inA = property in a;
const inB = property in b;
if (!inA && !inB) {
return true;
}
return Object.is(a[property], b[property]);
});
};
const compareEntityIdWithEntityRegId = (
+5 -15
View File
@@ -1553,8 +1553,7 @@ export const formatConsumptionShort = (
hass: HomeAssistant,
consumption: number | null,
unit: string,
targetUnit?: string,
displayPrecision?: number
targetUnit?: string
): string => {
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
let pickedUnit = unit;
@@ -1584,19 +1583,10 @@ export const formatConsumptionShort = (
pickedUnit = units[unitIndex];
}
return (
formatNumber(
val,
hass.locale,
displayPrecision !== undefined && pickedUnit === unit
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}
) +
formatNumber(val, hass.locale, {
maximumFractionDigits:
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
}) +
" " +
pickedUnit
);
+3
View File
@@ -50,6 +50,9 @@ export const DOMAIN_ATTRIBUTES_UNITS = {
azimuth: "°",
elevation: "°",
},
vacuum: {
battery_level: "%",
},
valve: {
current_position: "%",
},
+4 -2
View File
@@ -40,7 +40,8 @@ export const searchPlaces = (
limit ? `&limit=${limit}` : ""
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
hass.locale.language
}&[email protected]`
}&[email protected]`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
@@ -58,7 +59,8 @@ export const reverseGeocode = (
location[1]
}&accept-language=${hass.locale.language}&zoom=${
zoom ?? 18
}&format=jsonv2&[email protected]`
}&format=jsonv2&[email protected]`,
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
).then((res) => {
if (res.ok) {
return res.json();
-14
View File
@@ -88,20 +88,6 @@ export type Selector =
| UiTimeFormatSelector
| BackupLocationSelector;
type KeysOfUnion<T> = T extends T ? keyof T : never;
export type SelectorType = KeysOfUnion<Selector>;
type UnionMemberWithKey<U, K extends PropertyKey> = U extends unknown
? K extends keyof U
? U
: never
: never;
export type SelectorForType<T extends SelectorType> = UnionMemberWithKey<
Selector,
T
>;
export interface ActionSelector {
action: {
optionsInSidebar?: boolean;
+3 -8
View File
@@ -230,13 +230,13 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (!this.isConnected || !this._open || this._params !== expectedParams) {
if (!this._open || this._params !== expectedParams) {
return;
}
await this._waitForSelectorElements();
if (!this.isConnected || !this._open || this._params !== expectedParams) {
if (!this._open || this._params !== expectedParams) {
return;
}
@@ -250,12 +250,7 @@ export class DialogForm
): Promise<void> {
await this._afterFormRender();
if (
!this.isConnected ||
!this._open ||
this._params !== expectedParams ||
!this._dialog
) {
if (!this._open || this._params !== expectedParams || !this._dialog) {
return;
}
@@ -116,7 +116,10 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
></ha-vacuum-segment-area-mapper>
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._submitting}>
<ha-button
@click=${this._save}
.disabled=${!this._dirtyState?.isDirty || this._submitting}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
-9
View File
@@ -1,9 +0,0 @@
import { createContext } from "@lit/context";
export interface MoreInfoContext {
hash: URLSearchParams;
setHashParam: (key: string, value?: string) => void;
}
export const moreInfoContext =
createContext<MoreInfoContext>("more-info-context");
@@ -33,7 +33,6 @@ import type {
WeatherEntity,
} from "../../../data/weather";
import {
getDefaultForecastType,
getForecast,
getSecondaryWeatherAttribute,
getSupportedForecastTypes,
@@ -49,16 +48,11 @@ import type {
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { moreInfoContext, type MoreInfoContext } from "../context";
@customElement("more-info-weather")
class MoreInfoWeather extends LitElement {
@property({ attribute: false }) public stateObj?: WeatherEntity;
@state()
@consume({ context: moreInfoContext, subscribe: true })
private _moreInfoContext?: MoreInfoContext;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
@@ -82,7 +76,9 @@ class MoreInfoWeather extends LitElement {
@state() private _forecastType?: ModernForecastType;
@state() private _subscribed?: Promise<() => void>;
private _subscribed?: Promise<() => void>;
private _subscribedTo?: string;
private _dragScrollController = new DragScrollController(this, {
selector: ".forecast",
@@ -94,24 +90,30 @@ class MoreInfoWeather extends LitElement {
this._subscribed.then((unsub) => unsub());
this._subscribed = undefined;
}
this._subscribedTo = undefined;
this._forecastEvent = undefined;
}
private async _subscribeForecastEvents() {
this._unsubscribeForecastEvents();
if (
!this.isConnected ||
!this._connection ||
!this.stateObj ||
!this._forecastType
) {
private _updateForecastSubscription() {
const stateObj = this.stateObj;
const forecastType = this._forecastType;
if (!this.isConnected || !this._connection || !stateObj || !forecastType) {
this._unsubscribeForecastEvents();
return;
}
const target = `${stateObj.entity_id}-${forecastType}`;
if (target === this._subscribedTo) {
return;
}
this._unsubscribeForecastEvents();
this._subscribedTo = target;
this._subscribed = subscribeForecast(
this._connection.connection,
this.stateObj.entity_id,
this._forecastType,
stateObj.entity_id,
forecastType,
(event) => {
this._forecastEvent = event;
}
@@ -121,7 +123,7 @@ class MoreInfoWeather extends LitElement {
public connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._subscribeForecastEvents();
this._updateForecastSubscription();
}
}
@@ -133,37 +135,9 @@ class MoreInfoWeather extends LitElement {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
(changedProps.has("stateObj") ||
changedProps.has("_moreInfoContext") ||
!this._subscribed) &&
this.stateObj
) {
const oldState = changedProps.get("stateObj") as
WeatherEntity | undefined;
if (
oldState?.entity_id !== this.stateObj?.entity_id ||
changedProps.has("_moreInfoContext") ||
!this._subscribed
) {
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
const requestedForecastType =
this._moreInfoContext?.hash.get("forecast");
const selectedForecastType =
supportedForecastTypes.find(
(forecastType) => forecastType === requestedForecastType
) ?? getDefaultForecastType(this.stateObj);
if (selectedForecastType !== requestedForecastType) {
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
}
if (this._forecastType !== selectedForecastType || !this._subscribed) {
this._forecastType = selectedForecastType;
this._subscribeForecastEvents();
}
}
} else if (changedProps.has("_forecastType")) {
this._subscribeForecastEvents();
}
this._forecastType = this._selectedForecastType();
this._updateForecastSubscription();
}
protected updated(_changedProps: PropertyValues<this>): void {
@@ -184,6 +158,16 @@ class MoreInfoWeather extends LitElement {
getSupportedForecastTypes(stateObj)
);
private _selectedForecastType(): ModernForecastType | undefined {
if (!this.stateObj) {
return undefined;
}
const supported = this._supportedForecasts(this.stateObj);
return (
supported.find((type) => type === this._forecastType) ?? supported[0]
);
}
private _groupForecastByDay = memoizeOne((forecast: ForecastAttribute[]) => {
if (!forecast) return [];
@@ -536,14 +520,7 @@ class MoreInfoWeather extends LitElement {
private _handleForecastTypeChanged(
ev: HASSDomEvent<{ name: ModernForecastType }>
): void {
if (
!this.stateObj ||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
) {
return;
}
this._forecastType = ev.detail.name;
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
}
static get styles(): CSSResultGroup {
+54 -208
View File
@@ -1,11 +1,9 @@
import { mdiCheck, mdiContentCopy, mdiDevices, mdiTextureBox } from "@mdi/js";
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
@@ -13,16 +11,8 @@ import { getEntityContext } from "../../common/entity/context/get_entity_context
import checkValidDate from "../../common/datetime/check_valid_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import "../../components/ha-attribute-value";
import "../../components/ha-floor-icon";
import "../../components/ha-icon";
import "../../components/ha-icon-next";
import "../../components/ha-label";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import { copyToClipboard } from "../../common/util/copy-clipboard";
import type { LocalizeKeys } from "../../common/translations/localize";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
@@ -35,8 +25,6 @@ import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
import { stringCompare } from "../../common/string/compare";
import { brandsUrl } from "../../util/brands-url";
import { showToast } from "../../util/toast";
interface DetailsViewParams {
entityId: string;
@@ -45,10 +33,7 @@ interface DetailsViewParams {
interface DetailEntry {
translationKey: LocalizeKeys;
value: string;
displayValue?: TemplateResult;
href?: string;
icon?: TemplateResult;
copyable?: boolean;
}
@customElement("ha-more-info-details")
@@ -67,17 +52,6 @@ class HaMoreInfoDetails extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _copiedValue?: string;
private _copyFeedbackTimeout?: number;
public disconnectedCallback(): void {
super.disconnectedCallback();
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = undefined;
this._copiedValue = undefined;
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("entry") && this.entry) {
@@ -119,12 +93,12 @@ class HaMoreInfoDetails extends LitElement {
? this.hass.localize(`component.${this.entry.platform}.title`) ||
this.entry.platform
: undefined;
const labels =
this.entry?.labels.map((labelId) => ({
id: labelId,
entry: this._labels?.find((label) => label.label_id === labelId),
})) ?? [];
const labelNames = labels.map(({ id, entry }) => entry?.name ?? id);
const labelNames =
this.entry?.labels.map(
(labelId) =>
this._labels?.find((label) => label.label_id === labelId)?.name ??
labelId
) ?? [];
const contextEntries: DetailEntry[] = [];
if (floor && floorName) {
@@ -132,7 +106,6 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.dialogs.more_info_control.floor",
value: floorName,
href: "/config/areas/dashboard",
icon: html`<ha-floor-icon slot="end" .floor=${floor}></ha-floor-icon>`,
});
}
if (area && areaName) {
@@ -140,9 +113,6 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.area",
value: areaName,
href: `/config/areas/area/${area.area_id}`,
icon: area.icon
? html`<ha-icon slot="end" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon slot="end" .path=${mdiTextureBox}></ha-svg-icon>`,
});
}
if (device && deviceName) {
@@ -150,7 +120,6 @@ class HaMoreInfoDetails extends LitElement {
translationKey: "ui.components.related-items.device",
value: deviceName,
href: `/config/devices/device/${device.id}`,
icon: html`<ha-svg-icon slot="end" .path=${mdiDevices}></ha-svg-icon>`,
});
}
if (this.entry?.platform && integrationName) {
@@ -160,63 +129,31 @@ class HaMoreInfoDetails extends LitElement {
href: this.entry.config_entry_id
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
: undefined,
icon: html`<img
slot="end"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: this.entry.platform,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>`,
});
}
contextEntries.push(
const entityEntries: DetailEntry[] = [
{
translationKey: "ui.dialogs.more_info_control.entity_id",
value: this.params.entityId,
copyable: true,
},
{
translationKey: "ui.dialogs.more_info_control.labels",
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
displayValue: labels.length
? html`<div class="labels">
${labels.map(
({ id, entry }) => html`
<ha-label
class="text-ellipsis"
.color=${entry?.color ?? undefined}
.description=${entry?.description ?? undefined}
>
${
entry?.icon
? html`<ha-icon
slot="icon"
.icon=${entry.icon}
></ha-icon>`
: nothing
}
${entry?.name ?? id}
</ha-label>
`
)}
</div>`
: undefined,
}
);
},
];
const yamlData = {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
...(contextEntries.length
? {
context: {
...(floorName ? { floor: floorName } : {}),
...(areaName ? { area: areaName } : {}),
...(deviceName ? { device: deviceName } : {}),
...(integrationName ? { integration: integrationName } : {}),
},
}
: {}),
entity: {
entity_id: this.params.entityId,
labels: labelNames,
},
@@ -234,9 +171,17 @@ class HaMoreInfoDetails extends LitElement {
in-dialog
></ha-yaml-editor>`
: html`
<ha-grouped-list>
${this._renderEntries(contextEntries)}
</ha-grouped-list>
${
contextEntries.length
? html`<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.context"
)}
>
${this._renderEntries(contextEntries)}
</ha-grouped-list>`
: nothing
}
<ha-grouped-list
.header=${this.hass.localize(
@@ -246,6 +191,14 @@ class HaMoreInfoDetails extends LitElement {
${this._renderEntries(stateEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.entity"
)}
>
${this._renderEntries(entityEntries)}
</ha-grouped-list>
<ha-grouped-list
.header=${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
@@ -331,72 +284,17 @@ class HaMoreInfoDetails extends LitElement {
}
private _renderEntries(entries: DetailEntry[]) {
return entries.map((entry) => {
const label = this.hass.localize(entry.translationKey);
if (!entry.href && !entry.copyable) {
return html`
<ha-list-item-value .label=${label}
>${entry.displayValue ?? entry.value}</ha-list-item-value
>
`;
}
if (entry.copyable) {
return html`
<ha-list-item-button
aria-label=${this.hass.localize(
"ui.dialogs.more_info_control.copy_value",
{ label, value: entry.value }
)}
data-value=${entry.value}
@click=${this._copyValue}
>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
<ha-svg-icon
class=${this._copiedValue === entry.value ? "copy-success" : ""}
slot="end"
.path=${
this._copiedValue === entry.value ? mdiCheck : mdiContentCopy
}
></ha-svg-icon>
</ha-list-item-button>
`;
}
return html`
<ha-list-item-button .href=${entry.href}>
<div class="link-row" slot="content">
<div class="label">${label}</div>
<div class="value">${entry.value}</div>
</div>
${entry.icon ?? nothing}
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`;
});
}
private async _copyValue(ev: HASSDomCurrentTargetEvent<HaListItemButton>) {
const value = ev.currentTarget.dataset.value;
if (value === undefined) {
return;
}
await copyToClipboard(value);
const duration = 4000;
this._copiedValue = value;
window.clearTimeout(this._copyFeedbackTimeout);
this._copyFeedbackTimeout = window.setTimeout(() => {
this._copiedValue = undefined;
this._copyFeedbackTimeout = undefined;
}, duration);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
duration,
});
return entries.map(
(entry) => html`
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
${
entry.href
? html`<a href=${entry.href}>${entry.value}</a>`
: entry.value
}
</ha-list-item-value>
`
);
}
private _renderAttributes(attributes: { name: string; label: string }[]) {
@@ -460,63 +358,11 @@ class HaMoreInfoDetails extends LitElement {
}
ha-grouped-list + ha-grouped-list {
margin-top: var(--ha-space-6);
margin-top: var(--ha-space-4);
}
ha-list-item-button {
--ha-row-item-padding-block: var(--ha-space-2);
--ha-row-item-min-height: 40px;
--ha-row-item-gap: var(--ha-space-3);
--mdc-icon-size: 20px;
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
ha-list-item-button img {
width: 20px;
height: 20px;
object-fit: contain;
}
.link-row {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-space-3);
}
.link-row .label {
flex: 1;
color: var(--secondary-text-color);
}
.link-row .value {
max-width: 60%;
min-width: 0;
text-align: end;
overflow-wrap: anywhere;
}
.labels {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--ha-space-1);
}
.labels ha-label {
min-width: 0;
max-width: 100%;
}
ha-icon-next {
color: var(--secondary-text-color);
}
ha-svg-icon.copy-success {
color: var(--success-color);
a {
color: var(--primary-color);
}
.empty {
+2 -36
View File
@@ -17,7 +17,6 @@ import {
mdiTransitConnectionVariant,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { provide } from "@lit/context";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -92,7 +91,6 @@ import {
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
type MoreInfoView,
} from "./const";
import { moreInfoContext, type MoreInfoContext } from "./context";
import "./controls/more-info-default";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
@@ -110,7 +108,6 @@ export interface MoreInfoDialogParams {
tab?: MoreInfoView;
large?: boolean;
data?: Record<string, any>;
hash?: URLSearchParams;
fromUrl?: boolean;
returnUrl?: string;
parentElement?: LitElement;
@@ -158,10 +155,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _data?: Record<string, any>;
@provide({ context: moreInfoContext })
@state()
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
private _returnUrl?: string;
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
@@ -198,7 +191,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const view = params.view || params.tab || DEFAULT_VIEW;
this._data = params.data;
this._moreInfoContext = this._createMoreInfoContext(params.hash);
this._returnUrl = params.returnUrl;
this._currView = view;
this._initialView = view;
@@ -252,7 +244,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._initialView = DEFAULT_VIEW;
this._currView = DEFAULT_VIEW;
this._childViewStack = [];
this._moreInfoContext = this._createMoreInfoContext();
this._returnUrl = undefined;
this._isEscapeEnabled = true;
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
@@ -301,10 +292,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
return entity?.device_id ?? null;
}
private _setView(view: MoreInfoView, preserveHash = false) {
if (view !== this._currView && !preserveHash) {
this._moreInfoContext = this._createMoreInfoContext();
}
private _setView(view: MoreInfoView) {
updateHistoryState({
dialogParams: {
...getHistoryState()?.dialogParams,
@@ -323,29 +311,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
createMoreInfoUrl(this._returnUrl, {
entityId: this._entityId,
view: this._currView,
hash: this._moreInfoContext.hash,
})
);
}
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
return {
hash: new URLSearchParams(hash),
setHashParam: (key, value) => this._setHashParam(key, value),
};
}
private _setHashParam(key: string, value?: string) {
const hash = new URLSearchParams(this._moreInfoContext.hash);
if (value) {
hash.set(key, value);
} else {
hash.delete(key);
}
this._moreInfoContext = this._createMoreInfoContext(hash);
this._syncUrl();
}
private _goBack() {
if (this._childView) {
const dialog =
@@ -371,7 +340,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
if (this._parentEntityIds.length > 0) {
this._entityId = this._parentEntityIds.pop();
this._currView = DEFAULT_VIEW;
this._moreInfoContext = this._createMoreInfoContext();
this._loadEntityRegistryEntry();
this._syncUrl();
}
@@ -1078,15 +1046,13 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
if (entityId === this._entityId) {
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._infoEditMode = false;
this._detailsYamlMode = false;
this._setView(view, true);
this._setView(view);
return;
}
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
this._entityId = entityId;
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
this._currView = view === "details" ? view : DEFAULT_VIEW;
this._initialView = view;
this._infoEditMode = false;
+4 -22
View File
@@ -24,8 +24,6 @@ export interface RouteOptions {
// Function to load the page.
load?: () => Promise<unknown>;
cache?: boolean;
// Recreate the page when the remaining path (the item id) changes.
itemId?: boolean;
waitForReady?: boolean;
}
@@ -140,23 +138,10 @@ export class HassRouterPage extends ReactiveElement {
}
if (this._currentPage === newPage) {
const oldRoute = changedProps.get("route");
const oldTail = oldRoute ? computeRouteTail(oldRoute).path : undefined;
const newTail = route ? this._computeTail(route).path : undefined;
if (
typeof routeOptions === "object" &&
routeOptions.itemId &&
oldTail !== newTail
) {
// Fall through to the normal create path so `load` / loading screen
// still run. itemId pages are not cached, so this is a new element.
this._currentPage = "";
} else {
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
if (this.lastChild) {
this.updatePageEl(this.lastChild, changedProps);
}
return;
}
if (!routeOptions) {
@@ -380,10 +365,7 @@ export class HassRouterPage extends ReactiveElement {
this.updatePageEl(panelEl);
this.appendChild(panelEl);
if (
(routerOptions.cacheAll || routeOptions.cache) &&
!routeOptions.itemId
) {
if (routerOptions.cacheAll || routeOptions.cache) {
this._cache[page] = panelEl;
}
}
+1 -5
View File
@@ -318,10 +318,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
if (!searchParams["more-info-entity-id"]) {
return;
}
const { entityId, view, hash } = decodeMoreInfoUrl(
window.location.search,
window.location.hash
);
const { entityId, view } = decodeMoreInfoUrl(window.location.search);
if (!entityId) {
return;
}
@@ -331,7 +328,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
showMoreInfoDialog(this, {
entityId,
view,
hash,
fromUrl: true,
});
});
+16 -81
View File
@@ -16,7 +16,6 @@ import {
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoize from "memoize-one";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import { resolveTimeZone } from "../../common/datetime/resolve-time-zone";
@@ -78,10 +77,6 @@ export class HAFullCalendar extends LitElement {
@property({ attribute: "add-fab", type: Boolean }) public addFab = false;
@property({ attribute: "add-fab-size" }) public addFabSize = "large";
@property({ attribute: "add-fab-style" }) public addFabStyle = "on_top";
@property({ attribute: false }) public events: CalendarEvent[] = [];
@property({ attribute: false }) public calendars: CalendarData[] = [];
@@ -177,34 +172,13 @@ export class HAFullCalendar extends LitElement {
</ha-icon-button-next>
</div>
<h1>${this.calendar.view.title}</h1>
<div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle === "header"
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
>
<ha-svg-icon
slot=""
.path=${mdiPlus}
></ha-svg-icon>
</ha-button>`
: nothing
}
</div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
`
: html`
<div class="controls">
@@ -234,34 +208,13 @@ export class HAFullCalendar extends LitElement {
"ui.components.calendar.today"
)}</ha-button
>
<div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle === "header"
? html`<ha-button
size="s"
class="fab-header"
aria-label=${this.hass.localize(
"ui.components.calendar.event.add"
)}
@click=${this._createEvent}
>
<ha-svg-icon
slot=""
.path=${mdiPlus}
></ha-svg-icon>
</ha-button>`
: nothing
}
</div>
<ha-button-toggle-group
.buttons=${viewToggleButtons}
.active=${this._activeView}
size="s"
no-wrap
@value-changed=${this._handleView}
></ha-button-toggle-group>
</div>
`
}
@@ -272,15 +225,8 @@ export class HAFullCalendar extends LitElement {
<div id="calendar"></div>
${
this.addFab &&
this._hasMutableCalendars &&
this.addFabStyle !== "header"
? html`<ha-button
size=${this.addFabSize.charAt(0)}
class=${classMap({ below: this.addFabStyle === "below" })}
slot="fab"
@click=${this._createEvent}
>
this.addFab && this._hasMutableCalendars
? html`<ha-button size="l" slot="fab" @click=${this._createEvent}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.components.calendar.event.add")}
</ha-button>`
@@ -628,17 +574,6 @@ export class HAFullCalendar extends LitElement {
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
ha-button.below[slot="fab"] {
position: relative;
margin-inline-start: auto;
padding-top: var(--ha-space-2);
left: 0;
right: 0;
bottom: 0;
top: 0;
--ha-button-box-shadow: none;
}
#calendar {
flex-grow: 1;
background-color: var(
@@ -3,10 +3,6 @@ import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../common/dom/fire_event";
import {
ACTION_ROW_CONFIG_KEYS,
pickRowConfig,
} from "../../../../data/automation";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import { COLLAPSIBLE_ACTION_ELEMENTS } from "../../../../data/action";
@@ -111,8 +107,9 @@ export default class HaAutomationActionEditor extends LitElement {
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...(this.action.note ? { note: this.action.note } : {}),
...ev.detail.value,
...pickRowConfig(this.action, ACTION_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -14,8 +14,6 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceActions,
deviceAutomationsEqual,
fetchDeviceActionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -42,8 +40,6 @@ export class HaDeviceAction extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -99,27 +95,13 @@ export class HaDeviceAction extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.action,
compositeSplits,
fetchDeviceActions
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -133,7 +115,6 @@ export class HaDeviceAction extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
.disabled=${this.disabled}
@value-changed=${this._devicePicked}
.hass=${this.hass}
@@ -175,19 +156,6 @@ export class HaDeviceAction extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("action");
if (previous && previous.device_id !== this.action.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -217,15 +185,6 @@ export class HaDeviceAction extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.action, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -7,11 +7,7 @@ import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import type { Condition } from "../../../../data/automation";
import {
CONDITION_ROW_CONFIG_KEYS,
expandConditionWithShorthand,
pickRowConfig,
} from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation";
import type { ConditionDescription } from "../../../../data/condition";
import { COLLAPSIBLE_CONDITION_ELEMENTS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
@@ -129,8 +125,9 @@ export default class HaAutomationConditionEditor extends LitElement {
private _onUiChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = {
...(this.condition.alias ? { alias: this.condition.alias } : {}),
...(this.condition.note ? { note: this.condition.note } : {}),
...ev.detail.value,
...pickRowConfig(this.condition, CONDITION_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -14,8 +14,6 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceConditions,
deviceAutomationsEqual,
fetchDeviceConditionCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -42,8 +40,6 @@ export class HaDeviceCondition extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -100,27 +96,13 @@ export class HaDeviceCondition extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.condition,
compositeSplits,
fetchDeviceConditions
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -134,7 +116,6 @@ export class HaDeviceCondition extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -176,19 +157,6 @@ export class HaDeviceCondition extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("condition");
if (previous && previous.device_id !== this.condition.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -219,15 +187,6 @@ export class HaDeviceCondition extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.condition, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -278,9 +278,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
const domain = hooks.domain;
try {
const config = await hooks.fetchFileConfig(this.hass, id);
if (!this.isConnected) {
return;
}
this.readOnly = false;
const report: AutomationMigrationReport = { deprecated: false };
this.config = hooks.normalizeConfig(config, report);
@@ -297,9 +294,6 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
);
hooks.checkValidation();
} catch (err: any) {
if (!this.isConnected) {
return;
}
if (err.status_code !== 404) {
const alertText =
err.body?.message || err.body || err.error || "Unknown error";
@@ -47,11 +47,9 @@ class HaConfigAutomation extends HassRouterPage {
},
edit: {
tag: "ha-automation-editor",
itemId: true,
},
show: {
tag: "ha-automation-editor",
itemId: true,
},
trace: {
tag: "ha-automation-trace",
@@ -479,12 +479,7 @@ export class HaAutomationRowTargets extends LitElement {
warning = true;
badgeTargetId = undefined;
badgeTargetType = undefined;
if (
targetType === "device" &&
this._compositeSplits?.[targetId]?.split_ids.some(
(id) => id in this._registries.devices
)
) {
if (targetType === "device" && this._compositeSplits?.[targetId]) {
// The device was replaced by one or more split devices; make clear
// this reference needs to be updated, distinct from "unknown device".
icon = mdiSwapHorizontal;
@@ -8,11 +8,7 @@ import "../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import "../../../../components/input/ha-input";
import type { Trigger } from "../../../../data/automation";
import {
TRIGGER_ROW_CONFIG_KEYS,
migrateAutomationTrigger,
pickRowConfig,
} from "../../../../data/automation";
import { migrateAutomationTrigger } from "../../../../data/automation";
import type { TriggerDescription } from "../../../../data/trigger";
import { isTriggerList } from "../../../../data/trigger";
import { haStyle } from "../../../../resources/styles";
@@ -148,8 +144,9 @@ export default class HaAutomationTriggerEditor extends LitElement {
if (isTriggerList(this.trigger)) return;
ev.stopPropagation();
const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
...(this.trigger.note ? { note: this.trigger.note } : {}),
...ev.detail.value,
...pickRowConfig(this.trigger, TRIGGER_ROW_CONFIG_KEYS),
};
fireEvent(this, "value-changed", { value });
}
@@ -16,8 +16,6 @@ import type {
} from "../../../../../data/device/device_automation";
import {
deviceAutomationEditorMode,
fetchReplacementDevices,
fetchDeviceTriggers,
deviceAutomationsEqual,
fetchDeviceTriggerCapabilities,
localizeExtraFieldsComputeHelperCallback,
@@ -44,8 +42,6 @@ export class HaDeviceTrigger extends LitElement {
@state() private _compositeSplits?: DeviceCompositeSplits;
@state() private _replacementDeviceIds?: string[];
private _loadingCompositeSplits = false;
@state()
@@ -104,27 +100,13 @@ export class HaDeviceTrigger extends LitElement {
return true;
}
private async _resolveReplacements(compositeSplits: DeviceCompositeSplits) {
this._replacementDeviceIds = await fetchReplacementDevices(
this.hass,
this._entityReg,
this.trigger,
compositeSplits,
fetchDeviceTriggers
);
}
private async _loadCompositeSplits() {
if (this._loadingCompositeSplits) {
return;
}
this._loadingCompositeSplits = true;
try {
// Resolve the candidates before exposing the split map, so the picker
// never offers one that cannot host the automation.
const compositeSplits = await fetchDeviceCompositeSplits(this.hass);
await this._resolveReplacements(compositeSplits);
this._compositeSplits = compositeSplits;
this._compositeSplits = await fetchDeviceCompositeSplits(this.hass);
} catch (_err) {
this._compositeSplits = {};
} finally {
@@ -138,7 +120,6 @@ export class HaDeviceTrigger extends LitElement {
return html`
<ha-device-picker
.value=${deviceId}
.replacementDeviceIds=${this._replacementDeviceIds}
@value-changed=${this._devicePicked}
.hass=${this.hass}
.disabled=${this.disabled}
@@ -180,19 +161,6 @@ export class HaDeviceTrigger extends LitElement {
`;
}
protected willUpdate(changedProps: PropertyValues<this>) {
// The picked device only lives here until the configuration catches up.
// Once it points somewhere else, undo and redo included, it is stale.
const previous = changedProps.get("trigger");
if (previous && previous.device_id !== this.trigger.device_id) {
this._deviceId = undefined;
this._replacementDeviceIds = undefined;
if (this._compositeSplits) {
this._resolveReplacements(this._compositeSplits);
}
}
}
protected firstUpdated() {
this.hass.loadBackendTranslation("device_automation");
if (!this._capabilities) {
@@ -240,15 +208,6 @@ export class HaDeviceTrigger extends LitElement {
private _devicePicked(ev) {
ev.stopPropagation();
// The automation exists as is on the replacement, so only the reference
// changes and the rest of the configuration is left untouched.
if (this._replacementDeviceIds?.includes(ev.target.value)) {
this._deviceId = undefined;
fireEvent(this, "value-changed", {
value: { ...this.trigger, device_id: ev.target.value },
});
return;
}
this._deviceId = ev.target.value;
if (this._deviceId === undefined) {
fireEvent(this, "value-changed", {
@@ -266,6 +225,9 @@ export class HaDeviceTrigger extends LitElement {
) {
trigger = this._origTrigger;
}
if (this.trigger.id) {
trigger.id = this.trigger.id;
}
fireEvent(this, "value-changed", { value: trigger });
}
@@ -9,7 +9,6 @@ import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformTrigger } from "../../../../../data/automation";
import { TRIGGER_ROW_CONFIG_KEYS } from "../../../../../data/automation";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
@@ -28,11 +27,15 @@ const showOptionalToggle = (field: TriggerDescription["fields"][string]) =>
!("boolean" in field.selector && field.default);
const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
...TRIGGER_ROW_CONFIG_KEYS,
"trigger",
"target",
"alias",
"note",
"id",
"variables",
"enabled",
"options",
];
] as const;
@customElement("ha-automation-trigger-platform")
export class HaPlatformTrigger extends LitElement {
@@ -201,7 +201,7 @@ export class DialogVacuumSegmentMapping
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._submitting}
.disabled=${this._submitting || !this.isDirtyState}
>
${this.hass.localize("ui.common.save")}
</ha-button>
@@ -1,13 +1,11 @@
import { mdiContentCopy } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
import "../../../../../components/ha-adaptive-dialog";
import "../../../../../components/ha-icon-button";
import "../../../../../components/item/ha-list-item-value";
import "../../../../../components/list/ha-grouped-list";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import type { HomeAssistant } from "../../../../../types";
import { showToast } from "../../../../../util/toast";
import type { SerialPortInfoDialogParams } from "./show-dialog-serial-port-info";
@@ -87,34 +85,64 @@ class DialogSerialPortInfo extends LitElement {
}
return html`
<ha-adaptive-dialog
<ha-dialog
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.serial.port_information"
)}
@closed=${this._dialogClosed}
>
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize("ui.common.copy")}
.path=${mdiContentCopy}
@click=${this._copyToClipboard}
></ha-icon-button>
<ha-grouped-list>
${this._fields().map(
([label, value]) =>
html`<ha-list-item-value .label=${label}
>${value}</ha-list-item-value
>`
)}
</ha-grouped-list>
</ha-adaptive-dialog>
<table>
<tbody>
${this._fields().map(
([label, value]) => html`
<tr>
<th>${label}</th>
<td>${value}</td>
</tr>
`
)}
</tbody>
</table>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._copyToClipboard}
>
${this.hass.localize("ui.common.copy")}
</ha-button>
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
static readonly styles: CSSResultGroup = css`
ha-grouped-list {
--ha-list-item-value-max-width: 80%;
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: start;
vertical-align: top;
white-space: nowrap;
padding-inline-end: var(--ha-space-4);
color: var(--secondary-text-color);
font-weight: var(--ha-font-weight-normal);
}
td {
width: 100%;
word-break: break-all;
}
tr:not(:first-child) th,
tr:not(:first-child) td {
padding-top: var(--ha-space-2);
}
`;
}
@@ -12,15 +12,6 @@ function getLQIWidth(lqi: number): number {
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
}
const RELATIONSHIP_PRIORITY: Record<string, number> = {
Parent: 0,
Sibling: 1,
NoneOfTheAbove: 2,
Child: 3,
PreviousChild: 5,
};
const UNKNOWN_RELATIONSHIP_PRIORITY = 4;
export function createZHANetworkChartData(
devices: ZHADevice[],
hass: HomeAssistant,
@@ -168,78 +159,38 @@ export function createZHANetworkChartData(
existingLinks.push(link);
}
});
}
});
// For every device whose routing table was empty (so it got no link
// above), independently compute its preferred fallback neighbor before
// creating any of these links. Doing this device-by-device while
// creating links (as before) meant whichever device the backend happens
// to list first "claims" the connection, and a sibling router listed
// later never gets to contribute its own — often better — choice,
// splitting the graph into islands depending on backend device order.
const fallbackTargets = new Map<string, { ieee: string; lqi: string }>();
devices.forEach((device) => {
const hasLink = links.some(
(link) => link.source === device.ieee || link.target === device.ieee
);
if (hasLink) {
return;
}
const neighbors: { ieee: string; lqi: string; relationship?: string }[] =
device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
} else if (existingLinks.length === 0) {
// If there are no links, create a link to the closest neighbor
const neighbors: { ieee: string; lqi: string }[] = device.neighbors ?? [];
if (neighbors.length === 0) {
// If there are no neighbors, look for links from other devices
devices.forEach((d) => {
if (d.neighbors && d.neighbors.length > 0) {
const neighbor = d.neighbors.find((n) => n.ieee === device.ieee);
if (neighbor) {
neighbors.push({ ieee: d.ieee, lqi: neighbor.lqi });
}
}
}
});
});
}
const closestNeighbor = neighbors.sort(
(a, b) => parseInt(b.lqi) - parseInt(a.lqi)
)[0];
if (closestNeighbor) {
links.push({
source: device.ieee,
target: closestNeighbor.ieee,
value: parseInt(closestNeighbor.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
}
}
// Prefer a neighbor the device's own Zigbee stack reports as its
// parent: picking by raw LQI alone regularly favors a nearby child end
// device (e.g. a plug sitting right next to its router) over the
// router's actual uplink, which severs it from the rest of the mesh in
// the visualization even though the real network is connected.
const closestNeighbor = neighbors.sort((a, b) => {
const aPriority =
RELATIONSHIP_PRIORITY[a.relationship ?? ""] ??
UNKNOWN_RELATIONSHIP_PRIORITY;
const bPriority =
RELATIONSHIP_PRIORITY[b.relationship ?? ""] ??
UNKNOWN_RELATIONSHIP_PRIORITY;
const priorityDiff = aPriority - bPriority;
return priorityDiff !== 0
? priorityDiff
: parseInt(b.lqi) - parseInt(a.lqi);
})[0];
if (closestNeighbor) {
fallbackTargets.set(device.ieee, closestNeighbor);
}
});
const addedFallbackPairs = new Set<string>();
fallbackTargets.forEach((target, ieee) => {
const pairKey = [ieee, target.ieee].sort().join("|");
if (addedFallbackPairs.has(pairKey)) {
return;
}
addedFallbackPairs.add(pairKey);
links.push({
source: ieee,
target: target.ieee,
value: parseInt(target.lqi),
symbolSize: 5,
lineStyle: {
width: 1,
color: style.getPropertyValue("--dark-primary-color"),
type: "dotted",
},
ignoreForceLayout: true,
});
});
// Now set ignoreForceLayout to false for the best connection of each device
@@ -40,7 +40,6 @@ class HaConfigScene extends HassRouterPage {
},
edit: {
tag: "ha-scene-editor",
itemId: true,
},
},
};
+5 -16
View File
@@ -911,15 +911,11 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
private async _subscribeEvents() {
const unsubscribe = await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
if (!this.isConnected || this._mode !== "live") {
unsubscribe();
return;
}
this._unsubscribeEvents = unsubscribe;
this._unsubscribeEvents =
await this.hass!.connection.subscribeEvents<HassEvent>(
(event) => this._stateChanged(event),
"state_changed"
);
}
private _showMoreInfo(ev: Event) {
@@ -932,9 +928,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
try {
config = await getSceneConfig(this.hass, this.sceneId!);
} catch (err: any) {
if (!this.isConnected) {
return;
}
await showAlertDialog(this, {
text:
err.status_code === 404
@@ -950,10 +943,6 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
return;
}
if (!this.isConnected) {
return;
}
if (!config.entities) {
config.entities = {};
}
@@ -40,11 +40,9 @@ class HaConfigScript extends HassRouterPage {
},
edit: {
tag: "ha-script-editor",
itemId: true,
},
show: {
tag: "ha-script-editor",
itemId: true,
},
trace: {
tag: "ha-script-trace",
+7 -1
View File
@@ -266,6 +266,7 @@ class HaPanelHistory extends LitElement {
.startTime=${this._startDate}
.endTime=${this._endDate}
.narrow=${this.narrow}
inside-labels
sync-charts
>
</state-history-charts>
@@ -809,7 +810,12 @@ class HaPanelHistory extends LitElement {
flex: 1;
min-width: 0;
overflow: hidden auto;
padding: 16px;
padding: 16px 8px;
}
/* Line the charts up with the toolbar when there are no axis labels. */
:host([narrow]) .results {
padding-inline: 16px;
}
.progress-wrapper {
@@ -112,11 +112,7 @@ class HuiEnergyDistrubutionCard
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
const oldStates = oldHass.states;
const oldStates = changedProps.get("hass").states;
if (
this._data?.co2SignalEntity &&
this.hass.states[this._data.co2SignalEntity] !==
@@ -124,20 +120,6 @@ class HuiEnergyDistrubutionCard
) {
return true;
}
if (
this._data &&
energySourcesByType(this._data.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
if (this._data && periodIncludesNow(this._data)) {
const batteries = energySourcesByType(this._data.prefs).battery;
if (
@@ -180,21 +162,6 @@ class HuiEnergyDistrubutionCard
const hasBattery = types.battery !== undefined;
const hasGas = types.gas !== undefined;
const hasWater = types.water !== undefined;
const gasUnit = this._data.gasUnit;
const gasDisplayPrecisions = types.gas
?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === gasUnit
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
const gasDisplayPrecision = gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
const hasReturnToGrid =
types.grid?.some((source) => source.stat_energy_to) ?? false;
@@ -470,9 +437,7 @@ class HuiEnergyDistrubutionCard
${formatConsumptionShort(
this.hass,
gasUsage,
this._data.gasUnit,
undefined,
gasDisplayPrecision
this._data.gasUnit
)}
</div>
<svg width="80" height="30">
@@ -11,7 +11,6 @@ import "../../../../components/chart/ha-chart-base";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import {
energySourcesByType,
getEnergyDataCollection,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -67,8 +66,6 @@ export class HuiEnergyGasGraphCard
@state() private _total?: number;
private _energyData?: EnergyData;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -91,68 +88,11 @@ export class HuiEnergyGasGraphCard
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (
return (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
if (
this._energyData &&
energySourcesByType(this._energyData.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
return false;
}
private get _displayPrecision(): number | undefined {
if (!this._energyData) {
return undefined;
}
const gasDisplayPrecisions = energySourcesByType(this._energyData.prefs)
.gas?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === this._unit
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
return gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
}
private get _gasFormatOptions(): Intl.NumberFormatOptions | undefined {
const displayPrecision = this._displayPrecision;
return displayPrecision !== undefined
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: undefined;
);
}
protected render() {
@@ -171,11 +111,7 @@ export class HuiEnergyGasGraphCard
? html`<hui-energy-graph-chip
.tooltip=${this._formatTotal(this._total)}
>
${formatNumber(
this._total,
this.hass.locale,
this._gasFormatOptions
)}
${formatNumber(this._total, this.hass.locale)}
${this._unit}
</hui-energy-graph-chip>`
: nothing
@@ -199,7 +135,7 @@ export class HuiEnergyGasGraphCard
this._unit,
this._compareStart,
this._compareEnd,
this._displayPrecision ?? this._yAxisFractionDigits
this._yAxisFractionDigits
)}
chart-type="bar"
></ha-chart-base>
@@ -226,10 +162,7 @@ export class HuiEnergyGasGraphCard
private _formatTotal = (total: number) =>
this.hass.localize(
"ui.panel.lovelace.cards.energy.energy_gas_graph.total_consumed",
{
num: formatNumber(total, this.hass.locale, this._gasFormatOptions),
unit: this._unit,
}
{ num: formatNumber(total, this.hass.locale), unit: this._unit }
);
private _createOptions = memoizeOne(
@@ -258,8 +191,6 @@ export class HuiEnergyGasGraphCard
);
private async _getStatistics(energyData: EnergyData): Promise<void> {
this._energyData = energyData;
const result = generateEnergyGasGraphData({
hass: this.hass,
energyData,
@@ -87,35 +87,11 @@ export class HuiEnergySourcesTableCard
}
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
if (
return (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
) {
return true;
}
const oldHass = changedProps.get("hass");
if (!oldHass) {
return true;
}
if (
this._data &&
energySourcesByType(this._data.prefs).gas?.some((source) => {
const statId = source.stat_energy_from;
return (
this.hass.entities[statId]?.display_precision !==
oldHass.entities[statId]?.display_precision ||
this.hass.states[statId]?.attributes.unit_of_measurement !==
oldHass.states[statId]?.attributes.unit_of_measurement
);
})
) {
return true;
}
return false;
);
}
protected _renderRow(
@@ -132,20 +108,6 @@ export class HuiEnergySourcesTableCard
compare: boolean,
name?: string
) {
const displayPrecision =
type === "gas" &&
this.hass.states[statId]?.attributes.unit_of_measurement === energyUnit
? this.hass.entities[statId]?.display_precision
: undefined;
const formatOptions =
displayPrecision !== undefined
? {
minimumFractionDigits: displayPrecision,
maximumFractionDigits: displayPrecision,
}
: undefined;
return html`<tr
class="mdc-data-table__row ${classMap({
clickable: !isExternalStatistic(statId),
@@ -189,8 +151,7 @@ export class HuiEnergySourcesTableCard
${
compare
? html`<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${formatNumber(compareEnergy, this.hass.locale, formatOptions)}
${energyUnit}
${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}
</td>
${
showCosts
@@ -211,7 +172,7 @@ export class HuiEnergySourcesTableCard
: ""
}
<td class="mdc-data-table__cell mdc-data-table__cell--numeric">
${formatNumber(energy, this.hass.locale, formatOptions)} ${energyUnit}
${formatNumber(energy, this.hass.locale)} ${energyUnit}
</td>
${
showCosts
@@ -242,8 +203,7 @@ export class HuiEnergySourcesTableCard
showCosts: boolean,
compare: boolean,
bulletColor?: { border: string; background: string },
isFinalTotal?: boolean,
formatOptions?: Intl.NumberFormatOptions
isFinalTotal?: boolean
) {
return html` <tr
class="mdc-data-table__row ${bulletColor && !isFinalTotal ? "" : "total"}"
@@ -268,11 +228,7 @@ export class HuiEnergySourcesTableCard
${
compareEnergy === null
? ""
: `${formatNumber(
compareEnergy,
this.hass.locale,
formatOptions
)} ${energyUnit}`
: `${formatNumber(compareEnergy, this.hass.locale)} ${energyUnit}`
}
</td>
${
@@ -297,11 +253,7 @@ export class HuiEnergySourcesTableCard
${
energy === null
? ""
: `${formatNumber(
energy,
this.hass.locale,
formatOptions
)} ${energyUnit}`
: `${formatNumber(energy, this.hass.locale)} ${energyUnit}`
}
</td>
${
@@ -406,30 +358,6 @@ export class HuiEnergySourcesTableCard
water: this._data.waterUnit,
};
const gasDisplayPrecisions = types.gas
?.filter(
(source) =>
this.hass.states[source.stat_energy_from]?.attributes
.unit_of_measurement === units.gas
)
.map(
(source) =>
this.hass.entities[source.stat_energy_from]?.display_precision
)
.filter((precision): precision is number => precision !== undefined);
const gasDisplayPrecision = gasDisplayPrecisions?.length
? Math.max(...gasDisplayPrecisions)
: undefined;
const gasFormatOptions =
gasDisplayPrecision !== undefined
? {
minimumFractionDigits: gasDisplayPrecision,
maximumFractionDigits: gasDisplayPrecision,
}
: undefined;
const compare = this._data.statsCompare !== undefined;
const _extractStatData = (
@@ -553,9 +481,7 @@ export class HuiEnergySourcesTableCard
0
),
}
: undefined,
false,
type === "gas" ? gasFormatOptions : undefined
: undefined
)
: ""
}`;
+13 -12
View File
@@ -222,9 +222,6 @@ export class HuiCalendarCard
"has-title": !!this._config.title,
loading: loading,
})}
?add-fab=${this._config.show_add_event}
add-fab-style=${this._config.show_add_event ? (this._config.add_event_style ?? "below") : nothing}
add-fab-size=${this._config.show_add_event && this._config.add_event_style !== "header" ? (this._config.add_event_size ?? "small") : nothing}
.narrow=${this._narrow}
.events=${this._events}
.calendars=${this._calendars}
@@ -373,7 +370,11 @@ export class HuiCalendarCard
}
private _measureCard() {
this._narrow = this.offsetWidth < 870;
const card = this.shadowRoot!.querySelector("ha-card");
if (!card) {
return;
}
this._narrow = card.offsetWidth < 870;
}
private async _attachObserver(): Promise<void> {
@@ -382,7 +383,12 @@ export class HuiCalendarCard
debounce(() => this._measureCard(), 250, false)
);
}
this._resizeObserver.observe(this);
const card = this.shadowRoot!.querySelector("ha-card");
// If we show an error or warning there is no ha-card
if (!card) {
return;
}
this._resizeObserver.observe(card);
}
static styles = css`
@@ -408,9 +414,9 @@ export class HuiCalendarCard
ha-full-calendar {
--calendar-height: 400px;
display: flex;
display: block;
width: 100%;
height: 100%;
height: var(--calendar-height);
min-height: var(--calendar-height);
}
@@ -425,14 +431,9 @@ export class HuiCalendarCard
ha-full-calendar.is-grid.has-title,
ha-full-calendar.is-panel.has-title {
--header-height: calc(
var(--ha-card-header-font-size, var(--ha-font-size-2xl)) *
var(--ha-line-height-condensed) + 16px
);
--calendar-height: calc(
100% - var(--ha-card-header-font-size, var(--ha-font-size-2xl)) - 22px
);
height: calc(100% - var(--header-height));
}
.loading {
-3
View File
@@ -52,9 +52,6 @@ export interface CalendarCardConfig extends LovelaceCardConfig {
initial_view?: FullCalendarView;
title?: string;
theme?: string;
show_add_event?: boolean;
add_event_style?: "below" | "on_top" | "header";
add_event_size?: "small" | "medium" | "large";
}
export interface ConditionalCardConfig extends LovelaceCardConfig {
@@ -10,16 +10,12 @@ import {
optional,
string,
union,
literal,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/entity/ha-entities-picker";
import "../../../../components/ha-form/ha-form";
import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type { CalendarCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
@@ -31,13 +27,6 @@ const cardConfigStruct = assign(
title: optional(union([string(), boolean()])),
initial_view: optional(string()),
theme: optional(string()),
show_add_event: optional(boolean()),
add_event_style: optional(
union([literal("below"), literal("on_top"), literal("header")])
),
add_event_size: optional(
union([literal("small"), literal("medium"), literal("large")])
),
entities: array(string()),
})
);
@@ -83,83 +72,7 @@ export class HuiCalendarCardEditor
],
},
{ name: "theme", required: false, selector: { theme: {} } },
{ name: "show_add_event", required: false, selector: { boolean: {} } },
{
name: "",
type: "grid",
schema: [
{
name: "add_event_style",
default: "below",
visible: { field: "show_add_event", operator: "eq", value: true },
required: false,
selector: {
select: {
options: [
{
value: "below",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.below"
),
},
{
value: "on_top",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.on_top"
),
},
{
value: "header",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.style.header"
),
},
],
mode: "dropdown",
},
},
},
{
name: "add_event_size",
default: "small",
visible: [
{ field: "show_add_event", operator: "eq", value: true },
{
field: "add_event_style",
operator: "not_eq",
value: "header",
},
],
required: false,
selector: {
select: {
options: [
{
value: "small",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.s"
),
},
{
value: "medium",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.m"
),
},
{
value: "large",
label: localize(
"ui.panel.lovelace.editor.card.calendar.add_event.size.l"
),
},
],
mode: "dropdown",
},
},
},
],
},
] as const satisfies readonly HaFormSchema[]
] as const
);
protected render() {
@@ -1,32 +0,0 @@
import { computeDomain } from "../../../../common/entity/compute_domain";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type {
PictureEntityCardConfig,
TileCardConfig,
} from "../../cards/types";
export const computeFavoriteCardConfig = (
entityId: string
): LovelaceCardConfig => {
// A camera tile would only show a thumbnail, so give the picture the room.
if (computeDomain(entityId) === "camera") {
return {
type: "picture-entity",
entity: entityId,
show_name: false,
show_state: false,
grid_options: {
columns: 6,
rows: 2,
},
} satisfies PictureEntityCardConfig;
}
return {
type: "tile",
entity: entityId,
// Favorites come from all over the home, so name the area.
state_content: ["state", "area_name"],
show_entity_picture: true,
} satisfies TileCardConfig;
};
@@ -34,7 +34,6 @@ import type {
TileCardConfig,
UpdatesCardConfig,
} from "../../cards/types";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import {
LARGE_SCREEN_CONDITION,
SMALL_SCREEN_CONDITION,
@@ -272,7 +271,15 @@ export class HomeOverviewViewStrategy extends ReactiveElement {
column_span: maxColumns,
cards: [
favoritesHeadingCard,
...favoriteEntities.map(computeFavoriteCardConfig),
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
],
};
}
@@ -4,9 +4,8 @@ import { isComponentLoaded } from "../../../../common/config/is_component_loaded
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import { getCommonControlsUsagePrediction } from "../../../../data/usage_prediction";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig } from "../../cards/types";
import type { HeadingCardConfig, TileCardConfig } from "../../cards/types";
import type { Condition } from "../../common/validate-condition";
import { computeFavoriteCardConfig } from "../helpers/favorite-cards";
import type { LovelaceStrategyDependency } from "../types";
const DEFAULT_LIMIT = 8;
@@ -26,6 +25,13 @@ export interface CommonControlsSectionStrategyConfig {
title_visibilty?: Condition[];
}
const toTileCard = (entity: string): TileCardConfig => ({
type: "tile",
entity,
state_content: ["state", "area_name"],
show_entity_picture: true,
});
@customElement("common-controls-section-strategy")
export class CommonControlsSectionStrategy extends ReactiveElement {
static registryDependencies: readonly LovelaceStrategyDependency[] = [];
@@ -57,9 +63,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
// Pinned entities already fill the section, skip the prediction call.
if (includedEntities.length >= limit) {
section.cards!.push(
...includedEntities.slice(0, limit).map(computeFavoriteCardConfig)
);
section.cards!.push(...includedEntities.slice(0, limit).map(toTileCard));
return section;
}
@@ -106,7 +110,7 @@ export class CommonControlsSectionStrategy extends ReactiveElement {
return section;
}
section.cards!.push(...entities.map(computeFavoriteCardConfig));
section.cards!.push(...entities.map(toTileCard));
return section;
}
}
@@ -15,8 +15,10 @@ import type { HomeAssistant } from "../../../types";
import type { TileCardConfig } from "../../lovelace/cards/types";
import { BINARY_STATE_ON } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import { getDeviceEntityDisplayLookup } from "../../../data/device/device_registry";
import { findBatteryChargingEntity } from "../../../data/entity/entity_registry";
import {
type EntityRegistryDisplayEntry,
findBatteryChargingEntity,
} from "../../../data/entity/entity_registry";
export interface MaintenanceViewStrategyConfig {
type: "maintenance";
@@ -35,42 +37,44 @@ export const maintenanceEntityFilters: EntityFilter[] = [
const LOW_BATTERY_THRESHOLD = 20;
const _deviceEntityLookup = memoizeOne((entities: HomeAssistant["entities"]) =>
getDeviceEntityDisplayLookup(Object.values(entities))
const _deviceEntities = memoizeOne(
(
deviceId: string,
entities: HomeAssistant["entities"]
): EntityRegistryDisplayEntry[] => {
const entries = Object.values(entities);
return entries.filter((entity) => entity.device_id === deviceId);
}
);
export const filterLowBatteryEntities = (
hass: HomeAssistant,
entityIds: string[]
): string[] => {
return entityIds.filter((entityId) => {
): string[] =>
entityIds.filter((entityId) => {
const state = hass.states[entityId]?.state ?? "";
if (computeDomain(entityId) === "binary_sensor") {
return state === BINARY_STATE_ON;
}
const stateValue = parseFloat(state);
if (isNaN(stateValue) || stateValue > LOW_BATTERY_THRESHOLD) {
return false;
}
const deviceId = hass.entities[entityId]?.device_id;
if (!deviceId) {
return true;
}
const entities = deviceId ? _deviceEntities(deviceId, hass.entities) : [];
const batteryChargingEntity = findBatteryChargingEntity(
hass.states,
_deviceEntityLookup(hass.entities)[deviceId] ?? []
entities
);
const batteryCharging = batteryChargingEntity
? hass.states[batteryChargingEntity.entity_id]
? hass.states[batteryChargingEntity?.entity_id]
: undefined;
return batteryCharging?.state !== "on";
if (batteryCharging && batteryCharging.state === "on") {
return false;
}
const stateValue = parseFloat(state);
return !isNaN(stateValue) && stateValue <= LOW_BATTERY_THRESHOLD;
});
};
export const filterUnavailableBatteryEntities = (
hass: HomeAssistant,
@@ -18,9 +18,11 @@ import type {
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
import type { SecurityAlertEntityConfig } from "../../../data/frontend";
import type { HomeAssistant } from "../../../types";
import type { LogbookCardConfig } from "../../lovelace/cards/types";
import type {
LogbookCardConfig,
TileCardConfig,
} from "../../lovelace/cards/types";
import { computeAreaTileCardConfig } from "../../lovelace/strategies/areas/helpers/areas-strategy-helper";
import { computeFavoriteCardConfig } from "../../lovelace/strategies/helpers/favorite-cards";
import { computeSecurityAlertCardConfig } from "./security-alerts";
export interface SecurityViewStrategyConfig {
@@ -184,7 +186,15 @@ export class SecurityViewStrategy extends ReactiveElement {
),
heading_style: "title",
},
...favoriteEntities.map(computeFavoriteCardConfig),
...favoriteEntities.map(
(entityId) =>
({
type: "tile",
entity: entityId,
state_content: ["state", "area_name"],
show_entity_picture: true,
}) satisfies TileCardConfig
),
],
});
}
-2
View File
@@ -55,7 +55,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)
: false),
data: ev.detail.data,
hash: ev.detail.hash,
returnUrl,
},
() => import("../dialogs/more-info/ha-more-info-dialog"),
@@ -66,7 +65,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
createMoreInfoUrl(returnUrl, {
entityId: ev.detail.entityId,
view,
hash: ev.detail.hash,
})
);
}
+2 -16
View File
@@ -1684,9 +1684,10 @@
"person": "Edit person"
},
"details": "Details",
"context": "Context",
"entity": "Entity",
"floor": "Floor",
"entity_id": "Entity ID",
"copy_value": "Copy {label}: {value}",
"labels": "Labels",
"toggle_yaml_mode": "Toggle YAML mode",
"translated": "Translated",
@@ -9924,21 +9925,6 @@
"dayGridWeek": "Week",
"dayGridDay": "Day",
"listWeek": "List (7 days)"
},
"show_add_event": "Show add event button",
"add_event_style": "Add event button style",
"add_event_size": "Add event button size",
"add_event": {
"style": {
"header": "Header",
"below": "Below",
"on_top": "On top"
},
"size": {
"s": "Small",
"m": "Medium",
"l": "Large"
}
}
},
"conditional": {
-106
View File
@@ -1,106 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from "vitest";
import { addLatinLabels } from "../../build-scripts/gulp/map-labels.js";
// The 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 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" },
],
};
// Just enough of the expression language for the expressions built here.
const evaluate = (expression, properties) => {
if (!Array.isArray(expression)) {
return expression;
}
const [op, ...args] = expression;
switch (op) {
case "get":
return properties[args[0]];
case "has":
return args[0] in properties;
case "!":
return !evaluate(args[0], properties);
case "<":
return evaluate(args[0], properties) < evaluate(args[1], properties);
case "concat":
return args.map((arg) => evaluate(arg, properties)).join("");
case "format":
return args
.filter((_, i) => i % 2 === 0)
.map((arg) => evaluate(arg, properties))
.join("");
case "case":
for (let i = 0; i < args.length - 1; i += 2) {
if (evaluate(args[i], properties)) {
return evaluate(args[i + 1], properties);
}
}
return evaluate(args[args.length - 1], properties);
default:
throw new Error(`Unexpected operator ${op}`);
}
};
const textField = (style, id) =>
style.layers.find((l) => l.id === id).layout["text-field"];
describe("addLatinLabels", () => {
const style = addLatinLabels(STYLE);
const city = textField(style, "label-place-city");
const street = textField(style, "label-street-primary");
it("leaves Latin names alone", () => {
expect(evaluate(city, { name: "Köln", name_en: "Cologne" })).toBe("Köln");
expect(evaluate(city, { name: "1er arrondissement" })).toBe(
"1er arrondissement"
);
});
it("adds the English name under a non-Latin one", () => {
expect(evaluate(city, { name: "Москва", name_en: "Moscow" })).toBe(
"Москва\nMoscow"
);
expect(evaluate(city, { name: "بيروت", name_en: "Beirut" })).toBe(
"بيروت\nBeirut"
);
expect(evaluate(city, { name: "東京", name_en: "Tokyo" })).toBe(
"東京\nTokyo"
);
});
it("sets the English line smaller", () => {
expect(city.at(-1)).toEqual(
expect.arrayContaining([["get", "name_en"], { "font-scale": 0.8 }])
);
});
it("falls back to the local name without an English one", () => {
expect(evaluate(city, { name: "בני ברק" })).toBe("בני ברק");
});
it("keeps street labels on one line", () => {
expect(
evaluate(street, { name: "شارع الحمرا", name_en: "Hamra Street" })
).toBe("شارع الحمرا (Hamra Street)");
});
it("does not touch other layers", () => {
expect(textField(style, "label-motorway-shield")).toBe("{ref}");
expect(style.layers[3]).toEqual(STYLE.layers[3]);
});
});
-365
View File
@@ -1,365 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LeafletModuleType } from "../../../src/common/dom/setup-leaflet-map";
// The fallback to raster tiles is what keeps the map working on devices
// without WebGL2, on instances that cannot load the MapLibre chunk, and when
// building the MapLibre map itself fails - a blocked worker, an exhausted
// WebGL context budget. None of that is reachable from the `ha-map` tests,
// which run in jsdom and therefore only ever take the raster branch.
const maplibreLayer = vi.hoisted(() => ({
addTo: vi.fn(),
remove: vi.fn(),
options: {} as { attribution?: string },
getMaplibreMap: vi.fn(),
}));
const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
const setRTLTextPlugin = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("maplibre-gl", () => ({ setRTLTextPlugin }));
const STYLE = {
version: 8,
sources: {},
layers: [],
sprite: [{ id: "basics", url: "/static/map/sprites/basics/sprites" }],
};
const rasterLayer = { addTo: vi.fn() };
// Kept as a local so tests can flip it: @types/leaflet has it readonly.
const browser = { retina: false };
const leaflet = {
tileLayer: vi.fn(() => rasterLayer),
Browser: browser,
} as unknown as LeafletModuleType;
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
const glHandlers: Record<string, () => void> = {};
const glMap = {
setStyle: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
glHandlers[event] = handler;
}),
};
const map = { on: vi.fn() } as any;
// The WebGL2 probe is cached for the lifetime of the module, so every test
// needs its own copy of it.
const setWebGL2 = async (supported: boolean) => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() =>
supported ? ({ getExtension: () => null } as any) : null
);
return (await import("../../../src/common/map/base-layer")).createBaseLayer;
};
const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
maplibreLayer.options = {};
maplibreGL.mockReturnValue(maplibreLayer);
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
maplibreLayer.getMaplibreMap.mockReturnValue(glMap);
for (const event of Object.keys(glHandlers)) {
delete glHandlers[event];
}
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("createBaseLayer", () => {
it("falls back to raster tiles without WebGL2", async () => {
const createBaseLayer = await setWebGL2(false);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreGL).not.toHaveBeenCalled();
expect(rasterLayer.addTo).toHaveBeenCalledWith(map);
const [url, options = {}] = vi.mocked(leaflet.tileLayer).mock.calls[0];
// No referrer: the only one a browser can send is its origin, which
// identifies a Nabu Casa installation. The fallback source is chosen so it
// does not need one.
expect(options).not.toHaveProperty("referrerPolicy");
// The vector layer takes its credit from the style's source instead, so the
// raster layer is the only one carrying attribution itself - and it credits
// both the data and whoever rendered it.
expect(options.attribution).toContain("openstreetmap.org/copyright");
expect(options.attribution).toContain("carto.com/attributions");
expect(url).toMatch(/\{z\}\/\{x\}\/\{y\}/);
});
// The devices on the fallback are the old retina tablets, and the source
// serves @2x, so they get sharp tiles without quadrupling the requests.
it("asks for @2x raster tiles on a retina screen", async () => {
const createBaseLayer = await setWebGL2(false);
browser.retina = true;
await createBaseLayer(leaflet, map, false);
expect(vi.mocked(leaflet.tileLayer).mock.calls[0][0]).toContain("@2x.png");
browser.retina = false;
});
it("registers the RTL text plugin once, lazily, from our own host", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
await createBaseLayer(leaflet, map, false);
expect(setRTLTextPlugin).toHaveBeenCalledOnce();
expect(setRTLTextPlugin).toHaveBeenCalledWith(
`${location.origin}/static/map/mapbox-gl-rtl-text.js`,
true
);
});
it("uses vector tiles when WebGL2 is available", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
expect(maplibreGL).toHaveBeenCalledOnce();
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
it("falls back to raster tiles when the style cannot be fetched", async () => {
const createBaseLayer = await setWebGL2(true);
vi.stubGlobal(
"fetch",
vi.fn(async () => {
throw new Error("offline");
})
);
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
// The plugin only builds the MapLibre map once the layer is added, so this
// is where a blocked worker or a refused WebGL context surfaces.
it("falls back to raster tiles when adding the vector layer throws", async () => {
const createBaseLayer = await setWebGL2(true);
maplibreLayer.addTo.mockImplementation(() => {
throw new Error("Failed to initialize WebGL");
});
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
expect(maplibreLayer.remove).toHaveBeenCalled();
});
it("still falls back when tearing down the half-added layer throws", async () => {
const createBaseLayer = await setWebGL2(true);
maplibreLayer.addTo.mockImplementation(() => {
throw new Error("Failed to initialize WebGL");
});
maplibreLayer.remove.mockImplementation(() => {
throw new Error("nothing to remove");
});
await createBaseLayer(leaflet, map, false);
expect(isRaster()).toBe(true);
});
});
describe("setDarkMode", () => {
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);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
baseLayer.setDarkMode(true);
expect(glMap.setStyle).toHaveBeenCalledOnce();
});
// A failed request must roll back to the style that is actually on screen.
// Rolling back to the opposite of the failed request instead would desync
// 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);
const failing = vi.fn(async () => {
throw new Error("offline");
});
vi.stubGlobal("fetch", failing);
// Dark is superseded by light, which then fails: the map is still light.
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
await vi.waitFor(() => expect(failing).toHaveBeenCalledTimes(2));
// Both rejections have to land before the retry, or this passes whatever
// the rollback does.
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => structuredClone(STYLE) }))
);
baseLayer.setDarkMode(true);
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
});
// Styles are fetched, so a burst of theme changes can resolve out of order.
// Applying a stale one would leave the map on the wrong theme for good.
it("ignores a style that resolves after a newer request", async () => {
const createBaseLayer = await setWebGL2(true);
const resolvers: ((value: unknown) => void)[] = [];
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise((resolve) => {
resolvers.push(resolve);
})
)
);
const styleResponse = (name: string) => ({
json: async () => ({ ...structuredClone(STYLE), name }),
});
// 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);
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
resolvers.shift()!(styleResponse("light"));
const baseLayer = await pending;
baseLayer.setDarkMode(true);
baseLayer.setDarkMode(false);
await vi.waitFor(() => expect(resolvers).toHaveLength(2));
// The dark request, which is no longer the newest, comes back last.
resolvers[1](styleResponse("light"));
resolvers[0](styleResponse("dark"));
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
expect(glMap.setStyle.mock.calls[0][0]).toMatchObject({ name: "light" });
});
});
// Browsers cap the number of live WebGL contexts and drop the oldest. With
// more map cards than that cap - measured at 16 in Chrome - the first cards
// lose their context and never get it back, because nothing frees a slot.
describe("WebGL context loss", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("falls back to raster tiles when the context stays lost", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
expect(leaflet.tileLayer).not.toHaveBeenCalled();
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(maplibreLayer.remove).toHaveBeenCalled();
expect(isRaster()).toBe(true);
});
it("keeps the vector layer when the context comes back", async () => {
const createBaseLayer = await setWebGL2(true);
await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
vi.runAllTimers();
expect(maplibreLayer.remove).not.toHaveBeenCalled();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
// Backgrounding a tab loses the context too, and there it comes back when
// the page is shown again. Running the clock anyway would mean switching
// apps for a few seconds was enough to come back to a raster map.
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);
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(isRaster()).toBe(true);
});
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);
glHandlers.webglcontextlost();
glHandlers.webglcontextrestored();
hidden.mockReturnValue(false);
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(leaflet.tileLayer).not.toHaveBeenCalled();
});
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);
glHandlers.webglcontextlost();
vi.runAllTimers();
expect(remove).toHaveBeenCalledWith(
"visibilitychange",
expect.any(Function)
);
// And a later visibility change must not add a second raster layer.
document.dispatchEvent(new Event("visibilitychange"));
vi.runAllTimers();
expect(isRaster()).toBe(true);
});
it("stops answering theme changes once it has fallen back", async () => {
const createBaseLayer = await setWebGL2(true);
const baseLayer = await createBaseLayer(leaflet, map, false);
glHandlers.webglcontextlost();
vi.runAllTimers();
baseLayer.setDarkMode(true);
expect(glMap.setStyle).not.toHaveBeenCalled();
});
});
+7 -22
View File
@@ -247,15 +247,13 @@ describe("todo query params", () => {
});
describe("more-info query params", () => {
it("decodes the entity, view, and named hash params", () => {
it("decodes the entity and view", () => {
const params = decodeMoreInfoUrl(
"?more-info-entity-id=weather.home&more-info-view=info",
"#forecast=hourly"
"?more-info-entity-id=weather.home&more-info-view=info"
);
expect(params.entityId).toBe("weather.home");
expect(params.view).toBe("info");
expect(params.hash.get("forecast")).toBe("hourly");
});
it("ignores invalid views", () => {
@@ -271,22 +269,21 @@ describe("more-info query params", () => {
createMoreInfoUrl("/lovelace/home?theme=dark", {
entityId: "weather.home",
view: "info",
hash: new URLSearchParams({ forecast: "hourly" }),
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info"
);
});
it("removes more-info query params but preserves other hash state", () => {
it("removes more-info query params but preserves the hash", () => {
expect(
removeMoreInfoUrl(
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#forecast=hourly"
"/lovelace/home?theme=dark&more-info-entity-id=weather.home&more-info-view=info#some-anchor"
)
).toBe("/lovelace/home?theme=dark#forecast=hourly");
).toBe("/lovelace/home?theme=dark#some-anchor");
});
it("preserves an unrelated hash when creating a more-info url without a dialog hash", () => {
it("preserves the hash of the page it links from", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
entityId: "light.kitchen",
@@ -296,16 +293,4 @@ describe("more-info query params", () => {
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info#some-anchor"
);
});
it("clears an existing hash when an empty dialog hash is explicitly supplied", () => {
expect(
createMoreInfoUrl("/lovelace/home?theme=dark#some-anchor", {
entityId: "light.kitchen",
view: "info",
hash: new URLSearchParams(),
})
).toBe(
"/lovelace/home?theme=dark&more-info-entity-id=light.kitchen&more-info-view=info"
);
});
});
@@ -42,11 +42,10 @@ const renderExtent = (
return extent;
};
const withGap = (includeZero = false, unit?: string) => ({
const withGap = (includeZero = false) => ({
scale: !includeZero,
...createYAxisPrecisionBounds({
includeZero,
unit,
onFractionDigits: () => undefined,
}),
});
@@ -89,12 +88,6 @@ describe("Y-axis tick nudge", () => {
);
});
it("does not round a percentage axis past 100", () => {
const battery = [20, 100, 60, 20, 80];
expect(renderExtent(battery, withGap())).toEqual([0, 120]);
expect(renderExtent(battery, withGap(false, "%"))).toEqual([0, 100]);
});
it("re-anchors at zero when a chart switches to a zero-anchored type", () => {
// setOption merges the Y axis, so a gap that is only emitted conditionally
// survives the switch and leaves the bars floating.
@@ -186,42 +186,6 @@ describe("createYAxisPrecisionBounds", () => {
expect(max({ min: 0, max: 0 })).toBe(1);
});
it("stops a percentage axis at 100 the way it stops at zero", () => {
const { max } = makeBounds({ unit: "%" });
// A battery that reaches full would otherwise round out to an axis
// labelled up to 120%.
expect(max({ min: 20, max: 100 })).toBe(100);
expect(max({ min: 20, max: 99 })).toBe(100);
// Well clear of the ceiling, so it still gets its gap.
expect(max({ min: 40, max: 60 })).toBeUndefined();
// Not every % sensor is bounded: power factor is signed and can read over
// 100, and a series already past the ceiling must be left alone.
expect(max({ min: -80, max: 140 })).toBeUndefined();
});
it("holds a constant percentage under the ceiling too", () => {
const { max } = makeBounds({ unit: "%" });
// A device left on the charger reads a flat 100%, and the expansion a flat
// series gets is a fraction of its magnitude, so it overshoots on its own.
expect(max({ min: 100, max: 100 })).toBe(100);
expect(max({ min: 80, max: 80 })).toBe(100);
// Far enough below that the expansion never reaches the ceiling.
expect(max({ min: 54, max: 54 })).toBe(90);
});
it("only treats a percentage as bounded", () => {
// The same extent without the unit is widened as usual.
expect(makeBounds().max({ min: 20, max: 100 })).toBeUndefined();
expect(
makeBounds({ unit: "W" }).max({ min: 20, max: 100 })
).toBeUndefined();
});
it("never widens a zero-anchored axis", () => {
const { min, max, boundaryGap } = makeBounds({ includeZero: true });
@@ -1,256 +0,0 @@
import { describe, expect, it } from "vitest";
import { computeInitialHaFormData } from "../../../src/components/ha-form/compute-initial-ha-form-data";
import type { Selector } from "../../../src/data/selector";
import type { HaFormSchema } from "../../../src/components/ha-form/types";
const requiredField = (selector: Selector): HaFormSchema => ({
name: "value",
required: true,
selector,
});
const optionalField = (selector: Selector): HaFormSchema => ({
name: "value",
required: false,
selector,
});
describe("computeInitialHaFormData", () => {
it("initializes required text selector with an empty string", () => {
expect(computeInitialHaFormData([requiredField({ text: {} })])).toEqual({
value: "",
});
});
it("initializes required multiple text selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
text: {
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("initializes required object selector with an empty string", () => {
expect(computeInitialHaFormData([requiredField({ object: {} })])).toEqual({
value: "",
});
});
it("initializes required multiple object selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
object: {
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("leaves a required single device class selector unset", () => {
expect(
computeInitialHaFormData([
requiredField({
device_class: {
domain: "sensor",
},
}),
])
).toEqual({});
});
it("initializes a required multiple device class selector with an empty array", () => {
expect(
computeInitialHaFormData([
requiredField({
device_class: {
domain: "sensor",
multiple: true,
},
}),
])
).toEqual({
value: [],
});
});
it("does not initialize optional text selectors", () => {
expect(
computeInitialHaFormData([
optionalField({ text: {} }),
{
...optionalField({
text: {
multiple: true,
},
}),
name: "multiple",
},
])
).toEqual({});
});
it("does not initialize optional object selectors", () => {
expect(
computeInitialHaFormData([
optionalField({ object: {} }),
{
...optionalField({
object: {
multiple: true,
},
}),
name: "multiple",
},
])
).toEqual({});
});
it("initializes a required constant selector without losing falsy values", () => {
expect(
computeInitialHaFormData([
requiredField({
constant: {
value: false,
},
}),
])
).toEqual({
value: false,
});
expect(
computeInitialHaFormData([
requiredField({
constant: {
value: 0,
},
}),
])
).toEqual({
value: 0,
});
});
it("initializes a required choose selector from a constant first choice", () => {
const schema = [
{
name: "match",
required: true,
selector: {
choose: {
choices: {
Disabled: {
selector: {
constant: {
value: "",
},
},
},
Enabled: {
selector: {
number: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toEqual({
match: {
active_choice: "Disabled",
Disabled: "",
},
});
});
it("initializes a required choose selector from its child selector", () => {
const schema = [
{
name: "mode",
required: true,
selector: {
choose: {
choices: {
First: {
selector: {
text: {
multiple: true,
},
},
},
Second: {
selector: {
text: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toEqual({
mode: {
active_choice: "First",
First: [],
},
});
});
it("throws for an unsupported required selector", () => {
expect(() =>
computeInitialHaFormData([
requiredField({
ui_action: {
default_action: "none",
},
}),
])
).toThrow("Selector ui_action not supported in initial form data");
});
it("omits a first choose child without an initial value", () => {
const schema = [
{
name: "mode",
required: true,
selector: {
choose: {
choices: {
First: {
selector: {
ui_action: {
default_action: "none",
},
},
},
Second: {
selector: {
text: {},
},
},
},
},
},
},
] as const;
expect(computeInitialHaFormData(schema)).toStrictEqual({
mode: {
active_choice: "First",
},
});
});
});
@@ -1,11 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ObjectSelector } from "../../../src/data/selector";
import type { HomeAssistant } from "../../../src/types";
import type { HaObjectSelector } from "../../../src/components/ha-selector/ha-selector-object";
import type {
FormDialogData,
FormDialogParams,
} from "../../../src/dialogs/form/show-form-dialog";
import type { FormDialogParams } from "../../../src/dialogs/form/show-form-dialog";
import "../../../src/components/ha-selector/ha-selector-object";
vi.mock("../../../src/components/ha-input-helper-text", () => {
@@ -55,23 +51,12 @@ const selectorConfig = {
const getInternals = (selector: HaObjectSelector) =>
selector as unknown as Record<string, unknown>;
interface ItemActionEvent {
stopPropagation: () => void;
currentTarget: {
item?: FormDialogData;
index?: number;
};
}
const mountSelector = async (
value: FormDialogData | FormDialogData[] | "",
config: ObjectSelector = selectorConfig
) => {
const mountSelector = async (value: Record<string, string>[]) => {
const selector = document.createElement(
"ha-selector-object"
) as HaObjectSelector;
selector.hass = hass;
selector.selector = config;
selector.selector = selectorConfig;
selector.value = value;
document.body.append(selector);
await selector.updateComplete;
@@ -81,8 +66,8 @@ const mountSelector = async (
const resolveFormDialog = async (
selector: HaObjectSelector,
action: "_addItem" | "_editItem",
result: FormDialogData | null,
item?: FormDialogData,
result: Record<string, string> | null,
item?: Record<string, string>,
index?: number
) => {
let params: FormDialogParams | undefined;
@@ -102,14 +87,15 @@ const resolveFormDialog = async (
);
});
const event: ItemActionEvent = {
const event = {
stopPropagation: vi.fn(),
currentTarget: { item, index },
};
const operation = (
getInternals(selector)[action] as (event: ItemActionEvent) => Promise<void>
getInternals(selector)[action] as (ev: typeof event) => Promise<void>
)(event);
await Promise.all([dialogShown, operation]);
await dialogShown;
await operation;
return params!;
};
@@ -119,105 +105,6 @@ afterEach(() => {
});
describe("ha-selector-object form dialog flow", () => {
it("initializes Add dialog data from the object field schema", async () => {
const selector = await mountSelector([], {
object: {
multiple: true,
fields: {
name: {
required: true,
selector: { text: {} },
},
states: {
required: true,
selector: {
text: {
multiple: true,
},
},
},
},
},
});
const params = await resolveFormDialog(selector, "_addItem", {
name: "A",
states: ["on"],
});
expect(params.data).toEqual({
name: "",
states: [],
});
});
it("leaves unsupported required fields unset in Add dialog data", async () => {
const selector = await mountSelector([], {
object: {
multiple: true,
fields: {
name: {
required: true,
selector: { text: {} },
},
tap_action: {
required: true,
selector: {
ui_action: {
default_action: "none",
},
},
},
},
},
});
const params = await resolveFormDialog(selector, "_addItem", {
name: "A",
tap_action: {
action: "none",
},
});
expect(params.data).toEqual({
name: "",
});
});
it("restores the initialized empty value after an add-delete round trip", async () => {
const selector = await mountSelector("", {
object: {
fields: {
name: {
selector: { text: {} },
},
},
},
});
const valueChanged = vi.fn();
selector.addEventListener("value-changed", valueChanged);
await resolveFormDialog(selector, "_addItem", { name: "A" });
expect(valueChanged.mock.calls[0][0].detail.value).toEqual({ name: "A" });
selector.value = valueChanged.mock.calls[0][0].detail.value;
await selector.updateComplete;
const event: ItemActionEvent = {
stopPropagation: vi.fn(),
currentTarget: {
index: 0,
},
};
(getInternals(selector)._deleteItem as (event: ItemActionEvent) => void)(
event
);
expect(valueChanged.mock.calls[1][0].detail.value).toBe("");
});
it("appends an item through the real Add flow", async () => {
const first = { name: "A" };
const selector = await mountSelector([first]);
-187
View File
@@ -1,187 +0,0 @@
import { describe, expect, it } from "vitest";
import type { DeviceTrigger } from "../../src/data/device/device_automation";
import {
fetchReplacementDevices,
findEquivalentDeviceAutomation,
} from "../../src/data/device/device_automation";
import type { DeviceCompositeSplits } from "../../src/data/device/device_registry";
import type { EntityRegistryEntry } from "../../src/data/entity/entity_registry";
import type { HomeAssistant } from "../../src/types";
const entityRegistry = [
{ id: "regid1", entity_id: "binary_sensor.one" },
{ id: "regid2", entity_id: "binary_sensor.two" },
] as EntityRegistryEntry[];
const trigger = (partial: Partial<DeviceTrigger>): DeviceTrigger =>
({
trigger: "device",
domain: "binary_sensor",
device_id: "device1",
...partial,
}) as DeviceTrigger;
describe("findEquivalentDeviceAutomation", () => {
it("picks the automation on the same entity among several of the same type", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid2" })
)
).toBe(automations[1]);
});
it("matches an entity referenced by entity id against one referenced by registry id", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid2" }),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "binary_sensor.two" })
)
).toBe(automations[1]);
});
it("returns undefined when the same type is only offered for another entity", () => {
const automations = [
trigger({ device_id: "device2", type: "turned_on", entity_id: "regid1" }),
trigger({
device_id: "device2",
type: "turned_off",
entity_id: "regid1",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid2" })
)
).toBeUndefined();
});
it("matches entity-less automations on their subtype", () => {
const automations = [
trigger({
device_id: "device2",
domain: "zha",
type: "remote_button_short_press",
subtype: "button_1",
}),
trigger({
device_id: "device2",
domain: "zha",
type: "remote_button_short_press",
subtype: "button_2",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({
domain: "zha",
type: "remote_button_short_press",
subtype: "button_2",
})
)
).toBe(automations[1]);
});
it("returns undefined when the device offers no automation of that type", () => {
const automations = [
trigger({
device_id: "device2",
type: "turned_off",
entity_id: "regid1",
}),
];
expect(
findEquivalentDeviceAutomation(
entityRegistry,
automations,
trigger({ type: "turned_on", entity_id: "regid1" })
)
).toBeUndefined();
});
});
describe("fetchReplacementDevices", () => {
const hass = {
callWS: () => Promise.resolve([]),
devices: { device2: {}, device3: {} },
} as unknown as HomeAssistant;
const compositeSplits = {
removed: { split_ids: ["device2", "device3"], primary_id: "device2" },
} as unknown as DeviceCompositeSplits;
const value = trigger({
device_id: "removed",
type: "turned_on",
entity_id: "regid1",
});
it("keeps only the devices that offer the automation", async () => {
const offers = {
device2: [
trigger({
device_id: "device2",
type: "turned_on",
entity_id: "regid1",
}),
],
device3: [
trigger({
device_id: "device3",
type: "turned_on",
entity_id: "regid2",
}),
],
};
expect(
await fetchReplacementDevices(
hass,
entityRegistry,
value,
compositeSplits,
(_callWS, deviceId) => Promise.resolve(offers[deviceId])
)
).toEqual(["device2"]);
});
it("drops a device whose automations cannot be listed", async () => {
expect(
await fetchReplacementDevices(
hass,
entityRegistry,
value,
compositeSplits,
(_callWS, deviceId) =>
deviceId === "device2"
? Promise.reject(new Error("unknown device"))
: Promise.resolve([
trigger({
device_id: "device3",
type: "turned_on",
entity_id: "regid1",
}),
])
)
).toEqual(["device3"]);
});
});
+1 -7
View File
@@ -1,6 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { deepActiveElement } from "../../../src/common/dom/deep-active-element";
import { nextRender } from "../../../src/common/util/render-status";
import type {
FormDialogData,
FormDialogParams,
@@ -147,13 +146,8 @@ const submit = (dialog: DialogForm) =>
const cancel = (dialog: DialogForm) =>
(getInternals(dialog)["_cancel"] as () => void)();
afterEach(async () => {
afterEach(() => {
mockForm.delayedTag = undefined;
document.body.querySelectorAll("dialog-form").forEach((el) => {
(el as DialogForm).closeDialog();
});
// Drain the fire-and-forget focus restore before jsdom teardown.
await nextRender();
document.body.replaceChildren();
vi.clearAllMocks();
});
+14 -27
View File
@@ -389,8 +389,10 @@ test.describe("Light more-info dialog", () => {
}
});
test.describe("Weather more-info deep link", () => {
test("opens and synchronizes the selected forecast", async ({ page }) => {
test.describe("Weather more-info forecast", () => {
test("switches the rendered forecast when a tab is selected", async ({
page,
}) => {
await goToPanel(
page,
"/?scenario=weather-more-info&more-info-entity-id=weather.test_weather&more-info-view=info#/lovelace"
@@ -406,40 +408,25 @@ test.describe("Weather more-info deep link", () => {
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await page.locator("ha-test").evaluate((el) => {
el.dispatchEvent(
new CustomEvent("hass-more-info", {
detail: {
entityId: "weather.test_weather",
hash: new URLSearchParams({ forecast: "hourly" }),
},
bubbles: true,
composed: true,
})
);
});
// Only the hourly and twice daily forecasts group their items under a day
// header, so it tells the rendered forecast apart from the daily one.
await expect(weather.locator(".forecast-day-header")).toHaveCount(0);
await weather
.locator("ha-tab-group-tab")
.filter({ hasText: "Hourly" })
.click();
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Hourly" })
).toBeAttached();
await expect(weather.locator(".forecast-day-header").first()).toBeVisible();
await dialog.getByRole("button", { name: "History" }).click();
await expect(page).toHaveURL(/more-info-view=history/);
await dialog.getByRole("button", { name: "Back" }).click();
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await weather
.locator("ha-tab-group-tab")
.filter({ hasText: "Daily" })
.click();
await expect(
weather.locator("ha-tab-group-tab[active]").filter({ hasText: "Daily" })
).toBeAttached();
await expect(page).toHaveURL(/more-info-view=info/);
await dialog.getByRole("button", { name: "Close" }).click();
await expect(dialog).toBeHidden();
-197
View File
@@ -120,50 +120,6 @@ class RaceRouter extends HassRouterPage {
}
}
class PathPanel extends HTMLElement {
public itemId = "";
}
class DashPanel extends HTMLElement {}
class PathRouter extends HassRouterPage {
public editLoads = 0;
protected routerOptions: RouterOptions = {
routes: {
dashboard: { tag: "test-dash-panel", cache: true },
edit: {
tag: "test-path-panel",
cache: true,
itemId: true,
load: () => {
this.editLoads += 1;
return Promise.resolve();
},
},
view: { tag: "test-path-panel", cache: true },
},
};
protected updatePageEl(el: PathPanel) {
if (this._currentPage === "edit" || this._currentPage === "view") {
el.itemId = this.routeTail.path.slice(1);
}
}
}
class ParentRouter extends HassRouterPage {
protected routerOptions: RouterOptions = {
routes: {
automation: { tag: "test-path-router" },
},
};
protected updatePageEl(el: PathRouter) {
el.route = this.routeTail;
}
}
customElements.define("test-router", TestRouter);
customElements.define("test-immediate-panel", ImmediatePanel);
customElements.define("test-deferred-panel", DeferredPanel);
@@ -173,10 +129,6 @@ customElements.define("test-swap-router", SwapRouter);
customElements.define("test-swap-first-panel", SwapFirstPanel);
customElements.define("test-swap-second-panel", SwapSecondPanel);
customElements.define("test-race-router", RaceRouter);
customElements.define("test-path-panel", PathPanel);
customElements.define("test-dash-panel", DashPanel);
customElements.define("test-path-router", PathRouter);
customElements.define("test-parent-router", ParentRouter);
let router: TestRouter | undefined;
@@ -342,151 +294,6 @@ describe("HassRouterPage update propagation during load", () => {
});
});
describe("HassRouterPage item ids", () => {
it("recreates the page when the item id changes", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
expect(first.itemId).toBe("a");
expect(element.editLoads).toBe(1);
element.route = { prefix: "/config/automation", path: "/edit/b" };
await element.updateComplete;
const second = element.lastElementChild as PathPanel;
expect(second).not.toBe(first);
expect(second.itemId).toBe("b");
expect(first.isConnected).toBe(false);
expect(element.editLoads).toBe(2);
element.remove();
});
it("does not cache a page that has an item id", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
element.route = { prefix: "/config/automation", path: "/dashboard" };
await element.updateComplete;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
const second = element.lastElementChild as PathPanel;
expect(second).not.toBe(first);
expect(second.itemId).toBe("a");
element.remove();
});
it("still caches pages that have no item id", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/dashboard" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as DashPanel;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
element.route = { prefix: "/config/automation", path: "/dashboard" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
element.remove();
});
it("keeps the same page when the item id is unchanged", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/config/automation", path: "/edit/a" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
element.route = { prefix: "/config/automation", path: "/edit/a" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
expect(first.itemId).toBe("a");
element.remove();
});
it("reuses a page with a one-segment tail when itemId is not set", async () => {
const element = document.createElement("test-path-router") as PathRouter;
element.route = { prefix: "/lovelace", path: "/view/0" };
document.body.append(element);
await element.updateComplete;
const first = element.lastElementChild as PathPanel;
expect(first.itemId).toBe("0");
element.route = { prefix: "/lovelace", path: "/view/1" };
await element.updateComplete;
expect(element.lastElementChild).toBe(first);
expect(first.itemId).toBe("1");
element.remove();
});
it("does not recreate a nested router when opening an item from the dashboard", async () => {
const element = document.createElement(
"test-parent-router"
) as ParentRouter;
element.route = { prefix: "/config", path: "/automation/dashboard" };
document.body.append(element);
await element.updateComplete;
const nested = element.lastElementChild as PathRouter;
expect(nested).toBeInstanceOf(PathRouter);
await nested.updateComplete;
const dashboard = nested.lastElementChild as DashPanel;
expect(dashboard).toBeInstanceOf(DashPanel);
element.route = { prefix: "/config", path: "/automation/edit/a" };
await element.updateComplete;
await nested.updateComplete;
expect(element.lastElementChild).toBe(nested);
const editor = nested.lastElementChild as PathPanel;
expect(editor.itemId).toBe("a");
element.remove();
});
it("does not recreate a nested router when only its child's id changes", async () => {
const element = document.createElement(
"test-parent-router"
) as ParentRouter;
element.route = { prefix: "/config", path: "/automation/edit/a" };
document.body.append(element);
await element.updateComplete;
const nested = element.lastElementChild as PathRouter;
expect(nested).toBeInstanceOf(PathRouter);
await nested.updateComplete;
const firstLeaf = nested.lastElementChild as PathPanel;
expect(firstLeaf.itemId).toBe("a");
element.route = { prefix: "/config", path: "/automation/edit/b" };
await element.updateComplete;
await nested.updateComplete;
expect(element.lastElementChild).toBe(nested);
const secondLeaf = nested.lastElementChild as PathPanel;
expect(secondLeaf).not.toBe(firstLeaf);
expect(secondLeaf.itemId).toBe("b");
element.remove();
});
});
declare global {
interface HTMLElementTagNameMap {
"test-router": TestRouter;
@@ -499,9 +306,5 @@ declare global {
"test-swap-first-panel": SwapFirstPanel;
"test-swap-second-panel": SwapSecondPanel;
"test-race-router": RaceRouter;
"test-path-panel": PathPanel;
"test-dash-panel": DashPanel;
"test-path-router": PathRouter;
"test-parent-router": ParentRouter;
}
}
@@ -1,53 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { goBack, navigate } from "../../../../src/common/navigate";
import type * as NavigateModule from "../../../../src/common/navigate";
import "../../../../src/panels/config/automation/ha-automation-editor";
import type { HaAutomationEditor } from "../../../../src/panels/config/automation/ha-automation-editor";
import { createMockHass } from "../../../fixtures/hass";
vi.mock("../../../../src/common/navigate", async (importOriginal) => {
const actual = await importOriginal<typeof NavigateModule>();
return {
...actual,
goBack: vi.fn(),
navigate: vi.fn(),
};
});
vi.mock("../../../../src/dialogs/generic/show-dialog-box", () => ({
showAlertDialog: vi.fn(async () => undefined),
showConfirmationDialog: vi.fn(async () => true),
}));
describe("automation editor disconnected load", () => {
test("ignores a config fetch that 404s after the editor is disconnected", async () => {
const el = document.createElement(
"ha-automation-editor"
) as HaAutomationEditor;
const hass = createMockHass();
let rejectLoad!: (reason: { status_code: number }) => void;
(hass as any).callApi = vi.fn(
() =>
new Promise((_resolve, reject) => {
rejectLoad = reject;
})
);
el.hass = hass;
el.automations = [];
let connected = true;
Object.defineProperty(el, "isConnected", {
configurable: true,
get: () => connected,
});
const load = (el as any).loadConfig("missing");
connected = false;
rejectLoad({ status_code: 404 });
await load;
expect(goBack).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
});
@@ -1,295 +0,0 @@
import { describe, expect, it } from "vitest";
import { createZHANetworkChartData } from "../../../../../../src/panels/config/integrations/integration-panels/zha/zha-network-data";
import type { ZHADevice } from "../../../../../../src/data/zha";
import type { HomeAssistant } from "../../../../../../src/types";
const hass = {
devices: {},
areas: {},
localize: () => "",
} as unknown as HomeAssistant;
const device = (partial: Partial<ZHADevice>): ZHADevice =>
({
available: true,
name: partial.ieee!,
lqi: 0,
rssi: "",
last_seen: "",
manufacturer: "",
model: "",
quirk_applied: false,
quirk_class: "",
entities: [],
manufacturer_code: 0,
device_reg_id: partial.ieee!,
active_coordinator: false,
signature: {},
routes: [],
neighbors: [],
...partial,
}) as ZHADevice;
describe("createZHANetworkChartData", () => {
it("links a router to its upstream router instead of a nearby child device when routing tables are empty", () => {
// Regression test: many Zigbee radios (e.g. TI CC2652 via zigpy-znp)
// never populate Mgmt_Rtg routes, so the chart has to fall back to
// picking a device's strongest RF neighbor. A router's own child end
// device (e.g. a plug sitting right next to it) commonly reports a
// stronger LQI than the router's real uplink, which must not be
// allowed to hide the backbone connection.
const coordinator = device({
ieee: "coordinator",
device_type: "Coordinator",
nwk: 0,
});
const upstreamRouter = device({
ieee: "upstream-router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "coordinator",
nwk: "0x0000",
lqi: "200",
depth: "0",
relationship: "Parent",
},
],
});
const downstreamRouter = device({
ieee: "downstream-router",
device_type: "Router",
nwk: 2,
neighbors: [
// Real backbone link: weaker signal, correctly flagged as a sibling router
{
ieee: "upstream-router",
nwk: "0x0001",
lqi: "80",
depth: "1",
relationship: "Sibling",
},
// Nearby child end device with a much stronger signal than the real uplink
{
ieee: "child-end-device",
nwk: "0x0003",
lqi: "250",
depth: "2",
relationship: "Child",
},
],
});
const childEndDevice = device({
ieee: "child-end-device",
device_type: "EndDevice",
nwk: 3,
neighbors: [
{
ieee: "downstream-router",
nwk: "0x0002",
lqi: "250",
depth: "2",
relationship: "Parent",
},
],
});
const { links } = createZHANetworkChartData(
[coordinator, upstreamRouter, downstreamRouter, childEndDevice],
hass,
document.createElement("div")
);
const adjacency = new Map<string, string[]>();
for (const link of links) {
adjacency.set(link.source, [
...(adjacency.get(link.source) ?? []),
link.target,
]);
adjacency.set(link.target, [
...(adjacency.get(link.target) ?? []),
link.source,
]);
}
// Every device should be reachable from the coordinator. Before the
// fix, downstream-router picked its child as its only link, leaving
// it (and its children) as a disconnected island in the chart even
// though the real network is fully connected.
const reachable = new Set(["coordinator"]);
const queue = ["coordinator"];
while (queue.length) {
const current = queue.shift()!;
for (const neighbor of adjacency.get(current) ?? []) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
expect(reachable.has("upstream-router")).toBe(true);
expect(reachable.has("downstream-router")).toBe(true);
expect(reachable.has("child-end-device")).toBe(true);
});
it("ranks NoneOfTheAbove above Child, and treats unrecognized relationships as better than PreviousChild", () => {
const router = device({
ieee: "router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "n-child",
nwk: "0x0001",
lqi: "100",
depth: "1",
relationship: "Child",
},
{
ieee: "n-none-of-the-above",
nwk: "0x0002",
lqi: "100",
depth: "1",
relationship: "NoneOfTheAbove",
},
],
});
const unknownRelationship = device({
ieee: "router-2",
device_type: "Router",
nwk: 2,
neighbors: [
{
ieee: "n-unrecognized",
nwk: "0x0003",
lqi: "100",
depth: "1",
relationship: "SomeFutureValue",
},
{
ieee: "n-previous-child",
nwk: "0x0004",
lqi: "100",
depth: "1",
relationship: "PreviousChild",
},
],
});
const { links } = createZHANetworkChartData(
[router, unknownRelationship],
hass,
document.createElement("div")
);
const otherEndOf = (ieee: string) => {
const link = links.find((l) => l.source === ieee || l.target === ieee);
return link?.source === ieee ? link.target : link?.source;
};
expect(otherEndOf("router")).toBe("n-none-of-the-above");
expect(otherEndOf("router-2")).toBe("n-unrecognized");
});
it("connects every device regardless of backend device order (child listed before its parent router)", () => {
// Regression test for a subtler variant of the same bug: the fallback
// link is only computed for a device if it doesn't already have a
// link. If the backend lists a child end device before its parent
// router, the child claims the link first, and the router - now
// appearing to "already have a link" - never gets to evaluate its own
// (better) neighbor choice, splitting the graph exactly as before but
// triggered by device order instead of by LQI.
const coordinator = device({
ieee: "coordinator",
device_type: "Coordinator",
nwk: 0,
});
const upstreamRouter = device({
ieee: "upstream-router",
device_type: "Router",
nwk: 1,
neighbors: [
{
ieee: "coordinator",
nwk: "0x0000",
lqi: "200",
depth: "0",
relationship: "Parent",
},
],
});
const downstreamRouter = device({
ieee: "downstream-router",
device_type: "Router",
nwk: 2,
neighbors: [
{
ieee: "upstream-router",
nwk: "0x0001",
lqi: "80",
depth: "1",
relationship: "Sibling",
},
{
ieee: "child-end-device",
nwk: "0x0003",
lqi: "250",
depth: "2",
relationship: "Child",
},
],
});
const childEndDevice = device({
ieee: "child-end-device",
device_type: "EndDevice",
nwk: 3,
neighbors: [
{
ieee: "downstream-router",
nwk: "0x0002",
lqi: "250",
depth: "2",
relationship: "Parent",
},
],
});
// Same topology as the first test, but the child is listed before its
// parent router this time.
const { links } = createZHANetworkChartData(
[coordinator, upstreamRouter, childEndDevice, downstreamRouter],
hass,
document.createElement("div")
);
const adjacency = new Map<string, string[]>();
for (const link of links) {
adjacency.set(link.source, [
...(adjacency.get(link.source) ?? []),
link.target,
]);
adjacency.set(link.target, [
...(adjacency.get(link.target) ?? []),
link.source,
]);
}
const reachable = new Set(["coordinator"]);
const queue = ["coordinator"];
while (queue.length) {
const current = queue.shift()!;
for (const neighbor of adjacency.get(current) ?? []) {
if (!reachable.has(neighbor)) {
reachable.add(neighbor);
queue.push(neighbor);
}
}
}
expect(reachable.has("upstream-router")).toBe(true);
expect(reachable.has("downstream-router")).toBe(true);
expect(reachable.has("child-end-device")).toBe(true);
});
});
@@ -1,145 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterLowBatteryEntities } from "../../../../src/panels/maintenance/strategies/maintenance-view-strategy";
import { mockEntity } from "../../../common/entity/context/context-mock";
import { createMockEntityState, createMockHass } from "../../../fixtures/hass";
describe("filterLowBatteryEntities", () => {
it("filters numeric battery entities by the low battery threshold", () => {
const hass = createMockHass({
"sensor.low_battery": createMockEntityState("sensor.low_battery", "20", {
device_class: "battery",
}),
"sensor.ok_battery": createMockEntityState("sensor.ok_battery", "21", {
device_class: "battery",
}),
});
expect(
filterLowBatteryEntities(hass, [
"sensor.low_battery",
"sensor.ok_battery",
])
).toEqual(["sensor.low_battery"]);
});
it("excludes a low battery when its device is charging", () => {
const entities = {
"sensor.device_1_battery": mockEntity({
entity_id: "sensor.device_1_battery",
device_id: "device_1",
}),
"binary_sensor.device_1_battery_charging": mockEntity({
entity_id: "binary_sensor.device_1_battery_charging",
device_id: "device_1",
}),
"sensor.device_2_battery": mockEntity({
entity_id: "sensor.device_2_battery",
device_id: "device_2",
}),
"binary_sensor.device_2_battery_charging": mockEntity({
entity_id: "binary_sensor.device_2_battery_charging",
device_id: "device_2",
}),
};
const hass = createMockHass(
{
"sensor.device_1_battery": createMockEntityState(
"sensor.device_1_battery",
"10",
{ device_class: "battery" }
),
"binary_sensor.device_1_battery_charging": createMockEntityState(
"binary_sensor.device_1_battery_charging",
"on",
{ device_class: "battery_charging" }
),
"sensor.device_2_battery": createMockEntityState(
"sensor.device_2_battery",
"10",
{ device_class: "battery" }
),
"binary_sensor.device_2_battery_charging": createMockEntityState(
"binary_sensor.device_2_battery_charging",
"off",
{ device_class: "battery_charging" }
),
},
{ entities }
);
expect(
filterLowBatteryEntities(hass, [
"sensor.device_1_battery",
"sensor.device_2_battery",
])
).toEqual(["sensor.device_2_battery"]);
});
it("keeps binary battery sensor behavior unchanged", () => {
const hass = createMockHass({
"binary_sensor.low_battery": createMockEntityState(
"binary_sensor.low_battery",
"on",
{ device_class: "battery" }
),
"binary_sensor.ok_battery": createMockEntityState(
"binary_sensor.ok_battery",
"off",
{ device_class: "battery" }
),
});
expect(
filterLowBatteryEntities(hass, [
"binary_sensor.low_battery",
"binary_sensor.ok_battery",
])
).toEqual(["binary_sensor.low_battery"]);
});
it("updates the device lookup when the entity registry changes", () => {
const states = {
"sensor.battery": createMockEntityState("sensor.battery", "10", {
device_class: "battery",
}),
"binary_sensor.battery_charging": createMockEntityState(
"binary_sensor.battery_charging",
"on",
{ device_class: "battery_charging" }
),
};
const firstHass = createMockHass(states, {
entities: {
"sensor.battery": mockEntity({
entity_id: "sensor.battery",
device_id: "device_1",
}),
"binary_sensor.battery_charging": mockEntity({
entity_id: "binary_sensor.battery_charging",
device_id: "device_1",
}),
},
});
expect(filterLowBatteryEntities(firstHass, ["sensor.battery"])).toEqual([]);
const secondHass = createMockHass(states, {
entities: {
"sensor.battery": mockEntity({
entity_id: "sensor.battery",
device_id: "device_1",
}),
"binary_sensor.battery_charging": mockEntity({
entity_id: "binary_sensor.battery_charging",
device_id: "device_2",
}),
},
});
expect(filterLowBatteryEntities(secondHass, ["sensor.battery"])).toEqual([
"sensor.battery",
]);
});
});
+91 -343
View File
@@ -3495,124 +3495,6 @@ __metadata:
languageName: node
linkType: hard
"@mapbox/jsonlint-lines-primitives@npm:^2.0.2, @mapbox/jsonlint-lines-primitives@npm:~2.0.2":
version: 2.0.3
resolution: "@mapbox/jsonlint-lines-primitives@npm:2.0.3"
checksum: 10/99404df11b1840153c910154c238f62ba0ea7cfab6aa7ed3bad1e522d31b746bfad78b23e6ee4887196acec76759f1863b5cb4f5e2d95d0dc2a8ea73091c78f6
languageName: node
linkType: hard
"@mapbox/mapbox-gl-rtl-text@npm:0.4.0":
version: 0.4.0
resolution: "@mapbox/mapbox-gl-rtl-text@npm:0.4.0"
checksum: 10/a678240e4cc6f589726ef2f35fac64a7eb073451a8686c09db58041d3fb351d2e72100bb12607591efc298d8ac5e19691c593b3c213d121b2a0e84978254e1a8
languageName: node
linkType: hard
"@mapbox/point-geometry@npm:^1.1.0, @mapbox/point-geometry@npm:~1.1.0":
version: 1.1.0
resolution: "@mapbox/point-geometry@npm:1.1.0"
checksum: 10/1e649be5c6c83584fae9e043a398c61ad56f81e2ae5334deeb4048884ed1f2a5dc7e727e1757e3159ebf12e6cb1f5b059519078df8d16452bf7c7ab26323eb4a
languageName: node
linkType: hard
"@mapbox/tiny-sdf@npm:^2.1.0":
version: 2.2.0
resolution: "@mapbox/tiny-sdf@npm:2.2.0"
checksum: 10/8a6c2664cc6d054155e6812bce41cbb7f2b86d2564adce0aebaadd141a827b78209bc026cd9d4d605095df5556274fb541f520222d19eb2b68d65156db9819d9
languageName: node
linkType: hard
"@mapbox/unitbezier@npm:^0.0.1":
version: 0.0.1
resolution: "@mapbox/unitbezier@npm:0.0.1"
checksum: 10/bf104c85dbff37bf47d3217d9457a3abbf23714f78fefadea64e56bdc7c538491b626166809ef28db134f09baccd6ca3df6988a6422df90d8d0c9a23b0686043
languageName: node
linkType: hard
"@mapbox/unitbezier@npm:^1.0.0":
version: 1.0.0
resolution: "@mapbox/unitbezier@npm:1.0.0"
checksum: 10/41371f6edf0c643da0867453c18e1f7070d4c342339817495075bbb841d5283589a1b5492da6f2eb617803e0d6efc2e5354080b302b0eb84b13b3dcd64991230
languageName: node
linkType: hard
"@mapbox/vector-tile@npm:^2.0.4":
version: 2.0.5
resolution: "@mapbox/vector-tile@npm:2.0.5"
dependencies:
"@mapbox/point-geometry": "npm:~1.1.0"
"@types/geojson": "npm:^7946.0.16"
pbf: "npm:^4.0.2"
checksum: 10/ca788f6c34bc699033f02f6f9a8fc2a4539cfb6c85bdd5a3a143c31698ae8865e99e3b51a4cf11a2070a14c435cba2ec8ca1bcad4eeac0cd678b972a7736c402
languageName: node
linkType: hard
"@mapbox/whoots-js@npm:^3.1.0":
version: 3.1.0
resolution: "@mapbox/whoots-js@npm:3.1.0"
checksum: 10/c1837c04effd205b207f441356d952eae7e8aad6c58f7c4900de50318c2147cf175936fc9434f20dfa409f9e6a78ec604d61e70c1c20572db0cc7655fbb65f50
languageName: node
linkType: hard
"@maplibre/geojson-vt@npm:^6.1.0":
version: 6.1.1
resolution: "@maplibre/geojson-vt@npm:6.1.1"
dependencies:
kdbush: "npm:^4.1.0"
checksum: 10/61c6ca6dc49d799f22dd98b450d48cf172cacf886edbe73e195b0526fe0bad9c42521792f1c7f4361da0069f604517941b71def7f4745fa945c9635a1eb3e250
languageName: node
linkType: hard
"@maplibre/maplibre-gl-leaflet@npm:0.1.4":
version: 0.1.4
resolution: "@maplibre/maplibre-gl-leaflet@npm:0.1.4"
peerDependencies:
"@types/leaflet": ^1.9.0
leaflet: ^1.9.3
maplibre-gl: ^2.4.0 || ^3.3.1 || ^4.3.2 || ^5.0.0 || ^6.0.0
checksum: 10/9a6d175eaa19d51505aaa2cbd16b78756530cb83a6b770e9a9859df566b9cce38b3ca904423bfde1798e539c6a1e5ff37cc4502e896c829dc219bece8b0b1ee6
languageName: node
linkType: hard
"@maplibre/maplibre-gl-style-spec@npm:^24.8.1":
version: 24.10.0
resolution: "@maplibre/maplibre-gl-style-spec@npm:24.10.0"
dependencies:
"@mapbox/jsonlint-lines-primitives": "npm:~2.0.2"
"@mapbox/unitbezier": "npm:^1.0.0"
json-stringify-pretty-compact: "npm:^4.0.0"
minimist: "npm:^1.2.8"
quickselect: "npm:^3.0.0"
tinyqueue: "npm:^3.0.0"
bin:
gl-style-format: dist/gl-style-format.mjs
gl-style-migrate: dist/gl-style-migrate.mjs
gl-style-validate: dist/gl-style-validate.mjs
checksum: 10/7d580c71a7978ffd6c93a95a9178eb55fa0777eb63f1954afec58934e940a7d1432ecd51e98b725e01ae97298b7a60d6a2a19a863952fad45f97ae609ad0d886
languageName: node
linkType: hard
"@maplibre/mlt@npm:^1.1.8":
version: 1.2.0
resolution: "@maplibre/mlt@npm:1.2.0"
dependencies:
"@mapbox/point-geometry": "npm:^1.1.0"
checksum: 10/9bc3d30f78dc2090ac80634e08848bf454daed888fcbcace7e1c1c9b8d1db6c5421376f681dffe90bb4274488753775f36e76a1568ba724e64a0ca59207c04c4
languageName: node
linkType: hard
"@maplibre/vt-pbf@npm:^4.3.0":
version: 4.3.2
resolution: "@maplibre/vt-pbf@npm:4.3.2"
dependencies:
"@mapbox/point-geometry": "npm:^1.1.0"
"@types/geojson": "npm:^7946.0.16"
pbf: "npm:^5.1.0"
checksum: 10/731a6a094964bc004b51451096cdb5ff65ca60f1c443e638f0e316bb1b24431e67988531bb2f9cb854d3ec31597f32fbbf7de550245efac3bfb1a0cc1d33b567
languageName: node
linkType: hard
"@marijn/find-cluster-break@npm:^1.0.0":
version: 1.0.3
resolution: "@marijn/find-cluster-break@npm:1.0.3"
@@ -5768,7 +5650,7 @@ __metadata:
languageName: node
linkType: hard
"@types/geojson@npm:*, @types/geojson@npm:^7946.0.16":
"@types/geojson@npm:*":
version: 7946.0.16
resolution: "@types/geojson@npm:7946.0.16"
checksum: 10/34d07421bdd60e7b99fa265441d17ac6e9aef48e3ce22d04324127d0de1daf7fbaa0bd3be1cece2092eb6995f21da84afa5231e24621a2910ff7340bc98f496f
@@ -5958,105 +5840,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.68.0"
"@typescript-eslint/eslint-plugin@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/type-utils": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/type-utils": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.68.0
"@typescript-eslint/parser": ^8.67.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/8776fd8ffde8eceae3b1aeca57e528e8ee61a683223c3bef455ca3c5e28e52275e6cd6ebf8ee284a46883e6220bbf297312084e1ee85e00cef017a78c169a674
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/parser@npm:8.68.0"
"@typescript-eslint/parser@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/parser@npm:8.67.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/979ae0d2c6a391fd5b9b0218189fda5112ff0b5ab2538eef1da4cd77ac8ae46f9b590db9779f2614bd193105b40cf12635b565c840f4cdbbb4a683e24cf5ec4b
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/project-service@npm:8.68.0"
"@typescript-eslint/project-service@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/project-service@npm:8.67.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.68.0"
"@typescript-eslint/types": "npm:^8.68.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
"@typescript-eslint/types": "npm:^8.67.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/37521df3731f9069c7df55362342dd0027f43947241de33c2a1c029b4553fe092e2ac69a534153aaf08c862e6b2390d864cb6c88a0714fc276395390df276fb6
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/scope-manager@npm:8.68.0"
"@typescript-eslint/scope-manager@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
checksum: 10/494d499315b4f3ab8eaa114aa53a4b2b9f533b82eb007cd5b5617cececfd8ad6938edb74f734479ae6e40a4086f510b6b984de8e66d06c518cb47817549db780
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.68.0, @typescript-eslint/tsconfig-utils@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.68.0"
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/b527a7c5deaad40bb72f78eec1bfea5997a7e792e64666984605e9a167246cf856c8d40290a7b3d39cefb3a242538e22a3a4fc136132e4669a314887e2de5f08
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/type-utils@npm:8.68.0"
"@typescript-eslint/type-utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/ed6b6fe74eee017a79c9dbfa72b3a08748dac8de5702314494d8849feee516c20e2907ad730fd82e50be81c5984c575b29e35eb8b5c99091445b4bc5102ce836
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.68.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/types@npm:8.68.0"
checksum: 10/f9337e0263d95e6edca6e364f9b1f70d5840b9648b5aa0d02065bcee5809a50c92d024ba17b458182ec4e7fffe5efd05584f1667b1e43ff3e7121b218283a207
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/types@npm:8.67.0"
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/typescript-estree@npm:8.68.0"
"@typescript-eslint/typescript-estree@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.68.0"
"@typescript-eslint/tsconfig-utils": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/visitor-keys": "npm:8.68.0"
"@typescript-eslint/project-service": "npm:8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -6064,32 +5946,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/47d75cbc9c10842aeb3c1cc8d0625bd578b3b43b75d3dc86d3543a739b2fc8d0b524fd86b5af1f1493a3f0ae1b9e65c3200bf01a6bde0162efe6414708eac553
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/utils@npm:8.68.0"
"@typescript-eslint/utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/utils@npm:8.67.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/6581a1e7ced23cce82279df001d643844c97408bc84826da77ec77d52b77047004b202ff8cfcfbc0b0fef15bf2cafb5ae21bafbda24c9b58102ce634f66c2af9
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.68.0":
version: 8.68.0
resolution: "@typescript-eslint/visitor-keys@npm:8.68.0"
"@typescript-eslint/visitor-keys@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
dependencies:
"@typescript-eslint/types": "npm:8.68.0"
"@typescript-eslint/types": "npm:8.67.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/d802bc2c4569495f3d83f9bcf169e3a432ac1c6672e243399886409dbc55d7fa30375d9d0b2cf0d9466b81fc2b01b991483eb56e8cdb8141721009978d3b078b
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
languageName: node
linkType: hard
@@ -6462,15 +6344,6 @@ __metadata:
languageName: node
linkType: hard
"@versatiles/style@npm:5.13.1":
version: 5.13.1
resolution: "@versatiles/style@npm:5.13.1"
dependencies:
brace-expansion: "npm:^5.0.9"
checksum: 10/046be455df89f55bc6a8b1b63a01169f68e701799249761c49523c6098b871d9cdb5b0c8a6a1a15503eee17d89f6fb9f0421eb48cf71d70dfa2cee2900a5b25a
languageName: node
linkType: hard
"@vibrant/color@npm:4.0.4, @vibrant/color@npm:^4.0.4":
version: 4.0.4
resolution: "@vibrant/color@npm:4.0.4"
@@ -7439,7 +7312,7 @@ __metadata:
languageName: node
linkType: hard
"brace-expansion@npm:^5.0.8, brace-expansion@npm:^5.0.9":
"brace-expansion@npm:^5.0.8":
version: 5.0.9
resolution: "brace-expansion@npm:5.0.9"
dependencies:
@@ -8549,13 +8422,6 @@ __metadata:
languageName: node
linkType: hard
"earcut@npm:^3.0.2":
version: 3.2.3
resolution: "earcut@npm:3.2.3"
checksum: 10/3c3c6bfc214060e6df83da1da413709cf15c77139b0e094a71e6e960ffce033c88586c1e880496ea0a39d891df74a065ccfe24c18f426898ea07a51e0fc0a446
languageName: node
linkType: hard
"eastasianwidth@npm:^0.2.0":
version: 0.2.0
resolution: "eastasianwidth@npm:0.2.0"
@@ -9104,9 +8970,9 @@ __metadata:
languageName: node
linkType: hard
"eslint@npm:10.9.1":
version: 10.9.1
resolution: "eslint@npm:10.9.1"
"eslint@npm:10.9.0":
version: 10.9.0
resolution: "eslint@npm:10.9.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.8.0"
"@eslint-community/regexpp": "npm:^4.12.2"
@@ -9145,7 +9011,7 @@ __metadata:
optional: true
bin:
eslint: bin/eslint.js
checksum: 10/4cee237c7d2c445a05330e595b18521d7452fd3f419241bf1ccf81c769a6761c10ca5a06988197482d4842ed1a26b1dcb51b9526408990cee84559e6c08cb3ab
checksum: 10/1cd63bbf4f2c003ed759ab6ae9e2030be0abafdcd7b9ffb5be9e9c3aa3ea6142e6af0a337aac21044957750807fde06c9d052e2c9734cca6792dc2d095f9c180
languageName: node
linkType: hard
@@ -9806,13 +9672,6 @@ __metadata:
languageName: node
linkType: hard
"gl-matrix@npm:^3.4.4":
version: 3.4.4
resolution: "gl-matrix@npm:3.4.4"
checksum: 10/0a19a881fbfa2cdcff2b5ece0f62041d17e55665393349653e8742a20e43d4516239c68ac6798baa7c35b0c7bd6c9226e70e7824af162228d471eba358a03090
languageName: node
linkType: hard
"glob-parent@npm:^5.1.2, glob-parent@npm:~5.1.2":
version: 5.1.2
resolution: "glob-parent@npm:5.1.2"
@@ -10155,8 +10014,6 @@ __metadata:
"@lit/reactive-element": "npm:2.1.2"
"@lit/task": "npm:1.0.3"
"@lokalise/node-api": "npm:16.3.0"
"@mapbox/mapbox-gl-rtl-text": "npm:0.4.0"
"@maplibre/maplibre-gl-leaflet": "npm:0.1.4"
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch"
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
"@material/web": "npm:2.5.0"
@@ -10189,7 +10046,6 @@ __metadata:
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"
"@typescript/native": "npm:[email protected]"
"@versatiles/style": "npm:5.13.1"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.11"
"@vvo/tzdb": "npm:6.198.0"
@@ -10214,7 +10070,7 @@ __metadata:
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.1"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.9.1"
eslint: "npm:10.9.0"
eslint-config-prettier: "npm:10.1.8"
eslint-import-resolver-webpack: "npm:0.13.11"
eslint-plugin-import-x: "npm:4.17.1"
@@ -10237,7 +10093,7 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.14"
js-yaml: "npm:5.4.0"
js-yaml: "npm:5.3.0"
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10253,8 +10109,7 @@ __metadata:
lodash.template: "npm:4.18.1"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
maplibre-gl: "npm:5.24.0"
marked: "npm:18.0.11"
marked: "npm:18.0.10"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.2.0"
node-vibrant: "npm:4.0.4"
@@ -10277,7 +10132,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.68.0"
typescript-eslint: "npm:8.67.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.11"
webpack-stats-plugin: "npm:1.1.3"
@@ -11185,14 +11040,14 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.4.0":
version: 5.4.0
resolution: "js-yaml@npm:5.4.0"
"js-yaml@npm:5.3.0":
version: 5.3.0
resolution: "js-yaml@npm:5.3.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/d8216083212772b203a1ee16be2ea96a1638c6d7f0ff769ba4da274b5ad29e858bd26d96761feb8937fce1aa47e8acfa79b2cd21a8ced724cc5555c11b4d056c
checksum: 10/6c7e8ea8dd5643d9df73f4aa796f3572537284e9b819b74ef919467452bfa4624f8f7477cfc775c91db7e2f32387133b1b6c4e95aef9702be6bb042ae554884a
languageName: node
linkType: hard
@@ -11306,13 +11161,6 @@ __metadata:
languageName: node
linkType: hard
"json-stringify-pretty-compact@npm:^4.0.0":
version: 4.0.0
resolution: "json-stringify-pretty-compact@npm:4.0.0"
checksum: 10/a10d5c423e467872994a49c5c1b56b073f277ce02d899cf567fc625f3783b89406bee6408bfb3b4bdeeff509b6a562f5259227e26754a6186f721809ca895f0c
languageName: node
linkType: hard
"json-with-bigint@npm:^3.5.3":
version: 3.5.11
resolution: "json-with-bigint@npm:3.5.11"
@@ -11382,13 +11230,6 @@ __metadata:
languageName: node
linkType: hard
"kdbush@npm:^4.0.2, kdbush@npm:^4.1.0":
version: 4.1.0
resolution: "kdbush@npm:4.1.0"
checksum: 10/40d0a1a8e9928fabc3b5f7026b280573e718020c74438a4b5702400f8a6e4c38fb6110c456d6e592f56352da7011cc794ed47195c9c501647bcb16287c1d7dd2
languageName: node
linkType: hard
"keyv@npm:^4.5.4":
version: 4.5.4
resolution: "keyv@npm:4.5.4"
@@ -11925,39 +11766,12 @@ __metadata:
languageName: node
linkType: hard
"maplibre-gl@npm:5.24.0":
version: 5.24.0
resolution: "maplibre-gl@npm:5.24.0"
dependencies:
"@mapbox/jsonlint-lines-primitives": "npm:^2.0.2"
"@mapbox/point-geometry": "npm:^1.1.0"
"@mapbox/tiny-sdf": "npm:^2.1.0"
"@mapbox/unitbezier": "npm:^0.0.1"
"@mapbox/vector-tile": "npm:^2.0.4"
"@mapbox/whoots-js": "npm:^3.1.0"
"@maplibre/geojson-vt": "npm:^6.1.0"
"@maplibre/maplibre-gl-style-spec": "npm:^24.8.1"
"@maplibre/mlt": "npm:^1.1.8"
"@maplibre/vt-pbf": "npm:^4.3.0"
"@types/geojson": "npm:^7946.0.16"
earcut: "npm:^3.0.2"
gl-matrix: "npm:^3.4.4"
kdbush: "npm:^4.0.2"
murmurhash-js: "npm:^1.0.0"
pbf: "npm:^4.0.1"
potpack: "npm:^2.1.0"
quickselect: "npm:^3.0.0"
tinyqueue: "npm:^3.0.0"
checksum: 10/0bf5bd004e1ae545456407bd42f201913e9e67fb302b7d18804c81dffd0feefad55dfb249514c7ad62e8cea57621777f1061f8187e254a108230e4f7eb4dd8e9
languageName: node
linkType: hard
"marked@npm:18.0.11":
version: 18.0.11
resolution: "marked@npm:18.0.11"
"marked@npm:18.0.10":
version: 18.0.10
resolution: "marked@npm:18.0.10"
bin:
marked: bin/marked.js
checksum: 10/6da69f17820c7442358d751e902d3e0e06fef957e1178d634db938cf339c4504399c9f11a953b0a116b1e5bebc1c7fb52726777cc6c351f2c9f39e4a8276e21a
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
languageName: node
linkType: hard
@@ -12115,7 +11929,7 @@ __metadata:
languageName: node
linkType: hard
"minimist@npm:^1.2.0, minimist@npm:^1.2.8":
"minimist@npm:^1.2.0":
version: 1.2.8
resolution: "minimist@npm:1.2.8"
checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f
@@ -12221,13 +12035,6 @@ __metadata:
languageName: node
linkType: hard
"murmurhash-js@npm:^1.0.0":
version: 1.0.0
resolution: "murmurhash-js@npm:1.0.0"
checksum: 10/875a24e0dd7870e51a7f73906e158fb06de50478669629746a35955cb0a00b6bb797f6b5a2884ee4ec4feefb9c5c27b74190f561eb72530ffc1c5d7c5429f49a
languageName: node
linkType: hard
"mute-stdout@npm:^2.0.0":
version: 2.0.0
resolution: "mute-stdout@npm:2.0.0"
@@ -12994,28 +12801,6 @@ __metadata:
languageName: node
linkType: hard
"pbf@npm:^4.0.1, pbf@npm:^4.0.2":
version: 4.0.2
resolution: "pbf@npm:4.0.2"
dependencies:
resolve-protobuf-schema: "npm:^2.1.0"
bin:
pbf: bin/pbf
checksum: 10/a6d60f2f374c51bcb08b1b49d08c01aed31c4e9cfbf43002178e1cf0f63c5f5fc4aa9dd0220948be775e2d0bcc644954b9d1e8dca91466a5160dcc0a0313edd3
languageName: node
linkType: hard
"pbf@npm:^5.1.0":
version: 5.1.2
resolution: "pbf@npm:5.1.2"
dependencies:
resolve-protobuf-schema: "npm:^2.1.0"
bin:
pbf: bin/pbf
checksum: 10/add0663777f352c9432503e5fa18eb5be112a8ee4d32b233b8c7128bebddb013b874748f63d9db1a6f3050d077c70acdafda75fd8d4b66a75af627ec2db4b7a8
languageName: node
linkType: hard
"peek-readable@npm:^4.1.0":
version: 4.1.0
resolution: "peek-readable@npm:4.1.0"
@@ -13149,13 +12934,6 @@ __metadata:
languageName: node
linkType: hard
"potpack@npm:^2.1.0":
version: 2.1.0
resolution: "potpack@npm:2.1.0"
checksum: 10/8b5c07c8569f06cb14a8034de3057129f8c4909837cb892eb6c1cbe79f06ee68f2e9fbc0610599e2e38a29a489e1ed72fbb4059e7ff6f648826fc961d4c0f607
languageName: node
linkType: hard
"preact@npm:~10.12.1":
version: 10.12.1
resolution: "preact@npm:10.12.1"
@@ -13249,13 +13027,6 @@ __metadata:
languageName: node
linkType: hard
"protocol-buffers-schema@npm:^3.3.1":
version: 3.6.1
resolution: "protocol-buffers-schema@npm:3.6.1"
checksum: 10/a7ca74e71227932f903feab9df8ffde80703b656fd024179b3a9439edb3907eca4c78f0cd6f47ad7698e832ae565a8df7585d9cedcc4f043794e6ba8e480cdec
languageName: node
linkType: hard
"punycode@npm:2.3.1, punycode@npm:^2.1.0, punycode@npm:^2.3.1":
version: 2.3.1
resolution: "punycode@npm:2.3.1"
@@ -13299,13 +13070,6 @@ __metadata:
languageName: node
linkType: hard
"quickselect@npm:^3.0.0":
version: 3.0.0
resolution: "quickselect@npm:3.0.0"
checksum: 10/8f72bedb8bb14bce5c3767c55f567bc296fa3ca9d98ba385e3867e434463bc633feee1eddf3dfec17914b7e88feeb08c7b313cf47114a8ff11bf964f77f51cfc
languageName: node
linkType: hard
"range-parser@npm:1.2.0":
version: 1.2.0
resolution: "range-parser@npm:1.2.0"
@@ -13602,15 +13366,6 @@ __metadata:
languageName: node
linkType: hard
"resolve-protobuf-schema@npm:^2.1.0":
version: 2.1.0
resolution: "resolve-protobuf-schema@npm:2.1.0"
dependencies:
protocol-buffers-schema: "npm:^3.3.1"
checksum: 10/88fffab2a3757888884a36f9aa4e24be5186b01820a8c26297dc1ce406b9daf776594926bdf524c2c8e8e5b0aba8ac48362b6584cdecc9a7083215ebca01c599
languageName: node
linkType: hard
"resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.11":
version: 1.22.12
resolution: "resolve@npm:1.22.12"
@@ -14984,13 +14739,6 @@ __metadata:
languageName: node
linkType: hard
"tinyqueue@npm:^3.0.0":
version: 3.0.0
resolution: "tinyqueue@npm:3.0.0"
checksum: 10/44195ae628e98f4de49acefac1fafa63a7f2b5d8a5c23ace6f49917109db3435db8ec9854f87c0d50f8a8c6a73f1526f3941921618a071e4ee1d246afacf69bb
languageName: node
linkType: hard
"tinyrainbow@npm:^3.1.0":
version: 3.1.1
resolution: "tinyrainbow@npm:3.1.1"
@@ -15240,18 +14988,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.68.0":
version: 8.68.0
resolution: "typescript-eslint@npm:8.68.0"
"typescript-eslint@npm:8.67.0":
version: 8.67.0
resolution: "typescript-eslint@npm:8.67.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.68.0"
"@typescript-eslint/parser": "npm:8.68.0"
"@typescript-eslint/typescript-estree": "npm:8.68.0"
"@typescript-eslint/utils": "npm:8.68.0"
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
"@typescript-eslint/parser": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/451c64296ac5f48e4621a03324f5aaef4a26f1dd9d4df63bd3cb07f136e8379185a800731f83634eb5d909c696fad820ba74d31ab3d7cf263328504275d1305b
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
languageName: node
linkType: hard