How to set up layers for different floorplans

I don’t seem to be able to set up different layers and toggle between them with buttons from the main floorplan as described by @ryanrdetzel . The only documentation I could find was here:
https://github.com/pkozul/ha-floorplan/pull/41/files
and this video:
https://www.youtube.com/watch?v=wHhAS8bUdds

I’ve reproduced all steps and created a custom_js.js file like this:

/**
  Pages start at 0 and go to 9
  Objects named 'show.pageN' where N is a number from 0-9 will show page N when clicked
  Objects named 'show.toggle' will cycle through all pages
  Layers in the SVG should be named page0-page9
  page0 is the default starting page 
*/ 
$( document ).on( "floorplan:loaded", function( event, arg, svg ) {
	var currentPage = 0;
	var pages = [];

	for (var x=0;x<10;x++){
		let page = $(svg).find(`[id="page${x}"]`)
		if (page.length === 1) pages.push(page);
	}

	function showPage(pageToShow=0){
		$.each(pages, function(n, pg){
			if (n === pageToShow){
				$(pg).show();
				currentPage = pageToShow;
			}else{
				$(pg).hide();
			}	
		});
	}

	showPage(currentPage);
	$.each($(svg).find("[id^='show.']"), function(i, e){
                $(e).css('cursor', 'pointer');
		$(e).click(function() {
			var pageName = $(e).attr('id').split('.')[1];
			if (pageName === "toggle"){
				currentPage++;
				if (currentPage >= pages.length) currentPage = 0;
				showPage(currentPage);
			}else{
				let pageNumber = pageName.replace('page','');
				showPage(parseInt(pageNumber));
			}
		});
	});
});

I’ve also added the 2 lines in www/custom_ui/floorplan/ha-floorplan.html:

Line 10

<script src="lib/custom_js.js"></script>

and Line 166

$(document).trigger( "floorplan:loaded", [this, svg]);

5 layers with different floorplans are defined in my svg as page0 to page5. Buttons are linking to these. These are blocks with id’s as follows show.page0 to show.page5.
When launching floorplan, the magic does not happen. Help is needed…

Here are some questions to start off:

  1. Should all layers be made visible in Inkscape?
  2. Can we have a different layer with the menu/toggles that always stays on or should these be copied to each layer? Does this layer need to have a name in particular?
  3. Am I missing a step?

Any help would be much appreciated!

3 Likes

Found out that changing the label name in Inkscape does not change its id. This needs to be done manually as follows:
id=“page5”
inkscape:label=“page5”
This still does not do the trick though…

Here is the code used for buttons:

<rect
   style="opacity:1;fill:#f4eed7;fill-opacity:1;stroke:url(#linearGradient1295);stroke-width:0.5;stroke-linecap:butt;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
   id="show.page0"
   width="238.28081"
   height="53.459202"
   x="4.8104739"
   y="279.93195"
   ry="6.9455667"
   inkscape:label="show.page0">
</rect>
<text
   xml:space="preserve"
   style="font-style:normal;font-weight:normal;font-size:33.6531868px;line-height:1;font-family:sans-serif;text-align:center;letter-spacing:0px;word-spacing:0px;text-anchor:middle;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none"
   x="110.64246"
   y="356.94653"
   id="text1343"
   transform="scale(1.1242983,0.88944367)"><tspan
     sodipodi:role="line"
     id="tspan1341"
     x="110.64246"
     y="356.94653"
     style="font-size:33.46704102px;stroke-width:0.5;stroke-miterlimit:4;stroke-dasharray:none">Groundfloor</tspan></text>

id=“page5”
inkscape:label=“page5”
Where, and in which file do you put that data?

this is in the xml within floorplan.svg. Just open your svg with a text editor like Atom and search for this in the file. Hope this helps!

Thank you! Finally can I call the layers in floorplan.yaml the same as the ones in my svg file :slight_smile:
For your problem I would use the same solution @heggico in post #555 here:

The problem is that I don`t where to excactly put this:

    case 'select':
      for (let otherElement of action.data.elements) {
        let otherSvgElement = $(svg).find(`[id="${otherElement.id}"]`);
        let otherSvgElementclass = otherElement.class;
        
        $(otherSvgElement).removeClass().addClass(otherSvgElementclass);
      }
    break; 

So for now I just use the toogle function to switch between the layers.
But if you figure how to paste that in the right place, pleease let me know!

1 Like

Hi @Zepixir,
Yes, this is exactly where I left it last night @1am :rofl:

I can toggle layers between hidden and visible status individually. This means when you change to a different layer, you need to make your visible layer hidden then make the hidden layer visible. Total mind fuck and not a solution that users can enjoy.

I did add @heggico 's code in Inkscape with no success. I added it in the Object Properties (Shift+Ctrl+O), on the interactivity tab’s onclick property. Next step is to add it directly to the Xml properties and see if it works…

Unless I get some assistance getting the custom_js.js to work properly, of course! This would be a much more elegant solution! @sgaumont, please don’t forget me

@Zepixir, this is what the xml should look like:

       style="opacity:1;fill:#f4eed7;fill-opacity:1;stroke:#000000;stroke-width:0.5;stroke-linecap:butt;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
       id="show.page5"
       width="238.31969"
       height="53.49572"
       x="4.7910037"
       y="587.78088"
       ry="7.2382884"
       inkscape:label="show.page5"
       onclick="case 'select':
              for (let otherElement of action.data.elements) {
                let otherSvgElement = $(svg).find(`[id="${otherElement.id}"]`);
                let otherSvgElementclass = otherElement.class;

                $(otherSvgElement).removeClass().addClass(otherSvgElementclass);
              }
            break;">
      <title
         id="title3707">Climate</title>
    </rect>```

