diff --git a/src/static/formation_overlay.js b/src/static/formation_overlay.js index 22ff14e..d627c62 100644 --- a/src/static/formation_overlay.js +++ b/src/static/formation_overlay.js @@ -3,6 +3,12 @@ * * Uses SVG layer positioned over the chart container. * Syncs with chart via requestAnimationFrame polling (not event subscriptions). + * + * Drawing UX for Lines: + * - Single click places a solid horizontal line with 3 anchors + * - Center anchor: drag to move line without changing angle + * - End anchors: drag to pivot from opposite end (change angle) + * - Hover over any part of line shows all anchors */ class FormationOverlay { constructor(chartContainerId, chart, candleSeries) { @@ -19,7 +25,11 @@ class FormationOverlay { this.currentPoints = []; this.tempLine = null; - // Rendered formations: Map + // Draft formation (created on first click, not yet saved) + this.draftFormation = null; + this.draftTblKey = '__draft__'; + + // Rendered formations: Map this.renderedFormations = new Map(); // Selected formation for editing @@ -29,6 +39,7 @@ class FormationOverlay { this.isDragging = false; this.dragAnchor = null; this.dragFormation = null; + this.dragAnchorType = null; // 'center', 'point1', 'point2' // RAF loop state this._loopRunning = false; @@ -38,12 +49,18 @@ class FormationOverlay { // Callback for saving formations this.onSaveCallback = null; - // Callback for when points change during drawing + // Callback for when points change during drawing (for UI updates) this.onPointsChangedCallback = null; + // Callback for when draft is ready to save (name input can appear) + this.onDraftReadyCallback = null; + // Track points needed for current drawing this._pointsNeeded = 0; + // Default line width in time units (seconds) - line extends this far on each side + this._defaultLineHalfWidth = 3600 * 4; // 4 hours on each side + // Colors this.defaultColor = '#667eea'; this.selectedColor = '#ff9500'; @@ -71,6 +88,14 @@ class FormationOverlay { this.onPointsChangedCallback = callback; } + /** + * Set callback for when draft formation is ready to save. + * @param {Function} callback - Called when user can enter name and save + */ + setOnDraftReadyCallback(callback) { + this.onDraftReadyCallback = callback; + } + /** * Create the SVG overlay layer. */ @@ -375,6 +400,7 @@ class FormationOverlay { startDrawing(type) { this.drawingMode = type; this.currentPoints = []; + this.draftFormation = null; this._pointsNeeded = this._getPointsNeeded(type); this._clearTempElements(); @@ -388,12 +414,13 @@ class FormationOverlay { this.svg.style.pointerEvents = 'all'; } - // Notify of initial state + // Notify of initial state (for support_resistance, we say 1 point needed since single click creates line) + const displayPoints = (type === 'support_resistance') ? 1 : this._pointsNeeded; if (this.onPointsChangedCallback) { - this.onPointsChangedCallback(0, this._pointsNeeded); + this.onPointsChangedCallback(0, displayPoints); } - console.log('FormationOverlay: Started drawing', type, 'needs', this._pointsNeeded, 'points'); + console.log('FormationOverlay: Started drawing', type); } /** @@ -401,6 +428,80 @@ class FormationOverlay { * @param {Object} coords - {time, price} */ _handleDrawingClick(coords) { + if (this.drawingMode === 'support_resistance') { + this._handleLineDrawingClick(coords); + } else if (this.drawingMode === 'channel') { + this._handleChannelDrawingClick(coords); + } else { + this._handleGenericDrawingClick(coords); + } + } + + /** + * Handle click for line (support_resistance) drawing. + * Single click creates a horizontal line with 3 anchors. + * @param {Object} coords - {time, price} + */ + _handleLineDrawingClick(coords) { + // If we already have a draft, ignore further clicks (user can drag anchors) + if (this.draftFormation) { + return; + } + + // Create a horizontal line centered at click point + const centerTime = coords.time; + const price = coords.price; + + // Create line endpoints on each side of center + const point1 = { time: centerTime - this._defaultLineHalfWidth, price: price }; + const point2 = { time: centerTime + this._defaultLineHalfWidth, price: price }; + + // Create draft formation + this.draftFormation = { + tbl_key: this.draftTblKey, + formation_type: 'support_resistance', + color: this.defaultColor, + lines_json: JSON.stringify({ + lines: [{ + point1: point1, + point2: point2, + center: { time: centerTime, price: price } + }] + }) + }; + + // Render the draft formation + this.renderFormation(this.draftFormation); + + // Change cursor back since line is placed + if (this.container) { + this.container.style.cursor = 'default'; + } + + // Notify that draft is ready (for name input) + if (this.onPointsChangedCallback) { + this.onPointsChangedCallback(1, 1); + } + if (this.onDraftReadyCallback) { + this.onDraftReadyCallback(); + } + + console.log('FormationOverlay: Draft line created at', coords); + } + + /** + * Handle click for channel drawing (original multi-click approach). + * @param {Object} coords - {time, price} + */ + _handleChannelDrawingClick(coords) { + this._handleGenericDrawingClick(coords); + } + + /** + * Handle click for generic multi-point drawing (original approach). + * @param {Object} coords - {time, price} + */ + _handleGenericDrawingClick(coords) { // Don't accept more points than needed if (this.currentPoints.length >= this._pointsNeeded) { console.log('FormationOverlay: Already have enough points'); @@ -509,21 +610,33 @@ class FormationOverlay { * @param {string} name - Formation name */ completeDrawing(name) { - if (!this.drawingMode || this.currentPoints.length < 2) { + let formationData; + + if (this.draftFormation) { + // Use the draft formation (for line drawing with 3-anchor UX) + formationData = { + name: name, + formation_type: this.draftFormation.formation_type, + lines_json: this.draftFormation.lines_json, + color: this.draftFormation.color + }; + + // Remove the draft from rendered formations + this.removeFormation(this.draftTblKey); + } else if (this.drawingMode && this.currentPoints.length >= 2) { + // Original multi-click approach (for channels, etc.) + const lines = this._buildLinesFromPoints(this.drawingMode, this.currentPoints); + formationData = { + name: name, + formation_type: this.drawingMode, + lines_json: JSON.stringify({ lines: lines }), + color: this.defaultColor + }; + } else { 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); @@ -569,6 +682,10 @@ class FormationOverlay { * Cancel the current drawing. */ cancelDrawing() { + // Remove draft formation if exists + if (this.draftFormation) { + this.removeFormation(this.draftTblKey); + } this._exitDrawingMode(); } @@ -579,7 +696,9 @@ class FormationOverlay { this.drawingMode = null; this.currentPoints = []; this._pointsNeeded = 0; + this.draftFormation = null; this.onPointsChangedCallback = null; + this.onDraftReadyCallback = null; this._clearTempElements(); // Reset cursor @@ -610,34 +729,111 @@ class FormationOverlay { const color = formation.color || this.defaultColor; const elements = []; + // Create a group for this formation (for hover behavior) + const formationGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + formationGroup.setAttribute('class', 'formation-group'); + formationGroup.setAttribute('data-tbl-key', formation.tbl_key); + formationGroup.style.pointerEvents = 'all'; + lines.forEach((line, index) => { - // Draw the infinite line + // Draw the infinite line with a wider invisible hit area + const lineHitArea = this._createLineHitArea(line.point1, line.point2, formation.tbl_key); const lineEl = this._createLine(line.point1, line.point2, color, formation.tbl_key); + + if (lineHitArea) { + formationGroup.appendChild(lineHitArea); + elements.push(lineHitArea); + } if (lineEl) { - this.linesGroup.appendChild(lineEl); + formationGroup.appendChild(lineEl); elements.push(lineEl); } - // Draw anchor points + // Calculate center point (use stored center or calculate midpoint) + const centerPoint = line.center || { + time: Math.floor((line.point1.time + line.point2.time) / 2), + price: (line.point1.price + line.point2.price) / 2 + }; + + // Draw anchor points (including center for 3-anchor control) const anchor1 = this._createAnchor(line.point1, formation.tbl_key, index, 'point1'); + const anchorCenter = this._createAnchor(centerPoint, formation.tbl_key, index, 'center'); const anchor2 = this._createAnchor(line.point2, formation.tbl_key, index, 'point2'); if (anchor1) { - this.anchorsGroup.appendChild(anchor1); + formationGroup.appendChild(anchor1); elements.push(anchor1); } + if (anchorCenter) { + formationGroup.appendChild(anchorCenter); + elements.push(anchorCenter); + } if (anchor2) { - this.anchorsGroup.appendChild(anchor2); + formationGroup.appendChild(anchor2); elements.push(anchor2); } }); + // Add hover behavior to show/hide all anchors in the group + this._setupGroupHoverBehavior(formationGroup); + + this.linesGroup.appendChild(formationGroup); + this.renderedFormations.set(formation.tbl_key, { formation: formation, - elements: elements + elements: elements, + group: formationGroup }); } + /** + * Setup hover behavior for a formation group. + * Hovering over any part of the formation shows all anchors. + * @param {SVGGElement} group - The formation group element + */ + _setupGroupHoverBehavior(group) { + const anchors = group.querySelectorAll('circle'); + + group.addEventListener('mouseenter', () => { + anchors.forEach(anchor => { + anchor.style.opacity = '1'; + }); + }); + + group.addEventListener('mouseleave', () => { + if (!this.isDragging) { + anchors.forEach(anchor => { + anchor.style.opacity = '0'; + }); + } + }); + } + + /** + * Create an invisible hit area for the line (wider than visible line). + * @param {Object} point1 - {time, price} + * @param {Object} point2 - {time, price} + * @param {string} tblKey - Formation tbl_key + * @returns {SVGLineElement|null} + */ + _createLineHitArea(point1, point2, 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', 'transparent'); + line.setAttribute('stroke-width', 20); // Wide hit area + line.setAttribute('data-tbl-key', tblKey); + line.setAttribute('data-hit-area', 'true'); + line.style.cursor = 'pointer'; + + return line; + } + /** * Create an SVG line element. * @param {Object} point1 - {time, price} @@ -671,45 +867,36 @@ class FormationOverlay { * @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' + * @param {string} pointKey - 'point1', 'point2', or 'center' * @returns {SVGCircleElement|null} */ _createAnchor(point, tblKey, lineIndex, pointKey) { const pixel = this._chartToPixel(point.time, point.price); if (!pixel) return null; + const isCenter = pointKey === 'center'; 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('r', isCenter ? 8 : 6); // Center anchor slightly larger + circle.setAttribute('fill', isCenter ? '#28a745' : this.defaultColor); // Center is green circle.setAttribute('stroke', this.anchorColor); circle.setAttribute('stroke-width', 2); - circle.setAttribute('cursor', 'move'); + circle.setAttribute('cursor', isCenter ? 'grab' : 'crosshair'); 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 + // Make anchors visible on hover (group hover handles visibility) 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'; - } - }); - + // Mouse down to start dragging circle.addEventListener('mousedown', (e) => { e.stopPropagation(); - this._startDrag(circle, tblKey); + this._startDrag(circle, tblKey, pointKey); }); return circle; @@ -805,18 +992,35 @@ class FormationOverlay { * Start dragging an anchor. * @param {SVGCircleElement} anchor - The anchor element * @param {string} tblKey - Formation tbl_key + * @param {string} anchorType - 'point1', 'point2', or 'center' */ - _startDrag(anchor, tblKey) { + _startDrag(anchor, tblKey, anchorType) { this.isDragging = true; this.dragAnchor = anchor; + this.dragAnchorType = anchorType; const data = this.renderedFormations.get(tblKey); if (data) { - this.dragFormation = data.formation; + // Make a deep copy to avoid modifying during drag + this.dragFormation = JSON.parse(JSON.stringify(data.formation)); + } + + // Store initial drag state for relative calculations + const linesData = JSON.parse(this.dragFormation.lines_json || '{}'); + if (linesData.lines && linesData.lines[0]) { + this._dragInitialLine = JSON.parse(JSON.stringify(linesData.lines[0])); + this._dragStartCoords = { + time: parseInt(anchor.getAttribute('data-time'), 10), + price: parseFloat(anchor.getAttribute('data-price')) + }; } // Visual feedback - anchor.setAttribute('r', 8); + const isCenter = anchorType === 'center'; + anchor.setAttribute('r', isCenter ? 10 : 8); + if (isCenter) { + anchor.style.cursor = 'grabbing'; + } // Prevent text selection during drag document.body.style.userSelect = 'none'; @@ -827,10 +1031,10 @@ class FormationOverlay { * @param {Object} coords - New position {time, price} */ _handleDrag(coords) { - if (!this.dragAnchor || !this.dragFormation) return; + if (!this.dragAnchor || !this.dragFormation || !this._dragInitialLine) return; const lineIndex = parseInt(this.dragAnchor.getAttribute('data-line-index'), 10); - const pointKey = this.dragAnchor.getAttribute('data-point-key'); + const pointKey = this.dragAnchorType; // Null guards if (isNaN(lineIndex) || !pointKey) return; @@ -845,24 +1049,53 @@ class FormationOverlay { if (!linesData.lines || !linesData.lines[lineIndex]) return; - // Update the point - linesData.lines[lineIndex][pointKey] = { - time: coords.time, - price: coords.price - }; + const line = linesData.lines[lineIndex]; + const initialLine = this._dragInitialLine; + + if (pointKey === 'center') { + // CENTER DRAG: Translate the entire line + const timeDelta = coords.time - this._dragStartCoords.time; + const priceDelta = coords.price - this._dragStartCoords.price; + + line.point1 = { + time: initialLine.point1.time + timeDelta, + price: initialLine.point1.price + priceDelta + }; + line.point2 = { + time: initialLine.point2.time + timeDelta, + price: initialLine.point2.price + priceDelta + }; + line.center = { + time: coords.time, + price: coords.price + }; + } else if (pointKey === 'point1') { + // POINT1 DRAG: Pivot around point2 (opposite end) + line.point1 = { + time: coords.time, + price: coords.price + }; + // Recalculate center as midpoint + line.center = { + time: Math.floor((line.point1.time + line.point2.time) / 2), + price: (line.point1.price + line.point2.price) / 2 + }; + } else if (pointKey === 'point2') { + // POINT2 DRAG: Pivot around point1 (opposite end) + line.point2 = { + time: coords.time, + price: coords.price + }; + // Recalculate center as midpoint + line.center = { + time: Math.floor((line.point1.time + line.point2.time) / 2), + price: (line.point1.price + line.point2.price) / 2 + }; + } // 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); } @@ -872,15 +1105,27 @@ class FormationOverlay { */ _endDrag() { if (this.dragAnchor) { - this.dragAnchor.setAttribute('r', 6); + const isCenter = this.dragAnchorType === 'center'; + this.dragAnchor.setAttribute('r', isCenter ? 8 : 6); + if (isCenter) { + this.dragAnchor.style.cursor = 'grab'; + } } - // If we were dragging, save the changes - // (This would typically emit an update event) + // Update the stored formation data + if (this.dragFormation && this.dragFormation.tbl_key) { + const data = this.renderedFormations.get(this.dragFormation.tbl_key); + if (data) { + data.formation = this.dragFormation; + } + } this.isDragging = false; this.dragAnchor = null; this.dragFormation = null; + this.dragAnchorType = null; + this._dragInitialLine = null; + this._dragStartCoords = null; document.body.style.userSelect = ''; } diff --git a/src/static/formations.js b/src/static/formations.js index 32536ad..4ab0359 100644 --- a/src/static/formations.js +++ b/src/static/formations.js @@ -64,15 +64,22 @@ class FormationsUIManager { // Set instruction text based on type const instructions = { - 'support_resistance': 'Click 2 points on the chart to draw a line', + 'support_resistance': 'Click on chart to place a line. Drag anchors to adjust.', 'channel': 'Click 3 points: first line (2 pts) + parallel offset (1 pt)' }; if (this.instructionTextEl) { this.instructionTextEl.textContent = instructions[type] || 'Click on chart to place points'; } - // Update points status - this.updatePointsStatus(0, pointsNeeded); + // Update points status (for lines, show "Click to place" instead of counter) + if (type === 'support_resistance') { + if (this.pointsStatusEl) { + this.pointsStatusEl.textContent = 'Click anywhere on the chart'; + this.pointsStatusEl.style.color = '#667eea'; + } + } else { + this.updatePointsStatus(0, pointsNeeded); + } } /** @@ -518,7 +525,7 @@ class Formations { */ _getPointsNeeded(type) { const pointsMap = { - 'support_resistance': 2, + 'support_resistance': 1, // Single click creates line with 3 anchors 'channel': 3 }; return pointsMap[type] || 2; @@ -537,9 +544,10 @@ class Formations { // Show drawing instructions (not name input yet) this.uiManager.showDrawingInstructions(type, pointsNeeded); - // Tell overlay to start drawing, with callback for point updates + // Tell overlay to start drawing, with callbacks if (this.overlay) { this.overlay.setOnPointsChangedCallback(this._onPointsChanged.bind(this)); + this.overlay.setOnDraftReadyCallback(this._onDraftReady.bind(this)); this.overlay.startDrawing(type); } } @@ -550,7 +558,12 @@ class Formations { * @param {number} pointsNeeded - Points needed for completion */ _onPointsChanged(currentPoints, pointsNeeded) { - // Update the UI status + // For line drawing, the name input is shown via _onDraftReady instead + if (this.drawingMode === 'support_resistance') { + return; + } + + // Update the UI status for multi-point drawings this.uiManager.updatePointsStatus(currentPoints, pointsNeeded); // If we have enough points, show the name input @@ -559,6 +572,20 @@ class Formations { } } + /** + * Called when a draft formation is ready (line placed, can be named). + */ + _onDraftReady() { + // Update status to show line is ready + if (this.uiManager.pointsStatusEl) { + this.uiManager.pointsStatusEl.textContent = 'Line placed! Drag anchors to adjust.'; + this.uiManager.pointsStatusEl.style.color = '#28a745'; + } + + // Show name input + this.uiManager.showNameInput(); + } + /** * Complete the current drawing. */