2100 lines
71 KiB
JavaScript
Executable File
2100 lines
71 KiB
JavaScript
Executable File
/*
|
|
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('<br>');
|
|
|
|
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(`<li class="${level}">${text}</li>`)
|
|
$(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);
|