This code is usable only if you are on Home Assistant 0.114 or older
For Home Assistant 0.115 and newer please go to: https://github.com/AdamNaj/ZWaveGraphHA
This code is usable only if you are on Home Assistant 0.114 or older
For Home Assistant 0.115 and newer please go to: https://github.com/AdamNaj/ZWaveGraphHA
panel_custom: | |
- name: zwavegraph2 | |
sidebar_title: Z-Wave Graph | |
sidebar_icon: mdi:access-point-network | |
url_path: zwave |
<!-- | |
https://gist.github.com/AdamNaj/cbf4d792a22f443fe9d354e4dca4de00 | |
Version 1.0: | |
- based on the brilliant code by @NigelL - with cosmetic changes mostly about clarity and shaping of nodes based on their function | |
Version 2.0: (02 July 2019) | |
- you can now pan the graph by dragging it | |
- you can now zoom the graph with your mouse wheel | |
- the graph initially is scaled to fill the full screen width | |
- added minimap to visualize which part of the graph you can see at the oment on the screen | |
- added 2 more tree layouts (click on the top-legend) - they didn't necessarily help me make the graph more manageable for me, but may be useful to others in their topology | |
- added the ability to show all node connections if someone wants to see the full picture of their Z-Wave mesh | |
- fixed the broken new line in the node tooltips | |
- you can now click on the node to see the entity dialog | |
Version 2.1: (20 September 2019) | |
- added Tools to graph legends so you can easily navigate to Z-Wave Network Management | |
- fixed (hopefully) the problem with the graph requiring page reload then navigating to it | |
Version 2.2: (04 October 2019) | |
- ability to turn off node grouping. Having the nodes grouped requires editing locations defined in the zwcfg_*.cfg | |
Version 2.3: (03 February 2020) | |
- Graph background reflects theme background color after page reload | |
- Fixed problem where some removed nodes lingering in the device registry could cause wrong node info card to be displayed after clicking on nodes with higher ids | |
--> | |
<dom-module id='ha-panel-zwavegraph2'> | |
<template> | |
<style include="ha-style"> | |
.thumb { | |
border: 1px solid #ddd; | |
position: absolute; | |
bottom: 5px; | |
right: 5px; | |
margin: 1px; | |
padding: 1px; | |
overflow: hidden; | |
} | |
#miniSvg { | |
z-index: 110; | |
background: white; | |
} | |
#scopeContainer { | |
z-index: 120; | |
} | |
.content { | |
overflow: hidden; | |
position: absolute; | |
left: 0px; | |
top: 64px; | |
bottom: 0px; | |
right: 0px; | |
padding: 8px; | |
} | |
svg>.output { | |
fill: #3598DB; | |
stroke: #2470A2; | |
} | |
.node>rect { | |
stroke: black; | |
} | |
.node.layer-1>rect, | |
.edgePath.layer-1>path { | |
fill: #3598DB; | |
stroke: #2470A2; | |
} | |
.node.layer-1>polygon, | |
.node.layer-1>rect, | |
.edgePath.layer-1>path { | |
fill: #3598DB; | |
stroke: #2470A2; | |
} | |
.node.layer-1 text { | |
fill: #1E5B84; | |
} | |
.node.layer-2>polygon, | |
.node.layer-2>rect, | |
.edgePath.layer-2>path { | |
stroke: #1D8548; | |
} | |
.node.layer-2>rect, | |
.edgePath.layer-2>path { | |
fill: #1BBC9B; | |
} | |
.node.layer-2 text { | |
fill: #11512C; | |
} | |
.node.layer-3>polygon, | |
.node.layer-3>rect, | |
.edgePath.layer-3>path { | |
stroke: #1D8548; | |
} | |
.node.layer-3>rect, | |
.edgePath.layer-3>path { | |
fill: #2DCC70; | |
} | |
.node.layer-3 text { | |
fill: #1D8548; | |
} | |
.node.layer-4>polygon, | |
.node.layer-4>rect, | |
.edgePath.layer-4>path { | |
stroke: #D25400; | |
} | |
.node.layer-4>rect, | |
.edgePath.layer-4>path { | |
fill: #F1C40F; | |
} | |
.node.layer-5>polygon, | |
.node.layer-4 text { | |
fill: #D25400; | |
} | |
.node.layer-5>polygon, | |
.node.layer-5>rect, | |
.edgePath.layer-5>path { | |
stroke: #D25400; | |
} | |
.node.layer-5>rect, | |
.edgePath.layer-5>path { | |
fill: #E77E23; | |
} | |
.node.layer-5 text { | |
fill: #D25400; | |
} | |
.node.Error>polygon, | |
.node.Error>rect { | |
fill: #ff7676; | |
stroke: darkred; | |
} | |
.node.Error text { | |
fill: darkred; | |
} | |
.node.unset>rect { | |
stroke: #666; | |
} | |
.node.unset>polygon, | |
.node.unset>rect { | |
stroke: #666; | |
fill: lightgray; | |
} | |
.cluster>rect { | |
stroke: lightgray; | |
fill: #f8f8f8; | |
stroke-width: 1px; | |
stroke-linecap: round; | |
} | |
.cluster>.label { | |
/* stroke: gray; */ | |
fill: lightgray; | |
} | |
.node.unset text { | |
fill: #666; | |
} | |
.node text { | |
font-size: 12px; | |
} | |
.edgePath.layer-1>path { | |
fill: transparent; | |
} | |
.edgePath path { | |
stroke: #333; | |
fill: #333; | |
} | |
.node>polygon { | |
opacity: 0.7; | |
} | |
.node>rect { | |
stroke-width: 1px; | |
stroke-linecap: round; | |
} | |
</style> | |
<app-header-layout has-scrolling-region> | |
<app-header slot="header" fixed> | |
<app-toolbar> | |
<ha-menu-button narrow='[[narrow]]' show-menu='[[showMenu]]'></ha-menu-button> | |
<div main-title>Z-Wave Graph</div> | |
</app-toolbar> | |
</app-header> | |
<div class="content" style="background: var(--primary-background-color);"> | |
<svg id="svg" width="100%" height="100%"></svg> | |
<svg id="scopeContainer" class="thumb"> | |
<g> | |
<rect id="scope" fill="red" fill-opacity="0.03" stroke="red" stroke-width="1px" stroke-opacity="0.3" x="0" | |
y="0" width="0" height="0" /> | |
<line id="line1" stroke="red" stroke-width="1px" x1="0" y1="0" x2="0" y2="0" /> | |
<line id="line2" stroke="red" stroke-width="1px" x1="0" y1="0" x2="0" y2="0" /> | |
</g> | |
</svg> | |
<svg id="miniSvg" class="thumb" style="background: var(--primary-background-color);"></svg> | |
</div> | |
</app-header-layout> | |
</template> | |
</dom-module> | |
<script src="https://d3js.org/d3.v5.min.js"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre-d3/0.6.1/dagre-d3.js"></script> | |
<script src="https://d3js.org/topojson.v2.min.js"></script> | |
<script src="https://cdn.jsdelivr.net/npm/svg-pan-zoom@3.5.0/dist/svg-pan-zoom.min.js"></script> | |
<script> | |
class HaPanelZWave extends Polymer.Element { | |
static get is() { | |
return 'ha-panel-zwavegraph2'; | |
} | |
static get properties() { | |
return { | |
// Home Assistant object | |
hass: Object, | |
// If should render in narrow mode | |
narrow: { | |
type: Boolean, | |
value: false, | |
}, | |
// If sidebar is currently shown | |
showMenu: { | |
type: Boolean, | |
value: false, | |
}, | |
// Home Assistant panel info99 | |
// panel.config contains config passed to register_panel serverside | |
panel: Object, | |
controls: { | |
type: Object | |
}, | |
controlsLoaded: { | |
type: Boolean, | |
value: false | |
}, | |
settings: { | |
type: Boolean, | |
value: false | |
}, | |
}; | |
} | |
ready() { | |
super.ready(); | |
this.$.svg.innerHTML = "" | |
var that = this; | |
setTimeout(function() { | |
that.paintGraph("network-simplex", "relevant", "z-wave"); | |
}, 100); | |
} | |
paintGraph(ranker, edgeVisibility, grouping) { | |
var legends = [{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "Hub" | |
}, | |
{ | |
shape: "rect", | |
color: "#1BBC9B", | |
stroke: "#1D8548", | |
textcolor: "#11512C", | |
text: "1 hop" | |
}, | |
{ | |
shape: "rect", | |
color: "#2DCC70", | |
stroke: "#1D8548", | |
textcolor: "#1D8548", | |
text: "2 hops" | |
}, | |
{ | |
shape: "rect", | |
color: "#F1C40F", | |
stroke: "#D25400", | |
textcolor: "#D25400", | |
text: "3 hops" | |
}, | |
{ | |
shape: "rect", | |
color: "E77E23", | |
stroke: "#D25400", | |
textcolor: "#D25400", | |
text: "4 hops" | |
}, | |
{ | |
shape: "rect", | |
color: "crimson", | |
stroke: "darkred", | |
textcolor: "darkred", | |
text: "Failed Node" | |
}, | |
{ | |
shape: "rect", | |
color: "lightgray", | |
stroke: "#666666", | |
textcolor: "#666666", | |
text: "Unconnected" | |
} | |
]; | |
var layout = [{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "Network Simplex", | |
ranker: "network-simplex", | |
cursor: "pointer" | |
}, | |
{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "Tight Tree", | |
ranker: "tight-tree", | |
cursor: "pointer" | |
}, | |
{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "Longest Path", | |
ranker: "longest-path", | |
cursor: "pointer" | |
} | |
]; | |
var edgesLegend = [{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "Relevant Neighbors", | |
edges: "relevant", | |
cursor: "pointer" | |
}, | |
{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "All Neighbors", | |
edges: "all", | |
cursor: "pointer" | |
} | |
]; | |
var groupingLegend = [{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "Z-Wave Locations", | |
grouping: "z-wave", | |
cursor: "pointer" | |
}, | |
{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "Ungrouped", | |
grouping: "ungrouped", | |
cursor: "pointer" | |
} | |
]; | |
var links = [{ | |
shape: "rect", | |
color: "#3598DB", | |
stroke: "#2470A2", | |
textcolor: "#2470A2", | |
text: "Network Management", | |
cursor: "hand", | |
url: "/config/zwave", | |
}, ]; | |
this.ranker = ranker; | |
this.edgeVisibility = edgeVisibility; | |
this.grouping = grouping; | |
var data = this.listNodes(this.hass); | |
var g = new dagreD3.graphlib.Graph({ | |
compound: true | |
}).setGraph({}); | |
g.graph().rankDir = "BT"; | |
//g.graph().rankDir = 'RL'; | |
g.graph().nodesep = 10; | |
g.graph().ranker = ranker; | |
// Create the renderer | |
var render = new dagreD3.render(); | |
var svg = d3.select(this.$.svg); | |
var inner = svg.append("g").attr("transform", "translate(20,200)scale(1)"); | |
g.graph().minlen = 0; | |
// Add our custom shape (a house) | |
render.shapes().house = this.renderHouse; | |
render.shapes().battery = this.renderBattery; | |
var groups = []; | |
var nodes = data["nodes"]; | |
// Set the parents to define which nodes belong to which cluster | |
// add nodes to graph | |
for (var i = 0; i < nodes.length; i++) { | |
var node = nodes[i]; | |
g.setNode(node.id, node); | |
if (this.grouping !== "ungrouped" && node.location != "" && node.location != undefined) { | |
g.setNode(node.location, { | |
label: node.location, | |
clusterLabelPos: 'bottom', | |
class: "group", | |
entityId: node.entity_id | |
}); | |
g.setParent(node.id, node.location); | |
} | |
} | |
// add edges to graph | |
for (var i = 0; i < data["edges"].length; i++) { | |
var edge = g.setEdge( | |
data["edges"][i].from, | |
data["edges"][i].to, { | |
label: "", | |
arrowhead: "undirected", | |
style: data["edges"][i].style, | |
class: data["edges"][i].class, | |
curve: d3.curveBundle.beta(0.2) | |
//curve: d3.curveBasis | |
}) | |
} | |
// Run the renderer. This is what draws the final graph. | |
render(inner, g); | |
// create battery state gradients | |
for (let layer = 0; layer < legends.length; layer++) { | |
for (let percent = 0; percent <= 100; percent += 10) { | |
var grad = svg.append("defs").append("linearGradient").attr("id", "fill-" + (layer + 1) + "-" + percent) | |
.attr("x1", "0%").attr("x2", "0%").attr("y1", "0%").attr("y2", "100%"); | |
grad.append("stop").attr("offset", (100 - percent - 10) + "%").style("stop-color", "white"); | |
grad.append("stop").attr("offset", (100 - percent) + "%").style("stop-color", legends[layer].color); | |
} | |
} | |
// Add the title element to be used for a tooltip (SVG functionality) | |
inner.selectAll("g.node") | |
.append("title").html(function (d) { | |
return g.node(d).title; | |
}); | |
inner.selectAll("g.node") | |
.attr("layer", function (d) { | |
return g.node(d).layer; | |
}) | |
.attr("fill", function (d) { | |
if (g.node(d).battery_level === 100) { | |
return "url(#fill-" + g.node(d).layer + "-100)"; | |
} | |
if (g.node(d).battery_level !== undefined) { | |
return "url(#fill-" + g.node(d).layer + "-" + Math.floor(g.node(d).battery_level / 10 % 10) + "0)"; | |
} | |
}); | |
inner.selectAll("g.edgePath") | |
.attr("layer", function (d) { | |
return g.edges(d).layer; | |
}); | |
var that = this; | |
var handleClick = function (d, i, nodeList) { // Add interactivity | |
var nodeId = nodeList[i].id; | |
var node = nodes.find(function(element) { | |
return element.id == nodeId; | |
}); | |
that.fire('hass-more-info', { | |
entityId: node.entity_id | |
}); | |
}; | |
// append handlers | |
svg.selectAll(".node") | |
.on("mouseover", this.handleMouseOver) | |
.on("mouseout", this.handleMouseOut) | |
.on("click", handleClick); | |
this.addLegend(this.$, svg, legends, 5, 20, "Node Colors", ranker, this.edgeVisibility, this.grouping); | |
this.addLegend(this.$, svg, layout, 150, 20, "Tree Layout", ranker, this.edgeVisibility, this.grouping); | |
this.addLegend(this.$, svg, edgesLegend, 320, 20, "Neighbors", ranker, this.edgeVisibility, this.grouping); | |
this.addLegend(this.$, svg, groupingLegend, 510, 20, "Grouping", ranker, this.edgeVisibility, this.grouping); | |
this.addLegend(this.$, svg, links, 700, 20, "Tools", ranker, this.edgeVisibility, this.grouping); | |
this.$.miniSvg.innerHTML = this.$.svg.innerHTML; | |
var panZoomGraph = svgPanZoom(this.$.svg); | |
this.bindThumbnail(this.$); | |
} | |
listNodes(hass) { | |
let states = new Array(); | |
for (let state in hass.states) { | |
states.push({ | |
name: state, | |
entity: hass.states[state] | |
}); | |
} | |
let zwaves = states.filter((s) => { | |
return s.name.indexOf("zwave.") == 0 && s.entity.attributes["capabilities"] !== undefined | |
}); | |
let result = { | |
"edges": [], | |
"nodes": [] | |
}; | |
let hubNode = 0; | |
let neighbors = {}; | |
for (let b in zwaves) { | |
let id = zwaves[b].entity.attributes["node_id"]; | |
let node = zwaves[b].entity; | |
if (node.attributes["capabilities"].filter( | |
(s) => { | |
return s == "primaryController" | |
}).length > 0) { | |
hubNode = id; | |
} | |
neighbors[id] = node.attributes['neighbors']; | |
let entities = states.filter((s) => { | |
return ((s.name.indexOf("zwave.") == -1) && | |
(s.entity.attributes["node_id"] == id)) | |
}); | |
let batlev = node.attributes.battery_level; | |
// create node | |
let entity = { | |
"id": id, | |
"entity_id": node.entity_id, | |
"label": "[" + id + (node.attributes["is_zwave_plus"] ? "+" : "") + "] " + (node.attributes[ | |
"friendly_name"] + " (" + node.attributes["averageResponseRTT"] + "ms)").replace(/ /g, "\n"), | |
"class": "unset layer-7", | |
"layer": 7, | |
"rx": "6", | |
"ry": "6", | |
"neighbors": neighbors[id], | |
"battery_level": batlev, | |
"mains": batlev, | |
"location": node.attributes["location"], | |
"failed": node.attributes["is_failed"], | |
"title": "<b>" + node.attributes["node_name"] + "</b>\n" + | |
"\n Entity ID: " + node.entity_id + | |
"\n Node: " + id + (node.attributes["is_zwave_plus"] ? "+" : "") + | |
"\n Product Name: " + node.attributes["product_name"] + | |
"\n Average Request RTT: " + node.attributes["averageResponseRTT"] + "ms" + | |
"\n Power source: " + (batlev != undefined ? "battery (" + batlev + "%)" : "mains") + | |
"\n " + entities.length + " entities" + | |
"\n Neighbors: " + node.attributes['neighbors'], | |
"forwards": (node.attributes.is_awake && node.attributes.is_ready && !node.attributes.is_failed && | |
node.attributes.capabilities.includes("listening")), | |
}; | |
entity["shape"] = id === hubNode ? "house" : (entity.forwards || batlev === undefined ? "rect" : "battery"); | |
if (node.attributes["is_failed"]) { | |
entity.label = "FAILED: " + entity.label; | |
entity["font.multi"] = true; | |
entity["title"] = "<b>FAILED: </b>" + entity.title; | |
entity["group"] = "Failed"; | |
entity["failed"] = true; | |
entity["class"] = "Error"; | |
} | |
if (hubNode == id) { | |
entity.label = "ZWave Hub"; | |
entity.borderWidth = 2; | |
entity.fixed = true; | |
} | |
result.nodes.push(entity); | |
} | |
if (hubNode > 0) { | |
let layer = 0; | |
let previousRow = [hubNode]; | |
let mappedNodes = [hubNode]; | |
let layers = []; | |
while (previousRow.length > 0) { | |
layer = layer + 1; | |
let nextRow = []; | |
let layerMembers = [] | |
layers[layer] = layerMembers; | |
for (let target in previousRow) { | |
// assign node to layer | |
result.nodes.filter((n) => { | |
return ((n.id == previousRow[target]) && (n.group = "unset")) | |
}) | |
.every((d) => { | |
d.class = "layer-" + layer; | |
d.layer = layer; | |
if (d.failed) { | |
d.class = d.class + " Error" | |
} | |
if (d.neighbors !== undefined) { | |
d.neighbors.forEach((n) => { | |
d.class = d.class + " neighbor-" + n | |
}); | |
} | |
}) | |
if (result.nodes.filter((n) => { | |
return ((n.id == previousRow[target]) && (n.forwards)) | |
}).length > 0) { | |
let row = neighbors[previousRow[target]]; | |
for (let node in row) { | |
if (neighbors[row[node]] !== undefined) { | |
if (!mappedNodes.includes(row[node])) { | |
layerMembers.push(row[node]); | |
result.edges.push({ | |
"from": row[node], | |
"to": previousRow[target], | |
"style": "", | |
"class": "layer-" + (layer + 1) + " node-" + row[node] + " node-" + previousRow[target], | |
"layer": layer, | |
}); | |
nextRow.push(row[node]); | |
} else { | |
// uncomment to show edges regardless of rows - mess! | |
if (this.edgeVisibility === "all") { | |
result.edges.push({ | |
"from": row[node], | |
"to": previousRow[target], | |
"style": "stroke-dasharray: 5, 5; fill:transparent; ", //"stroke: #ddd; stroke-width: 1px; fill:transparent; stroke-dasharray: 5, 5;", | |
"class": "layer-" + (layer + 1) + " node-" + row[node] + " node-" + previousRow[target] | |
}); | |
} | |
} | |
} | |
} | |
} | |
} | |
for (let idx in nextRow) { | |
mappedNodes.push(nextRow[idx]); | |
} | |
previousRow = nextRow; | |
} | |
} | |
return result; | |
} | |
// Add our custom shape (a house) | |
renderHouse(parent, bbox, node) { | |
var w = bbox.width, | |
h = bbox.height, | |
points = [{ | |
x: 0, | |
y: 0 | |
}, | |
{ | |
x: w, | |
y: 0 | |
}, | |
{ | |
x: w, | |
y: -h | |
}, | |
{ | |
x: w / 2, | |
y: -h * 3 / 2 | |
}, | |
{ | |
x: 0, | |
y: -h | |
} | |
], | |
shapeSvg = parent.insert("polygon", ":first-child") | |
.attr("points", points.map(function (d) { | |
return d.x + "," + d.y; | |
}).join(" ")) | |
.attr("transform", "translate(" + (-w / 2) + "," + (h * 3 / 4) + ")"); | |
node.intersect = function (point) { | |
return dagreD3.intersect.polygon(node, points, point); | |
}; | |
return shapeSvg; | |
}; | |
renderBattery(parent, bbox, node) { | |
var w = bbox.width, | |
h = bbox.height, | |
points = [{ | |
x: 0, | |
y: 0 | |
}, // bottom left | |
{ | |
x: w, | |
y: 0 | |
}, // bottom line | |
{ | |
x: w, | |
y: -h | |
}, // right line | |
{ | |
x: w * 7 / 10, | |
y: -h | |
}, // top right | |
{ | |
x: w * 7 / 10, | |
y: -h * 20 / 17 | |
}, // battery tip - right | |
{ | |
x: w * 3 / 10, | |
y: -h * 20 / 17 | |
}, // battery tip | |
{ | |
x: w * 3 / 10, | |
y: -h | |
}, // battery tip - left | |
{ | |
x: 0, | |
y: -h | |
}, // top left | |
{ | |
x: 0, | |
y: -h | |
} // left line | |
], | |
shapeSvg = parent.insert("polygon", ":first-child") | |
.attr("points", points.map(function (d) { | |
return d.x + "," + d.y; | |
}).join(" ")) | |
.attr("transform", "translate(" + (-w / 2) + "," + (h * 2 / 4) + ")"); | |
node.intersect = function (point) { | |
return dagreD3.intersect.polygon(node, points, point); | |
}; | |
return shapeSvg; | |
}; | |
handleMouseOver(d, i, nodeList) { // Add interactivity | |
var svg; | |
for (let nodeNum in nodeList) { | |
let node = nodeList[nodeNum]; | |
if (node.style !== undefined && node.id !== d) { | |
node.style.opacity = 0.1; | |
svg = node.ownerSVGElement | |
} | |
} | |
// Use D3 to select element, change color and size | |
svg.querySelectorAll(".edgePath") | |
.forEach(function (node) { | |
node.style.opacity = "0.3" | |
}); | |
var edges = svg.querySelectorAll(".edgePath.node-" + d); | |
for (let i = 0; i < edges.length; i++) { | |
edges[i].style.opacity = "1" | |
edges[i].style['stroke-width'] = "2"; | |
}; | |
var neighbors = svg.querySelectorAll(".node.neighbor-" + d); | |
for (let i = 0; i < neighbors.length; i++) { | |
neighbors[i].style.opacity = "0.7" | |
}; | |
}; | |
handleMouseOut(d, i, nodeList) { // Add interactivity | |
var svg; | |
for (let nodeNum in nodeList) { | |
let node = nodeList[nodeNum]; | |
if (node.style !== undefined && node.id !== d) { | |
node.style.opacity = 1; | |
svg = node.ownerSVGElement | |
} | |
} | |
// Use D3 to select element, change color and size | |
svg.querySelectorAll(".edgePath") | |
.forEach(function (node) { | |
node.style.opacity = "1"; | |
node.style['stroke-width'] = "1"; | |
}); | |
}; | |
addLegend($, svg, legends, startX, startY, title, ranker, edges, grouping) { | |
var that = this; | |
var handleClick = function (d, i, nodeList) { | |
if (nodeList[0].dataset.url !== undefined) { | |
window.location = nodeList[0].dataset.url; | |
} | |
var ranker = nodeList[0].dataset.ranker || that.ranker; | |
var edges = nodeList[0].dataset.edges || that.edgeVisibility; | |
var grouping = nodeList[0].dataset.grouping || that.grouping; | |
svg.selectAll("*").remove(); | |
// Destroy svgpanzoom | |
svgPanZoom($.svg).destroy(); | |
svgPanZoom($.miniSvg).destroy(); | |
that.paintGraph(ranker, edges, grouping); | |
} | |
var shape = svg.append('text') | |
.attr('x', startX) | |
.attr('y', startY + 5) | |
.text(title) | |
.attr('width', 10) | |
.attr('height', 10) | |
.style("font-weight", "800"); | |
for (var counter = 0; counter < legends.length; counter++) { | |
var isLink = legends[counter].url !== undefined; | |
if (isLink) { | |
var text = svg.append('text') | |
.attr("x", startX) | |
.attr("y", startY + 10 + 20 * (counter + 1)) | |
.attr("class", "textselected") | |
.attr('data-url', legends[counter].url) | |
.text(legends[counter].text) | |
.style("text-anchor", "start") | |
.style("fill", legends[counter].textcolor) | |
.style("font-size", 15) | |
.style("text-decoration", "underline") | |
.style("cursor", legends[counter].cursor) | |
.on("click", handleClick); | |
} else { | |
var shape = svg.append(legends[counter].shape) | |
.attr('x', startX) | |
.attr('y', startY + 20 * (counter + 1)) | |
.attr('width', 10) | |
.attr('height', 10) | |
.style("stroke", legends[counter].stroke) | |
.style("fill", legends[counter].color) | |
.style("cursor", legends[counter].cursor); | |
var text = svg.append('text') | |
.attr("x", startX + 20) | |
.attr("y", startY + 10 + 20 * (counter + 1)) | |
.attr("class", "textselected") | |
.text(legends[counter].text) | |
.style("text-anchor", "start") | |
.style("fill", legends[counter].textcolor) | |
.style("font-size", 15) | |
.style("cursor", legends[counter].cursor); | |
var dataLabel, dataValue, dataState; | |
if (legends[counter].ranker) { | |
dataLabel = 'data-ranker'; | |
dataValue = legends[counter].ranker; | |
dataState = ranker; | |
} | |
if (legends[counter].edges) { | |
dataLabel = 'data-edges'; | |
dataValue = legends[counter].edges; | |
dataState = edges; | |
} | |
if (legends[counter].grouping) { | |
dataLabel = 'data-grouping'; | |
dataValue = legends[counter].grouping; | |
dataState = grouping; | |
} | |
if (dataLabel !== undefined) { | |
shape.attr(dataLabel, dataValue) | |
.on("click", handleClick); | |
text.attr(dataLabel, dataValue) | |
.on("click", handleClick); | |
if (dataValue !== dataState) { | |
shape.style("fill", "transparent"); | |
} | |
} | |
} | |
} | |
} | |
bindThumbnail($) { | |
var beforePanMain = function (oldPan, newPan) { | |
var stopHorizontal = false, | |
stopVertical = false, | |
gutterWidth = 100, | |
gutterHeight = 100 | |
// Computed variables | |
, | |
sizes = this.getSizes(), | |
leftLimit = -((sizes.viewBox.x + sizes.viewBox.width) * sizes.realZoom) + gutterWidth, | |
rightLimit = sizes.width - gutterWidth - (sizes.viewBox.x * sizes.realZoom), | |
topLimit = -((sizes.viewBox.y + sizes.viewBox.height) * sizes.realZoom) + gutterHeight, | |
bottomLimit = sizes.height - gutterHeight - (sizes.viewBox.y * sizes.realZoom); | |
customPan = {}; | |
customPan.x = Math.max(leftLimit, Math.min(rightLimit, newPan.x)); | |
customPan.y = Math.max(topLimit, Math.min(bottomLimit, newPan.y)); | |
return customPan; | |
}; | |
var main = svgPanZoom($.svg, { | |
zoomEnabled: true, | |
controlIconsEnabled: true, | |
fit: true, | |
center: true, | |
beforePan: beforePanMain | |
}); | |
var thumb = svgPanZoom($.miniSvg, { | |
zoomEnabled: false, | |
panEnabled: false, | |
controlIconsEnabled: false, | |
dblClickZoomEnabled: false, | |
preventMouseEventsDefault: true, | |
}); | |
var resizeTimer; | |
var interval = 300; //msec | |
window.addEventListener('resize', function (event) { | |
if (resizeTimer !== false) { | |
clearTimeout(resizeTimer); | |
} | |
resizeTimer = setTimeout(function () { | |
main.resize(); | |
thumb.resize(); | |
}, interval); | |
}); | |
main.setOnZoom(function (level) { | |
thumb.updateThumbScope(); | |
}); | |
main.setOnPan(function (point) { | |
thumb.updateThumbScope(); | |
}); | |
var _updateThumbScope = function ($, main, thumb, scope, line1, line2) { | |
var mainPanX = main.getPan().x, | |
mainPanY = main.getPan().y, | |
mainWidth = main.getSizes().width, | |
mainHeight = main.getSizes().height, | |
mainZoom = main.getSizes().realZoom, | |
thumbPanX = thumb.getPan().x, | |
thumbPanY = thumb.getPan().y, | |
thumbZoom = thumb.getSizes().realZoom; | |
if (mainZoom === 0) { | |
return; | |
} | |
var thumByMainZoomRatio = thumbZoom / mainZoom; | |
var scopeX = thumbPanX - mainPanX * thumByMainZoomRatio; | |
var scopeY = thumbPanY - mainPanY * thumByMainZoomRatio; | |
var scopeWidth = mainWidth * thumByMainZoomRatio; | |
var scopeHeight = mainHeight * thumByMainZoomRatio; | |
$.scope.setAttribute("x", scopeX + 1); | |
$.scope.setAttribute("y", scopeY + 1); | |
$.scope.setAttribute("width", scopeWidth - 2); | |
$.scope.setAttribute("height", scopeHeight - 2); | |
}; | |
thumb.updateThumbScope = function () { | |
var scope = $.scope; | |
var line1 = $.line1; | |
var line2 = $.line2; | |
_updateThumbScope($, main, thumb, scope, line1, line2); | |
} | |
thumb.updateThumbScope($); | |
var _updateMainViewPan = function (clientX, clientY, scopeContainer, main, thumb) { | |
var dim = scopeContainer.getBoundingClientRect(), | |
mainWidth = main.getSizes().width, | |
mainHeight = main.getSizes().height, | |
mainZoom = main.getSizes().realZoom, | |
thumbWidth = thumb.getSizes().width, | |
thumbHeight = thumb.getSizes().height, | |
thumbZoom = thumb.getSizes().realZoom; | |
var thumbPanX = clientX - dim.left - thumbWidth / 2; | |
var thumbPanY = clientY - dim.top - thumbHeight / 2; | |
var mainPanX = -thumbPanX * mainZoom / thumbZoom; | |
var mainPanY = -thumbPanY * mainZoom / thumbZoom; | |
main.pan({ | |
x: mainPanX, | |
y: mainPanY | |
}); | |
}; | |
var updateMainViewPan = function (evt) { | |
if (evt.which == 0 && evt.button == 0) { | |
return false; | |
} | |
_updateMainViewPan(evt.clientX, evt.clientY, scopeContainer, main, thumb); | |
} | |
var scopeContainer = $.scopeContainer; | |
scopeContainer.addEventListener('click', function (evt) { | |
updateMainViewPan(evt); | |
}); | |
scopeContainer.addEventListener('mousemove', function (evt) { | |
updateMainViewPan(evt); | |
}); | |
} | |
fire(type, detail, options) { | |
options = options || {}; | |
detail = (detail === null || detail === undefined) ? {} : detail; | |
const event = new Event(type, { | |
bubbles: options.bubbles === undefined ? true : options.bubbles, | |
cancelable: Boolean(options.cancelable), | |
composed: options.composed === undefined ? true : options.composed | |
}); | |
event.detail = detail; | |
const node = options.node || this; | |
node.dispatchEvent(event); | |
return event; | |
} | |
} | |
customElements.define(HaPanelZWave.is, HaPanelZWave); | |
</script> |
Thanks for this.
Would also suggest adding a note in the HTML comment about where to save the html.
<homeassistant installation directory>/config/panels/zwavegraph2.html
Thank you for this note. Just quoting it in case any new person misses it
home-assistant/core#36464 (0.112) seems it deprecated something this is using. I'm using it as
panel_custom:
- name: zwavegraph2
sidebar_title: Z-Wave Graph
sidebar_icon: mdi:access-point-network
url_path: zwave_graph
Do I need to use it differently?
@mkarnebeek
home-assistant just flat out deprecated custom_panels which is what this is.
So this will need a complete rewrite or home-assistant adds back support.
Someone more skilled than me could move it to a custom lovelace card perhaps?
Man, that sucks! The Graph for ZWave was just great, hopefully there will be another version...
Still works fine for me after upgrading to 0.112.0
I do get this line in the log though:
WARNING (MainThread) [homeassistant.components.panel_custom] HTML custom panels have been deprecated
My config looks like this:
panel_custom:
- name: zwavegraph2
sidebar_title: Z-Wave Graph
sidebar_icon: mdi:access-point-network
url_path: zwave
I feel like I'm missing something, because I'm on 0.112 and mine still works. I'm using Hassio on an RPi, and my panel config looks almost exactly like this one from mkarnebeek
Mine does not work since using the new OpenZWave Beta Integration. However when bringing up the graph in the ZWave discord, I was told that Plus devices do not always follow a static route so the graph would be inaccurate anyway. Above my head, I'm not up on the tech behind Zwave/Plus.
I feel like I'm missing something, because I'm on 0.112 and mine still works. I'm using Hassio on an RPi, and my panel config looks almost exactly like this one from mkarnebeek
I think it's because you're probably using the zwave integration, not the new OZW beta based on v1.6. You'd know if you were on the OZW beta - you'd have to purposely install it. A lot changed and a LOT still doesn't work (I understand). And there is currently no upgrade path from one to the next. Not sure exactly what that means, other than a complete rebuild of our zwave networks using MQTT nomenclature instead of zwave. I'm waiting to see what shakes out.
Correct - Graph still works with old Zwave integration...
Broken since 0.115/ standard Zwave integration. Probably because of the new UI-customizable side panel?
Throws error:
Logger: homeassistant.components.panel_custom
Source: components/panel_custom/init.py:155
Integration: Custom Panel (documentation, issues)
First occurred: 19:32:15 (2 occurrences)
Last logged: 19:32:15
Unable to register panel Z-Wave Graph: Either js_url, module_url or html_url is required.
Unable to register panel System log: Either js_url, module_url or html_url is required.
I can't help with this not working, but for those who switched to the new OZW integration, you can use this Z-Wave Graph instead for a similar visualization. It's not quite as pretty as this one, but covers the main functionality.
Hoping this will get fixed because it's come in quite handy over time.
I can't help with this not working, but for those who switched to the new OZW integration, you can use this Z-Wave Graph instead for a similar visualization. It's not quite as pretty as this one, but covers the main functionality.
I think what is needed with the old ZWaveGraph2 is a JS loader, not HTML. It looks like you've already written the JS loader part, perhaps it can be adapted to the old ZWaveGraph2 so there would be two options of graphing with the old ZWave integration.
@girzzlyAK Read the about.md at the top. The code has been moved and works in 115.
Yeah, thanks. I just found it and am looking at the Github page - and updating my system. ;-)
@alandtse - thanks so much for the heads up (this is why you should sub, even to gists). I so missed this in 0.115.x and it's great to have it back again!
@AdamNaj will the graph still work with the new OZW Stack? Or will there be any version which works with it? Just curious ;)