Note to Inkscape users: everytime the svg is saved, Inkscape changes the id tag… PITA, but if things no more work, check the xml:

<g
     inkscape:groupmode="layer"
     id="page5"
     inkscape:label="page5"
     style="display:none" />```

Mine doesn’t change the ID tag when I save :), tho’ I notice you use the label tag which should have a # at the start (note the label tag is not used by floorplan and doesn’t need to be changed from whatever inkscape defines)

@keithh666 you’re right. Thanks for that!
It does not get changed, if you use the xml editor within Inkscape (Ctrl+Shift+X). If you use an external editor it does. I set all layers id within Inkscape and it did the trick!

Why would you even use the xml editor when you have this…

image

Just copy/paste the ID into the ID box and hit the set button - job done :slight_smile:

This is not for layer properties… Does the xml editor in Inkscape have a search function?

I see what you mean, you can’t change the ID of the layer they are just as set by Inkscape, the rename function only changes the label, it seems you can only change the layer id in the xml editor. CTRL-F works in the xml editor

1 Like

OK, I’ll now use the Inkscape xml rather than Atom then… Cheers

It seems you can change the layer ID in the internal editor…

image

Seems to miss something here.
Tried to implement this into the svg-file, but now it won`t load in the UI:

   inkscape:connector-curvature="0"
   d="m 317.56125,1070.458 c 8.05414,0 14.58266,6.1591 14.58266,12.1873 v 81.3821 c 13.67682,5.0409 23.09135,15.5725 23.09135,27.7549 0,17.0804 -18.4985,30.9257 -41.31969,30.9257 -22.82119,0 -41.31969,-13.8453 -41.31969,-30.9257 0,-12.1824 9.41453,-22.714 23.09136,-27.7549 v -81.3821 c 0,-6.0282 6.5285,-12.1873 14.58267,-12.1873 h 7.29134 m 0,-9.0968 h -7.29134 c -14.2426,0 -26.73702,9.9438 -26.73702,21.2818 v 76.1938 c -14.41105,7.4056 -23.09136,19.5689 -23.09136,32.9455 0,22.0691 23.98767,40.0226 53.47404,40.0226 29.48637,0 53.47404,-17.9535 53.47404,-40.0226 0,-13.3766 -8.68031,-25.5399 -23.09134,-32.9455 v -76.1915 c 0,-11.338 -12.49445,-21.2841 -26.73702,-21.2841 z m 18.53983,130.6423 c 0,-6.3732 -4.75814,-12.0896 -12.41817,-14.9134 l -9.76734,-3.5993 -9.76734,3.5993 c -7.66004,2.8238 -12.42134,8.5378 -12.42134,14.9134 0,9.1564 9.95169,16.6047 22.1855,16.6047 12.23382,0 22.18869,-7.4483 22.18869,-16.6047 z m 6.42044,-46.3076 c 0,-1.3133 -1.42078,-2.379 -3.17843,-2.379 h -12.71376 c -1.75768,0 -3.17844,1.0657 -3.17844,2.379 0,1.3129 1.42076,2.3788 3.17844,2.3788 h 12.71376 c 1.75765,0 3.17843,-1.0659 3.17843,-2.3788 z m 0,-18.194 c 0,-1.3132 -1.42078,-2.3789 -3.17843,-2.3789 h -12.71376 c -1.75768,0 -3.17844,1.0657 -3.17844,2.3789 0,1.3132 1.42076,2.3788 3.17844,2.3788 h 12.71376 c 1.75765,0 3.17843,-1.0633 3.17843,-2.3788 z m 0,-18.1915 c 0,-1.3132 -1.42078,-2.379 -3.17843,-2.379 h -12.71376 c -1.75768,0 -3.17844,1.0658 -3.17844,2.379 0,1.3132 1.42076,2.3789 3.17844,2.3789 h 12.71376 c 1.75765,0 3.17843,-1.0657 3.17843,-2.3789 z m 0,-18.1939 c 0,-1.3133 -1.42078,-2.3789 -3.17843,-2.3789 h -12.71376 c -1.75768,0 -3.17844,1.0656 -3.17844,2.3789 0,1.3132 1.42076,2.3789 3.17844,2.3789 h 12.71376 c 1.75765,0 3.17843,-1.0657 3.17843,-2.3789 z m -23.8383,84.0134 v -57.0937 c 0,-1.9721 -2.13271,-3.5682 -4.76765,-3.5682 -2.63492,0 -4.76765,1.5961 -4.76765,3.5682 v 57.0937 c 0,1.9697 2.13273,3.5684 4.76765,3.5684 2.63494,0 4.76765,-1.5987 4.76765,-3.5684 z"
   id="hhhgg"
   style="fill:#ffffff;fill-opacity:1;stroke-width:2.74976397"
   inkscape:label="#path2" /><rect
   style="display:inline;opacity:0;fill:#0000ff;fill-rule:evenodd"
   id="button_lights_layer"
   width="248.58289"
   height="207.1524"
   x="15.934801"
   y="1046.1938"
   inkscape:label="#button_lights_layer"
   onclick="case 'select':
          for (let otherElement of action.data.elements) {
            let otherSvgElement = $(svg).find(`[id="${otherElement.id}"]`);
            let otherSvgElementclass = otherElement.class;

            $(otherSvgElement).removeClass().addClass(otherSvgElementclass);
          }
        break;">
  <title
     id="title395">button_lights_layer</title>
