645 lines
21 KiB
JavaScript
645 lines
21 KiB
JavaScript
import { dispose, init, getSupportedIndicators } from 'klinecharts'
|
|
|
|
const MINUTE = 60 * 1000
|
|
const LOAD_BAR_COUNT = 500
|
|
const BAR_SPACE_STEP = 5
|
|
const BAR_SPACE_MIN = 2
|
|
const BAR_SPACE_MAX = 50
|
|
|
|
const PERIODS = [
|
|
{ key: '1m', period: { span: 1, type: 'minute' }, candleType: 'candle_solid' },
|
|
{ key: '5m', period: { span: 5, type: 'minute' }, candleType: 'candle_solid' },
|
|
{ key: '15m', period: { span: 15, type: 'minute' }, candleType: 'candle_solid' },
|
|
{ key: '1h', period: { span: 1, type: 'hour' }, candleType: 'candle_solid' },
|
|
{ key: '1d', period: { span: 1, type: 'day' }, candleType: 'candle_solid' }
|
|
]
|
|
|
|
const PRICE_OVERLAY_INDICATORS = new Set(['MA', 'EMA', 'BOLL', 'SMA', 'BBI', 'SAR'])
|
|
|
|
const INDICATORS = getSupportedIndicators().map(name => ({
|
|
key: name,
|
|
name,
|
|
stacked: PRICE_OVERLAY_INDICATORS.has(name)
|
|
}))
|
|
|
|
let chart = null
|
|
let rawBars = null
|
|
let rawLoaded = false
|
|
let activePeriodKey = PERIODS[0].key
|
|
let isDark = false
|
|
let activeIndicators = new Set()
|
|
let closePopup = () => {}
|
|
let closeCrosshair = () => {}
|
|
let fileName = null
|
|
let dataIntervalMin = 1
|
|
|
|
function getActivePeriodConfig() {
|
|
return PERIODS.find(({ key }) => key === activePeriodKey) ?? PERIODS[0]
|
|
}
|
|
|
|
function parseTimestamp(line, parts, fmt) {
|
|
const tsNum = Number(parts[0])
|
|
if (!isNaN(tsNum) && tsNum > 1e12 && tsNum < 1e14) return tsNum
|
|
if (!isNaN(tsNum) && tsNum > 1e9 && tsNum < 1e12) return tsNum * 1000
|
|
if (fmt === 'binance') {
|
|
let ts = tsNum
|
|
if (ts > 0 && ts < 1e13) ts *= 1000
|
|
return ts
|
|
}
|
|
const dateStr = parts[0]
|
|
const timeStr = parts[1] ?? ''
|
|
if (dateStr.includes('.')) {
|
|
const y = Number(dateStr.slice(0, 4))
|
|
const m = Number(dateStr.slice(5, 7)) - 1
|
|
const d = Number(dateStr.slice(8, 10))
|
|
const h = Number(timeStr.slice(0, 2))
|
|
const mi = Number(timeStr.slice(3, 5))
|
|
return Date.UTC(y, m, d, h, mi, 0, 0)
|
|
}
|
|
if (dateStr.includes('-')) {
|
|
const y = Number(dateStr.slice(0, 4))
|
|
const m = Number(dateStr.slice(5, 7)) - 1
|
|
const d = Number(dateStr.slice(8, 10))
|
|
const h = Number(timeStr.slice(0, 2) || '0')
|
|
const mi = Number(timeStr.slice(3, 5) || '0')
|
|
return Date.UTC(y, m, d, h, mi, 0, 0)
|
|
}
|
|
if (dateStr.length === 8 && /^\d+$/.test(dateStr)) {
|
|
const y = Number(dateStr.slice(0, 4))
|
|
const m = Number(dateStr.slice(4, 6)) - 1
|
|
const d = Number(dateStr.slice(6, 8))
|
|
const h = Number((parts[1] || '').slice(0, 2) || '0')
|
|
const mi = Number((parts[1] || '').slice(3, 5) || '0')
|
|
return Date.UTC(y, m, d, h, mi, 0, 0)
|
|
}
|
|
if (line.includes(' ')) {
|
|
const dt = line.split(/[,\t]/)[0]
|
|
const [ds, ts] = dt.split(' ')
|
|
if (ds && ts) {
|
|
const y = Number(ds.slice(0, 4))
|
|
const m = Number(ds.slice(4, 6)) - 1
|
|
const d = Number(ds.slice(6, 8))
|
|
const h = Number(ts.slice(0, 2))
|
|
const mi = Number(ts.slice(3, 5))
|
|
return Date.UTC(y, m, d, h, mi, 0, 0)
|
|
}
|
|
}
|
|
return NaN
|
|
}
|
|
|
|
function detectFormat(firstLine) {
|
|
const trimmed = firstLine.trim()
|
|
const lower = trimmed.toLowerCase()
|
|
if (lower.startsWith('timestamp') || lower.startsWith('datetime') || lower.startsWith('time') || lower.startsWith('date')) {
|
|
const cols = trimmed.split(/[,\t]/)
|
|
const remaining = cols.slice(1).join(',')
|
|
return { type: 'header', sub: detectFormat(remaining) ?? 'generic', headerCols: cols }
|
|
}
|
|
const parts = trimmed.split(/[,\t]/)
|
|
if (parts.length >= 6 && /^\d{10,13}$/.test(parts[0])) return 'binance'
|
|
if (parts.length >= 6 && parts[0].includes('.')) return 'dot'
|
|
if (parts.length >= 5 && parts[0].includes('-')) return 'dash'
|
|
if (parts.length >= 4 && parts[0].length === 8 && /^\d+$/.test(parts[0])) return 'yyyymmdd'
|
|
return 'generic'
|
|
}
|
|
|
|
function parseCSV(text) {
|
|
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0)
|
|
if (lines.length < 1) return null
|
|
const fmt = detectFormat(lines[0])
|
|
const hasHeader = fmt.type === 'header'
|
|
const startIndex = hasHeader ? 1 : 0
|
|
if (lines.length <= startIndex) return null
|
|
const count = lines.length - startIndex
|
|
const tsArr = new Float64Array(count)
|
|
const oArr = new Float64Array(count)
|
|
const hArr = new Float64Array(count)
|
|
const lArr = new Float64Array(count)
|
|
const cArr = new Float64Array(count)
|
|
const vArr = new Float64Array(count)
|
|
let n = 0
|
|
for (let i = startIndex; i < lines.length; i++) {
|
|
const line = lines[i]
|
|
if (!line) continue
|
|
const parts = line.split(/[,\t]/)
|
|
if (parts.length < 5) continue
|
|
let ts, o, h, l, c, v = 0
|
|
ts = parseTimestamp(line, parts, fmt)
|
|
if (hasHeader) {
|
|
const oIdx = (parts[1] || '').includes(':') ? 2 : 1
|
|
o = Number(parts[oIdx])
|
|
h = Number(parts[oIdx + 1])
|
|
l = Number(parts[oIdx + 2])
|
|
c = Number(parts[oIdx + 3])
|
|
v = Number(parts[oIdx + 4] || 0)
|
|
} else {
|
|
o = Number(parts[2])
|
|
h = Number(parts[3])
|
|
l = Number(parts[4])
|
|
c = Number(parts[5])
|
|
v = Number(parts[6] || 0)
|
|
}
|
|
if (Number.isNaN(ts) || !isFinite(o)) continue
|
|
tsArr[n] = ts
|
|
oArr[n] = o
|
|
hArr[n] = h
|
|
lArr[n] = l
|
|
cArr[n] = c
|
|
vArr[n] = v
|
|
n++
|
|
}
|
|
return {
|
|
ts: tsArr.slice(0, n),
|
|
o: oArr.slice(0, n),
|
|
h: hArr.slice(0, n),
|
|
l: lArr.slice(0, n),
|
|
c: cArr.slice(0, n),
|
|
v: vArr.slice(0, n),
|
|
len: n
|
|
}
|
|
}
|
|
|
|
function loadRawCSV(source) {
|
|
const loadText = source instanceof File
|
|
? () => source.text()
|
|
: () => fetch(source).then(r => {
|
|
if (!r.ok) throw new Error('CSV fetch failed: ' + r.status)
|
|
return r.text()
|
|
})
|
|
return loadText().then(text => {
|
|
const data = parseCSV(text)
|
|
if (!data || data.len === 0) throw new Error('No data parsed from CSV')
|
|
return data
|
|
})
|
|
}
|
|
|
|
function aggregateBars(data, startIndex, endIndex, spanMinutes) {
|
|
if (spanMinutes === 1) {
|
|
const result = []
|
|
for (let i = startIndex; i < endIndex; i++) {
|
|
result.push({
|
|
timestamp: data.ts[i],
|
|
open: data.o[i],
|
|
high: data.h[i],
|
|
low: data.l[i],
|
|
close: data.c[i],
|
|
volume: data.v[i]
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
const result = []
|
|
const spanMs = spanMinutes * MINUTE
|
|
let groupStart = startIndex
|
|
while (groupStart < endIndex) {
|
|
let groupEnd = groupStart + 1
|
|
const groupStartTs = Math.floor(data.ts[groupStart] / spanMs) * spanMs
|
|
while (groupEnd < endIndex && Math.floor(data.ts[groupEnd] / spanMs) * spanMs === groupStartTs) {
|
|
groupEnd++
|
|
}
|
|
let o = data.o[groupStart]
|
|
let h = data.h[groupStart]
|
|
let l = data.l[groupStart]
|
|
let c = data.c[groupEnd - 1]
|
|
let v = 0
|
|
for (let i = groupStart; i < groupEnd; i++) {
|
|
h = Math.max(h, data.h[i])
|
|
l = Math.min(l, data.l[i])
|
|
v += data.v[i]
|
|
}
|
|
result.push({
|
|
timestamp: groupStartTs,
|
|
open: o,
|
|
high: h,
|
|
low: l,
|
|
close: c,
|
|
volume: v
|
|
})
|
|
groupStart = groupEnd
|
|
}
|
|
return result
|
|
}
|
|
|
|
function findIndex(data, targetTs) {
|
|
let lo = 0
|
|
let hi = data.len - 1
|
|
while (lo <= hi) {
|
|
const mid = (lo + hi) >> 1
|
|
if (data.ts[mid] < targetTs) lo = mid + 1
|
|
else hi = mid - 1
|
|
}
|
|
return lo
|
|
}
|
|
|
|
function getSpanMinutes(period) {
|
|
if (period.type === 'minute') return period.span
|
|
if (period.type === 'hour') return period.span * 60
|
|
if (period.type === 'day') return period.span * 1440
|
|
return 1
|
|
}
|
|
|
|
function loadDataLoader() {
|
|
return {
|
|
getBars: ({ type, timestamp, period, callback }) => {
|
|
if (!rawLoaded) {
|
|
callback([], { forward: false, backward: false })
|
|
return
|
|
}
|
|
const span = getSpanMinutes(period)
|
|
const duration = span * MINUTE
|
|
const bars = []
|
|
let forwardMore = false
|
|
let backwardMore = false
|
|
if (type === 'init') {
|
|
const latestTs = rawBars.ts[rawBars.len - 1]
|
|
const startTs = latestTs - LOAD_BAR_COUNT * duration
|
|
const startIdx = findIndex(rawBars, startTs)
|
|
const endIdx = findIndex(rawBars, latestTs + 1)
|
|
bars.push(...aggregateBars(rawBars, startIdx, Math.min(endIdx, rawBars.len), span))
|
|
forwardMore = startIdx > 0
|
|
} else if (type === 'forward') {
|
|
const startIdx = findIndex(rawBars, timestamp)
|
|
const endIdx = Math.max(0, startIdx - LOAD_BAR_COUNT)
|
|
bars.push(...aggregateBars(rawBars, endIdx, startIdx, span))
|
|
forwardMore = endIdx > 0
|
|
} else if (type === 'backward') {
|
|
const startIdx = findIndex(rawBars, timestamp)
|
|
const endIdx = Math.min(rawBars.len, startIdx + LOAD_BAR_COUNT)
|
|
bars.push(...aggregateBars(rawBars, startIdx, endIdx, span))
|
|
forwardMore = false
|
|
backwardMore = endIdx < rawBars.len
|
|
}
|
|
callback(bars, { forward: forwardMore, backward: backwardMore })
|
|
updateStatus()
|
|
},
|
|
subscribeBar: () => {},
|
|
unsubscribeBar: () => {}
|
|
}
|
|
}
|
|
|
|
// ---- UI helpers ----
|
|
|
|
function setActivePeriodButton() {
|
|
document.querySelectorAll('#period-switcher button').forEach((btn) => {
|
|
btn.classList.toggle('active', btn.dataset.period === activePeriodKey)
|
|
})
|
|
}
|
|
|
|
function setActiveIndicatorButtons() {
|
|
document.querySelectorAll('#indicator-switcher button').forEach((btn) => {
|
|
btn.classList.toggle('active', activeIndicators.has(btn.dataset.indicator))
|
|
})
|
|
}
|
|
|
|
function updateStatus() {
|
|
const el = document.getElementById('status')
|
|
if (!el || chart === null) return
|
|
const bars = chart.getDataList()
|
|
if (bars.length > 0) {
|
|
const from = new Date(bars[0].timestamp).toLocaleDateString()
|
|
const to = new Date(bars[bars.length - 1].timestamp).toLocaleDateString()
|
|
el.textContent = `${from} → ${to} | ${bars.length} bars`
|
|
}
|
|
}
|
|
|
|
function formatDate(ts) {
|
|
const d = new Date(ts)
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
}
|
|
|
|
// ---- Chart actions ----
|
|
|
|
function switchPeriod(periodKey) {
|
|
const config = PERIODS.find(({ key }) => key === periodKey)
|
|
if (chart === null || config === undefined || activePeriodKey === periodKey) return
|
|
const targetSpan = getSpanMinutes(config.period)
|
|
if (targetSpan < dataIntervalMin) {
|
|
alert(`数据原始间隔为 ${dataIntervalMin}m,无法切换到 ${targetSpan}m(需要更高级别的源数据)`)
|
|
return
|
|
}
|
|
activePeriodKey = periodKey
|
|
setActivePeriodButton()
|
|
chart.setStyles({ candle: { type: config.candleType } })
|
|
chart.setPeriod(config.period)
|
|
}
|
|
|
|
function toggleIndicator(key) {
|
|
const spec = INDICATORS.find((i) => i.key === key)
|
|
if (!spec || chart === null) return
|
|
if (activeIndicators.has(key)) {
|
|
activeIndicators.delete(key)
|
|
chart.removeIndicator({ name: spec.name })
|
|
} else {
|
|
activeIndicators.add(key)
|
|
const opts = spec.stacked
|
|
? { name: spec.name, paneId: 'candle_pane' }
|
|
: { name: spec.name, paneId: 'new' }
|
|
chart.createIndicator(opts, spec.stacked)
|
|
}
|
|
setActiveIndicatorButtons()
|
|
}
|
|
|
|
function setTheme(dark) {
|
|
isDark = dark
|
|
document.body.classList.toggle('dark', dark)
|
|
const btn = document.getElementById('btn-theme')
|
|
btn.textContent = dark ? '☀' : '☾'
|
|
btn.classList.toggle('active-icon', dark)
|
|
chart.setStyles(dark ? 'dark' : 'light')
|
|
}
|
|
|
|
function doScreenshot() {
|
|
if (chart === null) return
|
|
const url = chart.getConvertPictureUrl(false, 'png', isDark ? '#0d1117' : '#ffffff')
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `kline-${activePeriodKey}-${Date.now()}.png`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
}
|
|
|
|
function setBarSpace(value) {
|
|
if (chart === null) return
|
|
chart.setBarSpace(value)
|
|
updateStatus()
|
|
}
|
|
|
|
function doZoomIn() {
|
|
if (chart === null) return
|
|
const current = chart.getBarSpace().bar
|
|
setBarSpace(Math.min(current + BAR_SPACE_STEP, BAR_SPACE_MAX))
|
|
}
|
|
|
|
function doZoomOut() {
|
|
if (chart === null) return
|
|
const current = chart.getBarSpace().bar
|
|
setBarSpace(Math.max(current - BAR_SPACE_STEP, BAR_SPACE_MIN))
|
|
}
|
|
|
|
function doZoomReset() {
|
|
if (chart === null) return
|
|
setBarSpace(Math.round((BAR_SPACE_MIN + BAR_SPACE_MAX) / 2))
|
|
chart.scrollToRealTime()
|
|
}
|
|
|
|
// ---- Crosshair info ----
|
|
|
|
function formatPrice(v) { return v != null ? v.toFixed(5) : '-' }
|
|
function formatVol(v) {
|
|
if (v == null) return '-'
|
|
if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B'
|
|
if (v >= 1e6) return (v / 1e6).toFixed(2) + 'M'
|
|
if (v >= 1e3) return (v / 1e3).toFixed(2) + 'K'
|
|
return String(v)
|
|
}
|
|
|
|
function onCrosshairHandler(crosshair) {
|
|
const el = document.getElementById('crosshair-info')
|
|
closeCrosshair()
|
|
closeCrosshair = () => { el.classList.add('hidden') }
|
|
if (!crosshair || !crosshair.kLineData) {
|
|
el.classList.add('hidden')
|
|
return
|
|
}
|
|
el.classList.remove('hidden')
|
|
const k = crosshair.kLineData
|
|
const d = new Date(k.timestamp)
|
|
const timeStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
|
const change = k.close - k.open
|
|
const changePct = k.open > 0 ? (change / k.open * 100) : 0
|
|
const color = change >= 0 ? (isDark ? '#ff7b72' : '#cf222e') : (isDark ? '#7ee787' : '#1a7f37')
|
|
el.innerHTML = `
|
|
<div style="color:#8b949e;margin-bottom:2px">${timeStr}</div>
|
|
<div>O <b>${formatPrice(k.open)}</b> H <b>${formatPrice(k.high)}</b></div>
|
|
<div>L <b>${formatPrice(k.low)}</b> C <b style="color:${color}">${formatPrice(k.close)}</b></div>
|
|
<div>Δ <b style="color:${color}">${change >= 0 ? '+' : ''}${change.toFixed(5)} (${changePct >= 0 ? '+' : ''}${changePct.toFixed(3)}%)</b></div>
|
|
<div>Vol <b>${formatVol(k.volume)}</b></div>
|
|
`
|
|
}
|
|
|
|
// ---- Bar click detail ----
|
|
|
|
function onBarClickHandler(crosshair) {
|
|
const popup = document.getElementById('bar-detail')
|
|
closePopup()
|
|
if (!crosshair || !crosshair.kLineData || crosshair.x == null) {
|
|
popup.classList.add('hidden')
|
|
return
|
|
}
|
|
const k = crosshair.kLineData
|
|
const bull = k.close >= k.open
|
|
const cls = bull ? 'bull' : 'bear'
|
|
popup.classList.remove('hidden')
|
|
popup.style.left = Math.min(crosshair.x + 20, window.innerWidth - 200) + 'px'
|
|
popup.style.top = Math.min(crosshair.y + 20, window.innerHeight - 200) + 'px'
|
|
popup.innerHTML = `
|
|
<div style="margin-bottom:6px;font-weight:600">${formatDate(k.timestamp)}</div>
|
|
<div class="row"><span class="label">Open</span><span class="value ${cls}">${formatPrice(k.open)}</span></div>
|
|
<div class="row"><span class="label">High</span><span class="value">${formatPrice(k.high)}</span></div>
|
|
<div class="row"><span class="label">Low</span><span class="value">${formatPrice(k.low)}</span></div>
|
|
<div class="row"><span class="label">Close</span><span class="value ${cls}">${formatPrice(k.close)}</span></div>
|
|
<div class="row"><span class="label">Volume</span><span class="value">${formatVol(k.volume)}</span></div>
|
|
`
|
|
closePopup = () => { popup.classList.add('hidden') }
|
|
}
|
|
|
|
// ---- Right-click context menu ----
|
|
|
|
function showContextMenu(x, y) {
|
|
const menu = document.getElementById('context-menu')
|
|
const items = INDICATORS.map((ind) => {
|
|
const on = activeIndicators.has(ind.key)
|
|
return `<div class="item" data-indicator="${ind.key}"><span class="check">${on ? '✓' : ''}</span>${ind.name}</div>`
|
|
}).join('')
|
|
menu.innerHTML = `
|
|
${items}
|
|
<div class="sep"></div>
|
|
<div class="item" data-action="theme"><span class="check"></span>切换主题 <span class="key-hint">D</span></div>
|
|
<div class="item" data-action="screenshot"><span class="check"></span>截图 <span class="key-hint">S</span></div>
|
|
`
|
|
menu.style.left = Math.min(x, window.innerWidth - 200) + 'px'
|
|
menu.style.top = Math.min(y, window.innerHeight - 260) + 'px'
|
|
menu.classList.remove('hidden')
|
|
closePopup()
|
|
closePopup = () => {
|
|
menu.classList.add('hidden')
|
|
menu.querySelectorAll('.item').forEach((el) => el.removeEventListener('click', handleMenuItem))
|
|
}
|
|
menu.querySelectorAll('.item').forEach((el) => el.addEventListener('click', handleMenuItem))
|
|
}
|
|
|
|
function handleMenuItem(e) {
|
|
const el = e.currentTarget
|
|
const ind = el.dataset.indicator
|
|
const action = el.dataset.action
|
|
if (ind) toggleIndicator(ind)
|
|
if (action === 'theme') setTheme(!isDark)
|
|
if (action === 'screenshot') doScreenshot()
|
|
closePopup()
|
|
}
|
|
|
|
// ---- Keyboard ----
|
|
|
|
function onKeydown(e) {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
|
if (chart === null) return
|
|
switch (e.key) {
|
|
case 'ArrowLeft':
|
|
chart.scrollByDistance(-3 * chart.getBarSpace().bar)
|
|
e.preventDefault()
|
|
break
|
|
case 'ArrowRight':
|
|
chart.scrollByDistance(3 * chart.getBarSpace().bar)
|
|
e.preventDefault()
|
|
break
|
|
case '+':
|
|
case '=':
|
|
doZoomIn()
|
|
e.preventDefault()
|
|
break
|
|
case '-':
|
|
doZoomOut()
|
|
e.preventDefault()
|
|
break
|
|
case 'Escape':
|
|
closePopup()
|
|
closeCrosshair()
|
|
break
|
|
case 'd':
|
|
setTheme(!isDark)
|
|
break
|
|
case 's':
|
|
if (!e.ctrlKey && !e.metaKey) doScreenshot()
|
|
break
|
|
}
|
|
}
|
|
|
|
// ---- Toolbar binding ----
|
|
|
|
function bindToolbar() {
|
|
const picker = document.getElementById('file-picker')
|
|
const nameEl = document.getElementById('file-name')
|
|
const btnFile = document.getElementById('btn-file')
|
|
const input = document.createElement('input')
|
|
input.type = 'file'
|
|
input.accept = '.csv,.txt,.tsv'
|
|
input.style.display = 'none'
|
|
document.body.appendChild(input)
|
|
|
|
function setFile(file) {
|
|
fileName = file.name.replace(/\.[^.]+$/, '').replace(/[_\s]+/g, ' ').slice(0, 30)
|
|
if (nameEl) nameEl.textContent = fileName
|
|
mountChart(file)
|
|
}
|
|
|
|
btnFile.addEventListener('click', () => input.click())
|
|
input.addEventListener('change', () => {
|
|
if (input.files[0]) setFile(input.files[0])
|
|
})
|
|
|
|
picker.addEventListener('dragover', (e) => { e.preventDefault(); picker.classList.add('over') })
|
|
picker.addEventListener('dragleave', () => picker.classList.remove('over'))
|
|
picker.addEventListener('drop', (e) => {
|
|
e.preventDefault()
|
|
picker.classList.remove('over')
|
|
const file = e.dataTransfer.files[0]
|
|
if (file && (file.name.endsWith('.csv') || file.name.endsWith('.txt') || file.name.endsWith('.tsv'))) {
|
|
setFile(file)
|
|
}
|
|
})
|
|
|
|
document.querySelectorAll('#period-switcher button').forEach((btn) => {
|
|
btn.addEventListener('click', () => switchPeriod(btn.dataset.period))
|
|
})
|
|
|
|
const indicatorContainer = document.getElementById('indicator-switcher')
|
|
INDICATORS.forEach(ind => {
|
|
const btn = document.createElement('button')
|
|
btn.type = 'button'
|
|
btn.dataset.indicator = ind.key
|
|
btn.textContent = ind.key
|
|
btn.className = 'toolbar-btn'
|
|
btn.addEventListener('click', () => toggleIndicator(ind.key))
|
|
indicatorContainer.appendChild(btn)
|
|
})
|
|
document.getElementById('zoom-in').addEventListener('click', doZoomIn)
|
|
document.getElementById('zoom-out').addEventListener('click', doZoomOut)
|
|
document.getElementById('zoom-reset').addEventListener('click', doZoomReset)
|
|
document.getElementById('btn-theme').addEventListener('click', () => setTheme(!isDark))
|
|
document.getElementById('btn-screenshot').addEventListener('click', doScreenshot)
|
|
document.addEventListener('keydown', onKeydown)
|
|
document.addEventListener('click', () => { closePopup(); closeCrosshair() })
|
|
document.addEventListener('contextmenu', (e) => {
|
|
e.preventDefault()
|
|
showContextMenu(e.clientX, e.clientY)
|
|
})
|
|
setActivePeriodButton()
|
|
setActiveIndicatorButtons()
|
|
}
|
|
|
|
function detectDefaultPeriod(data) {
|
|
if (data.len < 2) {
|
|
dataIntervalMin = 1
|
|
return '1m'
|
|
}
|
|
let minGap = Infinity
|
|
for (let i = 1; i < Math.min(data.len, 100); i++) {
|
|
const gap = data.ts[i] - data.ts[i - 1]
|
|
if (gap > 0 && gap < minGap) minGap = gap
|
|
}
|
|
const gapMin = Math.round(minGap / MINUTE)
|
|
dataIntervalMin = gapMin
|
|
const candidates = PERIODS.map(p => ({ key: p.key, span: getSpanMinutes(p.period) }))
|
|
let best = candidates[0]
|
|
for (const c of candidates) {
|
|
if (c.span <= gapMin + 0.01) best = c
|
|
}
|
|
return best.key
|
|
}
|
|
|
|
function mountChart(source) {
|
|
const chartDom = document.getElementById('chart')
|
|
if (chartDom === null) return
|
|
const loadingEl = document.getElementById('loading')
|
|
if (loadingEl) loadingEl.style.display = 'block'
|
|
loadRawCSV(source).then(data => {
|
|
rawBars = data
|
|
rawLoaded = true
|
|
if (loadingEl) loadingEl.style.display = 'none'
|
|
console.log(`Loaded ${data.len} bars, ${new Date(data.ts[0])} → ${new Date(data.ts[data.len - 1])}`)
|
|
const defaultPeriod = detectDefaultPeriod(data)
|
|
if (defaultPeriod !== activePeriodKey) {
|
|
activePeriodKey = defaultPeriod
|
|
setActivePeriodButton()
|
|
}
|
|
if (chart) dispose(chartDom)
|
|
chart = init(chartDom, {
|
|
styles: {
|
|
candle: { type: getActivePeriodConfig().candleType },
|
|
indicator: { lastValueMark: { show: true } }
|
|
},
|
|
layout: {
|
|
barSpaceLimit: { min: BAR_SPACE_MIN, max: BAR_SPACE_MAX },
|
|
basicParams: {}
|
|
}
|
|
})
|
|
chart.setSymbol({ ticker: fileName || 'CSV', pricePrecision: 5, volumePrecision: 0 })
|
|
chart.setPeriod(getActivePeriodConfig().period)
|
|
chart.setDataLoader(loadDataLoader())
|
|
chart.subscribeAction('onCrosshairChange', onCrosshairHandler)
|
|
chart.subscribeAction('onCandleBarClick', onBarClickHandler)
|
|
setTimeout(updateStatus, 200)
|
|
}).catch(err => {
|
|
if (loadingEl) loadingEl.textContent = 'Error: ' + err.message
|
|
console.error(err)
|
|
})
|
|
}
|
|
|
|
window.addEventListener('beforeunload', () => {
|
|
const chartDom = document.getElementById('chart')
|
|
if (chartDom !== null) dispose(chartDom)
|
|
})
|
|
|
|
bindToolbar()
|
|
mountChart('/data/eurgbp_m1.csv') |