Compare commits

..

3 Commits

Author SHA1 Message Date
Simon Lamon 60c86899f3 Swap google-timezones-json to @vvo/tzdb (#52770) 2026-06-25 16:14:42 +02:00
Petar Petrov f8d870d6bb Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:12:52 +02:00
Copilot 4d82b352a9 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:02:29 +02:00
16 changed files with 716 additions and 124 deletions
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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;
+25 -23
View File
@@ -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;
}
+1 -1
View File
@@ -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>
+1 -4
View File
@@ -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}
+1 -3
View File
@@ -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;
}
+2 -7
View File
@@ -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);
});
});
});
+8 -8
View File
@@ -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"