/* Floorplan for Home Assistant Version: 1.0.7.57 By Petar Kozul https://github.com/pkozul/ha-floorplan */ 'use strict'; (function () { if (typeof window.Floorplan === 'function') { return; } class Floorplan { constructor() { this.version = '1.0.7.57'; this.doc = {}; this.hass = {}; this.openMoreInfo = () => { }; this.setIsLoading = () => { }; this.config = {}; this.timeDifference = undefined; this.pageInfos = []; this.entityInfos = []; this.elementInfos = []; this.cssRules = []; this.entityTransitions = {}; this.lastMotionConfig = {}; this.logLevels = []; this.handleEntitiesDebounced = {}; this.variables = []; //this.setIsLoading(true); } hassChanged(newHass, oldHass) { this.hass = newHass; if (!this.config) { return; } this.handleEntitiesDebounced(); // use debounced wrapper } /***************************************************************************************************************************/ /* Startup /***************************************************************************************************************************/ init(options) { this.doc = options.doc; this.hass = options.hass; this.openMoreInfo = options.openMoreInfo; this.setIsLoading = options.setIsLoading; window.onerror = this.handleWindowError.bind(this); this.handleEntitiesDebounced = this.debounce(() => { return this.handleEntities(); }, 100); this.initTimeDifference(); return this.loadConfig(options.config) .then(config => { this.config = config; this.getLogLevels(); this.logInfo('VERSION', `Floorplan v${this.version}`); if (!this.validateConfig(this.config)) { this.setIsLoading(false); return Promise.resolve(); } return this.loadLibraries() .then(() => { this.initFullyKiosk(); return this.config.pages ? this.initMultiPage() : this.initSinglePage(); }); }) .catch(error => { this.setIsLoading(false); this.handleError(error); }); } initMultiPage() { return this.loadPages() .then(() => { this.setIsLoading(false); this.initPageDisplay(); this.initVariables(); this.initStartupActions(); return this.handleEntities(true); }); } initSinglePage() { let imageUrl = this.getBestImage(this.config); return this.loadFloorplanSvg(imageUrl) .then((svg) => { this.config.svg = svg; return this.loadStyleSheet(this.config.stylesheet) .then(() => { return this.initFloorplan(svg, this.config) .then(() => { this.setIsLoading(false); this.initPageDisplay(); this.initVariables(); this.initStartupActions(); return this.handleEntities(true); }) }); }); } getLogLevels() { if (!this.config.log_level) { return; } let allLogLevels = { error: ['error'], warning: ['error', 'warning'], info: ['error', 'warning', 'info'], debug: ['error', 'warning', 'info', 'debug'], }; this.logLevels = allLogLevels[this.config.log_level.toLowerCase()]; } /***************************************************************************************************************************/ /* Loading resources /***************************************************************************************************************************/ loadConfig(configUrl) { return this.fetchTextResource(configUrl, false) .then(config => { return Promise.resolve(YAML.parse(config)); }); } loadLibraries() { let promises = []; if (this.isOptionEnabled(this.config.pan_zoom)) { promises.push(this.loadScript('/local/custom_ui/floorplan/lib/svg-pan-zoom.min.js')); } if (this.isOptionEnabled(this.config.fully_kiosk)) { promises.push(this.loadScript('/local/custom_ui/floorplan/lib/fully-kiosk.js')); } return promises.length ? Promise.all(promises) : Promise.resolve(); } loadScript(scriptUrl) { return new Promise((resolve, reject) => { let script = document.createElement('script'); script.src = this.cacheBuster(scriptUrl); script.onload = () => { return resolve(); }; script.onerror = (err) => { reject(new URIError(`${err.target.src}`)); }; this.doc.appendChild(script); }); } loadPages() { let configPromises = [Promise.resolve()] .concat(this.config.pages.map(pageConfigUrl => { return this.loadPageConfig(pageConfigUrl, this.config.pages.indexOf(pageConfigUrl)); })); return Promise.all(configPromises) .then(() => { let pageInfos = Object.keys(this.pageInfos).map(key => this.pageInfos[key]); pageInfos.sort((a, b) => a.index - b.index); // sort ascending let masterPageInfo = pageInfos.find(pageInfo => pageInfo.config.master_page); if (masterPageInfo) { masterPageInfo.isMaster = true; } let defaultPageInfo = pageInfos.find(pageInfo => !pageInfo.config.master_page); if (defaultPageInfo) { defaultPageInfo.isDefault = true; } let svgPromises = [Promise.resolve()] .concat(pageInfos.map(pageInfo => this.loadPageFloorplanSvg(pageInfo, masterPageInfo))); return Promise.all(svgPromises); }); } loadPageConfig(pageConfigUrl, index) { return this.loadConfig(pageConfigUrl) .then((pageConfig) => { let pageInfo = this.createPageInfo(pageConfig); pageInfo.index = index; return Promise.resolve(pageInfo); }); } loadPageFloorplanSvg(pageInfo, masterPageInfo) { let imageUrl = this.getBestImage(pageInfo.config); return this.loadFloorplanSvg(imageUrl, pageInfo, masterPageInfo) .then((svg) => { svg.id = pageInfo.config.page_id; // give the SVG an ID so it can be styled (i.e. background color) pageInfo.svg = svg; return this.loadStyleSheet(pageInfo.config.stylesheet) .then(() => { return this.initFloorplan(pageInfo.svg, pageInfo.config); }); }); } getBestImage(config) { let imageUrl = ''; if (typeof config.image === 'string') { imageUrl = config.image; } else { if (config.image.sizes) { config.image.sizes.sort((a, b) => b.min_width - a.min_width); // sort descending for (let pageSize of config.image.sizes) { if (screen.width >= pageSize.min_width) { imageUrl = pageSize.location; break; } } } } return imageUrl; } createPageInfo(pageConfig) { let pageInfo = { config: pageConfig }; // Merge the page's rules with the main config's rules if (pageInfo.config.rules && this.config.rules) { pageInfo.config.rules = pageInfo.config.rules.concat(this.config.rules); } this.pageInfos[pageInfo.config.page_id] = pageInfo; return pageInfo; } loadStyleSheet(stylesheetUrl) { if (!stylesheetUrl) { return Promise.resolve(); } return this.fetchTextResource(stylesheetUrl, false) .then(stylesheet => { let link = document.createElement('style'); link.type = 'text/css'; link.innerHTML = stylesheet; this.doc.appendChild(link); let cssRules = this.getArray(link.sheet.cssRules); this.cssRules = this.cssRules.concat(cssRules); return Promise.resolve(); }); } loadFloorplanSvg(imageUrl, pageInfo, masterPageInfo) { return this.fetchTextResource(imageUrl, true) .then(result => { let svg = $(result).siblings('svg')[0]; svg = svg ? svg : $(result); if (pageInfo) { $(svg).attr('id', pageInfo.config.page_id); } $(svg).height('100%'); $(svg).width('100%'); $(svg).css('position', this.isPanel ? 'absolute' : 'relative'); $(svg).css('cursor', 'default'); $(svg).css('opacity', 0); $(svg).attr('xmlns:xlink', 'http://www.w3.org/1999/xlink'); if (pageInfo && masterPageInfo) { let masterPageId = masterPageInfo.config.page_id; let contentElementId = masterPageInfo.config.master_page.content_element; if (pageInfo.config.page_id === masterPageId) { $(this.doc).find('#floorplan').append(svg); } else { let $masterPageElement = $(this.doc).find('#' + masterPageId); let $contentElement = $(this.doc).find('#' + contentElementId); let height = Number.parseFloat($(svg).attr('height')); let width = Number.parseFloat($(svg).attr('width')); if (!$(svg).attr('viewBox')) { $(svg).attr('viewBox', `0 0 ${width} ${height}`); } $(svg) .attr('preserveAspectRatio', 'xMinYMin meet') .attr('height', $contentElement.attr('height')) .attr('width', $contentElement.attr('width')) .attr('x', $contentElement.attr('x')) .attr('y', $contentElement.attr('y')); $contentElement.parent().append(svg); } } else { $(this.doc).find('#floorplan').append(svg); } // Enable pan / zoom if enabled in config if (this.isOptionEnabled(this.config.pan_zoom)) { svgPanZoom($(svg)[0], { zoomEnabled: true, controlIconsEnabled: true, fit: true, center: true, }); } return Promise.resolve(svg); }); } loadImage(imageUrl, svgElementInfo, entityId, rule) { if (imageUrl.toLowerCase().indexOf('.svg') >= 0) { return this.loadSvgImage(imageUrl, svgElementInfo, entityId, rule); } else { return this.loadBitmapImage(imageUrl, svgElementInfo, entityId, rule); } } loadBitmapImage(imageUrl, svgElementInfo, entityId, rule) { return this.fetchImageResource(imageUrl, false, true) .then(imageData => { this.logDebug('IMAGE', `${entityId} (setting image: ${imageUrl})`); let svgElement = svgElementInfo.svgElement; // assume the target element already exists if (!$(svgElement).is('image')) { svgElement = this.createImageElement(svgElementInfo.originalSvgElement); $(svgElement).append(document.createElementNS('http://www.w3.org/2000/svg', 'title')) .off('click') .on('click', this.onEntityClick.bind({ instance: this, svgElementInfo: svgElementInfo, entityId: entityId, rule: rule })) .css('cursor', 'pointer') .addClass('ha-entity'); svgElementInfo.svgElement = this.replaceElement(svgElementInfo.svgElement, svgElement); } let existingHref = svgElement.getAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href'); if (existingHref !== imageData) { svgElement.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', imageUrl); } return Promise.resolve(svgElement); }); } loadSvgImage(imageUrl, svgElementInfo, entityId, rule) { return this.fetchTextResource(imageUrl, true) .then(result => { this.logDebug('IMAGE', `${entityId} (setting image: ${imageUrl})`); let svgElement = $(result).siblings('svg')[0]; svgElement = svgElement ? svgElement : $(result); let height = Number.parseFloat($(svgElement).attr('height')); let width = Number.parseFloat($(svgElement).attr('width')); if (!$(svgElement).attr('viewBox')) { $(svgElement).attr('viewBox', `0 0 ${width} ${height}`); } $(svgElement) .attr('id', svgElementInfo.svgElement.id) .attr('preserveAspectRatio', 'xMinYMin meet') .attr('height', svgElementInfo.originalBBox.height) .attr('width', svgElementInfo.originalBBox.width) .attr('x', svgElementInfo.originalBBox.x) .attr('y', svgElementInfo.originalBBox.y); $(svgElement).find('*').append(document.createElementNS('http://www.w3.org/2000/svg', 'title')) .off('click') .on('click', this.onEntityClick.bind({ instance: this, svgElementInfo: svgElementInfo, entityId: entityId, rule: rule })) .css('cursor', 'pointer') .addClass('ha-entity'); svgElementInfo.svgElement = this.replaceElement(svgElementInfo.svgElement, svgElement); return Promise.resolve(svgElement); }) } replaceElement(prevousSvgElement, svgElement) { let $parent = $(prevousSvgElement).parent(); $(prevousSvgElement).find('*') .off('click'); $(prevousSvgElement) .off('click') .remove(); $parent.append(svgElement); return svgElement; } /***************************************************************************************************************************/ /* Initialization /***************************************************************************************************************************/ initTimeDifference() { 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'); } }); } initFullyKiosk() { if (this.isOptionEnabled(this.config.fully_kiosk)) { this.fullyKiosk = new FullyKiosk(this); this.fullyKiosk.init(); } } initPageDisplay() { if (this.config.pages) { Object.keys(this.pageInfos).map(key => { let pageInfo = this.pageInfos[key]; $(pageInfo.svg).css('opacity', 1); $(pageInfo.svg).css('display', pageInfo.isMaster || pageInfo.isDefault ? 'initial' : 'none'); // Show the first page }); } else { // Show the SVG $(this.config.svg).css('opacity', 1); $(this.config.svg).css('display', 'initial'); } } initVariables() { if (this.config.variables) { for (let variable of this.config.variables) { this.initVariable(variable); } } if (this.config.pages) { for (let key of Object.keys(this.pageInfos)) { let pageInfo = this.pageInfos[key]; if (pageInfo.config.variables) { for (let variable of pageInfo.config.variables) { this.initVariable(variable); } } } } } initVariable(variable) { let variableName; let value; if (typeof variable === 'string') { variableName = variable; } else { variableName = variable.name; value = variable.value; if (variable.value_template) { value = this.evaluate(variable.value_template, variableName, undefined); } } if (!this.entityInfos[variableName]) { let entityInfo = { entityId: variableName, ruleInfos: [], lastState: undefined }; this.entityInfos[variableName] = entityInfo; } if (!this.hass.states[variableName]) { this.hass.states[variableName] = { entity_id: variableName, state: value, last_changed: new Date(), attributes: [], }; } this.setVariable(variableName, value, [], true); } initStartupActions() { let actions = []; let startup = this.config.startup; if (startup && startup.action) { actions = actions.concat(Array.isArray(startup.action) ? startup.action : [startup.action]); } if (this.config.pages) { for (let key of Object.keys(this.pageInfos)) { let pageInfo = this.pageInfos[key]; let startup = pageInfo.config.startup; if (startup && startup.action) { actions = actions.concat(Array.isArray(startup.action) ? startup.action : [startup.action]); } } } for (let action of actions) { if (action.service || action.service_template) { let actionService = this.getActionService(action, undefined, undefined); switch (this.getDomain(actionService)) { case 'floorplan': this.callFloorplanService(action, undefined, undefined); break; default: this.callHomeAssistantService(action, undefined, undefined); break; } } } } /***************************************************************************************************************************/ /* SVG initialization /***************************************************************************************************************************/ initFloorplan(svg, config) { if (!config.rules) { return Promise.resolve();; } let svgElements = $(svg).find('*').toArray(); this.initLastMotion(config, svg, svgElements); this.initRules(config, svg, svgElements); return Promise.resolve();; } initLastMotion(config, svg, svgElements) { // Add the last motion entity if required if (config.last_motion && config.last_motion.entity && config.last_motion.class) { this.lastMotionConfig = config.last_motion; let entityInfo = { entityId: config.last_motion.entity, ruleInfos: [], lastState: undefined }; this.entityInfos[config.last_motion.entity] = entityInfo; } } initRules(config, svg, svgElements) { // Apply default options to rules that don't override the options explictly if (config.defaults) { for (let rule of config.rules) { rule.hover_over = (rule.hover_over === undefined) ? config.defaults.hover_over : rule.hover_over; rule.more_info = (rule.more_info === undefined) ? config.defaults.more_info : rule.more_info; } } for (let rule of config.rules) { if (rule.entity || rule.entities) { this.initEntityRule(rule, svg, svgElements); } else if (rule.element || rule.elements) { this.initElementRule(rule, svg, svgElements); } } } initEntityRule(rule, svg, svgElements) { let entities = this.initGetEntityRuleEntities(rule); for (let entity of entities) { let entityId = entity.entityId; let elementId = entity.elementId; let entityInfo = this.entityInfos[entityId]; if (!entityInfo) { entityInfo = { entityId: entityId, ruleInfos: [], lastState: undefined }; this.entityInfos[entityId] = entityInfo; } let ruleInfo = { rule: rule, svgElementInfos: {}, }; entityInfo.ruleInfos.push(ruleInfo); let svgElement = svgElements.find(svgElement => svgElement.id === elementId); if (!svgElement) { this.logWarning('CONFIG', `Cannot find element '${elementId}' in SVG file`); continue; } let svgElementInfo = this.addSvgElementToRule(svg, svgElement, ruleInfo); let $svgElement = $(svgElementInfo.svgElement); if ($svgElement.length) { svgElementInfo.svgElement = $svgElement[0]; // Create a title element (to support hover over text) $svgElement.append(document.createElementNS('http://www.w3.org/2000/svg', 'title')); $svgElement.off('click').on('click', this.onEntityClick.bind({ instance: this, svgElementInfo: svgElementInfo, entityId: entityId, rule: ruleInfo.rule })); $svgElement.css('cursor', 'pointer'); $svgElement.addClass('ha-entity'); if ($svgElement.is('text') && ($svgElement[0].id === elementId)) { let backgroundSvgElement = svgElements.find(svgElement => svgElement.id === ($svgElement[0].id + '.background')); if (!backgroundSvgElement) { this.addBackgroundRectToText(svgElementInfo); } else { svgElementInfo.alreadyHadBackground = true; $(backgroundSvgElement).css('fill-opacity', 0); } } } } } initGetEntityRuleEntities(rule) { let targetEntities = []; // Split out HA entity groups into separate entities if (rule.groups) { for (let entityId of rule.groups) { let group = this.hass.states[entityId]; if (group) { for (let entityId of group.attributes.entity_id) { targetEntities.push({ entityId: entityId, elementId: entityId }); } } else { this.logWarning('CONFIG', `Cannot find '${entityId}' in Home Assistant groups`); } } } // HA entity treated as is if (rule.entity) { rule.entities = [rule.entity]; } // HA entities treated as is if (rule.entities) { let entityIds = rule.entities.filter(x => (typeof x === 'string')); for (let entityId of entityIds) { let entity = this.hass.states[entityId]; let isFloorplanVariable = (entityId.split('.')[0] === 'floorplan'); if (entity || isFloorplanVariable) { let elementId = rule.element ? rule.element : entityId; targetEntities.push({ entityId: entityId, elementId: elementId }); } else { this.logWarning('CONFIG', `Cannot find '${entityId}' in Home Assistant entities`); } } let entityObjects = rule.entities.filter(x => (typeof x !== 'string')); for (let entityObject of entityObjects) { let entity = this.hass.states[entityObject.entity]; let isFloorplanVariable = (entityObject.entity.split('.')[0] === 'floorplan'); if (entity || isFloorplanVariable) { targetEntities.push({ entityId: entityObject.entity, elementId: entityObject.element }); } else { this.logWarning('CONFIG', `Cannot find '${entityObject.entity}' in Home Assistant entities`); } } } return targetEntities; } initElementRule(rule, svg, svgElements) { if (rule.element) { rule.elements = [rule.element]; } for (let elementId of rule.elements) { let svgElement = svgElements.find(svgElement => svgElement.id === elementId); if (svgElement) { let elementInfo = this.elementInfos[elementId]; if (!elementInfo) { elementInfo = { ruleInfos: [], lastState: undefined }; this.elementInfos[elementId] = elementInfo; } let ruleInfo = { rule: rule, svgElementInfos: {}, }; elementInfo.ruleInfos.push(ruleInfo); let svgElementInfo = this.addSvgElementToRule(svg, svgElement, ruleInfo); let $svgElement = $(svgElementInfo.svgElement); $svgElement.off('click').on('click', this.onElementClick.bind({ instance: this, svgElementInfo: svgElementInfo, elementId: elementId, rule: rule })); $svgElement.css('cursor', 'pointer'); if ($svgElement.is('text') && ($svgElement[0].id === elementId)) { let backgroundSvgElement = svgElements.find(svgElement => svgElement.id === ($svgElement[0].id + '.background')); if (!backgroundSvgElement) { this.addBackgroundRectToText(svgElementInfo); } else { svgElementInfo.alreadyHadBackground = true; $(backgroundSvgElement).css('fill-opacity', 0); } } let actions = Array.isArray(rule.action) ? rule.action : [rule.action]; for (let action of actions) { if (action) { switch (action.service) { case 'toggle': for (let otherElementId of action.data.elements) { let otherSvgElement = svgElements.find(svgElement => svgElement.id === otherElementId); $(otherSvgElement).addClass(action.data.default_class); } break; default: break; } } } } else { this.logWarning('CONFIG', `Cannot find '${elementId}' in SVG file`); } } } addBackgroundRectToText(svgElementInfo) { let svgElement = svgElementInfo.svgElement; let bbox = svgElement.getBBox(); let rect = $(document.createElementNS('http://www.w3.org/2000/svg', 'rect')) .attr('id', svgElement.id + '.background') .attr('height', bbox.height + 1) .attr('width', bbox.width + 2) .attr('x', bbox.x - 1) .attr('y', bbox.y - 0.5) .css('fill-opacity', 0); $(rect).insertBefore(svgElement); } addSvgElementToRule(svg, svgElement, ruleInfo) { let svgElementInfo = { entityId: svgElement.id, svg: svg, svgElement: svgElement, originalSvgElement: svgElement, originalStroke: svgElement.style.stroke, originalFill: svgElement.style.fill, originalClasses: this.getArray(svgElement.classList), originalBBox: svgElement.getBBox(), originalClientRect: svgElement.getBoundingClientRect(), }; ruleInfo.svgElementInfos[svgElement.id] = svgElementInfo; this.addNestedSvgElementsToRule(svgElement, ruleInfo); return svgElementInfo; } addNestedSvgElementsToRule(svgElement, ruleInfo) { $(svgElement).find('*').each((i, svgNestedElement) => { ruleInfo.svgElementInfos[svgNestedElement.id] = { entityId: svgElement.id, svgElement: svgNestedElement, originalSvgElement: svgNestedElement, originalStroke: svgNestedElement.style.stroke, originalFill: svgNestedElement.style.fill, originalClasses: this.getArray(svgNestedElement.classList), //originalBBox: svgNestedElement.getBBox(), //originalClientRect: svgNestedElement.getBoundingClientRect(), }; }); } createImageElement(svgElement) { return $(document.createElementNS('http://www.w3.org/2000/svg', 'image')) .attr('id', $(svgElement).attr('id')) .attr('x', $(svgElement).attr('x')) .attr('y', $(svgElement).attr('y')) .attr('height', $(svgElement).attr('height')) .attr('width', $(svgElement).attr('width'))[0]; /* return $('object') .attr('type', $(svgElement).attr('image/svg+xml')) .attr('id', $(svgElement).attr('id')) .attr('x', $(svgElement).attr('x')) .attr('y', $(svgElement).attr('y')) .attr('height', $(svgElement).attr('height')) .attr('width', $(svgElement).attr('width'))[0]; */ } addClass(entityId, svgElement, className) { if ($(svgElement).hasClass('ha-leave-me-alone')) { return; } if (!$(svgElement).hasClass(className)) { this.logDebug('CLASS', `${entityId} (adding class: ${className})`); $(svgElement).addClass(className); if ($(svgElement).is('text')) { $(svgElement).parent().find(`[id="${entityId}.background"]`).each((i, rectElement) => { if (!$(rectElement).hasClass(className + '-background')) { $(rectElement).addClass(className + '-background'); } }); } } $(svgElement).find('*').each((i, svgNestedElement) => { if (!$(svgNestedElement).hasClass('ha-leave-me-alone')) { if (!$(svgNestedElement).hasClass(className)) { $(svgNestedElement).addClass(className); } } }); } removeClasses(entityId, svgElement, classes) { for (let className of classes) { if ($(svgElement).hasClass(className)) { this.logDebug('CLASS', `${entityId} (removing class: ${className})`); $(svgElement).removeClass(className); if ($(svgElement).is('text')) { $(svgElement).parent().find(`[id="${entityId}.background"]`).each((i, rectElement) => { if ($(rectElement).hasClass(className + '-background')) { $(rectElement).removeClass(className + '-background'); } }); } $(svgElement).find('*').each((i, svgNestedElement) => { if ($(svgNestedElement).hasClass(className)) { $(svgNestedElement).removeClass(className); } }); } } } setEntityStyle(svgElementInfo, svgElement, entityInfo, ruleInfo) { let stateConfig = ruleInfo.rule.states.find(stateConfig => (stateConfig.state === entityInfo.lastState.state)); if (stateConfig) { let stroke = this.getStroke(stateConfig); if (stroke) { svgElement.style.stroke = stroke; } else { if (svgElementInfo.originalStroke) { svgElement.style.stroke = svgElementInfo.originalStroke; } else { // ??? } } let fill = this.getFill(stateConfig); if (fill) { svgElement.style.fill = fill; } else { if (svgElementInfo.originalFill) { svgElement.style.fill = svgElementInfo.originalFill; } else { // ??? } } } } /***************************************************************************************************************************/ /* Entity handling (when states change) /***************************************************************************************************************************/ handleEntities(isInitialLoad) { this.handleElements(isInitialLoad); let changedEntityIds = this.getChangedEntities(isInitialLoad); changedEntityIds = changedEntityIds.concat(Object.keys(this.variables)); // always assume variables need updating if (changedEntityIds && changedEntityIds.length) { let promises = changedEntityIds.map(entityId => this.handleEntity(entityId, isInitialLoad)); return Promise.all(promises) .then(() => { return Promise.resolve(changedEntityIds); }); } else { return Promise.resolve(); } } getChangedEntities(isInitialLoad) { let changedEntityIds = []; let entityIds = Object.keys(this.hass.states); let lastMotionEntityInfo, oldLastMotionState, newLastMotionState; if (this.lastMotionConfig) { lastMotionEntityInfo = this.entityInfos[this.lastMotionConfig.entity]; if (lastMotionEntityInfo && lastMotionEntityInfo.lastState) { oldLastMotionState = lastMotionEntityInfo.lastState.state; newLastMotionState = this.hass.states[this.lastMotionConfig.entity].state; } } for (let entityId of entityIds) { let entityInfo = this.entityInfos[entityId]; if (entityInfo) { let entityState = this.hass.states[entityId]; if (isInitialLoad) { this.logDebug('STATE', `${entityId}: ${entityState.state} (initial load)`); if (changedEntityIds.indexOf(entityId) < 0) { changedEntityIds.push(entityId); } } else if (entityInfo.lastState) { let oldState = entityInfo.lastState.state; let newState = entityState.state; if (entityState.last_changed !== entityInfo.lastState.last_changed) { this.logDebug('STATE', `${entityId}: ${newState} (last changed ${moment(entityInfo.lastState.last_changed).format("DD-MMM-YYYY HH:mm:ss")})`); if (changedEntityIds.indexOf(entityId) < 0) { changedEntityIds.push(entityId); } } else { if (!this.equal(entityInfo.lastState.attributes, entityState.attributes)) { this.logDebug('STATE', `${entityId}: attributes (last updated ${moment(entityInfo.lastState.last_changed).format("DD-MMM-YYYY HH:mm:ss")})`); if (changedEntityIds.indexOf(entityId) < 0) { changedEntityIds.push(entityId); } } } if (this.lastMotionConfig) { if ((newLastMotionState !== oldLastMotionState) && (entityId.indexOf('binary_sensor') >= 0)) { let friendlyName = entityState.attributes.friendly_name; if (friendlyName === newLastMotionState) { this.logDebug('LAST_MOTION', `${entityId} (new)`); if (changedEntityIds.indexOf(entityId) < 0) { changedEntityIds.push(entityId); } } else if (friendlyName === oldLastMotionState) { this.logDebug('LAST_MOTION', `${entityId} (old)`); if (changedEntityIds.indexOf(entityId) < 0) { changedEntityIds.push(entityId); } } } } } } } return changedEntityIds; } handleEntity(entityId, isInitialLoad) { let entityState = this.hass.states[entityId]; let entityInfo = this.entityInfos[entityId]; entityInfo.lastState = Object.assign({}, entityState); return this.handleEntityUpdateDom(entityInfo) .then(() => { this.handleEntityUpdateCss(entityInfo, isInitialLoad); this.handleEntityUpdateLastMotionCss(entityInfo); this.handleEntitySetHoverOver(entityInfo); return Promise.resolve(); }); } handleEntityUpdateDom(entityInfo) { let promises = []; let entityId = entityInfo.entityId; let entityState = this.hass.states[entityId]; for (let ruleInfo of entityInfo.ruleInfos) { for (let svgElementId in ruleInfo.svgElementInfos) { let svgElementInfo = ruleInfo.svgElementInfos[svgElementId]; if ($(svgElementInfo.svgElement).is('text')) { this.handleEntityUpdateText(entityId, ruleInfo, svgElementInfo); } else if (ruleInfo.rule.image || ruleInfo.rule.image_template) { promises.push(this.handleEntityUpdateImage(entityId, ruleInfo, svgElementInfo)); } } } return promises.length ? Promise.all(promises) : Promise.resolve(); } handleElements(isInitialLoad) { let promises = []; Object.keys(this.elementInfos).map(key => { let elementInfo = this.elementInfos[key]; let promise = this.handleElementUpdateDom(elementInfo) .then(() => { return this.handleElementUpdateCss(elementInfo, isInitialLoad); }); promises.push(promise); }); return promises.length ? Promise.all(promises) : Promise.resolve(); } handleElementUpdateDom(elementInfo) { let promises = []; for (let ruleInfo of elementInfo.ruleInfos) { for (let svgElementId in ruleInfo.svgElementInfos) { let svgElementInfo = ruleInfo.svgElementInfos[svgElementId]; if ($(svgElementInfo.svgElement).is('text')) { this.handleEntityUpdateText(svgElementId, ruleInfo, svgElementInfo); } else if (ruleInfo.rule.image || ruleInfo.rule.image_template) { promises.push(this.handleEntityUpdateImage(svgElementId, ruleInfo, svgElementInfo)); } } } return promises.length ? Promise.all(promises) : Promise.resolve(); } handleEntityUpdateText(entityId, ruleInfo, svgElementInfo) { let svgElement = svgElementInfo.svgElement; let state = this.hass.states[entityId] ? this.hass.states[entityId].state : undefined; let text = ruleInfo.rule.text_template ? this.evaluate(ruleInfo.rule.text_template, entityId, svgElement) : 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); } } if (!svgElementInfo.alreadyHadBackground) { let rect = $(svgElement).parent().find(`[id="${entityId}.background"]`); if (rect.length) { if ($(svgElement).css('display') != 'none') { let parentSvg = $(svgElement).parents('svg').eq(0); if ($(parentSvg).css('display') !== 'none') { let bbox = svgElement.getBBox(); $(rect) .attr('x', bbox.x - 1) .attr('y', bbox.y - 0.5) .attr('height', bbox.height + 1) .attr('width', bbox.width + 2) .height(bbox.height + 1) .width(bbox.width + 2); } } } } } handleEntityUpdateImage(entityId, ruleInfo, svgElementInfo) { let svgElement = svgElementInfo.svgElement; let imageUrl = ruleInfo.rule.image ? ruleInfo.rule.image : this.evaluate(ruleInfo.rule.image_template, entityId, svgElement); if (imageUrl && (ruleInfo.imageUrl !== imageUrl)) { ruleInfo.imageUrl = imageUrl; if (ruleInfo.imageLoader) { clearInterval(ruleInfo.imageLoader); // cancel any previous image loading for this rule } if (ruleInfo.rule.image_refresh_interval) { let refreshInterval = parseInt(ruleInfo.rule.image_refresh_interval); ruleInfo.imageLoader = setInterval((imageUrl, svgElement) => { this.loadImage(imageUrl, svgElementInfo, entityId, ruleInfo.rule) .catch(error => { this.handleError(error); }); }, refreshInterval * 1000, imageUrl, svgElement); } return this.loadImage(imageUrl, svgElementInfo, entityId, ruleInfo.rule) .catch(error => { this.handleError(error); }); } else { return Promise.resolve(); } } handleEntitySetHoverOver(entityInfo) { let entityId = entityInfo.entityId; let entityState = this.hass.states[entityId]; for (let ruleInfo of entityInfo.ruleInfos) { if (ruleInfo.rule.hover_over !== false) { for (let svgElementId in ruleInfo.svgElementInfos) { let svgElementInfo = ruleInfo.svgElementInfos[svgElementId]; this.handlEntitySetHoverOverText(svgElementInfo.svgElement, entityState); } } } } handlEntitySetHoverOverText(element, entityState) { let dateFormat = this.config.date_format ? this.config.date_format : 'DD-MMM-YYYY'; $(element).find('title').each((i, titleElement) => { let lastChangedElapsed = moment().to(moment(entityState.last_changed)); let lastChangedDate = moment(entityState.last_changed).format(dateFormat); let lastChangedTime = moment(entityState.last_changed).format('HH:mm:ss'); let lastUpdatedElapsed = moment().to(moment(entityState.last_updated)); let lastUpdatedDate = moment(entityState.last_updated).format(dateFormat); let lastUpdatedTime = moment(entityState.last_updated).format('HH:mm:ss'); let titleText = `${entityState.attributes.friendly_name}\n`; titleText += `State: ${entityState.state}\n\n`; Object.keys(entityState.attributes).map(key => { titleText += `${key}: ${entityState.attributes[key]}\n`; }); titleText += '\n'; titleText += `Last changed: ${lastChangedDate} ${lastChangedTime}\n`; titleText += `Last updated: ${lastUpdatedDate} ${lastUpdatedTime}`; $(titleElement).html(titleText); }); } handleElementUpdateCss(elementInfo, isInitialLoad) { if (!this.cssRules || !this.cssRules.length) { return; } for (let ruleInfo of elementInfo.ruleInfos) { for (let svgElementId in ruleInfo.svgElementInfos) { let svgElementInfo = ruleInfo.svgElementInfos[svgElementId]; this.handleUpdateElementCss(svgElementInfo, ruleInfo); } } } handleEntityUpdateCss(entityInfo, isInitialLoad) { if (!this.cssRules || !this.cssRules.length) { return; } for (let ruleInfo of entityInfo.ruleInfos) { for (let svgElementId in ruleInfo.svgElementInfos) { let svgElementInfo = ruleInfo.svgElementInfos[svgElementId]; if (svgElementInfo.svgElement) { // images may not have been updated yet let wasTransitionApplied = this.handleEntityUpdateTransitionCss(entityInfo, ruleInfo, svgElementInfo, isInitialLoad); this.handleUpdateCss(entityInfo, svgElementInfo, ruleInfo, wasTransitionApplied); } } } } handleEntityUpdateTransitionCss(entityInfo, ruleInfo, svgElementInfo, isInitialLoad) { let entityId = entityInfo.entityId; let entityState = this.hass.states[entityId]; let svgElement = svgElementInfo.svgElement; let wasTransitionApplied = false; if (ruleInfo.rule.states && ruleInfo.rule.state_transitions) { let transitionConfig = ruleInfo.rule.state_transitions.find(transitionConfig => (transitionConfig.to_state === entityState.state)); if (transitionConfig && transitionConfig.from_state && transitionConfig.to_state && transitionConfig.duration) { let elapsed = Math.max(moment().diff(moment(entityState.last_changed), 'milliseconds'), 0); let remaining = (transitionConfig.duration * 1000) - elapsed; let fromStateConfig = ruleInfo.rule.states.find(stateConfig => (stateConfig.state === transitionConfig.from_state)); let toStateConfig = ruleInfo.rule.states.find(stateConfig => (stateConfig.state === transitionConfig.to_state)); let fromColor = this.getFill(fromStateConfig); let toColor = this.getFill(toStateConfig); if (fromColor && toColor) { if (remaining > 0) { let transition = this.entityTransitions[entityId]; if (!transition) { this.logDebug('TRANSITION', `${entityId} (created)`); transition = { entityId: entityId, svgElementInfo: svgElementInfo, ruleInfo: ruleInfo, duration: transitionConfig.duration, fromStateConfig: fromStateConfig, toStateConfig: toStateConfig, fromColor: fromColor, toColor: toColor, startMoment: undefined, endMoment: undefined, isActive: false, }; this.entityTransitions[entityId] = transition; } // Assume the transition starts (or started) when the origin state change occurred transition.startMoment = this.serverToLocalMoment(moment(entityState.last_changed)); transition.endMoment = transition.startMoment.clone(); transition.endMoment.add(transition.duration, 'seconds'); // If the transition is not currently running, kick it off if (!transition.isActive) { // If this state change just occurred, the transition starts as of now if (!isInitialLoad) { let nowMoment = moment(); transition.startMoment = nowMoment.clone(); transition.endMoment = transition.startMoment.clone(); transition.endMoment.add(transition.duration, 'seconds'); } this.logDebug('TRANSITION', `${transition.entityId}: (start)`); transition.isActive = true; this.handleEntityTransition(transition); } else { // If the transition is currently running, it will be extended with latest start / end times this.logDebug('TRANSITION', `${transition.entityId} (continue)`); } } else { this.setEntityStyle(svgElementInfo, svgElement, entityInfo, ruleInfo); } } else { this.setEntityStyle(svgElementInfo, svgElement, entityInfo, ruleInfo); } wasTransitionApplied = true; } } return wasTransitionApplied; } handleUpdateCss(entityInfo, svgElementInfo, ruleInfo, wasTransitionApplied) { let entityId = entityInfo.entityId; let svgElement = svgElementInfo.svgElement; let targetClass = undefined; let obsoleteClasses = []; if (ruleInfo.rule.class_template) { targetClass = this.evaluate(ruleInfo.rule.class_template, entityId, svgElement); } // Get the config for the current state if (ruleInfo.rule.states) { let entityState = this.hass.states[entityId]; let stateConfig = ruleInfo.rule.states.find(stateConfig => (stateConfig.state === entityState.state)); if (stateConfig && stateConfig.class && !wasTransitionApplied) { targetClass = stateConfig.class; } // Remove any other previously-added state classes for (let otherStateConfig of ruleInfo.rule.states) { if (!stateConfig || (otherStateConfig.state !== stateConfig.state)) { if (otherStateConfig.class && (otherStateConfig.class !== targetClass) && (otherStateConfig.class !== 'ha-entity') && $(svgElement).hasClass(otherStateConfig.class)) { if (svgElementInfo.originalClasses.indexOf(otherStateConfig.class) < 0) { obsoleteClasses.push(otherStateConfig.class); } } } } } else { if (svgElement.classList) { for (let otherClassName of this.getArray(svgElement.classList)) { if ((otherClassName !== targetClass) && (otherClassName !== 'ha-entity')) { if (svgElementInfo.originalClasses.indexOf(otherClassName) < 0) { obsoleteClasses.push(otherClassName); } } } } } // Remove any obsolete classes from the entity if (obsoleteClasses.length) { //this.logDebug(`${entityId}: Removing obsolete classes: ${obsoleteClasses.join(', ')}`); this.removeClasses(entityId, svgElement, obsoleteClasses); } // Add the target class to the entity if (targetClass && !$(svgElement).hasClass(targetClass)) { let hasTransitionConfig = ruleInfo.rule.states && ruleInfo.rule.state_transitions; if (hasTransitionConfig && !wasTransitionApplied) { let transition = this.entityTransitions[entityId]; if (transition && transition.isActive) { this.logDebug('TRANSITION', `${transition.entityId} (cancel)`); transition.isActive = false; } } this.addClass(entityId, svgElement, targetClass); } } handleUpdateElementCss(svgElementInfo, ruleInfo) { let entityId = svgElementInfo.entityId; let svgElement = svgElementInfo.svgElement; let targetClass = undefined; if (ruleInfo.rule.class_template) { targetClass = this.evaluate(ruleInfo.rule.class_template, entityId, svgElement); } let obsoleteClasses = []; for (let otherClassName of this.getArray(svgElement.classList)) { if ((otherClassName !== targetClass) && (otherClassName !== 'ha-entity')) { if (svgElementInfo.originalClasses.indexOf(otherClassName) < 0) { obsoleteClasses.push(otherClassName); } } } // Remove any obsolete classes from the entity if (obsoleteClasses.length) { this.removeClasses(entityId, svgElement, obsoleteClasses); } // Add the target class to the entity if (targetClass && !$(svgElement).hasClass(targetClass)) { this.addClass(entityId, svgElement, targetClass); } } handleEntityTransition(transition) { if (!transition.isActive) { return; } let nowMoment = moment(); let isExpired = (nowMoment >= transition.endMoment); let ratio = isExpired ? 1 : (nowMoment - transition.startMoment) / (transition.endMoment - transition.startMoment); let color = this.getTransitionColor(transition.fromColor, transition.toColor, ratio); //this.logDebug('TRANSITION', `${transition.entityId} (ratio: ${ratio}, element: ${transition.svgElementInfo.svgElement.id}, fill: ${color})`); transition.svgElementInfo.svgElement.style.fill = color; if (isExpired) { transition.isActive = false; this.logDebug('TRANSITION', `${transition.entityId} (end)`); return; } setTimeout(() => { this.handleEntityTransition(transition); }, 100); } handleEntityUpdateLastMotionCss(entityInfo) { if (!this.lastMotionConfig || !this.cssRules || !this.cssRules.length) { return; } let entityId = entityInfo.entityId; let entityState = this.hass.states[entityId]; for (let ruleInfo of entityInfo.ruleInfos) { for (let svgElementId in ruleInfo.svgElementInfos) { let svgElementInfo = ruleInfo.svgElementInfos[svgElementId]; let svgElement = svgElementInfo.svgElement; if (this.hass.states[this.lastMotionConfig.entity] && (entityState.attributes.friendly_name === this.hass.states[this.lastMotionConfig.entity].state)) { if (!$(svgElement).hasClass(this.lastMotionConfig.class)) { //this.logDebug(`${entityId}: Adding last motion class '${this.lastMotionConfig.class}'`); $(svgElement).addClass(this.lastMotionConfig.class); } } else { if ($(svgElement).hasClass(this.lastMotionConfig.class)) { //this.logDebug(`${entityId}: Removing last motion class '${this.lastMotionConfig.class}'`); $(svgElement).removeClass(this.lastMotionConfig.class); } } } } } /***************************************************************************************************************************/ /* Floorplan helper functions /***************************************************************************************************************************/ isOptionEnabled(option) { return ((option === null) || (option !== undefined)); } isLastMotionEnabled() { return this.lastMotionConfig && this.config.last_motion.entity && this.config.last_motion.class; } validateConfig(config) { let isValid = true; if (!config.pages && !config.rules) { this.setIsLoading(false); this.logWarning('CONFIG', `Cannot find 'pages' nor 'rules' in floorplan configuration`); isValid = false; } else { if (config.pages) { if (!config.pages.length) { this.logWarning('CONFIG', `The 'pages' section must contain one or more pages in floorplan configuration`); isValid = false; } } else { if (!config.rules) { this.logWarning('CONFIG', `Cannot find 'rules' in floorplan configuration`); isValid = false; } let invalidRules = config.rules.filter(x => x.entities && x.elements); if (invalidRules.length) { this.logWarning('CONFIG', `A rule cannot contain both 'entities' and 'elements' in floorplan configuration`); isValid = false; } invalidRules = config.rules.filter(x => !(x.entity || x.entities) && !(x.element || x.elements)); if (invalidRules.length) { this.logWarning('CONFIG', `A rule must contain either 'entities' or 'elements' in floorplan configuration`); isValid = false; } } } return isValid; } localToServerMoment(localMoment) { let serverMoment = localMoment.clone(); if (this.timeDifference >= 0) serverMoment.subtract(this.timeDifference, 'milliseconds'); else serverMoment.add(Math.abs(this.timeDifference), 'milliseconds'); return serverMoment; } serverToLocalMoment(serverMoment) { let localMoment = serverMoment.clone(); if (this.timeDifference >= 0) localMoment.add(Math.abs(this.timeDifference), 'milliseconds'); else localMoment.subtract(this.timeDifference, 'milliseconds'); return localMoment; } evaluate(code, entityId, svgElement) { let entityState = this.hass.states[entityId]; let functionBody = (code.indexOf('${') >= 0) ? `\`${code}\`;` : code; functionBody = (functionBody.indexOf('return') >= 0) ? functionBody : `return ${functionBody};`; let func = new Function('entity', 'entities', 'hass', 'config', 'element', functionBody); return func(entityState, this.hass.states, this.hass, this.config, svgElement); } /***************************************************************************************************************************/ /* Event handlers /***************************************************************************************************************************/ onElementClick(e) { e.stopPropagation(); this.instance.onActionClick(this.svgElementInfo, this.elementId, this.elementId, this.rule); } onEntityClick(e) { e.stopPropagation(); this.instance.onActionClick(this.svgElementInfo, this.entityId, this.elementId, this.rule); } onActionClick(svgElementInfo, entityId, elementId, rule) { if (!rule || !rule.action) { if (entityId && (rule.more_info !== false)) { this.openMoreInfo(entityId); } return; } let calledServiceCount = 0; let svgElement = svgElementInfo.svgElement; let actions = Array.isArray(rule.action) ? rule.action : [rule.action]; for (let action of actions) { if (action.service || action.service_template) { let actionService = this.getActionService(action, entityId, svgElement); switch (this.getDomain(actionService)) { case 'floorplan': this.callFloorplanService(action, entityId, svgElementInfo); break; default: this.callHomeAssistantService(action, entityId, svgElementInfo); break; } calledServiceCount++; } } if (!calledServiceCount) { if (entityId && (rule.more_info !== false)) { this.openMoreInfo(entityId); } } } callFloorplanService(action, entityId, svgElementInfo) { let svgElement = svgElementInfo ? svgElementInfo.svgElement : undefined; let actionService = this.getActionService(action, entityId, svgElement); let actionData = this.getActionData(action, entityId, svgElement); switch (this.getService(actionService)) { case 'class_toggle': if (actionData) { let classes = actionData.classes; for (let otherElementId of actionData.elements) { let otherSvgElement = $(svgElementInfo.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(actionData.default_class); } } } break; case 'page_navigate': let page_id = actionData.page_id; let targetPageInfo = page_id && this.pageInfos[page_id]; if (targetPageInfo) { Object.keys(this.pageInfos).map(key => { let pageInfo = this.pageInfos[key]; if (!pageInfo.isMaster) { if ($(pageInfo.svg).css('display') !== 'none') { $(pageInfo.svg).css('display', 'none'); } } }); $(targetPageInfo.svg).css('display', 'initial'); } break; case 'variable_set': if (actionData.variable) { let attributes = []; if (actionData.attributes) { for (let attribute of actionData.attributes) { let attributeValue = this.getActionValue(attribute, entityId, svgElement); attributes.push({ name: attribute.attribute, value: attributeValue }); } } let value = this.getActionValue(actionData, entityId, svgElement); this.setVariable(actionData.variable, value, attributes); } break; default: // Unknown floorplan service break; } } getActionValue(action, entityId, svgElement) { let value = action.value; if (action.value_template) { value = this.evaluate(action.value_template, entityId, svgElement); } return value; } setVariable(variableName, value, attributes, isInitialLoad) { this.variables[variableName] = value; if (this.hass.states[variableName]) { this.hass.states[variableName].state = value; for (let attribute of attributes) { this.hass.states[variableName].attributes[attribute.name] = attribute.value; } } for (let otherVariableName of Object.keys(this.variables)) { this.hass.states[otherVariableName].last_changed = new Date(); // mark all variables as changed } // Simulate an event change to all entities if (!isInitialLoad) { this.handleEntitiesDebounced(); // use debounced wrapper } } /***************************************************************************************************************************/ /* Home Assisant helper functions /***************************************************************************************************************************/ callHomeAssistantService(action, entityId, svgElementInfo) { let svgElement = svgElementInfo ? svgElementInfo.svgElement : undefined; let actionService = this.getActionService(action, entityId, svgElement); let actionData = this.getActionData(action, entityId, svgElement); /* if (!actionData.entity_id && entityId) { actionData.entity_id = entityId; } */ this.hass.callService(this.getDomain(actionService), this.getService(actionService), actionData); } getActionData(action, entityId, svgElement) { let data = action.data ? action.data : {}; if (action.data_template) { let result = this.evaluate(action.data_template, entityId, svgElement); data = (typeof result === 'string') ? JSON.parse(result) : result; } return data; } getActionService(action, entityId, svgElement) { let service = action.service; if (action.service_template) { service = this.evaluate(action.service_template, entityId, svgElement); } return service; } getDomain(actionService) { return actionService.split(".")[0]; } getService(actionService) { return actionService.split(".")[1]; } /***************************************************************************************************************************/ /* Logging / error handling functions /***************************************************************************************************************************/ handleWindowError(msg, url, lineNo, columnNo, error) { this.setIsLoading(false); if (msg.toLowerCase().indexOf("script error") >= 0) { this.logError('Script error: See browser console for detail'); } else { let message = [ msg, 'URL: ' + url, 'Line: ' + lineNo + ', column: ' + columnNo, 'Error: ' + JSON.stringify(error) ].join('
'); this.logError(message); } return false; } handleError(error) { let message = error; if (error.stack) { message = `${error.stack}`; } else if (error.message) { message = `${error.message}`; } this.log('error', message); } logError(message) { this.log('error', message); } logWarning(area, message) { this.log('warning', `${area} ${message}`); } logInfo(area, message) { this.log('info', `${area} ${message}`); } logDebug(area, message) { this.log('debug', `${area} ${message}`); } log(level, message) { let text = `${moment().format("DD-MM-YYYY HH:mm:ss")} ${level.toUpperCase()} ${message}`; switch (level) { case 'error': console.error(text); break; case 'warning': console.warn(text); break; case 'error': console.info(text); break; default: console.log(text); break; } if (!this.config) { // Always log messages before the config has been loaded } else if (!this.logLevels || !this.logLevels.length || (this.logLevels.indexOf(level) < 0)) { return; } let log = $(this.doc).find('#log'); $(log).find('ul').prepend(`
  • ${text}
  • `) $(log).css('display', 'block'); } /***************************************************************************************************************************/ /* CSS helper functions /***************************************************************************************************************************/ 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; } 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)}${rgb[1].toString(16)}${rgb[2].toString(16)}`; } } } } return fill; } getTransitionColor(fromColor, toColor, value) { return (value <= 0) ? fromColor : ((value >= 1) ? toColor : this.rgbToHex(this.mix(this.hexToRgb(toColor), this.hexToRgb(fromColor), value))); } /***************************************************************************************************************************/ /* General helper functions /***************************************************************************************************************************/ fetchTextResource(resourceUrl, useCache) { resourceUrl = this.cacheBuster(resourceUrl); useCache = false; return new Promise((resolve, reject) => { let request = new Request(resourceUrl, { cache: (useCache === true) ? 'reload' : 'no-cache', }); fetch(request) .then((response) => { if (response.ok) { return response.text(); } else { throw new Error(`Error fetching resource`); } }) .then((result) => resolve(result)) .catch((err) => { reject(new URIError(`${resourceUrl}: ${err.message}`)); }); }); } fetchImageResource(resourceUrl, useCache) { resourceUrl = this.cacheBuster(resourceUrl); useCache = false; return new Promise((resolve, reject) => { let request = new Request(resourceUrl, { cache: (useCache === true) ? 'reload' : 'no-cache', headers: new Headers({ 'Content-Type': 'text/plain; charset=x-user-defined' }), }); fetch(request) .then((response) => { if (response.ok) { return response.arrayBuffer(); } else { throw new Error(`Error fetching resource`); } }) .then((result) => resolve(`data:image/jpeg;base64,${this.arrayBufferToBase64(result)}`)) .catch((err) => { reject(new URIError(`${resourceUrl}: ${err.message}`)); }); }); } /***************************************************************************************************************************/ /* Utility functions /***************************************************************************************************************************/ getArray(list) { return Array.isArray(list) ? list : Object.keys(list).map(key => list[key]); } arrayBufferToBase64(buffer) { let binary = ''; let bytes = [].slice.call(new Uint8Array(buffer)); bytes.forEach((b) => binary += String.fromCharCode(b)); let base64 = window.btoa(binary); // IOS / Safari will not render base64 images unless length is divisible by 4 while ((base64.length % 4) > 0) { base64 += '='; } return base64; } base64Encodebase64Encode(str) { let CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; let out = "", i = 0, len = str.length, c1, c2, c3; while (i < len) { c1 = str.charCodeAt(i++) & 0xff; if (i === len) { out += CHARS.charAt(c1 >> 2); out += CHARS.charAt((c1 & 0x3) << 4); out += "=="; break; } c2 = str.charCodeAt(i++); if (i === len) { out += CHARS.charAt(c1 >> 2); out += CHARS.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4)); out += CHARS.charAt((c2 & 0xF) << 2); out += "="; break; } c3 = str.charCodeAt(i++); out += CHARS.charAt(c1 >> 2); out += CHARS.charAt(((c1 & 0x3) << 4) | ((c2 & 0xF0) >> 4)); out += CHARS.charAt(((c2 & 0xF) << 2) | ((c3 & 0xC0) >> 6)); out += CHARS.charAt(c3 & 0x3F); } // IOS / Safari will not render base64 images unless length is divisible by 4 while ((out.length % 4) > 0) { out += '='; } return out; } cacheBuster(url) { return `${url}${(url.indexOf('?') >= 0) ? '&' : '?'}_=${new Date().getTime()}`; } debounce(func, wait, immediate) { let timeout; return function () { let context = this, args = arguments; let later = function () { timeout = null; if (!immediate) func.apply(context, args); }; let callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; } equal(a, b) { if (a === b) return true; let arrA = Array.isArray(a) , arrB = Array.isArray(b) , i; if (arrA && arrB) { if (a.length != b.length) return false; for (i = 0; i < a.length; i++) if (!this.equal(a[i], b[i])) return false; return true; } if (arrA != arrB) return false; if (a && b && typeof a === 'object' && typeof b === 'object') { let keys = Object.keys(a); if (keys.length !== Object.keys(b).length) return false; let dateA = a instanceof Date , dateB = b instanceof Date; if (dateA && dateB) return a.getTime() == b.getTime(); if (dateA != dateB) return false; let regexpA = a instanceof RegExp , regexpB = b instanceof RegExp; if (regexpA && regexpB) return a.toString() == b.toString(); if (regexpA != regexpB) return false; for (i = 0; i < keys.length; i++) if (!Object.prototype.hasOwnProperty.call(b, keys[i])) return false; for (i = 0; i < keys.length; i++) if (!this.equal(a[keys[i]], b[keys[i]])) return false; return true; } return false; } /***************************************************************************************************************************/ /* Color functions /***************************************************************************************************************************/ 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; } wrap(svgTextElement, width, content) { let $text = $(svgTextElement); let words = content.split(/\s+/).reverse(); let line = []; let lineNumber = 0; let lineHeight = 1.1; // ems let x = $text.attr("x"); let y = $text.attr("y"); //let dy = 0; //parseFloat($text.attr("dy")), let $tspan = $text.append("tspan") .attr("x", x) .attr("y", y) .attr("dy", dy + "em") .text(null); let word; while (word = words.pop()) { line.push(word); $tspan.text(line.join(" ")); if ($tspan.node().getComputedTextLength() > width) { line.pop(); $tspan.text(line.join(" ")); line = [word]; $tspan = $text.append("tspan") .attr("x", x) .attr("y", y) .attr("dy", ++lineNumber * lineHeight + dy + "em") .text(word); } } } } window.Floorplan = Floorplan; }).call(this);