</rect><
   style="opacity:0;fill:#0000ff;fill-rule:evenodd"
   id="button_hue_layer"
   width="258.14377"
   height="248.58289"
   x="3.1869602"
   y="1224.6637"
   inkscape:label="#rect3765" /><rect
   style="opacity:0;fill:#0000ff;fill-rule:evenodd"
   id="button_temp_layer"
   width="172.09584"
   height="232.64809"
   x="254.95682"
   y="1020.6982"
   inkscape:label="#rect3769" /></g></svg>

Thoght it was supposed to go into the html file under onElementClick(e function.

But what am I doing wrong if you got it to work?

OK, I’ve finally gotten this to work with my 5 layers/floorplans thanks to great help from another user who provided the .js and .html codes. Here are the steps to reproduce:
-Create a custom_js.js file and copy it to your floorplan/lib folder. The code can be found in the next post.
-Create a ha-floorplan.html and copy it to your floorplan folder. The code can be found in the next post.
-Open your floorplan.svg with Inkscape. Create a new layer. Create one button for each floorplan. These can be simple rectangles or any shape.
-Change each button’s ID tag’s value to show.page0 or display.page0. You can have up to 10 “pages” (0-9). The ID should be changed using the Inkscape XML Editor (Shift+Ctrl+X). Using an external editor will require you change the ID after each save in Inkscape!
-Create a layer for each floorplan you wish to use. Change each floorpan’s ID to page0 through page9, as described above. You can keep each layer visible in Inkscape!
-Clean your browser’s cache!
-Restart Home Assistant
-Voila!

Your page0 layer will display on load. Clicking show.page1 will render page0 hidden and make page1 visible. No changes to floorplan.yaml is needed!

3 Likes

Here is the code for custom_js.js:

  Pages start at 0 and go to 9
  Objects named 'show.pageN' where N is a number from 0-9 will show page N when clicked
  Objects named 'show.toggle' will cycle through all pages
  Layers in the SVG should be named page0-page9
  page0 is the default starting page 
*/ 
$( document ).on( "floorplan:loaded", function( event, arg, svg ) {
	var currentPage = 0;
	var pages = [];

	for (var x=0;x<10;x++){
		let page = $(svg).find(`[id="page${x}"]`)
		if (page.length === 1) pages.push(page);
	}

	function showPage(pageToShow=0){
		$.each(pages, function(n, pg){
			if (n === pageToShow){
				$(pg).show();
				currentPage = pageToShow;
			}else{
				$(pg).hide();
			}	
		});
	}

	showPage(currentPage);
	
	$.each($(svg).find("[id^='display.']"), function(i, e){
                $(e).css('cursor', 'pointer');
		$(e).click(function() {
			var pageName = $(e).attr('id').split('.')[1];
			if (pageName === "toggle"){
				currentPage++;
				if (currentPage >= pages.length) currentPage = 0;
				showPage(currentPage);
			}else{
				let pageNumber = pageName.replace('page','');
				showPage(parseInt(pageNumber));
			}
		});
	});
	
	$.each($(svg).find("[id^='show.']"), function(i, e){
                $(e).css('cursor', 'pointer');
		$(e).click(function() {
			var pageName = $(e).attr('id').split('.')[1];
			if (pageName === "toggle"){
				currentPage++;
				if (currentPage >= pages.length) currentPage = 0;
				showPage(currentPage);
			}else{
				let pageNumber = pageName.replace('page','');
				showPage(parseInt(pageNumber));
			}
		});
	});
});

And, here is the code for ha-floorplan.html:

  Floorplan for Home Assistant
  Version: 1.0.6
  https://github.com/pkozul/ha-floorplan
-->

<script src="lib/jquery-3.2.1.min.js"></script>
<script src="lib//moment.min.js"></script>
<script src="lib/svg-pan-zoom.min.js"></script>
<script src="lib/custom_js.js"></script>

<!-- As documented here for chrome, removes the need for touchstart -->
<meta name="viewport" content="width=device-width">

<dom-module id="ha-floorplan">

  <template>
    <style>
      .loading-container {
        text-align: center;
        padding: 8px;
      }

      .loading {
        height: 0px;
        overflow: hidden;
      }

      #errors {
        color: #FF0000;
        display: none;
      }

      #warnings {
        color: #FF851B;
        display: none;
      }

      #debug {
        color: #000000;
        display: none;
      }
    </style>

    <template is='dom-if' if='[[isLoading]]'>
      <div class='loading-container'>
        <paper-spinner active alt='Loading'></paper-spinner>
      </div>
    </template>

    <div id="errors">
      <ul></ul>
    </div>

    <div id="warnings">
      <ul></ul>
    </div>

    <div id="debug">
      <ul></ul>
    </div>

    <div id="floorplan" on-tap="stopPropagation"></div>

  </template>

