export default class ThermostatUI { get container() { return this._container } set dual(val) { this._dual = val } get dual() { return this._dual; } get in_control() { return this._in_control; } get temperature() { return { low: this._low, high: this._high, target: this._target, } } get ambient() { return this._ambient; } set temperature(val) { this._ambient = val.ambient; this._low = val.low; this._high = val.high; this._target = val.target; if (this._low && this._high) this.dual = true; } constructor(config) { this._config = config; // need certain options for updates this._ticks = []; // need for dynamic tick updates this._controls = []; // need for managing highlight and clicks this._dual = false; // by default is single temperature this._container = document.createElement('div'); this._main_icon = document.createElement('div'); this._modes_dialog = document.createElement('div'); config.title = config.title === null || config.title === undefined ? 'Title' : config.title this._ic = document.createElement('div'); this._ic.className = "prop"; this._ic.innerHTML = ``; this._container.appendChild(this._ic) // this._container.appendChild(this._buildTitle(config.title)); this._ic.addEventListener('click', () => this.openProp()); this._container.appendChild(this._load_icon('','')); this.c_body = document.createElement('div'); this.c_body.className = 'c_body'; const root = this._buildCore(config.diameter); root.appendChild(this._buildDial(config.radius)); root.appendChild(this._buildTicks(config.num_ticks)); root.appendChild(this._buildRing(config.radius)); root.appendChild(this._buildThermoIcon(config.radius)); root.appendChild(this._buildDialSlot(1)); root.appendChild(this._buildDialSlot(2)); root.appendChild(this._buildDialSlot(3)); root.appendChild(this._buildText(config.radius, 'title', 0)); root.appendChild(this._buildText(config.radius, 'ambient', 0)); root.appendChild(this._buildText(config.radius, 'target', 0)); root.appendChild(this._buildText(config.radius, 'low', -config.radius / 2.5)); root.appendChild(this._buildText(config.radius, 'high', config.radius / 3)); root.appendChild(this._buildChevrons(config.radius, 0, 'low', 0.7, -config.radius / 2.5)); root.appendChild(this._buildChevrons(config.radius, 0, 'high', 0.7, config.radius / 3)); root.appendChild(this._buildChevrons(config.radius, 0, 'target', 1, 0)); root.appendChild(this._buildChevrons(config.radius, 180, 'low', 0.7, -config.radius / 2.5)); root.appendChild(this._buildChevrons(config.radius, 180, 'high', 0.7, config.radius / 3)); root.appendChild(this._buildChevrons(config.radius, 180, 'target', 1, 0)); this.c_body.appendChild(root); this._container.appendChild(this.c_body); this._root = root; this._buildControls(config.radius); this._root.addEventListener('click', () => this._enableControls()); this._container.appendChild(this._buildDialog()); this._main_icon.addEventListener('click', () => this._openDialog()); this._modes_dialog.addEventListener('click', () => this._hideDialog()); this._updateText('title', config.title); } updateState(options,hass) { const config = this._config; const away = options.away || false; this.entity = options.entity; this.min_value = options.min_value; this.max_value = options.max_value; this.hvac_state = options.hvac_state; this.preset_mode = options.preset_mode; this.hvac_modes = options.hvac_modes; this.temperature = { low: options.target_temperature_low, high: options.target_temperature_high, target: options.target_temperature, ambient: options.ambient_temperature, } this._updateClass('has_dual', this.dual); let tick_label, from, to; const tick_indexes = []; const ambient_index = SvgUtil.restrictToRange(Math.round((this.ambient - this.min_value) / (this.max_value - this.min_value) * config.num_ticks), 0, config.num_ticks - 1); const target_index = SvgUtil.restrictToRange(Math.round((this._target - this.min_value) / (this.max_value - this.min_value) * config.num_ticks), 0, config.num_ticks - 1); const high_index = SvgUtil.restrictToRange(Math.round((this._high - this.min_value) / (this.max_value - this.min_value) * config.num_ticks), 0, config.num_ticks - 1); const low_index = SvgUtil.restrictToRange(Math.round((this._low - this.min_value) / (this.max_value - this.min_value) * config.num_ticks), 0, config.num_ticks - 1); if (!this.dual) { tick_label = [this._target, this.ambient].sort(); this._updateTemperatureSlot(tick_label[0], -8, `temperature_slot_1`); this._updateTemperatureSlot(tick_label[1], 8, `temperature_slot_2`); switch (this.hvac_state) { case 'dry': this._load_icon(this.hvac_state, 'water-percent'); break; case 'fan_only': this._load_icon(this.hvac_state, 'fan'); break; case 'cool': this._load_icon(this.hvac_state, 'snowflake'); if (target_index <= ambient_index) { from = target_index; to = ambient_index; } break; case 'heat': this._load_icon(this.hvac_state, 'fire'); if (target_index >= ambient_index) { from = ambient_index; to = target_index; } break; case 'auto': this._load_icon(this.hvac_state, 'atom'); if (target_index >= ambient_index) { from = ambient_index; to = target_index; } break; case 'off': this._load_icon(this.hvac_state, 'power'); break; default: this._load_icon('more', 'dots-horizontal'); } } else { tick_label = [this._low, this._high, this.ambient].sort(); this._updateTemperatureSlot(null, 0, `temperature_slot_1`); this._updateTemperatureSlot(null, 0, `temperature_slot_2`); this._updateTemperatureSlot(null, 0, `temperature_slot_3`); switch (this.hvac_state) { case 'cool': if (high_index < ambient_index) { from = high_index; to = ambient_index; this._updateTemperatureSlot(this.ambient, 8, `temperature_slot_3`); this._updateTemperatureSlot(this._high, -8, `temperature_slot_2`); } break; case 'heat': if (low_index > ambient_index) { from = ambient_index; to = low_index; this._updateTemperatureSlot(this.ambient, -8, `temperature_slot_1`); this._updateTemperatureSlot(this._low, 8, `temperature_slot_2`); } break; case 'off': if (high_index < ambient_index) { from = high_index; to = ambient_index; this._updateTemperatureSlot(this.ambient, 8, `temperature_slot_3`); this._updateTemperatureSlot(this._high, -8, `temperature_slot_2`); } if (low_index > ambient_index) { from = ambient_index; to = low_index; this._updateTemperatureSlot(this.ambient, -8, `temperature_slot_1`); this._updateTemperatureSlot(this._low, 8, `temperature_slot_2`); } break; default: } } tick_label.forEach(item => tick_indexes.push(SvgUtil.restrictToRange(Math.round((item - this.min_value) / (this.max_value - this.min_value) * config.num_ticks), 0, config.num_ticks - 1))); this._updateTicks(from, to, tick_indexes, this.hvac_state); // this._updateColor(this.hvac_state, this.preset_mode); this._updateText('ambient', this.ambient); this._updateEdit(false); this._updateDialog(this.hvac_modes,hass); } _temperatureControlClicked(index) { const config = this._config; let chevron; this._root.querySelectorAll('path.dial__chevron').forEach(el => SvgUtil.setClass(el, 'pressed', false)); if (this.in_control) { if (this.dual) { switch (index) { case 0: // clicked top left chevron = this._root.querySelectorAll('path.dial__chevron--low')[1]; this._low = this._low + config.step; if ((this._low + config.idle_zone) >= this._high) this._low = this._high - config.idle_zone; break; case 1: // clicked top right chevron = this._root.querySelectorAll('path.dial__chevron--high')[1]; this._high = this._high + config.step; if (this._high > this.max_value) this._high = this.max_value; break; case 2: // clicked bottom right chevron = this._root.querySelectorAll('path.dial__chevron--high')[0]; this._high = this._high - config.step; if ((this._high - config.idle_zone) <= this._low) this._high = this._low + config.idle_zone; break; case 3: // clicked bottom left chevron = this._root.querySelectorAll('path.dial__chevron--low')[0]; this._low = this._low - config.step; if (this._low < this.min_value) this._low = this.min_value; break; } SvgUtil.setClass(chevron, 'pressed', true); setTimeout(() => SvgUtil.setClass(chevron, 'pressed', false), 200); if (config.highlight_tap) SvgUtil.setClass(this._controls[index], 'control-visible', true); } else { if (index < 2) { // clicked top chevron = this._root.querySelectorAll('path.dial__chevron--target')[1]; this._target = this._target + config.step; if (this._target > this.max_value) this._target = this.max_value; if (config.highlight_tap) { SvgUtil.setClass(this._controls[0], 'control-visible', true); SvgUtil.setClass(this._controls[1], 'control-visible', true); } } else { // clicked bottom chevron = this._root.querySelectorAll('path.dial__chevron--target')[0]; this._target = this._target - config.step; if (this._target < this.min_value) this._target = this.min_value; if (config.highlight_tap) { SvgUtil.setClass(this._controls[2], 'control-visible', true); SvgUtil.setClass(this._controls[3], 'control-visible', true); } } SvgUtil.setClass(chevron, 'pressed', true); setTimeout(() => SvgUtil.setClass(chevron, 'pressed', false), 200); } if (config.highlight_tap) { setTimeout(() => { SvgUtil.setClass(this._controls[0], 'control-visible', false); SvgUtil.setClass(this._controls[1], 'control-visible', false); SvgUtil.setClass(this._controls[2], 'control-visible', false); SvgUtil.setClass(this._controls[3], 'control-visible', false); }, 200); } } else { this._enableControls(); } } _updateEdit(show_edit) { SvgUtil.setClass(this._root, 'dial--edit', show_edit); } _enableControls() { const config = this._config; this._in_control = true; this._updateClass('in_control', this.in_control); if (this._timeoutHandler) clearTimeout(this._timeoutHandler); this._updateEdit(true); //this._updateClass('has-thermo', true); this._updateText('target', this.temperature.target); this._updateText('low', this.temperature.low); this._updateText('high', this.temperature.high); this._timeoutHandler = setTimeout(() => { this._updateText('ambient', this.ambient); this._updateEdit(false); //this._updateClass('has-thermo', false); this._in_control = false; this._updateClass('in_control', this.in_control); config.control(); }, config.pending * 1000); } _updateClass(class_name, flag) { SvgUtil.setClass(this._root, class_name, flag); } _updateText(id, value) { const lblTarget = this._root.querySelector(`#${id}`).querySelectorAll('tspan'); const text = Math.floor(value); if (value) { lblTarget[0].textContent = text; if (value % 1 != 0) { lblTarget[1].textContent = Math.round(value % 1 * 10); } else { lblTarget[1].textContent = ''; } } if (this.in_control && id == 'target' && this.dual) { lblTarget[0].textContent = 'ยท'; } if(id =='title'){ lblTarget[0].textContent = value; lblTarget[1].textContent = ''; } } _updateTemperatureSlot(value, offset, slot) { const config = this._config; const lblSlot1 = this._root.querySelector(`#${slot}`) lblSlot1.textContent = value != null ? SvgUtil.superscript(value) : ''; const peggedValue = SvgUtil.restrictToRange(value, this.min_value, this.max_value); const position = [config.radius, config.ticks_outer_radius - (config.ticks_outer_radius - config.ticks_inner_radius) / 2]; let degs = config.tick_degrees * (peggedValue - this.min_value) / (this.max_value - this.min_value) - config.offset_degrees + offset; const pos = SvgUtil.rotatePoint(position, degs, [config.radius, config.radius]); SvgUtil.attributes(lblSlot1, { x: pos[0], y: pos[1] }); } _updateColor(state, preset_mode) { if(Object.prototype.toString.call(preset_mode) === "[object String]"){ if(state != 'off' && preset_mode.toLowerCase() == 'idle') state = 'idle' this._root.classList.forEach(c => { if (c.indexOf('dial--state--') != -1) this._root.classList.remove(c); }); this._root.classList.add('dial--state--' + state); } } _updateTicks(from, to, large_ticks, hvac_state) { const config = this._config; const tickPoints = [ [config.radius - 1, config.ticks_outer_radius], [config.radius + 1, config.ticks_outer_radius], [config.radius + 1, config.ticks_inner_radius], [config.radius - 1, config.ticks_inner_radius] ]; const tickPointsLarge = [ [config.radius - 1.5, config.ticks_outer_radius], [config.radius + 1.5, config.ticks_outer_radius], [config.radius + 1.5, config.ticks_inner_radius + 20], [config.radius - 1.5, config.ticks_inner_radius + 20] ]; this._ticks.forEach((tick, index) => { let isLarge = false; let isActive = (index >= from && index <= to) ? 'active '+hvac_state : ''; large_ticks.forEach(i => isLarge = isLarge || (index == i)); if (isLarge) isActive += ' large'; const theta = config.tick_degrees / config.num_ticks; SvgUtil.attributes(tick, { d: SvgUtil.pointsToPath(SvgUtil.rotatePoints(isLarge ? tickPointsLarge : tickPoints, index * theta - config.offset_degrees, [config.radius, config.radius])), class: isActive }); }); } _updateDialog(modes,hass){ this._modes_dialog.innerHTML = ""; for(var i=0;i` d.addEventListener('click', (e) => this._setMode(e,mode,hass)); //this._modes[i].push(d); this._modes_dialog.appendChild(d) } } _buildCore(diameter) { return SvgUtil.createSVGElement('svg', { width: '100%', height: '100%', viewBox: '0 0 ' + diameter + ' ' + diameter, class: 'dial' }) } openProp() { this._config.propWin(this.entity.entity_id) } _openDialog(){ this._modes_dialog.className = "dialog modes"; } _hideDialog(){ this._modes_dialog.className = "dialog modes hide"; } _setMode(e, mode,hass){ console.log(mode); let config = this._config; if (this._timeoutHandlerMode) clearTimeout(this._timeoutHandlerMode); hass.callService('climate', 'set_hvac_mode', { entity_id: this._config.entity, hvac_mode: mode, }); this._modes_dialog.className = "dialog modes "+mode+" pending"; this._timeoutHandlerMode = setTimeout(() => { this._modes_dialog.className = "dialog modes hide"; }, config.pending * 1000); e.stopPropagation(); } _load_icon(state, ic_name){ let ic_dot = 'dot_r' if(ic_name == ''){ ic_dot = 'dot_h' } this._main_icon.innerHTML = `
`; return this._main_icon; } _buildDialog(){ this._modes_dialog.className = "dialog modes hide"; return this._modes_dialog; } // build black dial _buildDial(radius) { return SvgUtil.createSVGElement('circle', { cx: radius, cy: radius, r: radius, class: 'dial__shape' }) } // build circle around _buildRing(radius) { return SvgUtil.createSVGElement('path', { d: SvgUtil.donutPath(radius, radius, radius - 4, radius - 8), class: 'dial__editableIndicator', }) } _buildTicks(num_ticks) { const tick_element = SvgUtil.createSVGElement('g', { class: 'dial__ticks' }); for (let i = 0; i < num_ticks; i++) { const tick = SvgUtil.createSVGElement('path', {}) this._ticks.push(tick); tick_element.appendChild(tick); } return tick_element; } _buildChevrons(radius, rotation, id, scale, offset) { const config = this._config; const translation = rotation > 0 ? -1 : 1; const width = config.chevron_size; const chevron_def = ["M", 0, 0, "L", width / 2, width * 0.3, "L", width, 0].map((x) => isNaN(x) ? x : x * scale).join(' '); const translate = [radius - width / 2 * scale * translation + offset, radius + 70 * scale * 1.1 * translation]; const chevron = SvgUtil.createSVGElement('path', { class: `dial__chevron dial__chevron--${id}`, d: chevron_def, transform: `translate(${translate[0]},${translate[1]}) rotate(${rotation})` }); return chevron; } _buildThermoIcon(radius) { const thermoScale = radius / 3 / 100; const thermoDef = 'M 37.999 38.261 V 7 c 0 -3.859 -3.141 -7 -7 -7 s -7 3.141 -7 7 v 31.261 c -3.545 2.547 -5.421 6.769 -4.919 11.151 c 0.629 5.482 5.066 9.903 10.551 10.512 c 0.447 0.05 0.895 0.074 1.339 0.074 c 2.956 0 5.824 -1.08 8.03 -3.055 c 2.542 -2.275 3.999 -5.535 3.999 -8.943 C 42.999 44.118 41.14 40.518 37.999 38.261 Z M 37.666 55.453 c -2.146 1.921 -4.929 2.8 -7.814 2.482 c -4.566 -0.506 -8.261 -4.187 -8.785 -8.752 c -0.436 -3.808 1.28 -7.471 4.479 -9.56 l 0.453 -0.296 V 38 h 1 c 0.553 0 1 -0.447 1 -1 s -0.447 -1 -1 -1 h -1 v -3 h 1 c 0.553 0 1 -0.447 1 -1 s -0.447 -1 -1 -1 h -1 v -3 h 1 c 0.553 0 1 -0.447 1 -1 s -0.447 -1 -1 -1 h -1 v -3 h 1 c 0.553 0 1 -0.447 1 -1 s -0.447 -1 -1 -1 h -1 v -3 h 1 c 0.553 0 1 -0.447 1 -1 s -0.447 -1 -1 -1 h -1 v -3 h 1 c 0.553 0 1 -0.447 1 -1 s -0.447 -1 -1 -1 h -1 V 8 h 1 c 0.553 0 1 -0.447 1 -1 s -0.447 -1 -1 -1 H 26.1 c 0.465 -2.279 2.484 -4 4.899 -4 c 2.757 0 5 2.243 5 5 v 1 h -1 c -0.553 0 -1 0.447 -1 1 s 0.447 1 1 1 h 1 v 3 h -1 c -0.553 0 -1 0.447 -1 1 s 0.447 1 1 1 h 1 v 3 h -1 c -0.553 0 -1 0.447 -1 1 s 0.447 1 1 1 h 1 v 3 h -1 c -0.553 0 -1 0.447 -1 1 s 0.447 1 1 1 h 1 v 3 h -1 c -0.553 0 -1 0.447 -1 1 s 0.447 1 1 1 h 1 v 3 h -1 c -0.553 0 -1 0.447 -1 1 s 0.447 1 1 1 h 1 v 4.329 l 0.453 0.296 c 2.848 1.857 4.547 4.988 4.547 8.375 C 40.999 50.841 39.784 53.557 37.666 55.453 Z'.split(' ').map((x) => isNaN(x) ? x : x * thermoScale).join(' '); const translate = [radius - (thermoScale * 100 * 0.3), radius * 1.65] return SvgUtil.createSVGElement('path', { class: 'dial__ico__thermo', d: thermoDef, transform: 'translate(' + translate[0] + ',' + translate[1] + ')' }); } _buildDialSlot(index) { return SvgUtil.createSVGElement('text', { class: 'dial__lbl dial__lbl--ring', id: `temperature_slot_${index}` }) } _buildText(radius, name, offset) { const target = SvgUtil.createSVGElement('text', { x: radius + offset, y: radius-(name=='title'?radius/2:0), class: `dial__lbl dial__lbl--${name}`, id: name }); const text = SvgUtil.createSVGElement('tspan', { }); // hack if (name == 'target' || name == 'ambient') offset += 20; const superscript = SvgUtil.createSVGElement('tspan', { x: radius + radius / 3.1 + offset, y: radius - radius / 6, class: `dial__lbl--super--${name}` }); target.appendChild(text); target.appendChild(superscript); return target; } _buildControls(radius) { let startAngle = 270; let loop = 4; for (let index = 0; index < loop; index++) { const angle = 360 / loop; const sector = SvgUtil.anglesToSectors(radius, startAngle, angle); const controlsDef = 'M' + sector.L + ',' + sector.L + ' L' + sector.L + ',0 A' + sector.L + ',' + sector.L + ' 1 0,1 ' + sector.X + ', ' + sector.Y + ' z'; const path = SvgUtil.createSVGElement('path', { class: 'dial__temperatureControl', fill: 'blue', d: controlsDef, transform: 'rotate(' + sector.R + ', ' + sector.L + ', ' + sector.L + ')' }); this._controls.push(path); path.addEventListener('click', () => this._temperatureControlClicked(index)); this._root.appendChild(path); startAngle = startAngle + angle; } } } class SvgUtil { static createSVGElement(tag, attributes) { const element = document.createElementNS('http://www.w3.org/2000/svg', tag); this.attributes(element, attributes) return element; } static attributes(element, attrs) { for (let i in attrs) { element.setAttribute(i, attrs[i]); } } // Rotate a cartesian point about given origin by X degrees static rotatePoint(point, angle, origin) { const radians = angle * Math.PI / 180; const x = point[0] - origin[0]; const y = point[1] - origin[1]; const x1 = x * Math.cos(radians) - y * Math.sin(radians) + origin[0]; const y1 = x * Math.sin(radians) + y * Math.cos(radians) + origin[1]; return [x1, y1]; } // Rotate an array of cartesian points about a given origin by X degrees static rotatePoints(points, angle, origin) { return points.map((point) => this.rotatePoint(point, angle, origin)); } // Given an array of points, return an SVG path string representing the shape they define static pointsToPath(points) { return points.map((point, iPoint) => (iPoint > 0 ? 'L' : 'M') + point[0] + ' ' + point[1]).join(' ') + 'Z'; } static circleToPath(cx, cy, r) { return [ "M", cx, ",", cy, "m", 0 - r, ",", 0, "a", r, ",", r, 0, 1, ",", 0, r * 2, ",", 0, "a", r, ",", r, 0, 1, ",", 0, 0 - r * 2, ",", 0, "z" ].join(' ').replace(/\s,\s/g, ","); } static donutPath(cx, cy, rOuter, rInner) { return this.circleToPath(cx, cy, rOuter) + " " + this.circleToPath(cx, cy, rInner); } static superscript(n) { if ((n - Math.floor(n)) !== 0) n = Number(n).toFixed(1);; const x = `${n}${n == 0 ? '' : ''}`; return x; } // Restrict a number to a min + max range static restrictToRange(val, min, max) { if (val < min) return min; if (val > max) return max; return val; } static setClass(el, className, state) { el.classList[state ? 'add' : 'remove'](className); } static anglesToSectors(radius, startAngle, angle) { let aRad = 0 // Angle in Rad let z = 0 // Size z let x = 0 // Side x let X = 0 // SVG X coordinate let Y = 0 // SVG Y coordinate const aCalc = (angle > 180) ? 360 - angle : angle; aRad = aCalc * Math.PI / 180; z = Math.sqrt(2 * radius * radius - (2 * radius * radius * Math.cos(aRad))); if (aCalc <= 90) { x = radius * Math.sin(aRad); } else { x = radius * Math.sin((180 - aCalc) * Math.PI / 180); } Y = Math.sqrt(z * z - x * x); if (angle <= 180) { X = radius + x; } else { X = radius - x; } return { L: radius, X: X, Y: Y, R: startAngle } } }