Files
KlineChart_dev/main.js
T

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> &nbsp; H <b>${formatPrice(k.high)}</b></div>
<div>L <b>${formatPrice(k.low)}</b> &nbsp; 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')