UNPKG

signalk-parquet

Version:

SignalK plugin and webapp that archives SK data to Parquet files with a regimen control system, advanced querying, Claude integrated AI analysis, spatial capabilities, and REST API.

1,553 lines (1,359 loc) 105 kB
import { getPluginPath } from './utils.js'; let editingThresholds = []; let addingThresholds = []; let currentEditingCommand = null; let currentEditingRow = null; let editFormDirty = false; let editFormPlaceholder = null; let editFormRowElement = null; let editingThresholdIndex = null; // Track which threshold is being edited // Unified threshold modal state let thresholdModalContext = { mode: null, // 'edit' or 'create' targetArray: null, // editingThresholds or addingThresholds editIndex: null, // Index if editing existing threshold callback: null, // Callback when threshold is saved }; const editCommandFormElement = document.getElementById('editCommandForm'); let editFormOriginalParent = editCommandFormElement?.parentNode || null; if (editCommandFormElement && editFormOriginalParent) { editFormPlaceholder = document.createElement('div'); editFormPlaceholder.id = 'editCommandFormPlaceholder'; editFormPlaceholder.style.display = 'none'; editFormOriginalParent.insertBefore( editFormPlaceholder, editCommandFormElement.nextSibling ); } function getEditCommandForm() { return editCommandFormElement; } function getEditCommandStatusElement() { return document.getElementById('editCommandStatus'); } function getUpdateCommandButton() { return document.getElementById('updateCommandButton'); } /** * Build a hierarchical tree structure from flat path list */ function buildPathTree(paths) { const tree = {}; paths.forEach(path => { const parts = path.split('.'); let current = tree; parts.forEach((part, index) => { if (!current[part]) { current[part] = { _children: {}, _hasValue: index === parts.length - 1, _fullPath: parts.slice(0, index + 1).join('.'), }; } else if (index === parts.length - 1) { current[part]._hasValue = true; } current = current[part]._children; }); }); return tree; } /** * Check if node or any descendant matches search term */ function nodeMatchesSearch(node, searchTerm) { if (!searchTerm) return true; const term = searchTerm.toLowerCase(); // Check if this node's full path contains the search term if (node._fullPath && node._fullPath.toLowerCase().includes(term)) { return true; } // Check if any children match for (const childKey in node._children) { if (nodeMatchesSearch(node._children[childKey], searchTerm)) { return true; } } return false; } /** * Render a tree node */ function renderTreeNode(key, node, level = 0, searchTerm = '') { const hasChildren = Object.keys(node._children).length > 0; const fullPath = node._fullPath; // Filter based on search - show node if it or any descendant matches if (searchTerm && !nodeMatchesSearch(node, searchTerm)) { return ''; } const itemClasses = ['path-tree-item']; if (node._hasValue) itemClasses.push('has-value'); let html = `<div class="path-tree-node" data-level="${level}">`; html += `<div class="${itemClasses.join(' ')}" data-path="${fullPath}" onclick="selectPathTreeItem(this, '${fullPath}', ${node._hasValue})">`; if (hasChildren) { html += `<span class="path-tree-toggle" onclick="event.stopPropagation(); toggleTreeNode(this)">▶</span>`; } else { html += `<span class="path-tree-toggle"></span>`; } html += `<span class="path-tree-label">${key}</span>`; html += `</div>`; if (hasChildren) { html += `<div class="path-tree-children">`; Object.keys(node._children) .sort() .forEach(childKey => { html += renderTreeNode( childKey, node._children[childKey], level + 1, searchTerm ); }); html += `</div>`; } html += `</div>`; return html; } /** * Toggle tree node expansion */ window.toggleTreeNode = function (toggleElement) { const treeNode = toggleElement.closest('.path-tree-node'); const children = treeNode.querySelector('.path-tree-children'); if (children.classList.contains('expanded')) { children.classList.remove('expanded'); toggleElement.textContent = '▶'; } else { children.classList.add('expanded'); toggleElement.textContent = '▼'; } }; /** * Select a path tree item */ window.selectPathTreeItem = function (element, fullPath, hasValue) { if (!hasValue) { // If no value, just toggle expansion const toggle = element.querySelector('.path-tree-toggle'); if (toggle && toggle.textContent) { toggleTreeNode(toggle); } return; } // Remove previous selection const container = element.closest('.path-tree-container'); container.querySelectorAll('.path-tree-item.selected').forEach(el => { el.classList.remove('selected'); }); // Add selection element.classList.add('selected'); // Update hidden input const treeId = container.id; const inputId = treeId.replace('Tree', ''); const input = document.getElementById(inputId); if (input) { input.value = fullPath; // Trigger change event for any listeners input.dispatchEvent(new Event('change')); } }; /** * Show custom path input */ window.showCustomPathInput = function (inputId) { const customInput = document.getElementById(inputId + 'Custom'); const treeContainer = document.getElementById(inputId + 'Tree'); const searchInput = document.getElementById(inputId + 'Search'); if (customInput.style.display === 'none') { customInput.style.display = 'block'; treeContainer.style.display = 'none'; searchInput.style.display = 'none'; customInput.focus(); } else { customInput.style.display = 'none'; treeContainer.style.display = 'block'; searchInput.style.display = 'block'; } }; function extractPathsFromSignalK(obj, filterType = 'self') { const selfPaths = new Set(); const nonSelfPaths = new Set(); function extractRecursive(node, prefix = '') { if (!node || typeof node !== 'object') return; for (const key in node) { if ( key === 'meta' || key === 'timestamp' || key === 'source' || key === '$source' || key === 'values' || key === 'sentence' ) continue; const currentPath = prefix ? `${prefix}.${key}` : key; if (node[key] && typeof node[key] === 'object') { if (node[key].value !== undefined) { selfPaths.add(currentPath); } extractRecursive(node[key], currentPath); } } } function extractOtherVessel(node, prefix, tempPaths) { if (!node || typeof node !== 'object') return; for (const key in node) { if ( key === 'meta' || key === 'timestamp' || key === 'source' || key === '$source' || key === 'values' || key === 'sentence' ) continue; const currentPath = prefix ? `${prefix}.${key}` : key; if (node[key] && typeof node[key] === 'object') { if (node[key].value !== undefined) { tempPaths.add(currentPath); } extractOtherVessel(node[key], currentPath, tempPaths); } } } const selfVesselId = obj?.self; const actualSelfId = selfVesselId && selfVesselId.startsWith('vessels.') ? selfVesselId.replace('vessels.', '') : selfVesselId; if (obj?.vessels) { if (actualSelfId && obj.vessels[actualSelfId]) { extractRecursive(obj.vessels[actualSelfId], ''); } for (const vesselId in obj.vessels) { if (vesselId !== actualSelfId) { const tempPaths = new Set(); extractOtherVessel(obj.vessels[vesselId], '', tempPaths); tempPaths.forEach(path => nonSelfPaths.add(path)); } } } for (const key in obj || {}) { if ( !['vessels', 'self', 'version', 'sources', 'meta', 'timestamp'].includes( key ) ) { extractRecursive(obj[key], key); } } const targetPaths = filterType === 'self' ? selfPaths : nonSelfPaths; return Array.from(targetPaths).sort(); } function updateEditCommandStatus(message = '', type = 'info') { const statusEl = getEditCommandStatusElement(); if (!statusEl) return; if (!message) { statusEl.textContent = ''; statusEl.style.display = 'none'; return; } statusEl.textContent = message; statusEl.style.display = 'block'; statusEl.style.color = type === 'error' ? '#d32f2f' : '#0066cc'; } function setEditSaveButtonState() { const button = getUpdateCommandButton(); if (!button) return; button.disabled = !editFormDirty; button.textContent = '✅ Update Command'; button.style.opacity = editFormDirty ? '1' : '0.7'; } function markEditFormDirty() { editFormDirty = true; setEditSaveButtonState(); updateEditCommandStatus('Unsaved changes', 'info'); } function clearEditFormDirtyState(message = '') { editFormDirty = false; setEditSaveButtonState(); updateEditCommandStatus(message, 'info'); } function setupEditFormFieldListeners() { const form = getEditCommandForm(); if (!form || form.dataset.listenersAttached === 'true') { return; } const inputs = ['editCommandDescription', 'editCommandKeywords']; inputs.forEach(id => { const el = document.getElementById(id); if (el) { el.addEventListener('input', () => markEditFormDirty()); } }); // defaultState is now hardcoded to false on server side form.dataset.listenersAttached = 'true'; } function ensureEditFormInDom() { const form = getEditCommandForm(); if (!form) return null; if (!form.isConnected) { if (editFormPlaceholder && editFormPlaceholder.parentNode) { editFormPlaceholder.parentNode.insertBefore(form, editFormPlaceholder); } else { const parent = editFormOriginalParent || document.getElementById('commandManager') || document.body; parent.appendChild(form); } } return form; } function attachEditFormToRow(commandName, { scroll = true } = {}) { const form = ensureEditFormInDom(); if (!form) return; const targetRow = document.querySelector( `tr[data-command-row="${commandName}"]` ); if (!targetRow || !targetRow.parentNode) { return; } const tbody = targetRow.parentNode; if (!tbody) { return; } if (currentEditingRow && currentEditingRow !== targetRow) { currentEditingRow.classList.remove('editing-command-row'); currentEditingRow.style.outline = ''; currentEditingRow.style.outlineOffset = ''; } targetRow.classList.add('editing-command-row'); targetRow.style.outline = '2px solid #ffc107'; targetRow.style.outlineOffset = '4px'; currentEditingRow = targetRow; const columnCount = targetRow.children.length || targetRow.childElementCount || 1; if (!editFormRowElement) { editFormRowElement = document.createElement('tr'); editFormRowElement.id = 'editCommandFormRow'; editFormRowElement.classList.add('edit-command-form-row'); const cell = document.createElement('td'); cell.colSpan = columnCount; editFormRowElement.appendChild(cell); } const cell = editFormRowElement.firstElementChild || editFormRowElement.appendChild(document.createElement('td')); if (cell.colSpan !== columnCount) { cell.colSpan = columnCount; } cell.style.padding = '0'; if (form.parentNode !== cell) { while (cell.firstChild) { cell.removeChild(cell.firstChild); } cell.appendChild(form); } tbody.insertBefore(editFormRowElement, targetRow.nextSibling); form.style.display = 'block'; form.classList.add('active'); form.style.width = '100%'; form.style.maxWidth = '100%'; form.style.margin = '20px 0'; form.style.boxSizing = 'border-box'; if (scroll) { targetRow.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } function detachEditForm() { const form = getEditCommandForm(); if (!form) { return; } if (currentEditingRow) { currentEditingRow.classList.remove('editing-command-row'); currentEditingRow.style.outline = ''; currentEditingRow.style.outlineOffset = ''; currentEditingRow = null; } if (editFormRowElement && editFormRowElement.parentNode) { editFormRowElement.parentNode.removeChild(editFormRowElement); } if (editFormPlaceholder && editFormPlaceholder.parentNode) { editFormPlaceholder.parentNode.insertBefore(form, editFormPlaceholder); } else if (editFormOriginalParent) { editFormOriginalParent.appendChild(form); } cancelNewThreshold(); form.style.display = 'none'; form.classList.remove('active'); } async function persistCommandChanges({ silent = false, closeOnSuccess = false, reason = '', } = {}) { try { const form = ensureEditFormInDom(); if (!form) { if (!silent) { alert('Edit form is not available. Please reload the page.'); } return false; } const command = document.getElementById('editCommandName').value.trim(); const description = document .getElementById('editCommandDescription') .value.trim(); const keywordsInput = document .getElementById('editCommandKeywords') .value.trim(); const keywords = keywordsInput ? keywordsInput .split(',') .map(k => k.trim()) .filter(k => k.length > 0) : undefined; const defaultState = false; // Hardcoded to OFF const thresholds = editingThresholds.length > 0 ? editingThresholds : []; const response = await fetch(`${getPluginPath()}/api/commands/${command}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ description: description || undefined, keywords: keywords, defaultState: defaultState, thresholds: thresholds, }), }); const result = await response.json(); if (result.success) { clearEditFormDirtyState( reason || (silent ? '' : 'Command updated successfully!') ); await loadCommands(); if (!closeOnSuccess) { attachEditFormToRow(command, { scroll: false }); } if (closeOnSuccess) { hideEditCommandForm(true); } return true; } if (silent) { updateEditCommandStatus( result.error || 'Failed to update command', 'error' ); } else { alert('Failed to update command: ' + result.error); } return false; } catch (error) { if (silent) { updateEditCommandStatus( error.message || 'Error updating command', 'error' ); } else { alert('Error updating command: ' + error.message); } return false; } } export async function loadCommands() { try { const response = await fetch(`${getPluginPath()}/api/commands`); const result = await response.json(); if (result.success) { displayCommands(result.commands || []); // Update automation states after displaying commands setTimeout(() => updateAllAutomationStates(), 100); // Subscribe to real-time command state updates setTimeout(() => subscribeToCommandStates(result.commands || []), 200); } else { document.getElementById('commandContainer').innerHTML = `<div class="error">Error loading commands: ${result.error}</div>`; } } catch (error) { document.getElementById('commandContainer').innerHTML = `<div class="error">Error loading commands: ${error.message}</div>`; } } function displayCommands(commands) { const container = document.getElementById('commandContainer'); if (!commands || commands.length === 0) { container.innerHTML = '<div class="info">No commands registered yet.</div>'; return; } let html = '<div class="table-container"><table><thead><tr>'; html += '<th>Command</th><th>Path</th><th>Description</th><th>Status</th><th>Actions</th>'; html += '</tr></thead><tbody>'; commands.forEach(command => { // Real-time status will be populated by SignalK subscription const status = `<span id="command-state-${command.command}" class="command-state">⏳ Loading...</span>`; const registeredDate = new Date(command.registered).toLocaleString(); const keywords = command.keywords ? command.keywords.join(', ') : ''; // Automation info let automationInfo = '<div style="font-size: 0.9em;">'; // Automation status placeholder - will be populated by updateAutomationUI const hasThresholds = command.thresholds && command.thresholds.length > 0; if (hasThresholds) { automationInfo += `<div id="automation-status-${command.command}" style="margin-bottom: 5px;"> <!-- Automation status will be updated dynamically --> </div>`; } // Manual override status (only for commands without thresholds) if (command.manualOverride && !hasThresholds) { const expiry = command.manualOverrideUntil ? ` (expires ${new Date(command.manualOverrideUntil).toLocaleString()})` : ' (permanent)'; automationInfo += `<div style="color: #ff9800; margin-bottom: 5px;">🔒 Manual Override${expiry}</div>`; } // Thresholds configuration (multiple thresholds supported) if (command.thresholds && command.thresholds.length > 0) { command.thresholds.forEach(threshold => { if (threshold.enabled) { const operator = { gt: '>', lt: '<', eq: '=', ne: '≠', true: 'is true', false: 'is false', }[threshold.operator] || threshold.operator; const value = threshold.value !== undefined ? ` ${threshold.value}` : ''; const action = threshold.activateOnMatch ? 'ON' : 'OFF'; automationInfo += `<div style="color: #2196f3; margin-bottom: 3px;"> 🎯 <strong>${threshold.watchPath}</strong> ${operator}${value} → ${action} </div>`; if (threshold.hysteresis) { automationInfo += `<div style="color: #666; font-size: 0.8em;">⏱️ ${threshold.hysteresis}s hysteresis</div>`; } } }); } else if (command.defaultState !== undefined) { automationInfo += `<div style="color: #4caf50;">⚙️ Default: ${command.defaultState ? 'ON' : 'OFF'}</div>`; } else { automationInfo += '<div style="color: #999;">📱 Manual control only</div>'; } if (keywords) { automationInfo += `<div style="color: #666; font-size: 0.8em; margin-top: 3px;">🏷️ ${keywords}</div>`; } automationInfo += '</div>'; html += `<tr data-command-row="${command.command}"> <td> <div><strong>${command.command}</strong></div> <div style="font-size: 0.8em; color: #666; margin-top: 2px;">${command.description || 'No description'}</div> <div style="font-size: 0.7em; color: #999; margin-top: 2px;">Registered: ${registeredDate}</div> </td> <td><code style="font-size: 0.8em;">vessels.self.commands.${command.command}</code></td> <td>${automationInfo}</td> <td style="text-align: center;"> ${status} </td> <td style="text-align: center; min-width: 200px;"> <div style="display: flex; flex-direction: column; gap: 3px; align-items: center;"> <div style="display: flex; gap: 3px;"> <button id="toggle-btn-${command.command}" onclick="toggleCommand('${command.command}')" style="padding: 4px 8px; font-size: 0.8em;">🔴 Turn ON</button> </div> ${ hasThresholds ? `<div> <button id="auto-toggle-${command.command}" onclick="toggleAutomation('${command.command}')" style="background: #ff9800; color: white; border: none; padding: 2px 6px; border-radius: 3px; font-size: 0.8em;"> 👤 Disable Automation </button> </div>` : // No override buttons for commands without thresholds - just use Start/Stop '' } <div style="display: flex; gap: 3px;"> <button onclick="showEditCommandForm('${command.command}')" class="btn-secondary" style="padding: 3px 6px; font-size: 0.8em;">✏️ Edit</button> <button onclick="unregisterCommand('${command.command}')" class="btn-danger" style="padding: 3px 6px; font-size: 0.8em;">❌ Remove</button> </div> </div> </td> </tr>`; }); html += '</tbody></table></div>'; container.innerHTML = html; const form = getEditCommandForm(); if (currentEditingCommand && form && form.style.display !== 'none') { attachEditFormToRow(currentEditingCommand, { scroll: false }); } } export function showAddCommandForm() { document.getElementById('addCommandForm').style.display = 'block'; } export function hideAddCommandForm() { document.getElementById('addCommandForm').style.display = 'none'; clearAddCommandForm(); } function clearAddCommandForm() { document.getElementById('commandName').value = ''; document.getElementById('commandDescription').value = ''; document.getElementById('commandKeywords').value = ''; // defaultState is now hardcoded to false // Clear thresholds addingThresholds = []; displayAddCommandThresholdsList(); // Hide threshold form if visible cancelAddCmdThreshold(); } // Edit command functions export async function showEditCommandForm(commandName) { try { if ( currentEditingCommand && currentEditingCommand !== commandName && editFormDirty ) { const proceed = confirm( `You have unsaved changes for ${currentEditingCommand}. Discard and edit ${commandName}?` ); if (!proceed) { return; } } // Find the command in the current commands list const response = await fetch(`${getPluginPath()}/api/commands`).then(r => r.json() ); const command = response.commands.find(cmd => cmd.command === commandName); if (!command) { alert('Command not found'); return; } currentEditingCommand = command.command; const form = ensureEditFormInDom(); if (!form) { alert('Unable to locate the edit form in the document.'); return; } // Populate the form document.getElementById('editCommandName').value = command.command; document.getElementById('editCommandDescription').value = command.description || ''; document.getElementById('editCommandKeywords').value = command.keywords ? command.keywords.join(', ') : ''; // defaultState is now hardcoded to false on server side // Populate thresholds configuration (multiple thresholds supported) editingThresholds = command.thresholds ? [...command.thresholds] : []; displayThresholdsList(); setupEditFormFieldListeners(); attachEditFormToRow(command.command); clearEditFormDirtyState(''); } catch (error) { alert('Failed to load command details: ' + error.message); } } export function hideEditCommandForm(force = false) { if (!force && editFormDirty) { const confirmClose = confirm( 'You have unsaved changes. Close without saving?' ); if (!confirmClose) { return; } } detachEditForm(); currentEditingCommand = null; clearEditFormDirtyState(''); } export async function updateCommand() { const success = await persistCommandChanges({ silent: false, closeOnSuccess: true, }); if (success) { alert('Command updated successfully!'); } } export async function registerCommand() { try { const command = document.getElementById('commandName').value.trim(); const description = document .getElementById('commandDescription') .value.trim(); const keywordsInput = document .getElementById('commandKeywords') .value.trim(); if (!command) { alert('Command name is required'); return; } // Parse keywords from comma-separated string const keywords = keywordsInput ? keywordsInput .split(',') .map(k => k.trim()) .filter(k => k.length > 0) : undefined; // Default state is hardcoded to OFF const defaultState = false; // Get thresholds configuration const thresholds = addingThresholds.length > 0 ? addingThresholds : undefined; const response = await fetch(`${getPluginPath()}/api/commands`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ command: command, description: description || undefined, keywords: keywords, defaultState: defaultState, thresholds: thresholds, }), }); const result = await response.json(); if (result.success) { hideAddCommandForm(); await loadCommands(); await loadPathConfigurations(); // Refresh paths to show the auto-created path alert( `Command '${command}' registered successfully!\n\nA path configuration has been automatically created and enabled for this command.` ); } else { alert(`Error registering command: ${result.error}`); } } catch (error) { alert(`Network error: ${error.message}`); } } export async function executeCommand(commandName, value) { try { const response = await fetch( `${getPluginPath()}/api/commands/${commandName}/execute`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ value: value, }), } ); const result = await response.json(); if (result.success) { await loadCommandHistory(); } else { alert(`Error executing command: ${result.error}`); } } catch (error) { alert(`Network error: ${error.message}`); } } export async function toggleCommand(commandName) { // Get current state from the status display const stateElement = document.getElementById(`command-state-${commandName}`); if (!stateElement) return; const currentText = stateElement.textContent; const isCurrentlyOn = currentText.includes('🟢 ON'); // Toggle to opposite state await executeCommand(commandName, !isCurrentlyOn); } export async function unregisterCommand(commandName) { if ( !confirm(`Are you sure you want to unregister command '${commandName}'?`) ) { return; } try { const response = await fetch( `${getPluginPath()}/api/commands/${commandName}`, { method: 'DELETE', } ); const result = await response.json(); if (result.success) { await loadCommands(); await loadCommandHistory(); await loadPathConfigurations(); // Refresh paths to show the removed path alert( `Command '${commandName}' unregistered successfully!\n\nThe associated path configuration has been automatically removed.` ); } else { alert(`Error unregistering command: ${result.error}`); } } catch (error) { alert(`Network error: ${error.message}`); } } // Threshold UI functions // Bounding box UI helpers function initializeBoundingBoxUI(valueContainerId) { const anchorGrid = document.getElementById(`${valueContainerId}_anchorGrid`); const boxSizeInput = document.getElementById(`${valueContainerId}_boxSize`); if (!anchorGrid) return; // Define anchor points (9 positions in a 3x3 grid) const anchors = [ { id: 'nw', label: '↖️ NW', row: 0, col: 0, desc: 'Northwest Corner' }, { id: 'n', label: '⬆️ N', row: 0, col: 1, desc: 'North Edge' }, { id: 'ne', label: '↗️ NE', row: 0, col: 2, desc: 'Northeast Corner' }, { id: 'w', label: '⬅️ W', row: 1, col: 0, desc: 'West Edge' }, { id: 'center', label: '⏺️ Center', row: 1, col: 1, desc: 'Center' }, { id: 'e', label: '➡️ E', row: 1, col: 2, desc: 'East Edge' }, { id: 'sw', label: '↙️ SW', row: 2, col: 0, desc: 'Southwest Corner' }, { id: 's', label: '⬇️ S', row: 2, col: 1, desc: 'South Edge' }, { id: 'se', label: '↘️ SE', row: 2, col: 2, desc: 'Southeast Corner' }, ]; // Create anchor buttons anchors.forEach(anchor => { const button = document.createElement('button'); button.type = 'button'; button.id = `${valueContainerId}_anchor_${anchor.id}`; button.textContent = anchor.label; button.title = anchor.desc; button.style.cssText = ` padding: 12px 8px; border: 2px solid #ddd; background: white; cursor: pointer; border-radius: 4px; font-size: 1em; transition: all 0.2s; `; button.addEventListener('click', function () { // Remove selected state from all buttons anchorGrid.querySelectorAll('button').forEach(btn => { btn.style.background = 'white'; btn.style.borderColor = '#ddd'; btn.style.borderWidth = '2px'; }); // Mark this button as selected this.style.background = '#4caf50'; this.style.borderColor = '#2e7d32'; this.style.borderWidth = '3px'; this.style.color = 'white'; // Store selected anchor this.parentElement.dataset.selectedAnchor = anchor.id; // Update visualization updateBoundingBoxVisualization(valueContainerId); }); anchorGrid.appendChild(button); }); // Select center by default const centerBtn = document.getElementById( `${valueContainerId}_anchor_center` ); if (centerBtn) { centerBtn.click(); } // Update visualization when box size changes if (boxSizeInput) { boxSizeInput.addEventListener('input', () => { updateBoundingBoxVisualization(valueContainerId); }); } } function updateBoundingBoxVisualization(valueContainerId) { const vizContainer = document.getElementById( `${valueContainerId}_visualization` ); const anchorGrid = document.getElementById(`${valueContainerId}_anchorGrid`); const boxSizeInput = document.getElementById(`${valueContainerId}_boxSize`); if (!vizContainer || !anchorGrid) return; const selectedAnchor = anchorGrid.dataset.selectedAnchor || 'center'; const boxSize = parseFloat(boxSizeInput?.value || 1000); // Calculate box dimensions for visualization (scaled) const vizWidth = vizContainer.clientWidth - 30 || 250; const vizHeight = 180; const boxWidth = 120; const boxHeight = 80; // Home port is ALWAYS in the center of the visualization const homePortX = vizWidth / 2; const homePortY = vizHeight / 2; // Calculate box position based on where home port should be WITHIN the box let boxLeft, boxTop; switch (selectedAnchor) { case 'nw': // Home port at northwest corner - box extends south and east boxLeft = homePortX; boxTop = homePortY; break; case 'n': // Home port at north edge - box extends south, centered horizontally boxLeft = homePortX - boxWidth / 2; boxTop = homePortY; break; case 'ne': // Home port at northeast corner - box extends south and west boxLeft = homePortX - boxWidth; boxTop = homePortY; break; case 'w': // Home port at west edge - box extends east, centered vertically boxLeft = homePortX; boxTop = homePortY - boxHeight / 2; break; case 'center': // Home port at center - box extends equally in all directions boxLeft = homePortX - boxWidth / 2; boxTop = homePortY - boxHeight / 2; break; case 'e': // Home port at east edge - box extends west, centered vertically boxLeft = homePortX - boxWidth; boxTop = homePortY - boxHeight / 2; break; case 'sw': // Home port at southwest corner - box extends north and east boxLeft = homePortX; boxTop = homePortY - boxHeight; break; case 's': // Home port at south edge - box extends north, centered horizontally boxLeft = homePortX - boxWidth / 2; boxTop = homePortY - boxHeight; break; case 'se': // Home port at southeast corner - box extends north and west boxLeft = homePortX - boxWidth; boxTop = homePortY - boxHeight; break; default: boxLeft = homePortX - boxWidth / 2; boxTop = homePortY - boxHeight / 2; } // Create visualization HTML vizContainer.innerHTML = ` <svg width="${vizWidth}" height="${vizHeight}" style="display: block;"> <!-- Bounding box --> <rect x="${boxLeft}" y="${boxTop}" width="${boxWidth}" height="${boxHeight}" fill="rgba(76, 175, 80, 0.1)" stroke="#4caf50" stroke-width="2" stroke-dasharray="5,5"/> <!-- Box corners --> <circle cx="${boxLeft}" cy="${boxTop}" r="3" fill="#2196f3"/> <circle cx="${boxLeft + boxWidth}" cy="${boxTop}" r="3" fill="#2196f3"/> <circle cx="${boxLeft}" cy="${boxTop + boxHeight}" r="3" fill="#2196f3"/> <circle cx="${boxLeft + boxWidth}" cy="${boxTop + boxHeight}" r="3" fill="#2196f3"/> <!-- Home port marker (always in center of viz) --> <circle cx="${homePortX}" cy="${homePortY}" r="6" fill="#ff5722" stroke="white" stroke-width="2"/> <text x="${homePortX}" y="${homePortY + 20}" text-anchor="middle" font-size="11" fill="#666"> 🏠 Home Port </text> <!-- Distance labels --> <text x="${vizWidth / 2}" y="15" text-anchor="middle" font-size="11" font-weight="bold" fill="#4caf50"> ${boxSize}m to edges </text> </svg> <div style="margin-top: 10px; font-size: 0.85em; color: #666;"> 📍 The green dashed box shows where the bounding area will be placed<br> 🏠 Home port is at the <strong>${getAnchorName(selectedAnchor)}</strong> of the box </div> `; } function getAnchorName(anchorId) { const names = { nw: 'Northwest Corner', n: 'North Edge (centered)', ne: 'Northeast Corner', w: 'West Edge (centered)', center: 'Center', e: 'East Edge (centered)', sw: 'Southwest Corner', s: 'South Edge (centered)', se: 'Southeast Corner', }; return names[anchorId] || 'Center'; } // Path type detection async function detectPathType(path) { try { const response = await fetch( `${getPluginPath()}/api/paths/${encodeURIComponent(path)}/type` ); const result = await response.json(); if (result.success) { return { dataType: result.dataType || 'unknown', unit: result.unit, enumValues: result.enumValues, description: result.description, }; } } catch (error) { console.log('Could not detect path type:', error); } return { dataType: 'unknown' }; } // Update operator dropdown based on detected path type function updateOperatorDropdown(operatorSelectId, dataType) { const operatorSelect = document.getElementById(operatorSelectId); if (!operatorSelect) return; // Clear existing options operatorSelect.innerHTML = ''; let operators = []; switch (dataType) { case 'numeric': case 'angular': operators = [ { value: 'gt', label: '> Greater Than' }, { value: 'lt', label: '< Less Than' }, { value: 'eq', label: '= Equal To' }, { value: 'ne', label: '≠ Not Equal To' }, { value: 'range', label: '⇄ Within Range' }, ]; break; case 'boolean': operators = [ { value: 'true', label: 'Is True' }, { value: 'false', label: 'Is False' }, ]; break; case 'string': case 'enum': operators = [ { value: 'stringEquals', label: 'Equals' }, { value: 'contains', label: 'Contains' }, { value: 'startsWith', label: 'Starts With' }, { value: 'endsWith', label: 'Ends With' }, ]; break; case 'position': operators = [ { value: 'withinRadius', label: '📍 Within Radius' }, { value: 'outsideRadius', label: '📍 Outside Radius' }, { value: 'inBoundingBox', label: '🗺️ Inside Bounding Box' }, { value: 'outsideBoundingBox', label: '🗺️ Outside Bounding Box' }, ]; break; default: // Unknown type - show all operators operators = [ { value: 'gt', label: '> Greater Than' }, { value: 'lt', label: '< Less Than' }, { value: 'eq', label: '= Equal To' }, { value: 'ne', label: '≠ Not Equal To' }, { value: 'range', label: '⇄ Within Range' }, { value: 'true', label: 'Is True' }, { value: 'false', label: 'Is False' }, { value: 'stringEquals', label: 'Equals (String)' }, { value: 'contains', label: 'Contains' }, ]; break; } operators.forEach(op => { const option = document.createElement('option'); option.value = op.value; option.textContent = op.label; operatorSelect.appendChild(option); }); } // Update value fields based on operator and data type function updateValueFields(operatorSelectId, valueContainerId, dataType) { const operator = document.getElementById(operatorSelectId)?.value; const container = document.getElementById(valueContainerId); if (!container) return; // Clear existing content container.innerHTML = ''; if (!operator) return; // Boolean operators don't need value fields if (operator === 'true' || operator === 'false') { container.style.display = 'none'; return; } container.style.display = 'block'; // Range operator needs min/max fields if (operator === 'range') { container.innerHTML = ` <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;"> <div> <label>Min Value:</label> <input type="number" id="${valueContainerId}_min" step="any" style="width: 100%;"> </div> <div> <label>Max Value:</label> <input type="number" id="${valueContainerId}_max" step="any" style="width: 100%;"> </div> </div> ${dataType === 'angular' ? '<div style="font-size: 0.85em; color: #666; margin-top: 5px;">💡 Enter values in degrees (will be converted to radians)</div>' : ''} `; return; } // Geographic operators if (['withinRadius', 'outsideRadius'].includes(operator)) { // Check if home port is configured const checkHomePortConfigured = async () => { try { const response = await fetch(`${getPluginPath()}/api/config/homeport`); const data = await response.json(); return ( data.success && data.latitude !== null && data.longitude !== null ); } catch (error) { return false; } }; container.innerHTML = ` <div style="margin-bottom: 10px;"> <label> <input type="checkbox" id="${valueContainerId}_useHomePort"> Use Home Port as center </label> </div> <div id="${valueContainerId}_customLocation" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px;"> <div> <label>Latitude:</label> <input type="number" id="${valueContainerId}_lat" step="0.000001" placeholder="e.g., 40.712800" style="width: 100%;"> </div> <div> <label>Longitude:</label> <input type="number" id="${valueContainerId}_lon" step="0.000001" placeholder="e.g., -74.006000" style="width: 100%;"> </div> </div> <div> <label>Radius (meters):</label> <input type="number" id="${valueContainerId}_radius" step="1" min="0" placeholder="e.g., 1000" style="width: 100%;"> </div> `; // Add event listener to toggle custom location fields const checkbox = document.getElementById(`${valueContainerId}_useHomePort`); const customLocation = document.getElementById( `${valueContainerId}_customLocation` ); checkbox.addEventListener('change', function () { customLocation.style.display = this.checked ? 'none' : 'grid'; }); // Check home port and set default checkHomePortConfigured().then(isConfigured => { if (isConfigured) { checkbox.checked = true; customLocation.style.display = 'none'; } else { checkbox.checked = false; customLocation.style.display = 'grid'; } }); return; } if (['inBoundingBox', 'outsideBoundingBox'].includes(operator)) { // Check if home port is configured const checkHomePortConfigured = async () => { try { const response = await fetch(`${getPluginPath()}/api/config/homeport`); const data = await response.json(); return ( data.success && data.latitude !== null && data.longitude !== null ); } catch (error) { return false; } }; container.innerHTML = ` <div style="margin-bottom: 10px;"> <label> <input type="checkbox" id="${valueContainerId}_useHomePort"> Use Home Port as reference </label> </div> <div id="${valueContainerId}_manualBox" style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 10px;"> <div> <label>North (lat):</label> <input type="number" id="${valueContainerId}_north" step="0.000001" style="width: 100%;"> </div> <div> <label>South (lat):</label> <input type="number" id="${valueContainerId}_south" step="0.000001" style="width: 100%;"> </div> <div> <label>East (lon):</label> <input type="number" id="${valueContainerId}_east" step="0.000001" style="width: 100%;"> </div> <div> <label>West (lon):</label> <input type="number" id="${valueContainerId}_west" step="0.000001" style="width: 100%;"> </div> </div> <div id="${valueContainerId}_homePortBox" style="display: none;"> <div style="margin-bottom: 15px;"> <label>Box Size (meters):</label> <input type="number" id="${valueContainerId}_boxSize" value="1000" step="100" min="100" placeholder="e.g., 1000" style="width: 100%;"> <div style="font-size: 0.85em; color: #666; margin-top: 3px;">Distance from home port to edges</div> </div> <div style="margin-bottom: 15px;"> <label>Buffer (meters):</label> <input type="number" id="${valueContainerId}_buffer" value="5" step="1" min="0" max="50" placeholder="e.g., 5" style="width: 100%;"> <div style="font-size: 0.85em; color: #666; margin-top: 3px;">Extra margin to accommodate GPS accuracy (default: 5m)</div> </div> <div style="margin-bottom: 15px;"> <label>Anchor Point:</label> <div id="${valueContainerId}_anchorGrid" style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; margin-top: 5px;"> <!-- Visual grid will be inserted here --> </div> </div> <div id="${valueContainerId}_visualization" style="border: 1px solid #ddd; background: #f9f9f9; padding: 15px; border-radius: 4px; min-height: 200px; position: relative;"> <!-- Visualization will be drawn here --> </div> </div> <div style="font-size: 0.85em; color: #666; margin-top: 5px;">💡 Box can cross 180° meridian</div> `; // Setup toggle between manual and home port modes const checkbox = document.getElementById(`${valueContainerId}_useHomePort`); const manualBox = document.getElementById(`${valueContainerId}_manualBox`); const homePortBox = document.getElementById( `${valueContainerId}_homePortBox` ); checkbox.addEventListener('change', function () { if (this.checked) { manualBox.style.display = 'none'; homePortBox.style.display = 'block'; initializeBoundingBoxUI(valueContainerId); } else { manualBox.style.display = 'grid'; homePortBox.style.display = 'none'; } }); // Check home port and set default checkHomePortConfigured().then(isConfigured => { if (isConfigured) { checkbox.checked = true; manualBox.style.display = 'none'; homePortBox.style.display = 'block'; initializeBoundingBoxUI(valueContainerId); } else { checkbox.checked = false; manualBox.style.display = 'grid'; homePortBox.style.display = 'none'; } }); return; } // String operators if ( ['contains', 'startsWith', 'endsWith', 'stringEquals'].includes(operator) ) { container.innerHTML = ` <label>Value:</label> <input type="text" id="${valueContainerId}_value" placeholder="Enter text" style="width: 100%;"> `; return; } // Numeric operators (gt, lt, eq, ne) const isAngular = dataType === 'angular'; container.innerHTML = ` <label>Value:</label> <input type="number" id="${valueContainerId}_value" step="any" placeholder="Enter value" style="width: 100%;"> ${isAngular ? '<div style="font-size: 0.85em; color: #666; margin-top: 5px;">💡 Enter value in degrees (will be converted to radians)</div>' : ''} `; } export function updateThresholdPathFilter() { populateThresholdPaths(); } async function populateThresholdPaths() { ensureEditFormInDom(); const treeContainer = document.getElementById('newThresholdPathTree'); const searchInput = document.getElementById('newThresholdPathSearch'); const filterType = 'self'; if (!treeContainer) return; treeContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #666;">Loading paths...</div>'; try { let allPaths = []; try { const response = await fetch('/signalk/v1/api/'); if (response.ok) { const data = await response.json(); allPaths = extractPathsFromSignalK(data, filterType); } } catch (error) { console.log( 'Could not load SignalK API data, falling back to plugin paths:', error ); } if (!allPaths.length) { try { const pluginResponse = await fetch(`${getPluginPath()}/api/paths`); if (pluginResponse.ok) { const pluginData = await pluginResponse.json(); if (pluginData.success && Array.isArray(pluginData.paths)) { allPaths = pluginData.paths .map(pathInfo => pathInfo.path) .filter(Boolean); } } } catch (pluginError) { console.log('Failed to load plugin paths for thresholds:', pluginError); } } const uniquePaths = Array.from(new Set(allPaths)).sort(); const tree = buildPathTree(uniquePaths); // Render tree let html = ''; Object.keys(tree) .sort() .forEach(key => { html += renderTreeNode(key, tree[key], 0, ''); }); treeContainer.innerHTML = html || '<div style="padding: 20px; text-align: center; color: #666;">No paths found</div>'; // Setup search if (searchInput) { searchInput.addEventListener('input', e => { const searchTerm = e.target.value; let html = ''; Object.keys(tree) .sort() .forEach(key => { html += renderTreeNode(key, tree[key], 0, searchTerm); }); treeContainer.innerHTML = html || '<div style="padding: 20px; text-align: center; color: #666;">No matches found</div>'; // Auto-expand all when searching if (searchTerm) { treeContainer.querySelectorAll('.path-tree-children').forEach(el => { el.classList.add('expanded'); }); treeContainer.querySelectorAll('.path-tree-toggle').forEach(el => { if (el.textContent) el.textContent = '▼'; }); } }); } } catch (error) { console.log( 'Could not load real-time SignalK paths for thresholds:', error ); treeContainer.innerHTML = '<div style="padding: 20px; text-align: center; color: #999;">Error loading paths</div>'; } // Handle path selection for type detection const hiddenInput = document.getElementById('newThresholdPath'); if (hiddenInput) { hiddenInput.addEventListener('change', async function () { if (this.value) { const typeInfo = await detectPathType(this.value); updateOperatorDropdown('newThresholdOperator', typeInfo.dataType); updateValueFields( 'newThresholdOperator', 'newThresholdValueGroup', typeInfo.dataType ); document.getElementById('newThresholdOperator').dataset.pathDataType = typeInfo.dataType; } }); } // Handle custom path input const customInput = document.getElementById('newThresholdPathCustom'); if (customInput) { customInput.onblur = async function () { if (this.value) { const typeInfo = await detectPathType(this.value); updateOperatorDropdown('newThresholdOperator', typeInfo.dataType); updateValueFields(