/** * SigUIManager - Handles DOM updates and signal card rendering */ class SigUIManager { constructor() { this.targetEl = null; this.formElement = null; this.onDeleteSignal = null; } /** * Initializes the UI elements with provided IDs. * @param {string} targetId - The ID of the HTML element where signals will be displayed. * @param {string} formElId - The ID of the HTML element for the signal creation form. */ initUI(targetId, formElId) { this.targetEl = document.getElementById(targetId); if (!this.targetEl) { console.warn(`Element for displaying signals "${targetId}" not found.`); } this.formElement = document.getElementById(formElId); if (!this.formElement) { console.warn(`Signals form element "${formElId}" not found.`); } } /** * Displays the form for creating or editing a signal. * @param {string} action - The action to perform ('new' or 'edit'). * @param {object|null} signalData - The data of the signal to edit (only applicable for 'edit' action). */ displayForm(action, signalData = null) { if (!this.formElement) { console.error("Form element not initialized."); return; } const headerTitle = this.formElement.querySelector("h1"); const submitCreateBtn = this.formElement.querySelector("#submit-create-signal"); const submitEditBtn = this.formElement.querySelector("#submit-edit-signal"); const nameBox = this.formElement.querySelector('#signal_name'); const publicCheckbox = this.formElement.querySelector('#signal_public_checkbox'); const tblKeyInput = this.formElement.querySelector('#signal_tbl_key'); // Reset form to panel 1 const panel1 = this.formElement.querySelector('#panel_1'); const panel2 = this.formElement.querySelector('#panel_2'); const panel3 = this.formElement.querySelector('#panel_3'); if (panel1) panel1.style.display = 'grid'; if (panel2) panel2.style.display = 'none'; if (panel3) panel3.style.display = 'none'; if (action === 'new') { if (headerTitle) headerTitle.textContent = "Add New Signal"; if (submitCreateBtn) submitCreateBtn.style.display = "inline-block"; if (submitEditBtn) submitEditBtn.style.display = "none"; if (nameBox) nameBox.value = ''; if (publicCheckbox) publicCheckbox.checked = false; if (tblKeyInput) tblKeyInput.value = ''; } else if (action === 'edit' && signalData) { if (headerTitle) headerTitle.textContent = "Edit Signal"; if (submitCreateBtn) submitCreateBtn.style.display = "none"; if (submitEditBtn) submitEditBtn.style.display = "inline-block"; if (nameBox) nameBox.value = signalData.name || ''; if (publicCheckbox) publicCheckbox.checked = !!signalData.public; if (tblKeyInput) tblKeyInput.value = signalData.tbl_key || ''; // Pre-fill source fields const sigSource = this.formElement.querySelector('#sig_source'); const sigProp = this.formElement.querySelector('#sig_prop'); const sig2Source = this.formElement.querySelector('#sig2_source'); const sig2Prop = this.formElement.querySelector('#sig2_prop'); const sigType = this.formElement.querySelector('#select_s_type'); const valueInput = this.formElement.querySelector('#value'); if (sigSource && signalData.source1) sigSource.value = signalData.source1; if (sigProp && signalData.prop1) { // Fill prop options first, then set value if (UI.signals && signalData.source1) { UI.signals.fill_prop('sig_prop', signalData.source1); } setTimeout(() => { if (sigProp) sigProp.value = signalData.prop1; }, 50); } // Handle source2 - could be 'value' for fixed value type if (signalData.source2 === 'value') { if (sigType) sigType.value = 'Value'; if (valueInput) valueInput.value = signalData.prop2 || ''; } else { if (sigType) sigType.value = 'Comparison'; if (sig2Source && signalData.source2) sig2Source.value = signalData.source2; if (sig2Prop && signalData.prop2) { if (UI.signals && signalData.source2) { UI.signals.fill_prop('sig2_prop', signalData.source2); } setTimeout(() => { if (sig2Prop) sig2Prop.value = signalData.prop2; }, 50); } } // Set operator const operatorRadios = this.formElement.querySelectorAll('input[name="Operator"]'); operatorRadios.forEach(radio => { radio.checked = radio.value === signalData.operator; }); // Set range if applicable if (signalData.operator === '+/-' && signalData.range) { const rangeVal = this.formElement.querySelector('#rangeVal'); const rangeSlider = this.formElement.querySelector('#rangeSlider'); if (rangeVal) rangeVal.value = signalData.range; if (rangeSlider) rangeSlider.value = signalData.range; } } this.formElement.style.display = "grid"; } /** * Hides the signal form. */ hideForm() { if (this.formElement) { this.formElement.style.display = 'none'; } } /** * Updates the HTML representation of the signals as cards. * @param {Object[]} signals - The list of signals to display. */ updateSignalsHtml(signals) { if (!this.targetEl) { console.error("Target element for displaying signals is not set."); return; } // Clear existing content while (this.targetEl.firstChild) { this.targetEl.removeChild(this.targetEl.firstChild); } // Create and append new elements for all signals for (const signal of signals) { try { const signalCard = this._createSignalCard(signal); this.targetEl.appendChild(signalCard); } catch (error) { console.error(`Error processing signal:`, error, signal); } } } /** * Creates a signal card HTML element. * @param {Object} signal - The signal data. * @returns {HTMLElement} - The card element. */ _createSignalCard(signal) { const signalItem = document.createElement('div'); signalItem.className = 'signal-item'; signalItem.setAttribute('data-signal-id', signal.tbl_key || signal.name); // Add state-based styling const isTrue = signal.state === true || signal.state === 'true' || signal.state === 1; if (isTrue) { signalItem.classList.add('signal-true'); } else { signalItem.classList.add('signal-false'); } // Delete button const deleteButton = document.createElement('button'); deleteButton.className = 'delete-button'; deleteButton.innerHTML = '✘'; deleteButton.addEventListener('click', (e) => { e.stopPropagation(); if (this.onDeleteSignal) { this.onDeleteSignal(signal.tbl_key || signal.name); } }); signalItem.appendChild(deleteButton); // Signal icon container const signalIcon = document.createElement('div'); signalIcon.className = 'signal-icon'; signalIcon.addEventListener('click', () => { // Open edit form when clicking on signal this.displayForm('edit', signal); }); // Signal name const signalName = document.createElement('div'); signalName.className = 'signal-name'; signalName.textContent = signal.name || 'Unnamed Signal'; signalIcon.appendChild(signalName); // State indicator const stateIndicator = document.createElement('div'); stateIndicator.className = 'signal-state'; stateIndicator.id = `${signal.name}_state`; stateIndicator.textContent = isTrue ? 'TRUE' : 'FALSE'; signalIcon.appendChild(stateIndicator); signalItem.appendChild(signalIcon); // Hover details panel const signalHover = document.createElement('div'); signalHover.className = 'signal-hover'; // Build hover content let hoverHtml = `${signal.name || 'Unnamed Signal'}`; hoverHtml += `
`; hoverHtml += `Source 1: ${signal.source1} (${signal.prop1})`; hoverHtml += `Value: ${signal.value1 ?? signal.last_value1 ?? 'N/A'}`; hoverHtml += `Operator: ${signal.operator}${signal.operator === '+/-' ? ` (range: ${signal.range})` : ''}`; if (signal.source2 === 'value') { hoverHtml += `Compare to: ${signal.prop2}`; } else { hoverHtml += `Source 2: ${signal.source2} (${signal.prop2})`; hoverHtml += `Value: ${signal.value2 ?? signal.last_value2 ?? 'N/A'}`; } // State display const stateClass = isTrue ? 'state-true' : 'state-false'; hoverHtml += `State: ${isTrue ? 'TRUE' : 'FALSE'}`; if (signal.public) { hoverHtml += `Public`; } hoverHtml += `
`; signalHover.innerHTML = hoverHtml; signalItem.appendChild(signalHover); return signalItem; } /** * Updates a single signal's state display. * @param {string} signalName - The name of the signal. * @param {boolean} state - The new state. */ updateSignalState(signalName, state) { const stateEl = document.getElementById(`${signalName}_state`); if (stateEl) { const isTrue = state === true || state === 'true' || state === 1; stateEl.textContent = isTrue ? 'TRUE' : 'FALSE'; // Update parent card styling const card = stateEl.closest('.signal-item'); if (card) { card.classList.remove('signal-true', 'signal-false'); card.classList.add(isTrue ? 'signal-true' : 'signal-false'); } } } /** * Sets the callback function for deleting a signal. * @param {Function} callback - The callback function. */ registerDeleteSignalCallback(callback) { this.onDeleteSignal = callback; } } /** * SigDataManager - Manages in-memory signal data store */ class SigDataManager { constructor() { this.signals = []; } /** * Fetches the saved signals from the server. * @param {Object} comms - The communications instance. * @param {Object} data - An object containing user data. */ fetchSavedSignals(comms, data) { if (comms) { try { const requestData = { request: 'signals', user_name: data?.user_name }; comms.sendToApp('request', requestData); } catch (error) { console.error("Error fetching saved signals:", error.message); } } else { throw new Error('Communications instance not available.'); } } /** * Adds a new signal to the local store. * @param {Object} data - The signal data. */ addNewSignal(data) { const signalData = data.signal || data; console.log("Adding new signal:", signalData); if (!signalData.name) { console.error("Signal data missing 'name' field:", signalData); return; } // Check for duplicates const exists = this.signals.find(s => s.tbl_key === signalData.tbl_key || s.name === signalData.name); if (!exists) { this.signals.push(signalData); } } /** * Retrieves a signal by its tbl_key. * @param {string} tbl_key - The tbl_key of the signal. * @returns {Object|null} - The signal object or null. */ getSignalById(tbl_key) { return this.signals.find(signal => signal.tbl_key === tbl_key) || null; } /** * Retrieves a signal by its name. * @param {string} name - The name of the signal. * @returns {Object|null} - The signal object or null. */ getSignalByName(name) { return this.signals.find(signal => signal.name === name) || null; } /** * Updates signal data. * @param {Object} data - The updated signal data. */ updateSignalData(data) { const signalData = data.signal || data; const signalKey = signalData.tbl_key || signalData.name; if (!signalKey) return; const index = this.signals.findIndex( signal => signal.tbl_key === signalKey || signal.name === signalKey ); if (index !== -1) { this.signals[index] = { ...this.signals[index], ...signalData }; } else { this.signals.push(signalData); } } /** * Removes a signal from the store. * @param {string} identifier - The tbl_key or name of the signal. */ removeSignal(identifier) { console.log(`Removing signal: ${identifier}`); this.signals = this.signals.filter( sig => sig.tbl_key !== identifier && sig.name !== identifier ); } /** * Updates signal states from server updates. * @param {Object} stateUpdates - Map of signal names to states. */ applyStateUpdates(stateUpdates) { for (const name in stateUpdates) { const signal = this.getSignalByName(name); if (signal) { signal.state = stateUpdates[name]; } } } /** * Returns all signals. * @returns {Object[]} - The list of signals. */ getAllSignals() { return this.signals; } /** * Sets all signals (used when loading from server). * @param {Object[]} signals - The list of signals. */ setSignals(signals) { this.signals = Array.isArray(signals) ? signals : []; } } /** * Signals - Main coordinator class that manages SigUIManager, SigDataManager, and SocketIO communication */ class Signals { constructor(ui) { this.ui = ui; this.comms = ui?.data?.comms; this.indicatorData = ui?.data?.indicators; this.data = ui?.data; this.dataManager = new SigDataManager(); this.uiManager = new SigUIManager(); // Set up delete callback this.uiManager.registerDeleteSignalCallback(this.deleteSignal.bind(this)); // Bind methods this.submitSignal = this.submitSignal.bind(this); this._initialized = false; } /** * Initializes the Signals instance. * @param {string} targetId - The ID of the signals container element. * @param {string} formElId - The ID of the signal form element. */ initialize(targetId, formElId) { try { this.uiManager.initUI(targetId, formElId); if (!this.comms) { console.error("Communications instance not available."); return; } // Register handlers with Comms this.comms.on('signals', this.handleSignalsResponse.bind(this)); this.comms.on('signal_created', this.handleSignalCreated.bind(this)); this.comms.on('signal_updated', this.handleSignalUpdated.bind(this)); this.comms.on('signal_deleted', this.handleSignalDeleted.bind(this)); this.comms.on('signal_error', this.handleSignalError.bind(this)); this.comms.on('updates', this.handleUpdates.bind(this)); // Fetch saved signals this.dataManager.fetchSavedSignals(this.comms, this.data); this._initialized = true; } catch (error) { console.error("Error initializing Signals:", error); } } /** * Handle initial signals list response from server. * @param {Array} data - List of signal objects. */ handleSignalsResponse(data) { console.log("Received signals list:", data); if (Array.isArray(data)) { this.dataManager.setSignals(data); this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); } } /** * Handle new signal created event. * @param {Object} data - Server response with signal data. */ handleSignalCreated(data) { console.log("Signal created:", data); if (data.success) { this.dataManager.addNewSignal(data); this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); } else { alert(`Failed to create signal: ${data.message}`); } } /** * Handle signal updated event. * @param {Object} data - Server response with updated signal data. */ handleSignalUpdated(data) { console.log("Signal updated:", data); if (data.success) { this.dataManager.updateSignalData(data); this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); } else { alert(`Failed to update signal: ${data.message}`); } } /** * Handle signal deleted event. * @param {Object} data - Server response with deleted signal info. */ handleSignalDeleted(data) { console.log("Signal deleted:", data); const identifier = data.tbl_key || data.name; if (identifier) { this.dataManager.removeSignal(identifier); this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); } } /** * Handle signal error. * @param {Object} data - Error data. */ handleSignalError(data) { console.error("Signal error:", data.message); alert(`Signal error: ${data.message}`); } /** * Handle updates (including signal state changes). * @param {Object} data - Update data from server. */ handleUpdates(data) { const { s_updates } = data; if (s_updates) { this.dataManager.applyStateUpdates(s_updates); // Update UI for state changes for (const name in s_updates) { this.uiManager.updateSignalState(name, s_updates[name]); } } } // ================ Form Methods ================ /** * Opens the signal creation form. */ open_signal_Form() { this.uiManager.displayForm('new'); } /** * Closes the signal form. */ close_signal_Form() { this.uiManager.hideForm(); } /** * Requests signals from server. */ request_signals() { if (this.comms) { this.comms.sendToApp('request', { request: 'signals', user_name: this.data?.user_name }); } } /** * Deletes a signal by tbl_key or name. * @param {string} identifier - The tbl_key or name of the signal. */ deleteSignal(identifier) { if (!this.comms) { console.error("Comms instance not available."); return; } const signal = this.dataManager.getSignalById(identifier) || this.dataManager.getSignalByName(identifier); const deleteData = signal ? { tbl_key: signal.tbl_key, name: signal.name } : { name: identifier }; this.comms.sendToApp('delete_signal', deleteData); } /** * Submits a new or edited signal. * @param {string} action - 'new' or 'edit'. */ submitSignal(action) { const formElement = this.uiManager.formElement; if (!formElement) { console.error("Form element not available."); return; } const name = formElement.querySelector('#signal_name')?.value?.trim(); const source1 = formElement.querySelector('#sig_source')?.value; const prop1 = formElement.querySelector('#sig_prop')?.value; const source2 = formElement.querySelector('#sig2_source')?.value; const prop2 = formElement.querySelector('#sig2_prop')?.value; const operator = formElement.querySelector('input[name="Operator"]:checked')?.value; const range = formElement.querySelector('#rangeVal')?.value; const sigType = formElement.querySelector('#select_s_type')?.value; const value = formElement.querySelector('#value')?.value; const publicCheckbox = formElement.querySelector('#signal_public_checkbox'); const tblKey = formElement.querySelector('#signal_tbl_key')?.value; if (!name) { alert("Please provide a name for the signal."); return; } if (!prop1) { alert("Please select a property for the signal source."); return; } // Build signal data let actualSource2 = source2; let actualProp2 = prop2; if (sigType !== 'Comparison') { actualSource2 = 'value'; actualProp2 = value; } const signalData = { name, source1, prop1, operator, source2: actualSource2, prop2: actualProp2, state: false, value1: null, value2: null, public: publicCheckbox?.checked ? 1 : 0, user_name: this.data?.user_name }; if (operator === '+/-') { signalData.range = parseFloat(range) || 0; } if (action === 'edit' && tblKey) { signalData.tbl_key = tblKey; } const messageType = action === 'new' ? 'new_signal' : 'edit_signal'; this.comms.sendToApp(messageType, signalData); this.close_signal_Form(); } /** * Submits a new signal (legacy method name). */ submitNewSignal() { this.submitSignal('new'); } // ================ Helper Methods ================ /** * Fills property dropdown based on indicator type. * @param {string} target_id - The ID of the select element. * @param {string} indctr - The indicator name. */ fill_prop(target_id, indctr) { const target = document.getElementById(target_id); const indicatorConfig = this.indicatorData ? this.indicatorData[indctr] : null; if (!target) return; // Clear existing options while (target.options.length > 0) { target.remove(0); } if (!indicatorConfig) { console.warn(`Indicator "${indctr}" not found in indicator data`); return; } // Get the indicator outputs based on type const outputMap = { 'SMA': ['value'], 'EMA': ['value'], 'LREG': ['value'], 'RSI': ['value'], 'ATR': ['value'], 'Volume': ['value'], 'MACD': ['macd', 'signal', 'hist'], 'BOLBands': ['upper', 'middle', 'lower'] }; const indicatorType = indicatorConfig.type; const outputs = outputMap[indicatorType] || ['value']; for (const output of outputs) { const opt = document.createElement("option"); opt.value = output; opt.textContent = output; target.appendChild(opt); } } /** * Switches between form panels. * @param {string} p1 - Panel to hide. * @param {string} p2 - Panel to show. */ switch_panel(p1, p2) { const panel1 = document.getElementById(p1); const panel2 = document.getElementById(p2); if (panel1) panel1.style.display = 'none'; if (panel2) panel2.style.display = 'grid'; } /** * Conditionally hides an element. * @param {*} firstValue - First value to compare. * @param {*} scndValue - Second value to compare. * @param {string} id - Element ID to show/hide. */ hideIfTrue(firstValue, scndValue, id) { const el = document.getElementById(id); if (el) { el.style.display = firstValue === scndValue ? 'none' : 'block'; } } /** * Gets the current value of an indicator property from the DOM. * @param {string} source - The indicator source name. * @param {string} prop - The property name. * @returns {string} - The value. */ _getIndicatorValue(source, prop) { let element = document.getElementById(source + '_' + prop) || document.getElementById(source + '_value'); if (!element) { console.warn(`Could not find indicator value element for ${source}_${prop}`); return '0'; } let rawValue = element.value || element.textContent || '0'; // Parse multi-value format if needed if (rawValue.includes(':') && rawValue.includes(',')) { const parts = rawValue.split(',').map(p => p.trim()); for (const part of parts) { const [key, val] = part.split(':').map(s => s.trim()); if (key === prop) { return val; } } } return rawValue; } /** * Handles panel 1 "Next" button click. * @param {number} n - Panel number. */ ns_next(n) { if (n === 1) { const sigName = document.getElementById('signal_name')?.value; const sigSource = document.getElementById('sig_source')?.value; const sigProp = document.getElementById('sig_prop')?.value; if (!sigName) { alert('Please give the signal a name.'); return; } if (!sigProp) { alert('Please select a property.'); return; } const display = document.getElementById('sig_display'); if (display) { display.innerHTML = `${sigName}: {${sigSource}:${sigProp}}`; } // Get current indicator value const indctrVal = this._getIndicatorValue(sigSource, sigProp); const valueInput = document.getElementById('value'); if (valueInput) { valueInput.value = indctrVal || '0'; } this.switch_panel('panel_1', 'panel_2'); } if (n === 2) { const sigName = document.getElementById('signal_name')?.value; const sigSource = document.getElementById('sig_source')?.value; const sigProp = document.getElementById('sig_prop')?.value; const sig2Source = document.getElementById('sig2_source')?.value; const sig2Prop = document.getElementById('sig2_prop')?.value; const operator = document.querySelector('input[name="Operator"]:checked')?.value; const range = document.getElementById('rangeVal')?.value; const sigType = document.getElementById('select_s_type')?.value; const value = document.getElementById('value')?.value; const sig1 = `${sigSource} : ${sigProp}`; const sig2 = sigType === 'Comparison' ? `${sig2Source} : ${sig2Prop}` : value; const operatorStr = operator === '+/-' ? `${operator} ${range}` : operator; const sigDisplayStr = `(${sigName}) (${sig1}) (${operatorStr}) (${sig2})`; const sig1_realtime = this._getIndicatorValue(sigSource, sigProp); const sig2_realtime = sigType === 'Comparison' ? this._getIndicatorValue(sig2Source, sig2Prop) : value; const display2 = document.getElementById('sig_display2'); const realtime = document.getElementById('sig_realtime'); const evalEl = document.getElementById('sig_eval'); if (display2) display2.innerHTML = sigDisplayStr; if (realtime) realtime.innerHTML = `(${sigProp} : ${sig1_realtime}) (${operatorStr}) (${sig2_realtime})`; // Evaluate let evalStr; if (operator === '==') evalStr = parseFloat(sig1_realtime) === parseFloat(sig2_realtime); if (operator === '>') evalStr = parseFloat(sig1_realtime) > parseFloat(sig2_realtime); if (operator === '<') evalStr = parseFloat(sig1_realtime) < parseFloat(sig2_realtime); if (operator === '+/-') evalStr = Math.abs(parseFloat(sig1_realtime) - parseFloat(sig2_realtime)) <= parseFloat(range); if (evalEl) evalEl.innerHTML = evalStr ? 'true' : 'false'; this.switch_panel('panel_2', 'panel_3'); } } // Legacy methods for backwards compatibility i_update(updates) { for (const signal of this.dataManager.getAllSignals()) { const s1 = signal.source1; if (s1 in updates) { const p1 = signal.prop1; const value1 = updates[s1].data[0][p1]; signal.value1 = value1.toFixed(2); } if (signal.source2 !== 'value') { const s2 = signal.source2; if (s2 in updates) { const p2 = signal.prop2; const value2 = updates[s2].data[0][p2]; signal.value2 = value2.toFixed(2); } } const val1El = document.getElementById(signal.name + '_value1'); const val2El = document.getElementById(signal.name + '_value2'); if (val1El) val1El.innerHTML = signal.value1; if (val2El) val2El.innerHTML = signal.value2; } } update_signal_states(s_updates) { for (const name in s_updates) { this.uiManager.updateSignalState(name, s_updates[name]); } } set_data(signals) { // Legacy method - now handled by handleSignalsResponse if (Array.isArray(signals)) { for (const sig of signals) { const obj = typeof sig === 'string' ? JSON.parse(sig) : sig; this.dataManager.addNewSignal(obj); } this.uiManager.updateSignalsHtml(this.dataManager.getAllSignals()); } } delete_signal(signal_name) { // Legacy method - redirect to new method this.deleteSignal(signal_name); } }