/** * Floor plan editor. * * @param {number} thermostat_id */ beestat.component.card.floor_plan_editor = function(thermostat_id) { const self = this; this.thermostat_id_ = thermostat_id; // Whether or not to show the editor when loading. this.show_editor_ = beestat.floor_plan.get_bounding_box( beestat.setting('visualize.floor_plan_id') ).x === Infinity; /* const change_function = beestat.debounce(function() { // todo replace these with (if entity set active false?) delete self.state_.active_group; self.rerender(); // Center the content if the floor plan changed. if (self.floor_plan_ !== undefined) { self.floor_plan_.center_content(); } }, 10); beestat.dispatcher.addEventListener( 'setting.visualize.floor_plan_id', change_function );*/ beestat.component.card.apply(this, arguments); // Snapping initial if (this.state_.snapping === undefined) { this.state_.snapping = true; } // The first time this component renders center the content. this.addEventListener('render', function() { if (this.floor_plan_ !== undefined) { self.floor_plan_.center_content(); self.removeEventListener('render'); } }); }; beestat.extend(beestat.component.card.floor_plan_editor, beestat.component.card); /** * Decorate. * * @param {rocket.Elements} parent */ beestat.component.card.floor_plan_editor.prototype.decorate_contents_ = function(parent) { const self = this; const floor_plan = beestat.cache.floor_plan[beestat.setting('visualize.floor_plan_id')]; // Set group ids if they are not set. floor_plan.data.groups.forEach(function(group) { if (group.group_id === undefined) { group.group_id = window.crypto.randomUUID(); } }); /** * If there is an active_group_id, override whatever the current active * group is. Used for undo/redo. */ if (this.state_.active_group_id !== undefined) { for (let i = 0; i < floor_plan.data.groups.length; i++) { if (floor_plan.data.groups[i].group_id === this.state_.active_group_id) { this.state_.active_group = floor_plan.data.groups[i]; delete this.state_.active_group_id; break; } } } // If there is no active group, set it to best guess of ground floor. if (this.state_.active_group === undefined) { let closest_distance = Infinity; let closest_group; floor_plan.data.groups.forEach(function(group) { if (Math.abs(group.elevation) < closest_distance) { closest_group = group; closest_distance = Math.abs(group.elevation); } }); this.state_.active_group = closest_group; } this.floor_plan_tile_ = new beestat.component.tile.floor_plan( beestat.setting('visualize.floor_plan_id') ) .set_background_color(beestat.style.color.lightblue.base) .set_background_hover_color(beestat.style.color.lightblue.base) .set_text_color('#fff') .set_display('block') .addEventListener('click', function() { self.show_editor_ = !self.show_editor_; self.rerender(); }) .render(parent); // Decorate everything. if (this.show_editor_ === true) { const drawing_pane_container = $.createElement('div'); drawing_pane_container.style({ 'margin-top': beestat.style.size.gutter, 'position': 'relative', 'overflow-x': 'hidden' }); parent.appendChild(drawing_pane_container); this.decorate_drawing_pane_(drawing_pane_container); this.info_pane_container_ = $.createElement('div') .style('margin-top', beestat.style.size.gutter / 2); parent.appendChild(this.info_pane_container_); this.decorate_info_pane_(this.info_pane_container_); // Help container if (beestat.floor_plan.get_area(beestat.setting('visualize.floor_plan_id')) === 0) { const help_container = document.createElement('div'); Object.assign(help_container.style, { 'position': 'absolute', 'left': '65px', 'top': '59px' }); drawing_pane_container.appendChild(help_container); this.helper_tile_ = new beestat.component.tile() .set_text('Start by adding a room') .set_shadow(false) .set_background_color(beestat.style.color.green.base) .set_text_color('#fff') .set_type('pill') .set_size('small') .set_icon('arrow_left') .render($(help_container)); } } const expand_container = document.createElement('div'); Object.assign(expand_container.style, { 'position': 'absolute', 'right': '28px', 'top': '70px' }); parent.appendChild(expand_container); new beestat.component.tile() .set_icon(this.show_editor_ === true ? 'chevron_up' : 'chevron_down') .set_size('small') .set_shadow(false) .set_background_hover_color(beestat.style.color.lightblue.base) .set_text_color('#fff') .addEventListener('click', function() { self.show_editor_ = !self.show_editor_; self.rerender(); }) .render($(expand_container)); }; /** * Decorate the drawing pane. * * @param {rocket.Elements} parent */ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = function(parent) { const self = this; // Dispose existing SVG to remove any global listeners. if (this.floor_plan_ !== undefined) { this.floor_plan_.dispose(); } // Create and render a new SVG component. this.floor_plan_ = new beestat.component.floor_plan( beestat.setting('visualize.floor_plan_id'), this.state_ ); this.floor_plan_.render(parent); setTimeout(function() { if (parent.getBoundingClientRect().width > 0) { self.floor_plan_.set_width(parent.getBoundingClientRect().width); } }, 0); beestat.dispatcher.removeEventListener('resize.floor_plan_editor'); beestat.dispatcher.addEventListener('resize.floor_plan_editor', function() { self.floor_plan_.set_width(parent.getBoundingClientRect().width); }); // Rerender when stuff happens this.floor_plan_.addEventListener('add_room', function() { self.update_floor_plan_(); self.rerender(); }); this.floor_plan_.addEventListener('remove_room', function() { self.update_floor_plan_(); self.rerender(); }); this.floor_plan_.addEventListener('remove_point', function() { self.update_floor_plan_(); self.rerender(); }); this.floor_plan_.addEventListener('undo', function() { self.update_floor_plan_(); self.rerender(); }); this.floor_plan_.addEventListener('redo', function() { self.update_floor_plan_(); self.rerender(); }); this.floor_plan_.addEventListener('change_group', self.rerender.bind(this)); const group_below = this.floor_plan_.get_group_below(this.state_.active_group); if (group_below !== undefined) { group_below.rooms.forEach(function(room) { const room_entity = new beestat.component.floor_plan_entity.room(self.floor_plan_, self.state_) .set_enabled(false) .set_room(room) .set_group(self.state_.active_group); room_entity.render(self.floor_plan_.get_g()); }); } // Loop over the rooms in this group and add them. let active_room_entity; this.state_.active_group.rooms.forEach(function(room) { const room_entity = new beestat.component.floor_plan_entity.room(self.floor_plan_, self.state_) .set_room(room) .set_group(self.state_.active_group); // Update the GUI and save when a room changes. room_entity.addEventListener('update', function() { self.floor_plan_.update_infobox(); self.update_info_pane_(); self.update_floor_plan_tile_(); self.update_floor_plan_(); }); // Update GUI when a room is selected. room_entity.addEventListener('activate', function() { self.floor_plan_.update_infobox(); self.floor_plan_.update_toolbar(); self.update_info_pane_(); self.update_floor_plan_tile_(); }); // Update GUI when a room is deselected. room_entity.addEventListener('inactivate', function() { self.floor_plan_.update_infobox(); self.floor_plan_.update_toolbar(); self.update_info_pane_(); self.update_floor_plan_tile_(); }); /** * If there is currently an active room, use it to match to the newly * created room entities and then store it. After this loop is done * activate it to avoid other rooms getting written on top. Also delete * the active room from the state or it will needlessly be inactivated in * the set_active function. */ if ( self.state_.active_room_entity !== undefined && room.room_id === self.state_.active_room_entity.get_room().room_id ) { delete self.state_.active_room_entity; active_room_entity = room_entity; } // Render the room and save to the list of current entities. room_entity.render(self.floor_plan_.get_g()); }); if (active_room_entity !== undefined) { active_room_entity.set_active(true); } /** * If there was an active room, defer to adding it last so it ends up on * top. The set_active function doesn't do anything if the room isn't * rendered otherwise. */ if (this.state_.active_room_entity !== undefined) { this.state_.active_room_entity.render(this.floor_plan_.get_g()); } }; /** * Decorate the info pane. * * @param {rocket.Elements} parent */ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_ = function(parent) { if (this.state_.active_room_entity !== undefined) { this.decorate_info_pane_room_(parent); } else { this.decorate_info_pane_floor_(parent); } }; /** * Decorate the info pane for a floor. * * @param {rocket.Elements} parent */ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_floor_ = function(parent) { const self = this; const grid = $.createElement('div') .style({ 'display': 'grid', 'grid-template-columns': 'repeat(auto-fit, minmax(150px, 1fr))', 'column-gap': beestat.style.size.gutter }); parent.appendChild(grid); let div; // Group Name div = $.createElement('div'); grid.appendChild(div); const name_input = new beestat.component.input.text() .set_label('Floor Name') .set_placeholder('Unnamed Floor') .set_width('100%') .set_maxlength('50') .set_requirements({ 'required': true }) .render(div); if (this.state_.active_group.name !== undefined) { name_input.set_value(this.state_.active_group.name); } name_input.addEventListener('input', function() { self.state_.active_group.name = name_input.get_value(); self.floor_plan_.update_infobox(); }); name_input.addEventListener('change', function() { self.state_.active_group.name = name_input.get_value(); self.update_floor_plan_(); }); // Elevation div = $.createElement('div'); grid.appendChild(div); const elevation_input = new beestat.component.input.text() .set_label('Elevation (feet)') .set_placeholder(this.state_.active_group.elevation / 12) .set_value(this.state_.active_group.elevation / 12 || '') .set_width('100%') .set_maxlength('5') .set_requirements({ 'type': 'integer', 'required': true }) .render(div); elevation_input.addEventListener('change', function() { if (elevation_input.meets_requirements() === true) { self.state_.active_group.elevation = elevation_input.get_value() * 12; self.update_floor_plan_(); self.rerender(); } else { elevation_input.set_value(self.state_.active_group.elevation); } }); // Ceiling Height div = $.createElement('div'); grid.appendChild(div); const height_input = new beestat.component.input.text() .set_label('Ceiling Height (feet)') .set_placeholder(this.state_.active_group.height / 12) .set_value(this.state_.active_group.height / 12 || '') .set_width('100%') .set_maxlength('4') .set_requirements({ 'type': 'integer', 'min_value': 1, 'required': true }) .render(div); height_input.addEventListener('change', function() { if (height_input.meets_requirements() === true) { self.state_.active_group.height = height_input.get_value() * 12; self.update_floor_plan_(); } else { height_input.set_value(self.state_.active_group.height); } }); // Sensor div = $.createElement('div'); grid.appendChild(div); }; /** * Decorate the info pane for a room. * * @param {rocket.Elements} parent */ beestat.component.card.floor_plan_editor.prototype.decorate_info_pane_room_ = function(parent) { const self = this; const grid = $.createElement('div') .style({ 'display': 'grid', 'grid-template-columns': 'repeat(auto-fit, minmax(150px, 1fr))', 'column-gap': beestat.style.size.gutter }); parent.appendChild(grid); let div; // Room Name div = $.createElement('div'); grid.appendChild(div); const name_input = new beestat.component.input.text() .set_label('Room Name') .set_placeholder('Unnamed Room') .set_width('100%') .set_maxlength('50') .set_requirements({ 'required': true }) .render(div); if (this.state_.active_room_entity.get_room().name !== undefined) { name_input.set_value(this.state_.active_room_entity.get_room().name); } name_input.addEventListener('input', function() { self.state_.active_room_entity.get_room().name = name_input.get_value(); self.floor_plan_.update_infobox(); }); name_input.addEventListener('change', function() { self.state_.active_room_entity.get_room().name = name_input.get_value(); self.update_floor_plan_(); }); // Elevation div = $.createElement('div'); grid.appendChild(div); const elevation_input = new beestat.component.input.text() .set_label('Elevation (feet)') .set_placeholder(this.state_.active_group.elevation / 12) .set_value(this.state_.active_room_entity.get_room().elevation / 12 || '') .set_width('100%') .set_maxlength('5') .set_requirements({ 'type': 'integer' }) .render(div); elevation_input.addEventListener('change', function() { if (elevation_input.meets_requirements() === true) { self.state_.active_room_entity.get_room().elevation = elevation_input.get_value() * 12; self.update_floor_plan_(); self.rerender(); } else { elevation_input.set_value(''); } }); // Ceiling Height div = $.createElement('div'); grid.appendChild(div); const height_input = new beestat.component.input.text() .set_label('Ceiling Height (feet)') .set_placeholder(this.state_.active_group.height / 12) .set_value(this.state_.active_room_entity.get_room().height / 12 || '') .set_width('100%') .set_maxlength('4') .set_requirements({ 'type': 'integer', 'min_value': 1 }) .render(div); height_input.addEventListener('change', function() { if (height_input.meets_requirements() === true) { self.state_.active_room_entity.get_room().height = height_input.get_value() * 12; self.update_floor_plan_(); } else { height_input.set_value(''); } }); // Sensor div = $.createElement('div'); div.style('position', 'relative'); grid.appendChild(div); const sensor_input = new beestat.component.input.select() .add_option({ 'label': 'None', 'value': '' }) .set_width('100%') .set_label('Sensor'); const sensors = {}; Object.values(beestat.cache.thermostat).forEach(function(thermostat) { const thermostat_sensors = Object.values(beestat.cache.sensor).filter(function(sensor) { return sensor.thermostat_id === self.thermostat_id_; }) .sort(function(a, b) { return a.name.localeCompare(b.name, 'en', {'sensitivity': 'base'}); }); sensors[thermostat.thermostat_id] = thermostat_sensors; }); // Put the sensors in the select. for (let thermostat_id in sensors) { const thermostat = beestat.cache.thermostat[thermostat_id]; sensors[thermostat_id].forEach(function(sensor) { sensor_input.add_option({ 'group': thermostat.name, 'value': sensor.sensor_id, 'label': sensor.name }); }); } sensor_input.render(div); if (self.state_.active_room_entity.get_room().sensor_id !== undefined) { sensor_input.set_value(self.state_.active_room_entity.get_room().sensor_id); } else { sensor_input.set_value(''); } sensor_input.addEventListener('change', function() { const old_sensor_ids = Object.keys(beestat.floor_plan.get_sensor_ids_map( beestat.setting('visualize.floor_plan_id') )); if (sensor_input.get_value() === '') { delete self.state_.active_room_entity.get_room().sensor_id; } else { self.state_.active_room_entity.get_room().sensor_id = Number(sensor_input.get_value()); } const new_sensor_ids = Object.keys(beestat.floor_plan.get_sensor_ids_map( beestat.setting('visualize.floor_plan_id') )); // Delete data if the overall sensor set changes so it's re-fetched. if (old_sensor_ids.sort().join(' ') !== new_sensor_ids.sort().join(' ')) { beestat.cache.delete('data.three_d__runtime_sensor'); } // For the help box self.update_info_pane_(); self.update_floor_plan_(); }); // Help container if ( Object.keys(beestat.floor_plan.get_sensor_ids_map(beestat.setting('visualize.floor_plan_id'))).length === 0 && this.state_.active_room_entity !== undefined ) { const help_container = document.createElement('div'); Object.assign(help_container.style, { 'position': 'absolute', 'left': 0, 'top': '-9px' }); div.appendChild(help_container); this.helper_tile_ = new beestat.component.tile() .set_text('Assign a sensor') .set_shadow(false) .set_background_color(beestat.style.color.green.base) .set_text_color('#fff') .set_type('pill') .set_size('small') .set_icon('arrow_down') .render($(help_container)); sensor_input.set_label('⠀'); } }; /** * Rerender just the info pane to avoid rerendering the entire SVG for * resizes, drags, etc. This isn't super ideal but without making the info * pane a separate component this is the way. */ beestat.component.card.floor_plan_editor.prototype.update_info_pane_ = function() { var old_parent = this.info_pane_container_; this.info_pane_container_ = $.createElement('div') .style('margin-top', beestat.style.size.gutter / 2); this.decorate_info_pane_(this.info_pane_container_); old_parent.parentNode().replaceChild(this.info_pane_container_, old_parent); }; /** * Rerender just the top floor pane tile to avoid rerendering the entire SVG * for resizes, drags, etc. This isn't super ideal but without making the info * pane a separate component this is the way. */ beestat.component.card.floor_plan_editor.prototype.update_floor_plan_tile_ = function() { this.floor_plan_tile_.rerender(); }; /** * Get the title of the card. * * @return {string} The title. */ beestat.component.card.floor_plan_editor.prototype.get_title_ = function() { return 'Floor Plan'; }; /** * Update the floor plan in the database. This is throttled so the update can * only run so fast. */ beestat.component.card.floor_plan_editor.prototype.update_floor_plan_ = function() { const self = this; // Fake this event since the cache is being directly modified. beestat.dispatcher.dispatchEvent('cache.floor_plan'); window.clearTimeout(this.update_timeout_); this.update_timeout_ = window.setTimeout(function() { new beestat.api() .add_call( 'floor_plan', 'update', { 'attributes': { 'floor_plan_id': beestat.setting('visualize.floor_plan_id'), 'data': self.get_floor_plan_data_(beestat.setting('visualize.floor_plan_id')) } }, 'update_floor_plan' ) .send(); }, 1000); }; /** * Get cloned floor plan data. * * @param {number} floor_plan_id Floor plan ID * * @return {object} The modified floor plan data. */ beestat.component.card.floor_plan_editor.prototype.get_floor_plan_data_ = function(floor_plan_id) { return beestat.clone(beestat.cache.floor_plan[floor_plan_id].data); }; /** * Decorate the menu. * * @param {rocket.Elements} parent */ beestat.component.card.floor_plan_editor.prototype.decorate_top_right_ = function(parent) { const self = this; const menu = (new beestat.component.menu()).render(parent); if (window.is_demo === false) { if (Object.keys(beestat.cache.floor_plan).length > 1) { menu.add_menu_item(new beestat.component.menu_item() .set_text('Switch') .set_icon('home_switch') .set_callback(function() { (new beestat.component.modal.change_floor_plan()).render(); })); } menu.add_menu_item(new beestat.component.menu_item() .set_text('Add New') .set_icon('plus') .set_callback(function() { new beestat.component.modal.create_floor_plan( self.thermostat_id_ ).render(); })); if (beestat.setting('visualize.floor_plan_id') !== null) { menu.add_menu_item(new beestat.component.menu_item() .set_text('Edit') .set_icon('pencil') .set_callback(function() { new beestat.component.modal.update_floor_plan( beestat.setting('visualize.floor_plan_id') ).render(); })); } if (beestat.setting('visualize.floor_plan_id') !== null) { menu.add_menu_item(new beestat.component.menu_item() .set_text('Delete') .set_icon('delete') .set_callback(function() { new beestat.component.modal.delete_floor_plan( beestat.setting('visualize.floor_plan_id') ).render(); })); } } menu.add_menu_item(new beestat.component.menu_item() .set_text('Help') .set_icon('help_circle') .set_callback(function() { window.open('https://doc.beestat.io/86f6e4c44fc84c3cb4e8fb7b16d3d160'); })); };