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: 0, minMove: 1 }, 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;