diff --git a/src/static/formation_overlay.js b/src/static/formation_overlay.js index d627c62..561b8bb 100644 --- a/src/static/formation_overlay.js +++ b/src/static/formation_overlay.js @@ -9,6 +9,13 @@ * - 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 + * + * Drawing UX for Channels: + * - First click places primary line (3 anchors like single line) + * - Mouse move shows dotted parallel preview line + * - Second click places parallel line (1 center anchor) + * - Primary line anchors control both lines (angle/position) + * - Secondary line's center anchor moves it parallel to primary */ class FormationOverlay { constructor(chartContainerId, chart, candleSeries) { @@ -29,6 +36,11 @@ class FormationOverlay { this.draftFormation = null; this.draftTblKey = '__draft__'; + // Channel drawing state + this.channelPrimaryLine = null; // First line placed + this.channelPreviewLine = null; // Dotted preview of second line + this.channelStep = 0; // 0 = waiting for first click, 1 = waiting for second click + // Rendered formations: Map this.renderedFormations = new Map(); @@ -39,7 +51,7 @@ class FormationOverlay { this.isDragging = false; this.dragAnchor = null; this.dragFormation = null; - this.dragAnchorType = null; // 'center', 'point1', 'point2' + this.dragAnchorType = null; // 'center', 'point1', 'point2', 'channel_offset' // RAF loop state this._loopRunning = false; @@ -65,6 +77,7 @@ class FormationOverlay { this.defaultColor = '#667eea'; this.selectedColor = '#ff9500'; this.anchorColor = '#ffffff'; + this.channelSecondaryColor = '#48bb78'; // Green for secondary line // Initialize this._createSVGLayer(); @@ -221,19 +234,20 @@ class FormationOverlay { // 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); - } + const coords = this._pixelToChart(e.offsetX, e.offsetY); + + // Channel preview (parallel line following mouse) + if (this.drawingMode === 'channel' && this.channelStep === 1 && coords) { + this._updateChannelPreview(coords); + } + // Generic temp line preview + else if (this.drawingMode && this.currentPoints.length > 0 && 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); - } + if (this.isDragging && this.dragAnchor && this.dragFormation && coords) { + this._handleDrag(coords); } }); @@ -490,11 +504,157 @@ class FormationOverlay { } /** - * Handle click for channel drawing (original multi-click approach). + * Handle click for channel drawing. + * Step 1: First click creates primary line (like single line) + * Step 2: Second click places parallel line at mouse position * @param {Object} coords - {time, price} */ _handleChannelDrawingClick(coords) { - this._handleGenericDrawingClick(coords); + if (this.channelStep === 0) { + // STEP 1: Create primary line (horizontal at click position) + const centerTime = coords.time; + const price = coords.price; + + // Create primary line endpoints + const point1 = { time: centerTime - this._defaultLineHalfWidth, price: price }; + const point2 = { time: centerTime + this._defaultLineHalfWidth, price: price }; + + this.channelPrimaryLine = { + point1: point1, + point2: point2, + center: { time: centerTime, price: price } + }; + + // Create draft formation with just the primary line + this.draftFormation = { + tbl_key: this.draftTblKey, + formation_type: 'channel', + color: this.defaultColor, + lines_json: JSON.stringify({ + lines: [this.channelPrimaryLine] + }) + }; + + // Render the primary line + this.renderFormation(this.draftFormation); + + this.channelStep = 1; + + // Notify UI of progress + if (this.onPointsChangedCallback) { + this.onPointsChangedCallback(1, 2); + } + + console.log('FormationOverlay: Channel primary line created at', coords); + + } else if (this.channelStep === 1) { + // STEP 2: Place parallel line at current mouse position + const priceOffset = coords.price - this.channelPrimaryLine.center.price; + + // Create secondary line parallel to primary + const secondaryLine = { + point1: { + time: this.channelPrimaryLine.point1.time, + price: this.channelPrimaryLine.point1.price + priceOffset + }, + point2: { + time: this.channelPrimaryLine.point2.time, + price: this.channelPrimaryLine.point2.price + priceOffset + }, + center: { + time: this.channelPrimaryLine.center.time, + price: this.channelPrimaryLine.center.price + priceOffset + }, + isSecondary: true, // Flag to identify as secondary line + priceOffset: priceOffset // Store offset for parallel constraint + }; + + // Update draft formation with both lines + this.draftFormation = { + tbl_key: this.draftTblKey, + formation_type: 'channel', + color: this.defaultColor, + lines_json: JSON.stringify({ + lines: [this.channelPrimaryLine, secondaryLine] + }) + }; + + // Remove preview line + this._clearChannelPreview(); + + // Render complete channel + this.renderFormation(this.draftFormation); + + this.channelStep = 2; + + // Change cursor back + if (this.container) { + this.container.style.cursor = 'default'; + } + + // Notify that draft is ready + if (this.onPointsChangedCallback) { + this.onPointsChangedCallback(2, 2); + } + if (this.onDraftReadyCallback) { + this.onDraftReadyCallback(); + } + + console.log('FormationOverlay: Channel complete with offset', priceOffset); + } + } + + /** + * Update the parallel preview line for channel drawing. + * @param {Object} coords - Current mouse position {time, price} + */ + _updateChannelPreview(coords) { + if (this.drawingMode !== 'channel' || this.channelStep !== 1 || !this.channelPrimaryLine) { + return; + } + + // Remove existing preview + this._clearChannelPreview(); + + // Calculate price offset from primary line + const priceOffset = coords.price - this.channelPrimaryLine.center.price; + + // Create parallel line preview + const previewPoint1 = { + time: this.channelPrimaryLine.point1.time, + price: this.channelPrimaryLine.point1.price + priceOffset + }; + const previewPoint2 = { + time: this.channelPrimaryLine.point2.time, + price: this.channelPrimaryLine.point2.price + priceOffset + }; + + // Get infinite line endpoints + const endpoints = this._getInfiniteLineEndpoints(previewPoint1, previewPoint2); + if (!endpoints) return; + + // Create dotted preview line + this.channelPreviewLine = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.channelPreviewLine.setAttribute('x1', endpoints.start.x); + this.channelPreviewLine.setAttribute('y1', endpoints.start.y); + this.channelPreviewLine.setAttribute('x2', endpoints.end.x); + this.channelPreviewLine.setAttribute('y2', endpoints.end.y); + this.channelPreviewLine.setAttribute('stroke', this.channelSecondaryColor); + this.channelPreviewLine.setAttribute('stroke-width', 2); + this.channelPreviewLine.setAttribute('stroke-dasharray', '8,4'); + this.channelPreviewLine.style.opacity = '0.7'; + + this.tempGroup.appendChild(this.channelPreviewLine); + } + + /** + * Clear the channel preview line. + */ + _clearChannelPreview() { + if (this.channelPreviewLine) { + this.channelPreviewLine.remove(); + this.channelPreviewLine = null; + } } /** @@ -686,6 +846,8 @@ class FormationOverlay { if (this.draftFormation) { this.removeFormation(this.draftTblKey); } + // Clear channel preview + this._clearChannelPreview(); this._exitDrawingMode(); } @@ -699,6 +861,11 @@ class FormationOverlay { this.draftFormation = null; this.onPointsChangedCallback = null; this.onDraftReadyCallback = null; + + // Reset channel state + this.channelPrimaryLine = null; + this.channelStep = 0; + this._clearChannelPreview(); this._clearTempElements(); // Reset cursor @@ -735,10 +902,15 @@ class FormationOverlay { formationGroup.setAttribute('data-tbl-key', formation.tbl_key); formationGroup.style.pointerEvents = 'all'; + const isChannel = formation.formation_type === 'channel'; + lines.forEach((line, index) => { + // Determine line color (secondary channel line uses different color) + const lineColor = (isChannel && line.isSecondary) ? this.channelSecondaryColor : color; + // 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); + const lineEl = this._createLine(line.point1, line.point2, lineColor, formation.tbl_key); if (lineHitArea) { formationGroup.appendChild(lineHitArea); @@ -755,22 +927,31 @@ class FormationOverlay { 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'); + // For channel secondary line: only 1 center anchor (for offset adjustment) + if (isChannel && line.isSecondary) { + const anchorOffset = this._createAnchor(centerPoint, formation.tbl_key, index, 'channel_offset'); + if (anchorOffset) { + formationGroup.appendChild(anchorOffset); + elements.push(anchorOffset); + } + } else { + // Primary line or single line: 3 anchors + 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) { - formationGroup.appendChild(anchor1); - elements.push(anchor1); - } - if (anchorCenter) { - formationGroup.appendChild(anchorCenter); - elements.push(anchorCenter); - } - if (anchor2) { - formationGroup.appendChild(anchor2); - elements.push(anchor2); + if (anchor1) { + formationGroup.appendChild(anchor1); + elements.push(anchor1); + } + if (anchorCenter) { + formationGroup.appendChild(anchorCenter); + elements.push(anchorCenter); + } + if (anchor2) { + formationGroup.appendChild(anchor2); + elements.push(anchor2); + } } }); @@ -867,7 +1048,7 @@ 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', 'point2', or 'center' + * @param {string} pointKey - 'point1', 'point2', 'center', or 'channel_offset' * @returns {SVGCircleElement|null} */ _createAnchor(point, tblKey, lineIndex, pointKey) { @@ -875,14 +1056,29 @@ class FormationOverlay { if (!pixel) return null; const isCenter = pointKey === 'center'; + const isChannelOffset = pointKey === 'channel_offset'; + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', pixel.x); circle.setAttribute('cy', pixel.y); - circle.setAttribute('r', isCenter ? 8 : 6); // Center anchor slightly larger - circle.setAttribute('fill', isCenter ? '#28a745' : this.defaultColor); // Center is green + + // Anchor styling based on type + if (isChannelOffset) { + circle.setAttribute('r', 7); + circle.setAttribute('fill', this.channelSecondaryColor); // Green for channel offset + circle.setAttribute('cursor', 'ns-resize'); // Vertical resize cursor + } else if (isCenter) { + circle.setAttribute('r', 8); + circle.setAttribute('fill', '#28a745'); // Green for center + circle.setAttribute('cursor', 'grab'); + } else { + circle.setAttribute('r', 6); + circle.setAttribute('fill', this.defaultColor); + circle.setAttribute('cursor', 'crosshair'); + } + circle.setAttribute('stroke', this.anchorColor); circle.setAttribute('stroke-width', 2); - circle.setAttribute('cursor', isCenter ? 'grab' : 'crosshair'); circle.setAttribute('data-tbl-key', tblKey); circle.setAttribute('data-line-index', lineIndex); circle.setAttribute('data-point-key', pointKey); @@ -992,7 +1188,7 @@ 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' + * @param {string} anchorType - 'point1', 'point2', 'center', or 'channel_offset' */ _startDrag(anchor, tblKey, anchorType) { this.isDragging = true; @@ -1008,7 +1204,9 @@ class FormationOverlay { // 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])); + // Store all initial lines for channel support + this._dragInitialLines = JSON.parse(JSON.stringify(linesData.lines)); + this._dragInitialLine = this._dragInitialLines[0]; this._dragStartCoords = { time: parseInt(anchor.getAttribute('data-time'), 10), price: parseFloat(anchor.getAttribute('data-price')) @@ -1017,7 +1215,8 @@ class FormationOverlay { // Visual feedback const isCenter = anchorType === 'center'; - anchor.setAttribute('r', isCenter ? 10 : 8); + const isChannelOffset = anchorType === 'channel_offset'; + anchor.setAttribute('r', (isCenter || isChannelOffset) ? 10 : 8); if (isCenter) { anchor.style.cursor = 'grabbing'; } @@ -1047,50 +1246,101 @@ class FormationOverlay { return; } - if (!linesData.lines || !linesData.lines[lineIndex]) return; + if (!linesData.lines || !linesData.lines[0]) return; - const line = linesData.lines[lineIndex]; - const initialLine = this._dragInitialLine; + const isChannel = this.dragFormation.formation_type === 'channel'; + const primaryLine = linesData.lines[0]; + const secondaryLine = linesData.lines[1]; + const initialPrimary = this._dragInitialLines[0]; + const initialSecondary = this._dragInitialLines[1]; - if (pointKey === 'center') { - // CENTER DRAG: Translate the entire line + if (pointKey === 'channel_offset' && isChannel && secondaryLine) { + // CHANNEL OFFSET DRAG: Move secondary line parallel to primary (price only) + const priceDelta = coords.price - this._dragStartCoords.price; + const currentOffset = (initialSecondary.priceOffset || 0) + priceDelta; + + // Update secondary line position (keep parallel to primary) + secondaryLine.point1 = { + time: primaryLine.point1.time, + price: primaryLine.point1.price + currentOffset + }; + secondaryLine.point2 = { + time: primaryLine.point2.time, + price: primaryLine.point2.price + currentOffset + }; + secondaryLine.center = { + time: primaryLine.center.time, + price: primaryLine.center.price + currentOffset + }; + secondaryLine.priceOffset = currentOffset; + + } else if (pointKey === 'center' && lineIndex === 0) { + // PRIMARY CENTER DRAG: Translate both lines together 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 + // Move primary line + primaryLine.point1 = { + time: initialPrimary.point1.time + timeDelta, + price: initialPrimary.point1.price + priceDelta }; - line.point2 = { - time: initialLine.point2.time + timeDelta, - price: initialLine.point2.price + priceDelta + primaryLine.point2 = { + time: initialPrimary.point2.time + timeDelta, + price: initialPrimary.point2.price + priceDelta }; - line.center = { - time: coords.time, - price: coords.price - }; - } else if (pointKey === 'point1') { - // POINT1 DRAG: Pivot around point2 (opposite end) - line.point1 = { + primaryLine.center = { time: coords.time, price: coords.price }; + + // Move secondary line if channel + if (isChannel && secondaryLine && initialSecondary) { + const offset = initialSecondary.priceOffset || (initialSecondary.center.price - initialPrimary.center.price); + secondaryLine.point1 = { + time: primaryLine.point1.time, + price: primaryLine.point1.price + offset + }; + secondaryLine.point2 = { + time: primaryLine.point2.time, + price: primaryLine.point2.price + offset + }; + secondaryLine.center = { + time: primaryLine.center.time, + price: primaryLine.center.price + offset + }; + secondaryLine.priceOffset = offset; + } + + } else if ((pointKey === 'point1' || pointKey === 'point2') && lineIndex === 0) { + // PRIMARY ENDPOINT DRAG: Pivot primary line, keep secondary parallel + if (pointKey === 'point1') { + primaryLine.point1 = { time: coords.time, price: coords.price }; + } else { + primaryLine.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 - }; - } 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 + primaryLine.center = { + time: Math.floor((primaryLine.point1.time + primaryLine.point2.time) / 2), + price: (primaryLine.point1.price + primaryLine.point2.price) / 2 }; + + // Update secondary line to stay parallel if channel + if (isChannel && secondaryLine && initialSecondary) { + const offset = secondaryLine.priceOffset || (initialSecondary.center.price - initialPrimary.center.price); + secondaryLine.point1 = { + time: primaryLine.point1.time, + price: primaryLine.point1.price + offset + }; + secondaryLine.point2 = { + time: primaryLine.point2.time, + price: primaryLine.point2.price + offset + }; + secondaryLine.center = { + time: primaryLine.center.time, + price: primaryLine.center.price + offset + }; + } } // Update formation data @@ -1106,7 +1356,8 @@ class FormationOverlay { _endDrag() { if (this.dragAnchor) { const isCenter = this.dragAnchorType === 'center'; - this.dragAnchor.setAttribute('r', isCenter ? 8 : 6); + const isChannelOffset = this.dragAnchorType === 'channel_offset'; + this.dragAnchor.setAttribute('r', isChannelOffset ? 7 : (isCenter ? 8 : 6)); if (isCenter) { this.dragAnchor.style.cursor = 'grab'; } @@ -1125,6 +1376,7 @@ class FormationOverlay { this.dragFormation = null; this.dragAnchorType = null; this._dragInitialLine = null; + this._dragInitialLines = null; this._dragStartCoords = null; document.body.style.userSelect = ''; diff --git a/src/static/formations.js b/src/static/formations.js index 4ab0359..e27fe0d 100644 --- a/src/static/formations.js +++ b/src/static/formations.js @@ -65,18 +65,23 @@ class FormationsUIManager { // Set instruction text based on type const instructions = { '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)' + 'channel': 'Click to place first line, then click to place parallel line.' }; if (this.instructionTextEl) { this.instructionTextEl.textContent = instructions[type] || 'Click on chart to place points'; } - // Update points status (for lines, show "Click to place" instead of counter) + // Update points status (for single-click types, show descriptive text) if (type === 'support_resistance') { if (this.pointsStatusEl) { this.pointsStatusEl.textContent = 'Click anywhere on the chart'; this.pointsStatusEl.style.color = '#667eea'; } + } else if (type === 'channel') { + if (this.pointsStatusEl) { + this.pointsStatusEl.textContent = 'Step 1: Click to place primary line'; + this.pointsStatusEl.style.color = '#667eea'; + } } else { this.updatePointsStatus(0, pointsNeeded); } @@ -526,7 +531,7 @@ class Formations { _getPointsNeeded(type) { const pointsMap = { 'support_resistance': 1, // Single click creates line with 3 anchors - 'channel': 3 + 'channel': 2 // Two clicks: primary line + parallel line placement }; return pointsMap[type] || 2; } @@ -558,12 +563,23 @@ class Formations { * @param {number} pointsNeeded - Points needed for completion */ _onPointsChanged(currentPoints, pointsNeeded) { - // For line drawing, the name input is shown via _onDraftReady instead + // For single-click formations, use _onDraftReady instead if (this.drawingMode === 'support_resistance') { return; } - // Update the UI status for multi-point drawings + // For channel, update status based on step + if (this.drawingMode === 'channel') { + if (this.uiManager.pointsStatusEl) { + if (currentPoints === 1) { + this.uiManager.pointsStatusEl.textContent = 'Step 2: Move mouse and click to place parallel line'; + this.uiManager.pointsStatusEl.style.color = '#667eea'; + } + } + return; + } + + // Update the UI status for other multi-point drawings this.uiManager.updatePointsStatus(currentPoints, pointsNeeded); // If we have enough points, show the name input @@ -573,12 +589,16 @@ class Formations { } /** - * Called when a draft formation is ready (line placed, can be named). + * Called when a draft formation is ready (line/channel placed, can be named). */ _onDraftReady() { - // Update status to show line is ready + // Update status based on formation type if (this.uiManager.pointsStatusEl) { - this.uiManager.pointsStatusEl.textContent = 'Line placed! Drag anchors to adjust.'; + if (this.drawingMode === 'channel') { + this.uiManager.pointsStatusEl.textContent = 'Channel placed! Drag anchors to adjust.'; + } else { + this.uiManager.pointsStatusEl.textContent = 'Line placed! Drag anchors to adjust.'; + } this.uiManager.pointsStatusEl.style.color = '#28a745'; }