Initial commit. POC

This commit is contained in:
Thomas Lovén 2020-08-21 08:41:38 +02:00
commit d039c391e7
8 changed files with 652 additions and 0 deletions

1
.giattributes Normal file
View File

@ -0,0 +1 @@
package-lock.json binary

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
index.js

48
example.html Normal file
View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
<head>
<title>Script graph demo</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/js-yaml/3.14.0/js-yaml.min.js"></script>
<script src="index.js" type="module"></script>
</head>
<body style="margin: 24px">
<style>
div.card {
display: inline-block;
box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2),
0px 1px 1px 0px rgba(0, 0, 0, 0.14),
0px 1px 3px 0px rgba(0, 0, 0, 0.12);
padding: 16px;
font-family: Roboto, Noto, sans-serif;
font-size: 14px;
}
code {
display: block;
white-space: pre-wrap;
}
#graph {
max-width: 400px;
overflow: auto;
}
#code, #fullcode {
max-height: 800px;
width: 400px;
overflow: auto;
vertical-align: top;
}
</style>
<div>
<div class="card" id="graph">
</div>
<div class="card" id="code">
<code>
</code>
</div>
<div class="card" id="fullcode">
<wc-codemirror mode="yaml">
</wc-codemirror>
<button id="updateButton">Update</button>
</div>
</div>
</body>
</html>

138
package-lock.json generated Normal file
View File

@ -0,0 +1,138 @@
{
"name": "script-graph",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@mdi/js": {
"version": "5.5.55",
"resolved": "https://registry.npmjs.org/@mdi/js/-/js-5.5.55.tgz",
"integrity": "sha512-vbw1QW3M9A4vObU9WmTETTG7n7feC9HSn/3up8ZYk/M3K9fGL9FPtw0+bdASRu1UOBgEsqC9eBhNW10IOcwMIg=="
},
"@rollup/plugin-node-resolve": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-9.0.0.tgz",
"integrity": "sha512-gPz+utFHLRrd41WMP13Jq5mqqzHL3OXrfj3/MkSyB6UBIcuNt9j60GCbarzMzdf1VHFpOxfQh/ez7wyadLMqkg==",
"dev": true,
"requires": {
"@rollup/pluginutils": "^3.1.0",
"@types/resolve": "1.17.1",
"builtin-modules": "^3.1.0",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.17.0"
}
},
"@rollup/pluginutils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz",
"integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==",
"dev": true,
"requires": {
"@types/estree": "0.0.39",
"estree-walker": "^1.0.1",
"picomatch": "^2.2.2"
}
},
"@types/estree": {
"version": "0.0.39",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
"integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==",
"dev": true
},
"@types/node": {
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.0.tgz",
"integrity": "sha512-mikldZQitV94akrc4sCcSjtJfsTKt4p+e/s0AGscVA6XArQ9kFclP+ZiYUMnq987rc6QlYxXv/EivqlfSLxpKA==",
"dev": true
},
"@types/resolve": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz",
"integrity": "sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@vanillawc/wc-codemirror": {
"version": "1.8.10",
"resolved": "https://registry.npmjs.org/@vanillawc/wc-codemirror/-/wc-codemirror-1.8.10.tgz",
"integrity": "sha512-UKMD/UOpF1uRl29nlwvwQqSMBqsl+uDWYlGx82wIYbIBJkeqPrSo1Ez1rGi9jc1CL7/XwUr7u+l/kTwZAEkrEg=="
},
"builtin-modules": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.1.0.tgz",
"integrity": "sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==",
"dev": true
},
"deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
"dev": true
},
"estree-walker": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz",
"integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==",
"dev": true
},
"fsevents": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.1.3.tgz",
"integrity": "sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==",
"dev": true,
"optional": true
},
"is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha1-Mlj7afeMFNW4FdZkM2tM/7ZEFZE=",
"dev": true
},
"lit-element": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.3.1.tgz",
"integrity": "sha512-tOcUAmeO3BzwiQ7FGWdsshNvC0HVHcTFYw/TLIImmKwXYoV0E7zCBASa8IJ7DiP4cen/Yoj454gS0qqTnIGsFA==",
"requires": {
"lit-html": "^1.1.1"
}
},
"lit-html": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.2.1.tgz",
"integrity": "sha512-GSJHHXMGLZDzTRq59IUfL9FCdAlGfqNp/dEa7k7aBaaWD+JKaCjsAk9KYm2V12ItonVaYx2dprN66Zdm1AuBTQ=="
},
"path-parse": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==",
"dev": true
},
"picomatch": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.2.2.tgz",
"integrity": "sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==",
"dev": true
},
"resolve": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz",
"integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==",
"dev": true,
"requires": {
"path-parse": "^1.0.6"
}
},
"rollup": {
"version": "2.26.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.26.4.tgz",
"integrity": "sha512-6+qsGuP0MXGd7vlYmk72utm1MrgZj5GfXibGL+cRkKQ9+ZL/BnFThDl0D5bcl7AqlzMjAQXRAwZX1HVm22M/4Q==",
"dev": true,
"requires": {
"fsevents": "~2.1.2"
}
}
}
}

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "script-graph",
"private": true,
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w"
},
"author": "",
"license": "ISC",
"devDependencies": {
"@rollup/plugin-node-resolve": "^9.0.0",
"rollup": "^2.26.4"
},
"dependencies": {
"@mdi/js": "^5.5.55",
"@vanillawc/wc-codemirror": "^1.8.10",
"lit-element": "^2.3.1"
}
}

