Improve line drawing UX with single-click placement and 3-anchor control
- Single click places a solid horizontal line with 3 anchors - Center anchor (green): drag to move entire line without changing angle - End anchors (blue): drag to pivot from opposite end, changing angle - Hovering over any part of the line shows all anchors - Name input appears immediately after line is placed - Line can be adjusted before saving Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
ac4c085acd
commit
53797c2f39
|
|
@ -3,6 +3,12 @@
|
||||||
*
|
*
|
||||||
* Uses SVG layer positioned over the chart container.
|
* Uses SVG layer positioned over the chart container.
|
||||||
* Syncs with chart via requestAnimationFrame polling (not event subscriptions).
|
* 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 {
|
class FormationOverlay {
|
||||||
constructor(chartContainerId, chart, candleSeries) {
|
constructor(chartContainerId, chart, candleSeries) {
|
||||||
|
|
@ -19,7 +25,11 @@ class FormationOverlay {
|
||||||
this.currentPoints = [];
|
this.currentPoints = [];
|
||||||
this.tempLine = null;
|
this.tempLine = null;
|
||||||
|
|
||||||
// Rendered formations: Map<tbl_key, {formation, elements: SVGElement[]}>
|
// Draft formation (created on first click, not yet saved)
|
||||||
|
this.draftFormation = null;
|
||||||
|
this.draftTblKey = '__draft__';
|
||||||
|
|
||||||
|
// Rendered formations: Map<tbl_key, {formation, elements: SVGElement[], lineGroup: SVGGElement}>
|
||||||
this.renderedFormations = new Map();
|
this.renderedFormations = new Map();
|
||||||
|
|
||||||
// Selected formation for editing
|
// Selected formation for editing
|
||||||
|
|
@ -29,6 +39,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'
|
||||||
|
|
||||||
// RAF loop state
|
// RAF loop state
|
||||||
this._loopRunning = false;
|
this._loopRunning = false;
|
||||||
|
|
@ -38,12 +49,18 @@ class FormationOverlay {
|
||||||
// Callback for saving formations
|
// Callback for saving formations
|
||||||
this.onSaveCallback = null;
|
this.onSaveCallback = null;
|
||||||
|
|
||||||
// Callback for when points change during drawing
|
// Callback for when points change during drawing (for UI updates)
|
||||||
this.onPointsChangedCallback = null;
|
this.onPointsChangedCallback = null;
|
||||||
|
|
||||||
|
// Callback for when draft is ready to save (name input can appear)
|
||||||
|
this.onDraftReadyCallback = null;
|
||||||
|
|
||||||
// Track points needed for current drawing
|
// Track points needed for current drawing
|
||||||
this._pointsNeeded = 0;
|
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
|
// Colors
|
||||||
this.defaultColor = '#667eea';
|
this.defaultColor = '#667eea';
|
||||||
this.selectedColor = '#ff9500';
|
this.selectedColor = '#ff9500';
|
||||||
|
|
@ -71,6 +88,14 @@ class FormationOverlay {
|
||||||
this.onPointsChangedCallback = callback;
|
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.
|
* Create the SVG overlay layer.
|
||||||
*/
|
*/
|
||||||
|
|
@ -375,6 +400,7 @@ class FormationOverlay {
|
||||||
startDrawing(type) {
|
startDrawing(type) {
|
||||||
this.drawingMode = type;
|
this.drawingMode = type;
|
||||||
this.currentPoints = [];
|
this.currentPoints = [];
|
||||||
|
this.draftFormation = null;
|
||||||
this._pointsNeeded = this._getPointsNeeded(type);
|
this._pointsNeeded = this._getPointsNeeded(type);
|
||||||
this._clearTempElements();
|
this._clearTempElements();
|
||||||
|
|
||||||
|
|
@ -388,12 +414,13 @@ class FormationOverlay {
|
||||||
this.svg.style.pointerEvents = 'all';
|
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) {
|
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}
|
* @param {Object} coords - {time, price}
|
||||||
*/
|
*/
|
||||||
_handleDrawingClick(coords) {
|
_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
|
// Don't accept more points than needed
|
||||||
if (this.currentPoints.length >= this._pointsNeeded) {
|
if (this.currentPoints.length >= this._pointsNeeded) {
|
||||||
console.log('FormationOverlay: Already have enough points');
|
console.log('FormationOverlay: Already have enough points');
|
||||||
|
|
@ -509,21 +610,33 @@ class FormationOverlay {
|
||||||
* @param {string} name - Formation name
|
* @param {string} name - Formation name
|
||||||
*/
|
*/
|
||||||
completeDrawing(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');
|
console.warn('FormationOverlay: Not enough points to complete drawing');
|
||||||
return;
|
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
|
// Call save callback
|
||||||
if (this.onSaveCallback) {
|
if (this.onSaveCallback) {
|
||||||
this.onSaveCallback(formationData);
|
this.onSaveCallback(formationData);
|
||||||
|
|
@ -569,6 +682,10 @@ class FormationOverlay {
|
||||||
* Cancel the current drawing.
|
* Cancel the current drawing.
|
||||||
*/
|
*/
|
||||||
cancelDrawing() {
|
cancelDrawing() {
|
||||||
|
// Remove draft formation if exists
|
||||||
|
if (this.draftFormation) {
|
||||||
|
this.removeFormation(this.draftTblKey);
|
||||||
|
}
|
||||||
this._exitDrawingMode();
|
this._exitDrawingMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -579,7 +696,9 @@ class FormationOverlay {
|
||||||
this.drawingMode = null;
|
this.drawingMode = null;
|
||||||
this.currentPoints = [];
|
this.currentPoints = [];
|
||||||
this._pointsNeeded = 0;
|
this._pointsNeeded = 0;
|
||||||
|
this.draftFormation = null;
|
||||||
this.onPointsChangedCallback = null;
|
this.onPointsChangedCallback = null;
|
||||||
|
this.onDraftReadyCallback = null;
|
||||||
this._clearTempElements();
|
this._clearTempElements();
|
||||||
|
|
||||||
// Reset cursor
|
// Reset cursor
|
||||||
|
|
@ -610,34 +729,111 @@ class FormationOverlay {
|
||||||
const color = formation.color || this.defaultColor;
|
const color = formation.color || this.defaultColor;
|
||||||
const elements = [];
|
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) => {
|
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);
|
const lineEl = this._createLine(line.point1, line.point2, color, formation.tbl_key);
|
||||||
|
|
||||||
|
if (lineHitArea) {
|
||||||
|
formationGroup.appendChild(lineHitArea);
|
||||||
|
elements.push(lineHitArea);
|
||||||
|
}
|
||||||
if (lineEl) {
|
if (lineEl) {
|
||||||
this.linesGroup.appendChild(lineEl);
|
formationGroup.appendChild(lineEl);
|
||||||
elements.push(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 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');
|
const anchor2 = this._createAnchor(line.point2, formation.tbl_key, index, 'point2');
|
||||||
|
|
||||||
if (anchor1) {
|
if (anchor1) {
|
||||||
this.anchorsGroup.appendChild(anchor1);
|
formationGroup.appendChild(anchor1);
|
||||||
elements.push(anchor1);
|
elements.push(anchor1);
|
||||||
}
|
}
|
||||||
|
if (anchorCenter) {
|
||||||
|
formationGroup.appendChild(anchorCenter);
|
||||||
|
elements.push(anchorCenter);
|
||||||
|
}
|
||||||
if (anchor2) {
|
if (anchor2) {
|
||||||
this.anchorsGroup.appendChild(anchor2);
|
formationGroup.appendChild(anchor2);
|
||||||
elements.push(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, {
|
this.renderedFormations.set(formation.tbl_key, {
|
||||||
formation: formation,
|
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.
|
* Create an SVG line element.
|
||||||
* @param {Object} point1 - {time, price}
|
* @param {Object} point1 - {time, price}
|
||||||
|
|
@ -671,45 +867,36 @@ 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' or 'point2'
|
* @param {string} pointKey - 'point1', 'point2', or 'center'
|
||||||
* @returns {SVGCircleElement|null}
|
* @returns {SVGCircleElement|null}
|
||||||
*/
|
*/
|
||||||
_createAnchor(point, tblKey, lineIndex, pointKey) {
|
_createAnchor(point, tblKey, lineIndex, pointKey) {
|
||||||
const pixel = this._chartToPixel(point.time, point.price);
|
const pixel = this._chartToPixel(point.time, point.price);
|
||||||
if (!pixel) return null;
|
if (!pixel) return null;
|
||||||
|
|
||||||
|
const isCenter = pointKey === 'center';
|
||||||
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', 6);
|
circle.setAttribute('r', isCenter ? 8 : 6); // Center anchor slightly larger
|
||||||
circle.setAttribute('fill', this.defaultColor);
|
circle.setAttribute('fill', isCenter ? '#28a745' : this.defaultColor); // Center is green
|
||||||
circle.setAttribute('stroke', this.anchorColor);
|
circle.setAttribute('stroke', this.anchorColor);
|
||||||
circle.setAttribute('stroke-width', 2);
|
circle.setAttribute('stroke-width', 2);
|
||||||
circle.setAttribute('cursor', 'move');
|
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);
|
||||||
circle.setAttribute('data-time', point.time);
|
circle.setAttribute('data-time', point.time);
|
||||||
circle.setAttribute('data-price', point.price);
|
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.opacity = '0';
|
||||||
circle.style.transition = 'opacity 0.2s';
|
circle.style.transition = 'opacity 0.2s';
|
||||||
|
|
||||||
// Anchor event listeners
|
// Mouse down to start dragging
|
||||||
circle.addEventListener('mouseenter', () => {
|
|
||||||
circle.style.opacity = '1';
|
|
||||||
});
|
|
||||||
|
|
||||||
circle.addEventListener('mouseleave', () => {
|
|
||||||
if (!this.isDragging) {
|
|
||||||
circle.style.opacity = '0';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
circle.addEventListener('mousedown', (e) => {
|
circle.addEventListener('mousedown', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this._startDrag(circle, tblKey);
|
this._startDrag(circle, tblKey, pointKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
return circle;
|
return circle;
|
||||||
|
|
@ -805,18 +992,35 @@ 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'
|
||||||
*/
|
*/
|
||||||
_startDrag(anchor, tblKey) {
|
_startDrag(anchor, tblKey, anchorType) {
|
||||||
this.isDragging = true;
|
this.isDragging = true;
|
||||||
this.dragAnchor = anchor;
|
this.dragAnchor = anchor;
|
||||||
|
this.dragAnchorType = anchorType;
|
||||||
|
|
||||||
const data = this.renderedFormations.get(tblKey);
|
const data = this.renderedFormations.get(tblKey);
|
||||||
if (data) {
|
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
|
// 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
|
// Prevent text selection during drag
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
|
|
@ -827,10 +1031,10 @@ class FormationOverlay {
|
||||||
* @param {Object} coords - New position {time, price}
|
* @param {Object} coords - New position {time, price}
|
||||||
*/
|
*/
|
||||||
_handleDrag(coords) {
|
_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 lineIndex = parseInt(this.dragAnchor.getAttribute('data-line-index'), 10);
|
||||||
const pointKey = this.dragAnchor.getAttribute('data-point-key');
|
const pointKey = this.dragAnchorType;
|
||||||
|
|
||||||
// Null guards
|
// Null guards
|
||||||
if (isNaN(lineIndex) || !pointKey) return;
|
if (isNaN(lineIndex) || !pointKey) return;
|
||||||
|
|
@ -845,24 +1049,53 @@ class FormationOverlay {
|
||||||
|
|
||||||
if (!linesData.lines || !linesData.lines[lineIndex]) return;
|
if (!linesData.lines || !linesData.lines[lineIndex]) return;
|
||||||
|
|
||||||
// Update the point
|
const line = linesData.lines[lineIndex];
|
||||||
linesData.lines[lineIndex][pointKey] = {
|
const initialLine = this._dragInitialLine;
|
||||||
time: coords.time,
|
|
||||||
price: coords.price
|
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
|
// Update formation data
|
||||||
this.dragFormation.lines_json = JSON.stringify(linesData);
|
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
|
// Re-render the formation
|
||||||
this.renderFormation(this.dragFormation);
|
this.renderFormation(this.dragFormation);
|
||||||
}
|
}
|
||||||
|
|
@ -872,15 +1105,27 @@ class FormationOverlay {
|
||||||
*/
|
*/
|
||||||
_endDrag() {
|
_endDrag() {
|
||||||
if (this.dragAnchor) {
|
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
|
// Update the stored formation data
|
||||||
// (This would typically emit an update event)
|
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.isDragging = false;
|
||||||
this.dragAnchor = null;
|
this.dragAnchor = null;
|
||||||
this.dragFormation = null;
|
this.dragFormation = null;
|
||||||
|
this.dragAnchorType = null;
|
||||||
|
this._dragInitialLine = null;
|
||||||
|
this._dragStartCoords = null;
|
||||||
|
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,15 +64,22 @@ class FormationsUIManager {
|
||||||
|
|
||||||
// Set instruction text based on type
|
// Set instruction text based on type
|
||||||
const instructions = {
|
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)'
|
'channel': 'Click 3 points: first line (2 pts) + parallel offset (1 pt)'
|
||||||
};
|
};
|
||||||
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
|
// Update points status (for lines, show "Click to place" instead of counter)
|
||||||
this.updatePointsStatus(0, pointsNeeded);
|
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) {
|
_getPointsNeeded(type) {
|
||||||
const pointsMap = {
|
const pointsMap = {
|
||||||
'support_resistance': 2,
|
'support_resistance': 1, // Single click creates line with 3 anchors
|
||||||
'channel': 3
|
'channel': 3
|
||||||
};
|
};
|
||||||
return pointsMap[type] || 2;
|
return pointsMap[type] || 2;
|
||||||
|
|
@ -537,9 +544,10 @@ class Formations {
|
||||||
// Show drawing instructions (not name input yet)
|
// Show drawing instructions (not name input yet)
|
||||||
this.uiManager.showDrawingInstructions(type, pointsNeeded);
|
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) {
|
if (this.overlay) {
|
||||||
this.overlay.setOnPointsChangedCallback(this._onPointsChanged.bind(this));
|
this.overlay.setOnPointsChangedCallback(this._onPointsChanged.bind(this));
|
||||||
|
this.overlay.setOnDraftReadyCallback(this._onDraftReady.bind(this));
|
||||||
this.overlay.startDrawing(type);
|
this.overlay.startDrawing(type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -550,7 +558,12 @@ class Formations {
|
||||||
* @param {number} pointsNeeded - Points needed for completion
|
* @param {number} pointsNeeded - Points needed for completion
|
||||||
*/
|
*/
|
||||||
_onPointsChanged(currentPoints, pointsNeeded) {
|
_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);
|
this.uiManager.updatePointsStatus(currentPoints, pointsNeeded);
|
||||||
|
|
||||||
// If we have enough points, show the name input
|
// 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.
|
* Complete the current drawing.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue