Files
ai/web/src/components/ChartView.vue

300 lines
8.9 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, type WatchStopHandle } from 'vue'
import Card from 'primevue/card'
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
import { useTradingViewShapes } from '../composables/useTradingViewShapes'
import { useTradingViewIndicators } from '../composables/useTradingViewIndicators'
import { useCustomIndicators, getCustomIndicatorsGetter } from '../composables/useCustomIndicators'
import { useChartStore } from '../stores/chart'
import { useIndicatorTypesStore } from '../stores/indicatorTypes'
import type { IChartingLibraryWidget } from '../types/tradingview'
import { intervalToSeconds } from '../utils'
import { wsManager } from '../composables/useWebSocket'
// Convert seconds to TradingView interval string.
// TradingView uses plain minute numbers ("60", "240") for intraday,
// and "1D", "2D" etc for daily. Never use "H" suffix — it's not in supported_resolutions.
function secondsToInterval(seconds: number): string {
if (seconds % 86400 === 0) return `${seconds / 86400}D`
return `${seconds / 60}` // plain number = minutes
}
const chartContainer = ref<HTMLDivElement | null>(null)
const chartStore = useChartStore()
let tvWidget: IChartingLibraryWidget | null = null
let datafeed: any = null
let isUpdatingFromChart = false // Flag to prevent circular updates
let shapeCleanup: (() => void) | null = null // Cleanup function for shape sync
let indicatorCleanup: (() => void) | null = null // Cleanup function for indicator sync
let customIndicatorCleanup: (() => void) | null = null // Cleanup for custom TV studies
let chartInitialized = false // Guard against double-init on reconnect
let symbolWatcher: WatchStopHandle | null = null
const maybeInitChart = () => {
if (chartInitialized || !chartContainer.value) return
if (!chartStore.symbol) {
// Defer until backend provides a symbol
if (!symbolWatcher) {
symbolWatcher = watch(() => chartStore.symbol, (sym) => {
if (sym) {
symbolWatcher?.()
symbolWatcher = null
maybeInitChart()
}
})
}
return
}
chartInitialized = true
initChart()
}
onMounted(() => {
// Wait for workspace to be ready (persistent stores loaded from container)
// before initializing TradingView, so stores are populated when onChartReady fires.
watch(wsManager.sessionStatus, (status) => {
if (status === 'ready') maybeInitChart()
}, { immediate: true })
})
// Wait for TradingView library to load
function initChart() {
if (!window.TradingView) {
setTimeout(initChart, 100)
return
}
try {
datafeed = createTradingViewDatafeed()
const indicatorTypesStore = useIndicatorTypesStore()
console.log('[ChartView] indicator_types at widget init:', Object.keys(indicatorTypesStore.types))
tvWidget = new window.TradingView.widget({
symbol: chartStore.symbol, // Use symbol from store
datafeed: datafeed,
interval: secondsToInterval(chartStore.period) as any,
container: chartContainer.value!,
library_path: '/charting_library/',
locale: 'en',
// Register named studies (one per custom indicator type) plus generic fallbacks.
// Must be provided here — TV has no dynamic study registration API.
// indicatorTypesStore is populated before widget creation (session ready guard).
custom_indicators_getter: getCustomIndicatorsGetter(() => indicatorTypesStore.types),
disabled_features: [
'use_localstorage_for_settings',
'header_symbol_search',
'symbol_search_hot_key'
],
enabled_features: [],
fullscreen: false,
autosize: true,
theme: 'Dark',
timezone: 'Etc/UTC'
})
tvWidget.onChartReady(() => {
console.log('TradingView chart ready')
setupChartListeners()
setupStoreWatchers()
// Initialize visible range on chart load
initializeVisibleRange()
// Setup shape synchronization
if (tvWidget) {
shapeCleanup = useTradingViewShapes(tvWidget)
indicatorCleanup = useTradingViewIndicators(tvWidget)
customIndicatorCleanup = useCustomIndicators(tvWidget)
}
})
} catch (error) {
console.error('Failed to initialize TradingView widget:', error)
}
}
function initializeVisibleRange() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
const timeScale = chart.getVisibleRange()
if (timeScale) {
const startTime = Math.floor(timeScale.from)
const endTime = Math.floor(timeScale.to)
console.log('[ChartView] Initial visible range:', {
from: new Date(startTime * 1000).toISOString(),
to: new Date(endTime * 1000).toISOString()
})
chartStore.start_time = startTime
chartStore.end_time = endTime
}
}
function setupChartListeners() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
// Listen for symbol changes
chart.onSymbolChanged().subscribe(null, () => {
const symbolInfo = chart.symbolExt()
if (symbolInfo && symbolInfo.ticker) {
console.log('[ChartView] Symbol changed to:', symbolInfo.ticker)
isUpdatingFromChart = true
chartStore.symbol = symbolInfo.ticker
isUpdatingFromChart = false
}
})
// Listen for period changes
chart.onIntervalChanged().subscribe(null, (interval: string) => {
const seconds = intervalToSeconds(interval)
console.log('[ChartView] Period changed to:', interval, `(${seconds}s)`)
isUpdatingFromChart = true
chartStore.period = seconds
isUpdatingFromChart = false
})
// Listen for visible range changes (when user pans/zooms)
chart.onVisibleRangeChanged().subscribe(null, () => {
const timeScale = chart.getVisibleRange()
if (timeScale) {
// Convert from seconds to seconds (TradingView uses seconds for visible range)
const startTime = Math.floor(timeScale.from)
const endTime = Math.floor(timeScale.to)
console.log('[ChartView] Visible range changed:', {
from: new Date(startTime * 1000).toISOString(),
to: new Date(endTime * 1000).toISOString()
})
isUpdatingFromChart = true
chartStore.start_time = startTime
chartStore.end_time = endTime
isUpdatingFromChart = false
}
})
}
function setupStoreWatchers() {
if (!tvWidget) return
const chart = tvWidget.activeChart()
// Watch for external changes to symbol (e.g., from backend/agent)
watch(
() => chartStore.symbol,
(newSymbol) => {
if (isUpdatingFromChart) return // Ignore updates that came from the chart itself
console.log('[ChartView] Store symbol changed externally to:', newSymbol)
const currentSymbol = chart.symbolExt()
if (currentSymbol && currentSymbol.ticker !== newSymbol) {
chart.setSymbol(newSymbol, () => {
console.log('[ChartView] Chart symbol updated to:', newSymbol)
})
}
}
)
// Watch for external changes to period
watch(
() => chartStore.period,
(newPeriod) => {
if (isUpdatingFromChart) return
const tvInterval = secondsToInterval(newPeriod)
console.log('[ChartView] Store period changed externally to:', newPeriod, `-> ${tvInterval}`)
if (chart.resolution() !== tvInterval) {
chart.setResolution(tvInterval, () => {
console.log('[ChartView] Chart period updated to:', tvInterval)
})
}
}
)
// Note: Visible range is typically only set by user interaction,
// but we could add a watcher here if we want the backend to be able
// to change the visible range programmatically
}
onBeforeUnmount(() => {
if (symbolWatcher) {
symbolWatcher()
symbolWatcher = null
}
// Cleanup shape synchronization
if (shapeCleanup) {
shapeCleanup()
shapeCleanup = null
}
// Cleanup indicator synchronization
if (indicatorCleanup) {
indicatorCleanup()
indicatorCleanup = null
}
// Cleanup custom TV studies
if (customIndicatorCleanup) {
customIndicatorCleanup()
customIndicatorCleanup = null
}
if (tvWidget) {
tvWidget.remove()
tvWidget = null
}
if (datafeed && typeof datafeed.destroy === 'function') {
datafeed.destroy()
datafeed = null
}
})
</script>
<template>
<Card class="chart-card">
<template #content>
<div ref="chartContainer" class="chart-container"></div>
</template>
</Card>
</template>
<style scoped>
.chart-card {
height: 100% !important;
display: flex;
flex-direction: column;
overflow: hidden;
border: none;
border-radius: 0 !important;
background: var(--p-surface-0);
}
.chart-card :deep(.p-card) {
border-radius: 0 !important;
}
.chart-card :deep(.p-card-body) {
flex: 1;
display: flex;
flex-direction: column;
padding: 0;
overflow: hidden;
}
.chart-card :deep(.p-card-content) {
flex: 1;
display: flex;
overflow: hidden;
padding: 0;
}
.chart-container {
flex: 1;
width: 100%;
height: 100%;
}
</style>