12
rollup.config.js Normal file
View File

@ -0,0 +1,12 @@
import resolve from "@rollup/plugin-node-resolve";
export default {
input: "./src/main.js",
output: [
{
file: "index.js",
format: "cjs"
}
],
plugins: [resolve()]
};

63
src/demo-config.js Normal file
View File

@ -0,0 +1,63 @@
export const demoConfig = [
{ service: "light.turn_on",
data: {
entity_id: "group.bedroom",
brightness: 100,
},
},
{ condition: "state",
entity_id: "device_tracker.paulus",
state: "home",
},
{ delay: "01:00",
},
{ wait_template: "{{ is_state(media_player.floor', 'stop') }}",
},
{ event: "LOGBOOK_ENTRY",
event_data: {
name: "Paulus",
message: "is waking up",
entity_id: "device_tracker.paulus",
domain: "light",
},
},
{ repeat: {
count: "5",
sequence: [
{ delay: 2
},
{ service: "light.toggle",
data: {
entity_id: "light.bed_light",
},
},
],
},
},
{ choose: [
{ conditions: [],
sequence: [
{ service: "test"},
{ service: "test"},
{ service: "test"},
],
},
{ conditions: [],
sequence: [
{ service: "test"},
],
},
{ conditions: [],
sequence: [
{ service: "test"},
{ service: "test"},
],
},
],
default: [
{ service: "test"},
{ service: "test"},
{ service: "test"},
],
},
];

366
src/main.js Normal file
View File

