mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-25 17:51:39 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60c86899f3 | |||
| f8d870d6bb | |||
| 4d82b352a9 | |||
| 179b4cf77c | |||
| 542f07606a | |||
| cf2c440e7b | |||
| 27fbabb71b | |||
| 389af6e00c |
@@ -24,11 +24,16 @@ const getMinifier = () => {
|
||||
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
|
||||
// css`` templates and inline <style> is handled by minify-literals' lightningcss
|
||||
// default.
|
||||
//
|
||||
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
|
||||
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
|
||||
// trailing slash would break the markup. It is harmless for HTML.
|
||||
const htmlOptions = {
|
||||
caseSensitive: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
decodeEntities: true,
|
||||
keepClosingSlash: true,
|
||||
removeComments: true,
|
||||
removeRedundantAttributes: true,
|
||||
};
|
||||
|
||||
+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",
|
||||
|
||||
+9
-2
@@ -111,7 +111,7 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
|
||||
]);
|
||||
|
||||
/** Domains that use a timestamp for state. */
|
||||
export const TIMESTAMP_STATE_DOMAINS = new Set([
|
||||
const TIMESTAMP_STATE_DOMAINS_LIST = [
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
@@ -127,7 +127,14 @@ export const TIMESTAMP_STATE_DOMAINS = new Set([
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
]);
|
||||
] as const;
|
||||
|
||||
export type TimestampStateDomain =
|
||||
(typeof TIMESTAMP_STATE_DOMAINS_LIST)[number];
|
||||
|
||||
export const TIMESTAMP_STATE_DOMAINS = new Set<string>(
|
||||
TIMESTAMP_STATE_DOMAINS_LIST
|
||||
);
|
||||
|
||||
/** Temperature units. */
|
||||
export const UNIT_C = "°C";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
+42
-4
@@ -1,5 +1,6 @@
|
||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
|
||||
import type { TimestampStateDomain } from "../common/const";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
@@ -239,16 +240,53 @@ export const parseTriggerSource = (source: string): ParsedTriggerSource => {
|
||||
return {};
|
||||
};
|
||||
|
||||
// Short label shown instead of the bare timestamp for each timestamp-state
|
||||
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value), so a
|
||||
// new timestamp domain won't compile until it gets a label here.
|
||||
type LogbookActionMessage =
|
||||
| "pressed"
|
||||
| "activated"
|
||||
| "scanned"
|
||||
| "detected_event_no_type"
|
||||
| "updated"
|
||||
| "sent"
|
||||
| "detected"
|
||||
| "transcribed"
|
||||
| "spoke"
|
||||
| "responded"
|
||||
| "ran"
|
||||
| "command_sent";
|
||||
|
||||
const STATE_ACTION_MESSAGES: Record<
|
||||
Exclude<TimestampStateDomain, "datetime">,
|
||||
LogbookActionMessage
|
||||
> = {
|
||||
button: "pressed",
|
||||
input_button: "pressed",
|
||||
scene: "activated",
|
||||
tag: "scanned",
|
||||
event: "detected_event_no_type",
|
||||
image: "updated",
|
||||
notify: "sent",
|
||||
wake_word: "detected",
|
||||
stt: "transcribed",
|
||||
tts: "spoke",
|
||||
conversation: "responded",
|
||||
ai_task: "ran",
|
||||
infrared: "command_sent",
|
||||
radio_frequency: "command_sent",
|
||||
};
|
||||
|
||||
export const localizeStateMessage = (
|
||||
hass: HomeAssistant,
|
||||
state: string,
|
||||
stateObj: HassEntity,
|
||||
domain: string
|
||||
): string => {
|
||||
// Events expose a timestamp as their state, which has no meaningful display
|
||||
// value, so keep a dedicated phrase.
|
||||
if (domain === "event") {
|
||||
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
|
||||
const actionKey: LogbookActionMessage | undefined =
|
||||
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
|
||||
if (actionKey) {
|
||||
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.${actionKey}`);
|
||||
}
|
||||
// Every other domain reuses the backend state translation, so the logbook
|
||||
// speaks the same vocabulary as the rest of the UI.
|
||||
|
||||
@@ -1044,8 +1044,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
ha-more-info-history-and-logbook {
|
||||
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
|
||||
var(--ha-space-6);
|
||||
padding: var(--ha-space-2) 0 var(--ha-space-6) 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -278,6 +278,7 @@ export class MoreInfoHistory extends LitElement {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
padding-inline: var(--ha-space-6);
|
||||
}
|
||||
.header > a,
|
||||
a:visited {
|
||||
@@ -290,6 +291,12 @@ export class MoreInfoHistory extends LitElement {
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
ha-alert,
|
||||
state-history-charts,
|
||||
statistics-chart {
|
||||
display: block;
|
||||
padding-inline: var(--ha-space-6);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export class MoreInfoLogbook extends LitElement {
|
||||
css`
|
||||
ha-logbook {
|
||||
--logbook-max-height: 250px;
|
||||
--logbook-horizontal-padding: var(--ha-space-6);
|
||||
}
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-logbook {
|
||||
@@ -82,6 +83,7 @@ export class MoreInfoLogbook extends LitElement {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--ha-space-2);
|
||||
padding-inline: var(--ha-space-6);
|
||||
}
|
||||
.header > a,
|
||||
a:visited {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+226
-264
@@ -1,17 +1,189 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../../../../common/array/ensure-array";
|
||||
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import type { LocalizeFunc } from "../../../../../common/translations/localize";
|
||||
import "../../../../../components/ha-form/ha-form";
|
||||
import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
import type { NumericStateTrigger } from "../../../../../data/automation";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
const SCHEMA = [
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
context: { filter_entity: "entity_id" },
|
||||
selector: {
|
||||
attribute: {
|
||||
hide_attributes: [
|
||||
"access_token",
|
||||
"auto_update",
|
||||
"available_modes",
|
||||
"away_mode",
|
||||
"changed_by",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_mode",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"effect",
|
||||
"entity_id",
|
||||
"entity_picture",
|
||||
"event_type",
|
||||
"event_types",
|
||||
"fan_mode",
|
||||
"fan_modes",
|
||||
"fan_speed_list",
|
||||
"forecast",
|
||||
"friendly_name",
|
||||
"frontend_stream_type",
|
||||
"has_date",
|
||||
"has_time",
|
||||
"hs_color",
|
||||
"hvac_mode",
|
||||
"hvac_modes",
|
||||
"icon",
|
||||
"id",
|
||||
"latest_version",
|
||||
"max_color_temp_kelvin",
|
||||
"max_mireds",
|
||||
"max_temp",
|
||||
"media_album_name",
|
||||
"media_artist",
|
||||
"media_content_type",
|
||||
"media_position_updated_at",
|
||||
"media_title",
|
||||
"min_color_temp_kelvin",
|
||||
"min_mireds",
|
||||
"min_temp",
|
||||
"mode",
|
||||
"next_dawn",
|
||||
"next_dusk",
|
||||
"next_midnight",
|
||||
"next_noon",
|
||||
"next_rising",
|
||||
"next_setting",
|
||||
"operation_list",
|
||||
"operation_mode",
|
||||
"options",
|
||||
"percentage_step",
|
||||
"precipitation_unit",
|
||||
"preset_mode",
|
||||
"preset_modes",
|
||||
"pressure_unit",
|
||||
"release_notes",
|
||||
"release_summary",
|
||||
"release_url",
|
||||
"restored",
|
||||
"rgb_color",
|
||||
"rgbw_color",
|
||||
"shuffle",
|
||||
"skipped_version",
|
||||
"sound_mode_list",
|
||||
"sound_mode",
|
||||
"source_list",
|
||||
"source_type",
|
||||
"source",
|
||||
"state_class",
|
||||
"step",
|
||||
"supported_color_modes",
|
||||
"supported_features",
|
||||
"swing_mode",
|
||||
"swing_modes",
|
||||
"target_temp_step",
|
||||
"temperature_unit",
|
||||
"title",
|
||||
"token",
|
||||
"unit_of_measurement",
|
||||
"user_id",
|
||||
"uuid",
|
||||
"visibility_unit",
|
||||
"wind_speed_unit",
|
||||
"xy_color",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
choose: {
|
||||
translation_key:
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.threshold_type",
|
||||
choices: {
|
||||
value: {
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
input: {
|
||||
selector: {
|
||||
entity: { domain: ["input_number", "number", "sensor"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
choose: {
|
||||
translation_key:
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.threshold_type",
|
||||
choices: {
|
||||
value: {
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
input: {
|
||||
selector: {
|
||||
entity: { domain: ["input_number", "number", "sensor"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "value_template",
|
||||
selector: { template: {} },
|
||||
},
|
||||
{
|
||||
name: "for",
|
||||
selector: {
|
||||
choose: {
|
||||
translation_key:
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.for_type",
|
||||
choices: {
|
||||
duration: { selector: { duration: {} } },
|
||||
template: { selector: { template: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-numeric_state")
|
||||
export class HaNumericStateTrigger extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -20,236 +192,6 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state() private _inputAboveIsEntity?: boolean;
|
||||
|
||||
@state() private _inputBelowIsEntity?: boolean;
|
||||
|
||||
private _schema = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
inputAboveIsEntity?: boolean,
|
||||
inputBelowIsEntity?: boolean
|
||||
) =>
|
||||
[
|
||||
{
|
||||
name: "entity_id",
|
||||
required: true,
|
||||
selector: { entity: { multiple: true } },
|
||||
},
|
||||
{
|
||||
name: "attribute",
|
||||
context: { filter_entity: "entity_id" },
|
||||
selector: {
|
||||
attribute: {
|
||||
hide_attributes: [
|
||||
"access_token",
|
||||
"auto_update",
|
||||
"available_modes",
|
||||
"away_mode",
|
||||
"changed_by",
|
||||
"code_arm_required",
|
||||
"code_format",
|
||||
"color_mode",
|
||||
"color_modes",
|
||||
"current_activity",
|
||||
"device_class",
|
||||
"editable",
|
||||
"effect_list",
|
||||
"effect",
|
||||
"entity_id",
|
||||
"entity_picture",
|
||||
"event_type",
|
||||
"event_types",
|
||||
"fan_mode",
|
||||
"fan_modes",
|
||||
"fan_speed_list",
|
||||
"forecast",
|
||||
"friendly_name",
|
||||
"frontend_stream_type",
|
||||
"has_date",
|
||||
"has_time",
|
||||
"hs_color",
|
||||
"hvac_mode",
|
||||
"hvac_modes",
|
||||
"icon",
|
||||
"id",
|
||||
"latest_version",
|
||||
"max_color_temp_kelvin",
|
||||
"max_mireds",
|
||||
"max_temp",
|
||||
"media_album_name",
|
||||
"media_artist",
|
||||
"media_content_type",
|
||||
"media_position_updated_at",
|
||||
"media_title",
|
||||
"min_color_temp_kelvin",
|
||||
"min_mireds",
|
||||
"min_temp",
|
||||
"mode",
|
||||
"next_dawn",
|
||||
"next_dusk",
|
||||
"next_midnight",
|
||||
"next_noon",
|
||||
"next_rising",
|
||||
"next_setting",
|
||||
"operation_list",
|
||||
"operation_mode",
|
||||
"options",
|
||||
"percentage_step",
|
||||
"precipitation_unit",
|
||||
"preset_mode",
|
||||
"preset_modes",
|
||||
"pressure_unit",
|
||||
"release_notes",
|
||||
"release_summary",
|
||||
"release_url",
|
||||
"restored",
|
||||
"rgb_color",
|
||||
"rgbw_color",
|
||||
"shuffle",
|
||||
"skipped_version",
|
||||
"sound_mode_list",
|
||||
"sound_mode",
|
||||
"source_list",
|
||||
"source_type",
|
||||
"source",
|
||||
"state_class",
|
||||
"step",
|
||||
"supported_color_modes",
|
||||
"supported_features",
|
||||
"swing_mode",
|
||||
"swing_modes",
|
||||
"target_temp_step",
|
||||
"temperature_unit",
|
||||
"title",
|
||||
"token",
|
||||
"unit_of_measurement",
|
||||
"user_id",
|
||||
"uuid",
|
||||
"visibility_unit",
|
||||
"wind_speed_unit",
|
||||
"xy_color",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "lower_limit",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
[
|
||||
"value",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
|
||||
),
|
||||
],
|
||||
[
|
||||
"input",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
|
||||
),
|
||||
],
|
||||
],
|
||||
},
|
||||
...(inputAboveIsEntity
|
||||
? ([
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
entity: { domain: ["input_number", "number", "sensor"] },
|
||||
},
|
||||
},
|
||||
] as const)
|
||||
: ([
|
||||
{
|
||||
name: "above",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const)),
|
||||
{
|
||||
name: "upper_limit",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [
|
||||
[
|
||||
"value",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
|
||||
),
|
||||
],
|
||||
[
|
||||
"input",
|
||||
localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
|
||||
),
|
||||
],
|
||||
],
|
||||
},
|
||||
...(inputBelowIsEntity
|
||||
? ([
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
entity: { domain: ["input_number", "number", "sensor"] },
|
||||
},
|
||||
},
|
||||
] as const)
|
||||
: ([
|
||||
{
|
||||
name: "below",
|
||||
selector: {
|
||||
number: {
|
||||
mode: "box",
|
||||
min: Number.MIN_SAFE_INTEGER,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
step: 0.1,
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const)),
|
||||
{
|
||||
name: "value_template",
|
||||
selector: { template: {} },
|
||||
},
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
] as const
|
||||
);
|
||||
|
||||
public willUpdate(changedProperties: PropertyValues<this>) {
|
||||
this._inputAboveIsEntity =
|
||||
this._inputAboveIsEntity ??
|
||||
(typeof this.trigger.above === "string" &&
|
||||
((this.trigger.above as string).startsWith("input_number.") ||
|
||||
(this.trigger.above as string).startsWith("number.") ||
|
||||
(this.trigger.above as string).startsWith("sensor.")));
|
||||
this._inputBelowIsEntity =
|
||||
this._inputBelowIsEntity ??
|
||||
(typeof this.trigger.below === "string" &&
|
||||
((this.trigger.below as string).startsWith("input_number.") ||
|
||||
(this.trigger.below as string).startsWith("number.") ||
|
||||
(this.trigger.below as string).startsWith("sensor.")));
|
||||
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return;
|
||||
}
|
||||
// Check for templates in trigger. If found, revert to YAML mode.
|
||||
if (this.trigger && hasTemplate(this.trigger.for)) {
|
||||
fireEvent(
|
||||
this,
|
||||
"ui-mode-not-available",
|
||||
Error(this.hass.localize("ui.errors.config.no_template_editor_support"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public static get defaultConfig(): NumericStateTrigger {
|
||||
return {
|
||||
trigger: "numeric_state",
|
||||
@@ -257,39 +199,61 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
private _data = memoizeOne(
|
||||
(
|
||||
inputAboveIsEntity: boolean,
|
||||
inputBelowIsEntity: boolean,
|
||||
trigger: NumericStateTrigger
|
||||
) => ({
|
||||
lower_limit: inputAboveIsEntity ? "input" : "value",
|
||||
upper_limit: inputBelowIsEntity ? "input" : "value",
|
||||
...trigger,
|
||||
entity_id: ensureArray(trigger.entity_id),
|
||||
for: createDurationData(trigger.for),
|
||||
})
|
||||
);
|
||||
private _wrapForValue(
|
||||
forValue: NumericStateTrigger["for"]
|
||||
): Record<string, unknown> | undefined {
|
||||
if (forValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof forValue === "string" && hasTemplate(forValue)) {
|
||||
return { active_choice: "template", template: forValue };
|
||||
}
|
||||
return {
|
||||
active_choice: "duration",
|
||||
duration: createDurationData(forValue),
|
||||
};
|
||||
}
|
||||
|
||||
private _unwrapForValue(
|
||||
forValue: Record<string, unknown> | undefined
|
||||
): NumericStateTrigger["for"] {
|
||||
if (!forValue || !forValue.active_choice) {
|
||||
return forValue as NumericStateTrigger["for"];
|
||||
}
|
||||
if (forValue.active_choice === "template") {
|
||||
return forValue.template as string;
|
||||
}
|
||||
return forValue.duration as NumericStateTrigger["for"];
|
||||
}
|
||||
|
||||
private _unwrapThresholdValue(
|
||||
value: Record<string, unknown> | number | string | undefined
|
||||
): number | string | undefined {
|
||||
if (value === undefined || typeof value !== "object") {
|
||||
return value as number | string | undefined;
|
||||
}
|
||||
if (!value.active_choice) {
|
||||
return undefined;
|
||||
}
|
||||
return value[value.active_choice as string] as number | string | undefined;
|
||||
}
|
||||
|
||||
private _data = memoizeOne((trigger: NumericStateTrigger) => ({
|
||||
...trigger,
|
||||
entity_id: ensureArray(trigger.entity_id),
|
||||
for: this._wrapForValue(trigger.for),
|
||||
}));
|
||||
|
||||
public render() {
|
||||
const schema = this._schema(
|
||||
this.hass.localize,
|
||||
this._inputAboveIsEntity,
|
||||
this._inputBelowIsEntity
|
||||
);
|
||||
|
||||
const data = this._data(
|
||||
this._inputAboveIsEntity!,
|
||||
this._inputBelowIsEntity!,
|
||||
this.trigger
|
||||
);
|
||||
const data = this._data(this.trigger);
|
||||
|
||||
return html`
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.schema=${SCHEMA}
|
||||
.disabled=${this.disabled}
|
||||
.localizeValue=${this.hass.localize}
|
||||
@value-changed=${this._valueChanged}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
></ha-form>
|
||||
@@ -300,11 +264,9 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const newTrigger = { ...ev.detail.value };
|
||||
|
||||
this._inputAboveIsEntity = newTrigger.lower_limit === "input";
|
||||
this._inputBelowIsEntity = newTrigger.upper_limit === "input";
|
||||
|
||||
delete newTrigger.lower_limit;
|
||||
delete newTrigger.upper_limit;
|
||||
newTrigger.above = this._unwrapThresholdValue(newTrigger.above);
|
||||
newTrigger.below = this._unwrapThresholdValue(newTrigger.below);
|
||||
newTrigger.for = this._unwrapForValue(newTrigger.for);
|
||||
|
||||
if (newTrigger.value_template === "") {
|
||||
delete newTrigger.value_template;
|
||||
@@ -314,7 +276,7 @@ export class HaNumericStateTrigger extends LitElement {
|
||||
}
|
||||
|
||||
private _computeLabelCallback = (
|
||||
schema: SchemaUnion<ReturnType<typeof this._schema>>
|
||||
schema: SchemaUnion<typeof SCHEMA>
|
||||
): string => {
|
||||
switch (schema.name) {
|
||||
case "entity_id":
|
||||
|
||||
@@ -174,7 +174,19 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
},
|
||||
},
|
||||
},
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
{
|
||||
name: "for",
|
||||
selector: {
|
||||
choose: {
|
||||
translation_key:
|
||||
"ui.panel.config.automation.editor.triggers.type.state.for_type",
|
||||
choices: {
|
||||
duration: { selector: { duration: {} } },
|
||||
template: { selector: { template: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const satisfies HaFormSchema[]
|
||||
);
|
||||
|
||||
@@ -190,7 +202,9 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
delete this.trigger.for.milliseconds;
|
||||
}
|
||||
// Check for templates in trigger. If found, revert to YAML mode.
|
||||
if (this.trigger && hasTemplate(this.trigger)) {
|
||||
// Exclude "for" since the UI now supports templates there via choose.
|
||||
const { for: _for, ...triggerWithoutFor } = this.trigger;
|
||||
if (triggerWithoutFor && hasTemplate(triggerWithoutFor)) {
|
||||
fireEvent(
|
||||
this,
|
||||
"ui-mode-not-available",
|
||||
@@ -207,13 +221,38 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const trgFor = createDurationData(this.trigger.for);
|
||||
private _wrapForValue(
|
||||
forValue: StateTrigger["for"]
|
||||
): Record<string, unknown> | undefined {
|
||||
if (forValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof forValue === "string" && hasTemplate(forValue)) {
|
||||
return { active_choice: "template", template: forValue };
|
||||
}
|
||||
return {
|
||||
active_choice: "duration",
|
||||
duration: createDurationData(forValue),
|
||||
};
|
||||
}
|
||||
|
||||
private _unwrapForValue(
|
||||
forValue: Record<string, unknown> | undefined
|
||||
): StateTrigger["for"] {
|
||||
if (!forValue || !forValue.active_choice) {
|
||||
return forValue as StateTrigger["for"];
|
||||
}
|
||||
if (forValue.active_choice === "template") {
|
||||
return forValue.template as string;
|
||||
}
|
||||
return forValue.duration as StateTrigger["for"];
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const data = {
|
||||
...this.trigger,
|
||||
entity_id: ensureArray(this.trigger.entity_id),
|
||||
for: trgFor,
|
||||
for: this._wrapForValue(this.trigger.for),
|
||||
};
|
||||
|
||||
data.to = this._normalizeStates(this.trigger.to, data.attribute);
|
||||
@@ -230,6 +269,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${schema}
|
||||
.localizeValue=${this.hass.localize}
|
||||
@value-changed=${this._valueChanged}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.disabled=${this.disabled}
|
||||
@@ -241,6 +281,8 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
|
||||
ev.stopPropagation();
|
||||
const newTrigger = ev.detail.value;
|
||||
|
||||
newTrigger.for = this._unwrapForValue(newTrigger.for);
|
||||
|
||||
newTrigger.to = this._applyAnyStateExclusive(
|
||||
newTrigger.to,
|
||||
newTrigger.attribute
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { TemplateTrigger } from "../../../../../data/automation";
|
||||
@@ -11,7 +10,19 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
|
||||
|
||||
const SCHEMA = [
|
||||
{ name: "value_template", required: true, selector: { template: {} } },
|
||||
{ name: "for", selector: { duration: {} } },
|
||||
{
|
||||
name: "for",
|
||||
selector: {
|
||||
choose: {
|
||||
translation_key:
|
||||
"ui.panel.config.automation.editor.triggers.type.template.for_type",
|
||||
choices: {
|
||||
duration: { selector: { duration: {} } },
|
||||
template: { selector: { template: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@customElement("ha-automation-trigger-template")
|
||||
@@ -26,26 +37,37 @@ export class HaTemplateTrigger extends LitElement {
|
||||
return { trigger: "template", value_template: "" };
|
||||
}
|
||||
|
||||
public willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (!changedProperties.has("trigger")) {
|
||||
return;
|
||||
private _wrapForValue(
|
||||
forValue: TemplateTrigger["for"]
|
||||
): Record<string, unknown> | undefined {
|
||||
if (forValue === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
// Check for templates in trigger. If found, revert to YAML mode.
|
||||
if (this.trigger && hasTemplate(this.trigger.for)) {
|
||||
fireEvent(
|
||||
this,
|
||||
"ui-mode-not-available",
|
||||
Error(this.hass.localize("ui.errors.config.no_template_editor_support"))
|
||||
);
|
||||
if (typeof forValue === "string" && hasTemplate(forValue)) {
|
||||
return { active_choice: "template", template: forValue };
|
||||
}
|
||||
return {
|
||||
active_choice: "duration",
|
||||
duration: createDurationData(forValue),
|
||||
};
|
||||
}
|
||||
|
||||
private _unwrapForValue(
|
||||
forValue: Record<string, unknown> | undefined
|
||||
): TemplateTrigger["for"] {
|
||||
if (!forValue || !forValue.active_choice) {
|
||||
return forValue as TemplateTrigger["for"];
|
||||
}
|
||||
if (forValue.active_choice === "template") {
|
||||
return forValue.template as string;
|
||||
}
|
||||
return forValue.duration as TemplateTrigger["for"];
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const trgFor = createDurationData(this.trigger.for);
|
||||
|
||||
const data = {
|
||||
...this.trigger,
|
||||
for: trgFor,
|
||||
for: this._wrapForValue(this.trigger.for),
|
||||
};
|
||||
|
||||
return html`
|
||||
@@ -53,6 +75,7 @@ export class HaTemplateTrigger extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.data=${data}
|
||||
.schema=${SCHEMA}
|
||||
.localizeValue=${this.hass.localize}
|
||||
@value-changed=${this._valueChanged}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
.disabled=${this.disabled}
|
||||
@@ -64,8 +87,11 @@ export class HaTemplateTrigger extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const newTrigger = ev.detail.value;
|
||||
|
||||
newTrigger.for = this._unwrapForValue(newTrigger.for);
|
||||
|
||||
if (
|
||||
newTrigger.for &&
|
||||
typeof newTrigger.for === "object" &&
|
||||
Object.values(newTrigger.for).every((value) => value === 0)
|
||||
) {
|
||||
delete newTrigger.for;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { computeTimelineColor } from "../../components/chart/timeline-color";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
|
||||
import { useAmPm } from "../../common/datetime/use_am_pm";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { navigate } from "../../common/navigate";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
@@ -126,6 +127,7 @@ class HaLogbookEntry extends LitElement {
|
||||
[`node-${node}`]: true,
|
||||
"last-of-day": this.lastOfDay,
|
||||
[`category-${ctx.category}`]: true,
|
||||
"time-am-pm": useAmPm(this.hass.locale),
|
||||
})}"
|
||||
>
|
||||
${layout === "timeline"
|
||||
@@ -591,7 +593,7 @@ class HaLogbookEntry extends LitElement {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
/* No vertical padding: the rail must reach the row edges to stay continuous between nodes. */
|
||||
padding: 0 var(--ha-space-4);
|
||||
padding: 0 var(--logbook-horizontal-padding, var(--ha-space-4));
|
||||
grid-auto-rows: minmax(60px, auto);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
align-items: stretch;
|
||||
@@ -913,6 +915,7 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
.time-chip {
|
||||
flex-shrink: 0;
|
||||
text-align: end;
|
||||
line-height: 1;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
@@ -921,6 +924,14 @@ class HaLogbookEntry extends LitElement {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.time-chip {
|
||||
min-width: 4.5em;
|
||||
}
|
||||
|
||||
.entry.time-am-pm .time-chip {
|
||||
min-width: 6em;
|
||||
}
|
||||
|
||||
.time-chip:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@@ -197,7 +197,8 @@ class HaLogbookRenderer extends LitElement {
|
||||
|
||||
.date {
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
padding: var(--ha-space-2) var(--ha-space-4) 0;
|
||||
padding: var(--ha-space-2)
|
||||
var(--logbook-horizontal-padding, var(--ha-space-4)) 0;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mdiChevronRight } from "@mdi/js";
|
||||
import { startOfYesterday } from "date-fns";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
@@ -10,10 +9,10 @@ import { ensureArray } from "../../../common/array/ensure-array";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
|
||||
import { getEntityEntryContext } from "../../../common/entity/context/get_entity_context";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import { createSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-tooltip";
|
||||
import { resolveEntityIDs } from "../../../data/selector";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "../../logbook/ha-logbook";
|
||||
@@ -73,6 +72,8 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
|
||||
|
||||
@state() private _stateFilter?: string[];
|
||||
|
||||
private _showMoreLinkId = `logbook-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
public getCardSize(): number {
|
||||
return 9 + (this._config?.title ? 1 : 0);
|
||||
}
|
||||
@@ -142,10 +143,6 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
|
||||
this._stateFilter = ensureArray(config.state_filter);
|
||||
}
|
||||
|
||||
private _showMore() {
|
||||
navigate(this._showMoreUrl());
|
||||
}
|
||||
|
||||
private _showMoreUrl(): string {
|
||||
const target = this._targetPickerValue;
|
||||
const params: Record<string, string> = {
|
||||
@@ -291,16 +288,21 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
|
||||
return html`
|
||||
<ha-card class=${classMap({ "no-header": !this._config!.title })}>
|
||||
${this._config!.title
|
||||
? html`<div class="card-header">
|
||||
<h1 class="name">${this._config!.title}</h1>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronRight}
|
||||
.label=${this.hass.localize(
|
||||
? html`<h1 class="card-header">
|
||||
${this._config!.title}
|
||||
<a
|
||||
id=${this._showMoreLinkId}
|
||||
href=${this._showMoreUrl()}
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.show_more"
|
||||
)}
|
||||
@click=${this._showMore}
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
>
|
||||
<ha-icon-next></ha-icon-next>
|
||||
</a>
|
||||
<ha-tooltip for=${this._showMoreLinkId} placement="left">
|
||||
${this.hass.localize("ui.dialogs.more_info_control.show_more")}
|
||||
</ha-tooltip>
|
||||
</h1>`
|
||||
: nothing}
|
||||
<div class="content">
|
||||
<ha-logbook
|
||||
@@ -336,26 +338,18 @@ export class HuiLogbookCard extends LitElement implements LovelaceCard {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 16px 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.card-header .name {
|
||||
margin: 0;
|
||||
font-size: var(--ha-card-header-font-size, 1.4rem);
|
||||
font-weight: var(--ha-card-header-font-weight, 500);
|
||||
color: var(--ha-card-header-color, var(--primary-text-color));
|
||||
}
|
||||
|
||||
.card-header a {
|
||||
.card-header ha-icon-next {
|
||||
--ha-icon-button-size: 24px;
|
||||
line-height: 24px;
|
||||
color: var(--primary-text-color);
|
||||
margin-right: calc(var(--ha-space-2) * -1);
|
||||
margin-inline-end: calc(var(--ha-space-2) * -1);
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 100%;
|
||||
padding: 0 16px 16px;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.no-header .content {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-slider";
|
||||
import "../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../components/input/ha-input";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
|
||||
import { setValue } from "../../../data/input_text";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { hasConfigOrEntityChanged } from "../common/has-changed";
|
||||
@@ -91,7 +91,9 @@ class HuiNumberEntityRow extends LitElement implements LovelaceRow {
|
||||
@change=${this._selectedValueChanged}
|
||||
></ha-slider>
|
||||
<span class="state">
|
||||
${this.hass.formatEntityState(stateObj)}
|
||||
${stateObj.state === UNAVAILABLE || stateObj.state === UNKNOWN
|
||||
? "—"
|
||||
: this.hass.formatEntityState(stateObj)}
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -715,7 +715,18 @@
|
||||
"retrieval_error": "Could not load activity",
|
||||
"not_loaded": "[%key:ui::dialogs::helper_settings::platform_not_loaded%]",
|
||||
"messages": {
|
||||
"detected_event_no_type": "detected an event"
|
||||
"detected_event_no_type": "Event detected",
|
||||
"pressed": "Pressed",
|
||||
"activated": "Activated",
|
||||
"scanned": "Scanned",
|
||||
"updated": "Updated",
|
||||
"sent": "Sent",
|
||||
"detected": "Detected",
|
||||
"transcribed": "Transcribed",
|
||||
"spoke": "Spoke",
|
||||
"responded": "Responded",
|
||||
"ran": "Ran",
|
||||
"command_sent": "Command sent"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
@@ -2492,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."
|
||||
},
|
||||
@@ -5251,6 +5263,12 @@
|
||||
"description": {
|
||||
"picker": "Triggers when the state of an entity (or attribute) changes.",
|
||||
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
|
||||
},
|
||||
"for_type": {
|
||||
"choices": {
|
||||
"duration": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::duration%]",
|
||||
"template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::template%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"homeassistant": {
|
||||
@@ -5281,12 +5299,24 @@
|
||||
"upper_limit": "Upper limit",
|
||||
"value_template": "Value template",
|
||||
"type_value": "Fixed number",
|
||||
"type_input": "Numeric value of another entity",
|
||||
"type_input": "Value of an entity",
|
||||
"description": {
|
||||
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
|
||||
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
|
||||
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
|
||||
},
|
||||
"threshold_type": {
|
||||
"choices": {
|
||||
"value": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_value%]",
|
||||
"input": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_input%]"
|
||||
}
|
||||
},
|
||||
"for_type": {
|
||||
"choices": {
|
||||
"duration": "Duration",
|
||||
"template": "Template"
|
||||
}
|
||||
}
|
||||
},
|
||||
"persistent_notification": {
|
||||
@@ -5344,6 +5374,12 @@
|
||||
"description": {
|
||||
"picker": "Triggers when a template evaluates to true.",
|
||||
"full": "When a template changes from false to true{hasDuration, select, \n true { for {duration}} \n other {}\n }"
|
||||
},
|
||||
"for_type": {
|
||||
"choices": {
|
||||
"duration": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::duration%]",
|
||||
"template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::template%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
|
||||
@@ -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