How to set up layers for different floorplans

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

ha-floorplan.html (continued!):

    getServerMoment() {
      let serverMoment = moment();
      if (this.timeDifference >= 0)
        serverMoment.subtract(this.timeDifference, 'milliseconds');
      else
        serverMoment.add(Math.abs(this.timeDifference), 'milliseconds');
      return serverMoment;
    },

    getArray(list) {
      return Array.isArray(list) ? list : Object.keys(list).map(key => list[key]);
    },

    assemble(code, entity, entities) {
      let functionBody = (code.indexOf('return') >= 0) ? code : `return \`${code}\`;`;
      let func = new Function('entity', 'entities', 'hass', 'config', functionBody);
      return func(entity, entities, this.hass, this.config);
    },

    error(message) {
      let errors = Polymer.dom(this.$.errors).node;
      $(errors).find('ul').append(`<li>${message}</li>`)
      $(errors).css('display', 'block');
    },

    warn(message) {
      if ((this.config.warnings === null) || (this.config.warnings !== undefined)) {
        let warnings = Polymer.dom(this.$.warnings).node;
        $(warnings).find('ul').append(`<li>${message}</li>`)
        $(warnings).css('display', 'block');
      }
    },

    debug(message) {
      let debug = Polymer.dom(this.$.debug).node;
      $(debug).find('ul').append(`<li>${message}</li>`)
      $(debug).css('display', 'block');
    },

    loadStyleSheet(path, fn, scope) {
      let head = document.getElementsByTagName('head')[0]; // reference to document.head for appending/ removing link nodes
      let link = document.createElement('link');           // create the link node
      link.setAttribute('href', path);
      link.setAttribute('rel', 'stylesheet');
      link.setAttribute('type', 'text/css');

      let sheet, cssRules;
      // get the correct properties to check for depending on the browser
      if ('sheet' in link) {
        sheet = 'sheet'; cssRules = 'cssRules';
      }
      else {
        sheet = 'styleSheet'; cssRules = 'rules';
      }

      let interval_id = setInterval(function () {                     // start checking whether the style sheet has successfully loaded
        try {
          if (link[sheet] && link[sheet][cssRules].length) { // SUCCESS! our style sheet has loaded
            clearInterval(interval_id);                      // clear the counters
            clearTimeout(timeout_id);
            fn.call(scope || window, true, link);           // fire the callback with success == true
          }
        } catch (e) { } finally { }
      }, 10),                                                   // how often to check if the stylesheet is loaded
        timeout_id = setTimeout(function () {       // start counting down till fail
          clearInterval(interval_id);             // clear the counters
          clearTimeout(timeout_id);
          head.removeChild(link);                // since the style sheet didn't load, remove the link node from the DOM
          fn.call(scope || window, false, link); // fire the callback with success == false
        }, 15000);                                 // how long to wait before failing

      head.appendChild(link);  // insert the link node into the DOM and start loading the style sheet

      return link; // return the link node;
    },

    rgbToHex(rgb) {
      return "#" + ((1 << 24) + (rgb[0] << 16) + (rgb[1] << 8) + rgb[2]).toString(16).slice(1);
    },

    hexToRgb(hex) {
      // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
      let shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
      hex = hex.replace(shorthandRegex, (m, r, g, b) => {
        return r + r + g + g + b + b;
      });

      let result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
      return result ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16)
      } : null;
    },

    mix(color1, color2, weight) {
      let p = weight;
      let w = p * 2 - 1;
      let w1 = ((w / 1) + 1) / 2;
      let w2 = 1 - w1;
      let rgb = [
        Math.round(color1.r * w1 + color2.r * w2),
        Math.round(color1.g * w1 + color2.g * w2),
        Math.round(color1.b * w1 + color2.b * w2)
      ];
      return rgb;
    }
  });

</script>

Awesome! Will test this later! What was different from your first attemt?

The code for both the js and html was altered by a good willing ha user… Also, silly things like deleting the browser’s cache or getting rid of extra Inkscape labels and tags helped…

Ok, will use the ones you posted then :slight_smile: Thank you for your hard work and sharing! :smile:

