import { getAvailableIndicators, IndicatorRegistry as IR } from '../indicators/index.js';
let activeIndicators = [];
let configuringId = null;
let previewingType = null; // type being previewed (not yet added)
let nextInstanceId = 1;
const DEFAULT_COLORS = ['#2962ff', '#26a69a', '#ef5350', '#ff9800', '#9c27b0', '#00bcd4', '#ffeb3b', '#e91e63'];
const LINE_TYPES = ['solid', 'dotted', 'dashed'];
function getDefaultColor(index) {
return DEFAULT_COLORS[index % DEFAULT_COLORS.length];
}
function getPlotGroupName(plotId) {
if (plotId.toLowerCase().includes('fast')) return 'Fast';
if (plotId.toLowerCase().includes('slow')) return 'Slow';
if (plotId.toLowerCase().includes('upper')) return 'Upper';
if (plotId.toLowerCase().includes('lower')) return 'Lower';
if (plotId.toLowerCase().includes('middle') || plotId.toLowerCase().includes('basis')) return 'Middle';
if (plotId.toLowerCase().includes('signal')) return 'Signal';
if (plotId.toLowerCase().includes('histogram')) return 'Histogram';
if (plotId.toLowerCase().includes('k')) return '%K';
if (plotId.toLowerCase().includes('d')) return '%D';
return plotId;
}
function groupPlotsByColor(plots) {
const groups = {};
plots.forEach((plot, idx) => {
const groupName = getPlotGroupName(plot.id);
if (!groups[groupName]) {
groups[groupName] = { name: groupName, indices: [], plots: [] };
}
groups[groupName].indices.push(idx);
groups[groupName].plots.push(plot);
});
return Object.values(groups);
}
/** Generate a short label for an active indicator showing its key params */
function getIndicatorLabel(indicator) {
const meta = getIndicatorMeta(indicator);
if (!meta) return indicator.name;
const paramParts = meta.inputs.map(input => {
const val = indicator.params[input.name];
if (val !== undefined && val !== input.default) return val;
if (val !== undefined) return val;
return null;
}).filter(v => v !== null);
if (paramParts.length > 0) {
return `${indicator.name} (${paramParts.join(', ')})`;
}
return indicator.name;
}
function getIndicatorMeta(indicator) {
const IndicatorClass = IR?.[indicator.type];
if (!IndicatorClass) return null;
const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name });
return instance.getMetadata();
}
export function getActiveIndicators() {
return activeIndicators;
}
export function setActiveIndicators(indicators) {
activeIndicators = indicators;
}
/**
* Render the indicator catalog (available indicators) and active list.
* Catalog items are added via double-click (multiple instances allowed).
*/
export function renderIndicatorList() {
const container = document.getElementById('indicatorList');
if (!container) return;
const available = getAvailableIndicators();
container.innerHTML = `
${available.map(ind => `
${ind.name}
+
`).join('')}
${activeIndicators.length > 0 ? `
Active
${activeIndicators.map(ind => {
const isConfiguring = ind.id === configuringId;
const plotGroups = groupPlotsByColor(ind.plots || []);
const colorDots = plotGroups.map(group => {
const firstIdx = group.indices[0];
const color = ind.params[`_color_${firstIdx}`] || '#2962ff';
return `
`;
}).join('');
const label = getIndicatorLabel(ind);
return `
${ind.visible !== false ? '👁' : '👁🗨'}
${label}
${colorDots}
`;
}).join('')}
` : ''}
`;
// Bind events via delegation
container.querySelectorAll('.indicator-catalog-item').forEach(el => {
el.addEventListener('click', () => previewIndicator(el.dataset.type));
el.addEventListener('dblclick', () => addIndicator(el.dataset.type));
});
container.querySelectorAll('.indicator-catalog-add').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
addIndicator(el.dataset.type);
});
});
container.querySelectorAll('.indicator-active-name').forEach(el => {
el.addEventListener('click', () => selectIndicatorConfig(el.dataset.id));
});
container.querySelectorAll('.indicator-config-btn').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
selectIndicatorConfig(el.dataset.id);
});
});
container.querySelectorAll('.indicator-remove-btn').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
removeIndicatorById(el.dataset.id);
});
});
container.querySelectorAll('.indicator-active-eye').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
toggleVisibility(el.dataset.id);
});
});
updateConfigPanel();
updateChartLegend();
}
function updateConfigPanel() {
const configPanel = document.getElementById('indicatorConfigPanel');
const configButtons = document.getElementById('configButtons');
if (!configPanel) return;
configPanel.style.display = 'block';
// Active indicator config takes priority over preview
const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null;
if (indicator) {
renderIndicatorConfig(indicator);
if (configButtons) configButtons.style.display = 'flex';
} else if (previewingType) {
renderPreviewConfig(previewingType);
if (configButtons) configButtons.style.display = 'none';
} else {
const container = document.getElementById('configForm');
if (container) {
container.innerHTML = 'Click an indicator to preview its settings
';
}
if (configButtons) configButtons.style.display = 'none';
}
}
/** Single-click: preview config for a catalog indicator type (read-only) */
function previewIndicator(type) {
configuringId = null;
previewingType = previewingType === type ? null : type;
renderIndicatorList();
}
/** Render a read-only preview of an indicator's default config */
function renderPreviewConfig(type) {
const container = document.getElementById('configForm');
if (!container) return;
const IndicatorClass = IR?.[type];
if (!IndicatorClass) return;
const instance = new IndicatorClass({ type, params: {}, name: '' });
const meta = instance.getMetadata();
container.innerHTML = `
${meta.name}
${meta.description || ''}
${meta.inputs.map(input => `
${input.type === 'select' ?
`` :
``
}
`).join('')}
Double-click to add to chart
`;
}
/** Add a new instance of an indicator type */
export function addIndicator(type) {
const IndicatorClass = IR?.[type];
if (!IndicatorClass) return;
previewingType = null;
const id = `${type}_${nextInstanceId++}`;
const instance = new IndicatorClass({ type, params: {}, name: '' });
const metadata = instance.getMetadata();
const params = {
_lineType: 'solid',
_lineWidth: 1
};
// Set Hurst-specific defaults
if (type === 'hurst') {
params.timeframe = 'chart';
params.markerBuyShape = 'custom';
params.markerSellShape = 'custom';
params.markerBuyColor = '#9e9e9e';
params.markerSellColor = '#9e9e9e';
params.markerBuyCustom = '▲';
params.markerSellCustom = '▼';
}
metadata.plots.forEach((plot, idx) => {
params[`_color_${idx}`] = plot.color || getDefaultColor(activeIndicators.length + idx);
});
metadata.inputs.forEach(input => {
params[input.name] = input.default;
});
activeIndicators.push({
id,
type,
name: metadata.name,
params,
plots: metadata.plots,
series: [],
visible: true
});
configuringId = id;
renderIndicatorList();
drawIndicatorsOnChart();
}
function selectIndicatorConfig(id) {
previewingType = null;
if (configuringId === id) {
configuringId = null;
} else {
configuringId = id;
}
renderIndicatorList();
}
function toggleVisibility(id) {
const indicator = activeIndicators.find(a => a.id === id);
if (!indicator) return;
indicator.visible = indicator.visible === false ? true : false;
// Show/hide all series for this indicator
indicator.series?.forEach(s => {
try {
s.applyOptions({ visible: indicator.visible });
} catch(e) {}
});
renderIndicatorList();
}
export function renderIndicatorConfig(indicator) {
const container = document.getElementById('configForm');
if (!container || !indicator) return;
const IndicatorClass = IR?.[indicator.type];
if (!IndicatorClass) {
container.innerHTML = 'Error loading indicator
';
return;
}
const instance = new IndicatorClass({ type: indicator.type, params: indicator.params, name: indicator.name });
const meta = instance.getMetadata();
const plotGroups = groupPlotsByColor(meta.plots);
const colorInputs = plotGroups.map(group => {
const firstIdx = group.indices[0];
const color = indicator.params[`_color_${firstIdx}`] || meta.plots[firstIdx].color || '#2962ff';
return `
`;
}).join('');
container.innerHTML = `
${getIndicatorLabel(indicator)}
${colorInputs}
${meta.inputs.map(input => `
${input.type === 'select' ?
`` :
``
}
`).join('')}
`;
}
export function applyIndicatorConfig() {
const indicator = configuringId ? activeIndicators.find(a => a.id === configuringId) : null;
if (!indicator) return;
const IndicatorClass = IR?.[indicator.type];
if (!IndicatorClass) return;
const instance = new IndicatorClass({ type: indicator.type, params: {}, name: indicator.name });
const meta = instance.getMetadata();
const plotGroups = groupPlotsByColor(meta.plots);
plotGroups.forEach(group => {
const firstIdx = group.indices[0];
const colorEl = document.getElementById(`config__color_${firstIdx}`);
if (colorEl) {
const color = colorEl.value;
group.indices.forEach(idx => {
indicator.params[`_color_${idx}`] = color;
});
}
});
const lineTypeEl = document.getElementById('config__lineType');
const lineWidthEl = document.getElementById('config__lineWidth');
if (lineTypeEl) indicator.params._lineType = lineTypeEl.value;
if (lineWidthEl) indicator.params._lineWidth = parseInt(lineWidthEl.value);
meta.inputs.forEach(input => {
const el = document.getElementById(`config_${input.name}`);
if (el) {
indicator.params[input.name] = input.type === 'select' ? el.value : parseFloat(el.value);
}
});
renderIndicatorList();
drawIndicatorsOnChart();
}
export function removeIndicator() {
if (!configuringId) return;
removeIndicatorById(configuringId);
}
export function removeIndicatorById(id) {
const idx = activeIndicators.findIndex(a => a.id === id);
if (idx < 0) return;
activeIndicators[idx].series?.forEach(s => {
try { window.dashboard?.chart?.removeSeries(s); } catch(e) {}
});
activeIndicators.splice(idx, 1);
if (configuringId === id) {
configuringId = null;
}
renderIndicatorList();
drawIndicatorsOnChart();
}
export function removeIndicatorByIndex(index) {
if (index < 0 || index >= activeIndicators.length) return;
removeIndicatorById(activeIndicators[index].id);
}
let indicatorPanes = new Map();
let nextPaneIndex = 1;
export function drawIndicatorsOnChart() {
if (!window.dashboard || !window.dashboard.chart) return;
activeIndicators.forEach(ind => {
ind.series?.forEach(s => {
try { window.dashboard.chart.removeSeries(s); } catch(e) {}
});
});
const candles = window.dashboard.allData.get(window.dashboard.currentInterval);
if (!candles || candles.length === 0) return;
const lineStyleMap = { 'solid': LightweightCharts.LineStyle.Solid, 'dotted': LightweightCharts.LineStyle.Dotted, 'dashed': LightweightCharts.LineStyle.Dashed };
indicatorPanes.clear();
nextPaneIndex = 1;
const overlayIndicators = [];
const paneIndicators = [];
activeIndicators.forEach(ind => {
const IndicatorClass = IR?.[ind.type];
if (!IndicatorClass) return;
const instance = new IndicatorClass({ type: ind.type, params: ind.params, name: ind.name });
const meta = instance.getMetadata();
if (meta.displayMode === 'pane') {
paneIndicators.push({ indicator: ind, meta, instance });
} else {
overlayIndicators.push({ indicator: ind, meta, instance });
}
});
const totalPanes = 1 + paneIndicators.length;
const mainPaneHeight = paneIndicators.length > 0 ? 60 : 100;
const paneHeight = paneIndicators.length > 0 ? Math.floor(40 / paneIndicators.length) : 0;
window.dashboard.chart.panes()[0]?.setHeight(mainPaneHeight);
overlayIndicators.forEach(({ indicator, meta, instance }) => {
if (indicator.visible === false) {
indicator.series = [];
return;
}
renderIndicatorOnPane(indicator, meta, instance, candles, 0, lineStyleMap);
});
paneIndicators.forEach(({ indicator, meta, instance }, idx) => {
if (indicator.visible === false) {
indicator.series = [];
return;
}
const paneIndex = nextPaneIndex++;
indicatorPanes.set(indicator.id, paneIndex);
renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap);
const pane = window.dashboard.chart.panes()[paneIndex];
if (pane) {
pane.setHeight(paneHeight);
}
});
updateChartLegend();
}
function renderIndicatorOnPane(indicator, meta, instance, candles, paneIndex, lineStyleMap) {
let results = instance.calculate(candles);
if (!results || !Array.isArray(results)) {
console.error(`[renderIndicatorOnPane] ${indicator.name}: Failed to get valid results (got ${typeof results})`);
return;
}
indicator.series = [];
const lineStyle = lineStyleMap[indicator.params._lineType] || LightweightCharts.LineStyle.Solid;
const lineWidth = indicator.params._lineWidth || 1;
const firstNonNull = Array.isArray(results) ? results.find(r => r !== null && r !== undefined) : null;
const isObjectResult = firstNonNull && typeof firstNonNull === 'object';
meta.plots.forEach((plot, plotIdx) => {
if (isObjectResult) {
// Find if this specific plot has any non-null data across all results
const hasData = results.some(r => r && r[plot.id] !== undefined && r[plot.id] !== null);
if (!hasData) return;
}
// Skip hidden plots
if (plot.visible === false) return;
const plotColor = indicator.params[`_color_${plotIdx}`] || plot.color || '#2962ff';
const data = [];
for (let i = 0; i < candles.length; i++) {
let value;
if (isObjectResult) {
value = results[i]?.[plot.id];
} else {
value = results[i];
}
if (value !== null && value !== undefined) {
data.push({
time: candles[i].time,
value: value
});
}
}
if (data.length === 0) return;
let series;
// Determine line style for this specific plot
let plotLineStyle = lineStyle;
if (plot.style === 'dashed') plotLineStyle = LightweightCharts.LineStyle.Dashed;
else if (plot.style === 'dotted') plotLineStyle = LightweightCharts.LineStyle.Dotted;
else if (plot.style === 'solid') plotLineStyle = LightweightCharts.LineStyle.Solid;
if (plot.type === 'histogram') {
series = window.dashboard.chart.addSeries(LightweightCharts.HistogramSeries, {
color: plotColor,
priceFormat: {
type: 'price',
precision: 4,
minMove: 0.0001
},
priceLineVisible: false,
lastValueVisible: false
}, paneIndex);
} else if (plot.type === 'baseline') {
series = window.dashboard.chart.addSeries(LightweightCharts.BaselineSeries, {
baseValue: { type: 'price', price: plot.baseValue || 0 },
topLineColor: plot.topLineColor || plotColor,
topFillColor1: plot.topFillColor1 || plotColor,
topFillColor2: plot.topFillColor2 || '#00000000',
bottomFillColor1: plot.bottomFillColor1 || '#00000000',
bottomColor: plot.bottomColor || '#00000000',
lineWidth: plot.width !== undefined ? plot.width : lineWidth,
lineStyle: plotLineStyle,
title: plot.title || '',
priceLineVisible: false,
lastValueVisible: plot.lastValueVisible !== false
}, paneIndex);
} else {
series = window.dashboard.chart.addSeries(LightweightCharts.LineSeries, {
color: plotColor,
lineWidth: plot.width !== undefined ? plot.width : lineWidth,
lineStyle: plotLineStyle,
title: plot.title || '',
priceLineVisible: false,
lastValueVisible: plot.lastValueVisible !== false
}, paneIndex);
}
series.setData(data);
series.plotId = plot.id;
// Skip hidden plots (visible: false)
if (plot.visible === false) {
series.applyOptions({ visible: false });
}
indicator.series.push(series);
});
// Render gradient zones if available
if (meta.gradientZones && indicator.series.length > 0) {
// Find the main series to attach zones to
let baseSeries = indicator.series[0];
meta.gradientZones.forEach(zone => {
if (zone.from === undefined || zone.to === undefined) return;
// We use createPriceLine on the series for horizontal bands with custom colors
baseSeries.createPriceLine({
price: zone.from,
color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'),
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Solid,
axisLabelVisible: false,
title: zone.label || '',
});
if (zone.to !== zone.from) {
baseSeries.createPriceLine({
price: zone.to,
color: zone.color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[^)]+\)/, 'rgb($1, $2, $3)'),
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Solid,
axisLabelVisible: false,
title: '',
});
}
});
}
}
/** Update the TradingView-style legend overlay on the chart */
export function updateChartLegend() {
let legend = document.getElementById('chartIndicatorLegend');
if (!legend) {
const chartWrapper = document.getElementById('chartWrapper');
if (!chartWrapper) return;
legend = document.createElement('div');
legend.id = 'chartIndicatorLegend';
legend.className = 'chart-indicator-legend';
chartWrapper.appendChild(legend);
}
if (activeIndicators.length === 0) {
legend.innerHTML = '';
legend.style.display = 'none';
return;
}
legend.style.display = 'flex';
legend.innerHTML = activeIndicators.map(ind => {
const label = getIndicatorLabel(ind);
const plotGroups = groupPlotsByColor(ind.plots || []);
const firstColor = ind.params['_color_0'] || '#2962ff';
const dimmed = ind.visible === false;
return `
×
`;
}).join('');
// Bind legend events
legend.querySelectorAll('.legend-item').forEach(el => {
el.addEventListener('click', (e) => {
if (e.target.classList.contains('legend-close')) return;
selectIndicatorConfig(el.dataset.id);
renderIndicatorList();
});
});
legend.querySelectorAll('.legend-close').forEach(el => {
el.addEventListener('click', (e) => {
e.stopPropagation();
removeIndicatorById(el.dataset.id);
});
});
}
// Legacy compat: toggleIndicator still works for external callers
export function toggleIndicator(type) {
addIndicator(type);
}
export function showIndicatorConfig(index) {
if (index >= 0 && index < activeIndicators.length) {
selectIndicatorConfig(activeIndicators[index].id);
}
}
export function showIndicatorConfigByType(type) {
const ind = activeIndicators.find(a => a.type === type);
if (ind) {
selectIndicatorConfig(ind.id);
}
}
window.addIndicator = addIndicator;
window.toggleIndicator = toggleIndicator;
window.showIndicatorConfig = showIndicatorConfig;
window.applyIndicatorConfig = applyIndicatorConfig;
window.removeIndicator = removeIndicator;
window.removeIndicatorById = removeIndicatorById;
window.removeIndicatorByIndex = removeIndicatorByIndex;
window.drawIndicatorsOnChart = drawIndicatorsOnChart;