</dom-module>

<script>
  Polymer({
    is: 'ha-floorplan',

    ready() {
    },

    attached() {
      this.onAttached();
    },

    detached() {
    },

    properties: {
      hass: {
        type: Object,
        observer: 'hassChanged'
      },
      inDialog: {
        type: Boolean,
        value: false,
      },
      isPanel: {
        type: Boolean,
        value: false,
      },
      config: {
        type: Object,
      },
      isLoading: {
        type: Boolean,
        value: true,
      },
      timeDifference: {
        type: Number,
        value: undefined,
      },
      entityConfigs: {
        type: Array,
        value: () => { return []; },
      },
      elementConfigs: {
        type: Array,
        value: () => { return []; },
      },
      cssRules: {
        type: Array,
        value: () => { return []; },
      },
    },

    stopPropagation(e) {
      e.stopPropagation();
    },

    hassChanged: function (newHass, oldHass) {
      this.handleEntities(newHass.states);
    },

    onAttached() {
      window.onerror = this.handleWindowError.bind(this);

      if (!this.config.groups) {
        this.isLoading = false;
        this.warn(`Cannot find 'groups' in floorplan configuration`);
        return;
      }

      let invalidGroups = this.config.groups.filter(x => x.entities && x.elements);
      if (invalidGroups.length) {
        this.isLoading = false;
        this.warn(`A group cannot contain both 'entities' and 'elements' in floorplan configuration`);
        return;
      }

      invalidGroups = this.config.groups.filter(x => !x.entities && !x.elements);
      if (invalidGroups.length) {
        this.isLoading = false;
        this.warn(`A group must contain either 'entities' or 'elements' in floorplan configuration`);
        return;
      }

      this.hass.connection.socket.addEventListener('message', event => {
        let data = JSON.parse(event.data);

        // Store the time difference between the local web browser and the Home Assistant server
        if (data.event && data.event.time_fired) {
          let lastEventFiredTime = moment(data.event.time_fired).toDate();
          this.timeDifference = moment().diff(moment(lastEventFiredTime), 'milliseconds');
        }
      });

      this.addExternalCss(() => {
        this.loadFloorPlan((svg) => {
          this.isLoading = false;
          this.handleEntities(this.hass.states);
		  $(document).trigger( "floorplan:loaded", [this, svg]);
        });
      });

      if (this.config.groups.find(entityGroup => entityGroup.state_transitions)) {
        setInterval(this.updateStateTransitions.bind(this), 100);
      }
    },

    handleWindowError(msg, url, lineNo, columnNo, error) {
      if (msg.toLowerCase().indexOf("script error") >= 0) {
        this.error('Script error: See browser console for detail');
      }
      else {
        let message = [
          msg,
          'URL: ' + url,
          'Line: ' + lineNo + ', column: ' + columnNo,
          'Error: ' + JSON.stringify(error)
        ].join('<br>');

        this.error(message);
      }

      return false;
    },

    addExternalCss(callback) {
      if (!this.config.stylesheet) {
        callback();
      }

      this.loadStyleSheet(this.config.stylesheet + '?cacheBuster=' + (new Date().getTime()), function (success, link) {
        if (success) {
          Polymer.dom(this.instance.root).appendChild(link);
          let styleSheet = link['sheet'];
          this.instance.cssRules = this.instance.getArray(styleSheet.cssRules);
          callback();
        }
        else {
          this.instance.error("Error loading stylesheet");
        }
      }.bind({ instance: this, callback: callback }));
    },

    loadFloorPlan(callback) {
      jQuery.ajax({
        url: this.config.image + '?cacheBuster=' + (new Date().getTime()),
        success: function (result) {
          let svg = $(result).find('svg')[0];

          $(svg).height('100%');
          $(svg).width('100%');
          $(svg).css('position', this.instance.isPanel ? 'absolute' : 'relative');
          $(svg).css('cursor', 'default');

          Polymer.dom(this.instance.$.floorplan).node.appendChild(svg);

          let uniqueId = (new Date()).getTime();

          let svgElements = $(svg).find('*').toArray();

          let elementGroups = this.instance.config.groups.filter(x => x.elements);
          for (let elementGroup of elementGroups) {
            for (let elementId of elementGroup.elements) {
              let svgElement = $(svg).find(`[id="${elementId}"]`);

              if (svgElement.length) {
                $(svgElement).on('click', this.instance.onElementClick.bind({ instance: this.instance, elementId: elementId }));
                $(svgElement).css('cursor', 'pointer');

                let elementConfig = {
                  group: elementGroup,
                };

                this.instance.elementConfigs[elementId] = elementConfig;

                if (elementGroup.action.data.elements) {
                  for (let otherElementId of elementGroup.action.data.elements) {
                    let otherSvgElement = $(svg).find(`[id="${otherElementId}"]`);
                    $(otherSvgElement).addClass(elementGroup.action.data.default_class);
                  }
                }
              }
              else {
                this.instance.warn(`Cannot find '${elementId}' in SVG file`);
              }
            }
          }

          let entityGroups = this.instance.config.groups.filter(x => x.entities);
          for (let entityGroup of entityGroups) {
            let targetEntityIds = [];

            // Split out HA entity groups into separate entities
            if (entityGroup.groups) {
              for (let entityId of entityGroup.groups) {
                let group = this.instance.hass.states[entityId];
                if (group) {
                  for (let targetEntityId of group.attributes.entity_id) {
                    targetEntityIds.push(targetEntityId);
                  }
                }
                else {
                  this.instance.warn(`Cannot find '${entityId}' in HA group configuration`);
                }
              }
            }

            // HA entities treated as is
            if (entityGroup.entities) {
              for (let entityId of entityGroup.entities) {
                let entity = this.instance.hass.states[entityId];
                if (entity) {
                  targetEntityIds.push(entityId);
                }
                else {
                  this.instance.warn(`Cannot find '${entityId}' in HA group configuration`);
                }
              }
            }

            for (let entityId of targetEntityIds) {
              let entityConfig = {
                group: entityGroup,
                lastState: undefined,
                lastChangedTime: undefined,
                svgElementConfigs: {},
                imageUrl: undefined
              };

              this.instance.entityConfigs[entityId] = entityConfig;

              let svgElement = svgElements.find(svgElement => svgElement.id === entityId);
              if (!svgElement) {
                this.instance.warn(`Cannot find element '${entityId}' in SVG file`);
                continue;
              }

              entityConfig.svgElementConfigs[svgElement.id] = {
                svgElementId: svgElement.id,
                svgElement: svgElement,
                clonedsvgElement: svgElement.cloneNode(true),
                entityId: entityId
              };

              $(svgElement).find('*').each((i, svgNestedElement) => {
                // Ensure that all child elements have an Id.
                if (!svgNestedElement.id) {
                  svgNestedElement.id = uniqueId++;
                }

                entityConfig.svgElementConfigs[svgNestedElement.id] = {
                  svgElementId: svgNestedElement.id,
                  svgElement: svgNestedElement,
                  clonedsvgElement: svgNestedElement.cloneNode(true),
                  entityId: entityId
                };
              });

              for (svgElementId in entityConfig.svgElementConfigs) {
                let svgElementConfig = entityConfig.svgElementConfigs[svgElementId];

                let svgElement = $(svgElementConfig.svgElement);

                // Create a title element (to support hover over text)
                svgElement.append(document.createElementNS('http://www.w3.org/2000/svg', 'title'));

                if (svgElement.length) {
                  svgElementConfig.svgElement = svgElement[0];

                  $(svgElement).on('click', this.instance.onEntityClick.bind({ instance: this.instance, entityId: entityId }));
                  $(svgElement).css('cursor', 'pointer');
                  $(svgElement).addClass('ha-entity');

                  if ((svgElement[0].nodeName === 'text') && (svgElement[0].id === entityId)) {
                    let boundingBox = svgElement[0].getBBox();
                    let rect = $(document.createElementNS("http://www.w3.org/2000/svg", 'rect'))
                      .attr('id', entityId + '.background')
                      .attr('height', boundingBox.height + 1)
                      .attr('width', boundingBox.width + 2)
                      .height(boundingBox.height + 1)
                      .width(boundingBox.width + 2)
                      .attr('x', boundingBox.x - 1)
                      .attr('y', boundingBox.y - 0.5)
                      .css('fill-opacity', 0);

                    $(rect).insertBefore($(svgElement));
                  }
                }
              }
            }
          }

          // Enable pan / zoom if enabled in config
          if ((this.instance.config.pan_zoom === null) || (this.instance.config.pan_zoom !== undefined)) {
            svgPanZoom($(svg)[0], {
              zoomEnabled: true,
              controlIconsEnabled: true,
              fit: true,
              center: true,
            });
          }

          this.callback(svg);

        }.bind({ instance: this, callback: callback })
      });
    },

    handleEntities(entities) {
      let svg = Polymer.dom(this.$.floorplan).querySelector('svg');

      for (let entityId in entities) {
        let entityState = entities[entityId];

        let entityConfig = this.entityConfigs[entityId];
        if (!entityConfig)
          continue;

        entityConfig.lastState = entityState.state;

        for (let svgElementId in entityConfig.svgElementConfigs) {
          let svgElementConfig = entityConfig.svgElementConfigs[svgElementId];
          let svgElement = svgElementConfig.svgElement;

          if (!svgElement)
            continue;

          this.setHoverOverText(svgElement, entityState);

          if (svgElement.nodeName === 'text') {
            let text = entityConfig.group.text_template ?
              this.assemble(entityConfig.group.text_template, entityState, entities) : entityState.state;

            let tspan = $(svgElement).find('tspan');
            if (tspan.length) {
              $(tspan).text(text);
            }
            else {
              let title = $(svgElement).find('title');
              $(svgElement).text(text);
              if (title.length) {
                $(svgElement).append(title);
              }
            }

            let rect = $(svgElement).parent().find(`[id="${entityId}.background"]`);
            if (rect.length) {
              let boundingBox = svgElement.getBBox();
              $(rect)
                .attr("x", boundingBox.x - 1)
                .attr("y", boundingBox.y - 0.5)
                .attr('height', boundingBox.height + 1)
                .attr('width', boundingBox.width + 2)
                .height(boundingBox.height + 1)
                .width(boundingBox.width + 2);
            }
          }

          if (!this.cssRules || !this.cssRules.length)
            return;

          let wasTransitionHandled = false;

          if (entityConfig.group.states && entityConfig.group.state_transitions) {
            let transitionConfig = entityConfig.group.state_transitions.find(transitionConfig => (transitionConfig.to_state === entityState.state));
            if (transitionConfig && transitionConfig.from_state && transitionConfig.to_state && transitionConfig.duration) {
              // Determine the current time on the server (based on the local vs. server time difference)
              let serverMoment = this.getServerMoment();
              let lastChangedMoment = moment(entityState.last_changed);
              let elapsed = Math.max(serverMoment.diff(lastChangedMoment, 'milliseconds'), 0);
              let remaining = (transitionConfig.duration * 1000) - elapsed;

              if (remaining > 0) {
                entityConfig.lastChangedTime = lastChangedMoment.toDate();
              }
              else {
                this.setEntityStyle(svgElementConfig, svgElement, entityConfig);
              }
              wasTransitionHandled = true;
            }
          }

          if (entityConfig.group.image_template) {
            imageUrl = this.assemble(entityConfig.group.image_template, entityState, entities);
            if (entityConfig.imageUrl !== imageUrl) {
              entityConfig.imageUrl = imageUrl;
              this.loadImage(imageUrl, entityId, entityState, (embeddedSvg, entityState) => {
                this.setHoverOverText(embeddedSvg, entityState);
              });
            }

            let embeddedSvg = $(svg).find(`[id="image.${entityId}"]`)[0];
            this.setHoverOverText(embeddedSvg, entityState);
          }

          let targetClass = undefined;
          let obsoleteClasses = [];

          if (entityConfig.group.class_template) {
            targetClass = this.assemble(entityConfig.group.class_template, entityState, entities);
          }

          let originalClasses = this.getArray(svgElementConfig.clonedsvgElement.classList);

          // Get the config for the current state
          if (entityConfig.group.states) {
            let stateConfig = entityConfig.group.states.find(stateConfig => (stateConfig.state === entityState.state));
            if (stateConfig && stateConfig.class && !wasTransitionHandled) {
              targetClass = stateConfig.class;
            }

            // Remove any other previously-added state classes
            for (let otherStateConfig of entityConfig.group.states) {
              if (!stateConfig || (otherStateConfig.state != stateConfig.state)) {
                if (otherStateConfig.class && (otherStateConfig.class != 'ha-entity') && $(svgElement).hasClass(otherStateConfig.class)) {
                  if (originalClasses.indexOf(otherStateConfig.class) < 0) {
                    obsoleteClasses.push(otherStateConfig.class);
                  }
                }
              }
            }
          }
          else {
            for (let otherClassName of this.getArray(svgElement.classList)) {
              if ((otherClassName != targetClass) && (otherClassName != 'ha-entity')) {
                if (originalClasses.indexOf(otherClassName) < 0) {
                  obsoleteClasses.push(otherClassName);
                }
              }
            }
          }

          // Remove any obsolete classes from the entity
          this.removeClasses(entityId, svgElement, obsoleteClasses);

          // Add the target class to the entity
          if (targetClass) {
            this.addClass(entityId, svgElement, targetClass);
          }

          if (this.config.last_motion_entity && this.config.last_motion_class && entities[this.config.last_motion_entity] &&
            (entityState.attributes.friendly_name === entities[this.config.last_motion_entity].state)) {
            if (!$(svgElement).hasClass(this.config.last_motion_class)) {
              $(svgElement).addClass(this.config.last_motion_class);
            }
          }
          else {
            if ($(svgElement).hasClass(this.config.last_motion_class)) {
              $(svgElement).removeClass(this.config.last_motion_class);
            }
          }
        }
      }
    },

    setHoverOverText(element, entityState) {
      let title = $(element).find('title');
      if (title.length) {
        let dateFormat = this.config.date_format ? this.config.date_format : 'DD-MMM-YYYY';
        let titleText = entityState.attributes.friendly_name + '\n' +
          'State: ' + entityState.state + '\n' +
          'Last changed date: ' + moment(entityState.last_changed).format(dateFormat) + '\n' +
          'Last changed time: ' + moment(entityState.last_changed).format('HH:mm:ss');

        $(title).html(titleText);
      }
    },

    loadImage(imageUrl, entityId, entityState, callback) {
      let svg = Polymer.dom(this.$.floorplan).querySelector('svg');
      jQuery.ajax({
        url: imageUrl, // allow the browser cache to be used
        success: function (result) {
          let svgElement = $(svg).find(`[id="${entityId}"]`);
          let bbox = svgElement[0].getBBox();
          let clientRect = svgElement[0].getBoundingClientRect();

          let embeddedSvg = $(result).find('svg');

          embeddedSvg.attr('id', `image.${entityId}`);
          embeddedSvg.attr('preserveAspectRatio', 'xMinYMin meet')
          embeddedSvg
            .attr('height', bbox.height)
            .attr('width', bbox.width)
            .attr('x', bbox.x)
            .attr('y', bbox.y);

          $(embeddedSvg).find('*').append(document.createElementNS('http://www.w3.org/2000/svg', 'title'))
            .on('click', this.onEntityClick.bind({ instance: this, entityId: entityId }))
            .css('cursor', 'pointer')
            .addClass('ha-entity');

          // Remove previous SVG
          let previousEmbeddedSvg = $(svg).find(`[id="${embeddedSvg.attr('id')}"]`);
          $(previousEmbeddedSvg).find('*')
            .off('click')
            .remove();

          $(svg).append(embeddedSvg);

          callback(embeddedSvg, entityState);

        }.bind(this)
      });
    },

    addClass(entityId, svgElement, className) {
      if ($(svgElement).hasClass('ha-leave-me-alone')) {
        return;
      }

      if (!$(svgElement).hasClass(className)) {
        //console.log(`${entityId}: adding class "${className}" for current state "${entityState.state}" (${svgElement.id})`);
        $(svgElement).addClass(className);

        if ((svgElement.nodeName === 'text')) {
          let rect = $(svgElement).parent().find(`[id="${entityId}.background"]`);
          if (rect.length) {
            if (!$(rect).hasClass(className + '-background')) {
              $(rect).addClass(className + '-background');
            }
          }
        }
      }
    },

    removeClasses(entityId, svgElement, classes) {
      for (className of classes) {
        //console.log(`${entityId}: removing class "${className}" (${svgElement.id})`);
        if ($(svgElement).hasClass(className)) {
          $(svgElement).removeClass(className);

          if ((svgElement.nodeName === 'text')) {
            let rect = $(svgElement).parent().find(`[id="${entityId}.background"]`);
            if (rect.length) {
              if ($(rect).hasClass(className + '-background')) {
                $(rect).removeClass(className + '-background');
              }
            }
          }
        }
      }
    },

    updateStateTransitions() {
      if (!this.cssRules || !this.cssRules.length)
        return;

      let svg = Polymer.dom(this.$.floorplan).querySelector('svg');

      for (let entityId in this.entityConfigs) {
        let entityConfig = this.entityConfigs[entityId];

        if (!entityConfig || !entityConfig.group.states || !entityConfig.group.state_transitions || (entityConfig.lastChangedTime === undefined))
          continue;

        for (let svgElementId in entityConfig.svgElementConfigs) {
          let svgElementConfig = entityConfig.svgElementConfigs[svgElementId];
          let svgElement = svgElementConfig.svgElement;

          if (!svgElement)
            continue;

          let wasTransitionHandled = false;

          let transitionConfig = entityConfig.group.state_transitions.find(transitionConfig => (transitionConfig.to_state === entityConfig.lastState));
          if (transitionConfig && transitionConfig.from_state && transitionConfig.to_state && transitionConfig.duration) {
            let serverMoment = this.getServerMoment();
            let fromStateConfig = entityConfig.group.states.find(stateConfig => (stateConfig.state === transitionConfig.from_state));
            let toStateConfig = entityConfig.group.states.find(stateConfig => (stateConfig.state === transitionConfig.to_state));

            if (fromStateConfig && toStateConfig) {
              let fromFill = this.getFill(fromStateConfig);
              let toFill = this.getFill(toStateConfig);

              if (fromFill && toFill) {
                let elapsed = serverMoment.diff(moment(entityConfig.lastChangedTime), 'milliseconds');
                if (elapsed < 0) {
                  this.setTransitionFill(svgElement, fromFill, toFill, 1);
                }
                else {
                  if (elapsed < (transitionConfig.duration * 1000)) {
                    this.setTransitionFill(svgElement, fromFill, toFill, elapsed / (transitionConfig.duration * 1000));
                  }
                  else {
                    this.setTransitionFill(svgElement, fromFill, toFill, 0);
                    entityConfig.lastChangedTime = undefined;
                  }
                }

                wasTransitionHandled = true;
              }
            }
          }

          if (!wasTransitionHandled) {
            this.setEntityStyle(svgElementConfig, svgElement, entityConfig);
          }
        }
      }
    },

    setEntityStyle(svgElementConfig, svgElement, entityConfig, state) {
      let stateConfig = entityConfig.group.states.find(stateConfig => (stateConfig.state === entityConfig.lastState));
      if (stateConfig) {
        let stroke = this.getStroke(stateConfig);
        if (stroke) {
          svgElement.style.stroke = stroke;
        }
        else {
          if (svgElementConfig.clonedsvgElement) {
            svgElement.style.stroke = svgElementConfig.clonedsvgElement.style.stroke;
          }
          else {
            // ???
          }
        }

        let fill = this.getFill(stateConfig);
        if (fill) {
          svgElement.style.fill = fill;
        }
        else {
          if (svgElementConfig.clonedsvgElement) {
            svgElement.style.fill = svgElementConfig.clonedsvgElement.style.fill;
          }
          else {
            // ???
          }
        }
      }
    },

    onElementClick(e) {
      let svgElement = e.target;

      let elementConfig = this.instance.elementConfigs[this.elementId];
      if (elementConfig.group.action) {
        let action = elementConfig.group.action;
        if (action.service) {
          switch (action.domain) {
            case 'class':

              switch (action.service) {
                case 'toggle':
                  let svg = Polymer.dom(this.instance.$.floorplan).querySelector('svg');
                  let classes = action.data.classes;

                  for (let otherElementId of action.data.elements) {
                    let otherSvgElement = $(svg).find(`[id="${otherElementId}"]`);

                    if ($(otherSvgElement).hasClass(classes[0])) {
                      $(otherSvgElement).removeClass(classes[0]);
                      $(otherSvgElement).addClass(classes[1]);
                    }
                    else if ($(otherSvgElement).hasClass(classes[1])) {
                      $(otherSvgElement).removeClass(classes[1]);
                      $(otherSvgElement).addClass(classes[0]);
                    }
                    else {
                      $(otherSvgElement).addClass(action.data.default_class);
                    }
                  }
                  break;
              }
              break;

            default:
              domain = action.domain
              let data = action.data ? action.data : {};
              if (action.data_template) {
                let entities = this.instance.hass.states;
                let entityState = entities[entityId];
                let result = this.instance.assemble(action.data_template, entityState, entities);
                data = JSON.parse(result);
              }
              this.instance.hass.callService(domain, action.service, data);
              break;
          }
        }
      }
    },

    onEntityClick(e) {
      let entityId = this.entityId;

      let entityConfig = this.instance.entityConfigs[entityId];
      if (entityConfig.group.action) {
        let action = entityConfig.group.action;
        if (action.service) {
          let domain = action.domain ? action.domain : window.HAWS.extractDomain(entityId);
          domain = (domain == 'group') ? 'homeassistant' : domain;

          let data = {};
          if (action.data) {
            data = action.data;
          }
          if (action.data_template) {
            let entities = this.instance.hass.states;
            let entityState = entities[entityId];

            let result = this.instance.assemble(action.data_template, entityState, entities);
            data = JSON.parse(result);
          }

          if (!data.entity_id) {
            data['entity_id'] = entityId;
          }

          this.instance.hass.callService(domain, action.service, data);
        }
        else {
          this.instance.fire('hass-more-info', { entityId: entityId });
        }
      }
      else {
        this.instance.fire('hass-more-info', { entityId: entityId });
      }
    },

    getFill(stateConfig) {
      let fill = undefined;

      for (let cssRule of this.cssRules) {
        if (cssRule.selectorText && cssRule.selectorText.indexOf(`.${stateConfig.class}`) >= 0) {
          if (cssRule.style && cssRule.style.fill) {
            if (cssRule.style.fill[0] === '#') {
              fill = cssRule.style.fill;
            }
            else {
              let rgb = cssRule.style.fill.substring(4).slice(0, -1).split(',').map(x => parseInt(x));
              fill = `#${rgb[0].toString(16)[0]}${rgb[1].toString(16)[0]}${rgb[2].toString(16)[0]}`;
            }
          }
        }
      }

      return fill;
    },

    getStroke(stateConfig) {
      let stroke = undefined;

      for (let cssRule of this.cssRules) {
        if (cssRule.selectorText && cssRule.selectorText.indexOf(`.${stateConfig.class}`) >= 0) {
          if (cssRule.style && cssRule.style.stroke) {
            if (cssRule.style.stroke[0] === '#') {
              stroke = cssRule.style.stroke;
            }
            else {
              let rgb = cssRule.style.stroke.substring(4).slice(0, -1).split(',').map(x => parseInt(x));
              stroke = `#${rgb[0].toString(16)[0]}${rgb[1].toString(16)[0]}${rgb[2].toString(16)[0]}`;
            }
          }
        }
      }

      return stroke;
    },

    setTransitionFill(svgElement, fromFill, toFill, value) {
      if (value >= 1) {
        svgElement.style.fill = fromFill;
      }
      else if (value <= 0) {
        svgElement.style.fill = toFill;
      }
      else {
        let color = this.rgbToHex(this.mix(this.hexToRgb(toFill), this.hexToRgb(fromFill), value));
        svgElement.style.fill = color;
      }
    },
1 Like