Having fun with SVG - a customizable camera widget

Hi folks,

I'm already playing with the idea of having some kind of library of customizable SVG widgets (camera, sprinkler ...) which can be used in floorplans. To demonstrate what I mean, I have create a customizable camera widget that can be used in the SVG node.

  1. The camera definition contains a series of custom CSS variables (whose name start with --):

    <defs>
       <g id="circle_definition">
          <circle style="--arc-radians:calc(2 * 3.1416 * 10 * var(--arc-angle) / 360);
                         --arc-start:calc((var(--arc-center) - (var(--arc-angle)) / 2) * 1deg);
                         --arc-scale:calc(var(--arc-radius) / 10 / 2);                           
                         transform:scale(var(--arc-scale)) rotate(var(--arc-start));
                         opacity:var(--arc-opacity);" 
                  cx="0" cy="0" r="10" fill="transparent" stroke="var(--arc-color)" stroke-width="20" stroke-dasharray="var(--arc-radians) calc(2 * 3.1416 * 10)"></circle>
          <circle style="transform:scale(var(--icon-size))" cx="0" cy="0" r="1" fill="white" stroke-width="0"></circle>
          <text x="0" y="0" font-family="FontAwesome" fill="var(--icon-color)" stroke="none" font-size="var(--icon-size)" text-anchor="middle" alignment-baseline="middle" stroke-width="1">fa-video-camera</text>
       </g>
    </defs>
    

    Some remarks:

    • For those wondering: "yes" I was a hell for me to get this working. CSS is not my BFF :nauseated_face:
    • There are other ways to create definitions, like a symbol. However IMHO a simple group (without viewbox) was much more flexible for my purpose. So I gave up experimenting with symbols...
    • Perhaps I should apply default values to all those CSS variables...
  2. You can (re)use the above definitions multiple times in a single drawing. Just specify an id, a location (x, y) and values for the CSS variables:

    <use xlink:href="#circle_definition" href="#circle_definition" id="my_camera" x="680" y="270" style="
         --arc-angle:90;
         --arc-radius:140;
         --arc-center:225;
         --arc-color:blue;
         --arc-opacity:0.3;
         --icon-size:25;
         --icon-color:blue;" />
    
  3. Now you can override those (default) values from step 2, by setting new values via your Node-RED flow. For example when you PTZ control your camera - using my Onvif nodes - you could also move the camera on the floorplan ...

The following flow demonstrates how to change the CSS variables via sliders/colorpickers in the dashoard:

