mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-25 17:51:39 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60c86899f3 | |||
| f8d870d6bb | |||
| 4d82b352a9 |
+1
-1
@@ -81,6 +81,7 @@
|
||||
"@tsparticles/engine": "4.2.1",
|
||||
"@tsparticles/preset-links": "4.2.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.0",
|
||||
@@ -97,7 +98,6 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.4.2",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import timezones from "google-timezones-json";
|
||||
import { timeZonesNames } from "@vvo/tzdb";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
|
||||
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
@@ -10,7 +10,7 @@ const RESOLVED_TIME_ZONE =
|
||||
RESOLVED_RAW &&
|
||||
(RESOLVED_RAW === "UTC" ||
|
||||
RESOLVED_RAW === "Etc/UTC" ||
|
||||
RESOLVED_RAW in timezones)
|
||||
timeZonesNames.includes(RESOLVED_RAW))
|
||||
? RESOLVED_RAW
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import timezones from "google-timezones-json";
|
||||
import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -13,38 +13,40 @@ const SEARCH_KEYS = [
|
||||
{ name: "secondary", weight: 8 },
|
||||
];
|
||||
|
||||
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
|
||||
// though both are valid IANA identifiers and common server defaults. Without
|
||||
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
|
||||
// @vvo/tzdb is missing the bare "UTC" zone, even though it is a valid IANA
|
||||
// identifier and a common server default. Add UTC back so a
|
||||
// "UTC" configuration can be selected.
|
||||
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
|
||||
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
|
||||
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
|
||||
{ id: "UTC", primary: "+00:00 UTC", secondary: "UTC" },
|
||||
];
|
||||
|
||||
// google-timezones-json also ships an invalid IANA identifier. Correct it so
|
||||
// the zone can be selected (the backend rejects the invalid id).
|
||||
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
|
||||
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
|
||||
};
|
||||
|
||||
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
|
||||
const options: PickerComboBoxItem[] = Object.entries(
|
||||
timezones as Record<string, string>
|
||||
).map(([key, value]) => {
|
||||
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
|
||||
return {
|
||||
id,
|
||||
primary: value,
|
||||
secondary: id,
|
||||
};
|
||||
});
|
||||
const options: PickerComboBoxItem[] = Array.from(
|
||||
new Map(
|
||||
getTimeZones({ includeUtc: true })
|
||||
.flatMap((timezone) => {
|
||||
const groupArray = Array.isArray(timezone.group)
|
||||
? timezone.group
|
||||
: [timezone.group];
|
||||
const filteredGroup = groupArray.filter((gName) =>
|
||||
timeZonesNames.includes(gName)
|
||||
);
|
||||
|
||||
return [timezone.name, ...filteredGroup].map((nameString) => ({
|
||||
id: nameString,
|
||||
primary: timezone.rawFormat,
|
||||
secondary: nameString,
|
||||
}));
|
||||
})
|
||||
.map((item) => [item.id, item])
|
||||
).values()
|
||||
);
|
||||
|
||||
for (const timezone of ADDITIONAL_TIMEZONES) {
|
||||
if (!options.some((option) => option.id === timezone.id)) {
|
||||
options.push(timezone);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface MediaPlayerItemId {
|
||||
media_content_type?: string | undefined;
|
||||
}
|
||||
|
||||
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
|
||||
const MANUAL_ITEM: MediaPlayerItem = {
|
||||
can_expand: true,
|
||||
can_play: false,
|
||||
can_search: false,
|
||||
@@ -83,6 +83,7 @@ const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
|
||||
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
|
||||
media_content_type: "",
|
||||
iconPath: mdiKeyboard,
|
||||
title: "Manual entry",
|
||||
};
|
||||
|
||||
@customElement("ha-media-player-browse")
|
||||
@@ -239,7 +240,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
currentId.media_content_id &&
|
||||
isManualMediaSourceContentId(currentId.media_content_id)
|
||||
) {
|
||||
this._currentItem = this._manualItem();
|
||||
this._currentItem = MANUAL_ITEM;
|
||||
fireEvent(this, "media-browsed", {
|
||||
ids: navigateIds,
|
||||
current: this._currentItem,
|
||||
@@ -800,21 +801,12 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
return prom.then((item) => {
|
||||
if (!mediaContentId && this.action === "pick") {
|
||||
item.children = item.children || [];
|
||||
item.children.push(this._manualItem());
|
||||
item.children.push(MANUAL_ITEM);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
private _manualItem(): MediaPlayerItem {
|
||||
return {
|
||||
...MANUAL_ITEM_BASE,
|
||||
title: this.hass.localize(
|
||||
"ui.components.selectors.selector.types.manual"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private _measureCard(): void {
|
||||
this.narrow = (this.dialog ? window.innerWidth : this.offsetWidth) < 450;
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ class DialogEditSidebar extends DirtyStateProviderMixin<SidebarState>()(
|
||||
value: panel.url_path,
|
||||
label:
|
||||
(getPanelTitle(this.hass, panel) || panel.url_path) +
|
||||
`${defaultPanel === panel.url_path ? " (default)" : ""}`,
|
||||
`${defaultPanel === panel.url_path ? ` (${this.hass.localize("ui.sidebar.default")})` : ""}`,
|
||||
icon: getPanelIcon(panel),
|
||||
iconPath: getPanelIconPath(panel),
|
||||
disableHiding: panel.url_path === defaultPanel,
|
||||
|
||||
@@ -41,9 +41,7 @@ export class DialogSupportPackage extends LitElement {
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
width="full"
|
||||
.headerTitle=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.download_support_package"
|
||||
)}
|
||||
header-title="Download support package"
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._supportPackage
|
||||
@@ -54,16 +52,13 @@ export class DialogSupportPackage extends LitElement {
|
||||
: html`
|
||||
<div class="progress-container">
|
||||
<ha-spinner></ha-spinner>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.support_package_generating_preview"
|
||||
)}...
|
||||
Generating preview...
|
||||
</div>
|
||||
`}
|
||||
<div slot="footer" class="footer">
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.support_package_privacy_warning"
|
||||
)}
|
||||
This file may contain personal data about your home. Avoid sharing
|
||||
them with unverified or untrusted parties.
|
||||
</ha-alert>
|
||||
<hr />
|
||||
<ha-dialog-footer>
|
||||
@@ -72,10 +67,10 @@ export class DialogSupportPackage extends LitElement {
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize("ui.common.close")}
|
||||
Close
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._download}>
|
||||
${this.hass.localize("ui.common.download")}
|
||||
Download
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</div>
|
||||
|
||||
@@ -109,10 +109,7 @@ export class SystemLogCard extends LitElement {
|
||||
`
|
||||
: html`
|
||||
<div class="header">
|
||||
<h1 class="card-header">
|
||||
${this.header ||
|
||||
this.hass.localize("ui.panel.config.logs.caption")}
|
||||
</h1>
|
||||
<h1 class="card-header">${this.header || "Logs"}</h1>
|
||||
<div class="header-buttons">
|
||||
<ha-icon-button
|
||||
.path=${mdiDownload}
|
||||
|
||||
@@ -197,9 +197,7 @@ export class HaScriptTrace extends LitElement {
|
||||
</div>
|
||||
|
||||
${this._traces === undefined
|
||||
? html`<div class="container">
|
||||
${this.hass.localize("ui.panel.config.script.trace.loading")}…
|
||||
</div>`
|
||||
? html`<div class="container">Loading…</div>`
|
||||
: this._traces.length === 0
|
||||
? html`<div class="container">
|
||||
${this.hass!.localize(
|
||||
|
||||
@@ -58,9 +58,7 @@ export class HuiErrorBadge extends LitElement implements LovelaceBadge {
|
||||
class="error"
|
||||
@click=${this._viewDetail}
|
||||
type="button"
|
||||
.label=${this.hass?.localize(
|
||||
"ui.panel.lovelace.editor.error_section.title"
|
||||
) ?? ""}
|
||||
label="Error"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
|
||||
<div class="content">${this._config.error}</div>
|
||||
|
||||
@@ -135,11 +135,7 @@ class HuiHistoryChartCardFeature
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">
|
||||
${this.hass!.localize(
|
||||
"ui.components.history_charts.no_history_found"
|
||||
)}
|
||||
</div>
|
||||
<div class="info">No state history found.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -122,11 +122,7 @@ export class HuiGraphHeaderFooter
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">
|
||||
${this.hass!.localize(
|
||||
"ui.components.history_charts.no_history_found"
|
||||
)}
|
||||
</div>
|
||||
<div class="info">No state history found.</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -47,9 +47,7 @@ export class HuiErrorSection
|
||||
|
||||
// Todo improve
|
||||
return html`
|
||||
<h1>
|
||||
${this.hass!.localize("ui.panel.lovelace.editor.error_section.title")}
|
||||
</h1>
|
||||
<h1>Error</h1>
|
||||
<p>${this._config.error}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -331,6 +331,28 @@ export function computeBarycenter(
|
||||
return totalWeight > 0 ? weightedSum / totalWeight : fallback;
|
||||
}
|
||||
|
||||
// Index of the single highest-weight neighbor present in the reference
|
||||
// section (ties on weight broken by the earliest edge). Used only as a
|
||||
// barycenter tie-break so a node stays beside its dominant neighbor's group
|
||||
// instead of falling back to a stale seed index. Falls back to the node's own
|
||||
// index when it has no resolvable neighbor, matching computeBarycenter.
|
||||
export function dominantNeighborIndex(
|
||||
neighbors: WeightedNeighbor[],
|
||||
referenceIdIndexMap: Map<string, number>,
|
||||
fallback: number
|
||||
): number {
|
||||
let bestIdx = fallback;
|
||||
let bestWeight = -Infinity;
|
||||
neighbors.forEach(({ id, weight }) => {
|
||||
const idx = referenceIdIndexMap.get(id);
|
||||
if (idx !== undefined && weight > bestWeight) {
|
||||
bestWeight = weight;
|
||||
bestIdx = idx;
|
||||
}
|
||||
});
|
||||
return bestIdx;
|
||||
}
|
||||
|
||||
function buildIdIndexMap(section: Node[]): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
section.forEach((node, index) => map.set(node.id, index));
|
||||
@@ -342,12 +364,20 @@ function sortSectionByBarycenter(
|
||||
referenceMap: Map<string, number>,
|
||||
getNeighbors: (node: Node) => WeightedNeighbor[]
|
||||
): { sorted: Node[]; changed: boolean } {
|
||||
const decorated = section.map((node, index) => ({
|
||||
node,
|
||||
index,
|
||||
barycenter: computeBarycenter(getNeighbors(node), referenceMap, index),
|
||||
}));
|
||||
decorated.sort((a, b) => a.barycenter - b.barycenter || a.index - b.index);
|
||||
const decorated = section.map((node, index) => {
|
||||
const neighbors = getNeighbors(node);
|
||||
return {
|
||||
node,
|
||||
index,
|
||||
barycenter: computeBarycenter(neighbors, referenceMap, index),
|
||||
// Tie-break that keeps a node next to its dominant neighbor's group.
|
||||
anchor: dominantNeighborIndex(neighbors, referenceMap, index),
|
||||
};
|
||||
});
|
||||
decorated.sort(
|
||||
(a, b) =>
|
||||
a.barycenter - b.barycenter || a.anchor - b.anchor || a.index - b.index
|
||||
);
|
||||
const sorted = decorated.map((d) => d.node);
|
||||
const changed = sorted.some((n, idx) => n !== section[idx]);
|
||||
return { sorted, changed };
|
||||
@@ -452,6 +482,55 @@ function crossingsAdjacentTo(
|
||||
return total;
|
||||
}
|
||||
|
||||
function countAllCrossings(
|
||||
sections: Node[][],
|
||||
sectionMaps: Map<string, number>[],
|
||||
depths: number[],
|
||||
edges: GraphEdge[]
|
||||
): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < sections.length - 1; i++) {
|
||||
total += countCrossings(
|
||||
getEdgeSegmentsBetween(
|
||||
depths[i],
|
||||
depths[i + 1],
|
||||
depths,
|
||||
edges,
|
||||
sectionMaps[i],
|
||||
sectionMaps[i + 1]
|
||||
)
|
||||
);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
// A section is "multi-parent" when at least one of its real nodes draws from
|
||||
// two or more distinct parents in the previous section. In the energy/power/
|
||||
// water cards only the source/home layers do this (grid/solar/battery feed
|
||||
// home + battery_in + grid_return); every later section (floors, areas,
|
||||
// devices) is a pure single-parent tree level. Pass-throughs are always
|
||||
// single-parent (one source/target chain) and never make a section multi-parent.
|
||||
function sectionHasMultipleParents(
|
||||
section: Node[],
|
||||
prevDepth: number,
|
||||
depths: number[]
|
||||
): boolean {
|
||||
return section.some((node) => {
|
||||
if (isPassThroughNode(node)) {
|
||||
return false;
|
||||
}
|
||||
const parentIds = new Set(
|
||||
getNeighborIds(node, "source", prevDepth, depths).map((n) => n.id)
|
||||
);
|
||||
return parentIds.size > 1;
|
||||
});
|
||||
}
|
||||
|
||||
// The head barycenter sweep (STEP 1) only touches the multi-parent head
|
||||
// sections (the single-parent tree below is placed deterministically in
|
||||
// STEP 2), so a single forward+backward pass converges in practice and the loop
|
||||
// early-exits on the first no-change sweep. The cap is just headroom for unusual
|
||||
// topologies with several interacting head sections.
|
||||
const MAX_SORT_ITERATIONS = 4;
|
||||
|
||||
export function sortNodesInSections(
|
||||
@@ -460,13 +539,30 @@ export function sortNodesInSections(
|
||||
edges: GraphEdge[]
|
||||
): Record<number, Node[]> {
|
||||
const sections: Node[][] = depths.map((d) => [...(nodesPerSection[d] || [])]);
|
||||
// Id→index lookup per section, kept in sync with sections. Rebuilt only when
|
||||
// a section's order actually changes (inside tryReplace).
|
||||
// Id→index lookup per section, kept in sync with sections.
|
||||
const sectionMaps: Map<string, number>[] = sections.map(buildIdIndexMap);
|
||||
|
||||
// Replace a section with a candidate ordering only when crossings strictly
|
||||
// drop on either side. This keeps user-intended ordering intact when
|
||||
// barycenter would shuffle nodes without improving the layout.
|
||||
// Classify each section past the root. Multi-parent sections (the
|
||||
// intentionally-ordered source/home layers) are minimized by barycenter in
|
||||
// PASS 2; the rest are single-parent tree levels placed deterministically in
|
||||
// PASS 1. Classification reads only the graph, so it is stable across passes.
|
||||
const multiParent = depths.map(
|
||||
(_d, i) =>
|
||||
i >= 1 && sectionHasMultipleParents(sections[i], depths[i - 1], depths)
|
||||
);
|
||||
|
||||
// Best (fewest-crossing) head ordering seen so far, seeded from the original
|
||||
// input so the head is provably never worse than the seed.
|
||||
const snapshot = (): Node[][] => sections.map((s) => s.slice());
|
||||
let liveCrossings = countAllCrossings(sections, sectionMaps, depths, edges);
|
||||
let bestCrossings = liveCrossings;
|
||||
let bestSections = snapshot();
|
||||
|
||||
// Replace a multi-parent section with a candidate ordering when crossings on
|
||||
// its adjacent boundaries do not increase. Accepting equal-crossing
|
||||
// ("plateau") moves lets the sweep escape local optima; the best snapshot
|
||||
// (captured only on a strict global decrease) is what seeds the head order,
|
||||
// so the result is deterministic and never worse than the seed.
|
||||
const tryReplace = (i: number, candidate: Node[]): boolean => {
|
||||
const before = crossingsAdjacentTo(i, sections, sectionMaps, depths, edges);
|
||||
const sectionSnapshot = sections[i];
|
||||
@@ -474,7 +570,12 @@ export function sortNodesInSections(
|
||||
sections[i] = candidate;
|
||||
sectionMaps[i] = buildIdIndexMap(candidate);
|
||||
const after = crossingsAdjacentTo(i, sections, sectionMaps, depths, edges);
|
||||
if (after < before) {
|
||||
if (after <= before) {
|
||||
liveCrossings += after - before;
|
||||
if (liveCrossings < bestCrossings) {
|
||||
bestCrossings = liveCrossings;
|
||||
bestSections = snapshot();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
sections[i] = sectionSnapshot;
|
||||
@@ -482,39 +583,97 @@ export function sortNodesInSections(
|
||||
return false;
|
||||
};
|
||||
|
||||
for (let iter = 0; iter < MAX_SORT_ITERATIONS; iter++) {
|
||||
let changed = false;
|
||||
// STEP 1 — settle the multi-parent head sections (sources → home/battery_in/
|
||||
// grid_return) by barycenter. These layers have nodes with several parents and
|
||||
// so no single parent position to inherit; we minimize their crossings by the
|
||||
// weighted average of neighbour positions, iterating forward then backward
|
||||
// until the order is stable. The backward sweep is restricted to multi-parent
|
||||
// neighbours, so the head order never depends on its single-parent children —
|
||||
// that is what keeps the whole result idempotent, because STEP 2 re-derives
|
||||
// those children purely from the settled head. The root section (index 0) is
|
||||
// never reordered.
|
||||
if (multiParent.some(Boolean)) {
|
||||
for (let iter = 0; iter < MAX_SORT_ITERATIONS; iter++) {
|
||||
let changed = false;
|
||||
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
const prevDepth = depths[i - 1];
|
||||
const result = sortSectionByBarycenter(
|
||||
sections[i],
|
||||
sectionMaps[i - 1],
|
||||
(node) => getNeighborIds(node, "source", prevDepth, depths)
|
||||
);
|
||||
if (result.changed && tryReplace(i, result.sorted)) {
|
||||
changed = true;
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
if (!multiParent[i]) {
|
||||
continue;
|
||||
}
|
||||
const prevDepth = depths[i - 1];
|
||||
const result = sortSectionByBarycenter(
|
||||
sections[i],
|
||||
sectionMaps[i - 1],
|
||||
(node) => getNeighborIds(node, "source", prevDepth, depths)
|
||||
);
|
||||
if (result.changed && tryReplace(i, result.sorted)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = sections.length - 2; i >= 0; i--) {
|
||||
const nextDepth = depths[i + 1];
|
||||
const result = sortSectionByBarycenter(
|
||||
sections[i],
|
||||
sectionMaps[i + 1],
|
||||
(node) => getNeighborIds(node, "target", nextDepth, depths)
|
||||
);
|
||||
if (result.changed && tryReplace(i, result.sorted)) {
|
||||
changed = true;
|
||||
for (let i = sections.length - 2; i >= 1; i--) {
|
||||
if (!multiParent[i] || !multiParent[i + 1]) {
|
||||
continue;
|
||||
}
|
||||
const nextDepth = depths[i + 1];
|
||||
const result = sortSectionByBarycenter(
|
||||
sections[i],
|
||||
sectionMaps[i + 1],
|
||||
(node) => getNeighborIds(node, "target", nextDepth, depths)
|
||||
);
|
||||
if (result.changed && tryReplace(i, result.sorted)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) break;
|
||||
if (!changed || bestCrossings === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
// STEP 2 — deterministic hierarchy placement. Starting from the best head
|
||||
// ordering, walk left to right and order every single-parent section by the
|
||||
// position of each node's single parent in the already-final previous section
|
||||
// (sortSectionByBarycenter with one parent reduces to a stable sort by that
|
||||
// parent's index). This is the classic layered-tree drawing: each parent's
|
||||
// children stay contiguous, every single-parent boundary is crossing-free,
|
||||
// pass-throughs travel along their chain, and the user-configured floor/area
|
||||
// order is preserved because same-parent siblings keep their seed index.
|
||||
// It runs unconditionally — grouping a parent's children under it must win
|
||||
// even on a crossing-neutral plateau (the #52852 fix) — and because it is a
|
||||
// pure function of the settled head, re-running yields the identical layout.
|
||||
const finalSections = bestSections.map((s) => s.slice());
|
||||
const finalMaps = finalSections.map(buildIdIndexMap);
|
||||
for (let i = 1; i < finalSections.length; i++) {
|
||||
if (multiParent[i]) {
|
||||
continue;
|
||||
}
|
||||
const prevDepth = depths[i - 1];
|
||||
const { sorted } = sortSectionByBarycenter(
|
||||
finalSections[i],
|
||||
finalMaps[i - 1],
|
||||
(node) => getNeighborIds(node, "source", prevDepth, depths)
|
||||
);
|
||||
finalSections[i] = sorted;
|
||||
finalMaps[i] = buildIdIndexMap(sorted);
|
||||
}
|
||||
|
||||
// Hierarchy placement makes every single-parent boundary crossing-free, so on
|
||||
// the energy/water cards (multi-parent only at the head) it can only lower the
|
||||
// total. Guard the general graph: if regrouping somehow raised crossings (only
|
||||
// possible for a multi-parent section sitting *below* single-parent ones,
|
||||
// which the cards never produce), fall back to the gated best head so the
|
||||
// never-worse-than-seed guarantee always holds. Ties keep the grouped layout.
|
||||
const finalCrossings = countAllCrossings(
|
||||
finalSections,
|
||||
finalMaps,
|
||||
depths,
|
||||
edges
|
||||
);
|
||||
const chosen = finalCrossings <= bestCrossings ? finalSections : bestSections;
|
||||
|
||||
const sortedSections: Record<number, Node[]> = {};
|
||||
depths.forEach((depth, i) => {
|
||||
sortedSections[depth] = sections[i];
|
||||
sortedSections[depth] = chosen[i];
|
||||
});
|
||||
return sortedSections;
|
||||
}
|
||||
|
||||
@@ -2503,6 +2503,7 @@
|
||||
"edit_sidebar": "Edit sidebar",
|
||||
"edit_subtitle": "Synced on all devices",
|
||||
"migrate_to_user_data": "This will change the sidebar on all the devices you are logged in to. To create a sidebar per device, you should use a different user for that device.",
|
||||
"default": "default",
|
||||
"reset_to_defaults": "Reset to defaults",
|
||||
"reset_confirmation": "Are you sure you want to reset the sidebar to its default configuration? This will restore the original order and visibility of all panels."
|
||||
},
|
||||
@@ -6194,8 +6195,7 @@
|
||||
"paste_invalid_config": "Pasted script is not editable in the visual editor"
|
||||
},
|
||||
"trace": {
|
||||
"edit_script": "Edit script",
|
||||
"loading": "Loading"
|
||||
"edit_script": "Edit script"
|
||||
}
|
||||
},
|
||||
"scene": {
|
||||
@@ -6343,8 +6343,6 @@
|
||||
},
|
||||
"account": {
|
||||
"download_support_package": "Download support package",
|
||||
"support_package_generating_preview": "Generating preview",
|
||||
"support_package_privacy_warning": "This file may contain personal data about your home. Avoid sharing them with unverified or untrusted parties.",
|
||||
"reset_cloud_data": "Reset cloud data",
|
||||
"reset_data_confirm_title": "Reset cloud data?",
|
||||
"reset_data_confirm_text": "This will reset all your cloud settings. This includes your remote connection, Google Assistant, and Amazon Alexa integrations. This action cannot be undone.",
|
||||
@@ -9221,9 +9219,6 @@
|
||||
"title": "Delete section",
|
||||
"text": "This section and all its cards will be deleted."
|
||||
},
|
||||
"error_section": {
|
||||
"title": "Error"
|
||||
},
|
||||
"edit_section": {
|
||||
"header": "Edit section",
|
||||
"tab_visibility": "[%key:ui::panel::lovelace::editor::edit_view::tab_visibility%]",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
getPassThroughSections,
|
||||
createPassThroughNode,
|
||||
computeBarycenter,
|
||||
dominantNeighborIndex,
|
||||
sortNodesInSections,
|
||||
} from "../../../../../src/resources/echarts/components/sankey/sankey-layout";
|
||||
|
||||
@@ -756,5 +757,470 @@ describe("Sankey Layout Functions", () => {
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
|
||||
it("untangles a plateau-trapped subtree to remove an avoidable crossing (#52852)", () => {
|
||||
// Realistic consumption tree: home → floors → areas → devices, plus two
|
||||
// devices that attach higher up — one on a floor with no area
|
||||
// (dev_floor_outside) and one straight on home (dev_home) — which the
|
||||
// engine threads through with pass-throughs. The seed splits
|
||||
// floor_outside's subtree: its pass-through child sits *after*
|
||||
// floor_foundation's area. Pulling it back trades a crossing from the
|
||||
// (1,2) boundary to the (2,3) boundary — a net-zero "plateau" the old
|
||||
// strict gate refused, leaving the crossing. The plateau-escape must now
|
||||
// take that step and let the device section follow, reaching 0 crossings.
|
||||
const e = {
|
||||
homeFo: { source: "home", target: "floor_outside", value: 1 },
|
||||
homeFf: { source: "home", target: "floor_foundation", value: 1 },
|
||||
foHvac: { source: "floor_outside", target: "area_hvac", value: 1 },
|
||||
ffParking: {
|
||||
source: "floor_foundation",
|
||||
target: "area_parking",
|
||||
value: 1,
|
||||
},
|
||||
hvacDev: { source: "area_hvac", target: "dev_hvac", value: 1 },
|
||||
parkingDev: { source: "area_parking", target: "dev_parking", value: 1 },
|
||||
foDev: {
|
||||
source: "floor_outside",
|
||||
target: "dev_floor_outside",
|
||||
value: 1,
|
||||
},
|
||||
homeDev: { source: "home", target: "dev_home", value: 1 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
home: {
|
||||
id: "home",
|
||||
depth: 0,
|
||||
value: 4,
|
||||
inEdges: [],
|
||||
outEdges: [e.homeFo, e.homeFf, e.homeDev],
|
||||
},
|
||||
floor_outside: {
|
||||
id: "floor_outside",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.homeFo],
|
||||
outEdges: [e.foHvac, e.foDev],
|
||||
},
|
||||
floor_foundation: {
|
||||
id: "floor_foundation",
|
||||
depth: 1,
|
||||
value: 1,
|
||||
inEdges: [e.homeFf],
|
||||
outEdges: [e.ffParking],
|
||||
},
|
||||
area_hvac: {
|
||||
id: "area_hvac",
|
||||
depth: 2,
|
||||
value: 1,
|
||||
inEdges: [e.foHvac],
|
||||
outEdges: [e.hvacDev],
|
||||
},
|
||||
area_parking: {
|
||||
id: "area_parking",
|
||||
depth: 2,
|
||||
value: 1,
|
||||
inEdges: [e.ffParking],
|
||||
outEdges: [e.parkingDev],
|
||||
},
|
||||
dev_hvac: {
|
||||
id: "dev_hvac",
|
||||
depth: 3,
|
||||
value: 1,
|
||||
inEdges: [e.hvacDev],
|
||||
outEdges: [],
|
||||
},
|
||||
dev_parking: {
|
||||
id: "dev_parking",
|
||||
depth: 3,
|
||||
value: 1,
|
||||
inEdges: [e.parkingDev],
|
||||
outEdges: [],
|
||||
},
|
||||
dev_floor_outside: {
|
||||
id: "dev_floor_outside",
|
||||
depth: 3,
|
||||
value: 1,
|
||||
inEdges: [e.foDev],
|
||||
outEdges: [],
|
||||
},
|
||||
dev_home: {
|
||||
id: "dev_home",
|
||||
depth: 3,
|
||||
value: 1,
|
||||
inEdges: [e.homeDev],
|
||||
outEdges: [],
|
||||
},
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const ptHome1 = createPassThroughNode("home", "dev_home", 1, 1);
|
||||
const ptFo2 = createPassThroughNode(
|
||||
"floor_outside",
|
||||
"dev_floor_outside",
|
||||
2,
|
||||
1
|
||||
);
|
||||
const ptHome2 = createPassThroughNode("home", "dev_home", 2, 1);
|
||||
|
||||
// Seed order from ha-sankey-chart: pass-throughs appended after the real
|
||||
// children, so floor_outside's subtree is broken across the section.
|
||||
const input = {
|
||||
0: [graph.home],
|
||||
1: [graph.floor_outside, graph.floor_foundation, ptHome1],
|
||||
2: [graph.area_hvac, graph.area_parking, ptFo2, ptHome2],
|
||||
3: [
|
||||
graph.dev_hvac,
|
||||
graph.dev_parking,
|
||||
graph.dev_floor_outside,
|
||||
graph.dev_home,
|
||||
],
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2, 3], edges);
|
||||
|
||||
// floor_outside's children (area_hvac and its pass-through) are now
|
||||
// contiguous, ahead of floor_foundation's; the layout is crossing-free.
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["home"],
|
||||
1: ["floor_outside", "floor_foundation", "home-dev_home-1"],
|
||||
2: [
|
||||
"area_hvac",
|
||||
"floor_outside-dev_floor_outside-2",
|
||||
"area_parking",
|
||||
"home-dev_home-2",
|
||||
],
|
||||
3: ["dev_hvac", "dev_floor_outside", "dev_parking", "dev_home"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
|
||||
// Re-running on the result must not drift: plateau churn is discarded and
|
||||
// the best snapshot is returned, so the order is stable (idempotent).
|
||||
const again = sortNodesInSections(result, [0, 1, 2, 3], edges);
|
||||
expect(sectionIds(again)).toEqual(sectionIds(result));
|
||||
});
|
||||
|
||||
it("groups single-parent siblings under their parent and keeps configured sibling order", () => {
|
||||
// Two floors, two areas each, fed to the engine interleaved (not grouped
|
||||
// by floor). The deterministic hierarchy pass must regroup areas under
|
||||
// their floor, and within a floor preserve the configured (seed) order:
|
||||
// a1 before a2, b1 before b2.
|
||||
const e = {
|
||||
hFa: { source: "home", target: "floor_a", value: 2 },
|
||||
hFb: { source: "home", target: "floor_b", value: 2 },
|
||||
faA1: { source: "floor_a", target: "a1", value: 1 },
|
||||
faA2: { source: "floor_a", target: "a2", value: 1 },
|
||||
fbB1: { source: "floor_b", target: "b1", value: 1 },
|
||||
fbB2: { source: "floor_b", target: "b2", value: 1 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
home: {
|
||||
id: "home",
|
||||
depth: 0,
|
||||
value: 4,
|
||||
inEdges: [],
|
||||
outEdges: [e.hFa, e.hFb],
|
||||
},
|
||||
floor_a: {
|
||||
id: "floor_a",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.hFa],
|
||||
outEdges: [e.faA1, e.faA2],
|
||||
},
|
||||
floor_b: {
|
||||
id: "floor_b",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.hFb],
|
||||
outEdges: [e.fbB1, e.fbB2],
|
||||
},
|
||||
a1: { id: "a1", depth: 2, value: 1, inEdges: [e.faA1], outEdges: [] },
|
||||
a2: { id: "a2", depth: 2, value: 1, inEdges: [e.faA2], outEdges: [] },
|
||||
b1: { id: "b1", depth: 2, value: 1, inEdges: [e.fbB1], outEdges: [] },
|
||||
b2: { id: "b2", depth: 2, value: 1, inEdges: [e.fbB2], outEdges: [] },
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = {
|
||||
0: [graph.home],
|
||||
1: [graph.floor_a, graph.floor_b],
|
||||
2: [graph.a1, graph.b1, graph.a2, graph.b2], // interleaved
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2], edges);
|
||||
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["home"],
|
||||
1: ["floor_a", "floor_b"],
|
||||
2: ["a1", "a2", "b1", "b2"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
|
||||
it("orders a single-parent section by parent position, ignoring flow magnitude", () => {
|
||||
// childB carries a far larger flow than childA, but a single-parent
|
||||
// section is ordered by parent position (hierarchy), never by value.
|
||||
const edgeACa = { source: "A", target: "childA", value: 1 };
|
||||
const edgeBCb = { source: "B", target: "childB", value: 100 };
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
A: { id: "A", depth: 0, value: 1, inEdges: [], outEdges: [edgeACa] },
|
||||
B: { id: "B", depth: 0, value: 100, inEdges: [], outEdges: [edgeBCb] },
|
||||
childA: {
|
||||
id: "childA",
|
||||
depth: 1,
|
||||
value: 1,
|
||||
inEdges: [edgeACa],
|
||||
outEdges: [],
|
||||
},
|
||||
childB: {
|
||||
id: "childB",
|
||||
depth: 1,
|
||||
value: 100,
|
||||
inEdges: [edgeBCb],
|
||||
outEdges: [],
|
||||
},
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = { 0: [graph.A, graph.B], 1: [graph.childB, graph.childA] };
|
||||
const result = sortNodesInSections(input, [0, 1], edges);
|
||||
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["A", "B"],
|
||||
1: ["childA", "childB"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
|
||||
it("keeps the single-parent tree hierarchical below a multi-parent source layer", () => {
|
||||
// grid + solar feed home (a genuine multi-parent section that stays under
|
||||
// the barycenter sweep); the floor/area tree below is single-parent and
|
||||
// must regroup by parent regardless of the multi-parent head.
|
||||
const e = {
|
||||
gH: { source: "grid", target: "home", value: 2 },
|
||||
sH: { source: "solar", target: "home", value: 2 },
|
||||
hFa: { source: "home", target: "floor_a", value: 2 },
|
||||
hFb: { source: "home", target: "floor_b", value: 2 },
|
||||
faA: { source: "floor_a", target: "area_a", value: 2 },
|
||||
fbB: { source: "floor_b", target: "area_b", value: 2 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
grid: { id: "grid", depth: 0, value: 2, inEdges: [], outEdges: [e.gH] },
|
||||
solar: {
|
||||
id: "solar",
|
||||
depth: 0,
|
||||
value: 2,
|
||||
inEdges: [],
|
||||
outEdges: [e.sH],
|
||||
},
|
||||
home: {
|
||||
id: "home",
|
||||
depth: 1,
|
||||
value: 4,
|
||||
inEdges: [e.gH, e.sH],
|
||||
outEdges: [e.hFa, e.hFb],
|
||||
},
|
||||
floor_a: {
|
||||
id: "floor_a",
|
||||
depth: 2,
|
||||
value: 2,
|
||||
inEdges: [e.hFa],
|
||||
outEdges: [e.faA],
|
||||
},
|
||||
floor_b: {
|
||||
id: "floor_b",
|
||||
depth: 2,
|
||||
value: 2,
|
||||
inEdges: [e.hFb],
|
||||
outEdges: [e.fbB],
|
||||
},
|
||||
area_a: {
|
||||
id: "area_a",
|
||||
depth: 3,
|
||||
value: 2,
|
||||
inEdges: [e.faA],
|
||||
outEdges: [],
|
||||
},
|
||||
area_b: {
|
||||
id: "area_b",
|
||||
depth: 3,
|
||||
value: 2,
|
||||
inEdges: [e.fbB],
|
||||
outEdges: [],
|
||||
},
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = {
|
||||
0: [graph.grid, graph.solar],
|
||||
1: [graph.home],
|
||||
2: [graph.floor_a, graph.floor_b],
|
||||
3: [graph.area_b, graph.area_a], // reversed; must regroup under floors
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2, 3], edges);
|
||||
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["grid", "solar"],
|
||||
1: ["home"],
|
||||
2: ["floor_a", "floor_b"],
|
||||
3: ["area_a", "area_b"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
|
||||
it("regroups single-parent children after a multi-parent head is reordered (idempotent)", () => {
|
||||
// A multi-parent head section [H0,H1,H2] (only H1 draws from two sources)
|
||||
// gets reordered by the barycenter sweep. Its single-parent children must
|
||||
// then be regrouped under the *settled* head — H1's children before H2's
|
||||
// child — and the result must be idempotent. Before the head/tree passes
|
||||
// were ordered correctly the children stayed grouped against the head's
|
||||
// SEED order, which both mis-grouped them and broke f(f(x)) === f(x).
|
||||
const e = {
|
||||
s0h0: { source: "S0", target: "H0", value: 6 },
|
||||
s0h1: { source: "S0", target: "H1", value: 7 },
|
||||
s1h1: { source: "S1", target: "H1", value: 5 },
|
||||
s2h2: { source: "S2", target: "H2", value: 6 },
|
||||
h1d0: { source: "H1", target: "D0", value: 7 },
|
||||
h1d2: { source: "H1", target: "D2", value: 9 },
|
||||
h2d1: { source: "H2", target: "D1", value: 7 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
S0: {
|
||||
id: "S0",
|
||||
depth: 0,
|
||||
value: 13,
|
||||
inEdges: [],
|
||||
outEdges: [e.s0h0, e.s0h1],
|
||||
},
|
||||
S1: { id: "S1", depth: 0, value: 5, inEdges: [], outEdges: [e.s1h1] },
|
||||
S2: { id: "S2", depth: 0, value: 6, inEdges: [], outEdges: [e.s2h2] },
|
||||
H0: { id: "H0", depth: 1, value: 6, inEdges: [e.s0h0], outEdges: [] },
|
||||
H1: {
|
||||
id: "H1",
|
||||
depth: 1,
|
||||
value: 12,
|
||||
inEdges: [e.s1h1, e.s0h1],
|
||||
outEdges: [e.h1d0, e.h1d2],
|
||||
},
|
||||
H2: {
|
||||
id: "H2",
|
||||
depth: 1,
|
||||
value: 6,
|
||||
inEdges: [e.s2h2],
|
||||
outEdges: [e.h2d1],
|
||||
},
|
||||
D0: { id: "D0", depth: 2, value: 7, inEdges: [e.h1d0], outEdges: [] },
|
||||
D1: { id: "D1", depth: 2, value: 7, inEdges: [e.h2d1], outEdges: [] },
|
||||
D2: { id: "D2", depth: 2, value: 9, inEdges: [e.h1d2], outEdges: [] },
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = {
|
||||
0: [graph.S0, graph.S1, graph.S2],
|
||||
1: [graph.H2, graph.H1, graph.H0],
|
||||
2: [graph.D1, graph.D2, graph.D0],
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2], edges);
|
||||
|
||||
// Head settles to barycenter order [H0,H1,H2]; children regroup under it:
|
||||
// H1's children (D2,D0) precede H2's child (D1).
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["S0", "S1", "S2"],
|
||||
1: ["H0", "H1", "H2"],
|
||||
2: ["D2", "D0", "D1"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
|
||||
// Idempotent: re-feeding the output yields the identical order.
|
||||
const again = sortNodesInSections(result, [0, 1, 2], edges);
|
||||
expect(sectionIds(again)).toEqual(sectionIds(result));
|
||||
});
|
||||
|
||||
it("lets single-parent children follow their reordered multi-parent parent to reach 0 crossings", () => {
|
||||
// root [a,b,c,d]; section 1 [m1,m2] is multi-parent with a clean split
|
||||
// (c,d -> m1 ; a,b -> m2); section 2 [x<-m1, y<-m2] single-parent. The
|
||||
// optimum needs BOTH the parent order swapped (m2,m1) AND the children to
|
||||
// follow (y,x) — placing the tree after the head settles reaches it.
|
||||
const e = {
|
||||
am2: { source: "a", target: "m2", value: 1 },
|
||||
bm2: { source: "b", target: "m2", value: 1 },
|
||||
cm1: { source: "c", target: "m1", value: 1 },
|
||||
dm1: { source: "d", target: "m1", value: 1 },
|
||||
m1x: { source: "m1", target: "x", value: 2 },
|
||||
m2y: { source: "m2", target: "y", value: 2 },
|
||||
};
|
||||
const testNodes: Record<string, TestNode> = {
|
||||
a: { id: "a", depth: 0, value: 1, inEdges: [], outEdges: [e.am2] },
|
||||
b: { id: "b", depth: 0, value: 1, inEdges: [], outEdges: [e.bm2] },
|
||||
c: { id: "c", depth: 0, value: 1, inEdges: [], outEdges: [e.cm1] },
|
||||
d: { id: "d", depth: 0, value: 1, inEdges: [], outEdges: [e.dm1] },
|
||||
m1: {
|
||||
id: "m1",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.cm1, e.dm1],
|
||||
outEdges: [e.m1x],
|
||||
},
|
||||
m2: {
|
||||
id: "m2",
|
||||
depth: 1,
|
||||
value: 2,
|
||||
inEdges: [e.am2, e.bm2],
|
||||
outEdges: [e.m2y],
|
||||
},
|
||||
x: { id: "x", depth: 2, value: 2, inEdges: [e.m1x], outEdges: [] },
|
||||
y: { id: "y", depth: 2, value: 2, inEdges: [e.m2y], outEdges: [] },
|
||||
};
|
||||
const { nodes: graph, edges } = buildGraph(testNodes);
|
||||
const input = {
|
||||
0: [graph.a, graph.b, graph.c, graph.d],
|
||||
1: [graph.m1, graph.m2],
|
||||
2: [graph.x, graph.y],
|
||||
};
|
||||
const result = sortNodesInSections(input, [0, 1, 2], edges);
|
||||
|
||||
expect(sectionIds(result)).toEqual({
|
||||
0: ["a", "b", "c", "d"],
|
||||
1: ["m2", "m1"],
|
||||
2: ["y", "x"],
|
||||
});
|
||||
expectIdentityPreserved(result, input);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dominantNeighborIndex", () => {
|
||||
it("returns the index of the single heaviest neighbor", () => {
|
||||
const map = new Map([
|
||||
["light", 0],
|
||||
["heavy", 3],
|
||||
]);
|
||||
const result = dominantNeighborIndex(
|
||||
[
|
||||
{ id: "light", weight: 1 },
|
||||
{ id: "heavy", weight: 5 },
|
||||
],
|
||||
map,
|
||||
9
|
||||
);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it("breaks weight ties by the earliest edge", () => {
|
||||
const map = new Map([
|
||||
["a", 2],
|
||||
["b", 4],
|
||||
]);
|
||||
const result = dominantNeighborIndex(
|
||||
[
|
||||
{ id: "a", weight: 1 },
|
||||
{ id: "b", weight: 1 },
|
||||
],
|
||||
map,
|
||||
9
|
||||
);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("falls back when no neighbor is in the reference section", () => {
|
||||
const result = dominantNeighborIndex(
|
||||
[{ id: "missing", weight: 1 }],
|
||||
new Map(),
|
||||
7
|
||||
);
|
||||
expect(result).toBe(7);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6240,6 +6240,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@vvo/tzdb@npm:6.198.0":
|
||||
version: 6.198.0
|
||||
resolution: "@vvo/tzdb@npm:6.198.0"
|
||||
checksum: 10/702d25ed7e7a55c4ee3c81e5de79cdb5d11c73bc02e511fa8f93eb497e6ada1b198805469f9e203bef6ea304b914637a6c570f19ade405b6e97d45181a0216a1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@webcomponents/scoped-custom-element-registry@npm:0.0.10":
|
||||
version: 0.0.10
|
||||
resolution: "@webcomponents/scoped-custom-element-registry@npm:0.0.10"
|
||||
@@ -9511,13 +9518,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"google-timezones-json@npm:1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "google-timezones-json@npm:1.2.0"
|
||||
checksum: 10/c83fdaa3681de7b63704aa5d5c644fecd1e2c46047eb65716fd0a1ef28a778b1bbfd6f521c499247b4d7afdc085c7d8bbdbea56398492d395ef9c8d87a648b11
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"gopd@npm:^1.0.1, gopd@npm:^1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "gopd@npm:1.2.0"
|
||||
@@ -9774,6 +9774,7 @@ __metadata:
|
||||
"@types/tar": "npm:7.0.87"
|
||||
"@vibrant/color": "npm:4.0.4"
|
||||
"@vitest/coverage-v8": "npm:4.1.9"
|
||||
"@vvo/tzdb": "npm:6.198.0"
|
||||
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
|
||||
"@webcomponents/webcomponentsjs": "npm:2.8.0"
|
||||
babel-loader: "npm:10.1.1"
|
||||
@@ -9807,7 +9808,6 @@ __metadata:
|
||||
generate-license-file: "npm:4.2.1"
|
||||
glob: "npm:13.0.6"
|
||||
globals: "npm:17.6.0"
|
||||
google-timezones-json: "npm:1.2.0"
|
||||
gulp: "npm:5.0.1"
|
||||
gulp-brotli: "npm:3.0.0"
|
||||
gulp-json-transform: "npm:0.5.0"
|
||||
|
||||
Reference in New Issue
Block a user