@ -0,0 +1,366 @@
import {demoConfig} from "./demo-config";
import "@vanillawc/wc-codemirror";
import {
LitElement,
html,
css,
svg,
property
} from "lit-element";
import {
mdiCallSplit,
mdiAbTesting,
mdiCheck,
mdiClose,
mdiChevronRight,
mdiExclamation,
mdiTimerOutline,
mdiTrafficLight,
mdiRefresh,
mdiArrowUp,
mdiCodeJson,
mdiCheckBoxOutline,
mdiCheckboxBlankOutline,
mdiAsterisk,
} from "@mdi/js";
const SIZE = 35;
const BORDER = 5;
const R = SIZE/2;
const DIST = 20;
const ICONS = {
choose: mdiCallSplit,
chooseChoice: mdiCheckBoxOutline,
chooseDefault: mdiCheckboxBlankOutline,
condition: mdiAbTesting,
TRUE: mdiCheck,
FALSE: mdiClose,
service: mdiChevronRight,
event: mdiExclamation,
delay: mdiTimerOutline,
wait: mdiTrafficLight,
loop: mdiRefresh,
loopReturn: mdiArrowUp,
YAML: mdiCodeJson,
};
class ScriptGraph extends LitElement {
static get properties() {
return {
tree: {},
selected: {},
};
}
_select(idx) {
this.selected = idx;
const ev = new CustomEvent("selected", {detail: idx});
this.dispatchEvent(ev);
}
_draw_node(x, y, node, idx) {
const selected = (Array.isArray(this.selected)
? (this.selected[0] === idx)
: false
) || (idx === this.selected);
return svg`
<circle
cx=${x}
cy=${y + SIZE/2}
r=${R}
stroke-width=${BORDER}
stroke="black"
fill="white"
class="${selected ? 'selected' : ''}"
@click=${() => this._select(idx)}
/>
<g style="pointer-events: none">
${node in ICONS
? svg`
<g transform="translate(${x-12} ${y+R-12})">
<path d="${ICONS[node]}"/>
</g>
`
: svg`
<text
x=${x}
y=${y+R}
text-anchor="middle"
dominant-baseline="middle"
>${node}</text>
`}
</g>
`;
}
_draw_connector(x1, y1, x2, y2) {
return svg`
<line
x1=${x1}
y1=${y1}
x2=${x2}
y2=${y2}
stroke-width=${BORDER}
stroke="black"
stroke-linecap="round"
/>
`;
}
draw_tree(tree) {
if(!tree) return {svg: svg``, width: 1, height: 1};
let nodes = [];
let connections = [];
if(!Array.isArray(tree)) {
const selected = tree.idx == this.selected;
if(tree.type === "loop") {
const seq = this.draw_tree(tree.sequence);
const sep = seq.width + DIST;
const left = -(seq.width + SIZE + DIST)/2 + SIZE/2;
const right = left+DIST+seq.width/2 + SIZE/2;
return {
svg: svg`
${this._draw_connector(
0, R,
left, SIZE + DIST + R
)}
${this._draw_connector(
0, R,
right, SIZE + DIST + R
)}
${this._draw_connector(
right, SIZE + DIST + seq.height - R,
0, SIZE + DIST + seq.height + DIST
)}
${this._draw_connector(
left, SIZE + DIST + seq.height - R,
0, SIZE + DIST + seq.height + DIST
)}
${this._draw_connector(
left, SIZE + DIST + R,
left, SIZE + DIST + seq.height - R
)}
${this._draw_node(0, 0, "loop", tree.idx)}
${this._draw_node(left, SIZE+DIST+seq.height/2-R, "loopReturn", tree.idx)}
<g transform="translate(${right} ${SIZE + DIST})">
${seq.svg}
</g>
`,
height: SIZE + DIST + seq.height + DIST,
width: seq.width + SIZE,
}
}
if(tree.type === "condition") {
const sep = SIZE + DIST;
return {
svg: svg`
${this._draw_connector(
0, R,
-sep/2, SIZE + DIST + R
)}
${this._draw_connector(
0, R,
sep/2, SIZE + DIST + R
)}
${this._draw_connector(
-sep/2, SIZE + DIST + R,
0, SIZE + DIST + SIZE + DIST,
)}
${this._draw_node(0, 0, "condition", tree.idx)}
${this._draw_node(-sep/2, SIZE+DIST, "TRUE", tree.idx)}
${this._draw_node(sep/2, SIZE+DIST, "FALSE", tree.idx)}
`,
height: SIZE + DIST + SIZE + DIST,
width: SIZE + DIST + SIZE,
}
}
if(tree.type === "choose") {
const choices = tree.choices.map(x => this.draw_tree(x));
const maxHeight = choices.reduce((a,i) => Math.max(a, i.height), 0);
const sep = SIZE + DIST;
const totWidth = choices.reduce((a,i) => a+i.width, 0)+DIST*(choices.length-1);
const offset = choices.map((sum => value => sum += value.width)(0));
return {
svg: svg`
${choices.map((choice, idx) => this._draw_connector(
0,
R,
-totWidth/2+idx*DIST + (idx?offset[idx-1]:0) + choice.width/2,
SIZE + DIST + R,
))}
${choices.map((choice, idx) => this._draw_connector(
-totWidth/2+idx*DIST + (idx?offset[idx-1]:0)+ choice.width/2,
SIZE + DIST + R,
-totWidth/2+idx*DIST + (idx?offset[idx-1]:0)+ choice.width/2,
SIZE + DIST + R + DIST,
))}
${choices.map((choice, idx) => svg`
${this._draw_connector(
-totWidth/2+idx*DIST + (idx?offset[idx-1]:0) + choice.width/2,
SIZE + DIST + R + DIST + choice.height,
-totWidth/2+idx*DIST + (idx?offset[idx-1]:0) + choice.width/2,
SIZE + DIST + R + DIST + maxHeight + DIST
)}
${this._draw_connector(
-totWidth/2+idx*DIST + (idx?offset[idx-1]:0) + choice.width/2,
SIZE + DIST + R + DIST + maxHeight + DIST,
0,
SIZE + DIST + R + DIST + maxHeight + DIST + SIZE + DIST
)}
`)}
${this._draw_node(0, 0, tree.type, tree.idx)}
${choices.map((choice, idx) => svg`
<g transform="translate(
${-totWidth/2+idx*DIST + (idx?offset[idx-1]:0) + choice.width/2}
${SIZE + DIST + R + DIST})">
${choice.svg}
</g>
`)}
`,
height: SIZE + DIST + R + DIST + maxHeight + DIST + SIZE + DIST,
width: totWidth,
}
}
return {
svg: this._draw_node(0, 0, tree.type, tree.idx),
height: SIZE,
width: SIZE,
}
}
let height = 0;
let width = 0;
for (const [idx, node] of tree.entries()) {
const n = this.draw_tree(node);
if(idx) {
nodes.splice(nodes.length-1, 0, this._draw_connector(
0,
height-DIST,
0,
height+DIST
));
}
nodes.push(svg`<g transform="translate(0 ${height})">${n.svg}</g>`);
height += n.height+DIST;
width = Math.max(width, n.width);
}
height = height-DIST;
return {
svg: svg`${nodes.map((node, idx) => node)}`,
height,
width,
};
}
render() {
let processed_tree = this.draw_tree(this.tree);
return html`
<style>
circle, line {
stroke: rgb(3, 169, 244);
}
circle.selected {
stroke: rgb(255, 152, 0);
}
svg {
font-family: Roboto, Noto, sans-serif;
font-size: 14px;
}
</style>
<svg width=${processed_tree.width + SIZE} height=${processed_tree.height + BORDER}>
<g transform="translate(${processed_tree.width/2 + BORDER/2} ${BORDER/2})">
${processed_tree.svg}
</g>
</svg>
`;
}
}
customElements.define("script-graph", ScriptGraph);
let index_counter = 0;
let nodes = [];
const structure_tree = (inp) => {
if(!inp) return null;
if(Array.isArray(inp)) return inp.map(structure_tree);
let data = {};
let type = "YAML";
const idx = index_counter++;
if("service" in inp) type = "service";
if("condition" in inp) type = "condition";
if("delay" in inp) type = "delay";
if("wait_template" in inp) type = "wait";
if("event" in inp) type = "event";
if("repeat" in inp) {
type = "loop";
data = {sequence: structure_tree(inp.repeat.sequence)};
}
if("choose" in inp) {
type = "choose";
let choices = [];
for (const [i,c] of inp.choose.entries()) {
const header = {
type: "chooseChoice",
idx: [idx, i],
}
choices.push([header].concat(structure_tree(c.sequence)));
}
choices.push([{
type: "chooseDefault",
idx: [idx, -1],
}].concat(structure_tree(inp.default)));
data = {
choices,
}
}
nodes[idx] = inp;
return {type,
idx,
...data};
}
window.onload = () => {
let src = demoConfig;
const fullcode = document.querySelector("wc-codemirror");
fullcode.mode = "yaml";
window.setTimeout(()=> fullcode.value = jsyaml.safeDump(src), 100);
const updateButton = document.querySelector("#updateButton");
updateButton.addEventListener('click', () => {
src = jsyaml.safeLoad(fullcode.value);
index_counter = 0;
nodes = [];
graph.tree = structure_tree(src);
});
const graph = document.createElement("script-graph");
graph.tree = structure_tree(src);
graph.addEventListener("selected", (ev) => {
const idx = ev.detail;
const code = document.querySelector("code");
let c;
if(Array.isArray(idx)) {
c = nodes[idx[0]].choose[idx[1]];
} else {
c = nodes[idx];
}
code.innerHTML = JSON.stringify(c, null, ' ');
})
document.querySelector("#graph").appendChild(graph);
}