Compare commits

...
14 changed files with 322 additions and 105 deletions
@@ -93,9 +93,9 @@ export class HaAutomationRowEventChip extends LitElement {
}
:host([variant="neutral"]) {
--background-color: var(--ha-color-fill-neutral-normal-resting);
--background-color-hover: var(--ha-color-fill-neutral-normal-hover);
--text-color: var(--ha-color-on-neutral-normal);
--background-color: var(--ha-color-fill-neutral-loud-resting);
--background-color-hover: var(--ha-color-fill-neutral-loud-hover);
--text-color: var(--ha-color-on-neutral-loud);
}
:host([variant="success"]) {
+11 -5
View File
@@ -152,7 +152,7 @@ export class HaAutomationRow extends LitElement {
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
border: 1px solid var(--ha-color-border-neutral-normal);
border-radius: var(--ha-border-radius-md);
padding: var(--ha-space-1);
margin-top: 10px;
@@ -172,7 +172,6 @@ export class HaAutomationRow extends LitElement {
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
:host([building-block]) ::slotted(#condition-icon) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
}
:host([collapsed]) .expand-button {
@@ -185,9 +184,16 @@ export class HaAutomationRow extends LitElement {
outline-offset: -2px;
outline-width: 2px;
}
:host([disabled]) .row {
border-top-right-radius: var(--ha-border-radius-square);
border-top-left-radius: var(--ha-border-radius-square);
:host([disabled]) .row,
:host([disabled]) ::slotted([slot="leading-icon"]) {
color: var(--ha-color-text-disabled);
}
:host([disabled]) .leading-icon-wrapper {
opacity: 0.6;
}
:host([disabled]) ::slotted([slot="header"]) {
opacity: 0.6;
text-decoration: line-through;
}
.header {
position: relative;
+1 -1
View File
@@ -12,7 +12,6 @@ import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
export interface Node {
@@ -43,6 +42,7 @@ const OVERFLOW_MARGIN = 5;
const FONT_SIZE = 12;
const NODE_GAP = 6;
const LABEL_DISTANCE = 5;
const NODE_SIZE = 30;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
+84 -21
View File
@@ -1,7 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
import { BRANCH_HEIGHT, SPACING } from "./hat-graph-const";
interface BranchConfig {
@@ -10,6 +10,9 @@ interface BranchConfig {
start: boolean;
end: boolean;
track: boolean;
// A branch the run entered but never finished, because a step in it raised.
// Its incoming curve is tracked, everything leaving it is not.
trackEnd: boolean;
}
/**
@@ -32,34 +35,85 @@ export class HatGraphBranch extends LitElement {
private _maxHeight = 0;
private _updateBranches(ev: HASSDomTargetEvent<HTMLSlotElement>) {
@query("#branches slot") private _slot?: HTMLSlotElement;
// The branch children are Lit rendered by the parent, so their track
// attribute can flip (another trace, another run) without a slot change,
// and nested nodes can change without the assigned elements changing.
private _trackObserver = new MutationObserver((mutations) => {
if (
mutations.some(
(m) =>
m.type === "childList" || (m.target as Element).parentElement === this
)
) {
this._updateBranches();
}
});
public connectedCallback() {
super.connectedCallback();
this._trackObserver.observe(this, {
subtree: true,
attributes: true,
childList: true,
attributeFilter: ["track", "unfinished"],
});
}
public disconnectedCallback() {
super.disconnectedCallback();
this._trackObserver.disconnect();
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
// The branches are read from the DOM, so they are refreshed on every
// update to pick up size changes as well.
this._updateBranches();
}
private _updateBranches() {
if (!this._slot) {
return;
}
let total_width = 0;
const heights: number[] = [];
const branches: BranchConfig[] = [];
(ev.target as HTMLSlotElement).assignedElements().forEach((c) => {
this._slot.assignedElements().forEach((c) => {
const width = c.clientWidth;
const height = c.clientHeight;
const track = c.hasAttribute("track");
branches.push({
x: width / 2 + total_width,
height,
start: c.hasAttribute("graph-start"),
end: c.hasAttribute("graph-end"),
track: c.hasAttribute("track"),
track,
trackEnd: track && !c.hasAttribute("unfinished"),
});
total_width += width;
heights.push(height);
});
const maxHeight = Math.max(...heights);
// Tracked branches are drawn last, so they are never covered by the
// untracked ones where the paths overlap.
branches.sort(
(a, b) =>
Number(a.trackEnd) - Number(b.trackEnd) ||
Number(a.track) - Number(b.track)
);
if (
total_width === this._totalWidth &&
maxHeight === this._maxHeight &&
JSON.stringify(branches) === JSON.stringify(this._branches)
) {
// Nothing changed, don't trigger another update.
return;
}
this._totalWidth = total_width;
this._maxHeight = Math.max(...heights);
this._branches = branches.sort((a, b) => {
if (a.track && !b.track) {
return 1;
}
if (a.track && b.track) {
return 0;
}
return -1;
});
this._maxHeight = maxHeight;
this._branches = branches;
}
render() {
@@ -79,7 +133,9 @@ export class HatGraphBranch extends LitElement {
})}
d="
M ${this._totalWidth / 2} 0
L ${branch.x} ${BRANCH_HEIGHT}
C ${this._totalWidth / 2} ${BRANCH_HEIGHT / 2}
${branch.x} ${BRANCH_HEIGHT / 2}
${branch.x} ${BRANCH_HEIGHT}
"/>
`
)}
@@ -94,7 +150,7 @@ export class HatGraphBranch extends LitElement {
return svg`
<path
class=${classMap({
track: branch.track,
track: branch.trackEnd,
})}
d="
M ${branch.x} ${branch.height}
@@ -115,12 +171,14 @@ export class HatGraphBranch extends LitElement {
return svg`
<path
class=${classMap({
track: branch.track,
track: branch.trackEnd,
})}
d="
M ${branch.x} 0
V ${SPACING}
L ${this._totalWidth / 2} ${BRANCH_HEIGHT + SPACING}
C ${branch.x} ${SPACING + BRANCH_HEIGHT / 2}
${this._totalWidth / 2} ${SPACING + BRANCH_HEIGHT / 2}
${this._totalWidth / 2} ${BRANCH_HEIGHT + SPACING}
"/>
`;
})}
@@ -161,14 +219,19 @@ export class HatGraphBranch extends LitElement {
}
#bottom {
height: calc(var(--hat-graph-branch-height) + var(--hat-graph-spacing));
position: relative;
z-index: 2;
}
path {
stroke: var(--stroke-clr);
stroke-width: 2;
stroke: var(--connector-clr, var(--stroke-clr));
stroke-width: 1;
stroke-dasharray: 4 3;
fill: none;
}
path.track {
stroke: var(--track-clr);
stroke-width: 2;
stroke-dasharray: none;
}
:host([disabled]) path {
stroke: var(--disabled-clr);
+8 -1
View File
@@ -1,3 +1,10 @@
export const SPACING = 10;
export const NODE_SIZE = 30;
export const NODE_SIZE = 36;
export const NODE_RADIUS = 12;
export const BRANCH_HEIGHT = 20;
export const ICON_SIZE = 24;
// Building blocks are rotated 45deg, so their diagonal (size * sqrt(2)) is
// bigger than the node box: the node svg is allowed to overflow for them.
export const BUILDING_BLOCK_SIZE = 30;
export const BUILDING_BLOCK_RADIUS = 6;
export const BUILDING_BLOCK_ICON_SIZE = 20;
+120 -24
View File
@@ -3,7 +3,24 @@ import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing, svg } from "lit";
import { customElement, property } from "lit/decorators";
import { isSafari } from "../../util/is_safari";
import { NODE_SIZE, SPACING } from "./hat-graph-const";
import {
BUILDING_BLOCK_ICON_SIZE,
BUILDING_BLOCK_RADIUS,
BUILDING_BLOCK_SIZE,
ICON_SIZE,
NODE_RADIUS,
NODE_SIZE,
SPACING,
} from "./hat-graph-const";
/** Matches the 16px live-test indicator used by automation rows. */
const BADGE_RADIUS = 8;
/** Point on the rounded corner arc, so the badge straddles the node border. */
const BADGE_X = NODE_SIZE / 2 - NODE_RADIUS + NODE_RADIUS / Math.SQRT2;
const BADGE_Y = -BADGE_X;
/** The repeat count sits centered on the bottom edge. */
const COUNT_BADGE_Y = NODE_SIZE / 2;
const COUNT_BADGE_RADIUS = 9;
/**
* @attribute active
@@ -15,7 +32,7 @@ export class HatGraphNode extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public error = false;
@property({ type: Boolean, reflect: true }) public error = false;
@property({ attribute: "not-enabled", reflect: true, type: Boolean })
notEnabled = false;
@@ -26,6 +43,10 @@ export class HatGraphNode extends LitElement {
@property({ attribute: "graph-start", reflect: true, type: Boolean })
graphStart = false;
/** Renders the node as a filled diamond, like building block rows in the editor. */
@property({ attribute: "building-block", reflect: true, type: Boolean })
buildingBlock = false;
@property({ type: Boolean, attribute: "nofocus" }) noFocus = false;
@property({ reflect: true, type: Number }) badge?: number;
@@ -43,6 +64,9 @@ export class HatGraphNode extends LitElement {
protected render(): TemplateResult {
const height = NODE_SIZE + (this.graphStart ? 2 : SPACING + 1);
const width = SPACING + NODE_SIZE;
const size = this.buildingBlock ? BUILDING_BLOCK_SIZE : NODE_SIZE;
const iconSize = this.buildingBlock ? BUILDING_BLOCK_ICON_SIZE : ICON_SIZE;
// A rotated building block is wider than its side.
return html`
<svg
class=${isSafari ? "safari" : ""}
@@ -69,17 +93,24 @@ export class HatGraphNode extends LitElement {
`
}
<g class="node">
<circle cx="0" cy="0" r=${NODE_SIZE / 2} />
<rect
x=${-size / 2}
y=${-size / 2}
width=${size}
height=${size}
rx=${this.buildingBlock ? BUILDING_BLOCK_RADIUS : NODE_RADIUS}
transform=${this.buildingBlock ? "rotate(45)" : nothing}
/>
${
this.error
? svg`
<g class="error">
<circle
cx="-12"
cy=${-NODE_SIZE / 2}
r="8"
cx=${BADGE_X}
cy=${BADGE_Y}
r=${BADGE_RADIUS}
></circle>
<path transform="translate(-18 -21) scale(.5)" class="exclamation" d=${mdiExclamationThick}/>
<path transform="translate(${BADGE_X - 6} ${BADGE_Y - 6}) scale(.5)" class="exclamation" d=${mdiExclamationThick}/>
</g>
`
: nothing
@@ -89,27 +120,46 @@ export class HatGraphNode extends LitElement {
? svg`
<g class="number">
<circle
cx="12"
cy=${-NODE_SIZE / 2}
r="8"
cx="0"
cy=${COUNT_BADGE_Y}
r=${COUNT_BADGE_RADIUS}
></circle>
<text
x="12"
y=${-NODE_SIZE / 2}
x="0"
y=${COUNT_BADGE_Y}
text-anchor="middle"
alignment-baseline="middle"
dominant-baseline="central"
>${this.badge > 9 ? "9+" : this.badge}</text>
</g>
`
: nothing
}
<g style="pointer-events: none" transform="translate(-12 -12)">
<g
class="icon-wrapper"
style="pointer-events: none"
transform="translate(-${iconSize / 2} -${iconSize / 2}) scale(${
iconSize / ICON_SIZE
})"
>
${
this.iconPath
? svg`<path class="icon" d=${this.iconPath}/>`
: svg`<foreignObject><span class="icon"><slot name="icon"></slot></span></foreignObject>`
}
</g>
${
this.notEnabled
? svg`
<line
class="strike"
x1=${-iconSize / 2}
y1="0"
x2=${iconSize / 2}
y2="0"
/>
`
: nothing
}
</g>
</svg>
`;
@@ -122,6 +172,11 @@ export class HatGraphNode extends LitElement {
min-width: calc(var(--hat-graph-node-size) + var(--hat-graph-spacing));
height: calc(var(--hat-graph-node-size) + var(--hat-graph-spacing) + 1px);
}
/* The count badge overlaps the next node's connector. */
:host([badge]) {
position: relative;
z-index: 1;
}
:host([graph-start]) {
height: calc(var(--hat-graph-node-size) + 2px);
}
@@ -129,40 +184,81 @@ export class HatGraphNode extends LitElement {
--stroke-clr: var(--track-clr);
--icon-clr: var(--default-icon-clr);
}
:host([active]) circle {
/* A step that raised is drawn red, but only the node itself: the run did
reach it, so the connector above it stays on the tracked path colour. */
:host([error]) {
--icon-clr: var(--default-icon-clr);
}
:host([error]) rect {
--stroke-clr: var(--error-color);
}
:host([active]) rect {
--stroke-clr: var(--active-clr);
--icon-clr: var(--default-icon-clr);
stroke-width: 3;
}
:host(:focus) {
outline: none;
}
:host(:hover) circle {
:host(:hover) rect {
--stroke-clr: var(--hover-clr);
--icon-clr: var(--default-icon-clr);
}
:host([not-triggered]) circle {
:host([not-triggered]) rect {
stroke-dasharray: 4 3;
}
:host([not-enabled]) circle {
--stroke-clr: var(--disabled-clr);
:host([not-enabled]) {
--stroke-clr: var(--ha-color-border-neutral-normal);
--icon-clr: var(--ha-color-text-disabled);
}
:host([not-enabled][active]) circle {
/* One step off the surface in whichever direction the theme runs: #e5e5e5
on a white node body, #282828 on a near black one. */
:host([not-enabled]) rect {
fill: var(--secondary-background-color);
}
/* Not the whole node group: SVG composites group opacity as a unit, which
would fade the error and count badges along with the rest. The body is
left at full strength so the grey does not wash out. */
:host([not-enabled]) .icon-wrapper,
:host([not-enabled]) .strike {
opacity: 0.6;
}
.strike {
stroke: var(--icon-clr);
stroke-width: 2;
stroke-linecap: round;
}
:host([not-enabled][active]) rect {
--stroke-clr: var(--disabled-active-clr);
}
:host([not-enabled]:hover) circle {
:host([not-enabled]:hover) rect {
--stroke-clr: var(--disabled-hover-clr);
}
/* Rotated building blocks and corner badges reach outside the node box. */
svg {
overflow: visible;
}
svg:not(.safari) {
width: 100%;
height: 100%;
}
circle,
rect,
path.connector {
stroke: var(--stroke-clr);
stroke-width: 2;
fill: none;
}
circle {
path.connector {
stroke: var(--connector-clr, var(--stroke-clr));
stroke-width: 1;
stroke-dasharray: 4 3;
}
:host([track]) path.connector {
stroke: var(--stroke-clr);
stroke-width: 2;
stroke-dasharray: none;
}
rect {
fill: var(--background-clr);
stroke: var(--circle-clr, var(--stroke-clr));
}
@@ -180,7 +276,7 @@ export class HatGraphNode extends LitElement {
stroke-width: 0;
}
.number text {
font-size: var(--ha-font-size-xs);
font-size: var(--ha-font-size-s);
fill: var(--text-primary-color);
}
path.icon {
+9 -2
View File
@@ -38,12 +38,19 @@ export class HatGraphSpacer extends LitElement {
:host([track]) {
--stroke-clr: var(--track-clr);
}
:host([track]) path {
stroke: var(--stroke-clr);
stroke-width: 2;
stroke-dasharray: none;
}
:host-context([disabled]) {
--stroke-clr: var(--disabled-clr);
}
path {
stroke: var(--stroke-clr);
stroke-width: 2;
stroke: var(--connector-clr, var(--stroke-clr));
stroke-width: 1;
stroke-dasharray: 4 3;
stroke-dashoffset: 4px;
fill: none;
}
`;
@@ -56,8 +56,8 @@ import {
ACTION_BUILDING_BLOCKS,
ACTION_COMBINED_BLOCKS,
ACTION_ICONS,
YAML_ONLY_ACTION_TYPES,
getAutomationActionType,
YAML_ONLY_ACTION_TYPES,
} from "../../../../data/action";
import type {
ActionSidebarConfig,
@@ -425,6 +425,16 @@ export default class HaAutomationActionRow extends LitElement {
: nothing
}
</h3>
<ha-automation-row-event-chip
.show=${this.action.enabled === false && !this._running}
slot="event"
variant="neutral"
class="event-chip"
aria-live="polite"
>
${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")}
</ha-automation-row-event-chip>
<ha-automation-row-event-chip
.show=${this._running}
.variant=${this._runResult?.variant}
@@ -732,17 +742,6 @@ export default class HaAutomationActionRow extends LitElement {
return html`
<ha-card outlined>
${
this.action.enabled === false
? html`
<div class="disabled-bar">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.disabled"
)}
</div>
`
: nothing
}
${
this.optionsInSidebar
? html`<ha-automation-row
@@ -223,6 +223,16 @@ export default class HaAutomationConditionRow extends LitElement {
: nothing
}
</ha-automation-condition-summary>
<ha-automation-row-event-chip
.show=${this.condition.enabled === false && !this._testing}
slot="event"
variant="neutral"
class="event-chip"
aria-live="polite"
>
${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")}
</ha-automation-row-event-chip>
<ha-automation-row-event-chip
.show=${this._testing}
.variant=${this._testingResult ? "success" : "warning"}
@@ -506,17 +516,6 @@ export default class HaAutomationConditionRow extends LitElement {
!this._collapsed,
})}
>
${
this.condition.enabled === false
? html`
<div class="disabled-bar">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.disabled"
)}
</div>
`
: nothing
}
${
this.optionsInSidebar
? html`<ha-automation-row
@@ -676,6 +676,7 @@ export class HaAutomationTrace extends LitElement {
}
.graph {
background-color: var(--primary-background-color);
box-sizing: border-box;
display: flex;
flex-direction: column;
-12
View File
@@ -46,18 +46,6 @@ export const rowStyles = css`
ha-card {
transition: outline 0.2s;
}
.disabled-bar {
background: var(--divider-color, #e0e0e0);
text-align: center;
border-top-right-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
border-top-left-radius: var(
--ha-card-border-radius,
var(--ha-border-radius-lg)
);
}
.warning ul {
margin: 4px 0;
}
@@ -173,7 +173,7 @@ export default class HaAutomationTriggerRow extends LitElement {
@query("ha-automation-row")
private _automationRowElement?: HaAutomationRow;
@query("ha-automation-row-event-chip")
@query(".triggered-chip")
private _eventChipElement?: HaAutomationRowEventChip;
@storage({
@@ -345,10 +345,24 @@ export default class HaAutomationTriggerRow extends LitElement {
: nothing
}
</h3>
<ha-automation-row-event-chip
.show=${
"enabled" in this.trigger &&
this.trigger.enabled === false &&
!this._triggered
}
slot="event"
variant="neutral"
class="event-chip"
aria-live="polite"
>
${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")}
</ha-automation-row-event-chip>
<ha-automation-row-event-chip
.show=${this._triggered}
slot="event"
class="event-chip"
class="event-chip triggered-chip"
interactive
aria-live="polite"
@click=${this._showTriggeredInfo}
@@ -622,17 +636,6 @@ export default class HaAutomationTriggerRow extends LitElement {
return html`
<ha-card outlined class=${this._selected ? "selected" : ""}>
${
"enabled" in this.trigger && this.trigger.enabled === false
? html`
<div class="disabled-bar">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.disabled"
)}
</div>
`
: nothing
}
${
this.optionsInSidebar
? html`<ha-automation-row
@@ -652,6 +652,7 @@ export class HaScriptTrace extends LitElement {
}
.graph {
background-color: var(--primary-background-color);
box-sizing: border-box;
display: flex;
flex-direction: column;
+47
View File
@@ -0,0 +1,47 @@
import { expect, it } from "vitest";
import "../../src/components/trace/hat-graph-branch";
it("refreshes reused branch tracking and geometry without replacing the slot", async () => {
const graph = document.createElement("hat-graph-branch");
const branch = document.createElement("div");
let height = 40;
Object.defineProperties(branch, {
clientWidth: { get: () => 50 },
clientHeight: { get: () => height },
});
graph.append(branch);
document.body.append(graph);
try {
await graph.updateComplete;
await graph.updateComplete;
expect(graph._branches[0]).toMatchObject({
height: 40,
track: false,
trackEnd: false,
});
branch.setAttribute("track", "");
branch.setAttribute("unfinished", "");
await Promise.resolve();
await graph.updateComplete;
expect(graph._branches[0]).toMatchObject({ track: true, trackEnd: false });
branch.removeAttribute("unfinished");
height = 80;
branch.append(document.createElement("div"));
await Promise.resolve();
await graph.updateComplete;
expect(graph._branches[0]).toMatchObject({
height: 80,
track: true,
trackEnd: true,
});
branch.removeAttribute("track");
await Promise.resolve();
await graph.updateComplete;
expect(graph._branches[0]).toMatchObject({ track: false, trackEnd: false });
} finally {
graph.remove();
}
});