/** * Creates and manages all indicator-related logic for the chart. * @param {Object} chart - The Lightweight Charts instance. * @param {Array} baseCandleDataRef - A reference to the array holding the chart's BASE 1m candle data. * @param {Array} displayedCandleDataRef - A reference to the array with currently visible candles. * @returns {Object} A manager object with public methods to control indicators. */ function createIndicatorManager(chart, baseCandleDataRef, displayedCandleDataRef) { // --- FIX: --- Added `debounceTimerId` to each slot object to track pending updates. const indicatorSlots = [ { id: 1, cellId: 'indicator-cell-1', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null }, { id: 2, cellId: 'indicator-cell-2', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null }, { id: 3, cellId: 'indicator-cell-3', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null }, { id: 4, cellId: 'indicator-cell-4', series: [], definition: null, params: {}, calculator: null, debounceTimerId: null }, ]; const colors = { bb: { bb1_upper: 'rgba(128, 25, 34, 0.5)', bb2_upper: 'rgba(128, 25, 34, 0.75)', bb3_upper: 'rgba(128, 25, 34, 1)', bb1_lower: 'rgba(6, 95, 6, 0.5)', bb2_lower: 'rgba(6, 95, 6, 0.75)', bb3_lower: 'rgba(6, 95, 6, 1.0)', }, hurst: { topBand: '#787b86', bottomBand: '#787b86', topBand_h: '#673ab7', bottomBand_h: '#673ab7' }, default: ['#00BCD4', '#FFEB3B', '#4CAF50', '#E91E63'] }; function populateDropdowns() { indicatorSlots.forEach(slot => { const cell = document.getElementById(slot.cellId); if (!cell) return; const select = document.createElement('select'); select.innerHTML = ``; AVAILABLE_INDICATORS.forEach(ind => { select.innerHTML += ``; }); const controlsContainer = document.createElement('div'); controlsContainer.className = 'indicator-controls'; cell.innerHTML = ''; cell.appendChild(select); cell.appendChild(controlsContainer); select.addEventListener('change', (e) => loadIndicator(slot.id, e.target.value)); }); } function loadIndicator(slotId, indicatorName) { const slot = indicatorSlots.find(s => s.id === slotId); if (!slot) return; // --- FIX: --- Cancel any pending debounced update from the previous indicator's controls. // This is the core of the fix, preventing the race condition. if (slot.debounceTimerId) { clearTimeout(slot.debounceTimerId); slot.debounceTimerId = null; } slot.series.forEach(s => chart.removeSeries(s)); slot.series = []; slot.definition = null; slot.params = {}; slot.calculator = null; const controlsContainer = document.querySelector(`#${slot.cellId} .indicator-controls`); controlsContainer.innerHTML = ''; if (!indicatorName) return; const definition = AVAILABLE_INDICATORS.find(ind => ind.name === indicatorName); if (!definition) return; slot.definition = definition; // Special case for HTS: hide avgType/fast controls, show only Auto TF checkbox if (indicatorName === 'HTS') { const label = document.createElement('label'); label.textContent = 'Auto TF'; label.style.fontSize = '12px'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'input-field'; // initialize params // default HTS params slot.params.autoTF = false; slot.params.avgType = definition.params.find(p => p.name === 'avgType').defaultValue; slot.params.fast = definition.params.find(p => p.name === 'fast').defaultValue; checkbox.addEventListener('change', () => { slot.params.autoTF = checkbox.checked; updateIndicator(slot.id, true); }); const controlGroup = document.createElement('div'); controlGroup.style.display = 'flex'; controlGroup.style.flexDirection = 'column'; controlGroup.appendChild(label); controlGroup.appendChild(checkbox); controlsContainer.appendChild(controlGroup); // initial draw updateIndicator(slot.id, true); return; } // Default controls for other indicators definition.params.forEach(param => { const label = document.createElement('label'); label.textContent = param.label || param.name; label.style.fontSize = '12px'; // Create select for dropdowns, input for numbers let input; if (param.type === 'select') { input = document.createElement('select'); input.className = 'input-field'; // populate options param.options.forEach(opt => { const option = document.createElement('option'); option.value = opt; option.textContent = opt; if (opt === param.defaultValue) option.selected = true; input.appendChild(option); }); slot.params[param.name] = input.value; } else { input = document.createElement('input'); input.type = param.type; input.value = param.defaultValue; if (param.min !== undefined) input.min = param.min; if (param.step !== undefined) input.step = param.step; input.className = 'input-field'; slot.params[param.name] = parseFloat(input.value); } input.addEventListener('input', () => { // debounce param changes clearTimeout(slot.debounceTimerId); slot.debounceTimerId = setTimeout(() => { // update param value const val = input.value; slot.params[param.name] = (param.type === 'number') ? parseFloat(val) : val; updateIndicator(slot.id, true); slot.debounceTimerId = null; }, 500); }); const controlGroup = document.createElement('div'); controlGroup.style.display = 'flex'; controlGroup.style.flexDirection = 'column'; controlGroup.appendChild(label); controlGroup.appendChild(input); controlsContainer.appendChild(controlGroup); }); updateIndicator(slot.id, true); } function updateIndicator(slotId, isFullRecalculation = false) { const slot = indicatorSlots.find(s => s.id === slotId); if (!slot || !slot.definition) return; // for HTS autoTF, always use base data for aggregation; else follow definition flag let candleDataForCalc; if (slot.definition.name === 'HTS' && slot.params.autoTF) { candleDataForCalc = baseCandleDataRef; } else { candleDataForCalc = (slot.definition.usesBaseData) ? baseCandleDataRef : displayedCandleDataRef; } if (candleDataForCalc.length === 0) return; if (isFullRecalculation) { slot.series.forEach(s => chart.removeSeries(s)); slot.series = []; const indicatorResult = slot.definition.calculateFull(candleDataForCalc, slot.params); if (typeof indicatorResult === 'object' && !Array.isArray(indicatorResult)) { Object.keys(indicatorResult).forEach(key => { const seriesData = indicatorResult[key]; const indicatorNameLower = slot.definition.name.toLowerCase(); const series = chart.addLineSeries({ color: (colors[indicatorNameLower] && colors[indicatorNameLower][key]) ? colors[indicatorNameLower][key] : colors.default[slot.id - 1], lineWidth: 1, title: '', lastValueVisible: false, priceLineVisible: false, }); series.setData(seriesData); slot.series.push(series); }); } else { const series = chart.addLineSeries({ color: colors.default[slot.id - 1], lineWidth: 1, title: '', lastValueVisible: false, priceLineVisible: false, }); series.setData(indicatorResult); slot.series.push(series); } if (slot.definition.createRealtime) { slot.calculator = slot.definition.createRealtime(slot.params); slot.calculator.prime(candleDataForCalc); } } else if (slot.calculator) { const lastCandle = candleDataForCalc[candleDataForCalc.length - 1]; if (!lastCandle) return; const newPoint = slot.calculator.update(lastCandle); if (newPoint && typeof newPoint === 'object') { if (slot.series.length > 1) { // Multi-line indicator Object.keys(newPoint).forEach((key, index) => { if (slot.series[index] && newPoint[key]) { slot.series[index].update(newPoint[key]); } }); } else if (slot.series.length === 1) { // Single-line indicator slot.series[0].update(newPoint); } } } } function recalculateAllAfterHistory(baseData, displayedData) { baseCandleDataRef = baseData; displayedCandleDataRef = displayedData; // --- FIX: --- Clear any pending debounced updates from parameter changes. // This prevents a stale update from a parameter input from running after // the chart has already been reset for a new timeframe. indicatorSlots.forEach(slot => { if (slot.debounceTimerId) { clearTimeout(slot.debounceTimerId); slot.debounceTimerId = null; } }); // --- FIX: --- Defer the full recalculation to the next frame. // This prevents a race condition where indicators are removed/added while the chart // is still processing the main series' `setData` operation from a timeframe change. setTimeout(() => { indicatorSlots.forEach(slot => { if (slot.definition) { updateIndicator(slot.id, true); } }); }, 0); } function updateAllOnNewCandle() { indicatorSlots.forEach(slot => { if (slot.definition) { updateIndicator(slot.id, false); } }); } return { populateDropdowns, recalculateAllAfterHistory, updateAllOnNewCandle, }; }