[{"id":"663c0bc1.051714","type":"ui_svg_graphics","z":"7f1827bd.8acfe8","group":"5ae1b679.de89c8","order":9,"width":"12","height":"9","svgString":"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" preserveAspectRatio=\"none meet\" x=\"0\" y=\"0\" viewBox=\"0 0 900 710\" width=\"100%\" height=\"100%\">\n  <defs>\n    <g id=\"circle_definition\">\n      <!-- https://glennmccomb.com/articles/building-a-pure-css-animated-svg-spinner/#:~:text=When%20applied%20to%20an%20SVG,depending%20on%20the%20circle's%20radius.&text=A%20stroke%2Ddasharray%20of%20157,with%20r%3D%2245%22%20. -->\n      <!-- https://www.geeksforgeeks.org/arc-length-angle/ -->\n      <circle style=\"--arc-radians:calc(2 * 3.1416 * 10 * var(--arc-angle) / 360);\n                           --arc-start:calc((var(--arc-center) - (var(--arc-angle)) / 2) * 1deg);\n                           --arc-scale:calc(var(--arc-radius) / 10 / 2);                           \n                           transform:scale(var(--arc-scale)) rotate(var(--arc-start));\n                           opacity:var(--arc-opacity);\n                           \" cx=\"0\" cy=\"0\" r=\"10\" fill=\"transparent\" stroke=\"var(--arc-color)\" stroke-width=\"20\" stroke-dasharray=\"var(--arc-radians) calc(2 * 3.1416 * 10)\"></circle>\n      <circle style=\"transform:scale(var(--icon-size))\" cx=\"0\" cy=\"0\" r=\"1\" fill=\"white\" stroke-width=\"0\"></circle>\n      <text x=\"0\" y=\"0\" font-family=\"FontAwesome\" fill=\"var(--icon-color)\" stroke=\"none\" font-size=\"var(--icon-size)\" text-anchor=\"middle\" alignment-baseline=\"middle\" stroke-width=\"1\">fa-video-camera</text>\n    </g>\n  </defs>\n  <image width=\"100%\" height=\"100%\" id=\"background\" xlink:href=\"https://www.roomsketcher.com/wp-content/uploads/2016/10/1-Bedroom-Floor-Plans.jpg\" />\n  <!-- The x and y properties define an additional transformation (translate(x,y) on the group element. -->\n  <!-- See https://svgwg.org/svg2-draft/single-page.html#struct-UseLayout -->\n  <use xlink:href=\"#circle_definition\" href=\"#circle_definition\" id=\"my_camera\" x=\"680\" y=\"270\" style=\"--arc-angle:90;\n                --arc-radius:140;\n                --arc-center:225;\n                --arc-color:blue;\n                --arc-opacity:0.3;\n                --icon-size:25;\n                --icon-color:blue;\" />\n</svg>","clickableShapes":[],"javascriptHandlers":[],"smilAnimations":[{"id":"","targetId":"","classValue":"","attributeName":"transform","transformType":"rotate","fromValue":"","toValue":"","trigger":"msg","duration":"1","durationUnit":"s","repeatCount":"0","end":"restore","delay":"1","delayUnit":"s","custom":""}],"bindings":[],"showCoordinates":true,"autoFormatAfterEdit":false,"showBrowserErrors":true,"showBrowserEvents":false,"enableJsDebugging":false,"sendMsgWhenLoaded":false,"outputField":"payload","editorUrl":"http://drawsvg.org/drawsvg.html","directory":"","panning":"both","zooming":"enabled","panOnlyWhenZoomed":false,"doubleClickZoomEnabled":false,"mouseWheelZoomEnabled":false,"dblClickZoomPercentage":"200","name":"SVG with Javascript","x":1120,"y":460,"wires":[[]]},{"id":"c2e22011.78a16","type":"ui_slider","z":"7f1827bd.8acfe8","name":"","label":"--arc-radius","tooltip":"","group":"5ae1b679.de89c8","order":1,"width":0,"height":0,"passthru":true,"outs":"all","topic":"--arc-radius","min":"50","max":"240","step":"5","x":490,"y":460,"wires":[["5e94e0a9.f87bf"]]},{"id":"90f9f668.be25c8","type":"ui_slider","z":"7f1827bd.8acfe8","name":"","label":"--arc-opacity","tooltip":"","group":"5ae1b679.de89c8","order":4,"width":0,"height":0,"passthru":true,"outs":"all","topic":"--arc-opacity","min":"0","max":"1","step":"0.05","x":490,"y":640,"wires":[["5e94e0a9.f87bf"]]},{"id":"9ac69b92.2002e8","type":"ui_slider","z":"7f1827bd.8acfe8","name":"","label":"--icon-size","tooltip":"","group":"5ae1b679.de89c8","order":7,"width":0,"height":0,"passthru":true,"outs":"all","topic":"--icon-size","min":"20","max":"40","step":"1","x":490,"y":700,"wires":[["5e94e0a9.f87bf"]]},{"id":"5e94e0a9.f87bf","type":"change","z":"7f1827bd.8acfe8","name":"Update CSS variable","rules":[{"t":"set","p":"payload","pt":"msg","to":"{\t   \"command\":\"update_style\",\t   \"selector\":\"#my_camera\",\t   \"attributeName\":$.topic,\t   \"attributeValue\":$.payload\t}","tot":"jsonata"},{"t":"delete","p":"topic","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":860,"y":460,"wires":[["663c0bc1.051714"]]},{"id":"d13d682b.0da4e8","type":"inject","z":"7f1827bd.8acfe8","name":"Default radius","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"140","payloadType":"num","x":280,"y":460,"wires":[["c2e22011.78a16"]]},{"id":"7a2943eb.86efcc","type":"inject","z":"7f1827bd.8acfe8","name":"Default opacity","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"0.3","payloadType":"num","x":280,"y":640,"wires":[["90f9f668.be25c8"]]},{"id":"c01c7ed6.4fe48","type":"inject","z":"7f1827bd.8acfe8","name":"Default icon size","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"25","payloadType":"num","x":290,"y":700,"wires":[["9ac69b92.2002e8"]]},{"id":"adeba630.0fa0c8","type":"ui_slider","z":"7f1827bd.8acfe8","name":"","label":"--arc-angle","tooltip":"","group":"5ae1b679.de89c8","order":2,"width":0,"height":0,"passthru":true,"outs":"all","topic":"--arc-angle","min":"0","max":"360","step":"2","x":490,"y":520,"wires":[["5e94e0a9.f87bf"]]},{"id":"62229620.48cdc8","type":"inject","z":"7f1827bd.8acfe8","name":"Default angle","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"90","payloadType":"num","x":280,"y":520,"wires":[["adeba630.0fa0c8"]]},{"id":"c9e6880c.95d8f8","type":"ui_slider","z":"7f1827bd.8acfe8","name":"","label":"--arc-center","tooltip":"","group":"5ae1b679.de89c8","order":3,"width":0,"height":0,"passthru":true,"outs":"all","topic":"--arc-center","min":"0","max":"360","step":"2","x":490,"y":580,"wires":[["5e94e0a9.f87bf"]]},{"id":"44840ddf.3c0824","type":"inject","z":"7f1827bd.8acfe8","name":"Default center","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"225","payloadType":"num","x":280,"y":580,"wires":[["c9e6880c.95d8f8"]]},{"id":"549507b4.9af478","type":"ui_colour_picker","z":"7f1827bd.8acfe8","name":"","label":"--arc-color","group":"5ae1b679.de89c8","format":"hex","outformat":"string","showSwatch":true,"showPicker":false,"showValue":false,"showHue":false,"showAlpha":false,"showLightness":true,"square":"false","dynOutput":"false","order":5,"width":0,"height":0,"passthru":false,"topic":"--arc-color","x":490,"y":760,"wires":[["24093c69.0c72f4"]]},{"id":"24093c69.0c72f4","type":"function","z":"7f1827bd.8acfe8","name":"\"#\"...","func":"// The hex color value should have a \"#\" as prefix ...\nmsg.payload = \"#\" + msg.payload;\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","x":650,"y":760,"wires":[["5e94e0a9.f87bf"]]},{"id":"8f3a39ff.bd3a88","type":"inject","z":"7f1827bd.8acfe8","name":"Default arc color","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"blue","payloadType":"str","x":290,"y":760,"wires":[["549507b4.9af478"]]},{"id":"bcad582f.7f62a8","type":"ui_colour_picker","z":"7f1827bd.8acfe8","name":"","label":"--icon-color","group":"5ae1b679.de89c8","format":"hex","outformat":"string","showSwatch":true,"showPicker":false,"showValue":false,"showHue":false,"showAlpha":false,"showLightness":true,"square":"false","dynOutput":"false","order":8,"width":0,"height":0,"passthru":false,"topic":"--icon-color","x":490,"y":820,"wires":[["24093c69.0c72f4"]]},{"id":"7296c6a.74ff438","type":"inject","z":"7f1827bd.8acfe8","name":"Default icon color","props":[{"p":"payload"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"blue","payloadType":"str","x":290,"y":820,"wires":[["bcad582f.7f62a8"]]},{"id":"5ae1b679.de89c8","type":"ui_group","z":"","name":"Camera angle demo","tab":"3667e211.c08f0e","order":1,"disp":true,"width":"12","collapse":false},{"id":"3667e211.c08f0e","type":"ui_tab","name":"Home","icon":"dashboard","order":1,"disabled":false,"hidden":false}]

Which gives the following result:

P.S. I also tried to add a thumbnail image to my camera definition, like in this discussion. However that seems not possible: the SVG image element has a xlink:href attribute (which I need to update to push new images to it). However that is not a CSS property, so I cannot use a CSS variable for it. Moreover I cannot access that image element via Javascript, since the definition is "use"d in a closed shadow dom (which means you cannot access it) :roll_eyes:

Not sure what would be the best way to have the community cooperate to share such customizable widgets (sprinklers, ...)?

As always, all 'constructive' ideas are welcome!

Hope you like it as much as I do...
Bart

3 Likes

Very nice and potentially usable with the uibuilder SVG example I wrote when you released the Dashboard SVG node.

I'm surprised you can't automate the xlink though. I seem to remember that in uibuilder with Vue pretty much anything could be data driven inside the SVG. I would have though that would hold true for Angular as well.

Hi Julian, thanks for joining.
Sometimes I get the impression that all roads lead to uibuilder...
The uibuilder team's sales manager must be near to a burnout :wink:

I tried everything within my powers (to set a new value in the xlink:href attribute of the image element inside the definition), but nothing helps:

  1. xlink:href is not a css attribute, so cannot access it e.g. using a css variable (as you can see in this Stackoverflow discussion).

  2. The use element uses the definition as a shadow dom, so the shadow dom will be rendered (instead of the element's children):

    image

  3. As you can see in the above screenshot, the shadow dom is closed. Following this Stackoverflow discussion this means the shadow dom elements are not accessible via Javascript.

  4. It is also not possible to access the shadow dom elements via CSS selectors:

    image

  5. From this article I understand there are not much cross-browser solutions:

First I though I could perhaps - to solve point 4 - use the query-selector-shadow-dom package in the SVG node (instead of the standard querySelectorAll function), however I don't think that can work (due to the previous points).

But if you have any ideas or workarounds, please let me know!!!

The definition works as content storage of something to be used later on. It is against the logic that you try to change it.
At 1 past midnight I don't have solution to provide of course :stuck_out_tongue:

1 Like

Yes, but initially I had hoped that the definition's dom tree would be copied for each use instance. But that isn't the case, so therefore indeed I don't think I can solve this...
Would have been nice if I could have added the video thumbnail viewer inside the definition. Because it would have been very easy for users to setup video surveillance on their floorplans. But don't think I will be able to go all the way ...

Well speaking as an unbiased observer, I think you might be right! Good enough for the Romans, good enough for me.

Thankfully, the market is fairly small so I only need to advertise in one place :wink:

And of course, it pretty much sells itself to the right audience :smiley: Still, he doesn't get to rest just because it is Christmas :rofl:

To be honest, I never thought I'd write some software that consistently averages 2k downloads a month! Nearly 84k downloads so far.

Well, they will be much greater than mine!

Perhaps there is another way to achieve the same look though?

1 Like

One last thing about this. This article explains the difference between an open and closed shadow dom. With an open shadow dom, you could find an element (and manipulate it) of the shadow dom via the shadowRoot. Suppose the shadow dom tree would have been open, then I assume I could have searched through the shadow dom tree like this:

// Get the "use" element in the dom tree (= shadow host element)
var myUseElement = document.querySelectorAll("#my_camera");

// Get the root node of the shadow dom tree
var shadowRoot = myUseElement.shadowRoot;

// Search through the open shadow dom tree, and find the element with tag name "image"
var my ImageElement = shadowRoot.querySelectorAll("image");

But now it doesn't work that way. Because the shadow dom is closed, the shadowRoot will be null:

image

So I have no access to the closed shadow dom tree :roll_eyes: