Add channel drawing UX with parallel line placement
Channel drawing now works like line drawing: - First click places primary horizontal line (3 anchors) - Mouse move shows dotted parallel preview line - Second click places parallel line (1 center anchor) Channel anchor behavior: - Primary line center anchor: moves both lines together - Primary line end anchors: pivots both lines (stays parallel) - Secondary line center anchor: adjusts distance to primary Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
53797c2f39
commit
d7c8d42905
|
|
@ -9,6 +9,13 @@
|
||||||
* - Center anchor: drag to move line without changing angle
|
* - Center anchor: drag to move line without changing angle
|
||||||
* - End anchors: drag to pivot from opposite end (change angle)
|
* - End anchors: drag to pivot from opposite end (change angle)
|
||||||
* - Hover over any part of line shows all anchors
|
* - 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 {
|
class FormationOverlay {
|
||||||
constructor(chartContainerId, chart, candleSeries) {
|
constructor(chartContainerId, chart, candleSeries) {
|
||||||
|
|
@ -29,6 +36,11 @@ class FormationOverlay {
|
||||||
this.draftFormation = null;
|
this.draftFormation = null;
|
||||||
this.draftTblKey = '__draft__';
|
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<tbl_key, {formation, elements: SVGElement[], lineGroup: SVGGElement}>
|
// Rendered formations: Map<tbl_key, {formation, elements: SVGElement[], lineGroup: SVGGElement}>
|
||||||
this.renderedFormations = new Map();
|
this.renderedFormations = new Map();
|
||||||
|
|
||||||
|
|
@ -39,7 +51,7 @@ class FormationOverlay {
|
||||||
this.isDragging = false;
|
this.isDragging = false;
|
||||||
this.dragAnchor = null;
|
this.dragAnchor = null;
|
||||||
this.dragFormation = null;
|
this.dragFormation = null;
|
||||||
this.dragAnchorType = null; // 'center', 'point1', 'point2'
|
this.dragAnchorType = null; // 'center', 'point1', 'point2', 'channel_offset'
|
||||||
|
|
||||||
// RAF loop state
|
// RAF loop state
|
||||||
this._loopRunning = false;
|
this._loopRunning = false;
|
||||||
|
|
@ -65,6 +77,7 @@ class FormationOverlay {
|
||||||
this.defaultColor = '#667eea';
|
this.defaultColor = '#667eea';
|
||||||
this.selectedColor = '#ff9500';
|
this.selectedColor = '#ff9500';
|
||||||
this.anchorColor = '#ffffff';
|
this.anchorColor = '#ffffff';
|
||||||
|
this.channelSecondaryColor = '#48bb78'; // Green for secondary line
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
this._createSVGLayer();
|
this._createSVGLayer();
|
||||||
|
|
@ -221,20 +234,21 @@ class FormationOverlay {
|
||||||
|
|
||||||
// Mouse move for temp line preview
|
// Mouse move for temp line preview
|
||||||
this.container.addEventListener('mousemove', (e) => {
|
this.container.addEventListener('mousemove', (e) => {
|
||||||
if (this.drawingMode && this.currentPoints.length > 0) {
|
|
||||||
const coords = this._pixelToChart(e.offsetX, e.offsetY);
|
const coords = this._pixelToChart(e.offsetX, e.offsetY);
|
||||||
if (coords) {
|
|
||||||
this._updateTempLine(coords);
|
// 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
|
// Handle dragging
|
||||||
if (this.isDragging && this.dragAnchor && this.dragFormation) {
|
if (this.isDragging && this.dragAnchor && this.dragFormation && coords) {
|
||||||
const coords = this._pixelToChart(e.offsetX, e.offsetY);
|
|
||||||
if (coords) {
|
|
||||||
this._handleDrag(coords);
|
this._handleDrag(coords);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mouse up to end dragging
|
// Mouse up to end dragging
|
||||||
|
|
@ -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}
|
* @param {Object} coords - {time, price}
|
||||||
*/
|
*/
|
||||||
_handleChannelDrawingClick(coords) {
|
_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) {
|
if (this.draftFormation) {
|
||||||
this.removeFormation(this.draftTblKey);
|
this.removeFormation(this.draftTblKey);
|
||||||
}
|
}
|
||||||
|
// Clear channel preview
|
||||||
|
this._clearChannelPreview();
|
||||||
this._exitDrawingMode();
|
this._exitDrawingMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -699,6 +861,11 @@ class FormationOverlay {
|
||||||
this.draftFormation = null;
|
this.draftFormation = null;
|
||||||
this.onPointsChangedCallback = null;
|
this.onPointsChangedCallback = null;
|
||||||
this.onDraftReadyCallback = null;
|
this.onDraftReadyCallback = null;
|
||||||
|
|
||||||
|
// Reset channel state
|
||||||
|
this.channelPrimaryLine = null;
|
||||||
|
this.channelStep = 0;
|
||||||
|
this._clearChannelPreview();
|
||||||
this._clearTempElements();
|
this._clearTempElements();
|
||||||
|
|
||||||
// Reset cursor
|
// Reset cursor
|
||||||
|
|
@ -735,10 +902,15 @@ class FormationOverlay {
|
||||||
formationGroup.setAttribute('data-tbl-key', formation.tbl_key);
|
formationGroup.setAttribute('data-tbl-key', formation.tbl_key);
|
||||||
formationGroup.style.pointerEvents = 'all';
|
formationGroup.style.pointerEvents = 'all';
|
||||||
|
|
||||||
|
const isChannel = formation.formation_type === 'channel';
|
||||||
|
|
||||||
lines.forEach((line, index) => {
|
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
|
// Draw the infinite line with a wider invisible hit area
|
||||||
const lineHitArea = this._createLineHitArea(line.point1, line.point2, formation.tbl_key);
|
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) {
|
if (lineHitArea) {
|
||||||
formationGroup.appendChild(lineHitArea);
|
formationGroup.appendChild(lineHitArea);
|
||||||
|
|
@ -755,7 +927,15 @@ class FormationOverlay {
|
||||||
price: (line.point1.price + line.point2.price) / 2
|
price: (line.point1.price + line.point2.price) / 2
|
||||||
};
|
};
|
||||||
|
|
||||||
// Draw anchor points (including center for 3-anchor control)
|
// 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 anchor1 = this._createAnchor(line.point1, formation.tbl_key, index, 'point1');
|
||||||
const anchorCenter = this._createAnchor(centerPoint, formation.tbl_key, index, 'center');
|
const anchorCenter = this._createAnchor(centerPoint, formation.tbl_key, index, 'center');
|
||||||
const anchor2 = this._createAnchor(line.point2, formation.tbl_key, index, 'point2');
|
const anchor2 = this._createAnchor(line.point2, formation.tbl_key, index, 'point2');
|
||||||
|
|
@ -772,6 +952,7 @@ class FormationOverlay {
|
||||||
formationGroup.appendChild(anchor2);
|
formationGroup.appendChild(anchor2);
|
||||||
elements.push(anchor2);
|
elements.push(anchor2);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add hover behavior to show/hide all anchors in the group
|
// Add hover behavior to show/hide all anchors in the group
|
||||||
|
|
@ -867,7 +1048,7 @@ class FormationOverlay {
|
||||||
* @param {Object} point - {time, price}
|
* @param {Object} point - {time, price}
|
||||||
* @param {string} tblKey - Formation tbl_key
|
* @param {string} tblKey - Formation tbl_key
|
||||||
* @param {number} lineIndex - Index of line in formation
|
* @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}
|
* @returns {SVGCircleElement|null}
|
||||||
*/
|
*/
|
||||||
_createAnchor(point, tblKey, lineIndex, pointKey) {
|
_createAnchor(point, tblKey, lineIndex, pointKey) {
|
||||||
|
|
@ -875,14 +1056,29 @@ class FormationOverlay {
|
||||||
if (!pixel) return null;
|
if (!pixel) return null;
|
||||||
|
|
||||||
const isCenter = pointKey === 'center';
|
const isCenter = pointKey === 'center';
|
||||||
|
const isChannelOffset = pointKey === 'channel_offset';
|
||||||
|
|
||||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||||
circle.setAttribute('cx', pixel.x);
|
circle.setAttribute('cx', pixel.x);
|
||||||
circle.setAttribute('cy', pixel.y);
|
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', this.anchorColor);
|
||||||
circle.setAttribute('stroke-width', 2);
|
circle.setAttribute('stroke-width', 2);
|
||||||
circle.setAttribute('cursor', isCenter ? 'grab' : 'crosshair');
|
|
||||||
circle.setAttribute('data-tbl-key', tblKey);
|
circle.setAttribute('data-tbl-key', tblKey);
|
||||||
circle.setAttribute('data-line-index', lineIndex);
|
circle.setAttribute('data-line-index', lineIndex);
|
||||||
circle.setAttribute('data-point-key', pointKey);
|
circle.setAttribute('data-point-key', pointKey);
|
||||||
|
|
@ -992,7 +1188,7 @@ class FormationOverlay {
|
||||||
* Start dragging an anchor.
|
* Start dragging an anchor.
|
||||||
* @param {SVGCircleElement} anchor - The anchor element
|
* @param {SVGCircleElement} anchor - The anchor element
|
||||||
* @param {string} tblKey - Formation tbl_key
|
* @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) {
|
_startDrag(anchor, tblKey, anchorType) {
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
|
|
@ -1008,7 +1204,9 @@ class FormationOverlay {
|
||||||
// Store initial drag state for relative calculations
|
// Store initial drag state for relative calculations
|
||||||
const linesData = JSON.parse(this.dragFormation.lines_json || '{}');
|
const linesData = JSON.parse(this.dragFormation.lines_json || '{}');
|
||||||
if (linesData.lines && linesData.lines[0]) {
|
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 = {
|
this._dragStartCoords = {
|
||||||
time: parseInt(anchor.getAttribute('data-time'), 10),
|
time: parseInt(anchor.getAttribute('data-time'), 10),
|
||||||
price: parseFloat(anchor.getAttribute('data-price'))
|
price: parseFloat(anchor.getAttribute('data-price'))
|
||||||
|
|
@ -1017,7 +1215,8 @@ class FormationOverlay {
|
||||||
|
|
||||||
// Visual feedback
|
// Visual feedback
|
||||||
const isCenter = anchorType === 'center';
|
const isCenter = anchorType === 'center';
|
||||||
anchor.setAttribute('r', isCenter ? 10 : 8);
|
const isChannelOffset = anchorType === 'channel_offset';
|
||||||
|
anchor.setAttribute('r', (isCenter || isChannelOffset) ? 10 : 8);
|
||||||
if (isCenter) {
|
if (isCenter) {
|
||||||
anchor.style.cursor = 'grabbing';
|
anchor.style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
|
|
@ -1047,50 +1246,101 @@ class FormationOverlay {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!linesData.lines || !linesData.lines[lineIndex]) return;
|
if (!linesData.lines || !linesData.lines[0]) return;
|
||||||
|
|
||||||
const line = linesData.lines[lineIndex];
|
const isChannel = this.dragFormation.formation_type === 'channel';
|
||||||
const initialLine = this._dragInitialLine;
|
const primaryLine = linesData.lines[0];
|
||||||
|
const secondaryLine = linesData.lines[1];
|
||||||
|
const initialPrimary = this._dragInitialLines[0];
|
||||||
|
const initialSecondary = this._dragInitialLines[1];
|
||||||
|
|
||||||
if (pointKey === 'center') {
|
if (pointKey === 'channel_offset' && isChannel && secondaryLine) {
|
||||||
// CENTER DRAG: Translate the entire line
|
// 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 timeDelta = coords.time - this._dragStartCoords.time;
|
||||||
const priceDelta = coords.price - this._dragStartCoords.price;
|
const priceDelta = coords.price - this._dragStartCoords.price;
|
||||||
|
|
||||||
line.point1 = {
|
// Move primary line
|
||||||
time: initialLine.point1.time + timeDelta,
|
primaryLine.point1 = {
|
||||||
price: initialLine.point1.price + priceDelta
|
time: initialPrimary.point1.time + timeDelta,
|
||||||
|
price: initialPrimary.point1.price + priceDelta
|
||||||
};
|
};
|
||||||
line.point2 = {
|
primaryLine.point2 = {
|
||||||
time: initialLine.point2.time + timeDelta,
|
time: initialPrimary.point2.time + timeDelta,
|
||||||
price: initialLine.point2.price + priceDelta
|
price: initialPrimary.point2.price + priceDelta
|
||||||
};
|
};
|
||||||
line.center = {
|
primaryLine.center = {
|
||||||
time: coords.time,
|
time: coords.time,
|
||||||
price: coords.price
|
price: coords.price
|
||||||
};
|
};
|
||||||
} else if (pointKey === 'point1') {
|
|
||||||
// POINT1 DRAG: Pivot around point2 (opposite end)
|
// Move secondary line if channel
|
||||||
line.point1 = {
|
if (isChannel && secondaryLine && initialSecondary) {
|
||||||
time: coords.time,
|
const offset = initialSecondary.priceOffset || (initialSecondary.center.price - initialPrimary.center.price);
|
||||||
price: coords.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
|
// Recalculate center as midpoint
|
||||||
line.center = {
|
primaryLine.center = {
|
||||||
time: Math.floor((line.point1.time + line.point2.time) / 2),
|
time: Math.floor((primaryLine.point1.time + primaryLine.point2.time) / 2),
|
||||||
price: (line.point1.price + line.point2.price) / 2
|
price: (primaryLine.point1.price + primaryLine.point2.price) / 2
|
||||||
};
|
};
|
||||||
} else if (pointKey === 'point2') {
|
|
||||||
// POINT2 DRAG: Pivot around point1 (opposite end)
|
// Update secondary line to stay parallel if channel
|
||||||
line.point2 = {
|
if (isChannel && secondaryLine && initialSecondary) {
|
||||||
time: coords.time,
|
const offset = secondaryLine.priceOffset || (initialSecondary.center.price - initialPrimary.center.price);
|
||||||
price: coords.price
|
secondaryLine.point1 = {
|
||||||
|
time: primaryLine.point1.time,
|
||||||
|
price: primaryLine.point1.price + offset
|
||||||
};
|
};
|
||||||
// Recalculate center as midpoint
|
secondaryLine.point2 = {
|
||||||
line.center = {
|
time: primaryLine.point2.time,
|
||||||
time: Math.floor((line.point1.time + line.point2.time) / 2),
|
price: primaryLine.point2.price + offset
|
||||||
price: (line.point1.price + line.point2.price) / 2
|
|
||||||
};
|
};
|
||||||
|
secondaryLine.center = {
|
||||||
|
time: primaryLine.center.time,
|
||||||
|
price: primaryLine.center.price + offset
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update formation data
|
// Update formation data
|
||||||
|
|
@ -1106,7 +1356,8 @@ class FormationOverlay {
|
||||||
_endDrag() {
|
_endDrag() {
|
||||||
if (this.dragAnchor) {
|
if (this.dragAnchor) {
|
||||||
const isCenter = this.dragAnchorType === 'center';
|
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) {
|
if (isCenter) {
|
||||||
this.dragAnchor.style.cursor = 'grab';
|
this.dragAnchor.style.cursor = 'grab';
|
||||||
}
|
}
|
||||||
|
|
@ -1125,6 +1376,7 @@ class FormationOverlay {
|
||||||
this.dragFormation = null;
|
this.dragFormation = null;
|
||||||
this.dragAnchorType = null;
|
this.dragAnchorType = null;
|
||||||
this._dragInitialLine = null;
|
this._dragInitialLine = null;
|
||||||
|
this._dragInitialLines = null;
|
||||||
this._dragStartCoords = null;
|
this._dragStartCoords = null;
|
||||||
|
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
|
|
|
||||||
|
|
@ -65,18 +65,23 @@ class FormationsUIManager {
|
||||||
// Set instruction text based on type
|
// Set instruction text based on type
|
||||||
const instructions = {
|
const instructions = {
|
||||||
'support_resistance': 'Click on chart to place a line. Drag anchors to adjust.',
|
'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) {
|
if (this.instructionTextEl) {
|
||||||
this.instructionTextEl.textContent = instructions[type] || 'Click on chart to place points';
|
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 (type === 'support_resistance') {
|
||||||
if (this.pointsStatusEl) {
|
if (this.pointsStatusEl) {
|
||||||
this.pointsStatusEl.textContent = 'Click anywhere on the chart';
|
this.pointsStatusEl.textContent = 'Click anywhere on the chart';
|
||||||
this.pointsStatusEl.style.color = '#667eea';
|
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 {
|
} else {
|
||||||
this.updatePointsStatus(0, pointsNeeded);
|
this.updatePointsStatus(0, pointsNeeded);
|
||||||
}
|
}
|
||||||
|
|
@ -526,7 +531,7 @@ class Formations {
|
||||||
_getPointsNeeded(type) {
|
_getPointsNeeded(type) {
|
||||||
const pointsMap = {
|
const pointsMap = {
|
||||||
'support_resistance': 1, // Single click creates line with 3 anchors
|
'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;
|
return pointsMap[type] || 2;
|
||||||
}
|
}
|
||||||
|
|
@ -558,12 +563,23 @@ class Formations {
|
||||||
* @param {number} pointsNeeded - Points needed for completion
|
* @param {number} pointsNeeded - Points needed for completion
|
||||||
*/
|
*/
|
||||||
_onPointsChanged(currentPoints, pointsNeeded) {
|
_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') {
|
if (this.drawingMode === 'support_resistance') {
|
||||||
return;
|
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);
|
this.uiManager.updatePointsStatus(currentPoints, pointsNeeded);
|
||||||
|
|
||||||
// If we have enough points, show the name input
|
// 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() {
|
_onDraftReady() {
|
||||||
// Update status to show line is ready
|
// Update status based on formation type
|
||||||
if (this.uiManager.pointsStatusEl) {
|
if (this.uiManager.pointsStatusEl) {
|
||||||
|
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.textContent = 'Line placed! Drag anchors to adjust.';
|
||||||
|
}
|
||||||
this.uiManager.pointsStatusEl.style.color = '#28a745';
|
this.uiManager.pointsStatusEl.style.color = '#28a745';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue