shape editing

This commit is contained in:
2026-03-02 22:49:45 -04:00
parent f4da40706c
commit bf7af2b426
18 changed files with 2236 additions and 209 deletions

View File

@@ -7,6 +7,7 @@ import ChatPanel from './components/ChatPanel.vue'
import LoginScreen from './components/LoginScreen.vue'
import { useOrderStore } from './stores/orders'
import { useChartStore } from './stores/chart'
import { useShapeStore } from './stores/shapes'
import { useStateSync } from './composables/useStateSync'
import { wsManager } from './composables/useWebSocket'
@@ -63,9 +64,11 @@ const handleAuthenticate = async (
// Initialize state sync after successful authentication
const orderStore = useOrderStore()
const chartStore = useChartStore()
const shapeStore = useShapeStore()
const stateSync = useStateSync({
OrderStore: orderStore,
ChartStore: chartStore
ChartStore: chartStore,
ShapeStore: shapeStore
})
stateSyncCleanup = stateSync.cleanup
} else {

View File

@@ -2,6 +2,7 @@
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Card from 'primevue/card'
import { createTradingViewDatafeed } from '../composables/useTradingViewDatafeed'
import { useTradingViewShapes } from '../composables/useTradingViewShapes'
import { useChartStore } from '../stores/chart'
import type { IChartingLibraryWidget } from '../types/tradingview'
@@ -10,6 +11,7 @@ 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
onMounted(() => {
if (!chartContainer.value) return
@@ -49,6 +51,10 @@ onMounted(() => {
setupStoreWatchers()
// Initialize visible range on chart load
initializeVisibleRange()
// Setup shape synchronization
if (tvWidget) {
shapeCleanup = useTradingViewShapes(tvWidget)
}
})
} catch (error) {
console.error('Failed to initialize TradingView widget:', error)
@@ -165,6 +171,12 @@ function setupStoreWatchers() {
}
onBeforeUnmount(() => {
// Cleanup shape synchronization
if (shapeCleanup) {
shapeCleanup()
shapeCleanup = null
}
if (tvWidget) {
tvWidget.remove()
tvWidget = null

View File

@@ -271,9 +271,9 @@ const chatStyles = computed(() => JSON.stringify({
},
message: {
background: '#161e35',
backgroundMe: '#00d4aa',
backgroundMe: '#1e2d4f',
color: '#cdd6e8',
colorMe: '#0a0e1a'
colorMe: '#cdd6e8'
}
}))

View File

@@ -0,0 +1,735 @@
import { watch } from 'vue'
import type { IChartingLibraryWidget } from '../types/tradingview'
import { useShapeStore } from '../stores/shapes'
import { useChartStore } from '../stores/chart'
import type { Shape, ControlPoint } from '../stores/shapes'
/**
* Map TradingView internal tool names (LineToolXXX) to TradingView's native shape type names
* Used when converting user-drawn shapes to our Shape format
*/
const TV_TOOL_TO_SHAPE_TYPE: Record<string, string> = {
'LineToolTrendLine': 'trend_line',
'LineToolHorzLine': 'horizontal_line',
'LineToolVertLine': 'vertical_line',
'LineToolRectangle': 'rectangle',
'LineToolFibRetracement': 'fib_retracement',
'LineToolFibExtension': 'fib_trend_ext',
'LineToolParallelChannel': 'parallel_channel',
'LineToolArrow': 'arrow',
'LineToolText': 'text',
'LineToolCallout': 'text',
'LineToolCircle': 'circle',
'LineToolPath': 'path',
'LineToolPitchfork': 'pitchfork',
'LineToolGannFan': 'gannbox_fan',
'LineToolHeadAndShoulders': 'head_and_shoulders'
}
/**
* Map line styles between our format and TradingView format
*/
const LINE_STYLE_TO_TV: Record<string, number> = {
'solid': 0,
'dashed': 1,
'dotted': 2
}
const TV_TO_LINE_STYLE: Record<number, string> = {
0: 'solid',
1: 'dashed',
2: 'dotted'
}
/**
* Convert TradingView shape to our Shape format
*/
function convertTVShapeToShape(tvShape: any, symbol: string, shapeId?: string, shapeName?: string): Shape {
// Get shape ID - try multiple ways
let id = shapeId || tvShape.id
if (!id && typeof tvShape.getId === 'function') {
try {
id = tvShape.getId()
} catch (e) {
console.warn('[TradingView Shapes] Error calling getId:', e)
}
}
// Get shape name/type - use provided name first, then try multiple properties
if (!shapeName) {
shapeName = tvShape.name
}
if (!shapeName && typeof tvShape.getName === 'function') {
try {
shapeName = tvShape.getName()
} catch (e) {
console.warn('[TradingView Shapes] Error calling getName:', e)
}
}
// Fallback to checking shape type or tool name
if (!shapeName) {
shapeName = tvShape.toolName || tvShape.type || 'unknown'
}
const standardType = TV_TOOL_TO_SHAPE_TYPE[shapeName] || shapeName
// Extract control points from TradingView shape
const points: ControlPoint[] = []
// Try multiple ways to get points from the shape
let pointsData = tvShape.points || []
// If getPoints method exists, use it
if (typeof tvShape.getPoints === 'function') {
try {
pointsData = tvShape.getPoints() || []
} catch (e) {
console.warn('[TradingView Shapes] Error calling getPoints:', e)
}
}
// Convert points to our format
if (Array.isArray(pointsData)) {
for (const point of pointsData) {
if (point && typeof point === 'object') {
const controlPoint: ControlPoint = {
time: Math.floor(point.time),
price: point.price
}
// Only include channel if it's actually set
if (point.channel !== undefined && point.channel !== null) {
controlPoint.channel = point.channel
}
points.push(controlPoint)
}
}
}
console.log(`[TradingView Shapes] Converting shape ${id} (${shapeName}), found ${points.length} points`, points)
// Extract properties
let properties = tvShape.properties || {}
// If getProperties method exists, use it
if (typeof tvShape.getProperties === 'function') {
try {
properties = tvShape.getProperties() || properties
} catch (e) {
console.warn('[TradingView Shapes] Error calling getProperties:', e)
}
}
const color = properties.linecolor || '#2962FF'
const lineWidth = properties.linewidth || 1
const lineStyle = TV_TO_LINE_STYLE[properties.linestyle] || 'solid'
const now = Math.floor(Date.now() / 1000)
return {
id,
type: standardType,
points,
color,
line_width: lineWidth,
line_style: lineStyle,
properties: {
...properties,
// Store original TradingView shape name for reference
tv_shape_name: shapeName
},
symbol,
created_at: now,
modified_at: now
}
}
/**
* Convert interval string to seconds
*/
function intervalToSeconds(interval: string): number {
// Handle plain numbers (TradingView uses integers for sub-hour intervals in minutes)
const numericInterval = parseInt(interval)
if (!isNaN(numericInterval) && interval === numericInterval.toString()) {
return numericInterval * 60 // Plain number means minutes
}
// Handle formatted intervals like "15M", "1H", "1D"
const match = interval.match(/^(\d+)([SMHDW])$/)
if (!match) {
console.warn('[TradingView Shapes] Unknown interval format:', interval)
return 60 // Default to 1 minute
}
const value = parseInt(match[1])
const unit = match[2]
const multipliers: Record<string, number> = {
'S': 1,
'M': 60,
'H': 3600,
'D': 86400,
'W': 604800
}
return value * (multipliers[unit] || 60)
}
/**
* Canonicalize timestamp to candle boundary
* TradingView requires timestamps to align exactly with candle start times
*/
function canonicalizeTimestamp(timestamp: number, intervalSeconds: number): number {
// Round down to nearest candle boundary
return Math.floor(timestamp / intervalSeconds) * intervalSeconds
}
/**
* Setup bidirectional synchronization between TradingView shapes and ShapeStore
*/
export function useTradingViewShapes(tvWidget: IChartingLibraryWidget) {
const shapeStore = useShapeStore()
const chartStore = useChartStore()
let isApplyingTVUpdate = false // Prevent circular updates when TV modifies shapes
let isUpdatingStore = false // Prevent circular updates when we update the store from TV
let isChartReady = false // Track if chart API is ready
/**
* Handle drawing events from TradingView
*/
function handleDrawingEvent(shapeId: string, eventType: string) {
if (isApplyingTVUpdate) return
console.log('[TradingView Shapes] Drawing event:', eventType, shapeId)
try {
const chart = tvWidget.activeChart()
if (!chart) return
const currentSymbol = chartStore.chart_state.symbol
if (eventType === 'remove') {
isUpdatingStore = true
shapeStore.removeShape(shapeId)
isUpdatingStore = false
return
}
// First get the shape name from getAllShapes (which has id and name)
let shapeName: string | undefined
const allShapes = chart.getAllShapes()
if (allShapes) {
const shapeInfo = allShapes.find((s: any) => s.id === shapeId)
if (shapeInfo) {
shapeName = shapeInfo.name
}
}
// Then get the actual shape entity using getShapeById (which has methods)
let tvShape
try {
tvShape = chart.getShapeById(shapeId)
} catch (error) {
console.warn('[TradingView Shapes] Could not get shape:', shapeId)
return
}
if (!tvShape) return
// Debug: log on first encounter
if (eventType === 'create') {
console.log('[TradingView Shapes] Shape entity methods:', Object.keys(tvShape))
console.log('[TradingView Shapes] Shape name from getAllShapes:', shapeName)
}
if (eventType === 'create') {
// Get points immediately - don't create shape without them
const points = tvShape.getPoints()
const controlPoints = points && points.length > 0 ? points.map(p => {
const cp: ControlPoint = {
time: Math.floor(p.time),
price: p.price
}
if (p.channel !== undefined && p.channel !== null) {
cp.channel = p.channel
}
return cp
}) : []
// Create shape with complete data
const shape = convertTVShapeToShape(tvShape, currentSymbol, shapeId, shapeName)
// Override points with the ones we just fetched
shape.points = controlPoints
console.log('[TradingView Shapes] Creating shape:', shapeId, shape.type, 'with', shape.points.length, 'points')
isUpdatingStore = true
shapeStore.addShape(shape)
isUpdatingStore = false
} else if (eventType === 'points_changed') {
// Points updated - get latest points and create NEW array
const points = tvShape.getPoints()
if (points) {
// Create a completely new array to ensure it's treated as a replacement
const controlPoints = Array.from(points.map(p => {
const cp: ControlPoint = {
time: Math.floor(p.time),
price: p.price
}
if (p.channel !== undefined && p.channel !== null) {
cp.channel = p.channel
}
return cp
}))
console.log('[TradingView Shapes] Points changed:', shapeId, 'now has', controlPoints.length, 'points')
isUpdatingStore = true
shapeStore.updateShape(shapeId, { points: controlPoints })
isUpdatingStore = false
}
} else if (eventType === 'properties_changed') {
// Properties updated - get latest properties
const properties = tvShape.getProperties()
if (properties) {
const color = properties.linecolor || '#2962FF'
const lineWidth = properties.linewidth || 1
const lineStyle = TV_TO_LINE_STYLE[properties.linestyle] || 'solid'
console.log('[TradingView Shapes] Properties changed:', shapeId)
isUpdatingStore = true
shapeStore.updateShape(shapeId, {
color,
line_width: lineWidth,
line_style: lineStyle,
properties
})
isUpdatingStore = false
}
} else if (eventType === 'move') {
// Shape moved - update points with NEW array
const points = tvShape.getPoints()
if (points) {
const controlPoints = Array.from(points.map(p => {
const cp: ControlPoint = {
time: Math.floor(p.time),
price: p.price
}
if (p.channel !== undefined && p.channel !== null) {
cp.channel = p.channel
}
return cp
}))
console.log('[TradingView Shapes] Shape moved:', shapeId)
isUpdatingStore = true
shapeStore.updateShape(shapeId, { points: controlPoints })
isUpdatingStore = false
}
}
} catch (error) {
console.error('[TradingView Shapes] Error handling drawing event:', error)
}
}
/**
* Setup listeners for TradingView shape events
*/
function setupShapeListeners() {
try {
const chart = tvWidget.activeChart()
if (!chart) {
console.warn('[TradingView Shapes] Active chart not available yet')
return () => {}
}
isChartReady = true
console.log('[TradingView Shapes] Setting up drawing event subscriptions')
// Subscribe to the drawing event which handles all shape events
const unsubscribe = tvWidget.subscribe('drawing_event', (shapeId: string, eventType: string) => {
handleDrawingEvent(shapeId, eventType)
})
// Initial sync on data load
const dataLoadedSubscription = chart.onDataLoaded().subscribe(null, () => {
console.log('[TradingView Shapes] Chart data loaded, performing initial sync')
syncShapesFromTV()
})
// Cleanup function to unsubscribe
return () => {
console.log('[TradingView Shapes] Cleaning up event subscriptions')
try {
if (typeof unsubscribe === 'function') {
unsubscribe()
} else {
console.warn('[TradingView Shapes] unsubscribe is not a function:', typeof unsubscribe)
}
if (dataLoadedSubscription && typeof dataLoadedSubscription.unsubscribe === 'function') {
dataLoadedSubscription.unsubscribe()
}
} catch (error) {
console.error('[TradingView Shapes] Error during cleanup:', error)
}
}
} catch (error) {
console.error('[TradingView Shapes] Error setting up shape listeners:', error)
return () => {}
}
}
/**
* Scan TradingView for shapes and sync to store
*/
function syncShapesFromTV() {
if (!isChartReady) {
return // Skip if chart isn't ready yet
}
try {
const chart = tvWidget.activeChart()
if (!chart) {
return // Chart not available
}
const allShapes = chart.getAllShapes()
if (!allShapes) return
const currentSymbol = chartStore.chart_state.symbol
// Track which shape IDs we've seen
const seenIds = new Set<string>()
isUpdatingStore = true
try {
for (const tvShape of allShapes) {
seenIds.add(tvShape.id)
const existingShape = shapeStore.getShape(tvShape.id)
const newShape = convertTVShapeToShape(tvShape, currentSymbol)
if (!existingShape) {
// New shape - add it
console.log('[TradingView Shapes] New shape detected:', tvShape.id, tvShape.name, 'with', newShape.points.length, 'points')
shapeStore.addShape(newShape)
} else {
// Check if shape has changed
const pointsChanged = JSON.stringify(existingShape.points) !== JSON.stringify(newShape.points)
const hasChanged =
pointsChanged ||
existingShape.color !== newShape.color ||
existingShape.line_width !== newShape.line_width ||
existingShape.line_style !== newShape.line_style
if (hasChanged) {
if (pointsChanged) {
console.log('[TradingView Shapes] Shape points changed:', tvShape.id,
'from', existingShape.points.length, 'to', newShape.points.length, 'points')
} else {
console.log('[TradingView Shapes] Shape properties modified:', tvShape.id)
}
shapeStore.updateShape(tvShape.id, {
points: newShape.points,
color: newShape.color,
line_width: newShape.line_width,
line_style: newShape.line_style,
properties: newShape.properties
})
}
}
}
// Check for deleted shapes
const allStoreShapes = shapeStore.getAllShapes()
for (const storeShape of allStoreShapes) {
if (storeShape.symbol === currentSymbol && !seenIds.has(storeShape.id)) {
console.log('[TradingView Shapes] Shape deleted:', storeShape.id)
shapeStore.removeShape(storeShape.id)
}
}
} finally {
isUpdatingStore = false
}
} catch (error) {
console.error('[TradingView Shapes] Error syncing shapes from TV:', error)
}
}
/**
* Setup watchers for ShapeStore changes to apply to TradingView
*/
function setupStoreWatchers() {
// Watch for shape store changes and apply to TradingView
watch(
() => shapeStore.shapes,
async (newShapes, oldShapes) => {
if (isUpdatingStore || !isChartReady) return
isApplyingTVUpdate = true
try {
const chart = tvWidget.activeChart()
if (!chart) return
const currentSymbol = chartStore.chart_state.symbol
// Find added shapes
for (const [id, shape] of Object.entries(newShapes)) {
// Only apply shapes for current symbol (or shapes with null symbol)
if (shape.symbol && shape.symbol !== currentSymbol) continue
if (!oldShapes || !oldShapes[id]) {
// Shape was added to store
await createTVShape(shape)
} else {
// Check if shape exists in TradingView
let tvShape = null
try {
tvShape = chart.getShapeById(id)
} catch (e) {
// Shape doesn't exist in TV
}
if (!tvShape) {
// Shape is in store but not in TradingView - create it
await createTVShape(shape)
} else if (JSON.stringify(shape) !== JSON.stringify(oldShapes[id])) {
// Shape was modified
updateTVShape(shape)
}
}
}
// Find removed shapes
if (oldShapes) {
for (const [id, shape] of Object.entries(oldShapes)) {
if (shape.symbol && shape.symbol !== currentSymbol) continue
if (!newShapes[id]) {
removeTVShape(id)
}
}
}
} catch (error) {
console.error('[TradingView Shapes] Error applying store changes:', error)
} finally {
isApplyingTVUpdate = false
}
},
{ deep: true }
)
}
/**
* Create a shape in TradingView
*/
async function createTVShape(shape: Shape) {
if (!isChartReady) return
try {
const chart = tvWidget.activeChart()
if (!chart) return
const currentSymbol = chartStore.chart_state.symbol
// Get current chart interval and convert to seconds for timestamp canonicalization
const interval = chartStore.chart_state.interval
const intervalSeconds = intervalToSeconds(interval)
// Convert points to TradingView format and canonicalize timestamps to candle boundaries
const tvPoints = shape.points.map(p => {
const canonicalTime = canonicalizeTimestamp(p.time, intervalSeconds)
const point: any = {
time: canonicalTime,
price: p.price
}
// Only include channel if it has a value (for parallel channels, etc.)
if (p.channel != null) {
point.channel = p.channel
}
return point
})
// Build overrides - merge color/width/style with properties
const overrides: any = {
...shape.properties
}
if (shape.color) {
overrides.linecolor = shape.color
}
if (shape.line_width) {
overrides.linewidth = shape.line_width
}
if (shape.line_style) {
overrides.linestyle = LINE_STYLE_TO_TV[shape.line_style] || 0
}
const options = {
shape: shape.type,
zOrder: 'top',
lock: false,
disableSelection: false,
disableSave: false,
disableUndo: false,
overrides
}
console.log(`[TradingView Shapes] Creating shape type="${shape.type}" with ${tvPoints.length} points:`, tvPoints)
// Create the shape - use createShape for single points, createMultipointShape for multiple
let entityIdPromise
if (tvPoints.length === 1) {
console.log(`[TradingView Shapes] Using createShape() for single point`)
entityIdPromise = chart.createShape(tvPoints[0], options)
} else {
console.log(`[TradingView Shapes] Using createMultipointShape() for ${tvPoints.length} points`)
entityIdPromise = chart.createMultipointShape(tvPoints, options)
}
const entityId = await entityIdPromise
// If TradingView assigned a different ID, update the store
if (entityId && entityId !== shape.id) {
isUpdatingStore = true
await new Promise(resolve => setTimeout(resolve, 0))
try {
shapeStore.removeShape(shape.id)
const updatedShape: Shape = {
...shape,
id: entityId,
original_id: shape.original_id || shape.id,
symbol: shape.symbol || currentSymbol
}
shapeStore.addShape(updatedShape)
} finally {
await new Promise(resolve => setTimeout(resolve, 0))
isUpdatingStore = false
}
}
// Bring shape to front to ensure visibility
try {
const createdShape = chart.getShapeById(entityId)
if (createdShape && typeof createdShape.bringToFront === 'function') {
createdShape.bringToFront()
}
} catch (e) {
// Ignore errors
}
} catch (error) {
console.error('[TradingView Shapes] Error creating shape:', error)
}
}
/**
* Update a shape in TradingView
*/
function updateTVShape(shape: Shape) {
if (!isChartReady) return
try {
const chart = tvWidget.activeChart()
if (!chart) return
const tvShape = chart.getShapeById(shape.id)
if (!tvShape) {
console.warn('[TradingView Shapes] Shape not found in TV, creating instead:', shape.id)
createTVShape(shape)
return
}
// Update points
const tvPoints = shape.points.map(p => {
const point: { time: number; price: number; channel?: string } = {
time: p.time,
price: p.price
}
if (p.channel) {
point.channel = p.channel
}
return point
})
tvShape.setPoints(tvPoints)
// Update properties
tvShape.setProperties({
linecolor: shape.color || '#2962FF',
linewidth: shape.line_width || 1,
linestyle: LINE_STYLE_TO_TV[shape.line_style || 'solid'] || 0,
...shape.properties
})
console.log('[TradingView Shapes] Updated shape in TV:', shape.id)
} catch (error) {
console.error('[TradingView Shapes] Error updating shape in TV:', error)
}
}
/**
* Remove a shape from TradingView
*/
function removeTVShape(shapeId: string) {
if (!isChartReady) return
try {
const chart = tvWidget.activeChart()
if (!chart) return
const tvShape = chart.getShapeById(shapeId)
if (tvShape) {
chart.removeEntity(tvShape)
console.log('[TradingView Shapes] Removed shape from TV:', shapeId)
}
} catch (error) {
console.error('[TradingView Shapes] Error removing shape from TV:', error)
}
}
/**
* Setup selection tracking
*/
function setupSelectionTracking() {
if (!isChartReady) return
try {
const chart = tvWidget.activeChart()
if (!chart) return
chart.onSelectionChanged().subscribe(null, () => {
const selectedObjects = chart.selection()
// Filter for shape/drawing objects only
const selectedShapeIds = selectedObjects
.filter((obj: any) => obj.type === 'study' || obj.name?.startsWith('LineTool'))
.map((obj: any) => obj.id)
console.log('[TradingView Shapes] Selection changed:', selectedShapeIds)
chartStore.chart_state.selected_shapes = selectedShapeIds
})
} catch (error) {
console.warn('[TradingView Shapes] Selection tracking not available:', error)
}
}
// Initialize
const cleanupShapeListeners = setupShapeListeners()
setupStoreWatchers()
setupSelectionTracking()
// Return cleanup function
return () => {
cleanupShapeListeners()
}
}

View File

@@ -6,6 +6,7 @@ export interface ChartState {
start_time: number | null
end_time: number | null
interval: string
selected_shapes: string[]
}
export const useChartStore = defineStore('ChartStore', () => {
@@ -13,7 +14,8 @@ export const useChartStore = defineStore('ChartStore', () => {
symbol: 'BINANCE:BTC/USDT',
start_time: null,
end_time: null,
interval: '15'
interval: '15',
selected_shapes: []
})
return { chart_state }

64
web/src/stores/shapes.ts Normal file
View File

@@ -0,0 +1,64 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface ControlPoint {
time: number
price: number
channel?: string
}
export interface Shape {
id: string
type: string
points: ControlPoint[]
color?: string
line_width?: number
line_style?: string
properties?: Record<string, any>
symbol?: string
created_at?: number
modified_at?: number
original_id?: string // Original ID from backend/agent before TradingView assigns its own ID
}
export const useShapeStore = defineStore('ShapeStore', () => {
const shapes = ref<Record<string, Shape>>({})
// Helper methods
const addShape = (shape: Shape) => {
shapes.value[shape.id] = shape
}
const updateShape = (id: string, updates: Partial<Shape>) => {
if (shapes.value[id]) {
const updated = {
...shapes.value[id],
...updates,
modified_at: Math.floor(Date.now() / 1000)
}
// Replace the entire shape object to ensure arrays are replaced atomically
shapes.value[id] = updated
}
}
const removeShape = (id: string) => {
delete shapes.value[id]
}
const getShape = (id: string): Shape | undefined => {
return shapes.value[id]
}
const getAllShapes = (): Shape[] => {
return Object.values(shapes.value)
}
return {
shapes,
addShape,
updateShape,
removeShape,
getShape,
getAllShapes
}
})