← Назад// ==========================================
// Mini-Charts v3 — Full Market Screener
// Uses IntersectionObserver to only render visible charts
// IndexedDB persistent cache for instant chart loading
// ==========================================
// ---- IndexedDB Klines Cache ----
const IDB = {
db: null,
DB_NAME: 'FuturesScreenerKlines',
STORE: 'klines',
VERSION: 1,
async open() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.DB_NAME, this.VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.STORE)) {
db.createObjectStore(this.STORE); // key = "BTCUSDT:5m"
}
};
req.onsuccess = (e) => { this.db = e.target.result; resolve(this.db); };
req.onerror = () => { console.warn('[IDB] Open failed'); resolve(null); };
});
},
async get(sym, tf) {
try {
const db = await this.open();
if (!db) return null;
return new Promise((resolve) => {
const tx = db.transaction(this.STORE, 'readonly');
const req = tx.objectStore(this.STORE).get(`${sym}:${tf}`);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
} catch(e) { return null; }
},
async put(sym, tf, candles) {
try {
const db = await this.open();
if (!db || !candles || candles.length === 0) return;
const tx = db.transaction(this.STORE, 'readwrite');
tx.objectStore(this.STORE).put({
candles,
lastTime: candles[candles.length - 1].time,
count: candles.length,
updated: Date.now()
}, `${sym}:${tf}`);
} catch(e) { /* quota exceeded or other — ignore */ }
},
async clearTF(tf) {
try {
const db = await this.open();
if (!db) return;
const tx = db.transaction(this.STORE, 'readwrite');
const store = tx.objectStore(this.STORE);
const req = store.openCursor();
req.onsuccess = (e) => {
const cursor = e.target.result;
if (!cursor) return;
if (cursor.key.endsWith(`:${tf}`)) cursor.delete();
cursor.continue();
};
} catch(e) { /* ignore */ }
}
};
// Pre-open IndexedDB
IDB.open();
const FLAG_COLORS = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#ec4899'];
const mc = {
sortBy: 'change',
sortDir: 'asc',
globalTF: '5m',
loaded: false,
allPairs: [], // all fetched pairs (unfiltered)
filteredPairs: [], // after filters applied
charts: {}, // { sym: { chart, series, lines[] } } — only visible chart instances
dataCache: {}, // { sym: { candles, volume, tf } } — persists across scroll (never deleted until TF change)
loadedData: {}, // { sym: true } — tracks which symbols have been loaded
observer: null, // IntersectionObserver
loadQueue: [], // queue for staggered loading
loadingActive: false,
filters: { minVol: 50, minNatr: 0, minTrades: 0 },
searchQuery: '', // coin search filter
flags: {}, // { sym: '#color' } — color flags, persisted in localStorage
ws: null, // Binance kline WebSocket
wsStreams: new Set(), // currently subscribed streams
wsPending: new Set(), // streams waiting to subscribe
srData: {}, // { sym: { type, pct, price, touches } } — nearest S/R distance
tlData: {}, // { sym: { type, pct, price, touches } } — nearest auto-trendline distance
};
// Load flags from localStorage
try { mc.flags = JSON.parse(localStorage.getItem('mc_flags') || '{}'); } catch(e) { mc.flags = {}; }
// --- Settings helpers ---
const _sp = () => typeof settingsPanel !== 'undefined' ? settingsPanel : null
function spGet(key, fallback) { const sp = _sp(); return sp ? sp.get(key) : fallback }
function getGridOpts() {
const show = spGet('showGrid', true)
const c = show ? 'rgba(255,255,255,0.03)' : 'transparent'
return { vertLines: { color: c }, horzLines: { color: c } }
}
function getVolScaleTop() { return 1 - (spGet('volumeHeight', 15) / 100) }
function getPriceScaleMode() { return spGet('logScale', false) ? 1 : 0 }
function addMainSeries(chart, prec, minMove) {
const type = spGet('candleType', 'Candlestick')
const up = spGet('candleUp', '#22c55e')
const down = spGet('candleDown', '#ef4444')
const pf = { type: 'price', precision: prec, minMove }
if (type === 'Line') return chart.addSeries(LightweightCharts.LineSeries, { color: up, lineWidth: 2, priceFormat: pf })
if (type === 'Area') return chart.addSeries(LightweightCharts.AreaSeries, { topColor: up + '66', bottomColor: up + '0d', lineColor: up, lineWidth: 2, priceFormat: pf })
if (type === 'Bar') return chart.addSeries(LightweightCharts.BarSeries, { upColor: up, downColor: down, priceFormat: pf })
return chart.addSeries(LightweightCharts.CandlestickSeries, { upColor: up, downColor: down, borderVisible: false, wickUpColor: up, wickDownColor: down, priceFormat: pf })
}
function saveFlags() {
lsSet('mc_flags', JSON.stringify(mc.flags));
}
// --- Sidebar & Grid settings ---
const CARD_HEIGHTS = { compact: 200, normal: 270, large: 360 }
function applyGridColumns(n) {
const grid = document.querySelector('.mc-grid')
if (grid) grid.style.gridTemplateColumns = `repeat(${n}, 1fr)`
}
function applyCardSize(size) {
const grid = document.querySelector('.mc-grid')
if (grid) grid.style.gridAutoRows = (CARD_HEIGHTS[size] || 270) + 'px'
// Resize existing charts
Object.values(mc.charts).forEach(c => { try { c.chart.resize() } catch(e) {} })
setTimeout(() => Object.values(mc.charts).forEach(c => { try { c.chart.applyOptions({ autoSize: true }) } catch(e) {} }), 100)
}
function applySidebarColumns() {
const showChg = spGet('colChg', true)
const showNatr = spGet('colNatr', true)
const showVol = spGet('colVol', true)
// Build grid-template-columns: flag(20px) name(1fr) ★(18px) copy(16px) [chg 50px] [natr 34px] [vol 40px]
let cols = '20px 1fr 18px 16px'
if (showChg) cols += ' 50px'
if (showNatr) cols += ' 34px'
if (showVol) cols += ' 40px'
// Apply to header
const hdr = document.getElementById('mcColHeaders')
if (hdr) {
hdr.style.gridTemplateColumns = cols
// Show/hide header spans
const spans = hdr.querySelectorAll('.mc-col-hdr')
spans.forEach(s => {
if (s.dataset.sort === 'change') s.style.display = showChg ? '' : 'none'
if (s.dataset.sort === 'natr') s.style.display = showNatr ? '' : 'none'
if (s.dataset.sort === 'volume') s.style.display = showVol ? '' : 'none'
})
}
// Apply to all coin items
document.querySelectorAll('.mc-coin-item').forEach(item => {
item.style.gridTemplateColumns = cols
const chgEl = item.querySelector('.mc-coin-change')
const natrEl = item.querySelector('.mc-coin-natr')
const volEl = item.querySelector('.mc-coin-vol')
if (chgEl) chgEl.style.display = showChg ? '' : 'none'
if (natrEl) natrEl.style.display = showNatr ? '' : 'none'
if (volEl) volEl.style.display = showVol ? '' : 'none'
})
}
function applyAllLayoutSettings() {
applyGridColumns(spGet('cardsPerRow', 4))
applyCardSize(spGet('cardSize', 'normal'))
applySidebarColumns()
}
// Rebuild all visible charts (called when chart settings change)
function rebuildAllCharts() {
// Destroy all mini-chart instances, remember which were loaded
const syms = Object.keys(mc.charts)
syms.forEach(sym => {
try { mc.charts[sym].chart.remove() } catch(e) {}
})
mc.charts = {}
mc.loadedData = {}
// Re-create them
syms.forEach(sym => {
createChartInstance(sym)
mc.loadQueue.push(sym)
})
processLoadQueue()
// Rebuild modal if open
if (modal.chart && modal.currentSym) {
loadModalChart(modal.currentSym, modal.currentTF)
}
// Rebuild multi-chart slots
if (typeof mch !== 'undefined' && mch.slots) {
mch.slots.forEach((slot, i) => {
if (slot.chart && slot.sym) {
try { slot.chart.remove() } catch(e) {}
slot.chart = null
slot.series = null
slot.volSeries = null
createSlotChart(i)
loadSlotChart(i)
}
})
}
showSettingsToast('Settings applied ✓')
}
function showSettingsToast(msg) {
const sp = _sp()
if (sp) sp.showToast(msg)
}
async function initMiniCharts() {
if (!mc.loaded) {
mc.loaded = true;
// Apply saved theme
const savedTheme = spGet('theme', 'dark')
if (savedTheme !== 'dark') document.body.classList.add('theme-' + savedTheme)
// Apply saved data settings
mc.sortBy = spGet('defaultSort', 'change')
mc.sortDir = spGet('defaultSortDir', 'asc')
mc.filters.minVol = spGet('minVolume', 50)
// Sync sort headers with settings
const colHeaders = document.getElementById('mcColHeaders')
if (colHeaders) {
colHeaders.querySelectorAll('.mc-col-hdr').forEach(h => {
h.classList.remove('active', 'asc', 'desc')
if (h.dataset.sort === mc.sortBy) h.classList.add('active', mc.sortDir)
})
}
// Sync volume dropdown
const volSel = el('mcFilterVol')
if (volSel) volSel.value = mc.filters.minVol
// Apply saved layout settings
applyAllLayoutSettings()
// Apply default TF from settings
const savedTF = spGet('defaultTF', '5m')
if (savedTF) mc.globalTF = savedTF
// Listen for settings changes
const sp = _sp()
if (sp) {
sp.onChange((key, val) => {
const chartKeys = ['candleType', 'logScale', 'volumeHeight', 'showGrid', 'showWatermark', 'candleUp', 'candleDown']
if (chartKeys.includes(key)) {
rebuildAllCharts()
} else if (key === 'defaultTF') {
mc.globalTF = val
wsUnsubscribeAll() // unsub old TF streams
mc.dataCache = {} // TF changed, clear cache
mc.srData = {}
mc.tlData = {}
// Update active TF button
const tfGroup = el('mcGlobalTF')
if (tfGroup) {
tfGroup.querySelectorAll('.mc-tf-btn').forEach(b => b.classList.toggle('active', b.dataset.tf === val))
}
mc.loadedData = {}
mc.loadingActive = false // reset so processLoadQueue can run
Object.keys(mc.charts).forEach(sym => mc.loadQueue.push(sym))
processLoadQueue()
showSettingsToast('Timeframe → ' + val)
} else if (key === 'densityEnabled') {
if (!val) {
// Remove density lines from all charts
Object.values(mc.charts).forEach(c => {
if (c.densityLines) { c.densityLines.forEach(pl => { try { c.series.removePriceLine(pl) } catch(e){} }); c.densityLines = [] }
})
if (modal.chart && modal.densityLines) { modal.densityLines.forEach(pl => { try { modal.series.removePriceLine(pl) } catch(e){} }); modal.densityLines = [] }
} else {
// Re-apply densities to all visible charts
const visibleSyms = Object.keys(mc.charts).filter(s => mc.charts[s] && mc.charts[s].series)
if (visibleSyms.length > 0) applyDensityToBatch(visibleSyms)
if (modal.chart && modal.currentSym) applyDensityToModal()
}
showSettingsToast(val ? 'Densities enabled' : 'Densities disabled')
} else if (['densityDepthPct', 'densityTTLMin', 'densitySeveritySmall', 'densitySeverityMedium', 'densitySeverityLarge', 'densityBlacklist'].includes(key)) {
// Re-apply densities with new filters
if (spGet('densityEnabled', true)) {
const visibleSyms = Object.keys(mc.charts).filter(s => mc.charts[s] && mc.charts[s].series)
if (visibleSyms.length > 0) applyDensityToBatch(visibleSyms)
if (modal.chart && modal.currentSym) applyDensityToModal()
}
showSettingsToast('Density filter updated')
} else if (key === 'signalMinRatio') {
showSettingsToast('Signal ratio → ' + val + 'x')
} else if (key === 'cardsPerRow') {
applyGridColumns(val)
showSettingsToast('Cards per row → ' + val)
} else if (key === 'cardSize') {
applyCardSize(val)
showSettingsToast('Card size → ' + val)
} else if (key === 'colChg' || key === 'colNatr' || key === 'colVol') {
applySidebarColumns()
renderSidebar()
showSettingsToast('Sidebar updated')
} else if (key === 'defaultSort') {
mc.sortBy = val
renderSidebar()
showSettingsToast('Sort → ' + val)
} else if (key === 'defaultSortDir') {
mc.sortDir = val
renderSidebar()
showSettingsToast('Sort direction → ' + val)
} else if (key === 'minVolume') {
mc.filters.minVol = val
// Sync the toolbar dropdown if exists
const volSel = el('mcFilterVol')
if (volSel) volSel.value = val
applyFiltersAndRebuild()
showSettingsToast(val > 0 ? 'Min volume → $' + val + 'M' : 'Volume filter OFF')
} else if (key === 'layout') {
// Map settings layout to multi-chart layout
if (val === '1') {
switchLayout('grid')
} else {
switchLayout(val)
}
showSettingsToast('Layout → ' + val)
} else if (key === 'theme') {
document.body.className = document.body.className.replace(/theme-\w+/g, '')
if (val !== 'dark') document.body.classList.add('theme-' + val)
showSettingsToast('Theme → ' + val)
} else if (key === 'indicatorOI' || key === 'indicatorOIColor') {
// Modal
if (modal.chart && modal.currentSym) applyOIOverlay(modal.chart, modal.currentSym)
// Mini-charts
const visSyms = Object.keys(mc.charts).filter(s => mc.charts[s] && mc.charts[s].chart)
if (visSyms.length > 0) applyOIToBatch(visSyms)
// Multi-chart slots
if (typeof mch !== 'undefined') mch.slots.forEach((slot, i) => { if (slot.chart && slot.sym) applyOI(slot, slot.sym, slot.tf) })
showSettingsToast(key === 'indicatorOI' ? (val ? 'OI indicator ON' : 'OI indicator OFF') : 'OI color updated')
} else if (key === 'watchlistOnly') {
renderSidebar()
} else if (key === '__watchlist') {
renderSidebar()
}
})
}
// Global TF buttons — sync active with settings default
const tfGroup = el('mcGlobalTF');
if (tfGroup) {
tfGroup.querySelectorAll('.mc-tf-btn').forEach(b => b.classList.toggle('active', b.dataset.tf === mc.globalTF));
tfGroup.addEventListener('click', (e) => {
const btn = e.target.closest('.mc-tf-btn');
if (!btn) return;
tfGroup.querySelectorAll('.mc-tf-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
mc.globalTF = btn.dataset.tf;
console.log('[TF-SWITCH] Switching to', mc.globalTF, 'charts:', Object.keys(mc.charts).length, 'wsStreams:', mc.wsStreams.size);
// Cancel any pending destroy timers before TF switch
Object.keys(mc.charts).forEach(s => {
if (mc.charts[s]?._destroyTimer) { clearTimeout(mc.charts[s]._destroyTimer); mc.charts[s]._destroyTimer = null; }
});
// Unsubscribe old TF streams — critical! Otherwise old TF data
// keeps arriving and new TF streams may not subscribe properly
wsUnsubscribeAll();
// Clear data cache — TF changed, all cached candles are stale
mc.dataCache = {};
mc.srData = {};
mc.tlData = {};
// Reload all currently visible charts with new TF
mc.loadedData = {};
mc.loadingActive = false; // reset so processLoadQueue can run
Object.keys(mc.charts).forEach(sym => {
mc.loadQueue.push(sym);
});
processLoadQueue();
// Refresh NATR for new TF
fetchServerNATR(mc.globalTF);
});
}
// Sort by column headers in sidebar
const colHeaders2 = document.getElementById('mcColHeaders');
if (colHeaders2) {
colHeaders2.querySelectorAll('.mc-col-hdr').forEach(hdr => {
hdr.addEventListener('click', () => {
const key = hdr.dataset.sort;
// Toggle direction if same column, else set desc
if (mc.sortBy === key) {
mc.sortDir = mc.sortDir === 'desc' ? 'asc' : 'desc';
} else {
mc.sortBy = key;
mc.sortDir = 'desc';
}
// Update header classes
colHeaders2.querySelectorAll('.mc-col-hdr').forEach(h => h.classList.remove('active', 'asc', 'desc'));
hdr.classList.add('active', mc.sortDir);
// Clear toolbar sort buttons
['mcSortSR', 'mcSortTL', 'mcSortSig'].forEach(id => { const b = el(id); if (b) b.classList.remove('active'); });
applyFiltersAndRebuild();
});
});
}
// Filters
['mcFilterVol', 'mcFilterNatr', 'mcFilterTrades'].forEach(id => {
const sel = el(id);
if (sel) {
sel.addEventListener('change', () => {
mc.filters.minVol = parseFloat(el('mcFilterVol').value);
mc.filters.minNatr = parseFloat(el('mcFilterNatr').value);
mc.filters.minTrades = parseFloat(el('mcFilterTrades').value);
applyFiltersAndRebuild();
});
}
});
// Refresh button
const refreshBtn = el('mcRefreshBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', () => refreshMiniCharts());
}
// Density toggle checkbox
const densityToggle = el('mcDensityToggle');
if (densityToggle) {
densityToggle.checked = spGet('densityEnabled', true);
densityToggle.addEventListener('change', () => {
const enabled = densityToggle.checked;
const sp = _sp();
if (sp) sp.set('densityEnabled', enabled);
if (!enabled) {
// Remove density lines from all mini-charts
Object.values(mc.charts).forEach(c => {
if (c.densityLines) { c.densityLines.forEach(pl => { try { c.series.removePriceLine(pl) } catch(e){} }); c.densityLines = [] }
});
// Remove from modal
if (modal.densityLines) { modal.densityLines.forEach(pl => { try { modal.series.removePriceLine(pl) } catch(e){} }); modal.densityLines = [] }
} else {
// Re-apply densities
const visibleSyms = Object.keys(mc.charts).filter(s => mc.charts[s].series);
if (visibleSyms.length > 0) applyDensityToBatch(visibleSyms);
if (modal.chart && modal.currentSym) applyDensityToModal();
}
});
}
// Levels toggle checkbox
const levelsToggle = el('mcLevelsToggle');
if (levelsToggle) {
levelsToggle.checked = spGet('levelsEnabled', true);
levelsToggle.addEventListener('change', () => {
const enabled = levelsToggle.checked;
const sp = _sp();
if (sp) sp.set('levelsEnabled', enabled);
if (!enabled) {
// Remove auto levels from all mini-charts
Object.keys(mc.charts).forEach(sym => removeAutoLevels(sym));
// Remove from modal
removeModalAutoLevels();
} else {
// Re-apply levels
Object.keys(mc.charts).forEach(sym => applyAutoLevels(sym));
if (modal.chart && modal.currentSym) applyModalAutoLevels();
}
});
}
// Sort S/R button
const sortSRBtn = el('mcSortSR');
if (sortSRBtn) {
sortSRBtn.addEventListener('click', () => {
if (mc.sortBy === 'sr') {
mc.sortBy = 'change';
mc.sortDir = 'desc';
sortSRBtn.classList.remove('active');
} else {
mc.sortBy = 'sr';
mc.sortDir = 'desc';
sortSRBtn.classList.add('active');
}
// Clear other sort buttons
const sortTL = el('mcSortTL');
if (sortTL) sortTL.classList.remove('active');
const sortSig = el('mcSortSig');
if (sortSig) sortSig.classList.remove('active');
const colHeaders = document.getElementById('mcColHeaders');
if (colHeaders) colHeaders.querySelectorAll('.mc-col-hdr').forEach(h => h.classList.remove('active', 'asc', 'desc'));
if (mc.sortBy !== 'sr') {
const chgHdr = colHeaders?.querySelector('[data-sort="change"]');
if (chgHdr) chgHdr.classList.add('active', 'desc');
}
applyFiltersAndRebuild();
});
}
// Trendlines toggle checkbox
const trendlinesToggle = el('mcTrendlinesToggle');
if (trendlinesToggle) {
trendlinesToggle.checked = spGet('trendlinesEnabled', false);
trendlinesToggle.addEventListener('change', () => {
const enabled = trendlinesToggle.checked;
const sp = _sp();
if (sp) sp.set('trendlinesEnabled', enabled);
if (!enabled) {
Object.keys(mc.charts).forEach(sym => removeAutoTrendlines(sym));
removeModalAutoTrendlines();
} else {
Object.keys(mc.charts).forEach(sym => applyAutoTrendlines(sym));
if (modal.chart && modal.currentSym) applyModalAutoTrendlines();
}
});
}
// Channels dropdown (multi-select per channel type)
const chBtn = el('mcChannelsBtn');
const chDrop = el('mcChannelsDrop');
if (chBtn && chDrop) {
chBtn.addEventListener('click', (e) => {
e.stopPropagation();
chDrop.classList.toggle('open');
});
document.addEventListener('click', () => chDrop.classList.remove('open'));
chDrop.addEventListener('click', (e) => e.stopPropagation());
// Channel type map: key → { apply, remove, applyModal, removeModal }
const chTypes = {
keltner: { apply: applyKeltnerChannel, remove: removeKeltnerChannel, applyModal: applyModalKeltnerChannel, removeModal: removeModalKeltnerChannel },
regression: { apply: applyRegressionChannel, remove: removeRegressionChannel, applyModal: applyModalRegressionChannel, removeModal: removeModalRegressionChannel },
};
const chBoxes = chDrop.querySelectorAll('input[data-ch]');
chBoxes.forEach(cb => {
const chKey = cb.dataset.ch;
cb.checked = spGet('ch_' + chKey, false);
cb.addEventListener('change', () => {
const sp = _sp();
if (sp) sp.set('ch_' + chKey, cb.checked);
const fns = chTypes[chKey];
if (!fns) return;
if (cb.checked) {
Object.keys(mc.charts).forEach(sym => fns.apply(sym));
if (modal.chart && modal.currentSym) fns.applyModal();
} else {
Object.keys(mc.charts).forEach(sym => fns.remove(sym));
fns.removeModal();
}
// Update button glow
const anyActive = [...chBoxes].some(c => c.checked);
chBtn.classList.toggle('has-active', anyActive);
});
});
// Init button glow
const anyActive = [...chBoxes].some(c => c.checked);
chBtn.classList.toggle('has-active', anyActive);
}
// Sort by trendline proximity button
const sortTLBtn = el('mcSortTL');
if (sortTLBtn) {
sortTLBtn.addEventListener('click', () => {
if (mc.sortBy === 'tl') {
mc.sortBy = 'change';
mc.sortDir = 'desc';
sortTLBtn.classList.remove('active');
} else {
mc.sortBy = 'tl';
mc.sortDir = 'desc';
sortTLBtn.classList.add('active');
}
// Clear col header highlights + other sort buttons
const colHeaders = document.getElementById('mcColHeaders');
if (colHeaders) colHeaders.querySelectorAll('.mc-col-hdr').forEach(h => h.classList.remove('active', 'asc', 'desc'));
const sortSR = el('mcSortSR');
if (sortSR) sortSR.classList.remove('active');
const sortSig2 = el('mcSortSig');
if (sortSig2) sortSig2.classList.remove('active');
if (mc.sortBy !== 'tl') {
const chgHdr = colHeaders?.querySelector('[data-sort="change"]');
if (chgHdr) chgHdr.classList.add('active', 'desc');
}
applyFiltersAndRebuild();
});
}
// Sort by latest signal button
const sortSigBtn = el('mcSortSig');
if (sortSigBtn) {
sortSigBtn.addEventListener('click', () => {
if (mc.sortBy === 'signals') {
mc.sortBy = 'change';
mc.sortDir = 'desc';
sortSigBtn.classList.remove('active');
} else {
mc.sortBy = 'signals';
mc.sortDir = 'desc';
sortSigBtn.classList.add('active');
}
// Clear col header highlights + other sort buttons
const colHeaders = document.getElementById('mcColHeaders');
if (colHeaders) colHeaders.querySelectorAll('.mc-col-hdr').forEach(h => h.classList.remove('active', 'asc', 'desc'));
const sortSR = el('mcSortSR');
if (sortSR) sortSR.classList.remove('active');
const sortTL = el('mcSortTL');
if (sortTL) sortTL.classList.remove('active');
if (mc.sortBy !== 'signals') {
const chgHdr = colHeaders?.querySelector('[data-sort="change"]');
if (chgHdr) chgHdr.classList.add('active', 'desc');
}
applyFiltersAndRebuild();
});
}
// Mobile sidebar toggle (hamburger)
const sidebarToggle = el('mcSidebarToggle');
const sidebarOverlay = el('mcSidebarOverlay');
const sidebar = el('mcSidebar');
if (sidebarToggle && sidebar) {
const openSidebar = () => {
sidebar.classList.add('mobile-open');
if (sidebarOverlay) sidebarOverlay.classList.add('active');
};
const closeSidebar = () => {
sidebar.classList.remove('mobile-open');
if (sidebarOverlay) sidebarOverlay.classList.remove('active');
};
sidebarToggle.addEventListener('click', () => {
sidebar.classList.contains('mobile-open') ? closeSidebar() : openSidebar();
});
if (sidebarOverlay) {
sidebarOverlay.addEventListener('click', closeSidebar);
}
// Close sidebar when coin is clicked (mobile)
sidebar.addEventListener('click', (e) => {
if (e.target.closest('.mc-coin-item') && window.innerWidth <= 768) {
closeSidebar();
}
});
}
// Search input (debounced 300ms)
const searchInput = el('mcSearchInput');
if (searchInput) {
let _searchTimer = null;
searchInput.addEventListener('input', (e) => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
mc.searchQuery = e.target.value.trim().toUpperCase();
renderSidebar();
}, 300);
});
}
// Close flag popups on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('.mc-flag-btn') && !e.target.closest('.mc-flag-popup')) {
document.querySelectorAll('.mc-flag-popup').forEach(p => p.remove());
}
});
// Init modal events
initModalEvents();
// Init layout picker (multi-chart)
initLayoutPicker();
// Setup IntersectionObserver with debounced batch loading
mc._queueFlushTimer = null;
mc.observer = new IntersectionObserver((entries) => {
let added = 0;
entries.forEach(entry => {
const sym = entry.target.dataset.symbol;
if (!sym) return;
if (entry.isIntersecting) {
// Card scrolled back into view — cancel pending destroy
if (mc.charts[sym] && mc.charts[sym]._destroyTimer) {
clearTimeout(mc.charts[sym]._destroyTimer);
mc.charts[sym]._destroyTimer = null;
}
// Card scrolled into view — create chart & queue for batch load
if (!mc.charts[sym]) {
createChartInstance(sym);
mc.loadQueue.push(sym);
added++;
}
} else {
// Card scrolled out — delay destruction (avoids jerk on scroll-back)
if (mc.charts[sym] && !mc.charts[sym]._destroyTimer) {
mc.charts[sym]._destroyTimer = setTimeout(() => {
if (!mc.charts[sym]) return;
// Save candle data to cache before destroying chart
if (mc.charts[sym].candleData && mc.charts[sym].candleData.length > 0) {
mc.dataCache[sym] = {
candles: mc.charts[sym].candleData,
volume: extractVolume(mc.charts[sym].candleData),
tf: mc.globalTF
};
}
if (mc.charts[sym]._infiniteUnsub) {
mc.charts[sym]._infiniteUnsub();
}
mc.charts[sym].chart.remove();
delete mc.charts[sym];
delete mc.loadedData[sym];
// Unsub this symbol's stream (debounced)
const stream = `${sym.toLowerCase()}@kline_${mc.globalTF}`;
mc.wsStreams.delete(stream);
_wsPendingSub.delete(stream);
if (!mc._wsUnsubPending) mc._wsUnsubPending = new Set();
mc._wsUnsubPending.add(stream);
clearTimeout(mc._wsUnsubTimer);
mc._wsUnsubTimer = setTimeout(() => {
if (mc._wsUnsubPending.size > 0 && mc.ws && mc.ws.readyState === WebSocket.OPEN) {
mc.ws.send(JSON.stringify({ method: 'UNSUBSCRIBE', params: [...mc._wsUnsubPending], id: Date.now() }));
}
mc._wsUnsubPending.clear();
}, 200);
}, 5000); // 5s grace period before destroy
}
}
});
// Debounce: wait 50ms for all visible cards to register, then flush as one big batch
if (added > 0) {
clearTimeout(mc._queueFlushTimer);
mc._queueFlushTimer = setTimeout(() => processLoadQueue(), 50);
}
}, {
root: null,
rootMargin: '200px', // preload 200px before visible
threshold: 0
});
}
if (mc.allPairs.length === 0) {
await refreshMiniCharts();
}
}
async function refreshMiniCharts() {
const status = el('mcStatus');
if (status) status.textContent = 'Loading...';
try {
const res = await fetch('/api/ticker24hr');
const data = await res.json();
let pairs = data.filter(d => d.symbol.endsWith('USDT') && !d.symbol.includes('_'));
pairs.forEach(p => {
const h = parseFloat(p.highPrice);
const l = parseFloat(p.lowPrice);
p.proxyNatr = l > 0 ? ((h - l) / l * 100) : 0;
p.quoteVol = parseFloat(p.quoteVolume);
p.tradesCount = parseInt(p.count);
p.priceChange = parseFloat(p.priceChangePercent);
p.lastPrice = parseFloat(p.lastPrice);
});
// Filter out frozen/halted/delisted pairs
// Frozen pairs have closeTime far in the past (trading stopped)
const now = Date.now();
pairs = pairs.filter(p => {
const closeTime = parseInt(p.closeTime);
const age = now - closeTime;
// If last trade was >1 hour ago, pair is frozen
if (age > 3600000) return false;
// Also filter flat pairs where high == low
if (parseFloat(p.highPrice) === parseFloat(p.lowPrice)) return false;
return true;
});
mc.allPairs = pairs;
applyFiltersAndRebuild();
if (status) {
status.textContent = `${mc.filteredPairs.length}/${pairs.length}`;
}
// Fetch real NATR from server (background)
fetchServerNATR(mc.globalTF);
// Prefetch top-3 klines (BTC/ETH/SOL) — warm server cache for instant modal open
prefetchTopKlines(mc.globalTF);
} catch (e) {
console.error('Mini-Charts fetch error:', e);
if (status) status.textContent = 'Error';
}
}
async function fetchServerNATR(tf) {
try {
const res = await fetch(`/api/natr?interval=${tf}`);
const natrMap = await res.json();
if (!natrMap || typeof natrMap !== 'object') return;
// Update all pairs with real NATR
mc.allPairs.forEach(p => {
if (natrMap[p.symbol] !== undefined) {
p.proxyNatr = natrMap[p.symbol];
}
});
// Update displayed values on visible chart cards
Object.keys(natrMap).forEach(sym => {
const card = document.getElementById(`mc-card-${sym}`);
if (card) {
const span = card.querySelector('.mc-natr');
if (span) span.textContent = natrMap[sym].toFixed(1) + '%';
}
});
// Update sidebar NATR values
const sidebarItems = document.querySelectorAll('.mc-coin-item');
sidebarItems.forEach(item => {
const sym = item.dataset.symbol;
if (sym && natrMap[sym] !== undefined) {
const natrSpan = item.querySelector('.mc-coin-natr');
if (natrSpan) natrSpan.textContent = natrMap[sym].toFixed(1);
}
});
// Re-sort if sorting by NATR
if (mc.sortBy === 'natr') {
sortPairs();
renderSidebar();
// Reorder cards in DOM without destroying charts
const grid = el('chartsGrid');
if (grid) {
mc.filteredPairs.forEach(p => {
const card = document.getElementById(`mc-card-${p.symbol}`);
if (card) grid.appendChild(card);
});
}
}
} catch(e) {
console.error('NATR fetch error:', e);
}
}
const PREFETCH_SYMS = ['BTCUSDT', 'ETHUSDT', 'SOLUSDT'];
function prefetchTopKlines(tf) {
// Fire-and-forget: warm server klines cache for top coins so modal opens instantly
PREFETCH_SYMS.forEach(sym => {
fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=1000`)
.then(r => r.ok && console.log(`[prefetch] ${sym} ${tf} warmed`))
.catch(() => {}); // silent — prefetch is best-effort
});
}
function applyFiltersAndRebuild() {
mc.filteredPairs = mc.allPairs.filter(p => {
if (mc.filters.minVol > 0 && p.quoteVol < mc.filters.minVol * 1e6) return false;
if (mc.filters.minNatr > 0 && p.proxyNatr < mc.filters.minNatr) return false;
if (mc.filters.minTrades > 0 && p.tradesCount < mc.filters.minTrades) return false;
return true;
});
rebuildGrid();
const status = el('mcStatus');
if (status) status.textContent = `${mc.filteredPairs.length}/${mc.allPairs.length}`;
}
function rebuildGrid() {
// Unsubscribe all WS streams
wsUnsubscribeAll();
// Destroy all existing charts (cancel pending destroy timers first)
Object.keys(mc.charts).forEach(sym => {
if (mc.charts[sym]._destroyTimer) clearTimeout(mc.charts[sym]._destroyTimer);
mc.charts[sym].chart.remove();
delete mc.charts[sym];
});
mc.loadedData = {};
mc.loadQueue = [];
sortPairs();
renderSidebar();
const grid = el('chartsGrid');
if (!grid) return;
// Disconnect old observations
mc.observer.disconnect();
// Render ALL cards (lightweight — just header + empty body)
grid.innerHTML = mc.filteredPairs.map(p => {
const sym = p.symbol;
const ticker = sym.replace('USDT', '');
const chg = p.priceChange;
const chgClass = chg >= 0 ? 'mc-metric-green' : 'mc-metric-red';
const chgSign = chg >= 0 ? '+' : '';
const vol = p.quoteVol >= 1e9 ? (p.quoteVol / 1e9).toFixed(1) + 'B' : (p.quoteVol / 1e6).toFixed(0) + 'M';
const natr = p.proxyNatr ? p.proxyNatr.toFixed(1) : '—';
const trades = p.tradesCount >= 1e6 ? (p.tradesCount / 1e6).toFixed(1) + 'M' : p.tradesCount >= 1e3 ? (p.tradesCount / 1e3).toFixed(0) + 'K' : p.tradesCount;
return `<div class="mc-chart-card" data-symbol="${sym}" id="mc-card-${sym}">
<div class="mc-chart-header">
<span class="mc-chart-symbol">${ticker}</span>
<button class="mc-copy-btn mc-copy-card" data-ticker="${sym.toLowerCase()}" title="Copy ${sym.toLowerCase()}"><svg width="10" height="10" viewBox="0 0 16 16" fill="none"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
<div class="mc-chart-metrics">
<span class="${chgClass}">${chgSign}${chg.toFixed(2)}%</span>
<span class="mc-metric-muted" title="24h Volume"><svg width="10" height="10" viewBox="0 0 10 10" style="vertical-align:-1px;margin-right:1px"><rect x="1" y="5" width="2" height="5" fill="currentColor" opacity="0.5"/><rect x="4" y="2" width="2" height="8" fill="currentColor" opacity="0.7"/><rect x="7" y="0" width="2" height="10" fill="currentColor"/></svg>${vol}</span>
<span class="mc-metric-muted" title="NATR Volatility"><svg width="10" height="10" viewBox="0 0 10 10" style="vertical-align:-1px;margin-right:1px"><path d="M0 7L3 3L5 6L7 1L10 5" stroke="currentColor" stroke-width="1.3" fill="none"/></svg><span class="mc-natr">—</span></span>
<span class="mc-metric-muted" title="24h Trades"><svg width="10" height="10" viewBox="0 0 10 10" style="vertical-align:-1px;margin-right:1px"><path d="M1 3h3M6 3h3M1 7h3M6 7h3" stroke="currentColor" stroke-width="1.2"/></svg>${trades}</span>
</div>
</div>
<div class="mc-chart-body" id="mc-body-${sym}"></div>
</div>`;
}).join('');
// Copy ticker on mini-chart cards
grid.querySelectorAll('.mc-copy-card').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(btn.dataset.ticker).then(() => {
btn.classList.add('mc-copy-ok');
setTimeout(() => btn.classList.remove('mc-copy-ok'), 800);
});
});
});
// Observe all cards + click to open modal
grid.querySelectorAll('.mc-chart-card').forEach(card => {
mc.observer.observe(card);
card.querySelector('.mc-chart-header').addEventListener('click', () => {
handleSidebarCoinClick(card.dataset.symbol);
});
});
}
/** Signal data per symbol: count + latest timestamp (for sorting by recency) */
function _getSignalData() {
if (typeof sigState === 'undefined' || !sigState.signals) return {};
const data = {};
for (const s of sigState.signals) {
if (!data[s.symbol]) {
data[s.symbol] = { count: 0, latest: 0 };
}
data[s.symbol].count++;
const ts = new Date(s.created_at.includes('T') ? s.created_at : s.created_at.replace(' ', 'T') + 'Z').getTime() || 0;
if (ts > data[s.symbol].latest) data[s.symbol].latest = ts;
}
return data;
}
function sortPairs() {
const dir = mc.sortDir === 'desc' ? 1 : -1;
const _sigD = mc.sortBy === 'signals' ? _getSignalData() : null;
const sorter = (a, b) => {
if (mc.sortBy === 'symbol') return dir * a.symbol.localeCompare(b.symbol);
if (mc.sortBy === 'natr') return dir * (b.proxyNatr - a.proxyNatr);
if (mc.sortBy === 'trades') return dir * (b.tradesCount - a.tradesCount);
if (mc.sortBy === 'change') return dir * (b.priceChange - a.priceChange);
if (mc.sortBy === 'sr') {
const sa = mc.srData[a.symbol]?.pct ?? 999;
const sb = mc.srData[b.symbol]?.pct ?? 999;
return dir * (sa - sb); // closest to S/R first when desc
}
if (mc.sortBy === 'tl') {
const ta = mc.tlData[a.symbol]?.pct ?? 999;
const tb = mc.tlData[b.symbol]?.pct ?? 999;
return dir * (ta - tb); // closest to trendline first when desc
}
if (mc.sortBy === 'signals') {
return dir * ((_sigD[b.symbol]?.latest || 0) - (_sigD[a.symbol]?.latest || 0));
}
return dir * (b.quoteVol - a.quoteVol); // volume default
};
mc.allPairs.sort(sorter);
mc.filteredPairs.sort(sorter);
}
function renderSidebar() {
const list = el('mcCoinList');
const countEl = el('mcCoinCount');
if (!list) return;
// Filter by search query
let pairs = mc.filteredPairs;
if (mc.searchQuery) {
pairs = pairs.filter(p => p.symbol.replace('USDT', '').includes(mc.searchQuery));
}
// Watchlist-only filter
const sp = _sp()
if (sp && spGet('watchlistOnly', false)) {
pairs = pairs.filter(p => sp.wlHas(p.symbol));
}
// Sort: watchlist first, then flagged, then by current sort column
const dir = mc.sortDir === 'desc' ? 1 : -1;
pairs = [...pairs].sort((a, b) => {
// Watchlist coins first
const wa = sp && sp.wlHas(a.symbol) ? 1 : 0;
const wb = sp && sp.wlHas(b.symbol) ? 1 : 0;
if (wb !== wa) return wb - wa;
const fa = mc.flags[a.symbol] ? 1 : 0;
const fb = mc.flags[b.symbol] ? 1 : 0;
if (fb !== fa) return fb - fa;
if (mc.sortBy === 'symbol') return dir * a.symbol.localeCompare(b.symbol);
if (mc.sortBy === 'natr') return dir * ((b.proxyNatr || 0) - (a.proxyNatr || 0));
if (mc.sortBy === 'change') return dir * (b.priceChange - a.priceChange);
if (mc.sortBy === 'sr') {
const sa = mc.srData[a.symbol]?.pct ?? 999;
const sb = mc.srData[b.symbol]?.pct ?? 999;
return dir * (sa - sb);
}
if (mc.sortBy === 'signals') {
const _sd = _getSignalData();
return dir * ((_sd[b.symbol]?.latest || 0) - (_sd[a.symbol]?.latest || 0));
}
return dir * (b.quoteVol - a.quoteVol);
});
if (countEl) countEl.textContent = pairs.length;
// Pre-compute signal data for sidebar display
const _sidebarSigData = _getSignalData();
list.innerHTML = pairs.map(p => {
const sym = p.symbol;
const ticker = sym.replace('USDT', '');
const chg = p.priceChange;
const chgClass = chg >= 0 ? 'mc-metric-green' : 'mc-metric-red';
const chgSign = chg >= 0 ? '+' : '';
const vol = p.quoteVol >= 1e9 ? (p.quoteVol / 1e9).toFixed(1) + 'B' : (p.quoteVol / 1e6).toFixed(0) + 'M';
const natr = p.proxyNatr ? p.proxyNatr.toFixed(1) : '—';
const flagColor = mc.flags[sym] || '';
const flagStyle = flagColor ? `background:${flagColor}; border-color:transparent;` : '';
const flagClass = flagColor ? 'mc-flag-btn flagged' : 'mc-flag-btn';
const showChg = spGet('colChg', true)
const showNatr = spGet('colNatr', true)
const showVol = spGet('colVol', true)
const isWl = sp && sp.wlHas(sym)
return `<div class="mc-coin-item${isWl ? ' mc-wl' : ''}" data-symbol="${sym}">
<button class="${flagClass}" style="${flagStyle}" data-flag="${sym}" title="Set color flag"></button>
<span class="mc-coin-name">${ticker}</span>
<button class="mc-wl-btn${isWl ? ' active' : ''}" data-wl="${sym}" title="${isWl ? 'Remove from watchlist' : 'Add to watchlist'}">★</button>
<button class="mc-copy-btn" data-ticker="${ticker.toLowerCase()}usdt" title="Copy ${ticker.toLowerCase()}usdt">
<svg width="10" height="10" viewBox="0 0 16 16" fill="none"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
</button>
${showChg ? `<span class="mc-coin-change ${chgClass}">${chgSign}${chg.toFixed(1)}%</span>` : ''}
${showNatr ? `<span class="mc-coin-natr">${natr}</span>` : ''}
${showVol ? `<span class="mc-coin-vol">${vol}</span>` : ''}
</div>`;
}).join('');
// Flag button click — show color picker
list.querySelectorAll('.mc-flag-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const sym = btn.dataset.flag;
document.querySelectorAll('.mc-flag-popup').forEach(p => p.remove());
const popup = document.createElement('div');
popup.className = 'mc-flag-popup';
popup.innerHTML = FLAG_COLORS.map(c =>
`<div class="mc-flag-color" data-color="${c}" style="background:${c};"></div>`
).join('') + `<button class="mc-flag-clear" title="Remove flag">×</button>`;
btn.style.position = 'relative';
btn.appendChild(popup);
popup.querySelectorAll('.mc-flag-color').forEach(dot => {
dot.addEventListener('click', (ev) => {
ev.stopPropagation();
mc.flags[sym] = dot.dataset.color;
saveFlags();
popup.remove();
renderSidebar();
});
});
popup.querySelector('.mc-flag-clear').addEventListener('click', (ev) => {
ev.stopPropagation();
delete mc.flags[sym];
saveFlags();
popup.remove();
renderSidebar();
});
});
});
// Watchlist ★ button
list.querySelectorAll('.mc-wl-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const sym = btn.dataset.wl;
if (sp) {
sp.wlToggle(sym);
btn.classList.toggle('active');
const item = btn.closest('.mc-coin-item');
if (item) item.classList.toggle('mc-wl');
}
});
});
// Copy ticker button
list.querySelectorAll('.mc-copy-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const ticker = btn.dataset.ticker;
navigator.clipboard.writeText(ticker).then(() => {
btn.classList.add('mc-copy-ok');
setTimeout(() => btn.classList.remove('mc-copy-ok'), 800);
});
});
});
// Click handler — open coin modal or assign to multi-chart slot
list.querySelectorAll('.mc-coin-item').forEach(item => {
item.addEventListener('click', (e) => {
if (e.target.closest('.mc-flag-btn') || e.target.closest('.mc-flag-popup') || e.target.closest('.mc-copy-btn') || e.target.closest('.mc-wl-btn')) return;
handleSidebarCoinClick(item.dataset.symbol);
});
});
// Apply column visibility from settings
applySidebarColumns()
}
function getPricePrecision(price) {
if (price >= 1000) return 1;
if (price >= 100) return 2;
if (price >= 1) return 3;
if (price >= 0.01) return 4;
if (price >= 0.001) return 5;
return 6;
}
function createChartInstance(sym) {
const chartEl = el(`mc-body-${sym}`);
if (!chartEl || mc.charts[sym]) return;
// Get price for precision
const pair = mc.allPairs.find(p => p.symbol === sym);
const price = pair ? pair.lastPrice : 1;
const prec = getPricePrecision(price);
const minMove = parseFloat((1 / Math.pow(10, prec)).toFixed(prec));
const chart = LightweightCharts.createChart(chartEl, {
autoSize: true,
...localChartOptions,
layout: { background: { type: 'solid', color: 'transparent' }, textColor: '#64748b', fontSize: 9 },
grid: getGridOpts(),
crosshair: { mode: 0 },
rightPriceScale: { borderColor: 'rgba(255,255,255,0.06)', scaleMargins: { top: 0.1, bottom: 0.1 }, minimumWidth: 32, mode: getPriceScaleMode() },
timeScale: { borderColor: 'rgba(255,255,255,0.06)', timeVisible: true, secondsVisible: false, rightOffset: 10, shiftVisibleRangeOnNewBar: true, lockVisibleTimeRangeOnResize: true, tickMarkFormatter: localTickFormatter },
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { mouseWheel: true, pinch: true },
});
const series = addMainSeries(chart, prec, minMove);
// Volume histogram
const volSeries = chart.addSeries(LightweightCharts.HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: 'vol',
color: 'rgba(100,116,139,0.3)',
});
chart.priceScale('vol').applyOptions({
scaleMargins: { top: getVolScaleTop(), bottom: 0 },
drawTicks: false,
borderVisible: false,
});
mc.charts[sym] = { chart, series, volSeries, lines: [], oiSeries: null, candleData: null };
// Drawing tools on mini-chart — switch context on interaction
chartEl.addEventListener('mousedown', () => {
if (drawCtx.source !== 'mini:' + sym) {
setDrawCtxMini(sym);
renderDrawToolbar(chartEl);
}
}, true);
chartEl.addEventListener('touchstart', () => {
if (drawCtx.source !== 'mini:' + sym) {
setDrawCtxMini(sym);
renderDrawToolbar(chartEl);
}
}, { capture: true, passive: true });
// Attach ruler + drawing handlers
attachRuler(chartEl, chart, series);
setupDrawingHandlers(chartEl);
}
// Render chart from cached data (instant, no network)
function renderFromCache(sym) {
const cache = mc.dataCache[sym];
if (!cache || !mc.charts[sym]) return false;
if (cache.tf !== mc.globalTF) return false; // TF changed, cache stale
const c = mc.charts[sym];
c.candleData = cache.candles;
c.series.setData(cache.candles);
c.volSeries?.setData(cache.volume);
const visibleCount = Math.min(100, cache.candles.length);
c.chart.timeScale().setVisibleLogicalRange({
from: cache.candles.length - visibleCount,
to: cache.candles.length - 1 + 10
});
mc.loadedData[sym] = true;
applyDrawingsToMiniChart(sym);
applySignalMarkers(sym, c.series, cache.candles);
wsSubscribe(sym);
return true;
}
// Infinite scroll: load more history when user scrolls to left edge
// Works for both mini-charts and modal. Uses official TradingView pattern.
const HISTORY_TARGET = 20000;
const HISTORY_BARS_THRESHOLD = 10; // load when fewer than N bars remain on left
function setupInfiniteScroll(chartObj, seriesObj, volSeriesObj, sym, tf, getCandleData, setCandleData) {
let loading = false;
const unsub = chartObj.timeScale().subscribeVisibleLogicalRangeChange(logicalRange => {
if (!logicalRange || loading) return;
if (logicalRange.from >= HISTORY_BARS_THRESHOLD) return;
const candleData = getCandleData();
if (!candleData || candleData.length === 0 || candleData.length >= HISTORY_TARGET) return;
// Guard: don't trigger if user hasn't zoomed in (fitContent shows all data)
const visibleBars = logicalRange.to - logicalRange.from;
if (visibleBars >= candleData.length) return;
loading = true;
const oldestTime = candleData[0].time * 1000;
fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=1500&endTime=${oldestTime - 1}`)
.then(r => r.json())
.then(json => {
if (!Array.isArray(json) || json.length === 0) return;
const olderData = parseKlines(json);
const freshData = getCandleData() || candleData;
const oldestExisting = freshData[0]?.time || 0;
const filtered = olderData.filter(c => c.time < oldestExisting);
if (filtered.length === 0) return;
const newData = [...filtered, ...freshData];
setCandleData(newData);
// Save viewport before setData, restore shifted by prepend count
const rangeBefore = chartObj.timeScale().getVisibleLogicalRange();
const prependCount = filtered.length;
seriesObj.setData(newData);
if (volSeriesObj) volSeriesObj.setData(extractVolume(newData));
if (rangeBefore) {
chartObj.timeScale().setVisibleLogicalRange({
from: rangeBefore.from + prependCount,
to: rangeBefore.to + prependCount
});
}
console.log(`[InfiniteScroll] ${sym} ${tf}: +${filtered.length} → ${newData.length}`);
})
.catch(() => {})
.finally(() => { loading = false; });
});
return unsub;
}
// Apply parsed candles to a chart (shared logic)
function applyDataToChart(sym, parsed, tf) {
if (!mc.charts[sym] || parsed.length === 0) return false;
mc.charts[sym].candleData = parsed;
mc.charts[sym].series.setData(parsed);
mc.charts[sym].volSeries?.setData(extractVolume(parsed));
const visibleCount = Math.min(200, parsed.length);
mc.charts[sym].chart.timeScale().setVisibleLogicalRange({
from: parsed.length - visibleCount,
to: parsed.length - 1 + 10
});
mc.loadedData[sym] = true;
mc.dataCache[sym] = { candles: parsed, volume: extractVolume(parsed), tf };
const realNatr = calcNATR(parsed);
if (realNatr > 0) updateCardNATR(sym, realNatr);
wsSubscribe(sym);
// Save to IndexedDB in background (fire-and-forget)
IDB.put(sym, tf, parsed);
// Defer overlays to next animation frame (single batch render, no jerk)
requestAnimationFrame(() => {
if (!mc.charts[sym]) return;
applyDrawingsToMiniChart(sym);
applySignalMarkers(sym, mc.charts[sym].series, parsed);
applyAutoLevels(sym);
applyAutoTrendlines(sym);
applyKeltnerChannel(sym);
applyRegressionChannel(sym);
});
// Infinite scroll on mini-charts — load history when user scrolls left
if (mc.charts[sym] && !mc.charts[sym]._infiniteUnsub) {
const c = mc.charts[sym];
mc.charts[sym]._infiniteUnsub = setupInfiniteScroll(
c.chart, c.series, c.volSeries, sym, tf,
() => mc.charts[sym]?.candleData,
(newData) => {
if (mc.charts[sym]) {
mc.charts[sym].candleData = newData;
mc.dataCache[sym] = { candles: newData, volume: extractVolume(newData), tf: mc.globalTF };
IDB.put(sym, mc.globalTF, newData);
}
}
);
}
return true;
}
// Batch load queue — 3-tier cache: memory → IndexedDB → server (SQLite → Binance)
async function processLoadQueue() {
if (mc.loadingActive) return;
mc.loadingActive = true;
while (mc.loadQueue.length > 0) {
const batch = [];
while (mc.loadQueue.length > 0 && batch.length < 20) {
const sym = mc.loadQueue.shift();
if (!mc.charts[sym]) continue;
if (mc.loadedData[sym]) continue;
batch.push(sym);
}
if (batch.length === 0) continue;
// Tier 1: Memory cache (instant, ~0ms)
const tier1Done = [];
const tier2Check = [];
for (const sym of batch) {
if (mc.dataCache[sym] && mc.dataCache[sym].tf === mc.globalTF) {
if (renderFromCache(sym)) {
tier1Done.push(sym);
continue;
}
}
tier2Check.push(sym);
}
// Tier 2: Skip IDB — server SQLite cache is fast enough, IDB delta merge caused gaps
const needFetch = [...tier2Check];
// Apply densities for all cached charts
const allCached = [...tier1Done];
if (allCached.length > 0) {
applyDensityToBatch(allCached);
applyOIToBatch(allCached);
}
// Tier 3: Server (SQLite cache → Binance fallback)
if (needFetch.length > 0) {
try {
const res = await fetch('/api/klines-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbols: needFetch, interval: mc.globalTF, limit: 1500 })
});
const allData = await res.json();
const loadedSyms = [];
for (const sym of needFetch) {
if (!mc.charts[sym]) continue;
if (allData[sym] && Array.isArray(allData[sym])) {
const parsed = parseKlines(allData[sym]);
if (applyDataToChart(sym, parsed, mc.globalTF)) {
loadedSyms.push(sym);
}
}
}
if (loadedSyms.length > 0) {
applyDensityToBatch(loadedSyms);
applyOIToBatch(loadedSyms);
}
} catch(e) {
for (const sym of needFetch) {
if (!mc.charts[sym] || mc.loadedData[sym]) continue;
await loadChartData(sym, mc.globalTF);
await new Promise(r => setTimeout(r, 80));
}
}
}
}
mc.loadingActive = false;
}
// NO manual TZ offset — timestamps are pure UTC seconds.
// All time formatting done via Date() which uses browser's local timezone.
const TZ_OFFSET_SEC = 0;
// Local time formatters for LightweightCharts
const localTimeFormatter = (utcSec) => {
const d = new Date(utcSec * 1000);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
};
const localTickFormatter = (utcSec, tickMarkType, locale) => {
const d = new Date(utcSec * 1000);
// tickMarkType: 0=Year, 1=Month, 2=DayOfMonth, 3=Time, 4=TimeWithSeconds
if (tickMarkType <= 1) return d.toLocaleDateString([], { month: 'short', year: '2-digit' });
if (tickMarkType === 2) return d.toLocaleDateString([], { day: 'numeric', month: 'short' });
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
};
const localChartOptions = {
localization: { timeFormatter: localTimeFormatter },
};
function parseKlines(json) {
return json.map(k => ({
time: k[0] / 1000,
open: parseFloat(k[1]),
high: parseFloat(k[2]),
low: parseFloat(k[3]),
close: parseFloat(k[4]),
volume: parseFloat(k[5]),
}));
}
function extractVolume(data) {
return data.map(d => ({
time: d.time,
value: d.volume,
color: d.close >= d.open ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'
}));
}
// ======================== SIGNAL MARKERS ON CHARTS ========================
const _sigTypeLabel = { oi_cvd: '🔮 OI+CVD', volume_spike: '📊 Vol', big_mover: '🚀 Mover', liq_sweep: '🎯 Sweep', oi_divergence: '🔀 OI Div', oi_funding_squeeze: '⚡ Squeeze', channel: '📐 Channel' };
/**
* Place signal markers on a chart series (works for both mini-charts and modal).
* Reads from sigState.signals (populated by signals.js).
* @param {string} sym — symbol e.g. "BTCUSDT"
* @param {Object} series — LightweightCharts series instance
* @param {Array} candles — [{time, open, high, low, close}, ...] ASC
* @param {Object} [modalRef] — if provided, stores markers ref on modal obj; else on mc.charts[sym]
*/
function applySignalMarkers(sym, series, candles, modalRef, force) {
// Check setting (skip check when forced, e.g. from Signals tab modal)
if (!force) {
const sp = typeof settingsPanel !== 'undefined' ? settingsPanel : null;
if (sp && !sp.get('showSignalsOnCharts')) return;
}
if (!series || !candles || candles.length === 0) return;
// Get signals for this symbol, filtered by selected types
const hasSigState = typeof sigState !== 'undefined' && sigState.signals;
const activeTypes = (hasSigState && sigState.typeFilter instanceof Set && sigState.typeFilter.size > 0)
? sigState.typeFilter : null;
const signals = hasSigState
? sigState.signals.filter(s => s.symbol === sym && (!activeTypes || activeTypes.has(s.type)))
: [];
// Also consume pending signal marker (from push notification click)
const pending = window._pendingSignalMarker;
if (pending && modalRef) {
// Inject pending marker as a synthetic signal so it always shows
signals.push({
symbol: sym,
created_at: new Date(pending.time * 1000).toISOString(),
price: pending.price,
direction: pending.direction,
type: pending.type,
description: pending.description,
});
window._pendingSignalMarker = null;
console.log(`[Signal] Consumed pending marker for ${sym}: ${pending.type} ${pending.direction} @ ${pending.price}`);
}
if (signals.length === 0) return;
const firstTime = candles[0].time;
const lastTime = candles[candles.length - 1].time;
const markers = [];
for (const sig of signals) {
const sigTime = Math.floor(new Date(ensureUTC(sig.created_at)).getTime() / 1000);
if (sigTime < firstTime || sigTime > lastTime) continue;
// Find nearest candle
let best = candles[candles.length - 1];
let bestDiff = Infinity;
for (const c of candles) {
const diff = Math.abs(c.time - sigTime);
if (diff < bestDiff) { bestDiff = diff; best = c; }
}
const isLong = sig.direction === 'LONG';
// Build marker text — show extra detail for channel signals
let markerText = `${_sigTypeLabel[sig.type] || sig.type} ${sig.direction}`;
if (sig.type === 'channel' && sig.metadata) {
const m = sig.metadata;
const sub = (m.subType || '').replace('channel_', '');
const tf = m.interval || '';
const stars = m.confluence > 1 ? '★'.repeat(m.confluence) : '';
markerText = `📐 ${sub}${tf ? ' ' + tf : ''}${stars ? ' ' + stars : ''}`;
}
markers.push({
time: best.time,
position: isLong ? 'belowBar' : 'aboveBar',
color: isLong ? '#22c55e' : '#ef4444',
shape: isLong ? 'arrowUp' : 'arrowDown',
text: markerText,
});
}
if (markers.length === 0) return;
// Sort markers by time (LWC requires this)
markers.sort((a, b) => a.time - b.time);
// Dedupe same time+direction (keep first)
const seen = new Set();
const unique = markers.filter(m => {
const key = `${m.time}:${m.position}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
try {
const target = modalRef || mc.charts[sym];
if (!target) return;
if (target._sigMarkers) target._sigMarkers.setMarkers([]);
target._sigMarkers = LightweightCharts.createSeriesMarkers(series, unique);
} catch (e) {
// Silently fail — chart may have been disposed
}
}
/** Refresh signal markers on all visible mini-charts + open modal (called when type filter changes) */
function refreshSignalMarkers() {
// Mini-charts
for (const [sym, c] of Object.entries(mc.charts)) {
if (!c || !c.series || !c.candleData) continue;
if (c._sigMarkers) { try { c._sigMarkers.setMarkers([]) } catch(_){} c._sigMarkers = null; }
applySignalMarkers(sym, c.series, c.candleData);
}
// Open modal
if (typeof modal !== 'undefined' && modal.chart && modal.series && modal.candleData && modal.currentSym) {
if (modal._sigMarkers) { try { modal._sigMarkers.setMarkers([]) } catch(_){} modal._sigMarkers = null; }
applySignalMarkers(modal.currentSym, modal.series, modal.candleData, modal);
}
}
// Reuse ensureUTC from signals.js (or define fallback)
if (typeof ensureUTC === 'undefined') {
var ensureUTC = function(iso) {
if (!iso) return iso;
let s = iso.includes('T') ? iso : iso.replace(' ', 'T');
if (!s.endsWith('Z') && !s.includes('+')) s += 'Z';
return s;
};
}
// Apply saved drawings (hlines, fibs) to mini-chart
function applyDrawingsToMiniChart(sym) {
const c = mc.charts[sym];
if (!c || !c.series) return;
// Clear old drawing objects
if (c.drawObjs) {
c.drawObjs.forEach(obj => {
if (obj.priceLine) try { c.series.removePriceLine(obj.priceLine); } catch(e) {}
if (obj.lineSeries) try { c.chart.removeSeries(obj.lineSeries); } catch(e) {}
});
}
c.drawObjs = [];
const saved = drawStore.load(sym);
if (!saved || saved.length === 0) return;
saved.forEach(s => {
if (s.type === 'hline' && s.data) {
const pl = c.series.createPriceLine({
price: s.data.price,
color: s.color || '#5b9cf6',
lineWidth: 1,
lineStyle: 2, // dashed on mini
axisLabelVisible: false,
title: '',
});
c.drawObjs.push({ priceLine: pl });
} else if (s.type === 'fib' && s.data) {
const rawLevels = s.data.levels || FIB_DEFAULTS_OBJ;
const diff = s.data.p2 - s.data.p1;
rawLevels.forEach((item, i) => {
const lvl = typeof item === 'number' ? item : item.level;
const clr = (typeof item === 'object' && item.color) ? item.color : (s.color || '#5b9cf6');
const price = s.data.p1 + diff * lvl;
const pl = c.series.createPriceLine({
price,
color: clr,
lineWidth: 1,
lineStyle: 0,
axisLabelVisible: false,
title: '',
});
c.drawObjs.push({ priceLine: pl });
});
} else if ((s.type === 'ray' || s.type === 'trendline') && s.data) {
const { t1, p1, t2, p2 } = s.data;
const points = [];
if (s.type === 'ray') {
const dt = t2 - t1;
const dp = p2 - p1;
const steps = 200;
const seen = new Set();
for (let i = 0; i <= steps; i++) {
const ratio = i / 5;
const t = Math.round(t1 + dt * ratio);
if (seen.has(t)) continue;
seen.add(t);
points.push({ time: t, value: p1 + dp * ratio });
}
points.sort((a, b) => a.time - b.time);
} else {
points.push({ time: t1, value: p1 });
points.push({ time: t2, value: p2 });
}
const ls = c.chart.addSeries(LightweightCharts.LineSeries, {
color: s.color || '#5b9cf6',
lineWidth: 1,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
pointMarkersVisible: false,
});
ls.setData(points);
c.drawObjs.push({ lineSeries: ls });
} else if (s.type === 'rect' && s.data) {
// Rectangle on mini-chart: top + bottom border lines
const { t1, p1, t2, p2 } = s.data;
const clr = s.color || '#5b9cf6';
const topLs = c.chart.addSeries(LightweightCharts.LineSeries, {
color: clr, lineWidth: 1, crosshairMarkerVisible: false,
lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false,
});
topLs.setData([{ time: t1, value: Math.max(p1, p2) }, { time: t2, value: Math.max(p1, p2) }]);
c.drawObjs.push({ lineSeries: topLs });
const botLs = c.chart.addSeries(LightweightCharts.LineSeries, {
color: clr, lineWidth: 1, crosshairMarkerVisible: false,
lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false,
});
botLs.setData([{ time: t1, value: Math.min(p1, p2) }, { time: t2, value: Math.min(p1, p2) }]);
c.drawObjs.push({ lineSeries: botLs });
}
});
}
// Calculate real ATR(14) / close * 100 = NATR from candle data
function calcNATR(candles, period = 14) {
if (!candles || candles.length < period + 1) return 0;
const trs = [];
for (let i = 1; i < candles.length; i++) {
const h = candles[i].high;
const l = candles[i].low;
const pc = candles[i - 1].close;
trs.push(Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)));
}
// SMA of last `period` TRs
const recent = trs.slice(-period);
const atr = recent.reduce((s, v) => s + v, 0) / recent.length;
const lastClose = candles[candles.length - 1].close;
return lastClose > 0 ? (atr / lastClose) * 100 : 0;
}
// Update NATR display on mini-chart card after real calc
function updateCardNATR(sym, natr) {
const card = document.getElementById(`mc-card-${sym}`);
if (!card) return;
const natrSpan = card.querySelector('.mc-natr');
if (natrSpan) natrSpan.textContent = natr.toFixed(1) + '%';
// Update pair data for sorting
const pair = mc.allPairs.find(p => p.symbol === sym);
if (pair) pair.proxyNatr = natr;
}
async function loadChartData(sym, tf) {
if (!mc.charts[sym]) return;
// Try cache first
if (renderFromCache(sym)) {
applyDensityToMiniChart(sym);
return;
}
try {
// Load 1500 candles (max Binance allows per request)
const res1 = await fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=1500`);
const json1 = await res1.json();
if (!Array.isArray(json1) || !mc.charts[sym]) return;
const data1 = parseKlines(json1);
mc.charts[sym].candleData = data1;
mc.charts[sym].series.setData(data1);
mc.charts[sym].volSeries.setData(extractVolume(data1));
mc.loadedData[sym] = true;
const visibleCount = Math.min(100, data1.length);
mc.charts[sym].chart.timeScale().setVisibleLogicalRange({
from: data1.length - visibleCount,
to: data1.length - 1 + 10
});
// Save to cache
mc.dataCache[sym] = { candles: data1, volume: extractVolume(data1), tf };
const realNatr = calcNATR(data1);
if (realNatr > 0) updateCardNATR(sym, realNatr);
applyDrawingsToMiniChart(sym);
applyDensityToMiniChart(sym);
wsSubscribe(sym);
// Auto S/R levels applied via applyDataToChart → applyAutoLevels
} catch (e) {
console.error(`Chart load error ${sym}:`, e);
}
}
// ==========================================
// Auto S/R Levels — Fractal Pivots + ATR Clustering
// ==========================================
function computeAutoLevels(candles) {
if (!candles || candles.length < 30) return [];
// 1. Calculate ATR(14) for adaptive clustering
const trs = [];
for (let i = 1; i < candles.length; i++) {
const h = candles[i].high, l = candles[i].low, pc = candles[i - 1].close;
trs.push(Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)));
}
const atrSlice = trs.slice(-14);
const atr = atrSlice.reduce((s, v) => s + v, 0) / atrSlice.length;
const mergeDist = atr * 1.5; // ATR-based merge threshold (wider to merge nearby levels)
if (mergeDist <= 0) return [];
const currentPrice = candles[candles.length - 1].close;
const maxDist = atr * 40; // only levels within 40×ATR of current price
// 2. Detect fractal pivots (both swing highs and lows)
const WINDOW = Math.min(10, Math.max(3, Math.floor(candles.length / 30)));
const pivots = [];
const totalCandles = candles.length;
for (let i = WINDOW; i < totalCandles - WINDOW; i++) {
let isHigh = true, isLow = true;
for (let j = i - WINDOW; j <= i + WINDOW; j++) {
if (i === j) continue;
if (candles[j].high >= candles[i].high) isHigh = false;
if (candles[j].low <= candles[i].low) isLow = false;
if (!isHigh && !isLow) break;
}
if (isHigh) pivots.push({ price: candles[i].high, idx: i, vol: candles[i].volume || 0 });
if (isLow) pivots.push({ price: candles[i].low, idx: i, vol: candles[i].volume || 0 });
}
if (pivots.length < 2) return [];
// 3. Cluster pivots using DBSCAN (density-based, no chain-merge problem)
// epsilon = mergeDist (ATR×1.5), minPts = 2
const MIN_PTS = 2;
const visited = new Set();
const clustered = new Set();
const clusters = [];
for (let i = 0; i < pivots.length; i++) {
if (visited.has(i)) continue;
visited.add(i);
// Find all neighbors within epsilon (mergeDist)
const neighbors = [];
for (let j = 0; j < pivots.length; j++) {
if (Math.abs(pivots[i].price - pivots[j].price) <= mergeDist) {
neighbors.push(j);
}
}
if (neighbors.length < MIN_PTS) continue; // noise point
// Expand cluster
const cluster = [];
const queue = [...neighbors];
for (const ni of queue) {
if (!clustered.has(ni)) {
clustered.add(ni);
cluster.push(pivots[ni]);
}
if (visited.has(ni)) continue;
visited.add(ni);
// Find neighbors of neighbor
const nn = [];
for (let j = 0; j < pivots.length; j++) {
if (Math.abs(pivots[ni].price - pivots[j].price) <= mergeDist) {
nn.push(j);
}
}
if (nn.length >= MIN_PTS) {
for (const nj of nn) {
if (!queue.includes(nj)) queue.push(nj);
}
}
}
if (cluster.length >= MIN_PTS) clusters.push(cluster);
}
// 4. Score each cluster
const levels = [];
for (const cluster of clusters) {
// Use median price (more robust than average for skewed clusters)
const prices = cluster.map(p => p.price).sort((a, b) => a - b);
const medianPrice = prices[Math.floor(prices.length / 2)];
// Skip levels too far from current price
if (Math.abs(medianPrice - currentPrice) > maxDist) continue;
const touches = cluster.length;
// Recency score: exponential decay, recent pivots matter more
const recencyScore = cluster.reduce((s, p) => {
const age = totalCandles - p.idx;
return s + Math.exp(-age / (totalCandles * 0.4));
}, 0) / touches;
// Volume score: log-scaled average volume at touch points
const avgVol = cluster.reduce((s, p) => s + p.vol, 0) / touches;
const volScore = avgVol > 0 ? Math.log10(avgVol + 1) : 0;
// Type based on position relative to current price (not pivot type)
const type = medianPrice < currentPrice ? 'support' : 'resistance';
// Composite score (0-100)
const score = Math.min(100, Math.round(
Math.min(touches, 8) * 10 + // touch count capped at 8 (2→20, 5→50, 8→80)
recencyScore * 15 + // recency (0-15)
volScore * 2 // volume bonus
));
levels.push({ price: medianPrice, type, touches: Math.min(touches, 99), score });
}
// 5. Add Volume Profile levels (POC / VAH / VAL)
const vpLevels = computeVolumeProfile(candles, currentPrice);
for (const vp of vpLevels) {
// Avoid duplicates: skip VP level if too close to an existing fractal level
const tooClose = levels.some(l => Math.abs(l.price - vp.price) <= mergeDist);
if (!tooClose) levels.push(vp);
}
// 6. Break detection — penalize levels that price has recently broken through
// Scan last 20% of candles for decisive closes past the level.
// A "break" = close clearly past level (by 0.3×ATR). If price later returns, partial recovery.
const breakScanStart = Math.floor(totalCandles * 0.8);
const breakThresh = atr * 0.3; // close must be 0.3×ATR past level to count as break
for (const level of levels) {
if (level.vpType) continue; // don't penalize VP levels
let broken = false;
let returnedAfterBreak = false;
for (let i = breakScanStart; i < totalCandles; i++) {
const c = candles[i].close;
if (level.type === 'support') {
// Support broken = close below level
if (c < level.price - breakThresh) broken = true;
// Returned = close back above level after break
else if (broken && c > level.price + breakThresh) returnedAfterBreak = true;
} else {
// Resistance broken = close above level
if (c > level.price + breakThresh) broken = true;
else if (broken && c < level.price - breakThresh) returnedAfterBreak = true;
}
}
if (broken && !returnedAfterBreak) {
level.score = Math.max(10, Math.round(level.score * 0.5)); // halve score
level.broken = true;
} else if (broken && returnedAfterBreak) {
// Retested after break — slight penalty only (level proved it still matters)
level.score = Math.max(20, Math.round(level.score * 0.8));
level.retested = true;
}
}
// 7. Sort by proximity to current price, then score
const supports = levels.filter(l => l.type === 'support')
.sort((a, b) => b.price - a.price) // nearest support first
.slice(0, 3);
const resists = levels.filter(l => l.type === 'resistance')
.sort((a, b) => a.price - b.price) // nearest resistance first
.slice(0, 3);
return [...supports, ...resists];
}
// ── Volume Profile (POC / VAH / VAL) ─────────────────────────────
// Divides visible price range into bins, distributes volume,
// returns POC (max volume), VAH/VAL (70% value area) as S/R levels.
function computeVolumeProfile(candles, currentPrice) {
if (!candles || candles.length < 30) return [];
const NUM_BINS = 100;
const VALUE_AREA_PCT = 0.70;
// Price range
let minP = Infinity, maxP = -Infinity;
for (const c of candles) {
if (c.low < minP) minP = c.low;
if (c.high > maxP) maxP = c.high;
}
const range = maxP - minP;
if (range <= 0) return [];
const binSize = range / NUM_BINS;
const bins = new Float64Array(NUM_BINS); // volume per bin
// Distribute each candle's volume across bins it touches
for (const c of candles) {
const vol = c.volume || 0;
if (vol <= 0) continue;
const lo = Math.max(0, Math.floor((c.low - minP) / binSize));
const hi = Math.min(NUM_BINS - 1, Math.floor((c.high - minP) / binSize));
const span = hi - lo + 1;
const volPerBin = vol / span;
for (let b = lo; b <= hi; b++) bins[b] += volPerBin;
}
// POC = bin with max volume
let pocBin = 0, maxVol = 0;
for (let i = 0; i < NUM_BINS; i++) {
if (bins[i] > maxVol) { maxVol = bins[i]; pocBin = i; }
}
// Value Area: expand from POC until 70% of total volume captured
let totalVol = 0;
for (let i = 0; i < NUM_BINS; i++) totalVol += bins[i];
const targetVol = totalVol * VALUE_AREA_PCT;
let vaLow = pocBin, vaHigh = pocBin;
let areaVol = bins[pocBin];
while (areaVol < targetVol && (vaLow > 0 || vaHigh < NUM_BINS - 1)) {
const addLow = vaLow > 0 ? bins[vaLow - 1] : 0;
const addHigh = vaHigh < NUM_BINS - 1 ? bins[vaHigh + 1] : 0;
if (addLow >= addHigh && vaLow > 0) {
vaLow--;
areaVol += bins[vaLow];
} else if (vaHigh < NUM_BINS - 1) {
vaHigh++;
areaVol += bins[vaHigh];
} else {
vaLow--;
areaVol += bins[vaLow];
}
}
const pocPrice = minP + (pocBin + 0.5) * binSize;
const vahPrice = minP + (vaHigh + 1) * binSize; // upper edge
const valPrice = minP + vaLow * binSize; // lower edge
const results = [];
// POC — always significant (score 90, high visibility)
results.push({
price: pocPrice,
type: pocPrice < currentPrice ? 'support' : 'resistance',
touches: 0, // VP levels don't have "touches"
score: 90,
vpType: 'POC'
});
// VAH — value area high (resistance zone edge)
if (Math.abs(vahPrice - pocPrice) > binSize * 2) {
results.push({
price: vahPrice,
type: vahPrice < currentPrice ? 'support' : 'resistance',
touches: 0,
score: 70,
vpType: 'VAH'
});
}
// VAL — value area low (support zone edge)
if (Math.abs(valPrice - pocPrice) > binSize * 2) {
results.push({
price: valPrice,
type: valPrice < currentPrice ? 'support' : 'resistance',
touches: 0,
score: 70,
vpType: 'VAL'
});
}
return results;
}
// ── Multi-TF Confluence ───────────────────────────────────────────
// Fetches klines for additional timeframes, computes S/R on each,
// then boosts levels that appear on multiple TFs.
// Returns enriched levels with `confluence` count (1-3 TFs).
const CONFLUENCE_TFS = {
'1m': ['5m', '15m'],
'3m': ['15m', '1h'],
'5m': ['1h', '4h'],
'15m': ['1h', '4h'],
'30m': ['4h', '1d'],
'1h': ['4h', '1d'],
'2h': ['4h', '1d'],
'4h': ['1d', '1w'],
'6h': ['1d', '1w'],
'8h': ['1d', '1w'],
'12h': ['1d', '1w'],
'1d': ['1w', '1M'],
'1w': ['1M'],
};
async function computeMultiTFLevels(sym, currentTF, currentCandles) {
// Start with levels from current TF
const baseLevels = computeAutoLevels(currentCandles);
if (baseLevels.length === 0) return baseLevels;
const extraTFs = CONFLUENCE_TFS[currentTF] || [];
if (extraTFs.length === 0) return baseLevels;
const currentPrice = currentCandles[currentCandles.length - 1].close;
// Fetch klines for additional TFs in parallel
const extraLevelSets = await Promise.all(extraTFs.map(async tf => {
try {
const res = await fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=500`);
if (!res.ok) return [];
const raw = await res.json();
if (!Array.isArray(raw) || raw.length < 30) return [];
const candles = parseKlines(raw);
return computeAutoLevels(candles);
} catch { return []; }
}));
// ATR from current candles for merge distance
const trs = [];
for (let i = 1; i < currentCandles.length; i++) {
const h = currentCandles[i].high, l = currentCandles[i].low, pc = currentCandles[i - 1].close;
trs.push(Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)));
}
const atr = trs.slice(-14).reduce((s, v) => s + v, 0) / Math.min(14, trs.length);
const confluenceDist = atr * 2; // levels within 2×ATR are "same level"
// Enrich base levels with confluence count
for (const level of baseLevels) {
let conf = 1; // already on current TF
for (const extraLevels of extraLevelSets) {
const match = extraLevels.some(el => Math.abs(el.price - level.price) <= confluenceDist);
if (match) conf++;
}
level.confluence = conf;
// Boost score for multi-TF levels
if (conf >= 2) level.score = Math.min(100, level.score + 15);
if (conf >= 3) level.score = Math.min(100, level.score + 10);
}
// Also check if higher TFs have levels not in base set
for (const extraLevels of extraLevelSets) {
for (const el of extraLevels) {
const alreadyClose = baseLevels.some(bl => Math.abs(bl.price - el.price) <= confluenceDist);
if (!alreadyClose && Math.abs(el.price - currentPrice) <= atr * 40) {
el.confluence = 1;
el.score = Math.min(100, el.score + 10); // HTF bonus
el.htf = true; // mark as higher-TF only level
baseLevels.push(el);
}
}
}
// Re-sort and limit: 3 supports + 3 resistances, nearest first
const supports = baseLevels.filter(l => l.type === 'support')
.sort((a, b) => b.price - a.price).slice(0, 3);
const resists = baseLevels.filter(l => l.type === 'resistance')
.sort((a, b) => a.price - b.price).slice(0, 3);
return [...supports, ...resists];
}
// ── Auto Trendlines v3 ─────────────────────────────────────────
// ZigZag swing detection + gradient descent slope optimization.
// Key principle: ZERO violations — no candle crosses the line on wrong side.
// Inspired by neurotrader888/TechnicalAnalysisAutomation.
// Returns top 1 support + 1 resistance trendline (closest to price).
// Each: { p1:{time,price}, p2:{time,price}, type, touches, score, slope, broken }
function computeAutoTrendlines(candles) {
if (!candles || candles.length < 40) return [];
const totalCandles = candles.length;
const currentPrice = candles[totalCandles - 1].close;
const lastTime = candles[totalCandles - 1].time;
// ATR(14)
const trs = [];
for (let i = 1; i < totalCandles; i++) {
const h = candles[i].high, l = candles[i].low, pc = candles[i - 1].close;
trs.push(Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)));
}
const atr = trs.slice(-14).reduce((s, v) => s + v, 0) / Math.min(14, trs.length);
if (atr <= 0) return [];
const touchTol = atr * 0.3; // tighter than before (0.4 → 0.3)
// ── 1. ZigZag swing detection (ATR-based threshold) ──
// Produces 5-15 significant swings instead of 30+ fractal pivots
const zigzagThreshold = atr * 3; // swing must be ≥ 3×ATR to count
const swings = [];
let lastType = candles[0].close > candles[1].close ? 'high' : 'low';
let lastHigh = candles[0].high, lastLow = candles[0].low;
let lastHighIdx = 0, lastLowIdx = 0;
for (let i = 1; i < totalCandles; i++) {
if (lastType === 'low') {
if (candles[i].high > lastHigh) { lastHigh = candles[i].high; lastHighIdx = i; }
if (lastHigh - candles[i].low >= zigzagThreshold) {
swings.push({ idx: lastHighIdx, price: lastHigh, type: 'high', time: candles[lastHighIdx].time });
lastType = 'high'; lastLow = candles[i].low; lastLowIdx = i;
}
} else {
if (candles[i].low < lastLow) { lastLow = candles[i].low; lastLowIdx = i; }
if (candles[i].high - lastLow >= zigzagThreshold) {
swings.push({ idx: lastLowIdx, price: lastLow, type: 'low', time: candles[lastLowIdx].time });
lastType = 'low'; lastHigh = candles[i].high; lastHighIdx = i;
}
}
}
// Add final pending swing
if (lastType === 'low') swings.push({ idx: lastHighIdx, price: lastHigh, type: 'high', time: candles[lastHighIdx].time });
else swings.push({ idx: lastLowIdx, price: lastLow, type: 'low', time: candles[lastLowIdx].time });
const swingHighs = swings.filter(s => s.type === 'high');
const swingLows = swings.filter(s => s.type === 'low');
// ── 2. Gradient descent: find optimal trendline through each pivot ──
// For each pivot, try slope via gradient descent so that:
// Support line: ALL candle lows stay ABOVE the line (zero violations)
// Resistance line: ALL candle highs stay BELOW the line (zero violations)
// Then pick the slope that's closest to price (tightest valid line).
function fitTrendline(pivotIdx, pivotPrice, isSupport) {
// Initial slope: linear regression slope of closes
const startIdx = Math.max(0, pivotIdx - Math.floor(totalCandles * 0.3));
const endIdx = Math.min(totalCandles - 1, pivotIdx + Math.floor(totalCandles * 0.3));
const scanStart = Math.max(0, pivotIdx - Math.floor(totalCandles * 0.5));
const scanEnd = totalCandles - 1;
// Initial slope estimate from regression
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0, n = 0;
for (let i = startIdx; i <= endIdx; i++) {
const x = i - pivotIdx;
const y = candles[i].close;
sumX += x; sumY += y; sumXY += x * y; sumXX += x * x; n++;
}
let slope = n > 1 ? (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX) : 0;
// Gradient descent: adjust slope to eliminate violations
const lr = atr * 0.001; // learning rate
const maxIter = 50;
for (let iter = 0; iter < maxIter; iter++) {
let worstViolation = 0;
let worstIdx = -1;
for (let i = scanStart; i <= scanEnd; i++) {
const linePrice = pivotPrice + slope * (i - pivotIdx);
const candlePrice = isSupport ? candles[i].low : candles[i].high;
const violation = isSupport
? linePrice - candlePrice // support: line above low = bad
: candlePrice - linePrice; // resistance: high above line = bad
if (violation > worstViolation) {
worstViolation = violation;
worstIdx = i;
}
}
if (worstViolation <= atr * 0.05) break; // close enough to zero
// Nudge slope to fix the worst violation
const dx = worstIdx - pivotIdx;
if (dx === 0) break;
// Adjust slope so the line moves away from the violation
slope -= (worstViolation * Math.sign(dx)) / Math.abs(dx) * 0.5;
}
// Final verification: zero violations
let valid = true;
for (let i = scanStart; i <= scanEnd; i++) {
const linePrice = pivotPrice + slope * (i - pivotIdx);
if (isSupport && candles[i].low < linePrice - atr * 0.08) { valid = false; break; }
if (!isSupport && candles[i].high > linePrice + atr * 0.08) { valid = false; break; }
}
if (!valid) return null;
// Enforce correct slope direction:
// Support (lows) must slope UP (rising lows)
// Resistance (highs) must slope DOWN (falling highs)
if (isSupport && slope < 0) return null; // support must rise
if (!isSupport && slope > 0) return null; // resistance must fall
// Reject near-horizontal (S/R levels territory)
const totalMove = Math.abs(slope * totalCandles);
if (totalMove < atr * 0.5) return null;
// Reject too steep
if (Math.abs(slope) > atr * 0.5) return null;
// Count touches (zigzag points near the line)
let touches = 1; // pivot itself
const allPivots = isSupport ? swingLows : swingHighs;
for (const p of allPivots) {
if (p.idx === pivotIdx) continue;
const linePrice = pivotPrice + slope * (p.idx - pivotIdx);
if (Math.abs(p.price - linePrice) <= touchTol) touches++;
}
// Also count candle wicks that touch the line
for (let i = scanStart; i <= scanEnd; i++) {
const linePrice = pivotPrice + slope * (i - pivotIdx);
const wickPrice = isSupport ? candles[i].low : candles[i].high;
const dist = Math.abs(wickPrice - linePrice);
if (dist <= touchTol && !allPivots.some(p => p.idx === i)) touches++;
}
// Cap wick touches to avoid noise
touches = Math.min(touches, 12);
// Project to current bar
const projectedPrice = pivotPrice + slope * (totalCandles - 1 - pivotIdx);
const distToCurrent = Math.abs(projectedPrice - currentPrice) / currentPrice * 100;
// Break detection: has price crossed the line after pivot?
let broken = false, breakIdx = -1;
for (let i = pivotIdx + 1; i < totalCandles; i++) {
const linePrice = pivotPrice + slope * (i - pivotIdx);
if (isSupport && candles[i].close < linePrice - atr * 0.8) { broken = true; breakIdx = i; break; }
if (!isSupport && candles[i].close > linePrice + atr * 0.8) { broken = true; breakIdx = i; break; }
}
// Recency: how recent is the pivot
const recency = Math.exp(-(totalCandles - pivotIdx) / (totalCandles * 0.4));
// Score: exponential touch reward + recency + proximity
const proximityBonus = distToCurrent < 5 ? (5 - distToCurrent) / 5 : 0;
const brokenPenalty = broken ? 0.3 : 1.0;
const score = Math.min(100, Math.round(
(Math.pow(touches, 2.5) * 3 + recency * 25 + proximityBonus * 15) * brokenPenalty
));
return {
pivotIdx, pivotPrice, slope, touches, score,
projectedPrice, distToCurrent, broken, breakIdx,
};
}
// ── 3. Run gradient descent on each zigzag pivot ──
const supportCandidates = [];
const resistCandidates = [];
for (const s of swingLows) {
const result = fitTrendline(s.idx, s.price, true);
if (result && result.touches >= 2) supportCandidates.push({ ...result, time: s.time });
}
for (const s of swingHighs) {
const result = fitTrendline(s.idx, s.price, false);
if (result && result.touches >= 2) resistCandidates.push({ ...result, time: s.time });
}
supportCandidates.sort((a, b) => b.score - a.score);
resistCandidates.sort((a, b) => b.score - a.score);
// ── 4. Dedup & pick best ──
function dedup(arr) {
if (arr.length <= 1) return arr;
const result = [arr[0]];
for (let i = 1; i < arr.length; i++) {
const cur = arr[i];
const isDup = result.some(prev => {
const projDiff = Math.abs(cur.projectedPrice - prev.projectedPrice);
const slopeRatio = prev.slope !== 0 ? Math.abs(cur.slope / prev.slope) : 2;
return projDiff < atr * 2 && slopeRatio > 0.6 && slopeRatio < 1.4;
});
if (!isDup) result.push(cur);
}
return result;
}
const bestSupport = dedup(supportCandidates).slice(0, 1);
const bestResistance = dedup(resistCandidates).slice(0, 1);
// ── 5. Format output ──
const results = [];
for (const c of [...bestSupport, ...bestResistance]) {
const isSupport = supportCandidates.includes(c);
const type = isSupport ? 'support' : 'resistance';
const p1 = { time: c.time, price: c.pivotPrice, idx: c.pivotIdx };
let endIdx, endTime, endPrice;
if (c.broken && c.breakIdx > 0) {
endIdx = c.breakIdx;
} else {
endIdx = totalCandles - 1;
}
endTime = candles[endIdx].time;
endPrice = c.pivotPrice + c.slope * (endIdx - c.pivotIdx);
// Start line from the very beginning of the chart
const startIdx = 0;
const startTime = candles[0].time;
const startPrice = c.pivotPrice + c.slope * (0 - c.pivotIdx);
const finalProjected = c.broken ? endPrice : c.projectedPrice;
const finalDist = Math.abs(finalProjected - currentPrice) / currentPrice * 100;
if (finalDist > 10) continue;
results.push({
p1: { time: startTime, price: startPrice, idx: startIdx },
p2: { time: endTime, price: endPrice, idx: endIdx },
type,
touches: c.touches,
score: c.score,
slope: c.slope,
projectedPrice: finalProjected,
distToCurrent: finalDist,
broken: c.broken,
});
}
return results;
}
// Compute nearest auto-trendline distance % for a symbol (for sidebar sorting)
// Stores result on mc.tlData[sym] = { type: 'S'|'R', pct: 0.5, price: 74610, touches }
function computeTLDistance(sym) {
const chartObj = mc.charts[sym];
const candles = chartObj?.candleData || mc.dataCache[sym]?.candles;
if (!candles || candles.length < 40) return;
const lines = computeAutoTrendlines(candles);
if (lines.length === 0) { mc.tlData[sym] = null; return; }
const price = candles[candles.length - 1].close;
let nearest = null;
let minPct = Infinity;
for (const l of lines) {
const pct = l.distToCurrent;
if (pct < minPct) {
minPct = pct;
nearest = { type: l.type === 'support' ? 'S' : 'R', pct, price: l.projectedPrice, touches: l.touches };
}
}
mc.tlData[sym] = nearest;
}
// Format trendline distance for sidebar display
function formatTL(sym) {
const tl = mc.tlData[sym];
if (!tl) return '<span style="color:#555">—</span>';
const color = tl.type === 'S' ? '#22c55e' : '#ef4444';
return `<span style="color:${color}">T${tl.type}${tl.pct.toFixed(1)}%</span>`;
}
// Compute nearest S/R distance % for a symbol (for sidebar sorting)
// Stores result on mc.srData[sym] = { type: 'S'|'R', pct: 0.5, price: 74610 }
function computeSRDistance(sym) {
const chartObj = mc.charts[sym];
const candles = chartObj?.candleData || mc.dataCache[sym]?.candles;
if (!candles || candles.length < 30) return;
const levels = computeAutoLevels(candles);
if (levels.length === 0) { mc.srData[sym] = null; return; }
const price = candles[candles.length - 1].close;
let nearest = null;
let minPct = Infinity;
for (const l of levels) {
const pct = Math.abs(l.price - price) / price * 100;
if (pct < minPct) {
minPct = pct;
nearest = { type: l.type === 'support' ? 'S' : 'R', pct, price: l.price, touches: l.touches };
}
}
mc.srData[sym] = nearest;
}
// Format S/R distance for sidebar display
function formatSR(sym) {
const sr = mc.srData[sym];
if (!sr) return '<span style="color:#555">—</span>';
const color = sr.type === 'S' ? '#22c55e' : '#ef4444';
return `<span style="color:${color}">${sr.type}${sr.pct.toFixed(1)}%</span>`;
}
// ── Level → PriceLine config helper ──────────────────────────────
// Unified styling for all chart types: mini-charts, modal, multi-chart slots.
// Handles VP levels, fractal S/R, confluence tags, broken/retested states.
function levelToPriceLine(l, showAxisLabel) {
let color, title, lineStyle, lineWidth;
if (l.vpType) {
// Volume Profile: POC = blue bold dotted, VAH/VAL = purple dotted
color = l.vpType === 'POC' ? 'rgba(59,130,246,0.85)' : 'rgba(168,85,247,0.65)';
title = l.vpType;
lineStyle = 2; // dotted
lineWidth = l.vpType === 'POC' ? 2 : 1;
} else {
// Fractal S/R
const opacity = l.broken ? 0.25 : l.retested ? 0.35 : Math.max(0.4, l.score / 100);
const baseColor = l.type === 'support' ? [34, 197, 94] : [239, 68, 68];
color = `rgba(${baseColor.join(',')},${opacity})`;
const prefix = l.type === 'support' ? 'S' : 'R';
const confTag = l.confluence >= 2 ? ` ★${l.confluence}TF` : '';
const breakTag = l.broken ? ' ✗' : l.retested ? ' ↻' : '';
title = `${prefix}×${l.touches}${confTag}${breakTag}`;
lineStyle = l.broken ? 2 : 1; // broken = dotted, normal = dashed
lineWidth = l.confluence >= 3 ? 2 : 1;
}
return { price: l.price, color, lineWidth, lineStyle, axisLabelVisible: !!showAxisLabel, title };
}
// Apply auto S/R levels to a mini-chart
function applyAutoLevels(sym) {
// Always compute SR distance for sidebar (even if levels display is off)
computeSRDistance(sym);
if (!spGet('levelsEnabled', true)) return;
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.series || !chartObj.candleData) return;
removeAutoLevels(sym);
const levels = computeAutoLevels(chartObj.candleData);
chartObj.autoLevels = [];
levels.forEach(l => {
const line = chartObj.series.createPriceLine(levelToPriceLine(l, false));
chartObj.autoLevels.push(line);
});
}
// Remove auto S/R levels from a mini-chart
function removeAutoLevels(sym) {
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.autoLevels) return;
chartObj.autoLevels.forEach(pl => { try { chartObj.series.removePriceLine(pl); } catch(e) {} });
chartObj.autoLevels = [];
}
// Apply auto S/R levels to modal chart (with Multi-TF confluence)
async function applyModalAutoLevels() {
if (!spGet('levelsEnabled', true)) return;
if (!modal.chart || !modal.series || !modal.candleData) return;
removeModalAutoLevels();
// Use multi-TF confluence for modal (richer data, single symbol)
const sym = modal.currentSym;
const tf = modal.currentTF || mc.globalTF;
let levels;
try {
levels = await computeMultiTFLevels(sym, tf, modal.candleData);
} catch {
levels = computeAutoLevels(modal.candleData); // fallback to single-TF
}
modal.autoLevels = [];
// Guard: modal may have been closed during async fetch
if (!modal.chart || !modal.series) return;
levels.forEach(l => {
const line = modal.series.createPriceLine(levelToPriceLine(l, true));
modal.autoLevels.push(line);
});
}
function removeModalAutoLevels() {
if (!modal.autoLevels) return;
modal.autoLevels.forEach(pl => { try { modal.series.removePriceLine(pl); } catch(e) {} });
modal.autoLevels = [];
}
// ── Auto Trendlines — Drawing ──────────────────────────────────
// Apply auto trendlines to a mini-chart (as LineSeries, like manual trendlines)
function applyAutoTrendlines(sym) {
// Only compute TL distance if sorting by trendlines or trendlines enabled
if (mc.sortBy === 'tl' || spGet('trendlinesEnabled', false)) {
computeTLDistance(sym);
}
if (!spGet('trendlinesEnabled', false)) return;
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.series || !chartObj.candleData) return;
removeAutoTrendlines(sym);
const lines = computeAutoTrendlines(chartObj.candleData);
chartObj.autoTrendlines = [];
lines.forEach(tl => {
const opacity = tl.broken ? 0.25 : Math.max(0.4, tl.score / 100);
const baseColor = tl.type === 'support' ? [34, 197, 94] : [239, 68, 68];
const color = `rgba(${baseColor.join(',')},${opacity})`;
const ls = chartObj.chart.addSeries(LightweightCharts.LineSeries, {
color,
lineWidth: 1,
lineStyle: tl.broken ? 3 : 2, // dotted if broken, dashed if active
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
pointMarkersVisible: false,
});
ls.setData([
{ time: tl.p1.time, value: tl.p1.price },
{ time: tl.p2.time, value: tl.p2.price },
]);
chartObj.autoTrendlines.push({ lineSeries: ls });
});
}
// Remove auto trendlines from a mini-chart
function removeAutoTrendlines(sym) {
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.autoTrendlines) return;
chartObj.autoTrendlines.forEach(obj => {
if (obj.lineSeries) try { chartObj.chart.removeSeries(obj.lineSeries); } catch(e) {}
});
chartObj.autoTrendlines = [];
}
// Apply auto trendlines to modal chart
function applyModalAutoTrendlines() {
if (!spGet('trendlinesEnabled', false)) return;
if (!modal.chart || !modal.series || !modal.candleData) return;
removeModalAutoTrendlines();
const lines = computeAutoTrendlines(modal.candleData);
modal.autoTrendlines = [];
lines.forEach(tl => {
const opacity = tl.broken ? 0.25 : Math.max(0.4, tl.score / 100);
const baseColor = tl.type === 'support' ? [34, 197, 94] : [239, 68, 68];
const color = `rgba(${baseColor.join(',')},${opacity})`;
const ls = modal.chart.addSeries(LightweightCharts.LineSeries, {
color,
lineWidth: tl.broken ? 1 : 2,
lineStyle: tl.broken ? 3 : 2, // dotted if broken, dashed if active
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
pointMarkersVisible: false,
});
ls.setData([
{ time: tl.p1.time, value: tl.p1.price },
{ time: tl.p2.time, value: tl.p2.price },
]);
modal.autoTrendlines.push({ lineSeries: ls, type: tl.type, touches: tl.touches });
});
}
function removeModalAutoTrendlines() {
if (!modal.autoTrendlines) return;
modal.autoTrendlines.forEach(obj => {
if (obj.lineSeries) try { modal.chart.removeSeries(obj.lineSeries); } catch(e) {}
});
modal.autoTrendlines = [];
}
// ── Linear Regression Channel (auto-period via R²) ───────────────
// Scans lookback 20-200, picks best R²×log(length).
// Center = regression line, bands = ±2σ (standard deviation).
function computeRegressionChannel(candles, mult = 2.0) {
if (!candles || candles.length < 30) return [];
const n = candles.length;
const closes = candles.map(c => c.close);
// Scan multiple lookback periods, pick best by R² × log(length)
let bestScore = -Infinity, bestPeriod = 50;
const periods = [20, 30, 50, 75, 100, 150, 200].filter(p => p <= n);
for (const period of periods) {
const start = n - period;
// Linear regression on [start..n-1]
let sx = 0, sy = 0, sxy = 0, sxx = 0, syy = 0;
for (let i = start; i < n; i++) {
const x = i - start;
const y = closes[i];
sx += x; sy += y; sxy += x * y; sxx += x * x; syy += y * y;
}
const denom = period * sxx - sx * sx;
if (denom === 0) continue;
const slope = (period * sxy - sx * sy) / denom;
const intercept = (sy - slope * sx) / period;
// R² = 1 - SS_res / SS_tot
let ssRes = 0, ssTot = 0;
const meanY = sy / period;
for (let i = start; i < n; i++) {
const x = i - start;
const predicted = intercept + slope * x;
ssRes += (closes[i] - predicted) ** 2;
ssTot += (closes[i] - meanY) ** 2;
}
const r2 = ssTot > 0 ? 1 - ssRes / ssTot : 0;
const score = r2 * Math.log(period);
if (score > bestScore) { bestScore = score; bestPeriod = period; }
}
// Compute regression for best period
const start = n - bestPeriod;
let sx = 0, sy = 0, sxy = 0, sxx = 0;
for (let i = start; i < n; i++) {
const x = i - start; const y = closes[i];
sx += x; sy += y; sxy += x * y; sxx += x * x;
}
const denom = bestPeriod * sxx - sx * sx;
if (denom === 0) return [];
const slope = (bestPeriod * sxy - sx * sy) / denom;
const intercept = (sy - slope * sx) / bestPeriod;
// Standard deviation from regression line
let sumSqDev = 0;
for (let i = start; i < n; i++) {
const x = i - start;
const dev = closes[i] - (intercept + slope * x);
sumSqDev += dev * dev;
}
const sigma = Math.sqrt(sumSqDev / bestPeriod);
// Build data
const result = [];
for (let i = start; i < n; i++) {
const x = i - start;
const mid = intercept + slope * x;
result.push({
time: candles[i].time,
mid,
upper: mid + sigma * mult,
lower: mid - sigma * mult,
});
}
return result;
}
// Apply Regression Channel to mini-chart
function applyRegressionChannel(sym) {
if (!spGet('ch_regression', false)) return;
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.series || !chartObj.candleData) return;
removeRegressionChannel(sym);
const data = computeRegressionChannel(chartObj.candleData);
if (data.length < 2) return;
chartObj.regressionCh = [];
const addLine = (field, clr, dash) => {
const ls = chartObj.chart.addSeries(LightweightCharts.LineSeries, {
color: clr, lineWidth: 1, lineStyle: dash ? 2 : 0,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
ls.setData(data.map(d => ({ time: d.time, value: d[field] })));
chartObj.regressionCh.push(ls);
};
addLine('upper', 'rgba(56,189,248,0.5)', false);
addLine('mid', 'rgba(56,189,248,0.35)', true);
addLine('lower', 'rgba(56,189,248,0.5)', false);
}
function removeRegressionChannel(sym) {
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.regressionCh) return;
chartObj.regressionCh.forEach(ls => { try { chartObj.chart.removeSeries(ls); } catch(e) {} });
chartObj.regressionCh = [];
}
function applyModalRegressionChannel() {
if (!spGet('ch_regression', false)) return;
if (!modal.chart || !modal.series || !modal.candleData) return;
removeModalRegressionChannel();
const data = computeRegressionChannel(modal.candleData);
if (data.length < 2) return;
modal.regressionCh = [];
const addLine = (field, clr, dash) => {
const ls = modal.chart.addSeries(LightweightCharts.LineSeries, {
color: clr, lineWidth: dash ? 1 : 2, lineStyle: dash ? 2 : 0,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
ls.setData(data.map(d => ({ time: d.time, value: d[field] })));
modal.regressionCh.push(ls);
};
addLine('upper', 'rgba(56,189,248,0.6)', false);
addLine('mid', 'rgba(56,189,248,0.4)', true);
addLine('lower', 'rgba(56,189,248,0.6)', false);
}
function removeModalRegressionChannel() {
if (!modal.regressionCh) return;
modal.regressionCh.forEach(ls => { try { modal.chart.removeSeries(ls); } catch(e) {} });
modal.regressionCh = [];
}
// ── Keltner Channel (EMA + ATR bands) ─────────────────────────────
// EMA(20) center line ± ATR(10) × multiplier = upper/lower bands
// Mean-reversion channel: price at edge = potential bounce
function computeKeltnerChannel(candles, emaPeriod = 20, atrPeriod = 10, mult = 1.5) {
if (!candles || candles.length < Math.max(emaPeriod, atrPeriod) + 1) return [];
// 1. EMA of close prices
const closes = candles.map(c => c.close);
const emaK = 2 / (emaPeriod + 1);
const ema = new Array(candles.length);
// Seed EMA with SMA of first emaPeriod candles
let sum = 0;
for (let i = 0; i < emaPeriod; i++) sum += closes[i];
ema[emaPeriod - 1] = sum / emaPeriod;
for (let i = emaPeriod; i < candles.length; i++) {
ema[i] = closes[i] * emaK + ema[i - 1] * (1 - emaK);
}
// 2. ATR (True Range → SMA)
const trs = [0]; // first candle has no prev close
for (let i = 1; i < candles.length; i++) {
const h = candles[i].high, l = candles[i].low, pc = candles[i - 1].close;
trs.push(Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)));
}
// Rolling SMA of TR
const atr = new Array(candles.length);
const startIdx = Math.max(emaPeriod - 1, atrPeriod);
for (let i = startIdx; i < candles.length; i++) {
let s = 0;
for (let j = i - atrPeriod + 1; j <= i; j++) s += trs[j];
atr[i] = s / atrPeriod;
}
// 3. Build data points (time, mid, upper, lower)
const result = [];
for (let i = startIdx; i < candles.length; i++) {
if (ema[i] == null || atr[i] == null) continue;
result.push({
time: candles[i].time,
mid: ema[i],
upper: ema[i] + atr[i] * mult,
lower: ema[i] - atr[i] * mult,
});
}
return result;
}
// Apply Keltner Channel to a mini-chart
function applyKeltnerChannel(sym) {
if (!spGet('ch_keltner', false)) return;
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.series || !chartObj.candleData) return;
removeKeltnerChannel(sym);
const data = computeKeltnerChannel(chartObj.candleData);
if (data.length < 2) return;
chartObj.keltner = [];
// Upper band — orange, thin
const upperLs = chartObj.chart.addSeries(LightweightCharts.LineSeries, {
color: 'rgba(251,146,60,0.5)', lineWidth: 1, lineStyle: 0,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
upperLs.setData(data.map(d => ({ time: d.time, value: d.upper })));
chartObj.keltner.push(upperLs);
// Mid EMA — orange, dashed
const midLs = chartObj.chart.addSeries(LightweightCharts.LineSeries, {
color: 'rgba(251,146,60,0.35)', lineWidth: 1, lineStyle: 2,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
midLs.setData(data.map(d => ({ time: d.time, value: d.mid })));
chartObj.keltner.push(midLs);
// Lower band — orange, thin
const lowerLs = chartObj.chart.addSeries(LightweightCharts.LineSeries, {
color: 'rgba(251,146,60,0.5)', lineWidth: 1, lineStyle: 0,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
lowerLs.setData(data.map(d => ({ time: d.time, value: d.lower })));
chartObj.keltner.push(lowerLs);
}
// Remove Keltner Channel from a mini-chart
function removeKeltnerChannel(sym) {
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.keltner) return;
chartObj.keltner.forEach(ls => { try { chartObj.chart.removeSeries(ls); } catch(e) {} });
chartObj.keltner = [];
}
// Apply Keltner Channel to modal chart
function applyModalKeltnerChannel() {
if (!spGet('ch_keltner', false)) return;
if (!modal.chart || !modal.series || !modal.candleData) return;
removeModalKeltnerChannel();
const data = computeKeltnerChannel(modal.candleData);
if (data.length < 2) return;
modal.keltner = [];
const upperLs = modal.chart.addSeries(LightweightCharts.LineSeries, {
color: 'rgba(251,146,60,0.6)', lineWidth: 1, lineStyle: 0,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
upperLs.setData(data.map(d => ({ time: d.time, value: d.upper })));
modal.keltner.push(upperLs);
const midLs = modal.chart.addSeries(LightweightCharts.LineSeries, {
color: 'rgba(251,146,60,0.4)', lineWidth: 1, lineStyle: 2,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
midLs.setData(data.map(d => ({ time: d.time, value: d.mid })));
modal.keltner.push(midLs);
const lowerLs = modal.chart.addSeries(LightweightCharts.LineSeries, {
color: 'rgba(251,146,60,0.6)', lineWidth: 1, lineStyle: 0,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
lowerLs.setData(data.map(d => ({ time: d.time, value: d.lower })));
modal.keltner.push(lowerLs);
}
function removeModalKeltnerChannel() {
if (!modal.keltner) return;
modal.keltner.forEach(ls => { try { modal.chart.removeSeries(ls); } catch(e) {} });
modal.keltner = [];
}
// ==========================================
// Live WebSocket — Binance kline stream
// ==========================================
const BINANCE_WS = 'wss://fstream.binance.com/market/stream';
function updateWsIndicator(state, extra) {
let el = document.getElementById('wsStatusBadge');
if (!el) {
el = document.createElement('span');
el.id = 'wsStatusBadge';
el.style.cssText = 'font-size:10px;margin-left:6px;padding:1px 5px;border-radius:3px;vertical-align:middle;color:#fff;font-family:monospace;';
const status = document.getElementById('mcStatus');
if (status) status.parentElement.appendChild(el);
}
const colors = { open: '#22c55e', connecting: '#eab308', closed: '#ef4444', error: '#ef4444' };
el.style.background = colors[state] || '#666';
el.textContent = extra || state;
}
function wsConnect() {
if (mc.ws && mc.ws.readyState <= 1) return; // CONNECTING or OPEN
updateWsIndicator('connecting');
console.log('[MC-WS] Connecting to:', BINANCE_WS, 'TF:', mc.globalTF);
mc.ws = new WebSocket(BINANCE_WS);
mc.ws.onopen = () => {
console.log('[MC-WS] Connected, subscribing', mc.wsStreams.size, 'streams');
updateWsIndicator('open', 'WS:0');
// Subscribe any pending streams
if (mc.wsStreams.size > 0) {
const params = [...mc.wsStreams];
mc.ws.send(JSON.stringify({ method: 'SUBSCRIBE', params, id: 1 }));
console.log('[MC-WS] SUBSCRIBE sent:', params.length, 'streams:', params.slice(0, 3).join(', '), '...');
}
// Auto-resubscribe safety net: if no ticks after 5s, re-send SUBSCRIBE
setTimeout(() => {
if (mc._wsMsgCount === 0 && mc.ws && mc.ws.readyState === WebSocket.OPEN && mc.wsStreams.size > 0) {
console.warn('[MC-WS] No ticks after 5s! Re-subscribing', mc.wsStreams.size, 'streams on TF=' + mc.globalTF);
updateWsIndicator('open', 'RE-SUB');
// Re-build streams from current charts (in case globalTF changed)
const freshStreams = Object.keys(mc.charts).map(s => `${s.toLowerCase()}@kline_${mc.globalTF}`);
mc.wsStreams = new Set(freshStreams);
mc.ws.send(JSON.stringify({ method: 'SUBSCRIBE', params: freshStreams, id: Date.now() }));
document.title = `RE-SUB ${freshStreams.length} ${mc.globalTF}`;
}
}, 5000);
};
mc._wsMsgCount = 0;
mc._wsLastTick = 0;
mc.ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
// Log ALL raw messages for first 5 to debug format
if (mc._wsMsgCount < 3) {
console.log('[MC-WS] raw msg:', JSON.stringify(msg).substring(0, 200));
}
if (!msg.data || msg.data.e !== 'kline') return;
const k = msg.data.k;
const sym = k.s;
const candle = {
time: Math.floor(k.t / 1000),
open: parseFloat(k.o),
high: parseFloat(k.h),
low: parseFloat(k.l),
close: parseFloat(k.c),
};
const vol = parseFloat(k.v);
// Count WS ticks and show on badge
mc._wsMsgCount++;
mc._wsLastTick = Date.now();
const incomingInterval = k.i; // actual interval from Binance: "5m", "4h", etc.
if (mc._wsMsgCount <= 5 || mc._wsMsgCount % 100 === 0) {
updateWsIndicator('open', `WS:${mc._wsMsgCount}`);
}
// TEMP DEBUG: show tick info on first 5 ticks
if (mc._wsMsgCount <= 5) {
console.log(`[MC-WS] tick#${mc._wsMsgCount} sym=${sym} i=${incomingInterval} globalTF=${mc.globalTF} hasChart=${!!mc.charts[sym]} streams=${mc.wsStreams.size}`);
document.title = `T${mc._wsMsgCount} ${incomingInterval} ${sym.slice(0,4)} streams=${mc.wsStreams.size}`;
}
// Check price alerts (custom drawings + library DM drawings)
checkPriceAlerts(sym, candle.close);
if (typeof DM !== 'undefined' && DM && DM.checkAlerts) DM.checkAlerts(sym, candle.close);
// Update mini-chart (throttled: max 4 updates/sec per symbol)
if (mc.charts[sym]) {
const now = Date.now();
const lastUpdate = mc.charts[sym]._lastWsUpdate || 0;
if (now - lastUpdate >= 250) {
mc.charts[sym]._lastWsUpdate = now;
// Detect new bar (candle boundary) for smooth scroll
const cd = mc.charts[sym].candleData;
const isNewBar = cd && cd.length > 0 && candle.time > cd[cd.length - 1].time;
mc.charts[sym].series.update(candle);
mc.charts[sym].volSeries.update({
time: candle.time,
value: vol,
color: candle.close >= candle.open ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'
});
// New bar scroll handled by shiftVisibleRangeOnNewBar: true
// Keep dataCache in sync with live candles
if (cd) {
if (cd.length > 0 && cd[cd.length - 1].time === candle.time) {
cd[cd.length - 1] = { ...candle, volume: vol };
} else if (cd.length === 0 || candle.time > cd[cd.length - 1].time) {
cd.push({ ...candle, volume: vol });
}
}
}
}
// Update multi-chart slots
for (const slot of mch.slots) {
if (slot.sym === sym && slot.chart && slot.series) {
const slotStream = `${sym.toLowerCase()}@kline_${slot.tf}`;
const incomingStream = msg.stream || '';
if (incomingStream === slotStream) {
// Detect new bar for smooth scroll
const isSlotNewBar = slot._lastTime && candle.time > slot._lastTime;
slot.series.update(candle);
if (slot.volSeries) slot.volSeries.update({
time: candle.time,
value: vol,
color: candle.close >= candle.open ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'
});
slot._lastTime = candle.time;
// New bar scroll handled by shiftVisibleRangeOnNewBar: true
// Update header price/change
const pair = mc.allPairs.find(p => p.symbol === sym);
if (pair) {
const prec = getPricePrecision(candle.close);
const priceEl = slot.el.querySelector('.mch-slot-price');
if (priceEl) priceEl.textContent = '$' + candle.close.toFixed(prec);
}
}
}
}
// Update modal chart if same symbol & TF
if (modal.chart && modal.currentSym === sym) {
const modalStream = `${sym.toLowerCase()}@kline_${modal.currentTF}`;
const incomingStream = msg.stream || '';
if (incomingStream === modalStream || modal.wsStream === `${sym.toLowerCase()}@kline_${k.i}`) {
// Detect new bar for smooth scroll
const mcd = modal.candleData;
const isNewBar = mcd && mcd.length > 0 && candle.time > mcd[mcd.length - 1].time;
modal.series.update(candle);
if (modal.volSeries) modal.volSeries.update({
time: candle.time,
value: vol,
color: candle.close >= candle.open ? 'rgba(34,197,94,0.3)' : 'rgba(239,68,68,0.3)'
});
// Keep modal.candleData in sync with live candles
if (mcd) {
if (mcd.length > 0 && mcd[mcd.length - 1].time === candle.time) {
mcd[mcd.length - 1] = { ...candle, volume: vol };
} else if (mcd.length === 0 || candle.time > mcd[mcd.length - 1].time) {
mcd.push({ ...candle, volume: vol });
}
}
// New bar scroll handled by shiftVisibleRangeOnNewBar: true
}
}
} catch (e) { console.error('[MC-WS] onmessage error:', e.message, e.stack?.split('\n')[1]); }
};
mc.ws.onclose = (evt) => {
updateWsIndicator('closed');
if (mc._wsClosing) return; // Don't reconnect on intentional close
console.warn('[MC-WS] Disconnected (code:', evt.code, 'reason:', evt.reason || 'none', '), reconnecting in 3s...');
setTimeout(wsConnect, 3000);
};
mc.ws.onerror = (e) => {
updateWsIndicator('error');
console.error('[MC-WS] Error:', e.message || e.type || 'unknown');
};
}
// Clean up WebSocket on page unload to prevent dangling connections
window.addEventListener('beforeunload', () => {
mc._wsClosing = true;
if (mc.ws) {
mc.ws.close();
mc.ws = null;
}
});
// Debounced WS subscribe — Binance limits 10 msgs/sec per WS connection
// Collect individual subscribes for 200ms, then send as one batch
let _wsPendingSub = new Set();
let _wsSubTimer = null;
function _flushWsSubscribe() {
_wsSubTimer = null;
if (_wsPendingSub.size === 0) { console.warn('[MC-WS] _flushWsSubscribe: nothing pending'); return; }
if (!mc.ws || mc.ws.readyState !== WebSocket.OPEN) { console.error('[MC-WS] _flushWsSubscribe: WS not open! state=', mc.ws?.readyState); return; }
const params = [..._wsPendingSub];
_wsPendingSub.clear();
mc.ws.send(JSON.stringify({ method: 'SUBSCRIBE', params, id: Date.now() }));
console.log('[MC-WS] Batch subscribed', params.length, 'streams, TF=' + mc.globalTF, 'sample:', params.slice(0, 3));
}
function wsSubscribe(sym) {
const stream = `${sym.toLowerCase()}@kline_${mc.globalTF}`;
if (mc.wsStreams.has(stream)) return;
mc.wsStreams.add(stream);
if (!mc.ws || mc.ws.readyState !== WebSocket.OPEN) {
console.log(`[MC-WS] wsSubscribe ${stream}: WS not open (state=${mc.ws?.readyState}), calling wsConnect`);
wsConnect();
return; // onopen will subscribe all pending
}
// Debounce: collect for 200ms, send as one batch
_wsPendingSub.add(stream);
if (!_wsSubTimer) {
_wsSubTimer = setTimeout(_flushWsSubscribe, 200);
}
}
function wsUnsubscribeAll() {
console.log('[MC-WS] wsUnsubscribeAll: killing WS, had', mc.wsStreams.size, 'streams');
// Kill the entire WS connection — cleaner than unsub/resub
// New connection will be created by wsSubscribe() when charts reload
mc._wsClosing = true;
if (mc.ws) {
mc.ws.onclose = null; // prevent async zombie reconnect
try { mc.ws.close(); } catch(_) {}
mc.ws = null;
}
mc._wsClosing = false;
mc.wsStreams.clear();
_wsPendingSub.clear();
if (_wsSubTimer) { clearTimeout(_wsSubTimer); _wsSubTimer = null; }
}
// ==========================================
// Shift+Drag Ruler (like TradingView)
// ==========================================
function attachRuler(chartEl, chart, series) {
let rulerActive = false;
let startX = 0, startY = 0;
let startPrice = 0, startTime = 0;
let line = null, label = null;
function createOverlay() {
// SVG line
line = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
line.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:100;';
line.innerHTML = '<line x1="0" y1="0" x2="0" y2="0" stroke="#5b9cf6" stroke-width="1.5" stroke-dasharray="4,3"/>';
chartEl.appendChild(line);
// Label
label = document.createElement('div');
label.style.cssText = 'position:absolute;z-index:101;pointer-events:none;background:rgba(59,130,246,0.9);color:#fff;font-size:11px;font-weight:600;padding:3px 8px;border-radius:4px;white-space:nowrap;display:none;';
chartEl.appendChild(label);
}
function removeOverlay() {
if (line) { line.remove(); line = null; }
if (label) { label.remove(); label = null; }
}
chartEl.addEventListener('mousedown', (e) => {
if (!e.shiftKey) return;
e.preventDefault();
e.stopPropagation();
const rect = chartEl.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
// Convert pixel to price/time
startPrice = series.coordinateToPrice(startY);
startTime = chart.timeScale().coordinateToTime(startX);
if (startPrice === null) return;
rulerActive = true;
removeOverlay();
createOverlay();
// Temporarily disable chart interaction
chart.applyOptions({ handleScroll: false, handleScale: false });
});
chartEl.addEventListener('mousemove', (e) => {
if (!rulerActive || !line || !label) return;
const rect = chartEl.getBoundingClientRect();
const curX = e.clientX - rect.left;
const curY = e.clientY - rect.top;
const curPrice = series.coordinateToPrice(curY);
if (curPrice === null) return;
// Update SVG line
const svgLine = line.querySelector('line');
svgLine.setAttribute('x1', startX);
svgLine.setAttribute('y1', startY);
svgLine.setAttribute('x2', curX);
svgLine.setAttribute('y2', curY);
// Calculate diff
const priceDiff = curPrice - startPrice;
const pctDiff = startPrice !== 0 ? (priceDiff / startPrice * 100) : 0;
const prec = getPricePrecision(Math.abs(startPrice));
const sign = priceDiff >= 0 ? '+' : '';
// Calculate time duration
const curTime = chart.timeScale().coordinateToTime(curX);
let timeStr = '';
if (startTime && curTime) {
const timeDiffSec = Math.abs(curTime - startTime);
if (timeDiffSec < 3600) {
timeStr = Math.round(timeDiffSec / 60) + 'm';
} else if (timeDiffSec < 86400) {
timeStr = (timeDiffSec / 3600).toFixed(1) + 'h';
} else {
timeStr = (timeDiffSec / 86400).toFixed(1) + 'd';
}
}
// Position label at midpoint
const midX = (startX + curX) / 2;
const midY = Math.min(startY, curY) - 8;
label.style.left = midX + 'px';
label.style.top = Math.max(2, midY) + 'px';
label.style.transform = 'translateX(-50%)';
label.style.display = 'block';
label.style.background = priceDiff >= 0 ? 'rgba(34,197,94,0.92)' : 'rgba(239,68,68,0.92)';
const timeInfo = timeStr ? ` | ${timeStr}` : '';
label.textContent = `${sign}${priceDiff.toFixed(prec)} (${sign}${pctDiff.toFixed(2)}%)${timeInfo}`;
});
const endRuler = () => {
if (!rulerActive) return;
rulerActive = false;
chart.applyOptions({
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { mouseWheel: true, pinch: true }
});
// Remove after 3 seconds
setTimeout(removeOverlay, 3000);
};
chartEl.addEventListener('mouseup', endRuler);
chartEl.addEventListener('mouseleave', endRuler);
}
// ==========================================
// Coin Detail Modal
// ==========================================
const modal = {
chart: null,
series: null,
lines: [],
currentSym: null,
currentTF: '15m',
wsStream: null
};
function openCoinModal(sym) {
const pair = mc.allPairs.find(p => p.symbol === sym);
if (!pair) return;
modal.currentSym = sym;
modal.currentTF = mc.globalTF;
const ticker = sym.replace('USDT', '');
const prec = getPricePrecision(pair.lastPrice);
const chg = pair.priceChange;
const chgClass = chg >= 0 ? 'mc-metric-green' : 'mc-metric-red';
const chgSign = chg >= 0 ? '+' : '';
// Header
el('cmSymbol').textContent = ticker + '/USDT';
const cmCopyBtn = el('cmCopyBtn');
if (cmCopyBtn) {
cmCopyBtn.dataset.ticker = sym.toLowerCase();
cmCopyBtn.title = `Copy ${sym.toLowerCase()}`;
cmCopyBtn.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(sym.toLowerCase()).then(() => {
cmCopyBtn.classList.add('mc-copy-ok');
setTimeout(() => cmCopyBtn.classList.remove('mc-copy-ok'), 800);
});
};
}
el('cmPrice').textContent = '$' + pair.lastPrice.toFixed(prec);
const cmChange = el('cmChange');
cmChange.textContent = chgSign + chg.toFixed(2) + '%';
cmChange.className = 'mc-modal-change ' + chgClass;
// Metrics in header (icons, no text labels)
const vol = pair.quoteVol >= 1e9 ? (pair.quoteVol / 1e9).toFixed(2) + 'B' : (pair.quoteVol / 1e6).toFixed(1) + 'M';
const tradesStr = pair.tradesCount >= 1e6 ? (pair.tradesCount / 1e6).toFixed(1) + 'M'
: pair.tradesCount >= 1e3 ? (pair.tradesCount / 1e3).toFixed(1) + 'K'
: pair.tradesCount.toString();
el('cmStats').innerHTML = `
<span class="cm-metric" title="24h Volume"><svg width="11" height="11" viewBox="0 0 10 10" style="vertical-align:-1px;margin-right:2px"><rect x="1" y="5" width="2" height="5" fill="currentColor" opacity="0.5"/><rect x="4" y="2" width="2" height="8" fill="currentColor" opacity="0.7"/><rect x="7" y="0" width="2" height="10" fill="currentColor"/></svg>$${vol}</span>
<span class="cm-metric" title="NATR Volatility"><svg width="11" height="11" viewBox="0 0 10 10" style="vertical-align:-1px;margin-right:2px"><path d="M0 7L3 3L5 6L7 1L10 5" stroke="currentColor" stroke-width="1.3" fill="none"/></svg><span id="cmNatr">${pair.proxyNatr.toFixed(1)}%</span></span>
<span class="cm-metric" title="24h Trades"><svg width="11" height="11" viewBox="0 0 10 10" style="vertical-align:-1px;margin-right:2px"><path d="M1 3h3M6 3h3M1 7h3M6 7h3" stroke="currentColor" stroke-width="1.2"/></svg>${tradesStr}</span>
<span class="cm-metric" title="24h High"><svg width="11" height="11" viewBox="0 0 10 10" style="vertical-align:-1px;margin-right:2px"><path d="M5 1L8 5H2L5 1Z" fill="#22c55e" opacity="0.8"/></svg>${parseFloat(pair.highPrice).toFixed(prec)}</span>
<span class="cm-metric" title="24h Low"><svg width="11" height="11" viewBox="0 0 10 10" style="vertical-align:-1px;margin-right:2px"><path d="M5 9L2 5H8L5 9Z" fill="#ef4444" opacity="0.8"/></svg>${parseFloat(pair.lowPrice).toFixed(prec)}</span>
`;
// TF buttons — set active
const tfBtns = el('cmTFButtons');
tfBtns.querySelectorAll('.mc-tf-btn').forEach(b => {
b.classList.toggle('active', b.dataset.tf === modal.currentTF);
});
// Show modal (CSS: visibility:hidden→visible, NOT display:none)
el('coinModal').classList.remove('hidden');
// Create or recreate chart
if (modal._infiniteUnsub) { try { modal._infiniteUnsub(); } catch(e) {} modal._infiniteUnsub = null; }
if (modal.chart) {
modal.chart.remove();
modal.chart = null;
}
const chartEl = el('cmChartBody');
// Clean up stale DOM children (legend, ruler, toolbar) left from previous chart
chartEl.innerHTML = '';
// Re-create loader overlay (innerHTML cleared it)
const loader = document.createElement('div');
loader.id = 'cmChartLoader';
loader.className = 'cm-chart-loader';
loader.innerHTML = '<div class="cm-spinner"></div><span>Loading chart…</span>';
chartEl.appendChild(loader);
const cw = chartEl.clientWidth, ch = chartEl.clientHeight;
console.log('[modal] chartEl dimensions:', cw, 'x', ch, '| children cleared');
const minMove = parseFloat((1 / Math.pow(10, prec)).toFixed(prec));
const wmText = spGet('showWatermark', true) ? sym.replace('USDT', '/USDT') : '';
modal.chart = LightweightCharts.createChart(chartEl, {
width: cw,
height: ch,
...localChartOptions,
layout: { background: { type: 'solid', color: 'transparent' }, textColor: '#94a3b8' },
grid: getGridOpts(),
crosshair: { mode: 0 },
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)', scaleMargins: { top: 0.05, bottom: 0.05 }, minimumWidth: 50, mode: getPriceScaleMode() },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false, rightOffset: 10, shiftVisibleRangeOnNewBar: true, lockVisibleTimeRangeOnResize: true, tickMarkFormatter: localTickFormatter },
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { mouseWheel: true, pinch: true },
});
modal.series = addMainSeries(modal.chart, prec, minMove);
if (wmText) {
try {
LightweightCharts.createTextWatermark(modal.chart, { lines: [{ text: wmText, color: 'rgba(255,255,255,0.04)', fontSize: 48 }] });
} catch (e) {
console.warn('[modal] createTextWatermark failed (LWC v5 compat):', e.message);
}
}
modal.volSeries = modal.chart.addSeries(LightweightCharts.HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: 'vol',
color: 'rgba(100,116,139,0.3)',
});
modal.chart.priceScale('vol').applyOptions({
scaleMargins: { top: getVolScaleTop(), bottom: 0 },
drawTicks: false,
borderVisible: false,
});
// ResizeObserver for window resize + safety net for initial render (debounced to prevent jerk)
if (modal._resizeObserver) modal._resizeObserver.disconnect();
let _resizeTimer = 0;
modal._resizeObserver = new ResizeObserver(entries => {
const { width, height } = entries[0].contentRect;
if (width > 0 && height > 0 && modal.chart) {
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(() => {
if (modal.chart) modal.chart.resize(width, height);
}, 100);
}
});
modal._resizeObserver.observe(chartEl);
modal.lines = [];
modal.drawings = [];
// Attach ruler to modal chart
attachRuler(chartEl, modal.chart, modal.series);
// OHLCV legend on crosshair move
const legend = document.createElement('div');
legend.className = 'mc-ohlcv-legend';
chartEl.appendChild(legend);
modal.legend = legend;
modal.chart.subscribeCrosshairMove(param => {
if (!param || !param.time || !modal.legend) {
if (modal.legend) modal.legend.style.display = 'none';
return;
}
const data = param.seriesData.get(modal.series);
if (!data) { modal.legend.style.display = 'none'; return; }
const p = getPricePrecision(data.close || data.open || 1);
const o = (data.open || 0).toFixed(p);
const h = (data.high || 0).toFixed(p);
const l = (data.low || 0).toFixed(p);
const c = (data.close || 0).toFixed(p);
const volData = param.seriesData.get(modal.volSeries);
const v = volData ? (volData.value >= 1e6 ? (volData.value/1e6).toFixed(1)+'M' : (volData.value >= 1e3 ? (volData.value/1e3).toFixed(0)+'K' : volData.value.toFixed(0))) : '—';
const chg = data.close >= data.open;
const color = chg ? '#22c55e' : '#ef4444';
modal.legend.style.display = 'flex';
modal.legend.innerHTML = `<span style="color:${color}">O <b>${o}</b></span><span style="color:${color}">H <b>${h}</b></span><span style="color:${color}">L <b>${l}</b></span><span style="color:${color}">C <b>${c}</b></span><span style="color:var(--text-muted)">V <b>${v}</b></span>`;
});
setDrawCtxModal();
renderDrawToolbar();
setupDrawingHandlers();
updateModalCursor();
// Attach library DrawingManager (if loaded)
if (typeof DM !== 'undefined' && DM && DM.attach) {
try {
DM.attach(modal.chart, modal.series, chartEl, sym, () => {
// Callback: library finished drawing → reset our UI to cursor
draw.activeTool = 'cursor';
draw.clickCount = 0;
renderDrawToolbar();
updateModalCursor();
});
} catch(e) { console.warn('[DM] attach failed:', e.message); }
}
loadModalChart(sym, modal.currentTF);
startCountdown();
}
// ---- Candle Countdown Timer ----
const TF_MS = { '1m': 60000, '3m': 180000, '5m': 300000, '15m': 900000, '30m': 1800000, '1h': 3600000, '2h': 7200000, '4h': 14400000, '6h': 21600000, '8h': 28800000, '12h': 43200000, '1d': 86400000 };
function startCountdown() {
if (modal._countdownTimer) clearInterval(modal._countdownTimer);
updateCountdown();
modal._countdownTimer = setInterval(updateCountdown, 1000);
}
function updateCountdown() {
const cdEl = document.getElementById('cmChartCountdown');
if (!cdEl || !modal.currentTF) return;
const ms = TF_MS[modal.currentTF];
if (!ms) { cdEl.textContent = ''; return; }
const now = Date.now();
const remaining = ms - (now % ms);
const totalSec = Math.floor(remaining / 1000);
if (ms <= 3600000) {
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
cdEl.textContent = `${m}:${s.toString().padStart(2, '0')}`;
} else {
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
cdEl.textContent = `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
}
let _modalLoadController = null; // AbortController for current modal chart load
async function loadModalChart(sym, tf) {
// Abort previous load if still in progress
if (_modalLoadController) _modalLoadController.abort();
_modalLoadController = new AbortController();
const signal = _modalLoadController.signal;
// Loader helpers
const loaderEl = document.getElementById('cmChartLoader');
const showLoader = () => { if (loaderEl) { loaderEl.innerHTML = '<div class="cm-spinner"></div><span>Loading chart…</span>'; loaderEl.classList.remove('hidden'); } };
const hideLoader = () => { if (loaderEl) loaderEl.classList.add('hidden'); };
const showError = (msg) => {
if (loaderEl) {
loaderEl.innerHTML = `<div class="cm-chart-error">
<div style="margin-bottom:8px">⚠ ${msg}</div>
<button onclick="loadModalChart('${sym}','${tf}')">Retry</button>
</div>`;
loaderEl.classList.remove('hidden');
}
};
showLoader();
// Unsubscribe previous modal WS stream
if (modal.wsStream) {
if (mc.ws && mc.ws.readyState === WebSocket.OPEN) {
mc.ws.send(JSON.stringify({ method: 'UNSUBSCRIBE', params: [modal.wsStream], id: Date.now() }));
}
mc.wsStreams.delete(modal.wsStream);
modal.wsStream = null;
}
try {
// Phase 1: fast — 1000 candles (pre-warmed in server cache)
const res1 = await fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=1000`, { signal });
if (!res1.ok) throw new Error(`HTTP ${res1.status}`);
const json1 = await res1.json();
if (signal.aborted || !Array.isArray(json1) || !modal.chart) return;
if (json1.length === 0) { showError('No data for ' + sym); return; }
hideLoader();
const data1 = parseKlines(json1);
modal.candleData = data1;
if (drawCtx.source === 'modal') drawCtx.candleData = data1;
console.log('[modal] setData:', data1.length, 'candles, chart size:', modal.chart.timeScale().width(), 'x', el('cmChartBody').clientHeight);
modal.series.setData(data1);
if (modal.volSeries) modal.volSeries.setData(extractVolume(data1));
// Show last ~100 candles in viewport
const visFrom = Math.max(0, data1.length - 100);
const visTo = data1.length - 1 + 10;
modal.chart.timeScale().setVisibleLogicalRange({ from: visFrom, to: visTo });
console.log('[modal] visibleRange set:', visFrom, '-', visTo);
// Safety net: if chart internal width was 0 when range was set,
// re-apply range after layout settles (fixes LWC v5 first-open bug)
const tsWidth = modal.chart.timeScale().width();
if (tsWidth <= 0) {
console.log('[modal] timeScale width=0, scheduling fitContent fallback');
setTimeout(() => {
if (!modal.chart) return;
const w2 = modal.chart.timeScale().width();
console.log('[modal] fallback: timeScale width now=', w2);
if (w2 > 0 && modal.candleData) {
const vf = Math.max(0, modal.candleData.length - 100);
modal.chart.timeScale().setVisibleLogicalRange({ from: vf, to: modal.candleData.length - 1 + 10 });
} else {
modal.chart.timeScale().fitContent();
}
}, 150);
}
// Update modal NATR with real value
const modalNatr = calcNATR(data1);
const cmNatrEl = document.getElementById('cmNatr');
if (cmNatrEl && modalNatr > 0) cmNatrEl.textContent = modalNatr.toFixed(1) + '%';
// Restore saved drawings for this symbol
restoreDrawings();
// Signal markers on modal chart
// Show all signals for this symbol (not just pending one)
const isSignalsTab = document.getElementById('tab-signals')?.style.display !== 'none';
applySignalMarkers(sym, modal.series, data1, modal, isSignalsTab || !!window._pendingSignalMarker);
// Apply density walls to modal
applyDensityToModal();
// Apply auto S/R levels + trendlines + channels to modal
applyModalAutoLevels();
applyModalAutoTrendlines();
applyModalKeltnerChannel();
applyModalRegressionChannel();
// Apply OI overlay if enabled
applyOIOverlay(modal.chart, sym);
// Subscribe modal to live WS
const stream = `${sym.toLowerCase()}@kline_${tf}`;
modal.wsStream = stream;
mc.wsStreams.add(stream);
if (mc.ws && mc.ws.readyState === WebSocket.OPEN) {
mc.ws.send(JSON.stringify({ method: 'SUBSCRIBE', params: [stream], id: Date.now() }));
} else {
wsConnect();
}
// Phase 2: background prepend history in chunks (official TradingView pattern)
// Prepending via setData does NOT cause jumps (fixed in LWC PR #555)
if (modal._infiniteUnsub) { try { modal._infiniteUnsub(); } catch(e) {} }
modal._infiniteUnsub = setupInfiniteScroll(
modal.chart, modal.series, modal.volSeries, sym, tf,
() => modal.candleData,
(newData) => {
modal.candleData = newData;
if (drawCtx.source === 'modal') drawCtx.candleData = newData;
}
);
// Auto-load history in background (prepend chunks, no jumps)
setTimeout(async () => {
if (!modal.chart || modal.currentSym !== sym || modal.currentTF !== tf) return;
const TARGET = 20000;
let attempts = 0;
while (attempts < 14) {
if (!modal.chart || modal.currentSym !== sym || modal.currentTF !== tf) break;
const cd = modal.candleData;
if (!cd || cd.length >= TARGET) break;
const oldest = cd[0].time * 1000;
try {
const res = await fetch(`/api/klines?symbol=${sym}&interval=${tf}&limit=1500&endTime=${oldest - 1}`);
const json = await res.json();
if (!Array.isArray(json) || json.length === 0) break;
const olderData = parseKlines(json);
const fresh = modal.candleData || cd;
const filtered = olderData.filter(c => c.time < fresh[0].time);
if (filtered.length === 0) break;
const newData = [...filtered, ...fresh];
modal.candleData = newData;
if (drawCtx.source === 'modal') drawCtx.candleData = newData;
// Save viewport, restore shifted by prepend count
const mRangeBefore = modal.chart.timeScale().getVisibleLogicalRange();
const mPrepend = filtered.length;
modal.series.setData(newData);
if (modal.volSeries) modal.volSeries.setData(extractVolume(newData));
if (mRangeBefore) {
modal.chart.timeScale().setVisibleLogicalRange({
from: mRangeBefore.from + mPrepend,
to: mRangeBefore.to + mPrepend
});
}
console.log(`[Modal] ${sym} prepend +${filtered.length} → ${newData.length}`);
attempts++;
await new Promise(r => setTimeout(r, 100));
} catch(e) { break; }
}
}, 500);
console.log(`[Modal] ${sym} loaded ${data1.length} candles, background loading started`);
} catch (e) {
if (e.name === 'AbortError') return; // Cancelled by new TF click — expected
console.error('Modal chart error:', e);
showError('Failed to load chart');
}
}
// ---- OI Indicator (bottom pane, like TradingView) ----
const OI_PANE_HEIGHT = 0.10; // 10% of chart height for OI pane
const OI_TF_MAP = { '1m': '5m', '3m': '5m', '5m': '5m', '15m': '15m', '30m': '30m', '1h': '1h', '2h': '2h', '4h': '4h', '6h': '6h', '8h': '4h', '12h': '1d', '1d': '1d' };
const OI_FMT = (v) => v >= 1e9 ? (v/1e9).toFixed(1) + 'B' : v >= 1e6 ? (v/1e6).toFixed(0) + 'M' : v.toFixed(0);
// Adjust candle + volume margins when OI pane is toggled
function adjustChartMargins(chartObj, hasOI) {
// chartObj = { chart, series, volSeries } — works for modal, mc.charts[sym], mch.slots[i]
if (!chartObj || !chartObj.chart) return;
const bot = hasOI ? OI_PANE_HEIGHT + 0.02 : 0.05;
chartObj.chart.priceScale('right').applyOptions({
scaleMargins: { top: 0.05, bottom: bot },
});
if (chartObj.volSeries) {
const volH = spGet('volumeHeight', 15) / 100;
const volTop = hasOI ? (1 - OI_PANE_HEIGHT - 0.02 - volH) : (1 - volH);
const volBot = hasOI ? (OI_PANE_HEIGHT + 0.02) : 0;
try {
chartObj.chart.priceScale('vol').applyOptions({
scaleMargins: { top: volTop, bottom: volBot },
});
} catch(e) {}
}
}
// Generic: add OI line to any chart object. chartObj must have { chart, oiSeries? }
async function applyOI(chartObj, sym, tf) {
if (!chartObj || !chartObj.chart) return;
// Remove existing
if (chartObj.oiSeries) {
try { chartObj.chart.removeSeries(chartObj.oiSeries); } catch(e) {}
chartObj.oiSeries = null;
}
const enabled = spGet('indicatorOI', false);
if (!enabled || !sym) {
adjustChartMargins(chartObj, false);
return;
}
const period = OI_TF_MAP[tf || '5m'] || '5m';
try {
const res = await fetch(`/api/oi-history?symbol=${sym}&period=${period}&limit=500`);
const data = await res.json();
if (!Array.isArray(data) || data.length === 0) {
adjustChartMargins(chartObj, false);
return;
}
const color = spGet('indicatorOIColor', '#eab308');
chartObj.oiSeries = chartObj.chart.addSeries(LightweightCharts.LineSeries, {
color: color,
lineWidth: 1.5,
priceScaleId: 'oi',
priceFormat: { type: 'custom', formatter: OI_FMT },
title: 'OI',
lastValueVisible: true,
priceLineVisible: false,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 3,
});
chartObj.chart.priceScale('oi').applyOptions({
scaleMargins: { top: 1 - OI_PANE_HEIGHT, bottom: 0 },
drawTicks: false,
borderVisible: false,
entireTextOnly: true,
});
const oiData = data.map(d => ({
time: Math.floor(d.timestamp / 1000),
value: parseFloat(d.sumOpenInterestValue || d.sumOpenInterest || 0),
}));
chartObj.oiSeries.setData(oiData);
adjustChartMargins(chartObj, true);
} catch (e) {
console.error('[OI] Error:', sym, e);
adjustChartMargins(chartObj, false);
}
}
// Wrapper for modal (backward compat)
async function applyOIOverlay(chart, sym) {
await applyOI(modal, sym, modal.currentTF);
}
// Batch OI for mini-charts (throttled, non-blocking)
async function applyOIToBatch(symbols) {
if (!spGet('indicatorOI', false)) return;
for (const sym of symbols) {
const c = mc.charts[sym];
if (!c || !c.chart) continue;
applyOI(c, sym, mc.globalTF); // fire-and-forget, no await to avoid blocking
await new Promise(r => setTimeout(r, 50)); // 50ms throttle between fetches
}
}
// drawModalLevels replaced by applyModalAutoLevels() above
// ==========================================
// Drawing Tools — Modal only
// ==========================================
const DRAW_TOOLS = [
{ id: 'cursor', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M6 3v15.5l4-4.5 3.5 6.5 2-1-3.5-6.5H18L6 3z" fill="currentColor" fill-opacity="0.12" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>', title: 'Cursor (Esc)', key: 'Escape' },
{ id: 'hline', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><line x1="2" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2"/><circle cx="6" cy="12" r="2.5" stroke="currentColor" stroke-width="1.5" fill="#1e222d"/><circle cx="18" cy="12" r="2.5" stroke="currentColor" stroke-width="1.5" fill="#1e222d"/></svg>', title: 'Horizontal Line (H)', key: 'h' },
{ id: 'ray', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><circle cx="5" cy="12" r="2.5" stroke="currentColor" stroke-width="1.5" fill="#1e222d"/><line x1="7.5" y1="12" x2="22" y2="12" stroke="currentColor" stroke-width="2"/><path d="M19 9l3 3-3 3" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" fill="none"/></svg>', title: 'Horizontal Ray (R)', key: 'r' },
{ id: 'trendline', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><line x1="4" y1="18" x2="20" y2="6" stroke="currentColor" stroke-width="2"/><circle cx="4" cy="18" r="2.5" stroke="currentColor" stroke-width="1.5" fill="#1e222d"/><circle cx="20" cy="6" r="2.5" stroke="currentColor" stroke-width="1.5" fill="#1e222d"/></svg>', title: 'Trend Line (T)', key: 't' },
{ id: 'fib', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><line x1="3" y1="4" x2="21" y2="4" stroke="currentColor" stroke-width="1.8"/><line x1="3" y1="9.5" x2="21" y2="9.5" stroke="currentColor" stroke-width="1" opacity="0.5" stroke-dasharray="3 2"/><line x1="3" y1="14.5" x2="21" y2="14.5" stroke="currentColor" stroke-width="1" opacity="0.5" stroke-dasharray="3 2"/><line x1="3" y1="20" x2="21" y2="20" stroke="currentColor" stroke-width="1.8"/><text x="2" y="8" font-size="6" font-weight="600" fill="currentColor" opacity="0.6" font-family="sans-serif">0</text><text x="2" y="19" font-size="6" font-weight="600" fill="currentColor" opacity="0.6" font-family="sans-serif">1</text></svg>', title: 'Fibonacci (F)', key: 'f' },
{ id: 'rect', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><rect x="4" y="5" width="16" height="14" rx="1" stroke="currentColor" stroke-width="1.8" fill="currentColor" fill-opacity="0.06"/><circle cx="4" cy="5" r="2" stroke="currentColor" stroke-width="1.3" fill="#1e222d"/><circle cx="20" cy="5" r="2" stroke="currentColor" stroke-width="1.3" fill="#1e222d"/><circle cx="4" cy="19" r="2" stroke="currentColor" stroke-width="1.3" fill="#1e222d"/><circle cx="20" cy="19" r="2" stroke="currentColor" stroke-width="1.3" fill="#1e222d"/></svg>', title: 'Rectangle (B)', key: 'b' },
{ id: 'ruler', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M4 20L20 4" stroke="currentColor" stroke-width="2"/><path d="M4 20v-7" stroke="currentColor" stroke-width="1.2" opacity="0.4" stroke-dasharray="2 2"/><path d="M4 13h4" stroke="currentColor" stroke-width="1" opacity="0.35"/><path d="M4 16h3" stroke="currentColor" stroke-width="1" opacity="0.35"/><path d="M20 4h-7" stroke="currentColor" stroke-width="1.2" opacity="0.4" stroke-dasharray="2 2"/><path d="M13 4v4" stroke="currentColor" stroke-width="1" opacity="0.35"/><path d="M16 4v3" stroke="currentColor" stroke-width="1" opacity="0.35"/></svg>', title: 'Ruler / Measure (M)', key: 'm' },
{ id: 'trash', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M6 7h12l-1.2 12.5a1 1 0 01-1 .9H8.2a1 1 0 01-1-.9L6 7z" stroke="currentColor" stroke-width="1.5" fill="currentColor" fill-opacity="0.04"/><path d="M4 7h16" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M9 7V5a1 1 0 011-1h4a1 1 0 011 1v2" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="10" x2="10" y2="17" stroke="currentColor" stroke-width="1.2" opacity="0.4"/><line x1="14" y1="10" x2="14" y2="17" stroke="currentColor" stroke-width="1.2" opacity="0.4"/></svg>', title: 'Clear All', key: 'Delete' },
];
const DRAW_COLORS = ['#5b9cf6', '#ef4444', '#f97316', '#eab308', '#22c55e', '#a855f7', '#ec4899', '#ffffff'];
let drawIdCounter = 0;
// Persistent drawing storage (localStorage)
const drawStore = (() => {
const KEY = 'mc_drawings';
let _cache = null; // in-memory cache to avoid localStorage parse on every WS tick
function loadAll() {
if (_cache) return _cache;
try { _cache = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch(e) { _cache = {}; }
return _cache;
}
function saveAll(store) {
_cache = store;
lsSet(KEY, JSON.stringify(store));
}
return {
save(sym, drawings) {
const store = loadAll();
store[sym] = drawings.map(d => ({
type: d.type, color: d.color, locked: d.locked,
data: d.data, alert: d.alert || false
}));
saveAll(store);
},
load(sym) {
const store = loadAll();
return store[sym] || [];
},
loadAll,
invalidateCache() { _cache = null; },
remove(sym) {
const store = loadAll();
delete store[sym];
saveAll(store);
}
};
})();
// ==========================================
// Price Alert System
// ==========================================
const alertState = {
// Track last known price per symbol to detect crossings
lastPrices: {},
// Cooldown per alert to avoid spam (key: "sym:price", value: timestamp)
cooldowns: {},
COOLDOWN_MS: 60000, // 1 min cooldown per alert
};
function checkPriceAlerts(sym, currentPrice) {
const allDrawings = drawStore.loadAll();
const symDrawings = allDrawings[sym];
if (!symDrawings || symDrawings.length === 0) return;
const lastPrice = alertState.lastPrices[sym];
alertState.lastPrices[sym] = currentPrice;
if (lastPrice === undefined) return; // first tick, no crossing possible
symDrawings.forEach(d => {
if (!d.alert) return;
if (d.type !== 'hline' && d.type !== 'ray') return;
const alertPrice = d.data.price;
if (!alertPrice) return;
// Check crossing: last was below, now above (or vice versa)
const crossedUp = lastPrice < alertPrice && currentPrice >= alertPrice;
const crossedDown = lastPrice > alertPrice && currentPrice <= alertPrice;
if (!crossedUp && !crossedDown) return;
// Cooldown check
const cooldownKey = `${sym}:${alertPrice.toFixed(8)}`;
const now = Date.now();
if (alertState.cooldowns[cooldownKey] && now - alertState.cooldowns[cooldownKey] < alertState.COOLDOWN_MS) return;
alertState.cooldowns[cooldownKey] = now;
const direction = crossedUp ? '▲ Crossed Above' : '▼ Crossed Below';
const ticker = sym.replace('USDT', '');
showAlertToast(sym, ticker, currentPrice, alertPrice, direction, d.color);
});
}
function showAlertToast(sym, ticker, currentPrice, alertPrice, direction, color) {
// Remove old toasts if too many
const existing = document.querySelectorAll('.alert-toast');
if (existing.length >= 5) existing[0].remove();
const prec = getPricePrecision(currentPrice);
const toast = document.createElement('div');
toast.className = 'alert-toast';
toast.style.borderLeftColor = color || '#5b9cf6';
toast.innerHTML = `
<div class="alert-toast-header">
<span class="alert-toast-icon">🔔</span>
<span class="alert-toast-sym">${ticker}/USDT</span>
<button class="alert-toast-close">×</button>
</div>
<div class="alert-toast-body">
<div class="alert-toast-dir" style="color:${direction.includes('Above') ? '#22c55e' : '#ef4444'}">${direction}</div>
<div class="alert-toast-price">Level: <b>$${alertPrice.toFixed(prec)}</b></div>
<div class="alert-toast-current">Price: <b>$${currentPrice.toFixed(prec)}</b></div>
</div>
`;
// Click toast → open modal
toast.addEventListener('click', (e) => {
if (e.target.closest('.alert-toast-close')) {
toast.remove();
return;
}
toast.remove();
openCoinModal(sym);
});
// Close button
toast.querySelector('.alert-toast-close').addEventListener('click', (e) => {
e.stopPropagation();
toast.remove();
});
document.body.appendChild(toast);
// Auto-remove after 10s
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 10000);
// Also try browser Notification API
if (Notification.permission === 'granted') {
try {
new Notification(`${ticker}/USDT — ${direction}`, {
body: `Level: $${alertPrice.toFixed(prec)} | Price: $${currentPrice.toFixed(prec)}`,
icon: '🔔',
tag: `alert-${sym}-${alertPrice}`,
});
} catch(e) {}
}
}
// Fibonacci levels config (customizable, persisted in localStorage)
// Format: [{level: 0, color: '#4caf50'}, ...] — each level has its own color
const FIB_DEFAULT_COLORS = ['#787b86', '#f44336', '#ff9800', '#4caf50', '#2196f3', '#9c27b0', '#787b86', '#e91e63', '#00bcd4', '#8bc34a'];
const FIB_DEFAULTS_OBJ = [
{ level: 0, color: '#4caf50' },
{ level: 0.236, color: '#2196f3' },
{ level: 0.382, color: '#ff9800' },
{ level: 0.5, color: '#f59e0b' },
{ level: 0.618, color: '#ff9800' },
{ level: 0.786, color: '#2196f3' },
{ level: 1, color: '#4caf50' },
];
const fibConfig = (() => {
const KEY = 'mc_fib_levels';
function load() {
try {
const saved = JSON.parse(localStorage.getItem(KEY));
if (Array.isArray(saved) && saved.length > 0) {
// Migration: old format was plain numbers → convert to {level, color}
if (typeof saved[0] === 'number') {
return saved.map((lvl, i) => ({ level: lvl, color: FIB_DEFAULT_COLORS[i % FIB_DEFAULT_COLORS.length] }));
}
return saved;
}
} catch(e) {}
return FIB_DEFAULTS_OBJ.map(o => ({ ...o }));
}
function save(levels) {
lsSet(KEY, JSON.stringify(levels));
}
// Helper: extract plain level numbers from config
function levels(cfg) { return (cfg || load()).map(o => typeof o === 'number' ? o : o.level); }
function colorAt(cfg, i) { const c = cfg || load(); const item = c[i]; return (item && item.color) || FIB_DEFAULT_COLORS[i % FIB_DEFAULT_COLORS.length]; }
return { load, save, levels, colorAt };
})();
let drawMagnet = localStorage.getItem('fs_magnet') !== 'false'; // default ON
const draw = {
activeTool: 'cursor',
clickCount: 0,
startPrice: 0,
startTime: 0,
tempLine: null,
drawings: [], // { id, type, color, locked, priceLine/lineSeries/fibLines, data }
overlay: null, // canvas overlay for live preview
selected: null, // selected drawing id
dragging: false, // drag in progress
justDragged: false, // suppress click after drag
dragStartY: 0,
dragStartPrice: 0,
};
// Drawing Context — points to the active chart for drawing tools
// All drawing functions use drawCtx instead of hardcoded modal.*
// Switched when user activates a multi-chart slot or opens modal
const drawCtx = {
chart: null, // LightweightCharts instance
series: null, // main candle series
candleData: null,// array of OHLCV
chartEl: null, // DOM element containing the chart
sym: null, // current symbol
tf: null, // current timeframe
source: 'modal', // 'modal' | 'slot:0' | 'slot:1' etc
};
function setDrawCtx(source, chart, series, candleData, chartEl, sym, tf) {
drawCtx.chart = chart;
drawCtx.series = series;
drawCtx.candleData = candleData;
drawCtx.chartEl = chartEl;
drawCtx.sym = sym;
drawCtx.tf = tf;
drawCtx.source = source;
}
function setDrawCtxModal() {
setDrawCtx('modal', modal.chart, modal.series, modal.candleData, el('cmChartBody'), modal.currentSym, modal.currentTF);
}
function setDrawCtxSlot(slotIndex) {
const slot = mch.slots[slotIndex];
if (!slot || !slot.chart) return;
const chartEl = el('mch-chart-' + slotIndex);
setDrawCtx('slot:' + slotIndex, slot.chart, slot.series, slot.candleData || null, chartEl, slot.sym, slot.tf);
}
function setDrawCtxMini(sym) {
const c = mc.charts[sym];
if (!c || !c.chart) return;
const chartEl = el('mc-body-' + sym);
setDrawCtx('mini:' + sym, c.chart, c.series, c.candleData || null, chartEl, sym, mc.globalTF);
}
function renderDrawToolbar(targetEl) {
const chartEl = targetEl || drawCtx.chartEl || el('cmChartBody');
if (!chartEl) return;
// Remove old toolbar if exists
const old = chartEl.querySelector('.dt-tools');
if (old) old.remove();
const container = document.createElement('div');
container.className = 'dt-tools';
container.innerHTML = DRAW_TOOLS.map(t => {
const active = draw.activeTool === t.id ? ' dt-active' : '';
return `<button class="dt-btn${active}" data-tool="${t.id}" title="${t.title}">${t.icon}</button>`;
}).join('') + `<div class="dt-divider"></div><button class="dt-btn dt-magnet${drawMagnet ? ' dt-active' : ''}" data-tool="magnet" title="Magnet (snap to OHLC)"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M5 4h4v8a3 3 0 006 0V4h4v8a7 7 0 01-14 0V4z" stroke="currentColor" stroke-width="1.6" fill="currentColor" fill-opacity="0.08"/><rect x="4.5" y="2" width="5" height="3.5" rx="0.8" fill="currentColor" opacity="0.25"/><rect x="14.5" y="2" width="5" height="3.5" rx="0.8" fill="currentColor" opacity="0.25"/></svg></button>`;
chartEl.appendChild(container);
// Magnet toggle
const magnetBtn = container.querySelector('.dt-magnet');
if (magnetBtn) {
magnetBtn.addEventListener('click', (e) => {
e.stopPropagation();
drawMagnet = !drawMagnet;
lsSet('fs_magnet', drawMagnet);
magnetBtn.classList.toggle('dt-active', drawMagnet);
});
}
container.querySelectorAll('.dt-btn:not(.dt-magnet)').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const tool = btn.dataset.tool;
if (tool === 'trash') {
// Library drawings + custom drawings
if (typeof DM !== 'undefined' && DM && DM.isActive()) DM.clearAll();
clearAllDrawings();
return;
}
draw.activeTool = tool;
draw.clickCount = 0;
removePreviewOverlay();
if (tool !== 'ruler') removeRulerMeasurement();
// Route to library DrawingManager if available (hline/ray/trendline/fib/rect)
if (typeof DM !== 'undefined' && DM && DM.isActive() && tool !== 'ruler') {
DM.setTool(tool);
}
renderDrawToolbar();
updateModalCursor();
});
});
}
function updateModalCursor() {
const chartEl = drawCtx.chartEl || el('cmChartBody');
if (!chartEl) return;
const chart = drawCtx.chart || modal.chart;
if (draw.activeTool === 'cursor') {
chartEl.style.cursor = '';
if (chart) {
chart.applyOptions({
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
handleScale: { mouseWheel: true, pinch: true, axisPressedMouseMove: true },
});
}
} else {
chartEl.style.cursor = 'crosshair';
if (chart) {
chart.applyOptions({
handleScroll: false,
handleScale: false,
});
}
}
}
// Keyboard shortcuts for tools
document.addEventListener('keydown', (e) => {
if (!drawCtx.chart && !modal.chart) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
const tool = DRAW_TOOLS.find(t => t.key && t.key.toLowerCase() === e.key.toLowerCase());
if (tool) {
if (tool.id === 'Escape') {
draw.activeTool = 'cursor';
} else {
draw.activeTool = tool.id;
}
draw.clickCount = 0;
removePreviewOverlay();
// Route to library DrawingManager
if (typeof DM !== 'undefined' && DM && DM.isActive() && draw.activeTool !== 'ruler') {
DM.setTool(draw.activeTool);
}
renderDrawToolbar();
updateModalCursor();
}
if (e.key === 'Delete' && (drawCtx.chart || modal.chart)) {
// Try library first, then custom
if (typeof DM !== 'undefined' && DM && DM.isActive()) DM.deleteSelected();
if (draw.selected !== null) {
deleteDrawing(draw.selected);
} else {
clearAllDrawings();
}
}
});
function clearAllDrawings() {
draw.drawings.forEach(d => removeDrawingFromChart(d));
draw.drawings = [];
draw.clickCount = 0;
draw.selected = null;
hideDrawingPanel();
removePreviewOverlay();
removeRulerMeasurement();
persistDrawings();
}
function deleteDrawing(id) {
const idx = draw.drawings.findIndex(d => d.id === id);
if (idx === -1) return;
const d = draw.drawings[idx];
removeDrawingFromChart(d);
draw.drawings.splice(idx, 1);
if (draw.selected === id) {
draw.selected = null;
hideDrawingPanel();
}
persistDrawings();
}
function removeDrawingFromChart(d) {
const _s = drawCtx.series || modal.series;
const _c = drawCtx.chart || modal.chart;
if (d.priceLine && _s) {
try { _s.removePriceLine(d.priceLine); } catch(e) {}
}
if (d.lineSeries && _c) {
try { _c.removeSeries(d.lineSeries); } catch(e) {}
}
if (d.fibLines && _s) {
d.fibLines.forEach(fl => {
try { _s.removePriceLine(fl); } catch(e) {}
});
}
if (d.rectLines && _c) {
d.rectLines.forEach(ls => {
try { _c.removeSeries(ls); } catch(e) {}
});
}
if (d.fillSeries && _c) {
try { _c.removeSeries(d.fillSeries); } catch(e) {}
}
if (d.bottomPriceLine && _s) {
try { _s.removePriceLine(d.bottomPriceLine); } catch(e) {}
}
}
function selectDrawing(id) {
draw.selected = id;
const d = draw.drawings.find(dd => dd.id === id);
if (!d) return;
showDrawingPanel(d);
}
function deselectDrawing() {
draw.selected = null;
hideDrawingPanel();
}
function showDrawingPanel(d) {
hideDrawingPanel();
const chartEl = drawCtx.chartEl || el('cmChartBody');
if (!chartEl) return;
const panel = document.createElement('div');
panel.id = 'drawPanel';
panel.className = 'draw-panel';
// Stop ALL events on the panel from reaching chart handlers
panel.addEventListener('click', (e) => e.stopPropagation());
panel.addEventListener('mousedown', (e) => e.stopPropagation());
panel.addEventListener('mouseup', (e) => e.stopPropagation());
panel.addEventListener('touchstart', (e) => e.stopPropagation());
panel.addEventListener('touchend', (e) => e.stopPropagation());
// Color dots
const colorsHtml = DRAW_COLORS.map(c => {
const sel = c === d.color ? ' draw-color-active' : '';
return `<div class="draw-color-dot${sel}" data-color="${c}" style="background:${c};"></div>`;
}).join('');
const lockIcon = d.locked ? '🔒' : '🔓';
const fibBtn = d.type === 'fib' ? `<button class="draw-panel-btn" data-action="fib-settings" title="Fib Levels">⚙</button>` : '';
const alertBtn = (d.type === 'hline' || d.type === 'ray')
? `<button class="draw-panel-btn${d.alert ? ' draw-alert-active' : ''}" data-action="alert" title="${d.alert ? 'Disable Alert' : 'Enable Alert'}">🔔</button>`
: '';
panel.innerHTML = `
<div class="draw-panel-colors">${colorsHtml}</div>
${alertBtn}
${fibBtn}
<button class="draw-panel-btn" data-action="lock" title="${d.locked ? 'Unlock' : 'Lock'}">${lockIcon}</button>
<button class="draw-panel-btn draw-panel-delete" data-action="delete" title="Delete">✕</button>
`;
chartEl.appendChild(panel);
// Color click
panel.querySelectorAll('.draw-color-dot').forEach(dot => {
dot.addEventListener('click', () => changeDrawingColor(d.id, dot.dataset.color));
});
// Lock
panel.querySelector('[data-action="lock"]').addEventListener('click', () => {
d.locked = !d.locked;
persistDrawings();
showDrawingPanel(d);
});
// Delete
panel.querySelector('[data-action="delete"]').addEventListener('click', () => deleteDrawing(d.id));
// Alert toggle
const alertToggle = panel.querySelector('[data-action="alert"]');
if (alertToggle) {
alertToggle.addEventListener('click', () => {
d.alert = !d.alert;
persistDrawings();
showDrawingPanel(d); // refresh
// Request browser notification permission
if (d.alert && Notification.permission === 'default') {
Notification.requestPermission();
}
});
}
// Fib settings
const fibSettingsBtn = panel.querySelector('[data-action="fib-settings"]');
if (fibSettingsBtn) {
fibSettingsBtn.addEventListener('click', () => showFibLevelsPopup(d));
}
}
function showFibColorPicker(dotEl) {
// Remove old picker if exists
const oldPicker = document.getElementById('fibColorPicker');
if (oldPicker) oldPicker.remove();
const colors = ['#4caf50', '#2196f3', '#ff9800', '#f59e0b', '#f44336', '#9c27b0', '#e91e63', '#00bcd4', '#8bc34a', '#787b86', '#ffffff', '#5b9cf6'];
const picker = document.createElement('div');
picker.id = 'fibColorPicker';
picker.className = 'fib-color-picker';
// Position relative to dot
const dotRect = dotEl.getBoundingClientRect();
const popupEl = dotEl.closest('.fib-levels-popup');
const popupRect = popupEl.getBoundingClientRect();
picker.style.top = (dotRect.top - popupRect.top + dotRect.height + 4) + 'px';
picker.style.left = (dotRect.left - popupRect.left) + 'px';
picker.innerHTML = colors.map(c =>
`<span class="fib-color-opt${c === dotEl.dataset.color ? ' active' : ''}" data-c="${c}" style="background:${c}"></span>`
).join('');
picker.addEventListener('click', (e) => {
e.stopPropagation();
const opt = e.target.closest('.fib-color-opt');
if (!opt) return;
const newColor = opt.dataset.c;
dotEl.style.background = newColor;
dotEl.dataset.color = newColor;
picker.remove();
});
picker.addEventListener('mousedown', (e) => e.stopPropagation());
popupEl.appendChild(picker);
// Close on outside click
const closeHandler = (ev) => {
if (!picker.contains(ev.target) && ev.target !== dotEl) {
picker.remove();
document.removeEventListener('click', closeHandler, true);
}
};
setTimeout(() => document.addEventListener('click', closeHandler, true), 10);
}
function showFibLevelsPopup(d) {
// Remove old popup if exists
const oldPopup = document.getElementById('fibLevelsPopup');
if (oldPopup) oldPopup.remove();
const chartEl = el('cmChartBody');
if (!chartEl) return;
const rawLevels = d.data.levels || fibConfig.load();
// Normalize: support both old format (number[]) and new ({level,color}[])
const levels = rawLevels.map((item, i) => {
if (typeof item === 'number') return { level: item, color: FIB_DEFAULT_COLORS[i % FIB_DEFAULT_COLORS.length] };
return { level: item.level, color: item.color || FIB_DEFAULT_COLORS[i % FIB_DEFAULT_COLORS.length] };
});
const popup = document.createElement('div');
popup.id = 'fibLevelsPopup';
popup.className = 'fib-levels-popup';
popup.innerHTML = `
<div class="fib-popup-title">Fibonacci Levels</div>
<div class="fib-popup-list" id="fibLevelsList">
${levels.map((item, i) => `
<div class="fib-level-row" data-idx="${i}">
<span class="fib-level-color" style="background:${item.color}" data-color="${item.color}" title="Change color"></span>
<input type="number" class="fib-level-input" value="${(item.level * 100).toFixed(1)}" step="0.1" />
<span class="fib-level-pct">%</span>
<button class="fib-level-remove" title="Remove">×</button>
</div>
`).join('')}
</div>
<div class="fib-popup-actions">
<button class="fib-popup-btn fib-add-btn" id="fibAddLevel">+ Add</button>
<button class="fib-popup-btn fib-reset-btn" id="fibResetLevels">Reset</button>
<button class="fib-popup-btn fib-apply-btn" id="fibApplyLevels">Apply</button>
</div>
`;
// Block all events from reaching chart handlers
popup.addEventListener('click', (e) => e.stopPropagation());
popup.addEventListener('mousedown', (e) => e.stopPropagation());
popup.addEventListener('mouseup', (e) => e.stopPropagation());
popup.addEventListener('touchstart', (e) => e.stopPropagation());
popup.addEventListener('touchend', (e) => e.stopPropagation());
chartEl.appendChild(popup);
// Add level
popup.querySelector('#fibAddLevel').addEventListener('click', (e) => {
e.stopPropagation();
const list = popup.querySelector('#fibLevelsList');
const idx = list.children.length;
const defColor = FIB_DEFAULT_COLORS[idx % FIB_DEFAULT_COLORS.length];
const row = document.createElement('div');
row.className = 'fib-level-row';
row.dataset.idx = idx;
row.innerHTML = `
<span class="fib-level-color" style="background:${defColor}" data-color="${defColor}" title="Change color"></span>
<input type="number" class="fib-level-input" value="50.0" step="0.1" />
<span class="fib-level-pct">%</span>
<button class="fib-level-remove" title="Remove">×</button>
`;
list.appendChild(row);
row.querySelector('.fib-level-remove').addEventListener('click', (ev) => {
ev.stopPropagation();
row.remove();
});
row.querySelector('.fib-level-color').addEventListener('click', (ev) => {
ev.stopPropagation();
showFibColorPicker(ev.target);
});
});
// Remove level buttons
popup.querySelectorAll('.fib-level-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
btn.closest('.fib-level-row').remove();
});
});
// Color picker on each dot
popup.querySelectorAll('.fib-level-color').forEach(dot => {
dot.addEventListener('click', (e) => {
e.stopPropagation();
showFibColorPicker(dot);
});
});
// Reset
popup.querySelector('#fibResetLevels').addEventListener('click', (e) => {
e.stopPropagation();
const list = popup.querySelector('#fibLevelsList');
list.innerHTML = FIB_DEFAULTS_OBJ.map((item, i) => `
<div class="fib-level-row" data-idx="${i}">
<span class="fib-level-color" style="background:${item.color}" data-color="${item.color}" title="Change color"></span>
<input type="number" class="fib-level-input" value="${(item.level * 100).toFixed(1)}" step="0.1" />
<span class="fib-level-pct">%</span>
<button class="fib-level-remove" title="Remove">×</button>
</div>
`).join('');
list.querySelectorAll('.fib-level-remove').forEach(btn => {
btn.addEventListener('click', (ev) => {
ev.stopPropagation();
btn.closest('.fib-level-row').remove();
});
});
list.querySelectorAll('.fib-level-color').forEach(dot => {
dot.addEventListener('click', (ev) => {
ev.stopPropagation();
showFibColorPicker(dot);
});
});
});
// Apply
popup.querySelector('#fibApplyLevels').addEventListener('click', (e) => {
e.stopPropagation();
const rows = popup.querySelectorAll('.fib-level-row');
const newLevels = [];
rows.forEach(row => {
const inp = row.querySelector('.fib-level-input');
const colorDot = row.querySelector('.fib-level-color');
const val = parseFloat(inp.value);
if (!isNaN(val)) {
newLevels.push({
level: val / 100,
color: colorDot ? colorDot.dataset.color : FIB_DEFAULT_COLORS[newLevels.length % FIB_DEFAULT_COLORS.length]
});
}
});
newLevels.sort((a, b) => a.level - b.level);
// Save globally
fibConfig.save(newLevels);
// Update this drawing
const _s2 = drawCtx.series || modal.series;
if (d.fibLines && _s2) {
d.fibLines.forEach(fl => {
try { _s2.removePriceLine(fl); } catch(ex) {}
});
}
d.data.levels = newLevels;
const diff = d.data.p2 - d.data.p1;
d.fibLines = newLevels.map((item, i) => {
const price = d.data.p1 + diff * item.level;
return _s2.createPriceLine({
price,
color: item.color,
lineWidth: 1.5,
lineStyle: 0,
axisLabelVisible: true,
title: `${(item.level * 100).toFixed(1)}%`,
});
});
persistDrawings();
popup.remove();
});
}
function hideDrawingPanel() {
const p = document.getElementById('drawPanel');
if (p) p.remove();
const fp = document.getElementById('fibLevelsPopup');
if (fp) fp.remove();
}
function changeDrawingColor(id, color) {
const d = draw.drawings.find(dd => dd.id === id);
if (!d) return;
d.color = color;
const _s = drawCtx.series || modal.series;
const _c = drawCtx.chart || modal.chart;
// Recreate with new color
if (d.type === 'hline' && d.priceLine && _s) {
const price = d.data.price;
try { _s.removePriceLine(d.priceLine); } catch(e) {}
d.priceLine = _s.createPriceLine({
price, color, lineWidth: 2, lineStyle: 0,
axisLabelVisible: true, title: '',
});
} else if ((d.type === 'ray' || d.type === 'trendline') && d.lineSeries) {
d.lineSeries.applyOptions({ color });
} else if (d.type === 'fib' && d.fibLines && _s) {
d.fibLines.forEach(fl => {
try { _s.removePriceLine(fl); } catch(e) {}
});
const rawLevels = d.data.levels || fibConfig.load();
const diff = d.data.p2 - d.data.p1;
d.fibLines = rawLevels.map((item, i) => {
const lvl = typeof item === 'number' ? item : item.level;
const price = d.data.p1 + diff * lvl;
return _s.createPriceLine({
price, color, lineWidth: 1, lineStyle: 0,
axisLabelVisible: true, title: `${(lvl * 100).toFixed(1)}%`,
});
});
if (Array.isArray(d.data.levels)) {
d.data.levels = d.data.levels.map(item => {
if (typeof item === 'number') return { level: item, color };
return { ...item, color };
});
}
} else if (d.type === 'rect' && d.rectLines && _c) {
d.rectLines.forEach(ls => ls.applyOptions({ color }));
if (d.fillSeries) {
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
d.fillSeries.applyOptions({
topColor: `rgba(${r}, ${g}, ${b}, 0.08)`,
lineColor: 'transparent',
});
}
}
persistDrawings();
showDrawingPanel(d); // refresh panel
}
// Find drawing near a price (for click-to-select)
function findDrawingNearPrice(price) {
if (!(drawCtx.series || modal.series)) return null;
const threshold = Math.abs(price) * 0.005; // 0.5% tolerance
for (const d of draw.drawings) {
if (d.type === 'hline' && d.data) {
if (Math.abs(d.data.price - price) < threshold) return d;
}
if (d.type === 'ray' && d.data) {
if (Math.abs(d.data.price - price) < threshold) return d;
}
if (d.type === 'fib' && d.data) {
const diff = d.data.p2 - d.data.p1;
const rawLevels = d.data.levels || fibConfig.load();
for (const item of rawLevels) {
const lvl = typeof item === 'number' ? item : item.level;
const fibPrice = d.data.p1 + diff * lvl;
if (Math.abs(fibPrice - price) < threshold) return d;
}
}
if (d.type === 'rect' && d.data) {
// Select if price is near top or bottom border
if (Math.abs(d.data.p1 - price) < threshold || Math.abs(d.data.p2 - price) < threshold) return d;
// Or if price is inside the rectangle
if (price >= Math.min(d.data.p1, d.data.p2) && price <= Math.max(d.data.p1, d.data.p2)) return d;
}
}
return null;
}
// Save current drawings to localStorage
function persistDrawings() {
const sym = drawCtx.sym || modal.currentSym;
if (sym) {
drawStore.save(sym, draw.drawings);
}
}
// Restore drawings from localStorage for current symbol
function restoreDrawings() {
const _sym = drawCtx.sym || modal.currentSym;
const _s = drawCtx.series || modal.series;
const _c = drawCtx.chart || modal.chart;
if (!_sym || !_s || !_c) return;
const saved = drawStore.load(_sym);
saved.forEach(s => {
if (s.type === 'hline') {
drawHorizontalLine(s.data.price, s.color);
const d = draw.drawings[draw.drawings.length - 1];
d.locked = s.locked;
} else if (s.type === 'ray') {
drawHorizontalRay(s.data.price, s.data.startTime, s.color);
const d = draw.drawings[draw.drawings.length - 1];
d.locked = s.locked;
} else if (s.type === 'trendline') {
drawTwoPointLine(s.type, s.data.t1, s.data.p1, s.data.t2, s.data.p2, s.color);
const d = draw.drawings[draw.drawings.length - 1];
d.locked = s.locked;
} else if (s.type === 'fib') {
drawFibonacci(s.data.p1, s.data.p2, null, s.data.levels);
const d = draw.drawings[draw.drawings.length - 1];
d.locked = s.locked;
} else if (s.type === 'rect') {
drawRectangle(s.data.t1, s.data.p1, s.data.t2, s.data.p2, s.color);
const d = draw.drawings[draw.drawings.length - 1];
d.locked = s.locked;
}
});
}
function removePreviewOverlay() {
if (draw.overlay) {
draw.overlay.remove();
draw.overlay = null;
}
}
function getPreviewCanvas() {
if (draw.overlay) return draw.overlay;
const chartEl = drawCtx.chartEl || el('cmChartBody');
if (!chartEl) return null;
const canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:50;';
canvas.width = chartEl.clientWidth;
canvas.height = chartEl.clientHeight;
chartEl.appendChild(canvas);
draw.overlay = canvas;
return canvas;
}
// ============================================
// Drawing — click handlers (works on any chart via drawCtx)
// ============================================
function setupDrawingHandlers(targetEl) {
const chartEl = targetEl || el('cmChartBody');
if (!chartEl) return;
// Remove old listeners by replacing element reference approach — use data attribute
if (chartEl.dataset.drawInit) return;
chartEl.dataset.drawInit = '1';
// Getter helpers — always resolve to active drawCtx (or modal fallback)
const DC = () => drawCtx.chart || modal.chart;
const DS = () => drawCtx.series || modal.series;
const DD = () => drawCtx.candleData || modal.candleData;
const DTF = () => drawCtx.tf || modal.currentTF;
// Snap price to nearest OHLC of closest candle (magnet mode)
function snapToCandle(time, price) {
const data = DD();
if (!data || data.length === 0) return { time, price };
// Binary search for closest candle by time
let lo = 0, hi = data.length - 1;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (data[mid].time < time) lo = mid + 1;
else hi = mid;
}
// Check neighbors for closest (up to 2 in each direction)
let bestIdx = lo;
let bestDist = Math.abs(data[lo].time - time);
for (let i = Math.max(0, lo - 2); i <= Math.min(data.length - 1, lo + 2); i++) {
const d = Math.abs(data[i].time - time);
if (d < bestDist) { bestDist = d; bestIdx = i; }
}
const c = data[bestIdx];
const ohlc = [c.open, c.high, c.low, c.close];
// Find nearest OHLC value to cursor price
let nearestVal = ohlc[0];
let nearestDist = Math.abs(ohlc[0] - price);
for (const v of ohlc) {
const d = Math.abs(v - price);
if (d < nearestDist) { nearestDist = d; nearestVal = v; }
}
// Snap threshold: within 40% of candle range (tight but usable)
const range = c.high - c.low || Math.abs(c.close) * 0.005;
if (nearestDist > range * 0.4) return { time: c.time, price };
return { time: c.time, price: nearestVal };
}
// Unified handler for both click and touch
function handleDrawClick(clientX, clientY) {
const _c = drawCtx.chart || modal.chart;
const _s = drawCtx.series || modal.series;
if (!_c || !_s) return;
if (draw.activeTool === 'cursor') return;
// If library DrawingManager is handling this tool, don't use custom handlers
if (typeof DM !== 'undefined' && DM && DM.isActive() && draw.activeTool !== 'ruler') return;
const rect = chartEl.getBoundingClientRect();
const x = clientX - rect.left;
const y = clientY - rect.top;
let price = _s.coordinateToPrice(y);
let time = _c.timeScale().coordinateToTime(x);
if (price === null || time === null) return;
// Magnet snap to nearest OHLC
if (drawMagnet) {
const snapped = snapToCandle(time, price);
price = snapped.price;
time = snapped.time;
}
if (draw.activeTool === 'hline') {
drawHorizontalLine(price);
draw.activeTool = 'cursor';
renderDrawToolbar();
updateModalCursor();
} else if (draw.activeTool === 'ray') {
drawHorizontalRay(price, time);
draw.activeTool = 'cursor';
renderDrawToolbar();
updateModalCursor();
} else if (draw.activeTool === 'trendline') {
if (draw.clickCount === 0) {
draw.startPrice = price;
draw.startTime = time;
draw.clickCount = 1;
} else {
drawTwoPointLine(draw.activeTool, draw.startTime, draw.startPrice, time, price);
draw.clickCount = 0;
removePreviewOverlay();
draw.activeTool = 'cursor';
renderDrawToolbar();
updateModalCursor();
}
} else if (draw.activeTool === 'fib') {
if (draw.clickCount === 0) {
draw.startPrice = price;
draw.startTime = time;
draw.clickCount = 1;
} else {
drawFibonacci(draw.startPrice, price);
draw.clickCount = 0;
removePreviewOverlay();
draw.activeTool = 'cursor';
renderDrawToolbar();
updateModalCursor();
}
} else if (draw.activeTool === 'rect') {
if (draw.clickCount === 0) {
draw.startPrice = price;
draw.startTime = time;
draw.clickCount = 1;
} else {
drawRectangle(draw.startTime, draw.startPrice, time, price);
draw.clickCount = 0;
removePreviewOverlay();
draw.activeTool = 'cursor';
renderDrawToolbar();
updateModalCursor();
}
} else if (draw.activeTool === 'ruler') {
if (draw.clickCount === 0) {
draw.startPrice = price;
draw.startTime = time;
draw.clickCount = 1;
// Remove old ruler measurement if exists
removeRulerMeasurement();
} else {
showRulerMeasurement(draw.startTime, draw.startPrice, time, price);
draw.clickCount = 0;
removePreviewOverlay();
draw.activeTool = 'cursor';
renderDrawToolbar();
updateModalCursor();
}
}
}
// Clear ruler on any click/tap in cursor mode
chartEl.addEventListener('mousedown', () => {
if (draw.activeTool === 'cursor' && rulerOverlay) removeRulerMeasurement();
}, true);
chartEl.addEventListener('touchstart', () => {
if (draw.activeTool === 'cursor' && rulerOverlay) removeRulerMeasurement();
}, { capture: true, passive: true });
// Desktop click
chartEl.addEventListener('click', (e) => {
// Suppress click right after drag ended
if (draw.justDragged) {
draw.justDragged = false;
return;
}
if (draw.activeTool === 'cursor') {
// Shift+click starts ruler (like TradingView)
const _sc = drawCtx.series || modal.series;
const _cc = drawCtx.chart || modal.chart;
if (e.shiftKey && _sc && _cc) {
const rect = chartEl.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
let price = _sc.coordinateToPrice(y);
let time = _cc.timeScale().coordinateToTime(x);
if (price !== null && time !== null) {
if (drawMagnet) { const snapped = snapToCandle(time, price); price = snapped.price; time = snapped.time; }
removeRulerMeasurement();
draw.activeTool = 'ruler';
draw.startPrice = price;
draw.startTime = time;
draw.clickCount = 1;
renderDrawToolbar();
updateModalCursor();
}
return;
}
// Select/deselect drawing
const rect2 = chartEl.getBoundingClientRect();
const y2 = e.clientY - rect2.top;
const price2 = _sc ? _sc.coordinateToPrice(y2) : null;
if (price2 !== null) {
const found = findDrawingNearPrice(price2);
if (found) {
selectDrawing(found.id);
} else {
deselectDrawing();
}
}
return;
}
handleDrawClick(e.clientX, e.clientY);
});
// Mobile touch — use touchend so we get final position
chartEl.addEventListener('touchend', (e) => {
if (draw.dragging) {
draw.dragging = false;
draw.justDragged = true;
persistDrawings();
updateModalCursor();
return;
}
if (draw.activeTool === 'cursor') {
// Select drawing on tap
const touch = e.changedTouches[0];
if (!touch) return;
const tRect = chartEl.getBoundingClientRect();
const tY = touch.clientY - tRect.top;
const tPrice = (drawCtx.series || modal.series)?.coordinateToPrice(tY) ?? null;
if (tPrice !== null) {
const found = findDrawingNearPrice(tPrice);
if (found) {
selectDrawing(found.id);
} else {
deselectDrawing();
}
}
return;
}
// If library DrawingManager handles this tool — don't preventDefault,
// let native touch→click conversion happen so chart.subscribeClick fires for the library
if (typeof DM !== 'undefined' && DM && DM.isActive() && draw.activeTool !== 'ruler') return;
e.preventDefault();
const touch = e.changedTouches[0];
if (!touch) return;
handleDrawClick(touch.clientX, touch.clientY);
}, { passive: false });
// Touch→mouse proxy for library DrawingManager drag (library only listens to mouse events)
chartEl.addEventListener('touchstart', (e) => {
if (typeof DM === 'undefined' || !DM || !DM.isActive()) return;
if (draw.activeTool !== 'cursor') return;
const t = e.touches[0]; if (!t) return;
chartEl.dispatchEvent(new MouseEvent('mousedown', { clientX: t.clientX, clientY: t.clientY, bubbles: true }));
}, { passive: true });
chartEl.addEventListener('touchmove', (e) => {
if (typeof DM === 'undefined' || !DM || !DM.isDragging || !DM.isDragging()) return;
const t = e.touches[0]; if (!t) return;
chartEl.dispatchEvent(new MouseEvent('mousemove', { clientX: t.clientX, clientY: t.clientY, bubbles: true }));
}, { passive: true });
chartEl.addEventListener('touchend', (e) => {
if (typeof DM === 'undefined' || !DM || !DM.isDragging || !DM.isDragging()) return;
const t = e.changedTouches[0]; if (!t) return;
chartEl.dispatchEvent(new MouseEvent('mouseup', { clientX: t.clientX, clientY: t.clientY, bubbles: true }));
}, { passive: true });
// Drag support for hline and ray — mousedown
chartEl.addEventListener('mousedown', (e) => {
if (draw.activeTool !== 'cursor' || draw.selected === null) return;
const d = draw.drawings.find(dd => dd.id === draw.selected);
if (!d || d.locked || (d.type !== 'hline' && d.type !== 'ray')) return;
const rect = chartEl.getBoundingClientRect();
const y = e.clientY - rect.top;
const price = DS() ? DS().coordinateToPrice(y) : null;
if (price === null) return;
const dragPrice = d.data.price;
const threshold = Math.abs(dragPrice) * 0.005;
if (Math.abs(price - dragPrice) > threshold) return;
e.preventDefault();
draw.dragging = true;
draw.dragStartY = e.clientY;
draw.dragStartPrice = dragPrice;
if (DC()) DC().applyOptions({ handleScroll: false, handleScale: false });
});
// Drag — mousemove
chartEl.addEventListener('mousemove', (e) => {
if (draw.dragging && draw.selected !== null) {
const d = draw.drawings.find(dd => dd.id === draw.selected);
if (!d || (d.type !== 'hline' && d.type !== 'ray')) return;
const rect = chartEl.getBoundingClientRect();
const y = e.clientY - rect.top;
const newPrice = DS() ? DS().coordinateToPrice(y) : null;
if (newPrice === null) return;
if (d.type === 'hline') {
try { DS().removePriceLine(d.priceLine); } catch(ex) {}
d.priceLine = DS().createPriceLine({
price: newPrice, color: d.color, lineWidth: 2, lineStyle: 0,
axisLabelVisible: true, title: '',
});
d.data.price = newPrice;
} else if (d.type === 'ray') {
if (d.lineSeries) {
try { DC().removeSeries(d.lineSeries); } catch(ex) {}
}
const startTime = d.data.startTime;
const farTime = startTime + 365 * 24 * 3600;
const ls = DC().addSeries(LightweightCharts.LineSeries, {
color: d.color, lineWidth: 2,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
ls.setData([
{ time: startTime, value: newPrice },
{ time: farTime, value: newPrice },
]);
d.lineSeries = ls;
d.data.price = newPrice;
}
}
});
// Drag — mouseup
chartEl.addEventListener('mouseup', () => {
if (draw.dragging) {
draw.dragging = false;
draw.justDragged = true;
persistDrawings();
updateModalCursor();
}
});
// Touch drag for hline and ray
chartEl.addEventListener('touchstart', (e) => {
if (draw.activeTool !== 'cursor' || draw.selected === null) return;
const d = draw.drawings.find(dd => dd.id === draw.selected);
if (!d || d.locked || (d.type !== 'hline' && d.type !== 'ray')) return;
const touch = e.touches[0];
const rect = chartEl.getBoundingClientRect();
const y = touch.clientY - rect.top;
const price = DS() ? DS().coordinateToPrice(y) : null;
if (price === null) return;
const dragPrice = d.data.price;
const threshold = Math.abs(dragPrice) * 0.008;
if (Math.abs(price - dragPrice) > threshold) return;
e.preventDefault();
draw.dragging = true;
if (DC()) DC().applyOptions({ handleScroll: false, handleScale: false });
}, { passive: false });
chartEl.addEventListener('touchmove', (e) => {
if (draw.dragging && draw.selected !== null) {
e.preventDefault();
const d = draw.drawings.find(dd => dd.id === draw.selected);
if (!d || (d.type !== 'hline' && d.type !== 'ray')) return;
const touch = e.touches[0];
const rect = chartEl.getBoundingClientRect();
const y = touch.clientY - rect.top;
const newPrice = DS() ? DS().coordinateToPrice(y) : null;
if (newPrice === null) return;
if (d.type === 'hline') {
try { DS().removePriceLine(d.priceLine); } catch(ex) {}
d.priceLine = DS().createPriceLine({
price: newPrice, color: d.color, lineWidth: 2, lineStyle: 0,
axisLabelVisible: true, title: '',
});
d.data.price = newPrice;
} else if (d.type === 'ray') {
if (d.lineSeries) try { DC().removeSeries(d.lineSeries); } catch(ex) {}
const startTime = d.data.startTime;
const farTime = startTime + 365 * 24 * 3600;
const ls = DC().addSeries(LightweightCharts.LineSeries, {
color: d.color, lineWidth: 2,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
ls.setData([
{ time: startTime, value: newPrice },
{ time: farTime, value: newPrice },
]);
d.lineSeries = ls;
d.data.price = newPrice;
}
}
}, { passive: false });
// Live preview for 2-click tools
chartEl.addEventListener('mousemove', (e) => {
if (!DC() || !DS()) return;
if (draw.clickCount !== 1) return;
if (draw.activeTool !== 'trendline' && draw.activeTool !== 'fib' && draw.activeTool !== 'rect' && draw.activeTool !== 'ruler') return;
const rect = chartEl.getBoundingClientRect();
const x = e.clientX - rect.left;
let y = e.clientY - rect.top;
let price = DS().coordinateToPrice(y);
if (price === null) return;
// Snap to nearest candle OHLC (if magnet enabled)
if (drawMagnet) {
const curTime = DC().timeScale().coordinateToTime(x);
if (curTime !== null) {
const snapped = snapToCandle(curTime, price);
price = snapped.price;
const snapY = DS().priceToCoordinate(price);
if (snapY !== null) y = snapY;
}
}
const canvas = getPreviewCanvas();
if (!canvas) return;
const ctx = canvas.getContext('2d');
canvas.width = chartEl.clientWidth;
canvas.height = chartEl.clientHeight;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const startY2 = DS().priceToCoordinate(draw.startPrice);
const startX2 = DC().timeScale().timeToCoordinate(draw.startTime);
if (startY2 === null || startX2 === null) return;
ctx.strokeStyle = '#5b9cf6';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
if (draw.activeTool === 'fib') {
// Preview fib levels
const fibCfg = fibConfig.load();
const diff = price - draw.startPrice;
fibCfg.forEach((item, i) => {
const lvl = typeof item === 'number' ? item : item.level;
const clr = (typeof item === 'object' && item.color) ? item.color : FIB_DEFAULT_COLORS[i % FIB_DEFAULT_COLORS.length];
const fibPrice = draw.startPrice + diff * lvl;
const fibY = DS().priceToCoordinate(fibPrice);
if (fibY === null) return;
ctx.strokeStyle = clr;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, fibY);
ctx.lineTo(canvas.width, fibY);
ctx.stroke();
ctx.fillStyle = clr;
ctx.font = '10px Inter, sans-serif';
ctx.fillText(`${(lvl * 100).toFixed(1)}% ${fibPrice.toFixed(getPricePrecision(fibPrice))}`, 5, fibY - 3);
});
} else if (draw.activeTool === 'rect') {
// Preview rectangle
const w = x - startX2;
const h = y - startY2;
ctx.strokeStyle = '#5b9cf6';
ctx.lineWidth = 1.5;
ctx.setLineDash([5, 3]);
ctx.strokeRect(startX2, startY2, w, h);
ctx.fillStyle = 'rgba(91, 156, 246, 0.08)';
ctx.fillRect(startX2, startY2, w, h);
} else if (draw.activeTool === 'ruler') {
// Preview ruler measurement
const curTime = DC().timeScale().coordinateToTime(x);
const priceDiff = price - draw.startPrice;
const pctDiff = draw.startPrice !== 0 ? (priceDiff / draw.startPrice * 100) : 0;
const isUp = priceDiff >= 0;
const color = isUp ? 'rgba(34,197,94,0.9)' : 'rgba(239,68,68,0.9)';
const colorFill = isUp ? 'rgba(34,197,94,0.06)' : 'rgba(239,68,68,0.06)';
// Dashed lines (L-shape: vertical + horizontal)
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
// Vertical from start
ctx.beginPath(); ctx.moveTo(startX2, startY2); ctx.lineTo(startX2, y); ctx.stroke();
// Horizontal to end
ctx.beginPath(); ctx.moveTo(startX2, y); ctx.lineTo(x, y); ctx.stroke();
// Main diagonal line
ctx.lineWidth = 1.5;
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(startX2, startY2); ctx.lineTo(x, y); ctx.stroke();
// Fill area
ctx.fillStyle = colorFill;
ctx.beginPath(); ctx.moveTo(startX2, startY2); ctx.lineTo(startX2, y); ctx.lineTo(x, y); ctx.closePath(); ctx.fill();
// Dots at start/end
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(startX2, startY2, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
// Label
const prec = getPricePrecision(Math.abs(draw.startPrice));
const sign = isUp ? '+' : '';
let timeStr = '';
let barsStr = '';
if (draw.startTime && curTime) {
const timeDiffSec = Math.abs(curTime - draw.startTime);
if (timeDiffSec < 60) timeStr = Math.round(timeDiffSec) + 's';
else if (timeDiffSec < 3600) timeStr = Math.round(timeDiffSec / 60) + 'm';
else if (timeDiffSec < 86400) timeStr = (timeDiffSec / 3600).toFixed(1) + 'h';
else timeStr = (timeDiffSec / 86400).toFixed(1) + 'd';
// Bars count based on modal TF
const tfSec = { '1m': 60, '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400 }[DTF()] || 300;
barsStr = Math.round(timeDiffSec / tfSec) + ' bars';
}
const labelText = `${sign}${priceDiff.toFixed(prec)} (${sign}${pctDiff.toFixed(2)}%)`;
const labelText2 = timeStr ? `${timeStr} • ${barsStr}` : '';
// Background box
const midX = (startX2 + x) / 2;
const labelY = Math.min(startY2, y) - 12;
ctx.font = '600 12px Inter, sans-serif';
const w1 = ctx.measureText(labelText).width;
ctx.font = '11px Inter, sans-serif';
const w2 = labelText2 ? ctx.measureText(labelText2).width : 0;
const boxW = Math.max(w1, w2) + 16;
const boxH = labelText2 ? 38 : 22;
const boxX = midX - boxW / 2;
const boxY = Math.max(2, labelY - boxH);
ctx.fillStyle = isUp ? 'rgba(34,197,94,0.92)' : 'rgba(239,68,68,0.92)';
ctx.beginPath();
const r = 5;
ctx.moveTo(boxX + r, boxY); ctx.lineTo(boxX + boxW - r, boxY);
ctx.quadraticCurveTo(boxX + boxW, boxY, boxX + boxW, boxY + r);
ctx.lineTo(boxX + boxW, boxY + boxH - r);
ctx.quadraticCurveTo(boxX + boxW, boxY + boxH, boxX + boxW - r, boxY + boxH);
ctx.lineTo(boxX + r, boxY + boxH);
ctx.quadraticCurveTo(boxX, boxY + boxH, boxX, boxY + boxH - r);
ctx.lineTo(boxX, boxY + r);
ctx.quadraticCurveTo(boxX, boxY, boxX + r, boxY);
ctx.fill();
// Text
ctx.fillStyle = '#fff';
ctx.font = '600 12px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(labelText, midX, boxY + 15);
if (labelText2) {
ctx.font = '11px Inter, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fillText(labelText2, midX, boxY + 30);
}
ctx.textAlign = 'start';
} else {
// Preview trendline
ctx.beginPath();
ctx.moveTo(startX2, startY2);
ctx.lineTo(x, y);
ctx.stroke();
}
});
}
// ============================================
// Drawing implementations
// ============================================
function drawHorizontalLine(price, color) {
const _s = drawCtx.series || modal.series;
if (!_s) return;
const c = color || '#5b9cf6';
const priceLine = _s.createPriceLine({
price: price,
color: c,
lineWidth: 2,
lineStyle: 0,
axisLabelVisible: true,
title: '',
});
draw.drawings.push({ id: ++drawIdCounter, type: 'hline', color: c, locked: false, priceLine, data: { price } });
persistDrawings();
}
function drawHorizontalRay(price, startTime, color) {
const _c = drawCtx.chart || modal.chart;
if (!_c) return;
const c = color || '#5b9cf6';
const farTime = startTime + 365 * 24 * 3600; // 1 year forward
const lineSeries = _c.addSeries(LightweightCharts.LineSeries, {
color: c, lineWidth: 2,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
lineSeries.setData([
{ time: startTime, value: price },
{ time: farTime, value: price },
]);
draw.drawings.push({
id: ++drawIdCounter, type: 'ray', color: c, locked: false,
lineSeries, data: { price, startTime }
});
persistDrawings();
}
function drawTwoPointLine(type, t1, p1, t2, p2, color) {
const _c = drawCtx.chart || modal.chart;
if (!_c) return;
const c = color || '#5b9cf6';
const points = [];
points.push({ time: t1, value: p1 });
points.push({ time: t2, value: p2 });
const seen = new Set();
const uniquePoints = points.filter(p => {
if (seen.has(p.time)) return false;
seen.add(p.time);
return true;
}).sort((a, b) => a.time - b.time);
const lineSeries = _c.addSeries(LightweightCharts.LineSeries, {
color: c,
lineWidth: 2,
lineStyle: type === 'ray' ? 0 : 0,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
pointMarkersVisible: false,
});
lineSeries.setData(uniquePoints);
draw.drawings.push({ id: ++drawIdCounter, type, color: c, locked: false, lineSeries, data: { t1, p1, t2, p2 } });
persistDrawings();
}
function drawRectangle(t1, p1, t2, p2, color) {
const _c = drawCtx.chart || modal.chart;
const _s = drawCtx.series || modal.series;
if (!_c || !_s) return;
const c = color || '#5b9cf6';
const tMin = Math.min(t1, t2);
const tMax = Math.max(t1, t2);
const pMin = Math.min(p1, p2);
const pMax = Math.max(p1, p2);
const topLine = _c.addSeries(LightweightCharts.LineSeries, {
color: c, lineWidth: 1.5, crosshairMarkerVisible: false,
lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false,
});
topLine.setData([{ time: tMin, value: pMax }, { time: tMax, value: pMax }]);
const bottomLine = _c.addSeries(LightweightCharts.LineSeries, {
color: c, lineWidth: 1.5, crosshairMarkerVisible: false,
lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false,
});
bottomLine.setData([{ time: tMin, value: pMin }, { time: tMax, value: pMin }]);
const leftLine = _c.addSeries(LightweightCharts.LineSeries, {
color: c, lineWidth: 1.5, crosshairMarkerVisible: false,
lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false,
});
leftLine.setData([{ time: tMin, value: pMin }, { time: tMin + 1, value: pMax }]);
const rightLine = _c.addSeries(LightweightCharts.LineSeries, {
color: c, lineWidth: 1.5, crosshairMarkerVisible: false,
lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false,
});
rightLine.setData([{ time: tMax, value: pMin }, { time: tMax + 1, value: pMax }]);
// Fill area
const hexToFill = (hex) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, 0.08)`;
};
const fillSeries = _c.addSeries(LightweightCharts.AreaSeries, {
topColor: hexToFill(c),
bottomColor: 'transparent',
lineColor: 'transparent',
lineWidth: 0,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
});
// Generate fill data points
const fillPoints = [];
const steps = 20;
for (let i = 0; i <= steps; i++) {
const t = Math.round(tMin + (tMax - tMin) * i / steps);
fillPoints.push({ time: t, value: pMax });
}
// Deduplicate times
const seenTimes = new Set();
const uniqueFill = fillPoints.filter(p => {
if (seenTimes.has(p.time)) return false;
seenTimes.add(p.time);
return true;
});
fillSeries.setData(uniqueFill);
// Bottom price line for visual boundary
const bottomPriceLine = _s.createPriceLine({
price: pMin, color: 'transparent', lineWidth: 0, lineStyle: 2,
axisLabelVisible: false, title: '',
});
draw.drawings.push({
id: ++drawIdCounter, type: 'rect', color: c, locked: false,
rectLines: [topLine, bottomLine, leftLine, rightLine],
fillSeries, bottomPriceLine,
data: { t1: tMin, p1: pMin, t2: tMax, p2: pMax }
});
persistDrawings();
}
// ---- Ruler Measurement ----
let rulerOverlay = null;
function removeRulerMeasurement() {
if (rulerOverlay) {
if (rulerOverlay._unsub) rulerOverlay._unsub();
rulerOverlay.remove();
rulerOverlay = null;
}
}
function showRulerMeasurement(t1, p1, t2, p2) {
const _c = drawCtx.chart || modal.chart;
const _s = drawCtx.series || modal.series;
if (!_c || !_s) return;
removeRulerMeasurement();
const chartEl = drawCtx.chartEl || el('cmChartBody');
if (!chartEl) return;
// Create persistent overlay
const overlay = document.createElement('canvas');
overlay.className = 'ruler-overlay';
overlay.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:99;';
chartEl.appendChild(overlay);
rulerOverlay = overlay;
function render() {
if (!rulerOverlay || !_c || !_s) return;
const w = chartEl.clientWidth;
const h = chartEl.clientHeight;
overlay.width = w;
overlay.height = h;
const ctx = overlay.getContext('2d');
ctx.clearRect(0, 0, w, h);
const sx = _c.timeScale().timeToCoordinate(t1);
const sy = _s.priceToCoordinate(p1);
const ex = _c.timeScale().timeToCoordinate(t2);
const ey = _s.priceToCoordinate(p2);
if (sx === null || sy === null || ex === null || ey === null) return;
const priceDiff = p2 - p1;
const pctDiff = p1 !== 0 ? (priceDiff / p1 * 100) : 0;
const isUp = priceDiff >= 0;
const color = isUp ? 'rgba(34,197,94,0.9)' : 'rgba(239,68,68,0.9)';
const colorFill = isUp ? 'rgba(34,197,94,0.06)' : 'rgba(239,68,68,0.06)';
// Dashed L-shape
ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx, ey); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx, ey); ctx.lineTo(ex, ey); ctx.stroke();
// Main line
ctx.lineWidth = 1.5; ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(ex, ey); ctx.stroke();
// Fill triangle
ctx.fillStyle = colorFill;
ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(sx, ey); ctx.lineTo(ex, ey); ctx.closePath(); ctx.fill();
// Dots
ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(sx, sy, 4, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(ex, ey, 4, 0, Math.PI * 2); ctx.fill();
// Label
const prec = getPricePrecision(Math.abs(p1));
const sign = isUp ? '+' : '';
const timeDiffSec = Math.abs(t2 - t1);
let timeStr = '';
if (timeDiffSec < 60) timeStr = Math.round(timeDiffSec) + 's';
else if (timeDiffSec < 3600) timeStr = Math.round(timeDiffSec / 60) + 'm';
else if (timeDiffSec < 86400) timeStr = (timeDiffSec / 3600).toFixed(1) + 'h';
else timeStr = (timeDiffSec / 86400).toFixed(1) + 'd';
const tfSec = { '1m': 60, '5m': 300, '15m': 900, '1h': 3600, '4h': 14400, '1d': 86400 }[modal.currentTF] || 300;
const bars = Math.round(timeDiffSec / tfSec);
const line1 = `${sign}${priceDiff.toFixed(prec)} (${sign}${pctDiff.toFixed(2)}%)`;
const line2 = `${timeStr} • ${bars} bars`;
const midX = (sx + ex) / 2;
const labelY = Math.min(sy, ey) - 12;
ctx.font = '600 12px Inter, sans-serif';
const w1 = ctx.measureText(line1).width;
ctx.font = '11px Inter, sans-serif';
const w2 = ctx.measureText(line2).width;
const boxW = Math.max(w1, w2) + 16;
const boxH = 38;
const boxX = midX - boxW / 2;
const boxY = Math.max(2, labelY - boxH);
ctx.fillStyle = isUp ? 'rgba(34,197,94,0.92)' : 'rgba(239,68,68,0.92)';
const r = 5;
ctx.beginPath();
ctx.moveTo(boxX + r, boxY); ctx.lineTo(boxX + boxW - r, boxY);
ctx.quadraticCurveTo(boxX + boxW, boxY, boxX + boxW, boxY + r);
ctx.lineTo(boxX + boxW, boxY + boxH - r);
ctx.quadraticCurveTo(boxX + boxW, boxY + boxH, boxX + boxW - r, boxY + boxH);
ctx.lineTo(boxX + r, boxY + boxH);
ctx.quadraticCurveTo(boxX, boxY + boxH, boxX, boxY + boxH - r);
ctx.lineTo(boxX, boxY + r);
ctx.quadraticCurveTo(boxX, boxY, boxX + r, boxY);
ctx.fill();
ctx.fillStyle = '#fff'; ctx.textAlign = 'center';
ctx.font = '600 12px Inter, sans-serif';
ctx.fillText(line1, midX, boxY + 15);
ctx.font = '11px Inter, sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.fillText(line2, midX, boxY + 30);
ctx.textAlign = 'start';
}
render();
// Re-render on scroll/zoom so ruler follows the chart
const subId = _c.timeScale().subscribeVisibleTimeRangeChange(render);
overlay._unsub = () => {
try { _c.timeScale().unsubscribeVisibleTimeRangeChange(render); } catch(e) {}
};
}
function drawFibonacci(p1, p2, color, customLevels) {
const _s = drawCtx.series || modal.series;
if (!_s) return;
const rawLevels = customLevels || fibConfig.load();
const levels = rawLevels.map((item, i) => {
if (typeof item === 'number') return { level: item, color: color || FIB_DEFAULT_COLORS[i % FIB_DEFAULT_COLORS.length] };
return { level: item.level, color: color || item.color || FIB_DEFAULT_COLORS[i % FIB_DEFAULT_COLORS.length] };
});
const diff = p2 - p1;
const fibLines = [];
levels.forEach((item, i) => {
const price = p1 + diff * item.level;
const label = `${(item.level * 100).toFixed(1)}%`;
const priceLine = _s.createPriceLine({
price: price,
color: item.color,
lineWidth: 1.5,
lineStyle: 0,
axisLabelVisible: true,
title: label,
});
fibLines.push(priceLine);
});
const savedColor = color || levels[0]?.color || '#4caf50';
draw.drawings.push({
id: ++drawIdCounter, type: 'fib', color: savedColor, locked: false,
fibLines, priceLine: null, data: { p1, p2, levels }
});
persistDrawings();
}
function closeCoinModal() {
const closingSym = modal.currentSym;
el('coinModal').classList.add('hidden');
removeRulerMeasurement();
// Cleanup ResizeObserver
if (modal._resizeObserver) { modal._resizeObserver.disconnect(); modal._resizeObserver = null; }
// Detach library DrawingManager
if (typeof DM !== 'undefined' && DM && DM.detach) { try { DM.detach(); } catch(e) {} }
// Stop countdown timer
if (modal._countdownTimer) { clearInterval(modal._countdownTimer); modal._countdownTimer = null; }
// Clear signal markers
if (modal._sigMarkers) { try { modal._sigMarkers.setMarkers([]); } catch(_){} modal._sigMarkers = null; }
window._pendingSignalMarker = null;
// Unsubscribe modal WS stream
if (modal.wsStream) {
if (mc.ws && mc.ws.readyState === WebSocket.OPEN) {
mc.ws.send(JSON.stringify({ method: 'UNSUBSCRIBE', params: [modal.wsStream], id: Date.now() }));
}
mc.wsStreams.delete(modal.wsStream);
modal.wsStream = null;
}
if (modal._infiniteUnsub) { try { modal._infiniteUnsub(); } catch(e) {} modal._infiniteUnsub = null; }
if (modal.chart) {
modal.chart.remove();
modal.chart = null;
modal.series = null;
modal.volSeries = null;
modal.lines = [];
modal.legend = null;
}
modal.currentSym = null;
// Clear drawing chart objects (data already persisted in localStorage)
draw.drawings = [];
draw.selected = null;
draw.activeTool = 'cursor';
draw.clickCount = 0;
hideDrawingPanel();
removePreviewOverlay();
// Refresh drawings on mini-chart (user may have added/removed drawings)
if (closingSym && mc.charts[closingSym]) {
applyDrawingsToMiniChart(closingSym);
}
}
// Init modal event listeners (called once in initMiniCharts)
let _modalEventsInitialized = false;
function _modalEscapeHandler(e) {
if (e.key === 'Escape' && modal.currentSym) closeCoinModal();
}
function initModalEvents() {
if (_modalEventsInitialized) return; // Guard against double init
_modalEventsInitialized = true;
// Close button
el('cmClose').addEventListener('click', closeCoinModal);
// Overlay click
document.querySelector('.mc-modal-overlay').addEventListener('click', closeCoinModal);
// Escape key (named function — removable if needed)
document.addEventListener('keydown', _modalEscapeHandler);
// TF buttons in modal (event delegation on container — single listener)
el('cmTFButtons').addEventListener('click', (e) => {
const btn = e.target.closest('.mc-tf-btn');
if (!btn || !modal.currentSym) return;
el('cmTFButtons').querySelectorAll('.mc-tf-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
modal.currentTF = btn.dataset.tf;
// Clear chart objects before reload (data stays in localStorage)
draw.drawings.forEach(d => removeDrawingFromChart(d));
draw.drawings = [];
loadModalChart(modal.currentSym, modal.currentTF);
startCountdown();
});
}
// ==========================================
// Multi-Chart Layout System
// ==========================================
const mch = {
layout: 'grid', // 'grid' | '1' | '2' | '4'
activeSlot: 0, // which slot receives next symbol click
slots: [], // { sym, tf, chart, series, volSeries, legend }
};
// Restore layout from localStorage
try { mch.layout = localStorage.getItem('mch_layout') || 'grid'; } catch(e) {}
function initLayoutPicker() {
const picker = el('mcLayoutPicker');
if (!picker) return;
// Set initial active state
picker.querySelectorAll('.mc-layout-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.layout === mch.layout);
});
picker.addEventListener('click', (e) => {
const btn = e.target.closest('.mc-layout-btn');
if (!btn) return;
const layout = btn.dataset.layout;
if (layout === mch.layout) return;
picker.querySelectorAll('.mc-layout-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
switchLayout(layout);
});
// Apply saved layout on init
if (mch.layout !== 'grid') {
switchLayout(mch.layout);
}
}
function switchLayout(layout) {
mch.layout = layout;
lsSet('mch_layout', layout);
const miniChartsLayout = el('mcMiniChartsLayout');
const multiChart = el('mcMultiChart');
const sidebar = el('mcSidebar');
if (layout === 'grid') {
// Show mini-charts grid, hide multi-chart
miniChartsLayout.style.display = 'flex';
multiChart.style.display = 'none';
// Sidebar stays inside mc-layout
if (sidebar.parentElement !== miniChartsLayout) {
miniChartsLayout.appendChild(sidebar);
}
} else {
// Hide mini-charts grid, show multi-chart
miniChartsLayout.style.display = 'none';
multiChart.style.display = 'flex';
// Move sidebar into multi-chart container
if (sidebar.parentElement !== multiChart) {
multiChart.appendChild(sidebar);
}
renderMultiChartSlots(layout);
}
}
function renderMultiChartSlots(layout) {
const grid = el('mchGrid');
grid.dataset.layout = layout;
const countMap = { '2h': 2, '2v': 2, '4': 4, '1+3': 4 };
const count = countMap[layout] || parseInt(layout) || 2;
// Preserve existing slot symbols
const savedSyms = mch.slots.map(s => s.sym);
// Restore from localStorage if first time
if (savedSyms.length === 0) {
try {
const saved = JSON.parse(localStorage.getItem('mch_slots') || '[]');
for (let i = 0; i < count; i++) {
savedSyms[i] = saved[i] || null;
}
} catch(e) {}
}
// Destroy old chart instances
mch.slots.forEach(slot => {
if (slot.chart) {
try { slot.chart.remove(); } catch(e) {}
}
});
mch.slots = [];
grid.innerHTML = '';
for (let i = 0; i < count; i++) {
const slot = document.createElement('div');
slot.className = 'mch-slot' + (i === mch.activeSlot ? ' active' : '');
slot.dataset.index = i;
const sym = savedSyms[i] || null;
slot.innerHTML = `
<div class="mch-slot-header">
<span class="mch-slot-sym">${sym ? sym.replace('USDT','') + '/USDT' : '—'}</span>
${sym ? `<button class="mch-copy-btn" data-ticker="${sym.toLowerCase()}" title="Copy ${sym.toLowerCase()}"><svg width="11" height="11" viewBox="0 0 16 16" fill="none"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>` : ''}
<span class="mch-slot-price"></span>
<span class="mch-slot-change"></span>
<div class="mch-slot-tf">
<button class="mc-tf-btn" data-tf="1m">1m</button>
<button class="mc-tf-btn" data-tf="5m">5m</button>
<button class="mc-tf-btn active" data-tf="15m">15m</button>
<button class="mc-tf-btn" data-tf="1h">1h</button>
<button class="mc-tf-btn" data-tf="4h">4h</button>
<button class="mc-tf-btn" data-tf="1d">1d</button>
</div>
</div>
${sym ? '<div class="mch-slot-chart" id="mch-chart-' + i + '"></div>' : '<div class="mch-slot-empty">Click a coin in sidebar</div>'}
`;
// Copy ticker button
const copyBtn = slot.querySelector('.mch-copy-btn');
if (copyBtn) {
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(copyBtn.dataset.ticker).then(() => {
copyBtn.classList.add('mc-copy-ok');
setTimeout(() => copyBtn.classList.remove('mc-copy-ok'), 800);
});
});
}
// Click slot to make it active
slot.addEventListener('click', (e) => {
if (e.target.closest('.mc-tf-btn') || e.target.closest('.mch-copy-btn')) return;
setActiveSlot(i);
});
// TF buttons per slot
slot.querySelector('.mch-slot-tf').addEventListener('click', (e) => {
const btn = e.target.closest('.mc-tf-btn');
if (!btn) return;
const slotData = mch.slots[i];
if (!slotData || !slotData.sym) return;
slot.querySelectorAll('.mch-slot-tf .mc-tf-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Unsubscribe old TF stream, subscribe new
mchWsUnsubscribe(slotData.sym, slotData.tf);
slotData.tf = btn.dataset.tf;
mchWsSubscribe(slotData.sym, slotData.tf);
loadSlotChart(i);
});
grid.appendChild(slot);
mch.slots.push({
sym: sym,
tf: mc.globalTF,
chart: null,
series: null,
volSeries: null,
legend: null,
el: slot
});
// If slot has a symbol, create chart + subscribe WS
if (sym) {
createSlotChart(i);
loadSlotChart(i);
mchWsSubscribe(sym, mc.globalTF);
}
}
}
function setActiveSlot(index) {
mch.activeSlot = index;
document.querySelectorAll('.mch-slot').forEach((s, i) => {
s.classList.toggle('active', i === index);
});
// Switch drawing context to active slot
const slot = mch.slots[index];
if (slot && slot.chart && slot.sym) {
// Save current drawings before switching
persistDrawings();
// Clear current drawings from chart
draw.drawings.forEach(d => removeDrawingFromChart(d));
draw.drawings = [];
draw.selected = null;
draw.activeTool = 'cursor';
draw.clickCount = 0;
hideDrawingPanel();
removePreviewOverlay();
setDrawCtxSlot(index);
// Restore drawings for new symbol
restoreDrawings();
updateModalCursor();
}
}
function assignSymbolToSlot(sym, slotIndex) {
const slot = mch.slots[slotIndex];
if (!slot) return;
// Unsubscribe old symbol WS stream
if (slot.sym) {
mchWsUnsubscribe(slot.sym, slot.tf);
}
// Destroy old chart
if (slot.chart) {
try { slot.chart.remove(); } catch(e) {}
slot.chart = null;
slot.series = null;
slot.volSeries = null;
}
slot.sym = sym;
const pair = mc.allPairs.find(p => p.symbol === sym);
// Update header
const header = slot.el.querySelector('.mch-slot-sym');
header.textContent = sym ? sym.replace('USDT','') + '/USDT' : '—';
// Update or create copy button
let copyBtn = slot.el.querySelector('.mch-copy-btn');
if (sym) {
if (!copyBtn) {
copyBtn = document.createElement('button');
copyBtn.className = 'mch-copy-btn';
copyBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 16 16" fill="none"><rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 11V3a1 1 0 011-1h8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
header.after(copyBtn);
copyBtn.addEventListener('click', (e) => {
e.stopPropagation();
navigator.clipboard.writeText(copyBtn.dataset.ticker).then(() => {
copyBtn.classList.add('mc-copy-ok');
setTimeout(() => copyBtn.classList.remove('mc-copy-ok'), 800);
});
});
}
copyBtn.dataset.ticker = sym.toLowerCase();
copyBtn.title = `Copy ${sym.toLowerCase()}`;
} else if (copyBtn) {
copyBtn.remove();
}
if (pair) {
const prec = getPricePrecision(pair.lastPrice);
slot.el.querySelector('.mch-slot-price').textContent = '$' + pair.lastPrice.toFixed(prec);
const chg = pair.priceChange;
const chgEl = slot.el.querySelector('.mch-slot-change');
chgEl.textContent = (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%';
chgEl.style.color = chg >= 0 ? '#22c55e' : '#ef4444';
}
// Replace empty placeholder with chart div
const empty = slot.el.querySelector('.mch-slot-empty');
if (empty) {
const chartDiv = document.createElement('div');
chartDiv.className = 'mch-slot-chart';
chartDiv.id = 'mch-chart-' + slotIndex;
empty.replaceWith(chartDiv);
}
createSlotChart(slotIndex);
loadSlotChart(slotIndex);
saveSlotSymbols();
// Subscribe to WS for live updates
mchWsSubscribe(sym, slot.tf);
// Auto-advance to next slot
const nextSlot = (slotIndex + 1) % mch.slots.length;
setActiveSlot(nextSlot);
}
function createSlotChart(slotIndex) {
const slot = mch.slots[slotIndex];
const chartEl = el('mch-chart-' + slotIndex);
if (!chartEl || !slot.sym) return;
const pair = mc.allPairs.find(p => p.symbol === slot.sym);
const price = pair ? pair.lastPrice : 1;
const prec = getPricePrecision(price);
const minMove = parseFloat((1 / Math.pow(10, prec)).toFixed(prec));
slot.chart = LightweightCharts.createChart(chartEl, {
autoSize: true,
...localChartOptions,
layout: { background: { type: 'solid', color: 'transparent' }, textColor: '#94a3b8', fontSize: 10 },
grid: getGridOpts(),
crosshair: { mode: 0 },
rightPriceScale: { borderColor: 'rgba(255,255,255,0.08)', scaleMargins: { top: 0.05, bottom: 0.05 }, minimumWidth: 45, mode: getPriceScaleMode() },
timeScale: { borderColor: 'rgba(255,255,255,0.08)', timeVisible: true, secondsVisible: false, rightOffset: 10, shiftVisibleRangeOnNewBar: true, lockVisibleTimeRangeOnResize: true, tickMarkFormatter: localTickFormatter },
handleScroll: { mouseWheel: true, pressedMouseMove: true },
handleScale: { mouseWheel: true, pinch: true },
});
slot.series = addMainSeries(slot.chart, prec, minMove);
slot.volSeries = slot.chart.addSeries(LightweightCharts.HistogramSeries, {
priceFormat: { type: 'volume' },
priceScaleId: 'vol',
color: 'rgba(100,116,139,0.3)',
});
slot.chart.priceScale('vol').applyOptions({
scaleMargins: { top: getVolScaleTop(), bottom: 0 },
drawTicks: false,
borderVisible: false,
});
// OHLCV legend
const legend = document.createElement('div');
legend.className = 'mc-ohlcv-legend';
chartEl.appendChild(legend);
slot.legend = legend;
slot.chart.subscribeCrosshairMove(param => {
if (!param || !param.time || !slot.legend) {
if (slot.legend) slot.legend.style.display = 'none';
return;
}
const data = param.seriesData.get(slot.series);
if (!data) { slot.legend.style.display = 'none'; return; }
const p = getPricePrecision(data.close || data.open || 1);
const o = (data.open||0).toFixed(p), h = (data.high||0).toFixed(p);
const l = (data.low||0).toFixed(p), c = (data.close||0).toFixed(p);
const volData = param.seriesData.get(slot.volSeries);
const v = volData ? (volData.value >= 1e6 ? (volData.value/1e6).toFixed(1)+'M' : (volData.value >= 1e3 ? (volData.value/1e3).toFixed(0)+'K' : volData.value.toFixed(0))) : '—';
const color = data.close >= data.open ? '#22c55e' : '#ef4444';
slot.legend.style.display = 'flex';
slot.legend.innerHTML = `<span style="color:${color}">O <b>${o}</b></span><span style="color:${color}">H <b>${h}</b></span><span style="color:${color}">L <b>${l}</b></span><span style="color:${color}">C <b>${c}</b></span><span style="color:var(--text-muted)">V <b>${v}</b></span>`;
});
// Drawing tools on slot — switch context on any interaction
chartEl.addEventListener('mousedown', () => {
if (drawCtx.source !== 'slot:' + slotIndex) {
setDrawCtxSlot(slotIndex);
// Move toolbar to this slot
renderDrawToolbar(chartEl);
}
}, true);
chartEl.addEventListener('touchstart', () => {
if (drawCtx.source !== 'slot:' + slotIndex) {
setDrawCtxSlot(slotIndex);
renderDrawToolbar(chartEl);
}
}, { capture: true, passive: true });
// Attach ruler + drawing handlers
attachRuler(chartEl, slot.chart, slot.series);
setupDrawingHandlers(chartEl);
// Render compact toolbar
setDrawCtxSlot(slotIndex);
renderDrawToolbar(chartEl);
// Reset ctx back to modal if modal is open
if (modal.chart) setDrawCtxModal();
}
async function loadSlotChart(slotIndex) {
const slot = mch.slots[slotIndex];
if (!slot || !slot.sym || !slot.series) return;
try {
const res = await fetch(`/api/klines?symbol=${slot.sym}&interval=${slot.tf}&limit=500`);
const raw = await res.json();
if (!Array.isArray(raw) || raw.length === 0) return;
const parsed = parseKlines(raw);
slot.candleData = parsed;
slot.series.setData(parsed);
slot.volSeries?.setData(extractVolume(parsed));
// Update drawCtx if this slot is active
if (drawCtx.source === 'slot:' + slotIndex) drawCtx.candleData = parsed;
// Scroll to end respecting rightOffset
slot.chart.timeScale().scrollToRealTime();
// Apply saved drawings (per-symbol, visible on all TFs)
applyDrawingsToSlot(slotIndex);
// Fetch and render density walls
applyDensityToSlot(slotIndex);
// Auto S/R levels on slot (with Multi-TF confluence)
if (spGet('levelsEnabled', true) && slot.candleData) {
if (slot.autoLevels) slot.autoLevels.forEach(pl => { try { slot.series.removePriceLine(pl); } catch(e){} });
slot.autoLevels = [];
let levels;
try {
levels = await computeMultiTFLevels(slot.sym, slot.tf, slot.candleData);
} catch { levels = computeAutoLevels(slot.candleData); }
levels.forEach(l => {
const line = slot.series.createPriceLine(levelToPriceLine(l, true));
slot.autoLevels.push(line);
});
}
// Auto trendlines on slot
if (spGet('trendlinesEnabled', false) && slot.candleData) {
if (slot.autoTrendlines) slot.autoTrendlines.forEach(obj => { try { slot.chart.removeSeries(obj.lineSeries); } catch(e){} });
slot.autoTrendlines = [];
const tlines = computeAutoTrendlines(slot.candleData);
tlines.forEach(tl => {
const opacity = tl.broken ? 0.25 : Math.max(0.4, tl.score / 100);
const baseColor = tl.type === 'support' ? [34, 197, 94] : [239, 68, 68];
const color = `rgba(${baseColor.join(',')},${opacity})`;
const ls = slot.chart.addSeries(LightweightCharts.LineSeries, {
color, lineWidth: tl.broken ? 1 : 2, lineStyle: tl.broken ? 3 : 2,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
ls.setData([
{ time: tl.p1.time, value: tl.p1.price },
{ time: tl.p2.time, value: tl.p2.price },
]);
slot.autoTrendlines.push({ lineSeries: ls });
});
}
// Channels on slot (each type individually toggled)
// Keltner
if (slot.keltner) slot.keltner.forEach(ls => { try { slot.chart.removeSeries(ls); } catch(e){} });
slot.keltner = [];
if (spGet('ch_keltner', false) && slot.candleData) {
const kd = computeKeltnerChannel(slot.candleData);
if (kd.length >= 2) {
const addBand = (field, style) => {
const ls = slot.chart.addSeries(LightweightCharts.LineSeries, {
color: style.color, lineWidth: 1, lineStyle: style.dash ? 2 : 0,
crosshairMarkerVisible: false, lastValueVisible: false,
priceLineVisible: false, pointMarkersVisible: false,
});
ls.setData(kd.map(d => ({ time: d.time, value: d[field] })));
slot.keltner.push(ls);
};
addBand('upper', { color: 'rgba(251,146,60,0.6)' });
addBand('mid', { color: 'rgba(251,146,60,0.35)', dash: true });
addBand('lower', { color: 'rgba(251,146,60,0.6)' });
}
}
// Regression Channel on slot
if (slot.regressionCh) slot.regressionCh.forEach(ls => { try { slot.chart.removeSeries(ls); } catch(e){} });
slot.regressionCh = [];
if (spGet('ch_regression', false) && slot.candleData) {
const rd = computeRegressionChannel(slot.candleData);
if (rd.length >= 2) {
const addL = (f, c, d) => { const ls = slot.chart.addSeries(LightweightCharts.LineSeries, { color: c, lineWidth: 1, lineStyle: d ? 2 : 0, crosshairMarkerVisible: false, lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false }); ls.setData(rd.map(p => ({ time: p.time, value: p[f] }))); slot.regressionCh.push(ls); };
addL('upper', 'rgba(56,189,248,0.5)', false);
addL('mid', 'rgba(56,189,248,0.35)', true);
addL('lower', 'rgba(56,189,248,0.5)', false);
}
}
// OI indicator
applyOI(slot, slot.sym, slot.tf);
} catch(e) {
console.error('[MCH] Load error slot', slotIndex, e);
}
}
// Client-side density wall filter using settings
function filterDensityWalls(walls) {
const depthPct = spGet('densityDepthPct', 5.0);
const ttlMin = spGet('densityTTLMin', 1);
const xSmall = spGet('densitySeveritySmall', 2.0);
const blacklistRaw = spGet('densityBlacklist', 'USDC,FDUSD,TUSD,USDP,DAI,USDD,EUR');
const blacklist = blacklistRaw ? blacklistRaw.split(',').map(s => s.trim().toUpperCase()).filter(Boolean) : [];
return walls.filter(w => {
// Blacklist filter
if (blacklist.length > 0 && blacklist.some(b => w.symbol && w.symbol.includes(b))) return false;
// Depth filter — distancePct from current price
if (w.distancePct !== undefined && Math.abs(w.distancePct) > depthPct) return false;
// TTL filter — minimum lifetime in minutes
if (w.lifetimeMins !== undefined && w.lifetimeMins < ttlMin) return false;
// Severity filter — xMult must be >= smallest severity setting
if (w.xMult !== undefined && w.xMult < xSmall) return false;
return true;
});
}
async function applyDensityToSlot(slotIndex) {
if (!spGet('densityEnabled', true)) return;
const slot = mch.slots[slotIndex];
if (!slot || !slot.sym || !slot.chart || !slot.series) return;
// Clear old density lines
if (slot.densityObjs) {
slot.densityObjs.forEach(obj => {
if (obj.priceLine) try { slot.series.removePriceLine(obj.priceLine); } catch(e) {}
});
}
slot.densityObjs = [];
try {
const res = await fetch(`/densities/v2?symbols=${slot.sym}`);
const json = await res.json();
const items = json.data || [];
if (items.length === 0) return;
const entry = items[0];
const walls = [];
if (entry.support) walls.push({ ...entry.support, side: 'bid' });
if (entry.resistance) walls.push({ ...entry.resistance, side: 'ask' });
// Add extra bid/ask walls if available
(entry.bidWalls || []).slice(1, 3).forEach(w => walls.push({ ...w, side: 'bid' }));
(entry.askWalls || []).slice(1, 3).forEach(w => walls.push({ ...w, side: 'ask' }));
walls.forEach(w => {
const isBid = w.side === 'bid';
const color = isBid ? '#22c55e' : '#ef4444';
const notionalStr = w.notional >= 1e6 ? (w.notional / 1e6).toFixed(1) + 'M' : Math.round(w.notional / 1e3) + 'K';
const label = `$${notionalStr} ${w.sizeVsMedian}x`;
const priceLine = slot.series.createPriceLine({
price: w.price,
color: color,
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: true,
title: label,
});
slot.densityObjs.push({ priceLine });
});
} catch(e) {
// silently ignore density fetch errors
}
}
// Apply density walls to mini-chart card (grid view)
// Batch density load — one request for all visible symbols
async function applyDensityToBatch(symbols) {
if (!spGet('densityEnabled', true)) return;
try {
const res = await fetch(`/densities/v2?symbols=${symbols.join(',')}`);
const json = await res.json();
const items = json.data || [];
if (items.length === 0) return;
// Build map: symbol → flat wall list
const bySymbol = {};
items.forEach(entry => {
const walls = [];
if (entry.support) walls.push({ ...entry.support, side: 'bid' });
if (entry.resistance) walls.push({ ...entry.resistance, side: 'ask' });
(entry.bidWalls || []).slice(1, 2).forEach(w => walls.push({ ...w, side: 'bid' }));
(entry.askWalls || []).slice(1, 2).forEach(w => walls.push({ ...w, side: 'ask' }));
if (walls.length > 0) bySymbol[entry.symbol] = walls;
});
// Apply to each chart
for (const sym of symbols) {
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.series) continue;
if (chartObj.densityLines) {
chartObj.densityLines.forEach(pl => { try { chartObj.series.removePriceLine(pl); } catch(e) {} });
}
chartObj.densityLines = [];
const symWalls = bySymbol[sym] || [];
symWalls.forEach(w => {
const color = w.side === 'bid' ? '#22c55e' : '#ef4444';
const notionalStr = w.notional >= 1e6 ? (w.notional / 1e6).toFixed(1) + 'M' : Math.round(w.notional / 1e3) + 'K';
const priceLine = chartObj.series.createPriceLine({
price: w.price, color, lineWidth: 1, lineStyle: 2,
axisLabelVisible: false, title: `$${notionalStr} ${w.sizeVsMedian}x`,
});
chartObj.densityLines.push(priceLine);
});
}
} catch(e) { /* ignore */ }
}
async function applyDensityToMiniChart(sym) {
if (!spGet('densityEnabled', true)) return;
const chartObj = mc.charts[sym];
if (!chartObj || !chartObj.series) return;
// Clear old density lines
if (chartObj.densityLines) {
chartObj.densityLines.forEach(pl => {
try { chartObj.series.removePriceLine(pl); } catch(e) {}
});
}
chartObj.densityLines = [];
try {
const res = await fetch(`/densities/simple?symbols=${sym}&limitSymbols=1&xFilter=2`);
const json = await res.json();
const walls = filterDensityWalls(json.data || []);
if (walls.length === 0) return;
walls.forEach(w => {
const color = w.sideKey === 'bid' ? '#22c55e' : '#ef4444';
const notionalStr = w.notional >= 1e6 ? (w.notional / 1e6).toFixed(1) + 'M' : Math.round(w.notional / 1e3) + 'K';
const priceLine = chartObj.series.createPriceLine({
price: w.price,
color,
lineWidth: 1,
lineStyle: 2,
axisLabelVisible: false,
title: `$${notionalStr} x${w.xMult}`,
});
chartObj.densityLines.push(priceLine);
});
} catch(e) { /* ignore */ }
}
// Apply density walls to modal chart
async function applyDensityToModal() {
if (!spGet('densityEnabled', true)) return;
if (!modal.chart || !modal.series || !modal.currentSym) return;
// Clear old density lines
if (modal.densityLines) {
modal.densityLines.forEach(pl => {
try { modal.series.removePriceLine(pl); } catch(e) {}
});
}
modal.densityLines = [];
try {
const res = await fetch(`/densities/v2?symbols=${modal.currentSym}`);
const json = await res.json();
const items = json.data || [];
if (items.length === 0) return;
const entry = items[0];
const walls = [];
if (entry.support) walls.push({ ...entry.support, side: 'bid' });
if (entry.resistance) walls.push({ ...entry.resistance, side: 'ask' });
// Add extra walls (up to 5 per side)
(entry.bidWalls || []).slice(1, 5).forEach(w => walls.push({ ...w, side: 'bid' }));
(entry.askWalls || []).slice(1, 5).forEach(w => walls.push({ ...w, side: 'ask' }));
walls.forEach(w => {
const isBid = w.side === 'bid';
const color = isBid ? '#22c55e' : '#ef4444';
const notionalStr = w.notional >= 1e6 ? (w.notional / 1e6).toFixed(1) + 'M' : Math.round(w.notional / 1e3) + 'K';
const statusTag = w.status === 'strong' ? ' 🧱' : w.status === 'confirmed' ? ' ✓' : '';
const priceLine = modal.series.createPriceLine({
price: w.price,
color,
lineWidth: w.sizeVsMedian >= 10 ? 2 : 1,
lineStyle: 2,
axisLabelVisible: true,
title: `$${notionalStr} ${w.sizeVsMedian}x${statusTag}`,
});
modal.densityLines.push(priceLine);
});
} catch(e) { /* ignore */ }
}
function applyDrawingsToSlot(slotIndex) {
const slot = mch.slots[slotIndex];
if (!slot || !slot.sym || !slot.chart || !slot.series) return;
// Clear old drawing objects
if (slot.drawObjs) {
slot.drawObjs.forEach(obj => {
if (obj.priceLine) try { slot.series.removePriceLine(obj.priceLine); } catch(e) {}
if (obj.lineSeries) try { slot.chart.removeSeries(obj.lineSeries); } catch(e) {}
});
}
slot.drawObjs = [];
const saved = drawStore.load(slot.sym);
if (!saved || saved.length === 0) return;
saved.forEach(s => {
if (s.type === 'hline' && s.data) {
const pl = slot.series.createPriceLine({
price: s.data.price,
color: s.color || '#5b9cf6',
lineWidth: 2,
lineStyle: 0,
axisLabelVisible: true,
title: '',
});
slot.drawObjs.push({ priceLine: pl });
} else if (s.type === 'fib' && s.data) {
const rawLevels = s.data.levels || FIB_DEFAULTS_OBJ;
const diff = s.data.p2 - s.data.p1;
rawLevels.forEach((item, i) => {
const lvl = typeof item === 'number' ? item : item.level;
const clr = (typeof item === 'object' && item.color) ? item.color : (s.color || FIB_DEFAULT_COLORS[i % FIB_DEFAULT_COLORS.length]);
const price = s.data.p1 + diff * lvl;
const pl = slot.series.createPriceLine({
price,
color: clr,
lineWidth: 1.5,
lineStyle: 0,
axisLabelVisible: true,
title: `${(lvl * 100).toFixed(1)}%`,
});
slot.drawObjs.push({ priceLine: pl });
});
} else if (s.type === 'ray' && s.data) {
const startTime = s.data.startTime || s.data.t1;
const farTime = startTime + 365 * 24 * 3600;
const ls = slot.chart.addSeries(LightweightCharts.LineSeries, {
color: s.color || '#5b9cf6',
lineWidth: 2,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
pointMarkersVisible: false,
});
ls.setData([
{ time: startTime, value: s.data.price },
{ time: farTime, value: s.data.price },
]);
slot.drawObjs.push({ lineSeries: ls });
} else if (s.type === 'trendline' && s.data) {
const ls = slot.chart.addSeries(LightweightCharts.LineSeries, {
color: s.color || '#5b9cf6',
lineWidth: 2,
crosshairMarkerVisible: false,
lastValueVisible: false,
priceLineVisible: false,
pointMarkersVisible: false,
});
ls.setData([
{ time: s.data.t1, value: s.data.p1 },
{ time: s.data.t2, value: s.data.p2 },
]);
slot.drawObjs.push({ lineSeries: ls });
} else if (s.type === 'rect' && s.data) {
const { t1, p1, t2, p2 } = s.data;
const clr = s.color || '#5b9cf6';
const pMax = Math.max(p1, p2);
const pMin = Math.min(p1, p2);
// Top border
const topLs = slot.chart.addSeries(LightweightCharts.LineSeries, {
color: clr, lineWidth: 1.5, crosshairMarkerVisible: false,
lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false,
});
topLs.setData([{ time: t1, value: pMax }, { time: t2, value: pMax }]);
slot.drawObjs.push({ lineSeries: topLs });
// Bottom border
const botLs = slot.chart.addSeries(LightweightCharts.LineSeries, {
color: clr, lineWidth: 1.5, crosshairMarkerVisible: false,
lastValueVisible: false, priceLineVisible: false, pointMarkersVisible: false,
});
botLs.setData([{ time: t1, value: pMin }, { time: t2, value: pMin }]);
slot.drawObjs.push({ lineSeries: botLs });
}
});
}
function saveSlotSymbols() {
const syms = mch.slots.map(s => s.sym || null);
lsSet('mch_slots', JSON.stringify(syms));
}
// WS helpers for multi-chart slots
function mchWsSubscribe(sym, tf) {
if (!sym) return;
const stream = `${sym.toLowerCase()}@kline_${tf}`;
if (mc.wsStreams.has(stream)) return;
mc.wsStreams.add(stream);
if (mc.ws && mc.ws.readyState === WebSocket.OPEN) {
mc.ws.send(JSON.stringify({ method: 'SUBSCRIBE', params: [stream], id: Date.now() }));
} else {
wsConnect();
}
}
function mchWsUnsubscribe(sym, tf) {
if (!sym) return;
const stream = `${sym.toLowerCase()}@kline_${tf}`;
// Only unsubscribe if no other slot uses same stream
const othersUsing = mch.slots.some(s => s.sym === sym && s.tf === tf && s.chart);
if (othersUsing) return;
if (mc.ws && mc.ws.readyState === WebSocket.OPEN) {
mc.ws.send(JSON.stringify({ method: 'UNSUBSCRIBE', params: [stream], id: Date.now() }));
}
mc.wsStreams.delete(stream);
}
// Override sidebar click when in multi-chart mode
function handleSidebarCoinClick(sym) {
if (mch.layout === 'grid') {
openCoinModal(sym);
} else {
assignSymbolToSlot(sym, mch.activeSlot);
}
}