…and it`s working! Thanks again!

When using different layers, you may find that you want to use the same entity in different layers. I found an easy workaround for this for most of my devices, ie MQTT devices. The workaround I found for these duplicate entities is to create a new entity per different layer as follows:

- name: "Stairs"
  command_topic: milight/EDIT/rgb_cct/1
  state_topic: milight/updates/EDIT/rgb_cct/1
  <<: &MILIGHT_PARAMS
      platform: mqtt_json
      computed_color: true
      color_temp: true
      brightness: true
- name: "Upstairs Stairs"
  command_topic: milight/EDIT/rgb_cct/1
  state_topic: milight/updates/EDIT/rgb_cct/1
  <<: *MILIGHT_PARAMS #copy of light.stairs created for floorplan multilayers

Not the prettiest solution, as it adds a lot of unneeded code, but it works. Since these duplicated entities use the same MQTT topics, their statuses are linked and update at the same time on all layers. Sweet!

For the only other type of entity that I have on several layers, ie. camera, I just created a group with only that entity. This kinda works, but looks a bit crappy… Snapshot displays, but unnecessary info is added and no correct status (‘Streaming’, ‘Recording’ or ‘Idle’) is associated. I might have to look into @pkozul’s latest published camera implementation of floorplan, but that would involve editing the changes made in the HA-floorplant.htm and that could take more time than I have.

Let me know, if you found another more elegant solution…

Hi!
I’m trying to get this working but after adding your files nothing happens. Are you sure that I don’t need to change anything else? Like adding the buttons in the floorpan.yaml or similar. The buttons are dead so I don’t know if the rest will work after fixing that.

First thing I’d check is the svg IDs for buttons and especially layers. If you use Inkscape, this can be checked in the XML editor.

yes, what @monkey-house said. And be sure to comment out the start of the Javascript and html document (the posted ones are not). between the posted html and html continued there should be a empty line (don’t know if that is necessary, but to be sure, just do that). and meaby most important, clean your browser cache. if it’s still not working, try the same on a different device, ex. from Google Chrome on tablet to Google Chrome on a computer (and if course clean the browser cache on the new device first)

1 Like

Thank you. Both checked with the editor to be as described. page0 and show.page0
Still no luck unfortunately. I’ll check the .js for any errors due to the split of the file.

It seems to be the buttons I have issues with. I have tried to search, but is there an instruction for how to add the “onclick” code to the buttons? I guess it should be done in Incscape with xml-editor?

You don`t need to do anything to the onclick function of the elements in the svg-file.
Since it’s not working out of the box, meaby just make a new simple svg-file to test.

Make one layer with id: buttons. This layer will always be visible. Make one layer with id: page0
Make one layer with id: page1
On the buttons-layer, make a element with id: show.toggle
On page0, a rectangel
On page1, a circle

Then, in google chrome, delete the browser cache (make sure to check off all three boxes), then close the browser, relaunch the browser, and check if pressing the show.toggle button, it`s changing between the circle and the rectangle. If it does, all is good, and just copy the old file back, delete browser cache again (if necessary) close the browser, and relaunch the browser.

And just to make sure, when opening custom_js in your editor, it`s not called custom_js.js.js? If you have named it custom_js.js, it may got an extra “.js” at the end depending on how you edited it.

And you have comment out the start of the custom_js?

So the start of custom_js should look 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 
*/ 

and not 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 
*/ 

Not sure if the above is necessary, but just to be sure do it.

And at last the space between the two html document is there like this:

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;
  }
},

getServerMoment() {
  let serverMoment = moment();
  if (this.timeDifference >= 0)
    serverMoment.subtract(this.timeDifference, 'milliseconds');
  else
    serverMoment.add(Math.abs(this.timeDifference), 'milliseconds');
  return serverMoment;
},

getArray(list) {
  return Array.isArray(list) ? list : Object.keys(list).map(key => list[key]);
},

assemble(code, entity, entities) {
  let functionBody = (code.indexOf('return') >= 0) ? code : `return \`${code}\`;`;
  let func = new Function('entity', 'entities', 'hass', 'config', functionBody);
  return func(entity, entities, this.hass, this.config);
},

I am sorry if you have tried all this, but if all this is done, I don’t know what could be wrong.