mirror of
https://github.com/beestat/app.git
synced 2026-02-26 13:10:23 -05:00
258 lines
7.9 KiB
JavaScript
258 lines
7.9 KiB
JavaScript
/**
|
|
* 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'
|
|
});
|
|
|
|
// 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;
|
|
};
|