Implement chart formations MVP with Line and Channel support
Add formations feature for drawing trendlines and channels on charts: Backend (Formations.py): - CRUD operations with DataCache integration - Database table with scope-based indexing - Line value calculation with infinite extension - Property value lookup for strategy integration Frontend (formations.js, formation_overlay.js): - Three-class pattern: UIManager, DataManager, Controller - SVG overlay for drawing with RAF sync loop - Click-to-draw interface with temp line preview - Anchor dragging for line adjustment - Coordinate conversion using v5 API UI Integration: - HUD panel with Line/Channel buttons - Formation cards with hover details - Drawing controls (name input, save/cancel) Socket handlers for real-time sync: - request_formations, new_formation - edit_formation, delete_formation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ba7b6e79ff
commit
5717ac6a81
|
|
@ -11,6 +11,7 @@ from Configuration import Configuration
|
||||||
from ExchangeInterface import ExchangeInterface
|
from ExchangeInterface import ExchangeInterface
|
||||||
from indicators import Indicators
|
from indicators import Indicators
|
||||||
from Signals import Signals
|
from Signals import Signals
|
||||||
|
from Formations import Formations
|
||||||
from ExternalSources import ExternalSources
|
from ExternalSources import ExternalSources
|
||||||
from ExternalIndicators import ExternalIndicatorsManager
|
from ExternalIndicators import ExternalIndicatorsManager
|
||||||
from trade import Trades
|
from trade import Trades
|
||||||
|
|
@ -54,6 +55,9 @@ class BrighterTrades:
|
||||||
# Object that maintains signals.
|
# Object that maintains signals.
|
||||||
self.signals = Signals(self.data)
|
self.signals = Signals(self.data)
|
||||||
|
|
||||||
|
# Object that maintains chart formations (trendlines, channels, patterns).
|
||||||
|
self.formations = Formations(self.data)
|
||||||
|
|
||||||
# Object that maintains external data sources (custom signal types).
|
# Object that maintains external data sources (custom signal types).
|
||||||
self.external_sources = ExternalSources(self.data)
|
self.external_sources = ExternalSources(self.data)
|
||||||
|
|
||||||
|
|
@ -1888,6 +1892,42 @@ class BrighterTrades:
|
||||||
logger.error(f"Error getting public strategies: {e}", exc_info=True)
|
logger.error(f"Error getting public strategies: {e}", exc_info=True)
|
||||||
return standard_reply("public_strategies_error", {"message": str(e)})
|
return standard_reply("public_strategies_error", {"message": str(e)})
|
||||||
|
|
||||||
|
# ===== Formation Handlers =====
|
||||||
|
|
||||||
|
if msg_type == 'request_formations':
|
||||||
|
# Get formations for current chart scope
|
||||||
|
exchange = msg_data.get('exchange')
|
||||||
|
market = msg_data.get('market')
|
||||||
|
timeframe = msg_data.get('timeframe')
|
||||||
|
if not all([exchange, market, timeframe]):
|
||||||
|
return standard_reply("formation_error", {"message": "Missing scope parameters"})
|
||||||
|
formations = self.formations.get_for_scope(user_id, exchange, market, timeframe)
|
||||||
|
return standard_reply("formations", {"formations": formations})
|
||||||
|
|
||||||
|
if msg_type == 'new_formation':
|
||||||
|
result = self.formations.create(user_id, msg_data)
|
||||||
|
if result.get('success'):
|
||||||
|
return standard_reply("formation_created", result)
|
||||||
|
else:
|
||||||
|
return standard_reply("formation_error", result)
|
||||||
|
|
||||||
|
if msg_type == 'edit_formation':
|
||||||
|
result = self.formations.update(user_id, msg_data)
|
||||||
|
if result.get('success'):
|
||||||
|
return standard_reply("formation_updated", result)
|
||||||
|
else:
|
||||||
|
return standard_reply("formation_error", result)
|
||||||
|
|
||||||
|
if msg_type == 'delete_formation':
|
||||||
|
tbl_key = msg_data.get('tbl_key')
|
||||||
|
if not tbl_key:
|
||||||
|
return standard_reply("formation_error", {"message": "Missing tbl_key"})
|
||||||
|
result = self.formations.delete(user_id, tbl_key)
|
||||||
|
if result.get('success'):
|
||||||
|
return standard_reply("formation_deleted", result)
|
||||||
|
else:
|
||||||
|
return standard_reply("formation_error", result)
|
||||||
|
|
||||||
if msg_type == 'reply':
|
if msg_type == 'reply':
|
||||||
# If the message is a reply log the response to the terminal.
|
# If the message is a reply log the response to the terminal.
|
||||||
print(f"\napp.py:Received reply: {msg_data}")
|
print(f"\napp.py:Received reply: {msg_data}")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,490 @@
|
||||||
|
"""
|
||||||
|
Formations module for chart formation management.
|
||||||
|
|
||||||
|
Handles CRUD operations for chart formations (trendlines, channels, patterns)
|
||||||
|
with database-backed storage via DataCache.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from DataCache_v3 import DataCache
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Formation:
|
||||||
|
"""Class for individual formation properties."""
|
||||||
|
tbl_key: str
|
||||||
|
user_id: int
|
||||||
|
name: str
|
||||||
|
formation_type: str # 'support_resistance', 'channel', etc.
|
||||||
|
exchange: str
|
||||||
|
market: str
|
||||||
|
timeframe: str
|
||||||
|
lines_json: str # JSON string of line data
|
||||||
|
color: str = '#667eea'
|
||||||
|
visible: bool = True
|
||||||
|
created_at: int = 0
|
||||||
|
updated_at: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def lines(self) -> List[dict]:
|
||||||
|
"""Parse lines_json and return as list of line dicts."""
|
||||||
|
try:
|
||||||
|
data = json.loads(self.lines_json)
|
||||||
|
return data.get('lines', [])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def targets(self) -> List[dict]:
|
||||||
|
"""Parse lines_json and return targets (Phase C feature)."""
|
||||||
|
try:
|
||||||
|
data = json.loads(self.lines_json)
|
||||||
|
return data.get('targets', [])
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class Formations:
|
||||||
|
"""Manages chart formations with database-backed storage via DataCache."""
|
||||||
|
|
||||||
|
TABLE_NAME = 'formations'
|
||||||
|
|
||||||
|
def __init__(self, data_cache: DataCache):
|
||||||
|
"""
|
||||||
|
Initialize the Formations class.
|
||||||
|
|
||||||
|
:param data_cache: Instance of DataCache to manage cache and database interactions.
|
||||||
|
"""
|
||||||
|
self.data_cache = data_cache
|
||||||
|
|
||||||
|
# Ensure the formations table exists in the database
|
||||||
|
self._ensure_table_exists()
|
||||||
|
|
||||||
|
# Create a cache for formations
|
||||||
|
self.data_cache.create_cache(
|
||||||
|
name='formations',
|
||||||
|
cache_type='table',
|
||||||
|
size_limit=1000,
|
||||||
|
eviction_policy='deny',
|
||||||
|
columns=[
|
||||||
|
"tbl_key",
|
||||||
|
"user_id",
|
||||||
|
"name",
|
||||||
|
"formation_type",
|
||||||
|
"exchange",
|
||||||
|
"market",
|
||||||
|
"timeframe",
|
||||||
|
"lines_json",
|
||||||
|
"color",
|
||||||
|
"visible",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# In-memory cache of Formation objects
|
||||||
|
self.formations: Dict[str, Formation] = {}
|
||||||
|
|
||||||
|
# Load existing formations from database
|
||||||
|
self._load_formations_from_db()
|
||||||
|
|
||||||
|
def _ensure_table_exists(self) -> None:
|
||||||
|
"""Create the formations table in the database if it doesn't exist."""
|
||||||
|
try:
|
||||||
|
if not self.data_cache.db.table_exists(self.TABLE_NAME):
|
||||||
|
create_sql = """
|
||||||
|
CREATE TABLE IF NOT EXISTS formations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tbl_key TEXT UNIQUE NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
formation_type TEXT NOT NULL,
|
||||||
|
exchange TEXT NOT NULL,
|
||||||
|
market TEXT NOT NULL,
|
||||||
|
timeframe TEXT NOT NULL,
|
||||||
|
lines_json TEXT NOT NULL,
|
||||||
|
color TEXT DEFAULT '#667eea',
|
||||||
|
visible INTEGER DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(user_id, name, exchange, market, timeframe)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
self.data_cache.db.execute_sql(create_sql, params=[])
|
||||||
|
|
||||||
|
# Create index for scope queries
|
||||||
|
index_sql = """
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_formations_scope
|
||||||
|
ON formations(user_id, exchange, market, timeframe)
|
||||||
|
"""
|
||||||
|
self.data_cache.db.execute_sql(index_sql, params=[])
|
||||||
|
|
||||||
|
logger.info("Created formations table in database")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error ensuring formations table exists: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def _load_formations_from_db(self) -> None:
|
||||||
|
"""Load all formations from database into memory."""
|
||||||
|
try:
|
||||||
|
formations_df = self.data_cache.get_all_rows_from_datacache(cache_name='formations')
|
||||||
|
if formations_df is not None and not formations_df.empty:
|
||||||
|
for _, row in formations_df.iterrows():
|
||||||
|
formation = Formation(
|
||||||
|
tbl_key=row.get('tbl_key', ''),
|
||||||
|
user_id=int(row.get('user_id', 0)),
|
||||||
|
name=row.get('name', ''),
|
||||||
|
formation_type=row.get('formation_type', ''),
|
||||||
|
exchange=row.get('exchange', ''),
|
||||||
|
market=row.get('market', ''),
|
||||||
|
timeframe=row.get('timeframe', ''),
|
||||||
|
lines_json=row.get('lines_json', '{}'),
|
||||||
|
color=row.get('color', '#667eea'),
|
||||||
|
visible=bool(row.get('visible', True)),
|
||||||
|
created_at=int(row.get('created_at', 0)),
|
||||||
|
updated_at=int(row.get('updated_at', 0))
|
||||||
|
)
|
||||||
|
self.formations[formation.tbl_key] = formation
|
||||||
|
logger.info(f"Loaded {len(self.formations)} formations from database")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading formations from database: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def create(self, user_id: int, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Create a new formation.
|
||||||
|
|
||||||
|
:param user_id: ID of the user creating the formation
|
||||||
|
:param data: Dictionary containing formation data
|
||||||
|
:return: Dictionary with success status and formation data or error message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Validate required fields
|
||||||
|
required_fields = ['name', 'formation_type', 'exchange', 'market', 'timeframe', 'lines_json']
|
||||||
|
for field in required_fields:
|
||||||
|
if field not in data or not data[field]:
|
||||||
|
return {"success": False, "message": f"Missing required field: {field}"}
|
||||||
|
|
||||||
|
# Check for duplicate name in same scope
|
||||||
|
name = data['name']
|
||||||
|
exchange = data['exchange']
|
||||||
|
market = data['market']
|
||||||
|
timeframe = data['timeframe']
|
||||||
|
|
||||||
|
for formation in self.formations.values():
|
||||||
|
if (formation.user_id == user_id and
|
||||||
|
formation.name == name and
|
||||||
|
formation.exchange == exchange and
|
||||||
|
formation.market == market and
|
||||||
|
formation.timeframe == timeframe):
|
||||||
|
return {"success": False, "message": "A formation with this name already exists in this scope"}
|
||||||
|
|
||||||
|
# Generate unique key and timestamps
|
||||||
|
tbl_key = str(uuid.uuid4())
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
# Prepare formation data
|
||||||
|
columns = (
|
||||||
|
"tbl_key", "user_id", "name", "formation_type", "exchange",
|
||||||
|
"market", "timeframe", "lines_json", "color", "visible",
|
||||||
|
"created_at", "updated_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
values = (
|
||||||
|
tbl_key,
|
||||||
|
user_id,
|
||||||
|
name,
|
||||||
|
data['formation_type'],
|
||||||
|
exchange,
|
||||||
|
market,
|
||||||
|
timeframe,
|
||||||
|
data['lines_json'] if isinstance(data['lines_json'], str) else json.dumps(data['lines_json']),
|
||||||
|
data.get('color', '#667eea'),
|
||||||
|
1, # visible
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
|
||||||
|
# Insert into database via DataCache
|
||||||
|
self.data_cache.add_row_to_datacache(
|
||||||
|
cache_name='formations',
|
||||||
|
row_data=dict(zip(columns, values)),
|
||||||
|
tbl_key=tbl_key,
|
||||||
|
persist=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create Formation object and add to memory cache
|
||||||
|
formation = Formation(
|
||||||
|
tbl_key=tbl_key,
|
||||||
|
user_id=user_id,
|
||||||
|
name=name,
|
||||||
|
formation_type=data['formation_type'],
|
||||||
|
exchange=exchange,
|
||||||
|
market=market,
|
||||||
|
timeframe=timeframe,
|
||||||
|
lines_json=data['lines_json'] if isinstance(data['lines_json'], str) else json.dumps(data['lines_json']),
|
||||||
|
color=data.get('color', '#667eea'),
|
||||||
|
visible=True,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now
|
||||||
|
)
|
||||||
|
self.formations[tbl_key] = formation
|
||||||
|
|
||||||
|
logger.info(f"Created formation '{name}' with tbl_key {tbl_key}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Formation created successfully",
|
||||||
|
"formation": self._formation_to_dict(formation)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating formation: {e}", exc_info=True)
|
||||||
|
return {"success": False, "message": f"Error creating formation: {str(e)}"}
|
||||||
|
|
||||||
|
def update(self, user_id: int, data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Update an existing formation.
|
||||||
|
|
||||||
|
:param user_id: ID of the user updating the formation
|
||||||
|
:param data: Dictionary containing formation data with tbl_key
|
||||||
|
:return: Dictionary with success status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
tbl_key = data.get('tbl_key')
|
||||||
|
if not tbl_key:
|
||||||
|
return {"success": False, "message": "Missing tbl_key"}
|
||||||
|
|
||||||
|
# Find existing formation
|
||||||
|
formation = self.formations.get(tbl_key)
|
||||||
|
if not formation:
|
||||||
|
return {"success": False, "message": "Formation not found"}
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
if formation.user_id != user_id:
|
||||||
|
return {"success": False, "message": "Not authorized to edit this formation"}
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
now = int(time.time())
|
||||||
|
update_data = {
|
||||||
|
'updated_at': now
|
||||||
|
}
|
||||||
|
|
||||||
|
if 'name' in data:
|
||||||
|
formation.name = data['name']
|
||||||
|
update_data['name'] = data['name']
|
||||||
|
|
||||||
|
if 'lines_json' in data:
|
||||||
|
lines_json = data['lines_json'] if isinstance(data['lines_json'], str) else json.dumps(data['lines_json'])
|
||||||
|
formation.lines_json = lines_json
|
||||||
|
update_data['lines_json'] = lines_json
|
||||||
|
|
||||||
|
if 'color' in data:
|
||||||
|
formation.color = data['color']
|
||||||
|
update_data['color'] = data['color']
|
||||||
|
|
||||||
|
if 'visible' in data:
|
||||||
|
formation.visible = bool(data['visible'])
|
||||||
|
update_data['visible'] = int(data['visible'])
|
||||||
|
|
||||||
|
formation.updated_at = now
|
||||||
|
|
||||||
|
# Update in database
|
||||||
|
self.data_cache.update_row_in_datacache(
|
||||||
|
cache_name='formations',
|
||||||
|
tbl_key=tbl_key,
|
||||||
|
updates=update_data,
|
||||||
|
persist=True
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Updated formation '{formation.name}' (tbl_key: {tbl_key})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Formation updated successfully",
|
||||||
|
"formation": self._formation_to_dict(formation)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating formation: {e}", exc_info=True)
|
||||||
|
return {"success": False, "message": f"Error updating formation: {str(e)}"}
|
||||||
|
|
||||||
|
def delete(self, user_id: int, tbl_key: str) -> dict:
|
||||||
|
"""
|
||||||
|
Delete a formation.
|
||||||
|
|
||||||
|
:param user_id: ID of the user deleting the formation
|
||||||
|
:param tbl_key: Unique key of the formation to delete
|
||||||
|
:return: Dictionary with success status
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Find existing formation
|
||||||
|
formation = self.formations.get(tbl_key)
|
||||||
|
if not formation:
|
||||||
|
return {"success": False, "message": "Formation not found"}
|
||||||
|
|
||||||
|
# Verify ownership
|
||||||
|
if formation.user_id != user_id:
|
||||||
|
return {"success": False, "message": "Not authorized to delete this formation"}
|
||||||
|
|
||||||
|
# Remove from database
|
||||||
|
self.data_cache.delete_row_from_datacache(
|
||||||
|
cache_name='formations',
|
||||||
|
tbl_key=tbl_key,
|
||||||
|
persist=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove from memory cache
|
||||||
|
del self.formations[tbl_key]
|
||||||
|
|
||||||
|
logger.info(f"Deleted formation '{formation.name}' (tbl_key: {tbl_key})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Formation deleted successfully",
|
||||||
|
"tbl_key": tbl_key
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting formation: {e}", exc_info=True)
|
||||||
|
return {"success": False, "message": f"Error deleting formation: {str(e)}"}
|
||||||
|
|
||||||
|
def get_for_scope(self, user_id: int, exchange: str, market: str, timeframe: str) -> List[dict]:
|
||||||
|
"""
|
||||||
|
Get all formations for a specific scope (exchange/market/timeframe).
|
||||||
|
|
||||||
|
:param user_id: ID of the user
|
||||||
|
:param exchange: Exchange name
|
||||||
|
:param market: Market/trading pair
|
||||||
|
:param timeframe: Timeframe
|
||||||
|
:return: List of formation dictionaries
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for formation in self.formations.values():
|
||||||
|
if (formation.user_id == user_id and
|
||||||
|
formation.exchange == exchange and
|
||||||
|
formation.market == market and
|
||||||
|
formation.timeframe == timeframe):
|
||||||
|
result.append(self._formation_to_dict(formation))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_by_tbl_key(self, user_id: int, tbl_key: str) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get a formation by its tbl_key.
|
||||||
|
|
||||||
|
:param user_id: ID of the user (for ownership verification)
|
||||||
|
:param tbl_key: Unique key of the formation
|
||||||
|
:return: Formation dictionary or None
|
||||||
|
"""
|
||||||
|
formation = self.formations.get(tbl_key)
|
||||||
|
if formation and formation.user_id == user_id:
|
||||||
|
return self._formation_to_dict(formation)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_by_tbl_key_for_strategy(self, tbl_key: str, owner_user_id: int) -> Optional[dict]:
|
||||||
|
"""
|
||||||
|
Get a formation by tbl_key for strategy execution.
|
||||||
|
|
||||||
|
This uses the strategy owner's formations, not the current user's.
|
||||||
|
Parallel to indicator_owner_id pattern.
|
||||||
|
|
||||||
|
:param tbl_key: Unique key of the formation
|
||||||
|
:param owner_user_id: User ID of the strategy owner
|
||||||
|
:return: Formation dictionary or None
|
||||||
|
"""
|
||||||
|
formation = self.formations.get(tbl_key)
|
||||||
|
if formation and formation.user_id == owner_user_id:
|
||||||
|
return self._formation_to_dict(formation)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def calculate_line_value(self, line: dict, timestamp: int) -> float:
|
||||||
|
"""
|
||||||
|
Calculate price at timestamp using linear interpolation/extrapolation.
|
||||||
|
|
||||||
|
This implements infinite line extension - works for any timestamp,
|
||||||
|
past or future, by extrapolating the line defined by two points.
|
||||||
|
|
||||||
|
:param line: Dict with point1 and point2, each having 'time' and 'price'
|
||||||
|
:param timestamp: Unix timestamp in seconds UTC
|
||||||
|
:return: Extrapolated price value
|
||||||
|
"""
|
||||||
|
t1 = line['point1']['time']
|
||||||
|
p1 = line['point1']['price']
|
||||||
|
t2 = line['point2']['time']
|
||||||
|
p2 = line['point2']['price']
|
||||||
|
|
||||||
|
# Handle vertical line (same timestamp)
|
||||||
|
if t1 == t2:
|
||||||
|
logger.warning(f"Vertical line detected (t1==t2={t1}), returning average price")
|
||||||
|
return (p1 + p2) / 2
|
||||||
|
|
||||||
|
# Calculate slope and extrapolate
|
||||||
|
slope = (p2 - p1) / (t2 - t1)
|
||||||
|
return p1 + slope * (timestamp - t1)
|
||||||
|
|
||||||
|
def get_property_value(self, formation: dict, property_name: str, timestamp: int) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Get the value of a formation property at a given timestamp.
|
||||||
|
|
||||||
|
:param formation: Formation dictionary
|
||||||
|
:param property_name: Name of the property ('line', 'upper', 'lower', 'midline', etc.)
|
||||||
|
:param timestamp: Unix timestamp in seconds UTC
|
||||||
|
:return: Price value or None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
lines_data = json.loads(formation.get('lines_json', '{}'))
|
||||||
|
lines = lines_data.get('lines', [])
|
||||||
|
|
||||||
|
if not lines:
|
||||||
|
return None
|
||||||
|
|
||||||
|
formation_type = formation.get('formation_type', '')
|
||||||
|
|
||||||
|
# Support/Resistance - single line
|
||||||
|
if formation_type == 'support_resistance':
|
||||||
|
if property_name == 'line' and len(lines) > 0:
|
||||||
|
return self.calculate_line_value(lines[0], timestamp)
|
||||||
|
|
||||||
|
# Channel - two parallel lines
|
||||||
|
elif formation_type == 'channel':
|
||||||
|
if property_name == 'upper' and len(lines) > 0:
|
||||||
|
return self.calculate_line_value(lines[0], timestamp)
|
||||||
|
elif property_name == 'lower' and len(lines) > 1:
|
||||||
|
return self.calculate_line_value(lines[1], timestamp)
|
||||||
|
elif property_name == 'midline' and len(lines) >= 2:
|
||||||
|
upper = self.calculate_line_value(lines[0], timestamp)
|
||||||
|
lower = self.calculate_line_value(lines[1], timestamp)
|
||||||
|
return (upper + lower) / 2
|
||||||
|
|
||||||
|
# Default: return first line value
|
||||||
|
if len(lines) > 0:
|
||||||
|
return self.calculate_line_value(lines[0], timestamp)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting property value: {e}", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _formation_to_dict(self, formation: Formation) -> dict:
|
||||||
|
"""Convert Formation object to dictionary."""
|
||||||
|
return {
|
||||||
|
'tbl_key': formation.tbl_key,
|
||||||
|
'user_id': formation.user_id,
|
||||||
|
'name': formation.name,
|
||||||
|
'formation_type': formation.formation_type,
|
||||||
|
'exchange': formation.exchange,
|
||||||
|
'market': formation.market,
|
||||||
|
'timeframe': formation.timeframe,
|
||||||
|
'lines_json': formation.lines_json,
|
||||||
|
'color': formation.color,
|
||||||
|
'visible': formation.visible,
|
||||||
|
'created_at': formation.created_at,
|
||||||
|
'updated_at': formation.updated_at
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,870 @@
|
||||||
|
/**
|
||||||
|
* FormationOverlay - SVG-based overlay for drawing and rendering chart formations
|
||||||
|
*
|
||||||
|
* Uses SVG layer positioned over the chart container.
|
||||||
|
* Syncs with chart via requestAnimationFrame polling (not event subscriptions).
|
||||||
|
*/
|
||||||
|
class FormationOverlay {
|
||||||
|
constructor(chartContainerId, chart, candleSeries) {
|
||||||
|
this.chartContainerId = chartContainerId;
|
||||||
|
this.chart = chart;
|
||||||
|
this.candleSeries = candleSeries;
|
||||||
|
|
||||||
|
// SVG layer reference
|
||||||
|
this.svg = null;
|
||||||
|
this.container = null;
|
||||||
|
|
||||||
|
// Drawing state
|
||||||
|
this.drawingMode = null; // 'support_resistance', 'channel'
|
||||||
|
this.currentPoints = [];
|
||||||
|
this.tempLine = null;
|
||||||
|
|
||||||
|
// Rendered formations: Map<tbl_key, {formation, elements: SVGElement[]}>
|
||||||
|
this.renderedFormations = new Map();
|
||||||
|
|
||||||
|
// Selected formation for editing
|
||||||
|
this.selectedTblKey = null;
|
||||||
|
|
||||||
|
// Dragging state
|
||||||
|
this.isDragging = false;
|
||||||
|
this.dragAnchor = null;
|
||||||
|
this.dragFormation = null;
|
||||||
|
|
||||||
|
// RAF loop state
|
||||||
|
this._loopRunning = false;
|
||||||
|
this._animationFrameId = null;
|
||||||
|
this._lastTimeRange = null;
|
||||||
|
this._lastPriceRange = null;
|
||||||
|
|
||||||
|
// Callback for saving formations
|
||||||
|
this.onSaveCallback = null;
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
this.defaultColor = '#667eea';
|
||||||
|
this.selectedColor = '#ff9500';
|
||||||
|
this.anchorColor = '#ffffff';
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
this._createSVGLayer();
|
||||||
|
this._startSyncLoop();
|
||||||
|
this._attachEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for when a formation is ready to save.
|
||||||
|
* @param {Function} callback - Called with formation data
|
||||||
|
*/
|
||||||
|
setOnSaveCallback(callback) {
|
||||||
|
this.onSaveCallback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the SVG overlay layer.
|
||||||
|
*/
|
||||||
|
_createSVGLayer() {
|
||||||
|
this.container = document.getElementById(this.chartContainerId);
|
||||||
|
if (!this.container) {
|
||||||
|
console.error(`FormationOverlay: Container "${this.chartContainerId}" not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure container has relative positioning
|
||||||
|
const containerStyle = window.getComputedStyle(this.container);
|
||||||
|
if (containerStyle.position === 'static') {
|
||||||
|
this.container.style.position = 'relative';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create SVG element
|
||||||
|
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
|
this.svg.setAttribute('class', 'formation-overlay');
|
||||||
|
this.svg.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create groups for layering
|
||||||
|
this.linesGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
this.linesGroup.setAttribute('class', 'formation-lines');
|
||||||
|
|
||||||
|
this.anchorsGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
this.anchorsGroup.setAttribute('class', 'formation-anchors');
|
||||||
|
this.anchorsGroup.style.pointerEvents = 'all';
|
||||||
|
|
||||||
|
this.tempGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
||||||
|
this.tempGroup.setAttribute('class', 'formation-temp');
|
||||||
|
|
||||||
|
this.svg.appendChild(this.linesGroup);
|
||||||
|
this.svg.appendChild(this.anchorsGroup);
|
||||||
|
this.svg.appendChild(this.tempGroup);
|
||||||
|
|
||||||
|
this.container.appendChild(this.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the requestAnimationFrame sync loop.
|
||||||
|
* Uses guards to prevent multiple loops.
|
||||||
|
*/
|
||||||
|
_startSyncLoop() {
|
||||||
|
if (this._loopRunning) return;
|
||||||
|
this._loopRunning = true;
|
||||||
|
|
||||||
|
const sync = () => {
|
||||||
|
if (!this._loopRunning) return;
|
||||||
|
|
||||||
|
// Check if chart view has changed
|
||||||
|
if (this._hasViewChanged()) {
|
||||||
|
this._updateAllFormations();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._animationFrameId = requestAnimationFrame(sync);
|
||||||
|
};
|
||||||
|
|
||||||
|
this._animationFrameId = requestAnimationFrame(sync);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the chart view (time/price range) has changed.
|
||||||
|
* Uses time scale visible range - in v5, price scale methods differ.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_hasViewChanged() {
|
||||||
|
if (!this.chart) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timeScale = this.chart.timeScale();
|
||||||
|
const timeRange = timeScale.getVisibleLogicalRange();
|
||||||
|
// In v5, use barsInLogicalRange for visible bars info
|
||||||
|
const visibleBars = this.candleSeries ? this.candleSeries.barsInLogicalRange(timeRange) : null;
|
||||||
|
|
||||||
|
const timeChanged = JSON.stringify(timeRange) !== JSON.stringify(this._lastTimeRange);
|
||||||
|
const barsChanged = JSON.stringify(visibleBars) !== JSON.stringify(this._lastPriceRange);
|
||||||
|
|
||||||
|
if (timeChanged || barsChanged) {
|
||||||
|
this._lastTimeRange = timeRange;
|
||||||
|
this._lastPriceRange = visibleBars;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: always update if there's an error
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach mouse event listeners for drawing and dragging.
|
||||||
|
*/
|
||||||
|
_attachEventListeners() {
|
||||||
|
if (!this.container) return;
|
||||||
|
|
||||||
|
// Click handler for drawing
|
||||||
|
this.container.addEventListener('click', (e) => {
|
||||||
|
if (this.drawingMode) {
|
||||||
|
const coords = this._pixelToChart(e.offsetX, e.offsetY);
|
||||||
|
if (coords) {
|
||||||
|
this._handleDrawingClick(coords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse move for temp line preview
|
||||||
|
this.container.addEventListener('mousemove', (e) => {
|
||||||
|
if (this.drawingMode && this.currentPoints.length > 0) {
|
||||||
|
const coords = this._pixelToChart(e.offsetX, e.offsetY);
|
||||||
|
if (coords) {
|
||||||
|
this._updateTempLine(coords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle dragging
|
||||||
|
if (this.isDragging && this.dragAnchor && this.dragFormation) {
|
||||||
|
const coords = this._pixelToChart(e.offsetX, e.offsetY);
|
||||||
|
if (coords) {
|
||||||
|
this._handleDrag(coords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse up to end dragging
|
||||||
|
this.container.addEventListener('mouseup', () => {
|
||||||
|
if (this.isDragging) {
|
||||||
|
this._endDrag();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse leave to cancel dragging
|
||||||
|
this.container.addEventListener('mouseleave', () => {
|
||||||
|
if (this.isDragging) {
|
||||||
|
this._endDrag();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape key to cancel drawing
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && this.drawingMode) {
|
||||||
|
this.cancelDrawing();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert pixel coordinates to chart time/price.
|
||||||
|
* @param {number} x - Pixel X
|
||||||
|
* @param {number} y - Pixel Y
|
||||||
|
* @returns {{time: number, price: number}|null}
|
||||||
|
*/
|
||||||
|
_pixelToChart(x, y) {
|
||||||
|
if (!this.chart || !this.candleSeries) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timeScale = this.chart.timeScale();
|
||||||
|
const time = timeScale.coordinateToTime(x);
|
||||||
|
const price = this.candleSeries.coordinateToPrice(y);
|
||||||
|
|
||||||
|
if (time === null || price === null) return null;
|
||||||
|
|
||||||
|
return { time: Math.floor(time), price };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('FormationOverlay: pixel to chart conversion failed', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert chart time/price to pixel coordinates.
|
||||||
|
* @param {number} time - Unix timestamp
|
||||||
|
* @param {number} price - Price value
|
||||||
|
* @returns {{x: number, y: number}|null}
|
||||||
|
*/
|
||||||
|
_chartToPixel(time, price) {
|
||||||
|
if (!this.chart || !this.candleSeries) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const timeScale = this.chart.timeScale();
|
||||||
|
const x = timeScale.timeToCoordinate(time);
|
||||||
|
const y = this.candleSeries.priceToCoordinate(price);
|
||||||
|
|
||||||
|
if (x === null || y === null) return null;
|
||||||
|
|
||||||
|
return { x, y };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('FormationOverlay: chart to pixel conversion failed', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate infinite line endpoints that extend to viewport edges.
|
||||||
|
* @param {Object} point1 - {time, price}
|
||||||
|
* @param {Object} point2 - {time, price}
|
||||||
|
* @returns {{start: {x, y}, end: {x, y}}|null}
|
||||||
|
*/
|
||||||
|
_getInfiniteLineEndpoints(point1, point2) {
|
||||||
|
if (!this.svg) return null;
|
||||||
|
|
||||||
|
const width = this.svg.clientWidth || this.container.clientWidth;
|
||||||
|
const height = this.svg.clientHeight || this.container.clientHeight;
|
||||||
|
|
||||||
|
// Convert anchor points to pixels
|
||||||
|
const p1 = this._chartToPixel(point1.time, point1.price);
|
||||||
|
const p2 = this._chartToPixel(point2.time, point2.price);
|
||||||
|
|
||||||
|
if (!p1 || !p2) return null;
|
||||||
|
|
||||||
|
// Calculate line parameters
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
|
||||||
|
// Handle vertical line
|
||||||
|
if (Math.abs(dx) < 0.001) {
|
||||||
|
return {
|
||||||
|
start: { x: p1.x, y: 0 },
|
||||||
|
end: { x: p1.x, y: height }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle horizontal line
|
||||||
|
if (Math.abs(dy) < 0.001) {
|
||||||
|
return {
|
||||||
|
start: { x: 0, y: p1.y },
|
||||||
|
end: { x: width, y: p1.y }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate slope
|
||||||
|
const slope = dy / dx;
|
||||||
|
|
||||||
|
// Calculate y-intercept (y = mx + b => b = y - mx)
|
||||||
|
const b = p1.y - slope * p1.x;
|
||||||
|
|
||||||
|
// Find intersections with viewport edges
|
||||||
|
const intersections = [];
|
||||||
|
|
||||||
|
// Left edge (x = 0)
|
||||||
|
const yLeft = b;
|
||||||
|
if (yLeft >= 0 && yLeft <= height) {
|
||||||
|
intersections.push({ x: 0, y: yLeft });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right edge (x = width)
|
||||||
|
const yRight = slope * width + b;
|
||||||
|
if (yRight >= 0 && yRight <= height) {
|
||||||
|
intersections.push({ x: width, y: yRight });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top edge (y = 0)
|
||||||
|
const xTop = -b / slope;
|
||||||
|
if (xTop >= 0 && xTop <= width) {
|
||||||
|
intersections.push({ x: xTop, y: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom edge (y = height)
|
||||||
|
const xBottom = (height - b) / slope;
|
||||||
|
if (xBottom >= 0 && xBottom <= width) {
|
||||||
|
intersections.push({ x: xBottom, y: height });
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need exactly 2 intersections
|
||||||
|
if (intersections.length < 2) {
|
||||||
|
// Fallback: just use the anchor points
|
||||||
|
return { start: p1, end: p2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by x to get consistent start/end
|
||||||
|
intersections.sort((a, b) => a.x - b.x);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: intersections[0],
|
||||||
|
end: intersections[intersections.length - 1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ Drawing Methods ================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start drawing a formation.
|
||||||
|
* @param {string} type - Formation type ('support_resistance', 'channel')
|
||||||
|
*/
|
||||||
|
startDrawing(type) {
|
||||||
|
this.drawingMode = type;
|
||||||
|
this.currentPoints = [];
|
||||||
|
this._clearTempElements();
|
||||||
|
|
||||||
|
// Change cursor
|
||||||
|
if (this.container) {
|
||||||
|
this.container.style.cursor = 'crosshair';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable pointer events on SVG for clicks
|
||||||
|
if (this.svg) {
|
||||||
|
this.svg.style.pointerEvents = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('FormationOverlay: Started drawing', type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle click during drawing mode.
|
||||||
|
* @param {Object} coords - {time, price}
|
||||||
|
*/
|
||||||
|
_handleDrawingClick(coords) {
|
||||||
|
this.currentPoints.push(coords);
|
||||||
|
|
||||||
|
// Draw anchor at click point
|
||||||
|
this._drawTempAnchor(coords);
|
||||||
|
|
||||||
|
// Check if drawing is complete based on formation type
|
||||||
|
const pointsNeeded = this._getPointsNeeded(this.drawingMode);
|
||||||
|
|
||||||
|
if (this.currentPoints.length >= pointsNeeded) {
|
||||||
|
// Drawing complete, wait for name input
|
||||||
|
console.log('FormationOverlay: Points collected', this.currentPoints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of points needed for a formation type.
|
||||||
|
* @param {string} type - Formation type
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
_getPointsNeeded(type) {
|
||||||
|
const pointsMap = {
|
||||||
|
'support_resistance': 2,
|
||||||
|
'channel': 3,
|
||||||
|
'triangle': 3,
|
||||||
|
'head_shoulders': 5,
|
||||||
|
'double_bottom': 3,
|
||||||
|
'double_top': 3
|
||||||
|
};
|
||||||
|
return pointsMap[type] || 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw a temporary anchor circle.
|
||||||
|
* @param {Object} coords - {time, price}
|
||||||
|
*/
|
||||||
|
_drawTempAnchor(coords) {
|
||||||
|
const pixel = this._chartToPixel(coords.time, coords.price);
|
||||||
|
if (!pixel) return;
|
||||||
|
|
||||||
|
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
|
circle.setAttribute('cx', pixel.x);
|
||||||
|
circle.setAttribute('cy', pixel.y);
|
||||||
|
circle.setAttribute('r', 6);
|
||||||
|
circle.setAttribute('fill', this.defaultColor);
|
||||||
|
circle.setAttribute('stroke', this.anchorColor);
|
||||||
|
circle.setAttribute('stroke-width', 2);
|
||||||
|
|
||||||
|
this.tempGroup.appendChild(circle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the temporary preview line.
|
||||||
|
* @param {Object} coords - Current mouse position {time, price}
|
||||||
|
*/
|
||||||
|
_updateTempLine(coords) {
|
||||||
|
// Remove existing temp line
|
||||||
|
if (this.tempLine) {
|
||||||
|
this.tempLine.remove();
|
||||||
|
this.tempLine = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentPoints.length === 0) return;
|
||||||
|
|
||||||
|
const lastPoint = this.currentPoints[this.currentPoints.length - 1];
|
||||||
|
const endpoints = this._getInfiniteLineEndpoints(lastPoint, coords);
|
||||||
|
|
||||||
|
if (!endpoints) return;
|
||||||
|
|
||||||
|
this.tempLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
|
this.tempLine.setAttribute('x1', endpoints.start.x);
|
||||||
|
this.tempLine.setAttribute('y1', endpoints.start.y);
|
||||||
|
this.tempLine.setAttribute('x2', endpoints.end.x);
|
||||||
|
this.tempLine.setAttribute('y2', endpoints.end.y);
|
||||||
|
this.tempLine.setAttribute('stroke', this.defaultColor);
|
||||||
|
this.tempLine.setAttribute('stroke-width', 2);
|
||||||
|
this.tempLine.setAttribute('stroke-dasharray', '5,5');
|
||||||
|
this.tempLine.style.opacity = '0.7';
|
||||||
|
|
||||||
|
this.tempGroup.appendChild(this.tempLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear temporary drawing elements.
|
||||||
|
*/
|
||||||
|
_clearTempElements() {
|
||||||
|
while (this.tempGroup.firstChild) {
|
||||||
|
this.tempGroup.removeChild(this.tempGroup.firstChild);
|
||||||
|
}
|
||||||
|
this.tempLine = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete the current drawing and save.
|
||||||
|
* @param {string} name - Formation name
|
||||||
|
*/
|
||||||
|
completeDrawing(name) {
|
||||||
|
if (!this.drawingMode || this.currentPoints.length < 2) {
|
||||||
|
console.warn('FormationOverlay: Not enough points to complete drawing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build lines data based on formation type
|
||||||
|
const lines = this._buildLinesFromPoints(this.drawingMode, this.currentPoints);
|
||||||
|
|
||||||
|
const formationData = {
|
||||||
|
name: name,
|
||||||
|
formation_type: this.drawingMode,
|
||||||
|
lines_json: JSON.stringify({ lines: lines }),
|
||||||
|
color: this.defaultColor
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call save callback
|
||||||
|
if (this.onSaveCallback) {
|
||||||
|
this.onSaveCallback(formationData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset drawing state
|
||||||
|
this._exitDrawingMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build lines array from clicked points.
|
||||||
|
* @param {string} type - Formation type
|
||||||
|
* @param {Array} points - Array of {time, price}
|
||||||
|
* @returns {Array} Lines array
|
||||||
|
*/
|
||||||
|
_buildLinesFromPoints(type, points) {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
if (type === 'support_resistance' && points.length >= 2) {
|
||||||
|
// Single line from two points
|
||||||
|
lines.push({
|
||||||
|
point1: { time: points[0].time, price: points[0].price },
|
||||||
|
point2: { time: points[1].time, price: points[1].price }
|
||||||
|
});
|
||||||
|
} else if (type === 'channel' && points.length >= 3) {
|
||||||
|
// First line from points 0-1
|
||||||
|
lines.push({
|
||||||
|
point1: { time: points[0].time, price: points[0].price },
|
||||||
|
point2: { time: points[1].time, price: points[1].price }
|
||||||
|
});
|
||||||
|
// Second parallel line: same time span, offset by third point's price difference
|
||||||
|
const priceOffset = points[2].price - points[0].price;
|
||||||
|
lines.push({
|
||||||
|
point1: { time: points[0].time, price: points[0].price + priceOffset },
|
||||||
|
point2: { time: points[1].time, price: points[1].price + priceOffset }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the current drawing.
|
||||||
|
*/
|
||||||
|
cancelDrawing() {
|
||||||
|
this._exitDrawingMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exit drawing mode and reset state.
|
||||||
|
*/
|
||||||
|
_exitDrawingMode() {
|
||||||
|
this.drawingMode = null;
|
||||||
|
this.currentPoints = [];
|
||||||
|
this._clearTempElements();
|
||||||
|
|
||||||
|
// Reset cursor
|
||||||
|
if (this.container) {
|
||||||
|
this.container.style.cursor = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disable pointer events on SVG
|
||||||
|
if (this.svg) {
|
||||||
|
this.svg.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ Rendering Methods ================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a formation on the chart.
|
||||||
|
* @param {Object} formation - Formation object with tbl_key, lines_json, color, etc.
|
||||||
|
*/
|
||||||
|
renderFormation(formation) {
|
||||||
|
if (!formation || !formation.tbl_key) return;
|
||||||
|
|
||||||
|
// Remove existing if updating
|
||||||
|
this.removeFormation(formation.tbl_key);
|
||||||
|
|
||||||
|
const linesData = JSON.parse(formation.lines_json || '{}');
|
||||||
|
const lines = linesData.lines || [];
|
||||||
|
const color = formation.color || this.defaultColor;
|
||||||
|
const elements = [];
|
||||||
|
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
// Draw the infinite line
|
||||||
|
const lineEl = this._createLine(line.point1, line.point2, color, formation.tbl_key);
|
||||||
|
if (lineEl) {
|
||||||
|
this.linesGroup.appendChild(lineEl);
|
||||||
|
elements.push(lineEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw anchor points
|
||||||
|
const anchor1 = this._createAnchor(line.point1, formation.tbl_key, index, 'point1');
|
||||||
|
const anchor2 = this._createAnchor(line.point2, formation.tbl_key, index, 'point2');
|
||||||
|
|
||||||
|
if (anchor1) {
|
||||||
|
this.anchorsGroup.appendChild(anchor1);
|
||||||
|
elements.push(anchor1);
|
||||||
|
}
|
||||||
|
if (anchor2) {
|
||||||
|
this.anchorsGroup.appendChild(anchor2);
|
||||||
|
elements.push(anchor2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderedFormations.set(formation.tbl_key, {
|
||||||
|
formation: formation,
|
||||||
|
elements: elements
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an SVG line element.
|
||||||
|
* @param {Object} point1 - {time, price}
|
||||||
|
* @param {Object} point2 - {time, price}
|
||||||
|
* @param {string} color - Line color
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
* @returns {SVGLineElement|null}
|
||||||
|
*/
|
||||||
|
_createLine(point1, point2, color, tblKey) {
|
||||||
|
const endpoints = this._getInfiniteLineEndpoints(point1, point2);
|
||||||
|
if (!endpoints) return null;
|
||||||
|
|
||||||
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
||||||
|
line.setAttribute('x1', endpoints.start.x);
|
||||||
|
line.setAttribute('y1', endpoints.start.y);
|
||||||
|
line.setAttribute('x2', endpoints.end.x);
|
||||||
|
line.setAttribute('y2', endpoints.end.y);
|
||||||
|
line.setAttribute('stroke', color);
|
||||||
|
line.setAttribute('stroke-width', 2);
|
||||||
|
line.setAttribute('data-tbl-key', tblKey);
|
||||||
|
line.setAttribute('data-point1-time', point1.time);
|
||||||
|
line.setAttribute('data-point1-price', point1.price);
|
||||||
|
line.setAttribute('data-point2-time', point2.time);
|
||||||
|
line.setAttribute('data-point2-price', point2.price);
|
||||||
|
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an anchor circle for dragging.
|
||||||
|
* @param {Object} point - {time, price}
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
* @param {number} lineIndex - Index of line in formation
|
||||||
|
* @param {string} pointKey - 'point1' or 'point2'
|
||||||
|
* @returns {SVGCircleElement|null}
|
||||||
|
*/
|
||||||
|
_createAnchor(point, tblKey, lineIndex, pointKey) {
|
||||||
|
const pixel = this._chartToPixel(point.time, point.price);
|
||||||
|
if (!pixel) return null;
|
||||||
|
|
||||||
|
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
|
circle.setAttribute('cx', pixel.x);
|
||||||
|
circle.setAttribute('cy', pixel.y);
|
||||||
|
circle.setAttribute('r', 6);
|
||||||
|
circle.setAttribute('fill', this.defaultColor);
|
||||||
|
circle.setAttribute('stroke', this.anchorColor);
|
||||||
|
circle.setAttribute('stroke-width', 2);
|
||||||
|
circle.setAttribute('cursor', 'move');
|
||||||
|
circle.setAttribute('data-tbl-key', tblKey);
|
||||||
|
circle.setAttribute('data-line-index', lineIndex);
|
||||||
|
circle.setAttribute('data-point-key', pointKey);
|
||||||
|
circle.setAttribute('data-time', point.time);
|
||||||
|
circle.setAttribute('data-price', point.price);
|
||||||
|
|
||||||
|
// Make anchors visible on hover
|
||||||
|
circle.style.opacity = '0';
|
||||||
|
circle.style.transition = 'opacity 0.2s';
|
||||||
|
|
||||||
|
// Anchor event listeners
|
||||||
|
circle.addEventListener('mouseenter', () => {
|
||||||
|
circle.style.opacity = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
circle.addEventListener('mouseleave', () => {
|
||||||
|
if (!this.isDragging) {
|
||||||
|
circle.style.opacity = '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
circle.addEventListener('mousedown', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this._startDrag(circle, tblKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
return circle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update all rendered formations (called when chart view changes).
|
||||||
|
*/
|
||||||
|
_updateAllFormations() {
|
||||||
|
for (const [tblKey, data] of this.renderedFormations) {
|
||||||
|
// Re-render the formation
|
||||||
|
this.renderFormation(data.formation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update temp elements if drawing
|
||||||
|
if (this.drawingMode && this.currentPoints.length > 0) {
|
||||||
|
this._clearTempElements();
|
||||||
|
this.currentPoints.forEach(pt => this._drawTempAnchor(pt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a specific formation.
|
||||||
|
* @param {Object} formation - Updated formation data
|
||||||
|
*/
|
||||||
|
updateFormation(formation) {
|
||||||
|
this.renderFormation(formation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a formation from the overlay.
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
*/
|
||||||
|
removeFormation(tblKey) {
|
||||||
|
const data = this.renderedFormations.get(tblKey);
|
||||||
|
if (data) {
|
||||||
|
data.elements.forEach(el => el.remove());
|
||||||
|
this.renderedFormations.delete(tblKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all formations from the overlay.
|
||||||
|
*/
|
||||||
|
clearAllFormations() {
|
||||||
|
for (const tblKey of this.renderedFormations.keys()) {
|
||||||
|
this.removeFormation(tblKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a formation for editing.
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
*/
|
||||||
|
selectFormation(tblKey) {
|
||||||
|
// Deselect previous
|
||||||
|
if (this.selectedTblKey) {
|
||||||
|
const prevData = this.renderedFormations.get(this.selectedTblKey);
|
||||||
|
if (prevData) {
|
||||||
|
prevData.elements.forEach(el => {
|
||||||
|
if (el.tagName === 'line') {
|
||||||
|
el.setAttribute('stroke', prevData.formation.color || this.defaultColor);
|
||||||
|
}
|
||||||
|
if (el.tagName === 'circle') {
|
||||||
|
el.setAttribute('fill', prevData.formation.color || this.defaultColor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedTblKey = tblKey;
|
||||||
|
|
||||||
|
// Highlight selected
|
||||||
|
const data = this.renderedFormations.get(tblKey);
|
||||||
|
if (data) {
|
||||||
|
data.elements.forEach(el => {
|
||||||
|
if (el.tagName === 'line') {
|
||||||
|
el.setAttribute('stroke', this.selectedColor);
|
||||||
|
}
|
||||||
|
if (el.tagName === 'circle') {
|
||||||
|
el.setAttribute('fill', this.selectedColor);
|
||||||
|
el.style.opacity = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ Dragging Methods ================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start dragging an anchor.
|
||||||
|
* @param {SVGCircleElement} anchor - The anchor element
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
*/
|
||||||
|
_startDrag(anchor, tblKey) {
|
||||||
|
this.isDragging = true;
|
||||||
|
this.dragAnchor = anchor;
|
||||||
|
|
||||||
|
const data = this.renderedFormations.get(tblKey);
|
||||||
|
if (data) {
|
||||||
|
this.dragFormation = data.formation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visual feedback
|
||||||
|
anchor.setAttribute('r', 8);
|
||||||
|
|
||||||
|
// Prevent text selection during drag
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle drag movement.
|
||||||
|
* @param {Object} coords - New position {time, price}
|
||||||
|
*/
|
||||||
|
_handleDrag(coords) {
|
||||||
|
if (!this.dragAnchor || !this.dragFormation) return;
|
||||||
|
|
||||||
|
const lineIndex = parseInt(this.dragAnchor.getAttribute('data-line-index'), 10);
|
||||||
|
const pointKey = this.dragAnchor.getAttribute('data-point-key');
|
||||||
|
|
||||||
|
// Null guards
|
||||||
|
if (isNaN(lineIndex) || !pointKey) return;
|
||||||
|
|
||||||
|
// Parse current lines
|
||||||
|
let linesData;
|
||||||
|
try {
|
||||||
|
linesData = JSON.parse(this.dragFormation.lines_json || '{}');
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!linesData.lines || !linesData.lines[lineIndex]) return;
|
||||||
|
|
||||||
|
// Update the point
|
||||||
|
linesData.lines[lineIndex][pointKey] = {
|
||||||
|
time: coords.time,
|
||||||
|
price: coords.price
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update formation data
|
||||||
|
this.dragFormation.lines_json = JSON.stringify(linesData);
|
||||||
|
|
||||||
|
// Update anchor position
|
||||||
|
const pixel = this._chartToPixel(coords.time, coords.price);
|
||||||
|
if (pixel) {
|
||||||
|
this.dragAnchor.setAttribute('cx', pixel.x);
|
||||||
|
this.dragAnchor.setAttribute('cy', pixel.y);
|
||||||
|
this.dragAnchor.setAttribute('data-time', coords.time);
|
||||||
|
this.dragAnchor.setAttribute('data-price', coords.price);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render the formation
|
||||||
|
this.renderFormation(this.dragFormation);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End dragging.
|
||||||
|
*/
|
||||||
|
_endDrag() {
|
||||||
|
if (this.dragAnchor) {
|
||||||
|
this.dragAnchor.setAttribute('r', 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we were dragging, save the changes
|
||||||
|
// (This would typically emit an update event)
|
||||||
|
|
||||||
|
this.isDragging = false;
|
||||||
|
this.dragAnchor = null;
|
||||||
|
this.dragFormation = null;
|
||||||
|
|
||||||
|
document.body.style.userSelect = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ Cleanup ================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy the overlay and clean up resources.
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
// Stop RAF loop
|
||||||
|
this._loopRunning = false;
|
||||||
|
if (this._animationFrameId) {
|
||||||
|
cancelAnimationFrame(this._animationFrameId);
|
||||||
|
this._animationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove SVG element
|
||||||
|
if (this.svg && this.svg.parentNode) {
|
||||||
|
this.svg.parentNode.removeChild(this.svg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear references
|
||||||
|
this.svg = null;
|
||||||
|
this.container = null;
|
||||||
|
this.renderedFormations.clear();
|
||||||
|
|
||||||
|
console.log('FormationOverlay: Destroyed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
/**
|
||||||
|
* FormationsUIManager - Handles DOM updates and formation card rendering
|
||||||
|
*/
|
||||||
|
class FormationsUIManager {
|
||||||
|
constructor() {
|
||||||
|
this.targetEl = null;
|
||||||
|
this.drawingControlsEl = null;
|
||||||
|
this.nameInputEl = null;
|
||||||
|
this.onDeleteFormation = null;
|
||||||
|
this.onEditFormation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the UI elements.
|
||||||
|
* @param {string} targetId - ID of the formations list container
|
||||||
|
*/
|
||||||
|
initUI(targetId) {
|
||||||
|
this.targetEl = document.getElementById(targetId);
|
||||||
|
if (!this.targetEl) {
|
||||||
|
console.warn(`Formations container "${targetId}" not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawingControlsEl = document.getElementById('formation_drawing_controls');
|
||||||
|
this.nameInputEl = document.getElementById('formation_name_input');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback for delete formation.
|
||||||
|
* @param {Function} callback - Function to call when delete is clicked
|
||||||
|
*/
|
||||||
|
registerDeleteCallback(callback) {
|
||||||
|
this.onDeleteFormation = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register callback for edit formation.
|
||||||
|
* @param {Function} callback - Function to call when edit is clicked
|
||||||
|
*/
|
||||||
|
registerEditCallback(callback) {
|
||||||
|
this.onEditFormation = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show drawing controls.
|
||||||
|
*/
|
||||||
|
showDrawingControls() {
|
||||||
|
if (this.drawingControlsEl) {
|
||||||
|
this.drawingControlsEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
if (this.nameInputEl) {
|
||||||
|
this.nameInputEl.value = '';
|
||||||
|
this.nameInputEl.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide drawing controls.
|
||||||
|
*/
|
||||||
|
hideDrawingControls() {
|
||||||
|
if (this.drawingControlsEl) {
|
||||||
|
this.drawingControlsEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the formation name from input.
|
||||||
|
* @returns {string} Formation name
|
||||||
|
*/
|
||||||
|
getFormationName() {
|
||||||
|
return this.nameInputEl ? this.nameInputEl.value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render all formations as cards.
|
||||||
|
* @param {Array} formations - List of formation objects
|
||||||
|
*/
|
||||||
|
renderFormations(formations) {
|
||||||
|
if (!this.targetEl) {
|
||||||
|
console.warn("Formations container not initialized");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (!formations || formations.length === 0) {
|
||||||
|
this.targetEl.innerHTML = '<p style="color: #888; font-size: 12px; padding: 10px;">No formations yet. Click a button above to draw one.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
formations.forEach(formation => {
|
||||||
|
const card = this._createFormationCard(formation);
|
||||||
|
this.targetEl.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a formation card element.
|
||||||
|
* @param {Object} formation - Formation data
|
||||||
|
* @returns {HTMLElement} Card element
|
||||||
|
*/
|
||||||
|
_createFormationCard(formation) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'formation-item';
|
||||||
|
card.dataset.tblKey = formation.tbl_key;
|
||||||
|
card.dataset.type = formation.formation_type;
|
||||||
|
|
||||||
|
// Format type for display
|
||||||
|
const typeDisplay = this._formatType(formation.formation_type);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="formation-icon">
|
||||||
|
<span class="formation-name">${this._escapeHtml(formation.name)}</span>
|
||||||
|
<span class="formation-type">${typeDisplay}</span>
|
||||||
|
</div>
|
||||||
|
<button class="edit-button" title="Edit">✎</button>
|
||||||
|
<button class="delete-button" title="Delete">×</button>
|
||||||
|
<div class="formation-hover">
|
||||||
|
<strong>${this._escapeHtml(formation.name)}</strong>
|
||||||
|
<div class="formation-details">
|
||||||
|
<span>Type: ${typeDisplay}</span>
|
||||||
|
<span><span class="formation-color-dot" style="background: ${formation.color}"></span>Color</span>
|
||||||
|
<span>Scope: ${formation.exchange}/${formation.market}/${formation.timeframe}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add click handlers
|
||||||
|
const deleteBtn = card.querySelector('.delete-button');
|
||||||
|
deleteBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.onDeleteFormation) {
|
||||||
|
this.onDeleteFormation(formation.tbl_key, formation.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const editBtn = card.querySelector('.edit-button');
|
||||||
|
editBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.onEditFormation) {
|
||||||
|
this.onEditFormation(formation.tbl_key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Card click to select
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
this._selectCard(card);
|
||||||
|
if (this.onEditFormation) {
|
||||||
|
this.onEditFormation(formation.tbl_key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a card visually.
|
||||||
|
* @param {HTMLElement} card - Card to select
|
||||||
|
*/
|
||||||
|
_selectCard(card) {
|
||||||
|
// Deselect all
|
||||||
|
this.targetEl.querySelectorAll('.formation-item').forEach(c => {
|
||||||
|
c.classList.remove('selected');
|
||||||
|
});
|
||||||
|
// Select this one
|
||||||
|
card.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format formation type for display.
|
||||||
|
* @param {string} type - Formation type
|
||||||
|
* @returns {string} Formatted type
|
||||||
|
*/
|
||||||
|
_formatType(type) {
|
||||||
|
const typeMap = {
|
||||||
|
'support_resistance': 'Line',
|
||||||
|
'channel': 'Channel',
|
||||||
|
'triangle': 'Triangle',
|
||||||
|
'head_shoulders': 'H&S',
|
||||||
|
'double_bottom': 'Double Bottom',
|
||||||
|
'double_top': 'Double Top'
|
||||||
|
};
|
||||||
|
return typeMap[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS.
|
||||||
|
* @param {string} str - String to escape
|
||||||
|
* @returns {string} Escaped string
|
||||||
|
*/
|
||||||
|
_escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FormationsDataManager - Manages in-memory formations data
|
||||||
|
*/
|
||||||
|
class FormationsDataManager {
|
||||||
|
constructor() {
|
||||||
|
this.formations = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set all formations.
|
||||||
|
* @param {Array} formations - List of formations
|
||||||
|
*/
|
||||||
|
setFormations(formations) {
|
||||||
|
this.formations = Array.isArray(formations) ? formations : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all formations.
|
||||||
|
* @returns {Array} List of formations
|
||||||
|
*/
|
||||||
|
getAllFormations() {
|
||||||
|
return this.formations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new formation.
|
||||||
|
* @param {Object} formation - Formation to add
|
||||||
|
*/
|
||||||
|
addFormation(formation) {
|
||||||
|
if (formation) {
|
||||||
|
this.formations.push(formation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing formation.
|
||||||
|
* @param {Object} updatedFormation - Updated formation data
|
||||||
|
*/
|
||||||
|
updateFormation(updatedFormation) {
|
||||||
|
const index = this.formations.findIndex(f => f.tbl_key === updatedFormation.tbl_key);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.formations[index] = { ...this.formations[index], ...updatedFormation };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a formation by tbl_key.
|
||||||
|
* @param {string} tblKey - Formation tbl_key to remove
|
||||||
|
*/
|
||||||
|
removeFormation(tblKey) {
|
||||||
|
this.formations = this.formations.filter(f => f.tbl_key !== tblKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a formation by tbl_key.
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
* @returns {Object|null} Formation or null
|
||||||
|
*/
|
||||||
|
getFormation(tblKey) {
|
||||||
|
return this.formations.find(f => f.tbl_key === tblKey) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formations - Main coordinator class for formations feature
|
||||||
|
*/
|
||||||
|
class Formations {
|
||||||
|
constructor(ui) {
|
||||||
|
this.ui = ui;
|
||||||
|
this.comms = ui?.data?.comms;
|
||||||
|
this.data = ui?.data;
|
||||||
|
|
||||||
|
this.dataManager = new FormationsDataManager();
|
||||||
|
this.uiManager = new FormationsUIManager();
|
||||||
|
this.overlay = null;
|
||||||
|
|
||||||
|
// Current drawing state
|
||||||
|
this.drawingMode = null;
|
||||||
|
this.currentScope = null;
|
||||||
|
|
||||||
|
// Set up callbacks
|
||||||
|
this.uiManager.registerDeleteCallback(this.deleteFormation.bind(this));
|
||||||
|
this.uiManager.registerEditCallback(this.selectFormation.bind(this));
|
||||||
|
|
||||||
|
this._initialized = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the formations system.
|
||||||
|
* @param {string} targetId - ID of formations list container
|
||||||
|
*/
|
||||||
|
initialize(targetId) {
|
||||||
|
try {
|
||||||
|
this.uiManager.initUI(targetId);
|
||||||
|
|
||||||
|
if (!this.comms) {
|
||||||
|
console.error("Communications instance not available for Formations");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register socket handlers
|
||||||
|
this.registerSocketHandlers();
|
||||||
|
|
||||||
|
// Get current scope from chart data
|
||||||
|
this.currentScope = {
|
||||||
|
exchange: this.data?.exchange || window.bt_data?.exchange || 'kucoin',
|
||||||
|
market: this.data?.trading_pair || window.bt_data?.trading_pair || 'BTC/USDT',
|
||||||
|
timeframe: this.data?.timeframe || window.bt_data?.timeframe || '1h'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch formations for current scope
|
||||||
|
this.fetchFormations();
|
||||||
|
|
||||||
|
this._initialized = true;
|
||||||
|
console.log("Formations initialized for scope:", this.currentScope);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing Formations:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the SVG overlay for drawing.
|
||||||
|
* @param {string} chartContainerId - ID of chart container
|
||||||
|
* @param {Object} chart - Lightweight Charts instance
|
||||||
|
* @param {Object} candleSeries - Candlestick series for coordinate conversion
|
||||||
|
*/
|
||||||
|
initOverlay(chartContainerId, chart, candleSeries) {
|
||||||
|
if (typeof FormationOverlay !== 'undefined') {
|
||||||
|
this.overlay = new FormationOverlay(chartContainerId, chart, candleSeries);
|
||||||
|
this.overlay.setOnSaveCallback(this.saveFormation.bind(this));
|
||||||
|
console.log("Formation overlay initialized");
|
||||||
|
} else {
|
||||||
|
console.warn("FormationOverlay class not loaded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register socket handlers for formations.
|
||||||
|
*/
|
||||||
|
registerSocketHandlers() {
|
||||||
|
this.comms.on('formations', this.handleFormationsResponse.bind(this));
|
||||||
|
this.comms.on('formation_created', this.handleFormationCreated.bind(this));
|
||||||
|
this.comms.on('formation_updated', this.handleFormationUpdated.bind(this));
|
||||||
|
this.comms.on('formation_deleted', this.handleFormationDeleted.bind(this));
|
||||||
|
this.comms.on('formation_error', this.handleFormationError.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch formations for current scope.
|
||||||
|
*/
|
||||||
|
fetchFormations() {
|
||||||
|
if (!this.comms || !this.currentScope) return;
|
||||||
|
|
||||||
|
this.comms.sendToApp('request_formations', this.currentScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ Socket Handlers ================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle formations list response.
|
||||||
|
* @param {Object} data - Response with formations array
|
||||||
|
*/
|
||||||
|
handleFormationsResponse(data) {
|
||||||
|
console.log("Received formations:", data);
|
||||||
|
const formations = data.formations || [];
|
||||||
|
this.dataManager.setFormations(formations);
|
||||||
|
this.uiManager.renderFormations(formations);
|
||||||
|
|
||||||
|
// Render on overlay if available
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.clearAllFormations();
|
||||||
|
formations.forEach(f => this.overlay.renderFormation(f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle formation created event.
|
||||||
|
* @param {Object} data - Response with formation data
|
||||||
|
*/
|
||||||
|
handleFormationCreated(data) {
|
||||||
|
console.log("Formation created:", data);
|
||||||
|
if (data.success && data.formation) {
|
||||||
|
this.dataManager.addFormation(data.formation);
|
||||||
|
this.uiManager.renderFormations(this.dataManager.getAllFormations());
|
||||||
|
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.renderFormation(data.formation);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uiManager.hideDrawingControls();
|
||||||
|
this.drawingMode = null;
|
||||||
|
} else {
|
||||||
|
alert(`Failed to create formation: ${data.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle formation updated event.
|
||||||
|
* @param {Object} data - Response with updated formation data
|
||||||
|
*/
|
||||||
|
handleFormationUpdated(data) {
|
||||||
|
console.log("Formation updated:", data);
|
||||||
|
if (data.success && data.formation) {
|
||||||
|
this.dataManager.updateFormation(data.formation);
|
||||||
|
this.uiManager.renderFormations(this.dataManager.getAllFormations());
|
||||||
|
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.updateFormation(data.formation);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(`Failed to update formation: ${data.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle formation deleted event.
|
||||||
|
* @param {Object} data - Response with tbl_key of deleted formation
|
||||||
|
*/
|
||||||
|
handleFormationDeleted(data) {
|
||||||
|
console.log("Formation deleted:", data);
|
||||||
|
if (data.success && data.tbl_key) {
|
||||||
|
this.dataManager.removeFormation(data.tbl_key);
|
||||||
|
this.uiManager.renderFormations(this.dataManager.getAllFormations());
|
||||||
|
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.removeFormation(data.tbl_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle formation error.
|
||||||
|
* @param {Object} data - Error data
|
||||||
|
*/
|
||||||
|
handleFormationError(data) {
|
||||||
|
console.error("Formation error:", data.message);
|
||||||
|
alert(`Formation error: ${data.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================ Drawing Methods ================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start drawing a new formation.
|
||||||
|
* @param {string} type - Formation type ('support_resistance', 'channel')
|
||||||
|
*/
|
||||||
|
startDrawing(type) {
|
||||||
|
console.log("Starting drawing mode:", type);
|
||||||
|
this.drawingMode = type;
|
||||||
|
|
||||||
|
// Show drawing controls
|
||||||
|
this.uiManager.showDrawingControls();
|
||||||
|
|
||||||
|
// Tell overlay to start drawing
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.startDrawing(type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete the current drawing.
|
||||||
|
*/
|
||||||
|
completeDrawing() {
|
||||||
|
const name = this.uiManager.getFormationName();
|
||||||
|
if (!name) {
|
||||||
|
alert("Please enter a formation name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.completeDrawing(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the current drawing.
|
||||||
|
*/
|
||||||
|
cancelDrawing() {
|
||||||
|
this.drawingMode = null;
|
||||||
|
this.uiManager.hideDrawingControls();
|
||||||
|
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.cancelDrawing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a formation (callback from overlay).
|
||||||
|
* @param {Object} formationData - Formation data to save
|
||||||
|
*/
|
||||||
|
saveFormation(formationData) {
|
||||||
|
if (!this.comms) return;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
...formationData,
|
||||||
|
...this.currentScope
|
||||||
|
};
|
||||||
|
|
||||||
|
this.comms.sendToApp('new_formation', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a formation for editing.
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
*/
|
||||||
|
selectFormation(tblKey) {
|
||||||
|
console.log("Selecting formation:", tblKey);
|
||||||
|
if (this.overlay) {
|
||||||
|
this.overlay.selectFormation(tblKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a formation.
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
* @param {string} name - Formation name (for confirmation)
|
||||||
|
*/
|
||||||
|
deleteFormation(tblKey, name) {
|
||||||
|
if (!confirm(`Delete formation "${name}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.comms) {
|
||||||
|
this.comms.sendToApp('delete_formation', { tbl_key: tblKey });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a formation's lines (called from overlay after drag).
|
||||||
|
* @param {string} tblKey - Formation tbl_key
|
||||||
|
* @param {Object} linesData - Updated lines data
|
||||||
|
*/
|
||||||
|
updateFormationLines(tblKey, linesData) {
|
||||||
|
if (!this.comms) return;
|
||||||
|
|
||||||
|
this.comms.sendToApp('edit_formation', {
|
||||||
|
tbl_key: tblKey,
|
||||||
|
lines_json: JSON.stringify(linesData)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ class User_Interface {
|
||||||
this.users = new Users();
|
this.users = new Users();
|
||||||
this.indicators = new Indicators(this.data.comms);
|
this.indicators = new Indicators(this.data.comms);
|
||||||
this.signals = new Signals(this);
|
this.signals = new Signals(this);
|
||||||
|
this.formations = new Formations(this);
|
||||||
this.backtesting = new Backtesting(this);
|
this.backtesting = new Backtesting(this);
|
||||||
this.statistics = new Statistics(this.data.comms);
|
this.statistics = new Statistics(this.data.comms);
|
||||||
this.account = new Account();
|
this.account = new Account();
|
||||||
|
|
@ -78,6 +79,8 @@ class User_Interface {
|
||||||
this.indicators.addToCharts(this.charts, ind_init_data);
|
this.indicators.addToCharts(this.charts, ind_init_data);
|
||||||
|
|
||||||
this.signals.initialize('signal_list', 'new_sig_form');
|
this.signals.initialize('signal_list', 'new_sig_form');
|
||||||
|
this.formations.initialize('formations_list');
|
||||||
|
this.formations.initOverlay(this.data.chart1_id, this.charts.chart_1, this.charts.candleSeries);
|
||||||
this.alerts.set_target();
|
this.alerts.set_target();
|
||||||
this.alerts.initialize(this.data.comms);
|
this.alerts.initialize(this.data.comms);
|
||||||
this.controls.init_TP_selector();
|
this.controls.init_TP_selector();
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
{% include "indicators_hud.html" %}
|
{% include "indicators_hud.html" %}
|
||||||
<button class="collapsible bg_blue">Signals</button>
|
<button class="collapsible bg_blue">Signals</button>
|
||||||
{% include "signals_hud.html" %}
|
{% include "signals_hud.html" %}
|
||||||
|
<button class="collapsible bg_blue">Formations</button>
|
||||||
|
{% include "formations_hud.html" %}
|
||||||
<button class="collapsible bg_blue">Strategies</button>
|
<button class="collapsible bg_blue">Strategies</button>
|
||||||
{% include "strategies_hud.html" %}
|
{% include "strategies_hud.html" %}
|
||||||
<button class="collapsible bg_blue">Statistics</button>
|
<button class="collapsible bg_blue">Statistics</button>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
<div class="content" id="formations_panel">
|
||||||
|
<h4 style="margin: 5px 0 10px 0;">Draw Formation</h4>
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 5px;">
|
||||||
|
<button class="btn btn-sm" onclick="UI.formations.startDrawing('support_resistance')">
|
||||||
|
<span style="font-size: 18px;">━</span> Line
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm" onclick="UI.formations.startDrawing('channel')">
|
||||||
|
<span style="font-size: 18px;">═</span> Channel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drawing controls (shown when drawing) -->
|
||||||
|
<div id="formation_drawing_controls" style="display: none; margin-top: 10px; padding: 10px; background: #2a2a2a; border-radius: 5px;">
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<input type="text" id="formation_name_input" placeholder="Formation name"
|
||||||
|
style="flex: 1; padding: 5px; border-radius: 3px; border: 1px solid #444; background: #1e1e1e; color: #e0e0e0;">
|
||||||
|
<button class="btn btn-sm" style="background: #28a745;" onclick="UI.formations.completeDrawing()">Save</button>
|
||||||
|
<button class="btn btn-sm" style="background: #dc3545;" onclick="UI.formations.cancelDrawing()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<h3>Formations</h3>
|
||||||
|
<div class="formations-container" id="formations_list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Formations container - flex grid for cards */
|
||||||
|
.formations-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual formation card */
|
||||||
|
.formation-item {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(145deg, #2a3a5a, #1e2a40);
|
||||||
|
box-shadow: 5px 5px 10px #151f30, -5px -5px 10px #253550;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
overflow: visible;
|
||||||
|
border: 2px solid #3a5a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-item:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 8px 8px 15px #151f30, -8px -8px 15px #253550;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-item.selected {
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 15px rgba(102, 126, 234, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formation icon area */
|
||||||
|
.formation-icon {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS-based formation icon */
|
||||||
|
.formation-icon::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #4a5cd8 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line icon */
|
||||||
|
.formation-item[data-type="support_resistance"] .formation-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 25px;
|
||||||
|
width: 30px;
|
||||||
|
height: 3px;
|
||||||
|
background: white;
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Channel icon - two parallel lines */
|
||||||
|
.formation-item[data-type="channel"] .formation-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
width: 28px;
|
||||||
|
height: 3px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 10px 0 white;
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formation name */
|
||||||
|
.formation-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
color: #e0e0e0;
|
||||||
|
margin-top: 50px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-width: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Formation type label */
|
||||||
|
.formation-type {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #8899aa;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete button */
|
||||||
|
.formation-item .delete-button {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid #1e2a40;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-item:hover .delete-button {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-item .delete-button:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Edit button */
|
||||||
|
.formation-item .edit-button {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: 18px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: 2px solid #1e2a40;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-item:hover .edit-button {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-item .edit-button:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover details panel */
|
||||||
|
.formation-hover {
|
||||||
|
position: absolute;
|
||||||
|
top: 110px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 180px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #2a3a5a;
|
||||||
|
border: 1px solid #4a6a9a;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 100;
|
||||||
|
display: none;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-item:hover .formation-hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-hover strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-bottom: 1px solid #4a6a9a;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formation-details span {
|
||||||
|
color: #aabbcc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Color indicator */
|
||||||
|
.formation-color-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button styles */
|
||||||
|
.btn-sm {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(145deg, #3a4a6a, #2a3a5a);
|
||||||
|
color: #e0e0e0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm:hover {
|
||||||
|
background: linear-gradient(145deg, #4a5a7a, #3a4a6a);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -29,6 +29,8 @@
|
||||||
<script src="{{ url_for('static', filename='communication.js') }}"></script>
|
<script src="{{ url_for('static', filename='communication.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='controls.js') }}"></script>
|
<script src="{{ url_for('static', filename='controls.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='signals.js') }}"></script>
|
<script src="{{ url_for('static', filename='signals.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='formation_overlay.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='formations.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='trade.js') }}"></script>
|
<script src="{{ url_for('static', filename='trade.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='backtesting.js') }}"></script>
|
<script src="{{ url_for('static', filename='backtesting.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='Statistics.js') }}?v=1"></script>
|
<script src="{{ url_for('static', filename='Statistics.js') }}?v=1"></script>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue