Files
personal_TV/static/indicator-manager.js
2025-07-21 23:02:08 +02:00

267 lines
12 KiB
JavaScript

/**
* Creates and manages all indicator-related logic for the chart.
* @param {Object} chart - The Lightweight Charts instance.
* @param {Array<Object>} baseCandleDataRef - A reference to the array holding the chart's BASE 1m candle data.
* @param {Array<Object>} 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 = `<option value="">-- Select Indicator --</option>`;
AVAILABLE_INDICATORS.forEach(ind => {
select.innerHTML += `<option value="${ind.name}">${ind.label}</option>`;
});
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,
};
}