From f3877f9103571274e3fb69b2bb66b80480eadce3 Mon Sep 17 00:00:00 2001 From: Jon Ziebell Date: Fri, 13 Feb 2026 14:01:25 -0500 Subject: [PATCH] Rotation --- js/component/card/floor_plan_editor.js | 16 ++ js/component/card/three_d.js | 37 ++-- js/component/compass.js | 258 ++++++++++++++++++++++++ js/component/modal/update_floor_plan.js | 30 --- js/component/scene.js | 38 ++-- js/js.php | 1 + 6 files changed, 316 insertions(+), 64 deletions(-) create mode 100644 js/component/compass.js diff --git a/js/component/card/floor_plan_editor.js b/js/component/card/floor_plan_editor.js index be6d841..7a17721 100644 --- a/js/component/card/floor_plan_editor.js +++ b/js/component/card/floor_plan_editor.js @@ -176,6 +176,11 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func this.floor_plan_.dispose(); } + // Dispose existing compass + if (this.compass_ !== undefined) { + this.compass_.dispose(); + } + // Create and render a new SVG component. this.floor_plan_ = new beestat.component.floor_plan( beestat.setting('visualize.floor_plan_id'), @@ -184,6 +189,17 @@ beestat.component.card.floor_plan_editor.prototype.decorate_drawing_pane_ = func this.floor_plan_.render(parent); + // Create and render the compass for setting orientation + this.compass_ = new beestat.component.compass( + beestat.setting('visualize.floor_plan_id') + ); + this.compass_.render(parent); + + // Update floor plan when rotation changes + this.compass_.addEventListener('rotation_change', function() { + self.update_floor_plan_(); + }); + setTimeout(function() { if (parent.getBoundingClientRect().width > 0) { self.floor_plan_.set_width(parent.getBoundingClientRect().width); diff --git a/js/component/card/three_d.js b/js/component/card/three_d.js index bf36a44..26cf326 100644 --- a/js/component/card/three_d.js +++ b/js/component/card/three_d.js @@ -359,29 +359,28 @@ beestat.component.card.three_d.prototype.decorate_drawing_pane_ = function(paren this.update_scene_(); this.scene_.render($(parent)); + // Get current time of day + const now = moment(); + const current_hour = now.hour(); + const current_minute = now.minute(); + if (beestat.setting('visualize.range_type') === 'dynamic') { - const sensor_ids = Object.keys( - beestat.floor_plan.get_sensor_ids_map(this.floor_plan_id_) - ); - if ( - beestat.setting('visualize.range_dynamic') === 0 && - sensor_ids.length > 0 - ) { - this.date_m_ = this.get_most_recent_time_with_data_(); - } else { - this.date_m_ = moment() - .subtract( - beestat.setting('visualize.range_dynamic'), - 'day' - ) - .hour(0) - .minute(0) - .second(0); - } + // Set the date, then apply current time of day + this.date_m_ = moment() + .subtract( + beestat.setting('visualize.range_dynamic'), + 'day' + ) + .hour(current_hour) + .minute(current_minute) + .second(0); } else { + // Set the static date, then apply current time of day this.date_m_ = moment( beestat.setting('visualize.range_static.begin') + ' 00:00:00' - ); + ) + .hour(current_hour) + .minute(current_minute); } // Set some defaults on the scene. diff --git a/js/component/compass.js b/js/component/compass.js new file mode 100644 index 0000000..8ec9337 --- /dev/null +++ b/js/component/compass.js @@ -0,0 +1,258 @@ +/** + * Interactive compass component for setting floor plan rotation. + * Allows users to visually set which direction is "North" for their home. + * + * @param {number} floor_plan_id The floor plan ID + */ +beestat.component.compass = function(floor_plan_id) { + this.floor_plan_id_ = floor_plan_id; + beestat.component.apply(this, arguments); +}; +beestat.extend(beestat.component.compass, beestat.component); + +/** + * Render the compass. + * + * @param {HTMLElement} parent + * + * @return {beestat.component.compass} This + */ +beestat.component.compass.prototype.render = function(parent) { + const self = this; + + // Container for the compass + this.container_ = document.createElement('div'); + Object.assign(this.container_.style, { + 'position': 'absolute', + 'bottom': `${beestat.style.size.gutter}px`, + 'right': `${beestat.style.size.gutter}px`, + 'width': '80px', + 'height': '80px', + 'user-select': 'none', + 'cursor': 'grab', + 'z-index': '10' + }); + this.container_.title = 'Drag to set which direction is North for sun/moon positioning'; + + // Create SVG + this.svg_ = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.svg_.setAttribute('width', '80'); + this.svg_.setAttribute('height', '80'); + this.svg_.setAttribute('viewBox', '0 0 80 80'); + this.container_.appendChild(this.svg_); + + // Background circle + const bg_circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + bg_circle.setAttribute('cx', '40'); + bg_circle.setAttribute('cy', '40'); + bg_circle.setAttribute('r', '38'); + bg_circle.setAttribute('fill', beestat.style.color.bluegray.base); + bg_circle.setAttribute('stroke', '#fff'); + bg_circle.setAttribute('stroke-width', '2'); + bg_circle.setAttribute('opacity', '0.9'); + this.svg_.appendChild(bg_circle); + + // Create rotating group for compass rose + this.compass_group_ = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + this.compass_group_.setAttribute('transform', 'translate(40, 40)'); + this.svg_.appendChild(this.compass_group_); + + // Cardinal direction markers + const directions = [ + {label: 'N', angle: 0, color: beestat.style.color.red.base, size: 14}, + {label: 'E', angle: 90, color: '#fff', size: 10}, + {label: 'S', angle: 180, color: '#fff', size: 10}, + {label: 'W', angle: 270, color: '#fff', size: 10} + ]; + + directions.forEach(function(dir) { + const angle_rad = (dir.angle - 90) * Math.PI / 180; // -90 to start at top + const radius = 28; + const x = radius * Math.cos(angle_rad); + const y = radius * Math.sin(angle_rad); + + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('x', x); + text.setAttribute('y', y); + text.setAttribute('text-anchor', 'middle'); + text.setAttribute('dominant-baseline', 'middle'); + text.setAttribute('fill', dir.color); + text.setAttribute('font-size', dir.size); + text.setAttribute('font-weight', dir.label === 'N' ? 'bold' : 'normal'); + text.setAttribute('pointer-events', 'none'); + text.textContent = dir.label; + self.compass_group_.appendChild(text); + }); + + // North arrow indicator + const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + arrow.setAttribute('d', 'M 0,-22 L 4,-10 L 0,-12 L -4,-10 Z'); + arrow.setAttribute('fill', beestat.style.color.red.base); + arrow.setAttribute('stroke', '#fff'); + arrow.setAttribute('stroke-width', '1'); + arrow.setAttribute('pointer-events', 'none'); + this.compass_group_.appendChild(arrow); + + // Center dot + const center_dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + center_dot.setAttribute('cx', '0'); + center_dot.setAttribute('cy', '0'); + center_dot.setAttribute('r', '3'); + center_dot.setAttribute('fill', '#fff'); + center_dot.setAttribute('pointer-events', 'none'); + this.compass_group_.appendChild(center_dot); + + // Set initial rotation + this.update_rotation_(this.get_rotation_()); + + // Make it draggable + this.set_draggable_(); + + parent.appendChild(this.container_); + this.rendered_ = true; + + return this; +}; + +/** + * Get the current rotation value from the floor plan data. + * + * @return {number} Rotation in degrees (0-359) + */ +beestat.component.compass.prototype.get_rotation_ = function() { + const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_]; + return floor_plan.data.appearance?.rotation || 0; +}; + +/** + * Set the rotation value in the floor plan data. + * + * @param {number} degrees Rotation in degrees (0-359) + */ +beestat.component.compass.prototype.set_rotation_ = function(degrees) { + const floor_plan = beestat.cache.floor_plan[this.floor_plan_id_]; + + if (floor_plan.data.appearance === undefined) { + floor_plan.data.appearance = {}; + } + + // Normalize to 0-359 and store as integer + const normalized = Math.round(((degrees % 360) + 360) % 360); + floor_plan.data.appearance.rotation = normalized; + + this.update_rotation_(normalized); + + // Dispatch event so other components know rotation changed + this.dispatchEvent('rotation_change', normalized); +}; + +/** + * Update the visual rotation of the compass. + * + * @param {number} degrees Rotation in degrees + */ +beestat.component.compass.prototype.update_rotation_ = function(degrees) { + if (this.compass_group_) { + this.compass_group_.setAttribute( + 'transform', + `translate(40, 40) rotate(${degrees})` + ); + } +}; + +/** + * Make the compass draggable to change rotation. + */ +beestat.component.compass.prototype.set_draggable_ = function() { + const self = this; + let is_dragging = false; + + const get_angle = function(e) { + const rect = self.svg_.getBoundingClientRect(); + const center_x = rect.left + rect.width / 2; + const center_y = rect.top + rect.height / 2; + + const client_x = e.clientX || (e.touches && e.touches[0].clientX); + const client_y = e.clientY || (e.touches && e.touches[0].clientY); + + const dx = client_x - center_x; + const dy = client_y - center_y; + + // Calculate angle in degrees (0° = North/up, clockwise) + let angle = Math.atan2(dy, dx) * 180 / Math.PI + 90; + if (angle < 0) { + angle += 360; + } + + return angle; + }; + + const handle_start = function(e) { + is_dragging = true; + self.container_.style.cursor = 'grabbing'; + e.preventDefault(); + }; + + const handle_move = function(e) { + if (!is_dragging) { + return; + } + + const angle = get_angle(e); + self.set_rotation_(angle); + e.preventDefault(); + }; + + const handle_end = function(e) { + if (is_dragging) { + is_dragging = false; + self.container_.style.cursor = 'grab'; + e.preventDefault(); + } + }; + + // Store handlers for cleanup + this.handlers_ = { + 'mouse_start': handle_start, + 'mouse_move': handle_move, + 'mouse_end': handle_end, + 'touch_start': handle_start, + 'touch_move': handle_move, + 'touch_end': handle_end + }; + + // Mouse events + this.container_.addEventListener('mousedown', this.handlers_.mouse_start); + window.addEventListener('mousemove', this.handlers_.mouse_move); + window.addEventListener('mouseup', this.handlers_.mouse_end); + + // Touch events + this.container_.addEventListener('touchstart', this.handlers_.touch_start); + window.addEventListener('touchmove', this.handlers_.touch_move); + window.addEventListener('touchend', this.handlers_.touch_end); +}; + +/** + * Dispose of the compass. + */ +beestat.component.compass.prototype.dispose = function() { + // Remove event listeners + if (this.handlers_) { + if (this.container_) { + this.container_.removeEventListener('mousedown', this.handlers_.mouse_start); + this.container_.removeEventListener('touchstart', this.handlers_.touch_start); + } + window.removeEventListener('mousemove', this.handlers_.mouse_move); + window.removeEventListener('mouseup', this.handlers_.mouse_end); + window.removeEventListener('touchmove', this.handlers_.touch_move); + window.removeEventListener('touchend', this.handlers_.touch_end); + } + + // Remove from DOM + if (this.container_ && this.container_.parentNode) { + this.container_.parentNode.removeChild(this.container_); + } + + // Mark as not rendered + this.rendered_ = false; +}; diff --git a/js/component/modal/update_floor_plan.js b/js/component/modal/update_floor_plan.js index 0424564..1a4ce4a 100644 --- a/js/component/modal/update_floor_plan.js +++ b/js/component/modal/update_floor_plan.js @@ -79,10 +79,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio (new beestat.component.title('Appearance')).render(parent); parent.appendChild($.createElement('p').innerHTML('Customize how your floor plan looks in 3D view.')); - // Debug: Log current appearance data - console.log('Floor plan appearance data:', floor_plan.data.appearance); - console.log('State appearance:', self.state_.appearance); - const appearance_grid = document.createElement('div'); Object.assign(appearance_grid.style, { 'display': 'grid', @@ -93,29 +89,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio }); parent.appendChild(appearance_grid); - // Rotation input - const rotation_input = new beestat.component.input.text() - .set_label('Rotation (°)') - .set_width('100%') - .set_icon('rotate-right'); - - const current_rotation = self.state_.appearance?.rotation !== undefined - ? self.state_.appearance.rotation - : (floor_plan.data.appearance?.rotation || 0); - rotation_input.set_value(String(current_rotation)); - - rotation_input.addEventListener('change', function() { - if (self.state_.appearance === undefined) { - self.state_.appearance = {}; - } - const value = parseInt(rotation_input.get_value(), 10); - if (!isNaN(value) && value >= 0 && value <= 359) { - self.state_.appearance.rotation = value; - } - }); - - rotation_input.render($(appearance_grid)); - // Roof Style dropdown const roof_style_select = new beestat.component.input.select() .set_label('Roof Style') @@ -172,7 +145,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio const current_roof_color = self.state_.appearance?.roof_color !== undefined ? self.state_.appearance.roof_color : (floor_plan.data.appearance?.roof_color || '#3a3a3a'); - console.log('Setting roof color to:', current_roof_color); roof_color_select.set_value(current_roof_color); // Siding Color dropdown @@ -209,7 +181,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio const current_siding_color = self.state_.appearance?.siding_color !== undefined ? self.state_.appearance.siding_color : (floor_plan.data.appearance?.siding_color || '#889aaa'); - console.log('Setting siding color to:', current_siding_color); siding_color_select.set_value(current_siding_color); // Ground Color dropdown @@ -243,7 +214,6 @@ beestat.component.modal.update_floor_plan.prototype.decorate_contents_ = functio const current_ground_color = self.state_.appearance?.ground_color !== undefined ? self.state_.appearance.ground_color : (floor_plan.data.appearance?.ground_color || '#4a7c3f'); - console.log('Setting ground color to:', current_ground_color); ground_color_select.set_value(current_ground_color); // Address diff --git a/js/component/scene.js b/js/component/scene.js index cc16899..c5432b7 100644 --- a/js/component/scene.js +++ b/js/component/scene.js @@ -533,14 +533,10 @@ beestat.component.scene.prototype.update_celestial_lights_ = function(date, lati const js_date = date.toDate(); - // Get user rotation offset (0 = North, clockwise) - const rotation_degrees = this.get_appearance_value_('rotation'); - const rotation_offset = (rotation_degrees * Math.PI) / 180; - // === SUN === const sun_position = SunCalc.getPosition(js_date, latitude, longitude); const sun_altitude = sun_position.altitude; - const sun_azimuth = sun_position.azimuth + rotation_offset; + const sun_azimuth = sun_position.azimuth; // Convert spherical coordinates to Cartesian // SunCalc: azimuth 0=south, π/2=west, π/-π=north, -π/2=east @@ -565,7 +561,7 @@ beestat.component.scene.prototype.update_celestial_lights_ = function(date, lati // === MOON === const moon_position = SunCalc.getMoonPosition(js_date, latitude, longitude); const moon_altitude = moon_position.altitude; - const moon_azimuth = moon_position.azimuth + rotation_offset; + const moon_azimuth = moon_position.azimuth; // Get moon illumination (phase) const moon_illumination = SunCalc.getMoonIllumination(js_date); @@ -1112,10 +1108,22 @@ beestat.component.scene.prototype.update_debug_ = function() { beestat.component.scene.prototype.add_main_group_ = function() { const bounding_box = beestat.floor_plan.get_bounding_box(this.floor_plan_id_); + // Main group handles rotation and orientation this.main_group_ = new THREE.Group(); - this.main_group_.translateX((bounding_box.right + bounding_box.left) / -2); - this.main_group_.translateZ((bounding_box.bottom + bounding_box.top) / -2); + + // Apply X rotation to orient the floor plan this.main_group_.rotation.x = Math.PI / 2; + + // Apply user-defined rotation around Z axis (vertical axis after X rotation) + const rotation_degrees = this.get_appearance_value_('rotation'); + this.main_group_.rotation.z = (rotation_degrees * Math.PI) / 180; + + // Content group is offset to center the geometry at the rotation point + this.content_group_ = new THREE.Group(); + this.content_group_.translateX((bounding_box.right + bounding_box.left) / -2); + this.content_group_.translateZ((bounding_box.bottom + bounding_box.top) / -2); + + this.main_group_.add(this.content_group_); this.scene_.add(this.main_group_); }; @@ -1129,12 +1137,12 @@ beestat.component.scene.prototype.add_floor_plan_ = function() { this.layers_ = {}; const walls_layer = new THREE.Group(); - self.main_group_.add(walls_layer); + self.content_group_.add(walls_layer); self.layers_['walls'] = walls_layer; floor_plan.data.groups.forEach(function(group) { const layer = new THREE.Group(); - self.main_group_.add(layer); + self.content_group_.add(layer); self.layers_[group.group_id] = layer; group.rooms.forEach(function(room) { self.add_room_(layer, group, room); @@ -1343,7 +1351,7 @@ beestat.component.scene.prototype.add_hip_roofs_ = function() { // Create layer for roofs const roofs_layer = new THREE.Group(); - this.main_group_.add(roofs_layer); + this.content_group_.add(roofs_layer); this.layers_['roof'] = roofs_layer; const roof_pitch = beestat.component.scene.roof_pitch; @@ -1518,7 +1526,7 @@ beestat.component.scene.prototype.add_flat_roofs_ = function() { // Create layer for roofs const roofs_layer = new THREE.Group(); - this.main_group_.add(roofs_layer); + this.content_group_.add(roofs_layer); this.layers_['roof'] = roofs_layer; // Process each exposed area @@ -1605,7 +1613,7 @@ beestat.component.scene.prototype.add_roof_outlines_ = function() { // Create layer for roof outlines const roof_outlines_layer = new THREE.Group(); - this.main_group_.add(roof_outlines_layer); + this.content_group_.add(roof_outlines_layer); this.layers_['roof_outlines'] = roof_outlines_layer; // Render each exposed area as red outline @@ -1651,7 +1659,7 @@ beestat.component.scene.prototype.add_roof_skeleton_debug_ = function() { // Create layer for skeleton debug lines const skeleton_debug_layer = new THREE.Group(); - this.main_group_.add(skeleton_debug_layer); + this.content_group_.add(skeleton_debug_layer); this.layers_['roof_skeleton_debug'] = skeleton_debug_layer; let total_polygons = 0; @@ -1808,7 +1816,7 @@ beestat.component.scene.prototype.add_environment_ = function() { ]; const environment_layer = new THREE.Group(); - this.main_group_.add(environment_layer); + this.content_group_.add(environment_layer); this.layers_['environment'] = environment_layer; strata.forEach(function(stratum) { diff --git a/js/js.php b/js/js.php index c3bd3d0..996e1b1 100755 --- a/js/js.php +++ b/js/js.php @@ -131,6 +131,7 @@ if($setting->get('environment') === 'dev' || $setting->get('environment') === 'd echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; + echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL; echo '' . PHP_EOL;