mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-28 18:07:14 +00:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
709d22e777 | ||
|
|
cb4e925321 | ||
|
|
69c1735ce6 | ||
|
|
be6461ed9a | ||
|
|
7f86074e07 | ||
|
|
4b9e47079c | ||
|
|
e3b9c6e143 | ||
|
|
221f184cb1 | ||
|
|
27cbbdb83f | ||
|
|
337e88560e | ||
|
|
9c96c56558 | ||
|
|
27e0c4492c | ||
|
|
80889c3cea | ||
|
|
cbbaf51e75 | ||
|
|
9778a54305 | ||
|
|
b9125537c0 | ||
|
|
fd6017a963 | ||
|
|
e0f5d6d6b3 | ||
|
|
193ab1b424 | ||
|
|
4a3b6db411 | ||
|
|
46271d0435 | ||
|
|
d4bc02c86b | ||
|
|
a12ae9a8ed | ||
|
|
6095787294 | ||
|
|
127702b96f | ||
|
|
56a6c5d9fb | ||
|
|
3b686dc3b9 | ||
|
|
c5e8dea750 | ||
|
|
29cd46eb9e | ||
|
|
92b3081896 | ||
|
|
527319ab5e | ||
|
|
8d3877c388 | ||
|
|
66b9fbd4bf |
@@ -75,5 +75,3 @@ test/e2e/app/dist/
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
# Downloaded map glyph and sprite archives
|
||||
.map-assets/
|
||||
|
||||
@@ -1,136 +1,25 @@
|
||||
// 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.
|
||||
// Generates the MapLibre styles for the vector base map.
|
||||
//
|
||||
// Glyphs and sprites come from pinned VersaTiles releases, cached locally and
|
||||
// verified against a digest.
|
||||
// Only the styles. Glyphs, sprites and tiles are served by core's proxy, which
|
||||
// is what lets them be requested with an application User-Agent and without a
|
||||
// referrer. The styles stay here because they come from @versatiles/style and
|
||||
// core has no node toolchain to regenerate them with.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { 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";
|
||||
|
||||
// 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 PROXY_PATH = "/api/map_tiles";
|
||||
const TILEJSON_URL = `${PROXY_PATH}/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.
|
||||
// the proxy move the attribution and zoom range too, not just the URLs.
|
||||
const TILEJSON_FIELDS = [
|
||||
"tiles",
|
||||
"attribution",
|
||||
@@ -163,46 +52,25 @@ const useTileJson = (name, 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` }],
|
||||
glyphs: `${PROXY_PATH}/fonts/{fontstack}/{range}.pbf`,
|
||||
sprite: [{ id: "basics", url: `${PROXY_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,
|
||||
}),
|
||||
await Promise.all(
|
||||
// Both themes up front: dark is a real cartography, not an inverted raster.
|
||||
...[
|
||||
[
|
||||
["light", colorful],
|
||||
["dark", eclipse],
|
||||
].map(([name, builder]) => {
|
||||
const style = useTileJson(name, builder(styleOptions));
|
||||
assertBoldStaysOnRefs(name, style);
|
||||
return writeFile(
|
||||
].map(([name, builder]) =>
|
||||
writeFile(
|
||||
path.join(outputDir, `${name}.json`),
|
||||
JSON.stringify(style)
|
||||
);
|
||||
}),
|
||||
]);
|
||||
JSON.stringify(useTileJson(name, builder(styleOptions)))
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Shared so it does not have to be wired into every pipeline separately.
|
||||
|
||||
+1
-1
@@ -206,7 +206,7 @@
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.1.0",
|
||||
"minify-literals": "2.2.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.6",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260826.1"
|
||||
version = "20260826.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -19,6 +19,7 @@ export const setupLeafletMap = async (
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
darkMode?: boolean;
|
||||
token?: string;
|
||||
}
|
||||
): Promise<LeafletMapSetup> => {
|
||||
if (!mapElement.parentNode) {
|
||||
@@ -60,7 +61,8 @@ export const setupLeafletMap = async (
|
||||
const baseLayer = await createBaseLayer(
|
||||
Leaflet,
|
||||
map,
|
||||
initialView?.darkMode ?? false
|
||||
initialView?.darkMode ?? false,
|
||||
initialView?.token
|
||||
);
|
||||
|
||||
return { map, leaflet: Leaflet, baseLayer };
|
||||
|
||||
+131
-67
@@ -1,38 +1,40 @@
|
||||
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
|
||||
import type { Map as LeafletMap } from "leaflet";
|
||||
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
|
||||
import {
|
||||
MAP_TILES_PATH,
|
||||
refreshMapTilesToken,
|
||||
subscribeMapTilesToken,
|
||||
withMapTilesToken,
|
||||
} from "../../data/map_tiles";
|
||||
|
||||
// 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.
|
||||
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
|
||||
// TileJSON, deliberately: it follows whoever serves the tiles.
|
||||
const VECTOR_STYLES = {
|
||||
light: "/static/map/light.json",
|
||||
dark: "/static/map/dark.json",
|
||||
} as const;
|
||||
|
||||
// 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 =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, ' +
|
||||
'© <a href="https://carto.com/attributions">CARTO</a>';
|
||||
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
|
||||
// OSM serves no @2x variant.
|
||||
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
|
||||
const OSM_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
// 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.
|
||||
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
|
||||
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
|
||||
const CONTEXT_RESTORE_GRACE = 2000;
|
||||
const RECOVERY_THROTTLE = 30000;
|
||||
|
||||
// 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.
|
||||
// On the map, not the layer: marker clustering throws without a maximum. The
|
||||
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
|
||||
export const MAP_MIN_ZOOM = 1;
|
||||
export const MAP_MAX_ZOOM = 20;
|
||||
|
||||
// Leaflet substitutes any option into the URL template; its types do not.
|
||||
type TokenTileLayerOptions = TileLayerOptions & { token?: string };
|
||||
|
||||
export interface MapBaseLayer {
|
||||
// A no-op for raster, which has no dark variant and is inverted in CSS.
|
||||
setDarkMode: (darkMode: boolean) => void;
|
||||
@@ -40,7 +42,7 @@ export interface MapBaseLayer {
|
||||
|
||||
let webGL2Supported: boolean | undefined;
|
||||
|
||||
// Rules out iOS below 15, older Android tablets, and blocklisted drivers.
|
||||
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
|
||||
const supportsWebGL2 = (): boolean => {
|
||||
if (webGL2Supported === undefined) {
|
||||
try {
|
||||
@@ -55,11 +57,35 @@ const supportsWebGL2 = (): boolean => {
|
||||
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.
|
||||
// MapLibre rejects a relative sprite URL. Not the glyph URL: encoding would
|
||||
// mangle its {fontstack} and {range} placeholders.
|
||||
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
|
||||
// its glyphs and sprites, which is why those come from VersaTiles.
|
||||
const DEMO_UPSTREAM = {
|
||||
tilejson: "https://vector.openstreetmap.org/shortbread_v1/tilejson.json",
|
||||
assets: "https://tiles.versatiles.org/assets",
|
||||
};
|
||||
|
||||
const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
|
||||
style.glyphs = `${DEMO_UPSTREAM.assets}/glyphs/{fontstack}/{range}.pbf`;
|
||||
style.sprite = [
|
||||
{ id: "basics", url: `${DEMO_UPSTREAM.assets}/sprites/basics/sprites` },
|
||||
];
|
||||
Object.values(style.sources).forEach((source) => {
|
||||
if ("url" in source) {
|
||||
source.url = DEMO_UPSTREAM.tilejson;
|
||||
}
|
||||
});
|
||||
return style;
|
||||
};
|
||||
|
||||
const loadStyle = async (url: string): Promise<StyleSpecification> => {
|
||||
const style: StyleSpecification = await (await fetch(url)).json();
|
||||
|
||||
if (__DEMO__) {
|
||||
return useDemoUpstream(style);
|
||||
}
|
||||
|
||||
if (typeof style.sprite === "string") {
|
||||
style.sprite = new URL(style.sprite, location.href).href;
|
||||
} else if (Array.isArray(style.sprite)) {
|
||||
@@ -75,21 +101,24 @@ const createVectorLayer = async (
|
||||
createLayer: typeof maplibreGL,
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean
|
||||
darkMode: boolean,
|
||||
token: string | undefined
|
||||
): 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",
|
||||
// Absolute, or the worker fetching tiles cannot resolve them.
|
||||
transformRequest: (url) => ({
|
||||
url: withMapTilesToken(url),
|
||||
// OSM asks a website for a referrer, and the demo has no instance
|
||||
// hostname to leak.
|
||||
referrerPolicy: __DEMO__ ? "origin" : undefined,
|
||||
}),
|
||||
});
|
||||
// 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.
|
||||
// The plugin builds the MapLibre map in `onAdd`, so a refused context or a
|
||||
// blocked worker throws here. Keep it guarded or those lose the fallback.
|
||||
layer.addTo(map);
|
||||
} catch {
|
||||
if (layer) {
|
||||
@@ -102,14 +131,14 @@ const createVectorLayer = async (
|
||||
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.
|
||||
// Tracked apart so a failed request rolls back to what is displayed, not to
|
||||
// the opposite of what it asked - which with several in flight differs.
|
||||
let appliedDarkMode = darkMode;
|
||||
let requestedDarkMode = darkMode;
|
||||
// Styles are fetched, so only the newest request may touch the map.
|
||||
let latestRequest = 0;
|
||||
let vector = true;
|
||||
let refused = false;
|
||||
|
||||
const glMap = layer.getMaplibreMap();
|
||||
let fallbackTimeout: number | undefined;
|
||||
@@ -130,13 +159,12 @@ const createVectorLayer = async (
|
||||
} catch {
|
||||
// Nothing left to detach.
|
||||
}
|
||||
createRasterLayer(leaflet, map);
|
||||
createRasterLayer(leaflet, map, token);
|
||||
};
|
||||
|
||||
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.
|
||||
// Backgrounding drops it too, and there it comes back on return.
|
||||
if (!vector || document.hidden) {
|
||||
return;
|
||||
}
|
||||
@@ -159,54 +187,89 @@ const createVectorLayer = async (
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
|
||||
const applyStyle = (newDarkMode: boolean) => {
|
||||
const request = ++latestRequest;
|
||||
|
||||
loadStyle(VECTOR_STYLES[newDarkMode ? "dark" : "light"])
|
||||
.then((style) => {
|
||||
if (request === latestRequest) {
|
||||
appliedDarkMode = newDarkMode;
|
||||
refused = false;
|
||||
layer.getMaplibreMap()?.setStyle(style);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (request === latestRequest) {
|
||||
requestedDarkMode = appliedDarkMode;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// A refused request leaves the source dead: the TileJSON is fetched once and
|
||||
// is never retried, so the style has to be applied again once there is a new
|
||||
// token. Throttled, or a proxy refusing for another reason loops.
|
||||
let lastRecovery = 0;
|
||||
glMap.on("error", (event) => {
|
||||
if ((event.error as { status?: number } | undefined)?.status !== 403) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - lastRecovery < RECOVERY_THROTTLE) {
|
||||
return;
|
||||
}
|
||||
lastRecovery = Date.now();
|
||||
refused = true;
|
||||
refreshMapTilesToken();
|
||||
});
|
||||
|
||||
const unsubscribeToken = subscribeMapTilesToken(() => {
|
||||
if (vector && refused) {
|
||||
applyStyle(requestedDarkMode);
|
||||
}
|
||||
});
|
||||
map.on("unload", unsubscribeToken);
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
applyStyle(newDarkMode);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRasterLayer = (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap
|
||||
map: LeafletMap,
|
||||
token: string | undefined
|
||||
): 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,
|
||||
}
|
||||
)
|
||||
const layer = leaflet
|
||||
.tileLayer(RASTER_TILE_URL, {
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
maxZoom: MAP_MAX_ZOOM,
|
||||
// Leaflet throws on an undefined template variable, so no token means an
|
||||
// empty one: the tiles 403 and the markers still draw.
|
||||
token: token ?? "",
|
||||
} as TokenTileLayerOptions)
|
||||
.addTo(map);
|
||||
|
||||
// Substituted per request, so a refreshed token needs no new layer.
|
||||
const unsubscribe = subscribeMapTilesToken((newToken) => {
|
||||
(layer.options as TokenTileLayerOptions).token = newToken;
|
||||
// Tiles that 403'd are cached as failures; only a redraw asks again.
|
||||
layer.redraw();
|
||||
});
|
||||
map.on("unload", unsubscribe);
|
||||
|
||||
return { setDarkMode: () => undefined };
|
||||
};
|
||||
|
||||
export const createBaseLayer = async (
|
||||
leaflet: LeafletModuleType,
|
||||
map: LeafletMap,
|
||||
darkMode: boolean
|
||||
darkMode: boolean,
|
||||
token: string | undefined
|
||||
): Promise<MapBaseLayer> => {
|
||||
if (supportsWebGL2()) {
|
||||
let vectorLayer: MapBaseLayer | undefined;
|
||||
@@ -217,7 +280,8 @@ export const createBaseLayer = async (
|
||||
createLayer,
|
||||
leaflet,
|
||||
map,
|
||||
darkMode
|
||||
darkMode,
|
||||
token
|
||||
);
|
||||
} catch {
|
||||
// No chunk, no vector map - but still a map.
|
||||
@@ -226,5 +290,5 @@ export const createBaseLayer = async (
|
||||
return vectorLayer;
|
||||
}
|
||||
}
|
||||
return createRasterLayer(leaflet, map);
|
||||
return createRasterLayer(leaflet, map, token);
|
||||
};
|
||||
|
||||
+85
-2
@@ -80,6 +80,67 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
|
||||
return ensureDialogsClosed(timestamp);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a page with unsaved changes (e.g. the automation editor) veto
|
||||
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
|
||||
* true when navigation may proceed.
|
||||
*/
|
||||
export interface UnsavedChangesGuard {
|
||||
isDirty(): boolean;
|
||||
prompt(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
|
||||
|
||||
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.add(guard);
|
||||
};
|
||||
|
||||
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
|
||||
unsavedChangesGuards.delete(guard);
|
||||
};
|
||||
|
||||
let pendingUnsavedPrompt: Promise<boolean> | undefined;
|
||||
|
||||
/**
|
||||
* Counts navigations that changed the history entry, so a navigation held up
|
||||
* by an unsaved-changes prompt can tell whether a newer one has moved the app
|
||||
* on in the meantime.
|
||||
*/
|
||||
let committedNavigations = 0;
|
||||
|
||||
/**
|
||||
* Asks each dirty guard whether navigation may proceed. Returns true when
|
||||
* nothing is dirty or every prompt was confirmed. Concurrent navigations
|
||||
* share one pending prompt instead of stacking dialogs; the dirty check runs
|
||||
* before joining it, so a navigation triggered from inside a prompt (e.g. by
|
||||
* its save action) cannot deadlock on its own promise.
|
||||
*/
|
||||
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
|
||||
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
|
||||
guard.isDirty()
|
||||
);
|
||||
if (!dirtyGuards.length) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (!pendingUnsavedPrompt) {
|
||||
pendingUnsavedPrompt = (async () => {
|
||||
try {
|
||||
for (const guard of dirtyGuards) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await guard.prompt())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} finally {
|
||||
pendingUnsavedPrompt = undefined;
|
||||
}
|
||||
})();
|
||||
}
|
||||
return pendingUnsavedPrompt;
|
||||
};
|
||||
|
||||
const buildHistoryState = (
|
||||
data: Record<string, unknown> | undefined,
|
||||
from?: string
|
||||
@@ -91,7 +152,7 @@ const buildHistoryState = (
|
||||
return { ...state, from };
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
@@ -127,9 +188,28 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
fireEvent(mainWindow, "location-changed", {
|
||||
replace,
|
||||
});
|
||||
committedNavigations += 1;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const navigate = async (path: string, options?: NavigateOptions) => {
|
||||
// Only guard actual departures: navigating to the current path keeps the
|
||||
// page, and any unsaved state on it, mounted.
|
||||
if (path !== currentPath()) {
|
||||
const navigationsBeforePrompt = committedNavigations;
|
||||
if (!(await ensureUnsavedChangesConfirmed())) {
|
||||
return false;
|
||||
}
|
||||
if (committedNavigations !== navigationsBeforePrompt) {
|
||||
// Another navigation landed while the prompt was waiting for an answer,
|
||||
// so this destination is stale. Dropping it keeps a late answer from
|
||||
// pulling the user back off the page they are on now.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return performNavigation(path, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the previous history entry is a page this app navigated away from.
|
||||
* `history.length` cannot answer this: a login redirect goes through
|
||||
@@ -142,6 +222,9 @@ export const canGoBack = (): boolean =>
|
||||
/**
|
||||
* Navigate back to the page we came from, falling back to a path when the
|
||||
* previous entry is not ours (deep link, login redirect, fresh tab).
|
||||
* Deliberately not guarded against unsaved changes: pages with such a guard
|
||||
* confirm in their own back handlers, and delete flows leave through here
|
||||
* after the edited item is already gone.
|
||||
*/
|
||||
export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
@@ -156,5 +239,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
await navigate(fallbackPath || "/", { replace: true });
|
||||
await performNavigation(fallbackPath || "/", { replace: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import deepFreeze from "deep-freeze";
|
||||
|
||||
const inFlightRequests = new WeakMap<object, Map<string, Promise<unknown>>>();
|
||||
|
||||
export const shareInFlightRequest = <T>(
|
||||
owner: object,
|
||||
key: string,
|
||||
fetcher: () => Promise<T>
|
||||
): Promise<T> => {
|
||||
let requests = inFlightRequests.get(owner);
|
||||
if (!requests) {
|
||||
requests = new Map();
|
||||
inFlightRequests.set(owner, requests);
|
||||
}
|
||||
|
||||
const ownerRequests = requests;
|
||||
const existing = ownerRequests.get(key);
|
||||
if (existing) {
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = fetcher()
|
||||
.then((result) => deepFreeze(result) as T)
|
||||
.finally(() => {
|
||||
if (ownerRequests.get(key) !== request) {
|
||||
return;
|
||||
}
|
||||
|
||||
ownerRequests.delete(key);
|
||||
if (ownerRequests.size === 0) {
|
||||
inFlightRequests.delete(owner);
|
||||
}
|
||||
});
|
||||
|
||||
ownerRequests.set(key, request);
|
||||
return request;
|
||||
};
|
||||
@@ -3,6 +3,27 @@ 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.
|
||||
@@ -20,21 +41,29 @@ export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
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 x = offsetFromCursor(cursorX, dom, 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,6 +328,7 @@ 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,4 +1,3 @@
|
||||
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";
|
||||
@@ -13,7 +12,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 { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { itemTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
@@ -24,7 +23,6 @@ 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")
|
||||
@@ -43,10 +41,6 @@ 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;
|
||||
|
||||
@@ -69,13 +63,6 @@ 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() {
|
||||
@@ -83,7 +70,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
|
||||
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -193,19 +180,13 @@ 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") ||
|
||||
widthChanged
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -215,22 +196,14 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelWidth = showNames
|
||||
? 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",
|
||||
@@ -254,56 +227,41 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
: {
|
||||
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,
|
||||
},
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: insideLabels ? 20 : 10,
|
||||
top: 10,
|
||||
bottom: GRID_BOTTOM,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: itemTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -443,10 +401,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -79,10 +79,6 @@ 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;
|
||||
|
||||
@@ -231,7 +227,6 @@ 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}
|
||||
@@ -429,12 +424,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -446,6 +446,7 @@ 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,
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
import { intervalScaleEnsureValidExtent } from "echarts/lib/scale/helper";
|
||||
import { getPrecision, nice, round } from "echarts/lib/util/number";
|
||||
|
||||
// A range smaller than this fraction of the axis magnitude is floating-point
|
||||
// noise (e.g. from summed statistics), not real precision.
|
||||
const NEGLIGIBLE_RANGE_RATIO = 1e-10;
|
||||
|
||||
// Intervals the axis aims for. Passed to ECharts rather than assumed, so the
|
||||
// precision derived here cannot drift from the ticks it renders.
|
||||
const SPLIT_NUMBER = 5;
|
||||
|
||||
// How thin a gap between the data and the plot edge counts as no gap at all,
|
||||
// as a fraction of the data span. ECharts floors the axis minimum and ceils the
|
||||
// maximum to a tick multiple, which usually leaves headroom, but quantized
|
||||
// states often land exactly on a tick and get none — collapsing area-filled
|
||||
// series, which are drawn from their value down to the axis minimum. Widening
|
||||
// the extent by this much before that rounding bumps any axis with less
|
||||
// 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. We mirror how ECharts sizes its ticks: it splits the
|
||||
// range into ~5 intervals (its default `splitNumber`) and rounds that raw
|
||||
// interval to a "nice" 1/2/3/5×10ⁿ value, then reports the decimals that nice
|
||||
// interval needs. This matches the precision ECharts actually renders, so
|
||||
// labels are neither truncated to identical values nor padded with extra zeros.
|
||||
// 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
|
||||
// truncated to identical values nor padded with extra zeros.
|
||||
export function computeYAxisFractionDigits(
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -22,13 +41,7 @@ export function computeYAxisFractionDigits(
|
||||
// with a tail of zeros (e.g. "0.20000000000000"), so treat it as flat.
|
||||
const magnitude = Math.max(Math.abs(lo), Math.abs(hi));
|
||||
if (range <= magnitude * NEGLIGIBLE_RANGE_RATIO) return 1;
|
||||
const rawInterval = range / 5;
|
||||
const exponent = Math.floor(Math.log10(rawInterval));
|
||||
const mantissa = rawInterval / 10 ** exponent; // in [1, 10)
|
||||
// Rounding the mantissa to a nice value only ever carries to the next power
|
||||
// of ten (mantissa ≥ 7 → 10), which needs one fewer decimal.
|
||||
const niceExponent = mantissa >= 7 ? exponent + 1 : exponent;
|
||||
return Math.max(0, -niceExponent);
|
||||
return getPrecision(nice(range / SPLIT_NUMBER, true));
|
||||
}
|
||||
|
||||
interface YAxisExtentValues {
|
||||
@@ -44,34 +57,116 @@ const resolveYAxisBound = (
|
||||
values: YAxisExtentValues
|
||||
): number | undefined => (typeof bound === "function" ? bound(values) : bound);
|
||||
|
||||
// Wrap the Y-axis `min`/`max` options in callbacks so the tick-label precision
|
||||
// tracks the currently visible axis extent. ECharts re-invokes these callbacks
|
||||
// with the extent of the visible (zoom-filtered) data on every dataZoom, and
|
||||
// always before the label formatter runs, so recomputing the fraction digits
|
||||
// here keeps zoomed-in labels distinct. The callbacks return the original
|
||||
// bounds unchanged, so auto-scaling still applies when a bound is not set.
|
||||
// A constant series has no span for the gap to be a fraction of, so ECharts
|
||||
// falls back to `Math.abs(min)` when sizing it. That makes the extent unequal,
|
||||
// which in turn stops `intervalScaleEnsureValidExtent` from applying the ±|v|/2
|
||||
// expansion a flat series relies on for its window. Run that expansion here and
|
||||
// return fixed bounds, which also suppresses the gap.
|
||||
const flatSeriesExtent = (value: number, fixed: [boolean, boolean]) => {
|
||||
const [lo, hi] = intervalScaleEnsureValidExtent([value, value], fixed);
|
||||
const interval = nice((hi - lo) / SPLIT_NUMBER, true);
|
||||
const precision = getPrecision(interval);
|
||||
return {
|
||||
min: round(Math.floor(lo / interval) * interval, precision),
|
||||
max: round(Math.ceil(hi / interval) * interval, precision),
|
||||
};
|
||||
};
|
||||
|
||||
// Build the `yAxis` options that keep tick-label precision and the plot-edge gap
|
||||
// in agreement with the extent ECharts renders. It re-invokes the `min`/`max`
|
||||
// callbacks with the extent of the visible (zoom-filtered) data on every
|
||||
// dataZoom, and always before the label formatter runs, so the fraction digits
|
||||
// recomputed here track the zoomed range. A callback returns `undefined`
|
||||
// wherever auto-scaling should stand, and a number only where the axis has to be
|
||||
// pinned: an explicit bound, the zero anchor, or a constant series.
|
||||
export function createYAxisPrecisionBounds(options: {
|
||||
min?: YAxisBound;
|
||||
max?: YAxisBound;
|
||||
// Set for bar axes anchored at 0, so precision reflects the 0-based range.
|
||||
// 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;
|
||||
max: (values: YAxisExtentValues) => number | undefined;
|
||||
boundaryGap: [number, number];
|
||||
splitNumber: number;
|
||||
} {
|
||||
const { min, max, includeZero, onFractionDigits } = options;
|
||||
const { min, max, includeZero, unit, onFractionDigits } = options;
|
||||
const naturalMax = unit === "%" ? PERCENT_MAX : undefined;
|
||||
|
||||
const resolveBounds = (values: YAxisExtentValues) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
if (
|
||||
includeZero ||
|
||||
!Number.isFinite(values.min) ||
|
||||
!Number.isFinite(values.max)
|
||||
) {
|
||||
return { min: resolvedMin, max: resolvedMax, gap: 0 };
|
||||
}
|
||||
if (values.min === values.max) {
|
||||
const flat = flatSeriesExtent(values.min, [
|
||||
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,
|
||||
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;
|
||||
return {
|
||||
min:
|
||||
resolvedMin ??
|
||||
(floor !== undefined && values.min - floor < gap ? floor : undefined),
|
||||
max:
|
||||
resolvedMax ??
|
||||
(ceiling !== undefined && ceiling - values.max < gap
|
||||
? ceiling
|
||||
: undefined),
|
||||
gap,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
// Always emit the key. `setOption` merges the Y axis rather than replacing
|
||||
// it, so a conditionally spread gap would survive a chart switching to a
|
||||
// zero-anchored type and leave its bars floating.
|
||||
boundaryGap: includeZero
|
||||
? [0, 0]
|
||||
: [GAP_FRACTION_OF_SPAN, GAP_FRACTION_OF_SPAN],
|
||||
splitNumber: SPLIT_NUMBER,
|
||||
min: (values) => {
|
||||
const resolvedMin = resolveYAxisBound(min, values);
|
||||
const resolvedMax = resolveYAxisBound(max, values);
|
||||
const extentMin = resolvedMin ?? values.min;
|
||||
const extentMax = resolvedMax ?? values.max;
|
||||
const bounds = resolveBounds(values);
|
||||
onFractionDigits(
|
||||
computeYAxisFractionDigits(extentMin, extentMax, includeZero)
|
||||
computeYAxisFractionDigits(
|
||||
bounds.min ?? values.min - bounds.gap,
|
||||
bounds.max ?? values.max + bounds.gap,
|
||||
includeZero
|
||||
)
|
||||
);
|
||||
return resolvedMin;
|
||||
return bounds.min;
|
||||
},
|
||||
max: (values) => resolveYAxisBound(max, values),
|
||||
max: (values) => resolveBounds(values).max,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,10 +16,10 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticIds,
|
||||
getStatisticLabel,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import { getStatisticIds } from "../../data/recorder_statistic_ids";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../ha-combo-box-item";
|
||||
|
||||
@@ -90,7 +90,7 @@ class HaAlert extends LitElement {
|
||||
static styles = css`
|
||||
.issue-type {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
padding: var(--ha-alert-padding, 8px);
|
||||
display: flex;
|
||||
}
|
||||
.icon {
|
||||
|
||||
@@ -29,6 +29,7 @@ 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";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import { DecoratedMarker } from "../../common/map/decorated_marker";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
@@ -327,11 +328,19 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
this._loading = true;
|
||||
try {
|
||||
// The tiles are proxied by core behind a token, so nothing loads without
|
||||
// one. A host that provides no connection, or a backend without the
|
||||
// proxy, leaves the map without tiles rather than failing to set up.
|
||||
const token = this._connection
|
||||
? await ensureMapTilesToken(this._connection.connection)
|
||||
: undefined;
|
||||
|
||||
const setup = await setupLeafletMap(map, {
|
||||
latitude: this._config?.latitude ?? 52.3731339,
|
||||
longitude: this._config?.longitude ?? 4.8903147,
|
||||
zoom: this.zoom,
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
});
|
||||
// Setting up fetches a style, so the element can be gone by now.
|
||||
// `disconnectedCallback` had no map to tear down, and keeping this one
|
||||
|
||||
@@ -564,75 +564,73 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.primaryEntitiesOnly
|
||||
);
|
||||
|
||||
let referencedAreas = entries.referenced_areas;
|
||||
const hiddenAreaIds: string[] = [];
|
||||
if (this.type === "floor" || this.type === "label") {
|
||||
entries.referenced_areas = entries.referenced_areas.filter(
|
||||
(area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
referencedAreas = referencedAreas.filter((area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
);
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenAreaIds.push(area_id);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
let referencedDevices = entries.referenced_devices;
|
||||
const hiddenDeviceIds: string[] = [];
|
||||
if (
|
||||
this.type === "floor" ||
|
||||
this.type === "area" ||
|
||||
this.type === "label"
|
||||
) {
|
||||
entries.referenced_devices = entries.referenced_devices.filter(
|
||||
(device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
referencedDevices = referencedDevices.filter((device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
);
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
const referencedEntities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
// Core can reference entities that are absent from the display
|
||||
@@ -647,9 +645,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
(this.type === "area" && entity.area_id === this.itemId) ||
|
||||
(this.type === "floor" &&
|
||||
entity.area_id &&
|
||||
entries.referenced_areas.includes(entity.area_id)) ||
|
||||
referencedAreas.includes(entity.area_id)) ||
|
||||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
|
||||
entries.referenced_devices.includes(entity.device_id || "")
|
||||
referencedDevices.includes(entity.device_id || "")
|
||||
) {
|
||||
return entityRegMeetsFilter(
|
||||
entity,
|
||||
@@ -664,7 +662,12 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
this._entries = entries;
|
||||
this._entries = {
|
||||
...entries,
|
||||
referenced_areas: referencedAreas,
|
||||
referenced_devices: referencedDevices,
|
||||
referenced_entities: referencedEntities,
|
||||
};
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to extract target", e);
|
||||
|
||||
+35
-28
@@ -1909,20 +1909,13 @@ export const downloadEnergyData = (
|
||||
const device_consumption_water = energyData.prefs.device_consumption_water;
|
||||
const stats = energyData.state.stats;
|
||||
|
||||
const timeSet = new Set<number>();
|
||||
Object.values(stats).forEach((stat) => {
|
||||
stat.forEach((datapoint) => {
|
||||
timeSet.add(datapoint.start);
|
||||
});
|
||||
});
|
||||
const times = Array.from(timeSet).sort();
|
||||
|
||||
const headers =
|
||||
"entity_id,type,unit," +
|
||||
times.map((t) => new Date(t).toISOString()).join(",") +
|
||||
"\n";
|
||||
const csv: string[] = [];
|
||||
csv[0] = headers;
|
||||
interface CsvRow {
|
||||
id: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
data: StatisticValue[];
|
||||
}
|
||||
const rows: CsvRow[] = [];
|
||||
|
||||
const processCsvRow = function (
|
||||
id: string,
|
||||
@@ -1930,20 +1923,7 @@ export const downloadEnergyData = (
|
||||
unit: string,
|
||||
data: StatisticValue[]
|
||||
) {
|
||||
let n = 0;
|
||||
const row: string[] = [];
|
||||
row.push(id);
|
||||
row.push(type);
|
||||
row.push(unit.normalize("NFKD"));
|
||||
times.forEach((t) => {
|
||||
if (n < data.length && data[n].start === t) {
|
||||
row.push((data[n].change ?? "").toString());
|
||||
n++;
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
csv.push(row.join(",") + "\n");
|
||||
rows.push({ id, type, unit, data });
|
||||
};
|
||||
|
||||
const processStat = function (stat: string, type: string, unit: string) {
|
||||
@@ -2190,6 +2170,33 @@ export const downloadEnergyData = (
|
||||
);
|
||||
}
|
||||
|
||||
const timeSet = new Set<number>();
|
||||
rows.forEach((row) => {
|
||||
row.data.forEach((datapoint) => {
|
||||
timeSet.add(datapoint.start);
|
||||
});
|
||||
});
|
||||
const times = Array.from(timeSet).sort();
|
||||
|
||||
const csv: string[] = [
|
||||
"entity_id,type,unit," +
|
||||
times.map((t) => new Date(t).toISOString()).join(",") +
|
||||
"\n",
|
||||
];
|
||||
rows.forEach(({ id, type, unit, data }) => {
|
||||
let n = 0;
|
||||
const row: string[] = [id, type, unit.normalize("NFKD")];
|
||||
times.forEach((t) => {
|
||||
if (n < data.length && data[n].start === t) {
|
||||
row.push((data[n].change ?? "").toString());
|
||||
n++;
|
||||
} else {
|
||||
row.push("");
|
||||
}
|
||||
});
|
||||
csv.push(row.join(",") + "\n");
|
||||
});
|
||||
|
||||
const blob = new Blob(csv, {
|
||||
type: "text/csv",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { waitForMs } from "../common/util/wait";
|
||||
|
||||
export const MAP_TILES_PATH = "/api/map_tiles";
|
||||
|
||||
// Well inside the token's lifetime, as util/brands-url.ts does.
|
||||
const TOKEN_REFRESH_MS = 30 * 60 * 1000;
|
||||
|
||||
// Nothing loads without a token, so the first attempts are awaited - briefly,
|
||||
// or a backend without the proxy would hold the map hostage. The rest retry in
|
||||
// the background, for the window after a restart where the WebSocket is up but
|
||||
// the handler is not registered yet.
|
||||
const BLOCKING_DELAYS_MS = [0, 400, 1000];
|
||||
const BACKGROUND_DELAYS_MS = [2000, 5000, 10000, 15000];
|
||||
|
||||
let token: string | undefined;
|
||||
let refreshInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let watchingConnection = false;
|
||||
let activeConnection: Connection | undefined;
|
||||
let refreshing: Promise<void> | undefined;
|
||||
const listeners = new Set<(token: string) => void>();
|
||||
|
||||
const fetchToken = async (connection: Connection): Promise<void> => {
|
||||
const result = await connection.sendMessagePromise<{ token: string }>({
|
||||
type: "map_tiles/access_token",
|
||||
});
|
||||
if (result.token !== token) {
|
||||
token = result.token;
|
||||
listeners.forEach((listener) => listener(token!));
|
||||
}
|
||||
};
|
||||
|
||||
const attempt = async (connection: Connection, delays: number[]) => {
|
||||
/* eslint-disable no-await-in-loop -- retries are intentionally sequential */
|
||||
for (const delay of delays) {
|
||||
if (token) {
|
||||
return;
|
||||
}
|
||||
if (delay) {
|
||||
await waitForMs(delay);
|
||||
}
|
||||
try {
|
||||
await fetchToken(connection);
|
||||
return;
|
||||
} catch {
|
||||
// try next delay
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
};
|
||||
|
||||
export const ensureMapTilesToken = async (
|
||||
connection: Connection
|
||||
): Promise<string | undefined> => {
|
||||
activeConnection = connection;
|
||||
|
||||
if (!token) {
|
||||
await attempt(connection, BLOCKING_DELAYS_MS);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
attempt(connection, BACKGROUND_DELAYS_MS).then(() =>
|
||||
scheduleRefresh(connection)
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
scheduleRefresh(connection);
|
||||
return token;
|
||||
};
|
||||
|
||||
const scheduleRefresh = (connection: Connection) => {
|
||||
if (token && !refreshInterval) {
|
||||
refreshInterval = setInterval(() => {
|
||||
fetchToken(connection).catch(() => {
|
||||
// Keep the current token; the next interval retries.
|
||||
});
|
||||
}, TOKEN_REFRESH_MS);
|
||||
}
|
||||
|
||||
if (!watchingConnection) {
|
||||
watchingConnection = true;
|
||||
// The interval does not fire while the process is suspended, so the token
|
||||
// can be stale before it comes round; reconnecting is the reliable signal.
|
||||
connection.addEventListener("ready", () => {
|
||||
fetchToken(connection).catch(() => {
|
||||
// Nothing to do; the interval keeps trying.
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Forces a new token, for when the current one is refused. Deduplicated: a
|
||||
* refused map produces one of these per tile.
|
||||
*/
|
||||
export const refreshMapTilesToken = (): Promise<void> => {
|
||||
if (!activeConnection) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
refreshing ??= fetchToken(activeConnection)
|
||||
.catch(() => {
|
||||
// Leave the old token in place; a reconnect or the interval retries.
|
||||
})
|
||||
.finally(() => {
|
||||
refreshing = undefined;
|
||||
});
|
||||
return refreshing;
|
||||
};
|
||||
|
||||
/** Leaflet bakes its URL template at layer creation, so it needs telling. */
|
||||
export const subscribeMapTilesToken = (
|
||||
listener: (token: string) => void
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* MapLibre hands tile URLs to a worker, which has no document to resolve a
|
||||
* relative URL against, so the result has to be absolute.
|
||||
*/
|
||||
export const withMapTilesToken = (url: string): string => {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url, location.href);
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (token && parsed.pathname.startsWith(`${MAP_TILES_PATH}/`)) {
|
||||
parsed.searchParams.set("token", token);
|
||||
}
|
||||
|
||||
return parsed.href;
|
||||
};
|
||||
@@ -40,8 +40,7 @@ export const searchPlaces = (
|
||||
limit ? `&limit=${limit}` : ""
|
||||
}${addressdetails ? "&addressdetails=1" : ""}&accept-language=${
|
||||
hass.locale.language
|
||||
}&email=abuse@home-assistant.io`,
|
||||
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
|
||||
}&email=abuse@home-assistant.io`
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
@@ -59,8 +58,7 @@ export const reverseGeocode = (
|
||||
location[1]
|
||||
}&accept-language=${hass.locale.language}&zoom=${
|
||||
zoom ?? 18
|
||||
}&format=jsonv2&email=abuse@home-assistant.io`,
|
||||
{ headers: { "User-Agent": `HomeAssistant/${hass.config.version}` } }
|
||||
}&format=jsonv2&email=abuse@home-assistant.io`
|
||||
).then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
|
||||
@@ -160,15 +160,6 @@ export const getRecorderEntityOptions = (
|
||||
entity_id,
|
||||
});
|
||||
|
||||
export const getStatisticIds = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
statistic_type?: "mean" | "sum"
|
||||
) =>
|
||||
hass.callWS<StatisticsMetaData[]>({
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type,
|
||||
});
|
||||
|
||||
export const getStatisticMetadata = (
|
||||
hass: HomeAssistant,
|
||||
statistic_ids?: string[]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { StatisticsMetaData } from "./recorder";
|
||||
|
||||
type StatisticIdsType = "mean" | "sum";
|
||||
|
||||
type StatisticIdsApi = Pick<HomeAssistant, "callWS">;
|
||||
|
||||
export const getStatisticIds = (
|
||||
hass: StatisticIdsApi,
|
||||
statistic_type?: StatisticIdsType
|
||||
) =>
|
||||
shareInFlightRequest(
|
||||
hass.callWS,
|
||||
`recorder/list_statistic_ids:${statistic_type ?? "all"}`,
|
||||
() =>
|
||||
hass.callWS<StatisticsMetaData[]>({
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type,
|
||||
})
|
||||
);
|
||||
+10
-4
@@ -14,6 +14,7 @@ import {
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
|
||||
import type { EntityComboBoxItem } from "./entity/entity_picker";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
|
||||
import { shareInFlightRequest } from "../common/util/share-in-flight-request";
|
||||
|
||||
export const TARGET_SEPARATOR = "________";
|
||||
|
||||
@@ -54,13 +55,18 @@ export const extractFromTarget = async (
|
||||
target: HassServiceTarget,
|
||||
expandGroup = false,
|
||||
primaryEntitiesOnly = true
|
||||
) =>
|
||||
callWS<ExtractFromTargetResult>({
|
||||
type: "extract_from_target",
|
||||
) => {
|
||||
const request = {
|
||||
type: "extract_from_target" as const,
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
primary_entities_only: primaryEntitiesOnly,
|
||||
});
|
||||
};
|
||||
|
||||
return shareInFlightRequest(callWS, JSON.stringify(request), () =>
|
||||
callWS<ExtractFromTargetResult>(request)
|
||||
);
|
||||
};
|
||||
|
||||
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
|
||||
const tempTarget = {
|
||||
|
||||
@@ -526,6 +526,16 @@ export const fetchZwaveNetworkStatus = (
|
||||
});
|
||||
};
|
||||
|
||||
/** Node IDs of the nodes each node can reach directly, keyed by node ID. */
|
||||
export const fetchZwaveNetworkNeighbors = (
|
||||
hass: HomeAssistant,
|
||||
entry_id: string
|
||||
): Promise<Record<number, number[]>> =>
|
||||
hass.callWS({
|
||||
type: "zwave_js/network_neighbors",
|
||||
entry_id,
|
||||
});
|
||||
|
||||
export const fetchZwaveDataCollectionStatus = (
|
||||
hass: HomeAssistant,
|
||||
entry_id: string
|
||||
|
||||
@@ -117,6 +117,33 @@ const initRouting = () => {
|
||||
})
|
||||
);
|
||||
|
||||
// Strip the rotating token from the cache key, or every rotation refetches
|
||||
// every tile. The TileJSON is deliberately not matched: it is the switching
|
||||
// point for the tile source, so it stays on the network.
|
||||
registerRoute(
|
||||
({ url }) =>
|
||||
/^\/api\/map_tiles\/(vector|raster|fonts|sprites)\//.test(url.pathname),
|
||||
new CacheFirst({
|
||||
cacheName: "map-tiles",
|
||||
plugins: [
|
||||
{
|
||||
cacheKeyWillBeUsed: async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
url.searchParams.delete("token");
|
||||
return url.href;
|
||||
},
|
||||
},
|
||||
// A stale token gives a 403; caching that would pin the failure.
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7,
|
||||
purgeOnQuotaError: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// Short-circuit camera/image proxy requests with an expired signature or a
|
||||
// missing/undefined token so they don't hit core and get logged as invalid
|
||||
// login attempts. Registered before the generic /api route below so it wins.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { LitElement, PropertyValues } from "lit";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import type { UnsavedChangesGuard } from "../common/navigate";
|
||||
import {
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
} from "../common/navigate";
|
||||
import type { Constructor } from "../types";
|
||||
|
||||
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
|
||||
@@ -9,45 +13,34 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
|
||||
/** Provided by `DirtyStateProviderMixin`. */
|
||||
declare isDirtyState: boolean;
|
||||
|
||||
private _handleClick = async (e: MouseEvent) => {
|
||||
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
|
||||
const target = e.composedPath()[0];
|
||||
if (!isNavigationClick(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.promptDiscardChanges();
|
||||
if (result) {
|
||||
this._removeListeners();
|
||||
if (target) {
|
||||
const newEvent = new MouseEvent(e.type, e);
|
||||
target.dispatchEvent(newEvent);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
|
||||
private _removeListeners() {
|
||||
window.removeEventListener("click", this._handleClick, true);
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
private _unsavedChangesGuard: UnsavedChangesGuard = {
|
||||
isDirty: () => this.isDirtyState,
|
||||
prompt: () => this.promptDiscardChanges(),
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
registerUnsavedChangesGuard(this._unsavedChangesGuard);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
|
||||
if (this.isDirtyState && this.isConnected) {
|
||||
window.addEventListener("click", this._handleClick, true);
|
||||
window.addEventListener("beforeunload", this._handleUnload);
|
||||
} else {
|
||||
this._removeListeners();
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this._removeListeners();
|
||||
unregisterUnsavedChangesGuard(this._unsavedChangesGuard);
|
||||
window.removeEventListener("beforeunload", this._handleUnload);
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges(): Promise<boolean> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/tree-item/tree-item";
|
||||
import type WaTreeItem from "@home-assistant/webawesome/dist/components/tree-item/tree-item";
|
||||
import "@home-assistant/webawesome/dist/components/tree/tree";
|
||||
import type { WaSelectionChangeEvent } from "@home-assistant/webawesome/dist/events/selection-change";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
@@ -360,6 +361,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
? html`<ha-list-base>${floorAreas}</ha-list-base>`
|
||||
: html`<wa-tree
|
||||
@wa-selection-change=${this._handleSelectionChange}
|
||||
@dblclick=${this._handleDoubleClick}
|
||||
>${floorAreas}</wa-tree
|
||||
>`
|
||||
}`
|
||||
@@ -1444,6 +1446,26 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
this._toggleItem(targetId, false);
|
||||
}
|
||||
|
||||
private _handleDoubleClick(ev: MouseEvent) {
|
||||
// the expand button and non-selectable items already toggle on single click
|
||||
if (
|
||||
ev
|
||||
.composedPath()
|
||||
.some((el) => (el as HTMLElement).classList?.contains("expand-button"))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const item = (ev.target as HTMLElement).closest<
|
||||
WaTreeItem & { target: string }
|
||||
>("wa-tree-item");
|
||||
if (!item || item.isLeaf || item.preventSelection) {
|
||||
return;
|
||||
}
|
||||
// avoid leaving the label text selected by the double click
|
||||
window.getSelection()?.removeAllRanges();
|
||||
this._toggleItem(item.target, !item.expanded);
|
||||
}
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
|
||||
@@ -896,10 +896,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
return;
|
||||
}
|
||||
|
||||
this.yamlErrors = undefined;
|
||||
resolve(true);
|
||||
},
|
||||
onClose: () => resolve(false),
|
||||
onDiscard: () => resolve(true),
|
||||
onDiscard: () => {
|
||||
this.yamlErrors = undefined;
|
||||
this._markDirtyStateClean();
|
||||
resolve(true);
|
||||
},
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
entityRegistryEntry: this.registryEntry,
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -57,7 +57,7 @@ export class CloudAccountOverview extends LitElement {
|
||||
|
||||
private _renderTopCard(): TemplateResult {
|
||||
return html`
|
||||
<ha-card outlined>
|
||||
<ha-card outlined class="summary-card">
|
||||
<div class="card-content">
|
||||
<div
|
||||
class="thank-you-header"
|
||||
@@ -121,8 +121,8 @@ export class CloudAccountOverview extends LitElement {
|
||||
<p class="muted">
|
||||
${this.hass.localize("ui.panel.config.cloud.account.funding_note")}
|
||||
</p>
|
||||
${this._renderSubscriptionState()}
|
||||
</div>
|
||||
${this._renderSubscriptionState()}
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
@@ -132,6 +132,7 @@ export class CloudAccountOverview extends LitElement {
|
||||
case "trial":
|
||||
return html`
|
||||
<ha-alert
|
||||
class="subscription-alert"
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.trial_title"
|
||||
@@ -156,6 +157,7 @@ export class CloudAccountOverview extends LitElement {
|
||||
case "canceled":
|
||||
return html`
|
||||
<ha-alert
|
||||
class="subscription-alert"
|
||||
alert-type="warning"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.canceled_title"
|
||||
@@ -181,6 +183,7 @@ export class CloudAccountOverview extends LitElement {
|
||||
case "expired":
|
||||
return html`
|
||||
<ha-alert
|
||||
class="subscription-alert"
|
||||
alert-type="error"
|
||||
.title=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.overview.expired_title"
|
||||
@@ -636,11 +639,15 @@ export class CloudAccountOverview extends LitElement {
|
||||
width: auto;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-3);
|
||||
/* Prevent the embedded .subscription-alert ha-alert from bleeding outside the card */
|
||||
.summary-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-alert ha-button[slot="action"] {
|
||||
.subscription-alert {
|
||||
display: block;
|
||||
--ha-alert-padding: var(--ha-space-3);
|
||||
}
|
||||
.subscription-alert ha-button[slot="action"] {
|
||||
width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
+112
-3
@@ -1,3 +1,4 @@
|
||||
import { mdiSpiderWeb } from "@mdi/js";
|
||||
import type {
|
||||
CallbackDataParams,
|
||||
TopLevelFormatterParams,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
NetworkLink,
|
||||
NetworkNode,
|
||||
} from "../../../../../components/chart/ha-network-graph";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../../../components/input/ha-input-search";
|
||||
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
|
||||
@@ -25,6 +27,7 @@ import type {
|
||||
ZWaveJSNodeStatus,
|
||||
} from "../../../../../data/zwave_js";
|
||||
import {
|
||||
fetchZwaveNetworkNeighbors,
|
||||
fetchZwaveNetworkStatus,
|
||||
getNodeIdFromDevice,
|
||||
NodeStatus,
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
import "../../../../../layouts/hass-subpage";
|
||||
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
|
||||
import type { HomeAssistant, Route } from "../../../../../types";
|
||||
import { showToast } from "../../../../../util/toast";
|
||||
|
||||
@customElement("zwave_js-network-visualization")
|
||||
export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
@@ -55,11 +59,19 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _devices: Record<string, DeviceRegistryEntry> = {};
|
||||
|
||||
@state() private _neighbors?: Record<number, number[]>;
|
||||
|
||||
@state() private _showNeighbors = false;
|
||||
|
||||
@state() private _searchFilter = "";
|
||||
|
||||
// Route statistics reference repeaters by device registry ID
|
||||
private _nodeIdsByDeviceId: Record<string, number> = {};
|
||||
|
||||
private _neighborLinks = new Set<string>();
|
||||
|
||||
private _loadingNeighbors = false;
|
||||
|
||||
public hassSubscribe() {
|
||||
const subscriptions: Promise<UnsubscribeFunc>[] = [];
|
||||
const devices: Record<number, DeviceRegistryEntry> = {};
|
||||
@@ -89,6 +101,32 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
return subscriptions;
|
||||
}
|
||||
|
||||
private async _toggleNeighbors() {
|
||||
this._showNeighbors = !this._showNeighbors;
|
||||
if (!this._showNeighbors || this._neighbors || this._loadingNeighbors) {
|
||||
return;
|
||||
}
|
||||
// fetched on demand: reading neighbors turns the radio off briefly
|
||||
this._loadingNeighbors = true;
|
||||
try {
|
||||
this._neighbors = await fetchZwaveNetworkNeighbors(
|
||||
this.hass,
|
||||
this.configEntryId
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
this._showNeighbors = false;
|
||||
showToast(this, {
|
||||
message:
|
||||
(err as { message?: string }).message ??
|
||||
this.hass.localize(
|
||||
"ui.panel.config.zwave_js.visualization.neighbors_error"
|
||||
),
|
||||
});
|
||||
} finally {
|
||||
this._loadingNeighbors = false;
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._fetchNetworkStatus();
|
||||
@@ -116,13 +154,23 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
.searchFilter=${this._searchFilter}
|
||||
.data=${this._getNetworkData(
|
||||
this._nodeStatuses,
|
||||
this._nodeStatistics
|
||||
this._nodeStatistics,
|
||||
this._showNeighbors ? this._neighbors : undefined
|
||||
)}
|
||||
.searchableAttributes=${this._getSearchableAttributes}
|
||||
.tooltipFormatter=${this._tooltipFormatter}
|
||||
@chart-click=${this._handleChartClick}
|
||||
>
|
||||
${!this.narrow ? this._renderInputSearch("search") : nothing}
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class=${this._showNeighbors ? "active" : "inactive"}
|
||||
.path=${mdiSpiderWeb}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.visualization.toggle_neighbors"
|
||||
)}
|
||||
@click=${this._toggleNeighbors}
|
||||
></ha-icon-button>
|
||||
</ha-network-graph>
|
||||
</hass-subpage>
|
||||
`;
|
||||
@@ -184,6 +232,13 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
|
||||
const targetName =
|
||||
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
|
||||
if (this._neighborLinks.has(`${source}>${target}`)) {
|
||||
return html`${sourceName} ↔ ${targetName}<br /><b
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.zwave_js.visualization.neighbor"
|
||||
)}</b
|
||||
>`;
|
||||
}
|
||||
// links point away from the controller, so the route belongs to the target
|
||||
const stats =
|
||||
this._nodeStatistics[target] ?? this._nodeStatistics[source];
|
||||
@@ -263,7 +318,8 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
private _getNetworkData = memoizeOne(
|
||||
(
|
||||
nodeStatuses: Record<number, ZWaveJSNodeStatus>,
|
||||
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>
|
||||
nodeStatistics: Record<number, ZWaveJSNodeStatisticsUpdatedMessage>,
|
||||
neighbors: Record<number, number[]> | undefined
|
||||
): NetworkData => {
|
||||
const style = getComputedStyle(this);
|
||||
const nodes: NetworkNode[] = [];
|
||||
@@ -420,7 +476,49 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
});
|
||||
|
||||
return { nodes, links, categories };
|
||||
// Neighbors are the nodes a node can reach directly. They are symmetric
|
||||
// and carry no signal information, so they fill in the mesh underneath
|
||||
// the measured routes without overriding them.
|
||||
const neighborLinks: NetworkLink[] = [];
|
||||
const neighborKeys = new Set<string>();
|
||||
Object.entries(neighbors ?? {}).forEach(([nodeId, neighborIds]) => {
|
||||
neighborIds.forEach((neighborId) => {
|
||||
const target = String(neighborId);
|
||||
if (!nodeStatuses[neighborId] || target === nodeId) {
|
||||
return;
|
||||
}
|
||||
const [a, b] = [nodeId, target].sort();
|
||||
const key = `${a}>${b}`;
|
||||
if (
|
||||
neighborKeys.has(key) ||
|
||||
links.some(
|
||||
(link) =>
|
||||
(link.source === nodeId && link.target === target) ||
|
||||
(link.source === target && link.target === nodeId)
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
neighborKeys.add(key);
|
||||
neighborLinks.push({
|
||||
source: a,
|
||||
target: b,
|
||||
// equal values in both directions render the link without an arrow
|
||||
value: 1,
|
||||
reverseValue: 1,
|
||||
lineStyle: {
|
||||
width: 1,
|
||||
color: style.getPropertyValue("--disabled-color"),
|
||||
type: "dashed",
|
||||
},
|
||||
// neighbors are plentiful, let the routes shape the layout
|
||||
ignoreForceLayout: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
this._neighborLinks = neighborKeys;
|
||||
|
||||
return { nodes, links: [...neighborLinks, ...links], categories };
|
||||
}
|
||||
);
|
||||
|
||||
@@ -460,6 +558,17 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
|
||||
ha-input-search {
|
||||
flex: 1;
|
||||
}
|
||||
/* ha-chart-base can't style re-slotted buttons, so mirror its look */
|
||||
ha-icon-button[slot="button"] {
|
||||
background: var(--card-background-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--ha-icon-button-size: 32px;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
ha-icon-button[slot="button"].inactive {
|
||||
color: var(--state-inactive-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1126,20 +1126,24 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
|
||||
}
|
||||
|
||||
private async _confirmUnsavedChanged(): Promise<boolean> {
|
||||
if (this.isDirtyState) {
|
||||
return showConfirmationDialog(this, {
|
||||
title: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_title"
|
||||
),
|
||||
text: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_text"
|
||||
),
|
||||
confirmText: this.hass!.localize("ui.common.leave"),
|
||||
dismissText: this.hass!.localize("ui.common.stay"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!this.isDirtyState) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_title"
|
||||
),
|
||||
text: this.hass!.localize(
|
||||
"ui.panel.config.scene.editor.unsaved_confirm_text"
|
||||
),
|
||||
confirmText: this.hass!.localize("ui.common.leave"),
|
||||
dismissText: this.hass!.localize("ui.common.stay"),
|
||||
destructive: true,
|
||||
});
|
||||
if (confirmed) {
|
||||
this._markDirtyStateClean();
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
private async _duplicate() {
|
||||
|
||||
@@ -804,10 +804,15 @@ export class HaScriptEditor extends SubscribeMixin(
|
||||
return;
|
||||
}
|
||||
|
||||
this.yamlErrors = undefined;
|
||||
resolve(true);
|
||||
},
|
||||
onClose: () => resolve(false),
|
||||
onDiscard: () => resolve(true),
|
||||
onDiscard: () => {
|
||||
this.yamlErrors = undefined;
|
||||
this._markDirtyStateClean();
|
||||
resolve(true);
|
||||
},
|
||||
entityRegistryUpdate: this.entityRegistryUpdate,
|
||||
entityRegistryEntry: this.registryEntry,
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -52,11 +52,11 @@ import type {
|
||||
} from "../../../../data/recorder";
|
||||
import {
|
||||
clearStatistics,
|
||||
getStatisticIds,
|
||||
StatisticMeanType,
|
||||
updateStatisticsIssues,
|
||||
validateStatistics,
|
||||
} from "../../../../data/recorder";
|
||||
import { getStatisticIds } from "../../../../data/recorder_statistic_ids";
|
||||
import {
|
||||
apiContext,
|
||||
internationalizationContext,
|
||||
|
||||
@@ -395,9 +395,11 @@ export class VoiceAssistantsExpose extends LitElement {
|
||||
aliases: entry?.aliases || [],
|
||||
};
|
||||
}
|
||||
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
|
||||
result[entityId].assistants
|
||||
);
|
||||
if (result[entityId]) {
|
||||
result[entityId].assistants_sortable_key = getAssistantsSortableKey(
|
||||
result[entityId].assistants
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
return Object.values(result);
|
||||
|
||||
@@ -266,7 +266,6 @@ class HaPanelHistory extends LitElement {
|
||||
.startTime=${this._startDate}
|
||||
.endTime=${this._endDate}
|
||||
.narrow=${this.narrow}
|
||||
inside-labels
|
||||
sync-charts
|
||||
>
|
||||
</state-history-charts>
|
||||
@@ -810,12 +809,7 @@ class HaPanelHistory extends LitElement {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden auto;
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
/* Line the charts up with the toolbar when there are no axis labels. */
|
||||
:host([narrow]) .results {
|
||||
padding-inline: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.progress-wrapper {
|
||||
|
||||
@@ -188,7 +188,7 @@ class HuiHistoryChartCardFeature
|
||||
}
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="container no-history-found">
|
||||
<div class="info">
|
||||
${this.hass!.localize(
|
||||
"ui.components.history_charts.no_history_found"
|
||||
@@ -280,6 +280,14 @@ class HuiHistoryChartCardFeature
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.no-history-found {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
hui-graph-base {
|
||||
width: 100%;
|
||||
--accent-color: var(--feature-color);
|
||||
|
||||
@@ -217,22 +217,34 @@ export class HuiClockCardDigital extends LitElement {
|
||||
"hour minute second"
|
||||
"hour minute am-pm";
|
||||
|
||||
font-size: 1.5rem;
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-small,
|
||||
var(--ha-font-size-2xl)
|
||||
);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: 0.8;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.time-title + .time-parts {
|
||||
font-size: 1.5rem;
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-small,
|
||||
var(--ha-font-size-2xl)
|
||||
);
|
||||
}
|
||||
|
||||
.time-parts.size-medium {
|
||||
font-size: 3rem;
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-medium,
|
||||
calc(48px * var(--ha-font-size-scale))
|
||||
);
|
||||
}
|
||||
|
||||
.time-parts.size-large {
|
||||
font-size: 4rem;
|
||||
font-size: var(
|
||||
--ha-clock-card-digital-font-size-large,
|
||||
calc(64px * var(--ha-font-size-scale))
|
||||
);
|
||||
}
|
||||
|
||||
.time-parts.size-medium .time-part.second,
|
||||
|
||||
@@ -165,7 +165,7 @@ export class HuiViewBackgroundEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _currentBackgroundImage(): string | undefined {
|
||||
const background = this._backgroundData(this._config);
|
||||
const background = this._backgroundData(this.config);
|
||||
return typeof background.image === "object"
|
||||
? background.image.media_content_id
|
||||
: background.image;
|
||||
|
||||
@@ -8403,7 +8403,10 @@
|
||||
"status": "Status",
|
||||
"version": "Version",
|
||||
"data_rate": "Data rate",
|
||||
"area": "Area"
|
||||
"area": "Area",
|
||||
"neighbor": "In range of each other",
|
||||
"toggle_neighbors": "Toggle neighbor connections. Loading neighbor data temporarily turns off the Z-Wave adapter.",
|
||||
"neighbors_error": "Failed to load neighbor connections"
|
||||
},
|
||||
"node_status": {
|
||||
"0": "Unknown",
|
||||
|
||||
Vendored
+8
@@ -13,3 +13,11 @@ declare module "echarts/lib/util/states" {
|
||||
declare module "echarts/lib/chart/sankey/SankeyView" {
|
||||
export { default } from "echarts/types/src/chart/sankey/SankeyView";
|
||||
}
|
||||
|
||||
declare module "echarts/lib/util/number" {
|
||||
export * from "echarts/types/src/util/number";
|
||||
}
|
||||
|
||||
declare module "echarts/lib/scale/helper" {
|
||||
export * from "echarts/types/src/scale/helper";
|
||||
}
|
||||
|
||||
@@ -18,6 +18,22 @@ const maplibreGL = vi.hoisted(() => vi.fn(() => maplibreLayer));
|
||||
|
||||
vi.mock("@maplibre/maplibre-gl-leaflet", () => ({ maplibreGL }));
|
||||
|
||||
// The token module is driven directly here, so a stale token and the one that
|
||||
// replaces it can be played out without a WebSocket.
|
||||
const tokenListeners = vi.hoisted(() => new Set<(token: string) => void>());
|
||||
const refreshMapTilesToken = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../../../src/data/map_tiles", () => ({
|
||||
MAP_TILES_PATH: "/api/map_tiles",
|
||||
refreshMapTilesToken,
|
||||
subscribeMapTilesToken: (listener: (token: string) => void) => {
|
||||
tokenListeners.add(listener);
|
||||
return () => tokenListeners.delete(listener);
|
||||
},
|
||||
withMapTilesToken: (url: string) => new URL(url, location.href).href,
|
||||
}));
|
||||
const emitToken = (token: string) =>
|
||||
tokenListeners.forEach((listener) => listener(token));
|
||||
|
||||
const STYLE = {
|
||||
version: 8,
|
||||
sources: {},
|
||||
@@ -25,19 +41,23 @@ const STYLE = {
|
||||
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 rasterLayer = {
|
||||
// Leaflet returns the layer from addTo, and the source chains off it.
|
||||
addTo: vi.fn(() => rasterLayer),
|
||||
redraw: vi.fn(),
|
||||
options: {} as Record<string, unknown>,
|
||||
};
|
||||
const leaflet = {
|
||||
tileLayer: vi.fn(() => rasterLayer),
|
||||
Browser: browser,
|
||||
} as unknown as LeafletModuleType;
|
||||
|
||||
const TOKEN = "test-token";
|
||||
|
||||
// `createVectorLayer` listens on both the MapLibre map and the Leaflet map.
|
||||
const glHandlers: Record<string, () => void> = {};
|
||||
const glHandlers: Record<string, (event?: unknown) => void> = {};
|
||||
const glMap = {
|
||||
setStyle: vi.fn(),
|
||||
on: vi.fn((event: string, handler: () => void) => {
|
||||
on: vi.fn((event: string, handler: (event?: unknown) => void) => {
|
||||
glHandlers[event] = handler;
|
||||
}),
|
||||
};
|
||||
@@ -57,6 +77,7 @@ const isRaster = () => vi.mocked(leaflet.tileLayer).mock.calls.length === 1;
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
tokenListeners.clear();
|
||||
maplibreLayer.options = {};
|
||||
maplibreGL.mockReturnValue(maplibreLayer);
|
||||
maplibreLayer.addTo.mockImplementation(() => maplibreLayer);
|
||||
@@ -79,40 +100,28 @@ describe("createBaseLayer", () => {
|
||||
it("falls back to raster tiles without WebGL2", async () => {
|
||||
const createBaseLayer = await setWebGL2(false);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
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");
|
||||
// Through core's proxy, which is what identifies Home Assistant upstream -
|
||||
// a browser can set neither a User-Agent nor a Referer.
|
||||
expect(url).toContain("/api/map_tiles/raster/{z}/{x}/{y}.png");
|
||||
// Leaflet substitutes options into the template on every tile request, so
|
||||
// the token can be refreshed without recreating the layer.
|
||||
expect(url).toContain("token={token}");
|
||||
expect(options).toMatchObject({ token: TOKEN });
|
||||
// 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.
|
||||
// raster layer is the only one carrying attribution itself.
|
||||
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("uses vector tiles when WebGL2 is available", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(maplibreGL).toHaveBeenCalledOnce();
|
||||
expect(maplibreLayer.addTo).toHaveBeenCalledWith(map);
|
||||
@@ -128,7 +137,7 @@ describe("createBaseLayer", () => {
|
||||
})
|
||||
);
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
@@ -141,7 +150,7 @@ describe("createBaseLayer", () => {
|
||||
throw new Error("Failed to initialize WebGL");
|
||||
});
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
expect(maplibreLayer.remove).toHaveBeenCalled();
|
||||
@@ -156,7 +165,7 @@ describe("createBaseLayer", () => {
|
||||
throw new Error("nothing to remove");
|
||||
});
|
||||
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
expect(isRaster()).toBe(true);
|
||||
});
|
||||
@@ -169,7 +178,7 @@ describe("setDarkMode", () => {
|
||||
|
||||
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);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
baseLayer.setDarkMode(true);
|
||||
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
|
||||
@@ -183,7 +192,7 @@ describe("setDarkMode", () => {
|
||||
// the tracked mode, and the next toggle to that mode would do nothing.
|
||||
it("can retry a mode whose request failed while another was in flight", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
const failing = vi.fn(async () => {
|
||||
throw new Error("offline");
|
||||
@@ -230,7 +239,7 @@ describe("setDarkMode", () => {
|
||||
|
||||
// The layer only settles once its first style resolves, so let that one
|
||||
// through before the map exists to switch.
|
||||
const pending = createBaseLayer(leaflet, map, false);
|
||||
const pending = createBaseLayer(leaflet, map, false, TOKEN);
|
||||
await vi.waitFor(() => expect(resolvers).toHaveLength(1));
|
||||
resolvers.shift()!(styleResponse("light"));
|
||||
const baseLayer = await pending;
|
||||
@@ -262,7 +271,7 @@ describe("WebGL context loss", () => {
|
||||
|
||||
it("falls back to raster tiles when the context stays lost", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
expect(leaflet.tileLayer).not.toHaveBeenCalled();
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
@@ -274,7 +283,7 @@ describe("WebGL context loss", () => {
|
||||
|
||||
it("keeps the vector layer when the context comes back", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
glHandlers.webglcontextrestored();
|
||||
@@ -290,7 +299,7 @@ describe("WebGL context loss", () => {
|
||||
it("waits for the page to be visible before falling back", async () => {
|
||||
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
@@ -306,7 +315,7 @@ describe("WebGL context loss", () => {
|
||||
it("keeps the vector layer when a hidden page gets its context back", async () => {
|
||||
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
glHandlers.webglcontextrestored();
|
||||
@@ -321,7 +330,7 @@ describe("WebGL context loss", () => {
|
||||
it("stops listening for visibility once it has fallen back", async () => {
|
||||
const remove = vi.spyOn(document, "removeEventListener");
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
@@ -338,7 +347,7 @@ describe("WebGL context loss", () => {
|
||||
|
||||
it("stops answering theme changes once it has fallen back", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false);
|
||||
const baseLayer = await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.webglcontextlost();
|
||||
vi.runAllTimers();
|
||||
@@ -347,3 +356,63 @@ describe("WebGL context loss", () => {
|
||||
expect(glMap.setStyle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// A refused request leaves the source dead: the TileJSON is fetched once and
|
||||
// MapLibre never retries it, so the map stays blank until the style is
|
||||
// applied again.
|
||||
describe("recovering from a refused token", () => {
|
||||
it("asks for a new token and re-applies the style once it arrives", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
glMap.setStyle.mockClear();
|
||||
|
||||
glHandlers.error({ error: { status: 403 } });
|
||||
expect(refreshMapTilesToken).toHaveBeenCalled();
|
||||
|
||||
emitToken("fresh-token");
|
||||
await vi.waitFor(() => expect(glMap.setStyle).toHaveBeenCalledOnce());
|
||||
});
|
||||
|
||||
it("does not keep asking while the proxy refuses for another reason", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
glHandlers.error({ error: { status: 403 } });
|
||||
}
|
||||
|
||||
expect(refreshMapTilesToken).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores errors that are not a refusal", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
glHandlers.error({ error: { status: 500 } });
|
||||
|
||||
expect(refreshMapTilesToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves a working map alone when the token is merely refreshed", async () => {
|
||||
const createBaseLayer = await setWebGL2(true);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
glMap.setStyle.mockClear();
|
||||
|
||||
emitToken("fresh-token");
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
expect(glMap.setStyle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redraws the raster layer so refused tiles are asked for again", async () => {
|
||||
const createBaseLayer = await setWebGL2(false);
|
||||
await createBaseLayer(leaflet, map, false, TOKEN);
|
||||
|
||||
emitToken("fresh-token");
|
||||
|
||||
expect(rasterLayer.options.token).toBe("fresh-token");
|
||||
expect(rasterLayer.redraw).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { NavigateOptions } from "../../src/common/navigate";
|
||||
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
|
||||
import type {
|
||||
NavigateOptions,
|
||||
UnsavedChangesGuard,
|
||||
} from "../../src/common/navigate";
|
||||
import {
|
||||
canGoBack,
|
||||
goBack,
|
||||
navigate,
|
||||
registerUnsavedChangesGuard,
|
||||
unregisterUnsavedChangesGuard,
|
||||
} from "../../src/common/navigate";
|
||||
|
||||
// navigate() closes open dialogs before touching history.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
@@ -54,6 +63,145 @@ describe("navigate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsaved changes guard", () => {
|
||||
const registeredGuards: UnsavedChangesGuard[] = [];
|
||||
|
||||
// Registering through this keeps afterEach able to clean up the module-level
|
||||
// registry, which outlives the test that filled it.
|
||||
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
|
||||
registerUnsavedChangesGuard(guard);
|
||||
registeredGuards.push(guard);
|
||||
return guard;
|
||||
};
|
||||
|
||||
const registerGuard = (isDirty: boolean, promptResult = true) =>
|
||||
trackGuard({
|
||||
isDirty: vi.fn(() => isDirty),
|
||||
prompt: vi.fn(async () => promptResult),
|
||||
});
|
||||
|
||||
// A guard whose prompt stays open until the test answers it.
|
||||
const trackDeferredGuard = (isDirty: () => boolean) => {
|
||||
let answer!: (value: boolean) => void;
|
||||
const guard = trackGuard({
|
||||
isDirty,
|
||||
prompt: vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
answer = resolve;
|
||||
})
|
||||
),
|
||||
});
|
||||
return { guard, answerPrompt: (value: boolean) => answer(value) };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setEntry("/config");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
|
||||
});
|
||||
|
||||
it("navigates without prompting when no guard is dirty", async () => {
|
||||
const guard = registerGuard(false);
|
||||
|
||||
expect(await navigate("/config/areas")).toBe(true);
|
||||
|
||||
expect(window.location.pathname).toEqual("/config/areas");
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts a dirty guard and navigates when confirmed", async () => {
|
||||
const guard = registerGuard(true, true);
|
||||
|
||||
expect(await navigate("/config/areas")).toBe(true);
|
||||
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
expect(window.location.pathname).toEqual("/config/areas");
|
||||
});
|
||||
|
||||
it("leaves history untouched when the prompt is declined", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
const listener = vi.fn();
|
||||
window.addEventListener("location-changed", listener);
|
||||
|
||||
expect(await navigate("/config/areas")).toBe(false);
|
||||
|
||||
window.removeEventListener("location-changed", listener);
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
expect(window.location.pathname).toEqual("/config");
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prompt when navigating to the current path", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
expect(await navigate("/config")).toBe(true);
|
||||
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a pending prompt once no guard is dirty anymore", async () => {
|
||||
let dirty = true;
|
||||
const { guard, answerPrompt } = trackDeferredGuard(() => dirty);
|
||||
|
||||
const first = navigate("/config/areas");
|
||||
dirty = false;
|
||||
|
||||
expect(await navigate("/config/devices/dashboard")).toBe(true);
|
||||
expect(window.location.pathname).toEqual("/config/devices/dashboard");
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
|
||||
answerPrompt(true);
|
||||
await first;
|
||||
});
|
||||
|
||||
it("drops a navigation superseded while its prompt was open", async () => {
|
||||
let dirty = true;
|
||||
const { answerPrompt } = trackDeferredGuard(() => dirty);
|
||||
|
||||
const superseded = navigate("/config/areas");
|
||||
dirty = false;
|
||||
await navigate("/config/devices/dashboard");
|
||||
|
||||
answerPrompt(true);
|
||||
|
||||
expect(await superseded).toBe(false);
|
||||
expect(window.location.pathname).toEqual("/config/devices/dashboard");
|
||||
});
|
||||
|
||||
it("shares one pending prompt between concurrent navigations", async () => {
|
||||
const { guard, answerPrompt } = trackDeferredGuard(() => true);
|
||||
|
||||
const first = navigate("/config/areas");
|
||||
const second = navigate("/config/devices/dashboard");
|
||||
answerPrompt(true);
|
||||
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(guard.prompt).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stops prompting once the guard is unregistered", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
unregisterUnsavedChangesGuard(guard);
|
||||
|
||||
expect(await navigate("/config/areas")).toBe(true);
|
||||
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prompt for goBack's fallback navigation", async () => {
|
||||
const guard = registerGuard(true, false);
|
||||
|
||||
await goBack("/config/cloud/account");
|
||||
|
||||
expect(window.location.pathname).toEqual("/config/cloud/account");
|
||||
expect(guard.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("goBack", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config/cloud/remote");
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { shareInFlightRequest } from "../../../src/common/util/share-in-flight-request";
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
describe("shareInFlightRequest", () => {
|
||||
it("shares an in-flight request for the same owner and key", async () => {
|
||||
const owner = {};
|
||||
const request = deferred<number>();
|
||||
const fetcher = vi.fn(() => request.promise);
|
||||
|
||||
const first = shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
const second = shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
|
||||
request.resolve(42);
|
||||
|
||||
await expect(first).resolves.toBe(42);
|
||||
await expect(second).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it("does not share requests with different keys", async () => {
|
||||
const owner = {};
|
||||
const fetcherA = vi.fn(async () => "a");
|
||||
const fetcherB = vi.fn(async () => "b");
|
||||
|
||||
await Promise.all([
|
||||
shareInFlightRequest(owner, "resource:a", fetcherA),
|
||||
shareInFlightRequest(owner, "resource:b", fetcherB),
|
||||
]);
|
||||
|
||||
expect(fetcherA).toHaveBeenCalledTimes(1);
|
||||
expect(fetcherB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share requests between different owners", async () => {
|
||||
const firstOwner = {};
|
||||
const secondOwner = {};
|
||||
const firstFetcher = vi.fn(async () => "first");
|
||||
const secondFetcher = vi.fn(async () => "second");
|
||||
|
||||
await Promise.all([
|
||||
shareInFlightRequest(firstOwner, "resource:a", firstFetcher),
|
||||
shareInFlightRequest(secondOwner, "resource:a", secondFetcher),
|
||||
]);
|
||||
|
||||
expect(firstFetcher).toHaveBeenCalledTimes(1);
|
||||
expect(secondFetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets a request after it resolves", async () => {
|
||||
const owner = {};
|
||||
const fetcher = vi.fn(async () => 42);
|
||||
|
||||
await shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
await shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("forgets a request after it rejects", async () => {
|
||||
const owner = {};
|
||||
const fetcher = vi
|
||||
.fn<() => Promise<number>>()
|
||||
.mockRejectedValueOnce(new Error("failed"))
|
||||
.mockResolvedValueOnce(42);
|
||||
|
||||
await expect(
|
||||
shareInFlightRequest(owner, "resource:a", fetcher)
|
||||
).rejects.toThrow("failed");
|
||||
|
||||
await expect(
|
||||
shareInFlightRequest(owner, "resource:a", fetcher)
|
||||
).resolves.toBe(42);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deep-freezes resolved results", async () => {
|
||||
const owner = {};
|
||||
const result = { items: ["a"] };
|
||||
const fetcher = vi.fn(async () => result);
|
||||
|
||||
const shared = await shareInFlightRequest(owner, "resource:a", fetcher);
|
||||
|
||||
expect(shared).toBe(result);
|
||||
expect(Object.isFrozen(shared)).toBe(true);
|
||||
expect(Object.isFrozen((shared as typeof result).items)).toBe(true);
|
||||
expect(() => {
|
||||
(shared as typeof result).items.push("b");
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SVGRenderer } from "echarts/renderers";
|
||||
import echarts from "../../../src/resources/echarts/echarts";
|
||||
import { createYAxisPrecisionBounds } from "../../../src/components/chart/y-axis-fraction-digits";
|
||||
|
||||
// jsdom has no canvas, and zrender reaches for one to measure label text.
|
||||
HTMLCanvasElement.prototype.getContext = (() => ({
|
||||
measureText: () => ({ width: 10 }),
|
||||
})) as any;
|
||||
|
||||
// The app registers the canvas renderer; jsdom needs the SVG one.
|
||||
echarts.use([SVGRenderer]);
|
||||
|
||||
// The gap is opened by ECharts' own tick rounding, not by our helper, so these
|
||||
// assert the extent ECharts actually renders rather than what we hand it.
|
||||
const renderExtent = (
|
||||
data: number[],
|
||||
yAxis: Record<string, unknown>,
|
||||
nextYAxis?: Record<string, unknown>
|
||||
): [number, number] => {
|
||||
const chart = echarts.init(null, null, {
|
||||
ssr: true,
|
||||
renderer: "svg",
|
||||
width: 400,
|
||||
height: 300,
|
||||
});
|
||||
const option = (axis: Record<string, unknown>) => ({
|
||||
xAxis: { type: "category", data: data.map((_, index) => String(index)) },
|
||||
yAxis: { type: "value", ...axis },
|
||||
series: [{ type: "line", data }],
|
||||
});
|
||||
chart.setOption(option(yAxis));
|
||||
if (nextYAxis) {
|
||||
chart.setOption(option(nextYAxis), { replaceMerge: ["series"] });
|
||||
}
|
||||
// getModel() is internal, but it is the only way to read the rendered extent.
|
||||
const extent = (chart as any)
|
||||
.getModel()
|
||||
.getComponent("yAxis")
|
||||
.axis.scale.getExtent();
|
||||
chart.dispose();
|
||||
return extent;
|
||||
};
|
||||
|
||||
const withGap = (includeZero = false, unit?: string) => ({
|
||||
scale: !includeZero,
|
||||
...createYAxisPrecisionBounds({
|
||||
includeZero,
|
||||
unit,
|
||||
onFractionDigits: () => undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
describe("Y-axis tick nudge", () => {
|
||||
it("opens a gap under data that lands exactly on a tick", () => {
|
||||
// The reported bug: a thermostat's states are quantized, so the minimum is
|
||||
// an exact tick and the HVAC action band has nothing to fill.
|
||||
expect(renderExtent([18, 21.2, 19], { scale: true })).toEqual([18, 21.5]);
|
||||
expect(renderExtent([18, 21.2, 19], withGap())).toEqual([17.5, 21.5]);
|
||||
});
|
||||
|
||||
it("leaves an axis with real headroom untouched", () => {
|
||||
// Its minimum sits well clear of the tick below it, so the rounding it
|
||||
// already gets is enough and the widened extent floors to the same place.
|
||||
const data = [18.3, 21.2, 19.5];
|
||||
expect(renderExtent(data, withGap())).toEqual(
|
||||
renderExtent(data, { scale: true })
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a non-negative series anchored at zero", () => {
|
||||
expect(renderExtent([0, 3500, 1200], withGap())[0]).toBe(0);
|
||||
// Without the clamp the nudged minimum floors a whole interval below zero.
|
||||
expect(
|
||||
renderExtent([0, 3500, 1200], {
|
||||
scale: true,
|
||||
boundaryGap: [0.02, 0.02],
|
||||
})[0]
|
||||
).toBe(-1000);
|
||||
});
|
||||
|
||||
it("keeps the window ECharts gives a constant series", () => {
|
||||
expect(renderExtent([21, 21, 21], withGap())).toEqual(
|
||||
renderExtent([21, 21, 21], { scale: true })
|
||||
);
|
||||
// A series flat at zero has no magnitude to expand by.
|
||||
expect(renderExtent([0, 0, 0], withGap())).toEqual(
|
||||
renderExtent([0, 0, 0], { scale: true })
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
expect(renderExtent([20, 500, 300], withGap(), withGap(true))[0]).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -72,13 +72,25 @@ describe("computeYAxisFractionDigits", () => {
|
||||
});
|
||||
|
||||
describe("createYAxisPrecisionBounds", () => {
|
||||
it("computes digits from the visible extent when no bounds are set", () => {
|
||||
const makeBounds = (
|
||||
options: Omit<
|
||||
Parameters<typeof createYAxisPrecisionBounds>[0],
|
||||
"onFractionDigits"
|
||||
> = {}
|
||||
) => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({ onFractionDigits });
|
||||
return {
|
||||
...createYAxisPrecisionBounds({ ...options, onFractionDigits }),
|
||||
onFractionDigits,
|
||||
};
|
||||
};
|
||||
|
||||
it("computes digits from the visible extent when no bounds are set", () => {
|
||||
const { min, max, onFractionDigits } = makeBounds();
|
||||
|
||||
// Zoomed-out extent -> coarse precision, callbacks leave scaling to ECharts
|
||||
expect(min({ min: 0, max: 100 })).toBeUndefined();
|
||||
expect(max({ min: 0, max: 100 })).toBeUndefined();
|
||||
expect(min({ min: 10, max: 100 })).toBeUndefined();
|
||||
expect(max({ min: 10, max: 100 })).toBeUndefined();
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
|
||||
|
||||
// Zoomed-in narrow extent -> more decimals so ticks stay distinct
|
||||
@@ -87,12 +99,7 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("computes digits from numeric bounds and returns them unchanged", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({
|
||||
min: 1.85,
|
||||
max: 2,
|
||||
onFractionDigits,
|
||||
});
|
||||
const { min, max, onFractionDigits } = makeBounds({ min: 1.85, max: 2 });
|
||||
|
||||
// Fixed bounds pin the range, so the visible extent is ignored
|
||||
expect(min({ min: 1.9, max: 1.95 })).toBe(1.85);
|
||||
@@ -101,11 +108,9 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("resolves function bounds and passes their result through", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min, max } = createYAxisPrecisionBounds({
|
||||
const { min, max, onFractionDigits } = makeBounds({
|
||||
min: ({ min: dataMin }) => dataMin - 1,
|
||||
max: ({ max: dataMax }) => dataMax + 1,
|
||||
onFractionDigits,
|
||||
});
|
||||
|
||||
expect(min({ min: 10, max: 11 })).toBe(9);
|
||||
@@ -115,11 +120,7 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("unions the extent with zero for anchored axes", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min } = createYAxisPrecisionBounds({
|
||||
includeZero: true,
|
||||
onFractionDigits,
|
||||
});
|
||||
const { min, onFractionDigits } = makeBounds({ includeZero: true });
|
||||
|
||||
// Data sits at 20..25, but a bar axis renders from 0 -> coarse precision
|
||||
min({ min: 20, max: 25 });
|
||||
@@ -131,10 +132,102 @@ describe("createYAxisPrecisionBounds", () => {
|
||||
});
|
||||
|
||||
it("does not over-pad when the visible extent collapses to noise", () => {
|
||||
const onFractionDigits = vi.fn();
|
||||
const { min } = createYAxisPrecisionBounds({ onFractionDigits });
|
||||
const { min, onFractionDigits } = makeBounds();
|
||||
|
||||
min({ min: 0.3, max: 0.3 + 1e-15 });
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(1);
|
||||
});
|
||||
|
||||
it("widens the extent so ECharts always rounds out a gap", () => {
|
||||
const { min, max, boundaryGap, splitNumber } = makeBounds();
|
||||
|
||||
// The gap is applied by ECharts, so the bounds stay auto-scaled.
|
||||
expect(boundaryGap).toEqual([0.02, 0.02]);
|
||||
// Pinned rather than assumed, since the precision here is derived from it.
|
||||
expect(splitNumber).toBe(5);
|
||||
expect(min({ min: 18, max: 21.2 })).toBeUndefined();
|
||||
expect(max({ min: 18, max: 21.2 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("anchors at zero instead of widening a non-negative series below it", () => {
|
||||
const { min, max } = makeBounds();
|
||||
|
||||
// Without this, ECharts floors the widened -0.0035 to a full interval below
|
||||
// zero, inventing a negative region on a power chart.
|
||||
expect(min({ min: 0, max: 3500 })).toBe(0);
|
||||
expect(max({ min: 0, max: 3500 })).toBeUndefined();
|
||||
|
||||
// A minimum clear of the gap is left to ECharts.
|
||||
expect(min({ min: 100, max: 3500 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("anchors an all-negative series at zero from above", () => {
|
||||
const { min, max } = makeBounds();
|
||||
|
||||
expect(max({ min: -3500, max: 0 })).toBe(0);
|
||||
expect(min({ min: -3500, max: 0 })).toBeUndefined();
|
||||
|
||||
// Mixed-sign data is widened at both ends.
|
||||
expect(min({ min: -20, max: 20 })).toBeUndefined();
|
||||
expect(max({ min: -20, max: 20 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the expanded window ECharts gives a constant series", () => {
|
||||
const { min, max, onFractionDigits } = makeBounds();
|
||||
|
||||
// ECharts only expands a flat extent while its bounds are equal, so the gap
|
||||
// would otherwise collapse this to 20.99998..21.00002 at five decimals.
|
||||
expect(min({ min: 21, max: 21 })).toBe(10);
|
||||
expect(max({ min: 21, max: 21 })).toBe(35);
|
||||
expect(onFractionDigits).toHaveBeenLastCalledWith(0);
|
||||
|
||||
// A series flat at zero has no magnitude to expand by.
|
||||
expect(min({ min: 0, max: 0 })).toBe(0);
|
||||
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 });
|
||||
|
||||
// Widening below zero would defeat ECharts' anchoring and float the bars.
|
||||
expect(boundaryGap).toEqual([0, 0]);
|
||||
expect(min({ min: 0, max: 3500 })).toBeUndefined();
|
||||
expect(max({ min: 20, max: 20 })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,4 +173,31 @@ describe("ha-target-picker-item-row target extraction", () => {
|
||||
expect(entries!.referenced_devices).toEqual(["dev_1"]);
|
||||
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
|
||||
});
|
||||
|
||||
it("does not mutate the extracted target result", async () => {
|
||||
const result = extractResult({
|
||||
referenced_areas: ["area_missing"],
|
||||
referenced_devices: ["dev_missing"],
|
||||
referenced_entities: ["light.missing"],
|
||||
});
|
||||
|
||||
const entries = await extractedBy(
|
||||
result,
|
||||
{
|
||||
areas: {},
|
||||
devices: {},
|
||||
entities: {},
|
||||
},
|
||||
{ type: "floor", itemId: "floor_1" }
|
||||
);
|
||||
|
||||
expect(entries).toBeDefined();
|
||||
expect(entries).not.toBe(result);
|
||||
expect(entries!.referenced_areas).toEqual([]);
|
||||
expect(entries!.referenced_devices).toEqual([]);
|
||||
expect(entries!.referenced_entities).toEqual([]);
|
||||
expect(result.referenced_areas).toEqual(["area_missing"]);
|
||||
expect(result.referenced_devices).toEqual(["dev_missing"]);
|
||||
expect(result.referenced_entities).toEqual(["light.missing"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// MapLibre hands tile URLs to a worker, which has no document to resolve a
|
||||
// relative URL against. Measured: without absolute URLs nothing loads at all -
|
||||
// not the TileJSON, not the glyphs, not a tile - and MapLibre reports no error,
|
||||
// so this is worth pinning down.
|
||||
|
||||
const connectionWith = (...tokens: string[]) => {
|
||||
let call = 0;
|
||||
const listeners: Record<string, () => void> = {};
|
||||
return {
|
||||
sendMessagePromise: vi.fn(async () => ({
|
||||
token: tokens[Math.min(call++, tokens.length - 1)],
|
||||
})),
|
||||
addEventListener: vi.fn((event: string, cb: () => void) => {
|
||||
listeners[event] = cb;
|
||||
}),
|
||||
listeners,
|
||||
} as unknown as Connection & { listeners: Record<string, () => void> };
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
vi.resetModules();
|
||||
return import("../../src/data/map_tiles");
|
||||
};
|
||||
|
||||
describe("withMapTilesToken", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("makes relative URLs absolute", async () => {
|
||||
const { withMapTilesToken } = await load();
|
||||
|
||||
expect(withMapTilesToken("/api/map_tiles/tilejson.json")).toBe(
|
||||
`${location.origin}/api/map_tiles/tilejson.json`
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the token to proxy URLs once there is one", async () => {
|
||||
const { ensureMapTilesToken, withMapTilesToken } = await load();
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
const url = new URL(withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt"));
|
||||
expect(url.searchParams.get("token")).toBe("abc123");
|
||||
expect(url.pathname).toBe("/api/map_tiles/vector/1/0/0.mvt");
|
||||
});
|
||||
|
||||
it("leaves anything outside the proxy alone", async () => {
|
||||
const { ensureMapTilesToken, withMapTilesToken } = await load();
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
const url = new URL(
|
||||
withMapTilesToken("https://example.com/tiles/1/0/0.png")
|
||||
);
|
||||
expect(url.searchParams.get("token")).toBeNull();
|
||||
expect(url.href).toBe("https://example.com/tiles/1/0/0.png");
|
||||
});
|
||||
|
||||
it("still returns an absolute URL when there is no token", async () => {
|
||||
const { withMapTilesToken } = await load();
|
||||
|
||||
expect(
|
||||
withMapTilesToken("/api/map_tiles/fonts/noto_sans_regular/0-255.pbf")
|
||||
).toBe(
|
||||
`${location.origin}/api/map_tiles/fonts/noto_sans_regular/0-255.pbf`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureMapTilesToken", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("asks the backend once and reuses the answer", async () => {
|
||||
const { ensureMapTilesToken } = await load();
|
||||
const connection = connectionWith("abc123");
|
||||
|
||||
expect(await ensureMapTilesToken(connection)).toBe("abc123");
|
||||
expect(await ensureMapTilesToken(connection)).toBe("abc123");
|
||||
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// A backend without the proxy must not hold the map hostage while the
|
||||
// retries run; the map falls back to loading nothing rather than to nothing
|
||||
// being drawn at all.
|
||||
it("gives up quickly on a backend that does not answer", async () => {
|
||||
const { ensureMapTilesToken } = await load();
|
||||
const connection = {
|
||||
sendMessagePromise: vi.fn(async () => {
|
||||
throw new Error("unknown command");
|
||||
}),
|
||||
} as unknown as Connection;
|
||||
|
||||
const started = Date.now();
|
||||
expect(await ensureMapTilesToken(connection)).toBeUndefined();
|
||||
expect(Date.now() - started).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
it("tells subscribers when a token arrives, for Leaflet's baked template", async () => {
|
||||
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
|
||||
const listener = vi.fn();
|
||||
subscribeMapTilesToken(listener);
|
||||
|
||||
await ensureMapTilesToken(connectionWith("abc123"));
|
||||
|
||||
expect(listener).toHaveBeenCalledWith("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
// A token can expire before the refresh interval comes round, so recovery
|
||||
// hangs on the reconnect and on retrying a refused request.
|
||||
describe("recovering a stale token", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("fetches a new token when the connection comes back", async () => {
|
||||
const { ensureMapTilesToken, subscribeMapTilesToken } = await load();
|
||||
const connection = connectionWith("first", "second") as Connection & {
|
||||
listeners: Record<string, () => void>;
|
||||
};
|
||||
await ensureMapTilesToken(connection);
|
||||
|
||||
const listener = vi.fn();
|
||||
subscribeMapTilesToken(listener);
|
||||
connection.listeners.ready();
|
||||
await vi.waitFor(() => expect(listener).toHaveBeenCalledWith("second"));
|
||||
});
|
||||
|
||||
it("asks once for a refresh however many tiles were refused", async () => {
|
||||
const { ensureMapTilesToken, refreshMapTilesToken } = await load();
|
||||
const connection = connectionWith("first", "second");
|
||||
await ensureMapTilesToken(connection);
|
||||
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(1);
|
||||
|
||||
await Promise.all([
|
||||
refreshMapTilesToken(),
|
||||
refreshMapTilesToken(),
|
||||
refreshMapTilesToken(),
|
||||
]);
|
||||
|
||||
expect(connection.sendMessagePromise).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps the old token when the refresh fails", async () => {
|
||||
const { ensureMapTilesToken, refreshMapTilesToken, withMapTilesToken } =
|
||||
await load();
|
||||
const connection = connectionWith("first");
|
||||
await ensureMapTilesToken(connection);
|
||||
|
||||
vi.mocked(connection.sendMessagePromise).mockRejectedValueOnce(
|
||||
new Error("disconnected")
|
||||
);
|
||||
await refreshMapTilesToken();
|
||||
|
||||
expect(
|
||||
new URL(
|
||||
withMapTilesToken("/api/map_tiles/vector/1/0/0.mvt")
|
||||
).searchParams.get("token")
|
||||
).toBe("first");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { HomeAssistant } from "../../src/types";
|
||||
import { getStatisticIds } from "../../src/data/recorder_statistic_ids";
|
||||
import type { StatisticsMetaData } from "../../src/data/recorder";
|
||||
|
||||
const createHass = (callWS = vi.fn()) =>
|
||||
({
|
||||
callWS,
|
||||
}) as unknown as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const deferred = <T>() => {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
};
|
||||
|
||||
describe("getStatisticIds", () => {
|
||||
it("shares concurrent requests for the same statistic type", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
const first = getStatisticIds(hass);
|
||||
const second = getStatisticIds(hass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("works with callers that only expose callWS", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const api = { callWS } as Pick<HomeAssistant, "callWS">;
|
||||
|
||||
const first = getStatisticIds(api);
|
||||
const second = getStatisticIds(api);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("shares requests across callers with the same callWS", async () => {
|
||||
const request = deferred<StatisticsMetaData[]>();
|
||||
const callWS = vi.fn().mockReturnValue(request.promise);
|
||||
const firstHass = createHass(callWS);
|
||||
const secondHass = createHass(callWS);
|
||||
|
||||
const first = getStatisticIds(firstHass);
|
||||
const second = getStatisticIds(secondHass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).toBe(first);
|
||||
|
||||
request.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("does not share requests for different statistic types", async () => {
|
||||
const callWS = vi.fn().mockResolvedValue([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await Promise.all([
|
||||
getStatisticIds(hass),
|
||||
getStatisticIds(hass, "mean"),
|
||||
getStatisticIds(hass, "sum"),
|
||||
]);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(3);
|
||||
expect(callWS).toHaveBeenNthCalledWith(1, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: undefined,
|
||||
});
|
||||
expect(callWS).toHaveBeenNthCalledWith(2, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: "mean",
|
||||
});
|
||||
expect(callWS).toHaveBeenNthCalledWith(3, {
|
||||
type: "recorder/list_statistic_ids",
|
||||
statistic_type: "sum",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not share requests across different callWS owners", async () => {
|
||||
const firstRequest = deferred<StatisticsMetaData[]>();
|
||||
const secondRequest = deferred<StatisticsMetaData[]>();
|
||||
|
||||
const firstCallWS = vi.fn().mockReturnValue(firstRequest.promise);
|
||||
const secondCallWS = vi.fn().mockReturnValue(secondRequest.promise);
|
||||
|
||||
const first = getStatisticIds(createHass(firstCallWS));
|
||||
const second = getStatisticIds(createHass(secondCallWS));
|
||||
|
||||
expect(firstCallWS).toHaveBeenCalledTimes(1);
|
||||
expect(secondCallWS).toHaveBeenCalledTimes(1);
|
||||
expect(second).not.toBe(first);
|
||||
|
||||
firstRequest.resolve([]);
|
||||
secondRequest.resolve([]);
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it("fetches again after the previous request settles", async () => {
|
||||
const callWS = vi.fn().mockResolvedValue([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await getStatisticIds(hass, "sum");
|
||||
await getStatisticIds(hass, "sum");
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries after a failed request", async () => {
|
||||
const callWS = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("failed"))
|
||||
.mockResolvedValueOnce([]);
|
||||
const hass = createHass(callWS);
|
||||
|
||||
await expect(getStatisticIds(hass)).rejects.toThrow("failed");
|
||||
await getStatisticIds(hass);
|
||||
|
||||
expect(callWS).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { extractFromTarget } from "../../src/data/target";
|
||||
|
||||
type CallWS = Parameters<typeof extractFromTarget>[0];
|
||||
|
||||
const callWS = () => vi.fn().mockResolvedValue({}) as unknown as CallWS;
|
||||
|
||||
describe("extractFromTarget in-flight sharing", () => {
|
||||
it("shares identical concurrent requests", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target),
|
||||
extractFromTarget(ws, target),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share different targets", async () => {
|
||||
const ws = callWS();
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, { area_id: ["bathroom"] }),
|
||||
extractFromTarget(ws, { area_id: ["kitchen"] }),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share different expandGroup values", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target, false),
|
||||
extractFromTarget(ws, target, true),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share different primaryEntitiesOnly values", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(ws, target, false, true),
|
||||
extractFromTarget(ws, target, false, false),
|
||||
]);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not share between different callWS owners", async () => {
|
||||
const first = callWS();
|
||||
const second = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await Promise.all([
|
||||
extractFromTarget(first, target),
|
||||
extractFromTarget(second, target),
|
||||
]);
|
||||
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
expect(second).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fetches again after the request settles", async () => {
|
||||
const ws = callWS();
|
||||
const target = { area_id: ["bathroom"] };
|
||||
|
||||
await extractFromTarget(ws, target);
|
||||
await extractFromTarget(ws, target);
|
||||
|
||||
expect(ws).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,7 @@ const commandResults: Record<string, unknown> = {
|
||||
},
|
||||
"lovelace/info": { resource_mode: "storage" },
|
||||
"lovelace/resources": [],
|
||||
"map_tiles/access_token": { token: "map-tiles-token" },
|
||||
"recorder/info": {
|
||||
migration_in_progress: false,
|
||||
migration_is_live: false,
|
||||
@@ -177,6 +178,13 @@ export async function setupOnboardingMocks(
|
||||
): Promise<OnboardingCalls> {
|
||||
const calls: OnboardingCalls = { tokenRequests: [] };
|
||||
|
||||
// The location step shows a map, which asks core's tile proxy for tiles core
|
||||
// is not running here. Answer them so the outcome does not depend on what the
|
||||
// dev server does with an unknown /api path.
|
||||
await page.route("**/api/map_tiles/**", (route) =>
|
||||
route.fulfill({ status: 404, body: "" })
|
||||
);
|
||||
|
||||
await page.route("**/api/onboarding**", async (route) => {
|
||||
const request = route.request();
|
||||
const pathname = new URL(request.url()).pathname;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { LitElement } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { navigate } from "../../src/common/navigate";
|
||||
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
|
||||
import { PreventUnsavedMixin } from "../../src/mixins/prevent-unsaved-mixin";
|
||||
|
||||
// navigate() closes open dialogs before touching history.
|
||||
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
|
||||
closeAllDialogs: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
interface TestState {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Mirrors the editors: DirtyStateProviderMixin wraps PreventUnsavedMixin.
|
||||
class TestPreventUnsaved extends DirtyStateProviderMixin<TestState>()(
|
||||
PreventUnsavedMixin(LitElement)
|
||||
) {
|
||||
public promptResponse = true;
|
||||
|
||||
public promptCalls = 0;
|
||||
|
||||
public initialize(value: TestState) {
|
||||
this._initDirtyTracking({ type: "shallow" }, value);
|
||||
}
|
||||
|
||||
public setValue(value: TestState) {
|
||||
this._updateDirtyState(value);
|
||||
}
|
||||
|
||||
public markClean() {
|
||||
this._markDirtyStateClean();
|
||||
}
|
||||
|
||||
protected async promptDiscardChanges(): Promise<boolean> {
|
||||
this.promptCalls++;
|
||||
return this.promptResponse;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("test-prevent-unsaved", TestPreventUnsaved);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"test-prevent-unsaved": TestPreventUnsaved;
|
||||
}
|
||||
}
|
||||
|
||||
const setEntry = (path: string) => {
|
||||
window.history.replaceState(null, "", path);
|
||||
};
|
||||
|
||||
const mountClean = async () => {
|
||||
const element = document.createElement("test-prevent-unsaved");
|
||||
document.body.append(element);
|
||||
element.initialize({ name: "Kitchen" });
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
const mountDirty = async () => {
|
||||
const element = await mountClean();
|
||||
element.setValue({ name: "Bedroom" });
|
||||
await element.updateComplete;
|
||||
return element;
|
||||
};
|
||||
|
||||
describe("PreventUnsavedMixin", () => {
|
||||
beforeEach(() => {
|
||||
setEntry("/config/automation/edit/1234");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.querySelectorAll("test-prevent-unsaved").forEach((element) => {
|
||||
element.remove();
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks navigation while dirty when the prompt is declined", async () => {
|
||||
const element = await mountDirty();
|
||||
element.promptResponse = false;
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(false);
|
||||
|
||||
expect(element.promptCalls).toBe(1);
|
||||
expect(window.location.pathname).toEqual("/config/automation/edit/1234");
|
||||
});
|
||||
|
||||
it("navigates while dirty when the prompt is confirmed", async () => {
|
||||
const element = await mountDirty();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(1);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("does not prompt while clean", async () => {
|
||||
const element = await mountClean();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("does not prompt after changes are marked clean, before the next render", async () => {
|
||||
const element = await mountDirty();
|
||||
element.markClean();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("stops guarding after being disconnected", async () => {
|
||||
const element = await mountDirty();
|
||||
element.promptResponse = false;
|
||||
element.remove();
|
||||
|
||||
expect(await navigate("/config/scene/dashboard")).toBe(true);
|
||||
|
||||
expect(element.promptCalls).toBe(0);
|
||||
expect(window.location.pathname).toEqual("/config/scene/dashboard");
|
||||
});
|
||||
|
||||
it("arms beforeunload only while dirty", async () => {
|
||||
const element = await mountDirty();
|
||||
|
||||
let event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
|
||||
element.setValue({ name: "Kitchen" });
|
||||
await element.updateComplete;
|
||||
|
||||
event = new Event("beforeunload", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5368,7 +5368,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@sveltejs/acorn-typescript@npm:^1.0.10":
|
||||
"@sveltejs/acorn-typescript@npm:^1.0.13":
|
||||
version: 1.0.13
|
||||
resolution: "@sveltejs/acorn-typescript@npm:1.0.13"
|
||||
peerDependencies:
|
||||
@@ -6787,7 +6787,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.17.0":
|
||||
"acorn@npm:^8.10.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.18.0":
|
||||
version: 8.18.0
|
||||
resolution: "acorn@npm:8.18.0"
|
||||
bin:
|
||||
@@ -10248,7 +10248,7 @@ __metadata:
|
||||
maplibre-gl: "npm:5.24.0"
|
||||
marked: "npm:18.0.10"
|
||||
memoize-one: "npm:6.0.0"
|
||||
minify-literals: "npm:2.1.0"
|
||||
minify-literals: "npm:2.2.0"
|
||||
node-vibrant: "npm:4.0.4"
|
||||
object-hash: "npm:3.0.0"
|
||||
pinst: "npm:3.0.0"
|
||||
@@ -10327,9 +10327,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"html-minifier-next@npm:^7.0.0":
|
||||
version: 7.5.3
|
||||
resolution: "html-minifier-next@npm:7.5.3"
|
||||
"html-minifier-next@npm:^8.1.0":
|
||||
version: 8.1.0
|
||||
resolution: "html-minifier-next@npm:8.1.0"
|
||||
dependencies:
|
||||
commander: "npm:^15.0.0"
|
||||
entities: "npm:^8.0.0"
|
||||
@@ -10344,7 +10344,7 @@ __metadata:
|
||||
bin:
|
||||
hmn: cli.js
|
||||
html-minifier-next: cli.js
|
||||
checksum: 10/fe4ad84f5c577c2de936c7aad5c0a7619c5e7ce3763f3c2e15f1ecc9629d89ebc578a38c0e44883cc0388253de262962610f7dbc3101f8d16e363c477a6a9b07
|
||||
checksum: 10/5badf1abd2c6fb9ada2f7dd4929be74f43155510298f7af323b0e39a2c20e2a33ed8de507f105bcea389118f2813e7776b559cf4b2b6397537c5871aa27f2125
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -11600,7 +11600,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"lightningcss@npm:1.33.0, lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0":
|
||||
"lightningcss@npm:1.33.0, lightningcss@npm:^1.33.0":
|
||||
version: 1.33.0
|
||||
resolution: "lightningcss@npm:1.33.0"
|
||||
dependencies:
|
||||
@@ -11854,6 +11854,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magic-string@npm:^1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "magic-string@npm:1.2.2"
|
||||
dependencies:
|
||||
"@jridgewell/sourcemap-codec": "npm:^1.5.5"
|
||||
checksum: 10/23514d9fb744d30689037e41440125b6c4bc3fb5b8e6d992798e59f93d7452276f223d062ba9b1ce78fbee5f9cc365c1e0ae3c6babddb74cb3aa9074adb57b03
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"magicast@npm:^0.5.2":
|
||||
version: 0.5.4
|
||||
resolution: "magicast@npm:0.5.4"
|
||||
@@ -12058,16 +12067,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minify-literals@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "minify-literals@npm:2.1.0"
|
||||
"minify-literals@npm:2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "minify-literals@npm:2.2.0"
|
||||
dependencies:
|
||||
"@sveltejs/acorn-typescript": "npm:^1.0.10"
|
||||
acorn: "npm:^8.17.0"
|
||||
html-minifier-next: "npm:^7.0.0"
|
||||
lightningcss: "npm:^1.32.0"
|
||||
magic-string: "npm:^0.30.21"
|
||||
checksum: 10/95bcd25afcf638c00bcd8981c2e6409be8c2feb4fd8f954bc9a547b2ade917810cef17bb075ad95e5ceaac6fffc8e625c161ab77b2363c36de53da6d139de872
|
||||
"@sveltejs/acorn-typescript": "npm:^1.0.13"
|
||||
acorn: "npm:^8.18.0"
|
||||
html-minifier-next: "npm:^8.1.0"
|
||||
lightningcss: "npm:^1.33.0"
|
||||
magic-string: "npm:^1.2.2"
|
||||
checksum: 10/73f17955820675f405b95810f3bf861a05baf426300e8ba46d174987ee45366c885ef1281c40842025529343a4ca71b6fd8294ce674bccbd58685e4a917b9448
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user