mirror of
https://github.com/home-assistant/frontend.git
synced 2025-04-24 13:27:22 +00:00
Refactoring automation trace graphs (#8763)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com> Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
parent
20858db96d
commit
e714f32737
2
src/common/string/starts-with.ts
Normal file
2
src/common/string/starts-with.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const strStartsWith = (value: string, search: string) =>
|
||||
value.substring(0, search.length) === search;
|
187
src/components/trace/hat-graph-node.ts
Normal file
187
src/components/trace/hat-graph-node.ts
Normal file
@ -0,0 +1,187 @@
|
||||
import { css, customElement, LitElement, property, svg } from "lit-element";
|
||||
|
||||
import { NODE_SIZE, SPACING } from "./hat-graph";
|
||||
|
||||
@customElement("hat-graph-node")
|
||||
export class HatGraphNode extends LitElement {
|
||||
@property() iconPath?: string;
|
||||
|
||||
@property({ reflect: true, type: Boolean }) disabled?: boolean;
|
||||
|
||||
@property({ reflect: true, type: Boolean }) graphstart?: boolean;
|
||||
|
||||
@property({ reflect: true, type: Boolean }) nofocus?: boolean;
|
||||
|
||||
@property({ reflect: true, type: Number }) badge?: number;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (!this.hasAttribute("tabindex") && !this.nofocus)
|
||||
this.setAttribute("tabindex", "0");
|
||||
}
|
||||
|
||||
updated() {
|
||||
const svgEl = this.shadowRoot?.querySelector("svg");
|
||||
if (!svgEl) {
|
||||
return;
|
||||
}
|
||||
const bbox = svgEl.getBBox();
|
||||
const extra_height = this.graphstart ? 2 : 1;
|
||||
const extra_width = SPACING;
|
||||
svgEl.setAttribute("width", `${bbox.width + extra_width}px`);
|
||||
svgEl.setAttribute("height", `${bbox.height + extra_height}px`);
|
||||
svgEl.setAttribute(
|
||||
"viewBox",
|
||||
`${Math.ceil(bbox.x - extra_width / 2)}
|
||||
${Math.ceil(bbox.y - extra_height / 2)}
|
||||
${bbox.width + extra_width}
|
||||
${bbox.height + extra_height}`
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return svg`
|
||||
<svg
|
||||
>
|
||||
${
|
||||
this.graphstart
|
||||
? ``
|
||||
: svg`
|
||||
<path
|
||||
class="connector"
|
||||
d="
|
||||
M 0 ${-SPACING - NODE_SIZE / 2}
|
||||
L 0 0
|
||||
"
|
||||
line-caps="round"
|
||||
/>
|
||||
`
|
||||
}
|
||||
<g class="node">
|
||||
<circle
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="${NODE_SIZE / 2}"
|
||||
/>
|
||||
${
|
||||
this.badge
|
||||
? svg`
|
||||
<g class="number">
|
||||
<circle
|
||||
cx="8"
|
||||
cy="${-NODE_SIZE / 2}"
|
||||
r="8"
|
||||
></circle>
|
||||
<text
|
||||
x="8"
|
||||
y="${-NODE_SIZE / 2}"
|
||||
text-anchor="middle"
|
||||
alignment-baseline="middle"
|
||||
>${this.badge > 9 ? "9+" : this.badge}</text>
|
||||
</g>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<g
|
||||
style="pointer-events: none"
|
||||
transform="translate(${-12} ${-12})"
|
||||
>
|
||||
${this.iconPath ? svg`<path class="icon" d="${this.iconPath}"/>` : ""}
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--stroke-clr: var(--stroke-color, var(--secondary-text-color));
|
||||
--active-clr: var(--active-color, var(--primary-color));
|
||||
--track-clr: var(--track-color, var(--accent-color));
|
||||
--hover-clr: var(--hover-color, var(--primary-color));
|
||||
--disabled-clr: var(--disabled-color, var(--disabled-text-color));
|
||||
--default-trigger-color: 3, 169, 244;
|
||||
--rgb-trigger-color: var(--trigger-color, var(--default-trigger-color));
|
||||
--background-clr: var(--background-color, white);
|
||||
--default-icon-clr: var(--icon-color, black);
|
||||
--icon-clr: var(--stroke-clr);
|
||||
}
|
||||
:host(.track) {
|
||||
--stroke-clr: var(--track-clr);
|
||||
--icon-clr: var(--default-icon-clr);
|
||||
}
|
||||
:host(.active) circle {
|
||||
--stroke-clr: var(--active-clr);
|
||||
--icon-clr: var(--default-icon-clr);
|
||||
}
|
||||
:host(:focus) {
|
||||
outline: none;
|
||||
}
|
||||
:host(:hover) circle {
|
||||
--stroke-clr: var(--hover-clr);
|
||||
--icon-clr: var(--default-icon-clr);
|
||||
}
|
||||
:host([disabled]) circle {
|
||||
stroke: var(--disabled-clr);
|
||||
}
|
||||
:host-context([disabled]) {
|
||||
--stroke-clr: var(--disabled-clr);
|
||||
}
|
||||
|
||||
:host([nofocus]):host-context(.active),
|
||||
:host([nofocus]):host-context(:focus) {
|
||||
--stroke-clr: var(--active-clr);
|
||||
--icon-clr: var(--default-icon-clr);
|
||||
}
|
||||
|
||||
circle,
|
||||
path.connector {
|
||||
stroke: var(--stroke-clr);
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
circle {
|
||||
fill: var(--background-clr);
|
||||
stroke: var(--circle-clr, var(--stroke-clr));
|
||||
}
|
||||
.number circle {
|
||||
fill: var(--track-clr);
|
||||
stroke: none;
|
||||
stroke-width: 0;
|
||||
}
|
||||
.number text {
|
||||
font-size: smaller;
|
||||
}
|
||||
path.icon {
|
||||
fill: var(--icon-clr);
|
||||
}
|
||||
|
||||
:host(.triggered) svg {
|
||||
overflow: visible;
|
||||
}
|
||||
:host(.triggered) circle {
|
||||
animation: glow 10s;
|
||||
}
|
||||
@keyframes glow {
|
||||
0% {
|
||||
filter: drop-shadow(0px 0px 5px rgba(var(--rgb-trigger-color), 0));
|
||||
}
|
||||
10% {
|
||||
filter: drop-shadow(0px 0px 10px rgba(var(--rgb-trigger-color), 1));
|
||||
}
|
||||
100% {
|
||||
filter: drop-shadow(0px 0px 5px rgba(var(--rgb-trigger-color), 0));
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hat-graph-node": HatGraphNode;
|
||||
}
|
||||
}
|
@ -1,397 +1,218 @@
|
||||
import {
|
||||
LitElement,
|
||||
html,
|
||||
svg,
|
||||
property,
|
||||
customElement,
|
||||
SVGTemplateResult,
|
||||
css,
|
||||
customElement,
|
||||
html,
|
||||
LitElement,
|
||||
property,
|
||||
svg,
|
||||
} from "lit-element";
|
||||
import { classMap } from "lit-html/directives/class-map";
|
||||
|
||||
const SIZE = 35;
|
||||
const DIST = 20;
|
||||
export const BRANCH_HEIGHT = 20;
|
||||
export const SPACING = 10;
|
||||
export const NODE_SIZE = 30;
|
||||
|
||||
type ValueOrArray<T> = T | ValueOrArray<T>[];
|
||||
|
||||
// Return value is undefined if it's an empty array
|
||||
const extractFirstValue = <T>(val: ValueOrArray<T>): T | undefined =>
|
||||
Array.isArray(val) ? extractFirstValue(val[0]) : val;
|
||||
const extractLastValue = <T>(val: ValueOrArray<T>): T | undefined =>
|
||||
Array.isArray(val) ? extractLastValue(val[val.length - 1]) : val;
|
||||
const track_converter = {
|
||||
fromAttribute: (value) => value.split(",").map((v) => parseInt(v)),
|
||||
toAttribute: (value) =>
|
||||
value instanceof Array ? value.join(",") : `${value}`,
|
||||
};
|
||||
|
||||
export interface NodeInfo {
|
||||
path: string;
|
||||
config: any;
|
||||
update?: (conf: any) => void;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
icon: string;
|
||||
number?: number;
|
||||
end?: boolean;
|
||||
nodeInfo?: NodeInfo;
|
||||
children?: Array<ValueOrArray<TreeNode>>;
|
||||
clickCallback?: () => void;
|
||||
addCallback?: () => void;
|
||||
isActive: boolean;
|
||||
isTracked: boolean | undefined;
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export function* bfsIterateTreeNodes(
|
||||
nodeOrNodes: ValueOrArray<TreeNode>
|
||||
): IterableIterator<TreeNode> {
|
||||
if (Array.isArray(nodeOrNodes)) {
|
||||
for (const node of nodeOrNodes) {
|
||||
yield* bfsIterateTreeNodes(node);
|
||||
}
|
||||
return;
|
||||
}
|
||||
yield nodeOrNodes;
|
||||
|
||||
if (nodeOrNodes.children) {
|
||||
yield* bfsIterateTreeNodes(nodeOrNodes.children);
|
||||
}
|
||||
}
|
||||
|
||||
interface RenderedTree {
|
||||
svg: SVGTemplateResult[];
|
||||
width: number;
|
||||
interface BranchConfig {
|
||||
x: number;
|
||||
height: number;
|
||||
// These are the parts rendered before/after this tree
|
||||
previousPartTracked: boolean | undefined;
|
||||
nextPartTracked: boolean | undefined;
|
||||
// These are the parts inside the tree
|
||||
firstNodeTracked: boolean | undefined;
|
||||
lastNodeTracked: boolean | undefined;
|
||||
start: boolean;
|
||||
end: boolean;
|
||||
}
|
||||
|
||||
@customElement("hat-graph")
|
||||
class HatGraph extends LitElement {
|
||||
@property() tree!: TreeNode[];
|
||||
export class HatGraph extends LitElement {
|
||||
@property({ type: Number }) _num_items = 0;
|
||||
|
||||
@property() finishedActive = false;
|
||||
@property({ reflect: true, type: Boolean }) branching?: boolean;
|
||||
|
||||
@property() nodeSize = SIZE;
|
||||
@property({ reflect: true, converter: track_converter })
|
||||
track_start?: number[];
|
||||
|
||||
@property() nodeSeparation = DIST;
|
||||
@property({ reflect: true, converter: track_converter }) track_end?: number[];
|
||||
|
||||
private _draw_node(x: number, y: number, node: TreeNode) {
|
||||
return svg`
|
||||
<circle
|
||||
cx="${x}"
|
||||
cy="${y + this.nodeSize / 2}"
|
||||
r="${this.nodeSize / 2}"
|
||||
class="node ${classMap({
|
||||
active: node.isActive || false,
|
||||
track: node.isTracked || false,
|
||||
new: node.isNew || false,
|
||||
click: !!node.clickCallback,
|
||||
})}"
|
||||
@click=${node.clickCallback}
|
||||
/>
|
||||
<g style="pointer-events: none" transform="translate(${x - 12} ${
|
||||
y + this.nodeSize / 2 - 12
|
||||
})">
|
||||
${node.icon ? svg`<path d="${node.icon}"/>` : ""}
|
||||
${
|
||||
node.number
|
||||
? svg`<g class="number"><circle r="8" cx="27" cy=0></circle>
|
||||
<text x="27" y="1" text-anchor="middle" alignment-baseline="middle">${node.number}</text></g>`
|
||||
: ""
|
||||
}
|
||||
</g>
|
||||
`;
|
||||
}
|
||||
@property({ reflect: true, type: Boolean }) disabled?: boolean;
|
||||
|
||||
private _draw_new_node(x, y, node) {
|
||||
return svg`
|
||||
<circle
|
||||
cx="${x}"
|
||||
cy="${y + this.nodeSize / 4}"
|
||||
r="${this.nodeSize / 4}"
|
||||
class="newnode"
|
||||
@click=${node.addCallback}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
@property({ reflect: true, type: Boolean }) selected?: boolean;
|
||||
|
||||
private _draw_connector(x1, y1, x2, y2, track) {
|
||||
return svg`
|
||||
<line
|
||||
class=${classMap({ track })}
|
||||
x1=${x1}
|
||||
y1=${y1}
|
||||
x2=${x2}
|
||||
y2=${y2}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
@property({ reflect: true, type: Boolean }) short = false;
|
||||
|
||||
private _draw_tree(
|
||||
tree: ValueOrArray<TreeNode>,
|
||||
previousPartTracked: boolean | undefined,
|
||||
nextPartTracked: boolean | undefined
|
||||
): RenderedTree {
|
||||
if (!tree) {
|
||||
return {
|
||||
svg: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
previousPartTracked,
|
||||
nextPartTracked,
|
||||
firstNodeTracked: false,
|
||||
lastNodeTracked: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!Array.isArray(tree)) {
|
||||
return this._draw_tree_single(tree, previousPartTracked, nextPartTracked);
|
||||
}
|
||||
|
||||
if (tree.length === 0) {
|
||||
return {
|
||||
svg: [],
|
||||
width: 0,
|
||||
height: 0,
|
||||
previousPartTracked,
|
||||
nextPartTracked,
|
||||
firstNodeTracked: false,
|
||||
lastNodeTracked: false,
|
||||
};
|
||||
}
|
||||
|
||||
return this._draw_tree_array(tree, previousPartTracked, nextPartTracked);
|
||||
}
|
||||
|
||||
private _draw_tree_single(
|
||||
tree: TreeNode,
|
||||
previousPartTracked: boolean | undefined,
|
||||
nextPartTracked: boolean | undefined
|
||||
): RenderedTree {
|
||||
let height = this.nodeSize;
|
||||
let width = this.nodeSize;
|
||||
const pieces: SVGTemplateResult[] = [];
|
||||
|
||||
let lastNodeTracked = tree.isTracked;
|
||||
|
||||
// These children are drawn in parallel to one another.
|
||||
if (tree.children && tree.children.length > 0) {
|
||||
lastNodeTracked = extractFirstValue(
|
||||
tree.children[tree.children.length - 1]
|
||||
)?.isTracked;
|
||||
|
||||
const childTrees: RenderedTree[] = [];
|
||||
tree.children.forEach((child) => {
|
||||
childTrees.push(
|
||||
this._draw_tree(child, previousPartTracked, nextPartTracked)
|
||||
);
|
||||
});
|
||||
height += childTrees.reduce((a, i) => Math.max(a, i.height), 0);
|
||||
width =
|
||||
childTrees.reduce((a, i) => a + i.width, 0) +
|
||||
this.nodeSeparation * (tree.children.length - 1);
|
||||
const offsets = childTrees.map(
|
||||
((sum) => (value) => {
|
||||
return sum + value.width + this.nodeSeparation;
|
||||
})(0)
|
||||
);
|
||||
|
||||
let bottomConnectors = false;
|
||||
|
||||
let prevOffset = 0;
|
||||
|
||||
for (const [idx, child] of childTrees.entries()) {
|
||||
prevOffset += idx ? offsets[idx - 1] : 0;
|
||||
const x = -width / 2 + prevOffset + child.width / 2;
|
||||
// Draw top connectors
|
||||
pieces.push(
|
||||
this._draw_connector(
|
||||
0,
|
||||
this.nodeSize / 2,
|
||||
x,
|
||||
this.nodeSize + this.nodeSeparation,
|
||||
child.previousPartTracked && child.firstNodeTracked
|
||||
)
|
||||
);
|
||||
|
||||
const endNode = extractLastValue(tree.children[idx])!;
|
||||
|
||||
if (endNode.end !== true) {
|
||||
// Draw bottom fill
|
||||
pieces.push(
|
||||
this._draw_connector(
|
||||
x,
|
||||
this.nodeSeparation + child.height,
|
||||
x,
|
||||
this.nodeSeparation + height,
|
||||
child.lastNodeTracked && child.nextPartTracked
|
||||
)
|
||||
);
|
||||
|
||||
// Draw bottom connectors
|
||||
pieces.push(
|
||||
this._draw_connector(
|
||||
x,
|
||||
this.nodeSeparation + height - 1,
|
||||
0,
|
||||
this.nodeSeparation +
|
||||
height +
|
||||
this.nodeSize / 2 +
|
||||
this.nodeSeparation -
|
||||
1,
|
||||
child.lastNodeTracked && child.nextPartTracked
|
||||
)
|
||||
);
|
||||
bottomConnectors = true;
|
||||
}
|
||||
|
||||
// Draw child tree
|
||||
pieces.push(svg`
|
||||
<g class="a" transform="translate(${x} ${
|
||||
this.nodeSize + this.nodeSeparation
|
||||
})">
|
||||
${child.svg}
|
||||
</g>
|
||||
`);
|
||||
}
|
||||
if (bottomConnectors) {
|
||||
height += this.nodeSize + this.nodeSeparation;
|
||||
}
|
||||
}
|
||||
if (tree.addCallback) {
|
||||
pieces.push(
|
||||
this._draw_connector(
|
||||
0,
|
||||
height,
|
||||
0,
|
||||
height + this.nodeSeparation,
|
||||
tree.isTracked && nextPartTracked
|
||||
)
|
||||
);
|
||||
pieces.push(this._draw_new_node(0, height + this.nodeSeparation, tree));
|
||||
height += this.nodeSeparation + this.nodeSize / 2;
|
||||
}
|
||||
if (tree.end !== true) {
|
||||
// Draw bottom connector
|
||||
pieces.push(
|
||||
this._draw_connector(
|
||||
0,
|
||||
height,
|
||||
0,
|
||||
height + this.nodeSeparation,
|
||||
tree.isTracked && nextPartTracked
|
||||
)
|
||||
);
|
||||
height += this.nodeSeparation;
|
||||
}
|
||||
|
||||
// Draw the node itself
|
||||
pieces.push(this._draw_node(0, 0, tree));
|
||||
|
||||
return {
|
||||
svg: pieces,
|
||||
width,
|
||||
height,
|
||||
previousPartTracked,
|
||||
nextPartTracked,
|
||||
firstNodeTracked: tree.isTracked,
|
||||
lastNodeTracked,
|
||||
};
|
||||
}
|
||||
|
||||
private _draw_tree_array(
|
||||
tree: ValueOrArray<TreeNode>[],
|
||||
previousPartTracked: boolean | undefined,
|
||||
nextPartTracked: boolean | undefined
|
||||
): RenderedTree {
|
||||
const pieces: SVGTemplateResult[] = [];
|
||||
let height = 0;
|
||||
|
||||
// Render each entry while keeping track of the "track" variable.
|
||||
const childTrees: RenderedTree[] = [];
|
||||
let lastChildTracked: boolean | undefined = previousPartTracked;
|
||||
tree.forEach((child, idx) => {
|
||||
const lastNodeTracked = extractLastValue(child)?.isTracked;
|
||||
const nextChildTracked =
|
||||
idx < tree.length - 1
|
||||
? extractFirstValue(tree[idx + 1])?.isTracked
|
||||
: lastNodeTracked && nextPartTracked;
|
||||
childTrees.push(
|
||||
this._draw_tree(child, lastChildTracked, nextChildTracked)
|
||||
);
|
||||
lastChildTracked = lastNodeTracked;
|
||||
});
|
||||
|
||||
const width = childTrees.reduce((a, i) => Math.max(a, i.width), 0);
|
||||
for (const [_, node] of childTrees.entries()) {
|
||||
pieces.push(svg`
|
||||
<g class="b" transform="translate(0, ${height})">
|
||||
${node.svg}
|
||||
</g>
|
||||
`);
|
||||
height += node.height;
|
||||
}
|
||||
|
||||
return {
|
||||
svg: pieces,
|
||||
width,
|
||||
height,
|
||||
previousPartTracked,
|
||||
nextPartTracked,
|
||||
firstNodeTracked: extractFirstValue(tree[0])?.isTracked,
|
||||
lastNodeTracked: extractFirstValue(tree[tree.length - 1])?.isTracked,
|
||||
};
|
||||
async updateChildren() {
|
||||
this._num_items = this.children.length;
|
||||
}
|
||||
|
||||
render() {
|
||||
const tree = this._draw_tree(
|
||||
this.tree,
|
||||
this.tree.length > 0 && this.tree[0].isTracked,
|
||||
this.finishedActive
|
||||
);
|
||||
const branches: BranchConfig[] = [];
|
||||
let total_width = 0;
|
||||
let max_height = 0;
|
||||
let min_height = Number.POSITIVE_INFINITY;
|
||||
if (this.branching) {
|
||||
for (const c of Array.from(this.children)) {
|
||||
if (c.slot === "head") continue;
|
||||
const rect = c.getBoundingClientRect();
|
||||
branches.push({
|
||||
x: rect.width / 2 + total_width,
|
||||
height: rect.height,
|
||||
start: c.getAttribute("graphStart") != null,
|
||||
end: c.getAttribute("graphEnd") != null,
|
||||
});
|
||||
total_width += rect.width;
|
||||
max_height = Math.max(max_height, rect.height);
|
||||
min_height = Math.min(min_height, rect.height);
|
||||
}
|
||||
}
|
||||
|
||||
return html`
|
||||
<svg width=${tree.width + 32} height=${tree.height + 64}>
|
||||
<g transform="translate(${tree.width / 2 + 16} 16)">
|
||||
${tree.svg}
|
||||
</g>
|
||||
</svg>
|
||||
<slot name="head" @slotchange=${this.updateChildren}> </slot>
|
||||
${this.branching
|
||||
? svg`
|
||||
<svg
|
||||
id="top"
|
||||
width="${total_width}"
|
||||
height="${BRANCH_HEIGHT}"
|
||||
>
|
||||
${branches.map((branch, i) => {
|
||||
if (branch.start) return "";
|
||||
return svg`
|
||||
<path
|
||||
class="${classMap({
|
||||
line: true,
|
||||
track: this.track_start?.includes(i) ?? false,
|
||||
})}"
|
||||
id="${this.track_start?.includes(i) ? "track-start" : ""}"
|
||||
index=${i}
|
||||
d="
|
||||
M ${total_width / 2} 0
|
||||
L ${branch.x} ${BRANCH_HEIGHT}
|
||||
"/>
|
||||
`;
|
||||
})}
|
||||
<use xlink:href="#track-start" />
|
||||
</svg>
|
||||
`
|
||||
: ""}
|
||||
<div id="branches">
|
||||
${this.branching
|
||||
? svg`
|
||||
<svg
|
||||
id="lines"
|
||||
width="${total_width}"
|
||||
height="${max_height}"
|
||||
>
|
||||
${branches.map((branch, i) => {
|
||||
if (branch.end) return "";
|
||||
return svg`
|
||||
<path
|
||||
class="${classMap({
|
||||
line: true,
|
||||
track: this.track_end?.includes(i) ?? false,
|
||||
})}"
|
||||
index=${i}
|
||||
d="
|
||||
M ${branch.x} ${branch.height}
|
||||
l 0 ${max_height - branch.height}
|
||||
"/>
|
||||
`;
|
||||
})}
|
||||
</svg>
|
||||
`
|
||||
: ""}
|
||||
<slot @slotchange=${this.updateChildren}></slot>
|
||||
</div>
|
||||
|
||||
${this.branching && !this.short
|
||||
? svg`
|
||||
<svg
|
||||
id="bottom"
|
||||
width="${total_width}"
|
||||
height="${BRANCH_HEIGHT + SPACING}"
|
||||
>
|
||||
${branches.map((branch, i) => {
|
||||
if (branch.end) return "";
|
||||
return svg`
|
||||
<path
|
||||
class="${classMap({
|
||||
line: true,
|
||||
track: this.track_end?.includes(i) ?? false,
|
||||
})}"
|
||||
id="${this.track_end?.includes(i) ? "track-end" : ""}"
|
||||
index=${i}
|
||||
d="
|
||||
M ${branch.x} 0
|
||||
L ${branch.x} ${SPACING}
|
||||
L ${total_width / 2} ${BRANCH_HEIGHT + SPACING}
|
||||
"/>
|
||||
`;
|
||||
})}
|
||||
<use xlink:href="#track-end" />
|
||||
</svg>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
--stroke-clr: var(--stroke-color, var(--secondary-text-color));
|
||||
--active-clr: var(--active-color, var(--primary-color));
|
||||
--hover-clr: var(--hover-color, var(--primary-color));
|
||||
--track-clr: var(--track-color, var(--accent-color));
|
||||
--disabled-clr: var(--disabled-color, gray);
|
||||
}
|
||||
circle,
|
||||
line {
|
||||
:host(:focus) {
|
||||
outline: none;
|
||||
}
|
||||
#branches {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
:host([branching]) #branches {
|
||||
flex-direction: row;
|
||||
align-items: start;
|
||||
}
|
||||
:host([branching]) ::slotted(*) {
|
||||
z-index: 1;
|
||||
}
|
||||
:host([branching]) ::slotted([slot="head"]) {
|
||||
margin-bottom: ${-BRANCH_HEIGHT / 2}px;
|
||||
}
|
||||
|
||||
#lines {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
path.line {
|
||||
stroke: var(--stroke-clr);
|
||||
stroke-width: 3px;
|
||||
fill: white;
|
||||
stroke-width: 2;
|
||||
fill: none;
|
||||
}
|
||||
.click {
|
||||
cursor: pointer;
|
||||
}
|
||||
.click:hover {
|
||||
stroke: var(--hover-clr);
|
||||
}
|
||||
.track {
|
||||
path.line.track {
|
||||
stroke: var(--track-clr);
|
||||
}
|
||||
.active {
|
||||
:host([disabled]) path.line {
|
||||
stroke: var(--disabled-clr);
|
||||
}
|
||||
:host(.active) #top path.line {
|
||||
stroke: var(--active-clr);
|
||||
}
|
||||
.number circle {
|
||||
fill: var(--track-clr);
|
||||
stroke: none;
|
||||
}
|
||||
.number text {
|
||||
font-size: smaller;
|
||||
:host(:focus) #top path.line {
|
||||
stroke: var(--active-clr);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
@ -7,13 +7,47 @@ import {
|
||||
css,
|
||||
} from "lit-element";
|
||||
import "@material/mwc-icon-button/mwc-icon-button";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-svg-icon";
|
||||
import { AutomationTraceExtended } from "../../data/trace";
|
||||
import { bfsIterateTreeNodes, NodeInfo, TreeNode } from "./hat-graph";
|
||||
import { ActionHandler } from "./script-to-graph";
|
||||
import { mdiChevronDown, mdiChevronUp } from "@mdi/js";
|
||||
import {
|
||||
AutomationTraceExtended,
|
||||
ChooseActionTraceStep,
|
||||
ConditionTraceStep,
|
||||
} from "../../data/trace";
|
||||
import {
|
||||
mdiAbTesting,
|
||||
mdiArrowUp,
|
||||
mdiAsterisk,
|
||||
mdiCallSplit,
|
||||
mdiCheckboxBlankOutline,
|
||||
mdiCheckBoxOutline,
|
||||
mdiChevronDown,
|
||||
mdiChevronRight,
|
||||
mdiChevronUp,
|
||||
mdiClose,
|
||||
mdiCodeBrackets,
|
||||
mdiDevices,
|
||||
mdiExclamation,
|
||||
mdiRefresh,
|
||||
mdiTimerOutline,
|
||||
mdiTrafficLight,
|
||||
} from "@mdi/js";
|
||||
import "./hat-graph-node";
|
||||
import { classMap } from "lit-html/directives/class-map";
|
||||
import { NODE_SIZE, SPACING, NodeInfo } from "./hat-graph";
|
||||
import { Condition, Trigger } from "../../data/automation";
|
||||
import {
|
||||
Action,
|
||||
ChooseAction,
|
||||
DelayAction,
|
||||
DeviceAction,
|
||||
EventAction,
|
||||
RepeatAction,
|
||||
SceneAction,
|
||||
ServiceAction,
|
||||
WaitAction,
|
||||
WaitForTriggerAction,
|
||||
} from "../../data/script";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@ -27,27 +61,390 @@ class HatScriptGraph extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public selected;
|
||||
|
||||
private getActionHandler = memoizeOne((trace: AutomationTraceExtended) => {
|
||||
return new ActionHandler(
|
||||
trace.config.action,
|
||||
false,
|
||||
undefined,
|
||||
(nodeInfo) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(nodeInfo);
|
||||
fireEvent(this, "graph-node-selected", nodeInfo);
|
||||
this.requestUpdate();
|
||||
},
|
||||
this.selected,
|
||||
this.trace
|
||||
);
|
||||
});
|
||||
@property() trackedNodes: Record<string, any> = {};
|
||||
|
||||
private selectNode(config, path) {
|
||||
return () => {
|
||||
fireEvent(this, "graph-node-selected", { config, path });
|
||||
};
|
||||
}
|
||||
|
||||
private render_trigger(config: Trigger, i: number) {
|
||||
const path = `trigger/${i}`;
|
||||
const tracked = this.trace && path in this.trace.trace;
|
||||
if (tracked) {
|
||||
this.trackedNodes[path] = { config, path };
|
||||
}
|
||||
return html`
|
||||
<hat-graph-node
|
||||
graphStart
|
||||
@focus=${this.selectNode(config, path)}
|
||||
class=${classMap({
|
||||
track: tracked,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
.iconPath=${mdiAsterisk}
|
||||
tabindex=${tracked ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_condition(config: Condition, i: number) {
|
||||
const path = `condition/${i}`;
|
||||
const trace = this.trace.trace[path] as ConditionTraceStep[] | undefined;
|
||||
const track_path =
|
||||
trace === undefined ? 0 : trace![0].result.result ? 1 : 2;
|
||||
if (trace) {
|
||||
this.trackedNodes[path] = { config, path };
|
||||
}
|
||||
return html`
|
||||
<hat-graph
|
||||
branching
|
||||
@focus=${this.selectNode(config, path)}
|
||||
class=${classMap({
|
||||
track: track_path,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
.track_start=${[track_path]}
|
||||
.track_end=${[track_path]}
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
short
|
||||
>
|
||||
<hat-graph-node
|
||||
slot="head"
|
||||
class=${classMap({
|
||||
track: trace !== undefined,
|
||||
})}
|
||||
.iconPath=${mdiAbTesting}
|
||||
nofocus
|
||||
graphEnd
|
||||
></hat-graph-node>
|
||||
<div
|
||||
style=${`width: ${NODE_SIZE + SPACING}px;`}
|
||||
graphStart
|
||||
graphEnd
|
||||
></div>
|
||||
<div></div>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiClose}
|
||||
graphEnd
|
||||
nofocus
|
||||
class=${classMap({
|
||||
track: track_path === 2,
|
||||
})}
|
||||
></hat-graph-node>
|
||||
</hat-graph>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_choose_node(config: ChooseAction, path: string) {
|
||||
const trace = this.trace.trace[path] as ChooseActionTraceStep[] | undefined;
|
||||
const trace_path = trace
|
||||
? trace[0].result.choice === "default"
|
||||
? [config.choose.length]
|
||||
: [trace[0].result.choice]
|
||||
: [];
|
||||
return html`
|
||||
<hat-graph
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
branching
|
||||
.track_start=${trace_path}
|
||||
.track_end=${trace_path}
|
||||
@focus=${this.selectNode(config, path)}
|
||||
class=${classMap({
|
||||
track: trace !== undefined,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallSplit}
|
||||
class=${classMap({
|
||||
track: trace !== undefined,
|
||||
})}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
|
||||
${config.choose.map((branch, i) => {
|
||||
const branch_path = `${path}/choose/${i}`;
|
||||
return html`
|
||||
<hat-graph>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCheckBoxOutline}
|
||||
nofocus
|
||||
class=${classMap({
|
||||
track: trace !== undefined && trace[0].result.choice === i,
|
||||
})}
|
||||
></hat-graph-node>
|
||||
${branch.sequence.map((action, j) =>
|
||||
this.render_node(action, `${branch_path}/sequence/${j}`)
|
||||
)}
|
||||
</hat-graph>
|
||||
`;
|
||||
})}
|
||||
<hat-graph>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCheckboxBlankOutline}
|
||||
nofocus
|
||||
class=${classMap({
|
||||
track:
|
||||
trace !== undefined && trace[0].result.choice === "default",
|
||||
})}
|
||||
></hat-graph-node>
|
||||
${config.default?.map((action, i) =>
|
||||
this.render_node(action, `${path}/default/${i}`)
|
||||
)}
|
||||
</hat-graph>
|
||||
</hat-graph>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_condition_node(node: Condition, path: string) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const track_path = trace === undefined ? 0 : trace[0].result.result ? 1 : 2;
|
||||
return html`
|
||||
<hat-graph
|
||||
branching
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: track_path,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
.track_start=${[track_path]}
|
||||
.track_end=${[track_path]}
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
short
|
||||
>
|
||||
<hat-graph-node
|
||||
slot="head"
|
||||
class=${classMap({
|
||||
track: trace,
|
||||
})}
|
||||
.iconPath=${mdiAbTesting}
|
||||
nofocus
|
||||
graphEnd
|
||||
></hat-graph-node>
|
||||
<div
|
||||
style=${`width: ${NODE_SIZE + SPACING}px;`}
|
||||
graphStart
|
||||
graphEnd
|
||||
></div>
|
||||
<div></div>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiClose}
|
||||
graphEnd
|
||||
nofocus
|
||||
class=${classMap({
|
||||
track: track_path === 2,
|
||||
})}
|
||||
></hat-graph-node>
|
||||
</hat-graph>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_delay_node(node: DelayAction, path: string) {
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiTimerOutline}
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: path in this.trace.trace,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_device_node(node: DeviceAction, path: string) {
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiDevices}
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: path in this.trace.trace,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_event_node(node: EventAction, path: string) {
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiExclamation}
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: path in this.trace.trace,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_repeat_node(node: RepeatAction, path: string) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const track_path = trace ? [0, 1] : [];
|
||||
const repeats = this.trace?.trace[`${path}/repeat/sequence/0`]?.length;
|
||||
return html`
|
||||
<hat-graph
|
||||
.track_start=${track_path}
|
||||
.track_end=${track_path}
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
branching
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: path in this.trace.trace,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiRefresh}
|
||||
class=${classMap({
|
||||
track: trace,
|
||||
})}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiArrowUp}
|
||||
nofocus
|
||||
class=${classMap({
|
||||
track: track_path.includes(1),
|
||||
})}
|
||||
.badge=${repeats}
|
||||
></hat-graph-node>
|
||||
<hat-graph>
|
||||
${node.repeat.sequence.map((action, i) =>
|
||||
this.render_node(action, `${path}/repeat/sequence/${i}`)
|
||||
)}
|
||||
</hat-graph>
|
||||
</hat-graph>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_scene_node(node: SceneAction, path: string) {
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiExclamation}
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: path in this.trace.trace,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_service_node(node: ServiceAction, path: string) {
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiChevronRight}
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: path in this.trace.trace,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_wait_node(
|
||||
node: WaitAction | WaitForTriggerAction,
|
||||
path: string
|
||||
) {
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiTrafficLight}
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: path in this.trace.trace,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_other_node(node: Action, path: string) {
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCodeBrackets}
|
||||
@focus=${this.selectNode(node, path)}
|
||||
class=${classMap({
|
||||
track: path in this.trace.trace,
|
||||
active: this.selected === path,
|
||||
})}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private render_node(node: Action, path: string) {
|
||||
const NODE_TYPES = {
|
||||
choose: this.render_choose_node,
|
||||
condition: this.render_condition_node,
|
||||
delay: this.render_delay_node,
|
||||
device_id: this.render_device_node,
|
||||
event: this.render_event_node,
|
||||
repeat: this.render_repeat_node,
|
||||
scene: this.render_scene_node,
|
||||
service: this.render_service_node,
|
||||
wait_template: this.render_wait_node,
|
||||
wait_for_trigger: this.render_wait_node,
|
||||
other: this.render_other_node,
|
||||
};
|
||||
|
||||
const type = Object.keys(NODE_TYPES).find((key) => key in node) || "other";
|
||||
const nodeEl = NODE_TYPES[type].bind(this)(node, path);
|
||||
if (this.trace && path in this.trace.trace) {
|
||||
this.trackedNodes[path] = { config: node, path };
|
||||
}
|
||||
return nodeEl;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const actionHandler = this.getActionHandler(this.trace);
|
||||
const paths = Object.keys(this.getTrackedNodes());
|
||||
const paths = Object.keys(this.trackedNodes);
|
||||
|
||||
const manual_triggered = this.trace && "trigger" in this.trace.trace;
|
||||
let track_path = manual_triggered ? undefined : [0];
|
||||
const trigger_nodes = (Array.isArray(this.trace.config.trigger)
|
||||
? this.trace.config.trigger
|
||||
: [this.trace.config.trigger]
|
||||
).map((trigger, i) => {
|
||||
if (this.trace && `trigger/${i}` in this.trace.trace) {
|
||||
track_path = [i];
|
||||
}
|
||||
return this.render_trigger(trigger, i);
|
||||
});
|
||||
|
||||
return html`
|
||||
<hat-graph .tree=${actionHandler.createGraph()}></hat-graph>
|
||||
<hat-graph class="parent">
|
||||
<div></div>
|
||||
<hat-graph
|
||||
branching
|
||||
id="trigger"
|
||||
.short=${trigger_nodes.length < 2}
|
||||
.track_start=${track_path}
|
||||
.track_end=${track_path}
|
||||
>
|
||||
${trigger_nodes}
|
||||
</hat-graph>
|
||||
<hat-graph id="condition">
|
||||
${(!this.trace.config.condition ||
|
||||
Array.isArray(this.trace.config.condition)
|
||||
? this.trace.config.condition
|
||||
: [this.trace.config.condition]
|
||||
)?.map((condition, i) => this.render_condition(condition, i))}
|
||||
</hat-graph>
|
||||
${(Array.isArray(this.trace.config.action)
|
||||
? this.trace.config.action
|
||||
: [this.trace.config.action]
|
||||
).map((action, i) => this.render_node(action, `action/${i}`))}
|
||||
</hat-graph>
|
||||
<div class="actions">
|
||||
<mwc-icon-button
|
||||
.disabled=${paths.length === 0 || paths[0] === this.selected}
|
||||
@ -66,19 +463,11 @@ class HatScriptGraph extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public selectPath(path: string) {
|
||||
const actionHandler = this.getActionHandler(this.trace!);
|
||||
let selected: NodeInfo | undefined;
|
||||
|
||||
for (const node of actionHandler.createGraph()) {
|
||||
if (node.nodeInfo?.path === path) {
|
||||
selected = node.nodeInfo;
|
||||
break;
|
||||
}
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
if (changedProps.has("trace")) {
|
||||
this.trackedNodes = {};
|
||||
}
|
||||
|
||||
actionHandler.selected = selected?.path || "";
|
||||
this.requestUpdate();
|
||||
super.update(changedProps);
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
@ -93,22 +482,29 @@ class HatScriptGraph extends LitElement {
|
||||
if (this.selected === "" || !(this.selected in paths)) {
|
||||
// Find first tracked node with node info
|
||||
for (const path of paths) {
|
||||
if (tracked[path].nodeInfo) {
|
||||
fireEvent(this, "graph-node-selected", tracked[path].nodeInfo);
|
||||
if (tracked[path]) {
|
||||
fireEvent(this, "graph-node-selected", tracked[path]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("selected")) {
|
||||
this.getActionHandler(this.trace).selected = this.selected;
|
||||
this.requestUpdate();
|
||||
if (this.trace) {
|
||||
const sortKeys = Object.keys(this.trace.trace);
|
||||
const keys = Object.keys(this.trackedNodes).sort(
|
||||
(a, b) => sortKeys.indexOf(a) - sortKeys.indexOf(b)
|
||||
);
|
||||
const sortedTrackedNodes = keys.reduce((obj, key) => {
|
||||
obj[key] = this.trackedNodes[key];
|
||||
return obj;
|
||||
}, {});
|
||||
this.trackedNodes = sortedTrackedNodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getTrackedNodes() {
|
||||
return this._getTrackedNodes(this.trace);
|
||||
return this.trackedNodes;
|
||||
}
|
||||
|
||||
public previousTrackedNode() {
|
||||
@ -116,8 +512,8 @@ class HatScriptGraph extends LitElement {
|
||||
const nodes = Object.keys(tracked);
|
||||
|
||||
for (let i = nodes.indexOf(this.selected) - 1; i >= 0; i--) {
|
||||
if (tracked[nodes[i]].nodeInfo) {
|
||||
fireEvent(this, "graph-node-selected", tracked[nodes[i]].nodeInfo);
|
||||
if (tracked[nodes[i]]) {
|
||||
fireEvent(this, "graph-node-selected", tracked[nodes[i]]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -127,25 +523,13 @@ class HatScriptGraph extends LitElement {
|
||||
const tracked = this.getTrackedNodes();
|
||||
const nodes = Object.keys(tracked);
|
||||
for (let i = nodes.indexOf(this.selected) + 1; i < nodes.length; i++) {
|
||||
if (tracked[nodes[i]].nodeInfo) {
|
||||
fireEvent(this, "graph-node-selected", tracked[nodes[i]].nodeInfo);
|
||||
if (tracked[nodes[i]]) {
|
||||
fireEvent(this, "graph-node-selected", tracked[nodes[i]]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _getTrackedNodes = memoizeOne((trace) => {
|
||||
const tracked: Record<string, TreeNode> = {};
|
||||
for (const node of bfsIterateTreeNodes(
|
||||
this.getActionHandler(trace).createGraph()
|
||||
)) {
|
||||
if (node.isTracked && node.nodeInfo) {
|
||||
tracked[node.nodeInfo.path] = node;
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
});
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
@ -155,6 +539,9 @@ class HatScriptGraph extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.parent {
|
||||
margin-left: 8px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import {
|
||||
ChooseActionTraceStep,
|
||||
getDataFromPath,
|
||||
TriggerTraceStep,
|
||||
isTriggerPath,
|
||||
} from "../../data/trace";
|
||||
import { HomeAssistant } from "../../types";
|
||||
import "./ha-timeline";
|
||||
@ -218,7 +219,7 @@ class ActionRenderer {
|
||||
): number {
|
||||
const value = this._getItem(index);
|
||||
|
||||
if (value[0].path === "trigger") {
|
||||
if (isTriggerPath(value[0].path)) {
|
||||
return this._handleTrigger(index, value[0] as TriggerTraceStep);
|
||||
}
|
||||
|
||||
@ -267,9 +268,12 @@ class ActionRenderer {
|
||||
|
||||
private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number {
|
||||
this._renderEntry(
|
||||
"trigger",
|
||||
`Triggered by the
|
||||
${triggerStep.changed_variables.trigger.description} at
|
||||
triggerStep.path,
|
||||
`Triggered ${
|
||||
triggerStep.path === "trigger"
|
||||
? "manually"
|
||||
: `by the ${triggerStep.changed_variables.trigger.description}`
|
||||
} at
|
||||
${formatDateTimeWithSeconds(
|
||||
new Date(triggerStep.timestamp),
|
||||
this.hass.locale
|
||||
|
@ -1,443 +0,0 @@
|
||||
import {
|
||||
mdiCallSplit,
|
||||
mdiAbTesting,
|
||||
mdiCheck,
|
||||
mdiClose,
|
||||
mdiChevronRight,
|
||||
mdiExclamation,
|
||||
mdiTimerOutline,
|
||||
mdiTrafficLight,
|
||||
mdiRefresh,
|
||||
mdiArrowUp,
|
||||
mdiCodeJson,
|
||||
mdiCheckBoxOutline,
|
||||
mdiCheckboxBlankOutline,
|
||||
mdiAsterisk,
|
||||
mdiDevices,
|
||||
mdiFlare,
|
||||
} from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { Condition } from "../../data/automation";
|
||||
import { Action, ChooseAction, RepeatAction } from "../../data/script";
|
||||
import {
|
||||
AutomationTraceExtended,
|
||||
ChooseActionTraceStep,
|
||||
ChooseChoiceActionTraceStep,
|
||||
ConditionTraceStep,
|
||||
} from "../../data/trace";
|
||||
|
||||
import { NodeInfo, TreeNode } from "./hat-graph";
|
||||
|
||||
const ICONS = {
|
||||
new: mdiAsterisk,
|
||||
device_id: mdiDevices,
|
||||
service: mdiChevronRight,
|
||||
condition: mdiAbTesting,
|
||||
TRUE: mdiCheck,
|
||||
FALSE: mdiClose,
|
||||
delay: mdiTimerOutline,
|
||||
wait_template: mdiTrafficLight,
|
||||
event: mdiExclamation,
|
||||
repeat: mdiRefresh,
|
||||
repeatReturn: mdiArrowUp,
|
||||
choose: mdiCallSplit,
|
||||
chooseChoice: mdiCheckBoxOutline,
|
||||
chooseDefault: mdiCheckboxBlankOutline,
|
||||
YAML: mdiCodeJson,
|
||||
};
|
||||
|
||||
const OPTIONS = [
|
||||
"condition",
|
||||
"delay",
|
||||
"device_id",
|
||||
"event",
|
||||
"scene",
|
||||
"service",
|
||||
"wait_template",
|
||||
"repeat",
|
||||
"choose",
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||
interface NoAction {}
|
||||
|
||||
// const cmpLists = (a: Array<unknown>, b: Array<unknown>) =>
|
||||
// a.length === b.length && a.every((itm, idx) => b[idx] === itm);
|
||||
|
||||
const TRACE_ACTION_PREFIX = "action/";
|
||||
|
||||
export class ActionHandler {
|
||||
public pathPrefix: string;
|
||||
|
||||
constructor(
|
||||
public actions: Array<Action | NoAction>,
|
||||
/**
|
||||
* Do we allow adding new nodes
|
||||
*/
|
||||
private allowAdd: boolean,
|
||||
/**
|
||||
* Called when the data has changed
|
||||
*/
|
||||
private updateCallback?: (actions: ActionHandler["actions"]) => void,
|
||||
/**
|
||||
* Called when a node is clicked.
|
||||
*/
|
||||
private selectCallback?: (params: NodeInfo) => void,
|
||||
|
||||
public selected: string = "",
|
||||
|
||||
public trace?: AutomationTraceExtended,
|
||||
|
||||
pathPrefix?: string,
|
||||
|
||||
public end?: boolean
|
||||
) {
|
||||
if (pathPrefix !== undefined) {
|
||||
this.pathPrefix = pathPrefix;
|
||||
} else if (this.trace) {
|
||||
this.pathPrefix = TRACE_ACTION_PREFIX;
|
||||
} else {
|
||||
this.pathPrefix = "";
|
||||
}
|
||||
}
|
||||
|
||||
createGraph(): TreeNode[] {
|
||||
return this._createGraph(this.actions, this.selected, this.trace);
|
||||
}
|
||||
|
||||
_createGraph = memoizeOne((_actions, _selected, _trace) =>
|
||||
this._renderTraceHead().concat(
|
||||
this.actions.map((action, idx) =>
|
||||
this._createTreeNode(
|
||||
idx,
|
||||
action,
|
||||
this.actions.length === idx + 1 &&
|
||||
(this.end === undefined || this.end === true)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
_renderTraceHead(): TreeNode[] {
|
||||
if (this.pathPrefix !== TRACE_ACTION_PREFIX) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const triggerNodeInfo = {
|
||||
path: "trigger",
|
||||
// Just all triggers for now.
|
||||
config: this.trace!.config.trigger,
|
||||
};
|
||||
|
||||
const nodes: TreeNode[] = [
|
||||
{
|
||||
icon: mdiFlare,
|
||||
nodeInfo: triggerNodeInfo,
|
||||
clickCallback: () => {
|
||||
this._selectNode(triggerNodeInfo);
|
||||
},
|
||||
isActive: this.selected === "trigger",
|
||||
isTracked: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (this.trace!.config.condition) {
|
||||
this.trace!.config.condition.forEach((condition, idx) =>
|
||||
nodes.push(
|
||||
this._createConditionNode(
|
||||
"condition/",
|
||||
idx,
|
||||
condition,
|
||||
!this.actions.length &&
|
||||
this.trace!.config.condition!.length === idx + 1
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
_updateAction(idx: number, action) {
|
||||
if (action === null) {
|
||||
this.actions.splice(idx, 1);
|
||||
} else {
|
||||
this.actions[idx] = action;
|
||||
}
|
||||
if (this.updateCallback) this.updateCallback(this.actions);
|
||||
}
|
||||
|
||||
_addAction(idx: number) {
|
||||
this.actions.splice(idx, 0, {});
|
||||
if (this.updateCallback) {
|
||||
this.updateCallback(this.actions);
|
||||
}
|
||||
this._selectNode({
|
||||
path: `${this.pathPrefix}${idx}`,
|
||||
config: {},
|
||||
update: (a) => this._updateAction(idx, a),
|
||||
});
|
||||
}
|
||||
|
||||
_selectNode(nodeInfo: NodeInfo) {
|
||||
this.selected = nodeInfo.path;
|
||||
if (this.selectCallback) {
|
||||
this.selectCallback(nodeInfo);
|
||||
}
|
||||
}
|
||||
|
||||
_createTreeNode(idx: number, action, end: boolean): TreeNode {
|
||||
let _type = "yaml";
|
||||
|
||||
if (Object.keys(action).length === 0) {
|
||||
_type = "new";
|
||||
} else {
|
||||
_type = OPTIONS.find((option) => option in action) || "YAML";
|
||||
}
|
||||
|
||||
let node: TreeNode;
|
||||
|
||||
if (_type in this.SPECIAL) {
|
||||
node = this.SPECIAL[_type](idx, action, end);
|
||||
} else {
|
||||
const path = `${this.pathPrefix}${idx}`;
|
||||
const nodeInfo: NodeInfo = {
|
||||
path,
|
||||
config: action,
|
||||
update: (a) => this._updateAction(idx, a),
|
||||
};
|
||||
node = {
|
||||
icon: ICONS[_type],
|
||||
nodeInfo,
|
||||
clickCallback: () => {
|
||||
this._selectNode(nodeInfo);
|
||||
},
|
||||
isActive: path === this.selected,
|
||||
isTracked: this.trace && path in this.trace.trace,
|
||||
end,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.allowAdd) {
|
||||
node.addCallback = () => this._addAction(idx + 1);
|
||||
}
|
||||
if (_type === "new") {
|
||||
node.isNew = true;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
SPECIAL: Record<
|
||||
string,
|
||||
(idx: number, action: any, end: boolean) => TreeNode
|
||||
> = {
|
||||
condition: (idx, action: Condition, end: boolean): TreeNode =>
|
||||
this._createConditionNode(this.pathPrefix, idx, action, end),
|
||||
|
||||
repeat: (idx, action: RepeatAction, end: boolean): TreeNode => {
|
||||
let seq: Array<Action | NoAction> = action.repeat.sequence;
|
||||
if (!seq || !seq.length) {
|
||||
seq = [{}];
|
||||
}
|
||||
|
||||
const path = `${this.pathPrefix}${idx}`;
|
||||
const isTracked = this.trace && path in this.trace.trace;
|
||||
|
||||
const repeats =
|
||||
this.trace && this.trace.trace[`${path}/repeat/sequence/0`]?.length;
|
||||
|
||||
const nodeInfo: NodeInfo = {
|
||||
path,
|
||||
config: action,
|
||||
update: (conf) => this._updateAction(idx, conf),
|
||||
};
|
||||
return {
|
||||
icon: ICONS.repeat,
|
||||
number: repeats,
|
||||
nodeInfo,
|
||||
clickCallback: () => this._selectNode(nodeInfo),
|
||||
isActive: path === this.selected,
|
||||
isTracked,
|
||||
end,
|
||||
children: [
|
||||
new ActionHandler(
|
||||
seq,
|
||||
this.allowAdd,
|
||||
(a) => {
|
||||
action.repeat.sequence = a as Action[];
|
||||
this._updateAction(idx, action);
|
||||
},
|
||||
(params) => this._selectNode(params),
|
||||
this.selected,
|
||||
this.trace,
|
||||
`${path}/repeat/sequence/`,
|
||||
end
|
||||
).createGraph(),
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
choose: (idx, action: ChooseAction, end: boolean): TreeNode => {
|
||||
const choosePath = `${this.pathPrefix}${idx}`;
|
||||
let choice: number | "default" | undefined;
|
||||
|
||||
if (this.trace?.trace && choosePath in this.trace.trace) {
|
||||
const chooseResult = this.trace.trace[
|
||||
choosePath
|
||||
] as ChooseActionTraceStep[];
|
||||
choice = chooseResult[0].result.choice;
|
||||
}
|
||||
|
||||
const children = action.choose.map(
|
||||
(b, choiceIdx): NonNullable<TreeNode["children"]> => {
|
||||
// If we have a trace, highlight the chosen track here.
|
||||
const choicePath = `${this.pathPrefix}${idx}/choose/${choiceIdx}`;
|
||||
let chosen = false;
|
||||
if (this.trace && choicePath in this.trace.trace) {
|
||||
const choiceResult = this.trace.trace[
|
||||
choicePath
|
||||
] as ChooseChoiceActionTraceStep[];
|
||||
chosen = choiceResult[0].result.result;
|
||||
}
|
||||
const choiceNodeInfo: NodeInfo = {
|
||||
path: choicePath,
|
||||
config: b,
|
||||
update: (conf) => {
|
||||
action.choose[choiceIdx] = conf;
|
||||
this._updateAction(idx, action);
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
{
|
||||
icon: ICONS.chooseChoice,
|
||||
nodeInfo: choiceNodeInfo,
|
||||
clickCallback: () => this._selectNode(choiceNodeInfo),
|
||||
isActive: choicePath === this.selected,
|
||||
isTracked: chosen,
|
||||
},
|
||||
new ActionHandler(
|
||||
b.sequence || [{}],
|
||||
this.allowAdd,
|
||||
(actions) => {
|
||||
b.sequence = actions as Action[];
|
||||
action.choose[choiceIdx] = b;
|
||||
this._updateAction(idx, action);
|
||||
},
|
||||
(params) => {
|
||||
this._selectNode(params);
|
||||
},
|
||||
this.selected,
|
||||
this.trace,
|
||||
`${this.pathPrefix}${idx}/choose/${choiceIdx}/sequence/`,
|
||||
!chosen || end
|
||||
).createGraph(),
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
if (action.default || this.allowAdd) {
|
||||
const defaultConfig = action.default || [{}];
|
||||
|
||||
const updateDefault = (actions) => {
|
||||
action.default = actions as Action[];
|
||||
this._updateAction(idx, action);
|
||||
};
|
||||
|
||||
const defaultPath = `${this.pathPrefix}${idx}/default`;
|
||||
|
||||
const defaultNodeInfo: NodeInfo = {
|
||||
path: defaultPath,
|
||||
config: defaultConfig,
|
||||
update: updateDefault,
|
||||
};
|
||||
|
||||
children.push([
|
||||
{
|
||||
icon: ICONS.chooseDefault,
|
||||
nodeInfo: defaultNodeInfo,
|
||||
clickCallback: () => this._selectNode(defaultNodeInfo),
|
||||
isActive: defaultPath === this.selected,
|
||||
isTracked: choice === "default",
|
||||
},
|
||||
new ActionHandler(
|
||||
defaultConfig,
|
||||
this.allowAdd,
|
||||
updateDefault,
|
||||
(params) => this._selectNode(params),
|
||||
this.selected,
|
||||
this.trace,
|
||||
`${this.pathPrefix}${idx}/default/`,
|
||||
choice !== "default" || end
|
||||
).createGraph(),
|
||||
]);
|
||||
}
|
||||
|
||||
const chooseNodeInfo: NodeInfo = {
|
||||
path: choosePath,
|
||||
config: action,
|
||||
update: (conf) => this._updateAction(idx, conf),
|
||||
};
|
||||
|
||||
return {
|
||||
icon: ICONS.choose,
|
||||
nodeInfo: chooseNodeInfo,
|
||||
end,
|
||||
clickCallback: () => this._selectNode(chooseNodeInfo),
|
||||
isActive: choosePath === this.selected,
|
||||
isTracked: choice !== undefined,
|
||||
children,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
private _createConditionNode(
|
||||
pathPrefix: string,
|
||||
idx: number,
|
||||
action: Condition,
|
||||
end: boolean
|
||||
): TreeNode {
|
||||
const path = `${pathPrefix}${idx}`;
|
||||
let result: boolean | undefined;
|
||||
let isTracked = false;
|
||||
|
||||
if (this.trace && path in this.trace.trace) {
|
||||
const conditionResult = this.trace.trace[path] as ConditionTraceStep[];
|
||||
result = conditionResult[0].result.result;
|
||||
isTracked = true;
|
||||
}
|
||||
|
||||
const nodeInfo: NodeInfo = {
|
||||
path,
|
||||
config: action,
|
||||
update: (conf) => this._updateAction(idx, conf),
|
||||
};
|
||||
|
||||
const isActive = path === this.selected;
|
||||
|
||||
return {
|
||||
icon: ICONS.condition,
|
||||
nodeInfo,
|
||||
clickCallback: () => this._selectNode(nodeInfo),
|
||||
isActive,
|
||||
isTracked,
|
||||
end,
|
||||
children: [
|
||||
{
|
||||
icon: ICONS.TRUE,
|
||||
clickCallback: () => this._selectNode(nodeInfo),
|
||||
isActive,
|
||||
isTracked: result === true,
|
||||
},
|
||||
{
|
||||
icon: ICONS.FALSE,
|
||||
clickCallback: () => this._selectNode(nodeInfo),
|
||||
isActive,
|
||||
isTracked: result === false,
|
||||
end: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import { strStartsWith } from "../common/string/starts-with";
|
||||
import { HomeAssistant, Context } from "../types";
|
||||
import { AutomationConfig } from "./automation";
|
||||
|
||||
@ -146,3 +147,11 @@ export const getDataFromPath = (
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// It is 'trigger' if manually triggered by the user via UI
|
||||
export const isTriggerPath = (path: string): boolean =>
|
||||
path === "trigger" || strStartsWith(path, "trigger/");
|
||||
|
||||
export const getTriggerPathFromTrace = (
|
||||
steps: Record<string, BaseTraceStep[]>
|
||||
): string | undefined => Object.keys(steps).find((path) => isTriggerPath(path));
|
||||
|
@ -17,10 +17,7 @@ import {
|
||||
} from "../../../../data/trace";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-code-editor";
|
||||
import type {
|
||||
NodeInfo,
|
||||
TreeNode,
|
||||
} from "../../../../components/trace/hat-graph";
|
||||
import type { NodeInfo } from "../../../../components/trace/hat-graph";
|
||||
import { HomeAssistant } from "../../../../types";
|
||||
import { formatDateTimeWithSeconds } from "../../../../common/datetime/format_date_time";
|
||||
import { LogbookEntry } from "../../../../data/logbook";
|
||||
@ -39,7 +36,7 @@ export class HaAutomationTracePathDetails extends LitElement {
|
||||
|
||||
@property() public logbookEntries!: LogbookEntry[];
|
||||
|
||||
@property() public trackedNodes!: Record<string, TreeNode>;
|
||||
@property() public trackedNodes!: Record<string, any>;
|
||||
|
||||
@internalProperty() private _view:
|
||||
| "config"
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
import { AutomationTraceExtended } from "../../../../data/trace";
|
||||
import { HomeAssistant } from "../../../../types";
|
||||
import { LogbookEntry } from "../../../../data/logbook";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/trace/hat-trace-timeline";
|
||||
import { NodeInfo } from "../../../../components/trace/hat-graph";
|
||||
|
||||
@ -32,16 +31,11 @@ export class HaAutomationTraceTimeline extends LitElement {
|
||||
.logbookEntries=${this.logbookEntries}
|
||||
.selectedPath=${this.selected.path}
|
||||
allowPick
|
||||
@value-changed=${this._timelinePathPicked}
|
||||
>
|
||||
</hat-trace-timeline>
|
||||
`;
|
||||
}
|
||||
|
||||
private _timelinePathPicked(ev) {
|
||||
fireEvent(this, "value-changed", ev.detail);
|
||||
}
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [
|
||||
css`
|
||||
|
@ -384,7 +384,9 @@ export class HaAutomationTrace extends LitElement {
|
||||
const nodes = this.shadowRoot!.querySelector(
|
||||
"hat-script-graph"
|
||||
)!.getTrackedNodes();
|
||||
this._selected = nodes[path]?.nodeInfo;
|
||||
if (nodes[path]) {
|
||||
this._selected = nodes[path];
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
|
Loading…
x
Reference in New Issue
Block a user