diff --git a/static/app.js b/static/app.js index 50e8f0a..54f5e17 100644 --- a/static/app.js +++ b/static/app.js @@ -78,17 +78,19 @@ createApp({ const clusters = ref([]); const alerts = ref([]); let alertSequence = 0; - const drawMode = ref(false); + // Which tool the next chart gesture creates. null = pan/select as normal. + const armedTool = ref(null); const drawName = ref(''); const drawColor = ref('#65b7cf'); const drawWidth = ref(2); const drawSide = ref('support'); const snap = ref(true); - const drawPoints = ref([]); const selectedLine = ref(null); const selectedLines = ref([]); const alertPrice = ref(null); const alertNote = ref(''); + const levelColor = ref('#e0a34a'); + const levelWidth = ref(2); const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d']; const now = ref(Date.now()); let chartApi = null; @@ -187,47 +189,46 @@ createApp({ oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); }; } - function toggleDraw() { - drawMode.value = !drawMode.value; - drawPoints.value = []; + function armTool(tool) { + armedTool.value = armedTool.value === tool ? null : tool; selectedLine.value = null; - chartApi.clearLinePreview(); + chartApi.armTool(armedTool.value); } - function handleChartMove(param) { - if (!drawMode.value || drawPoints.value.length !== 1) return; - chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), drawColor.value, drawWidth.value); + function handleChartClick(param) { + selectedLine.value = chartApi.hitTest(param); + selectedLines.value = selectedLine.value ? [selectedLine.value] : []; } - async function handleChartClick(param) { - if (!drawMode.value) { - selectedLine.value = chartApi.hitTest(param); - selectedLines.value = selectedLine.value ? [selectedLine.value] : []; + async function handleToolComplete(result) { + // One placement per arming, so a tool cannot keep firing on stray drags. + armedTool.value = null; + chartApi.armTool(null); + if (result.tool === 'level') { + alertPrice.value = result.price; + await addPriceAlert(); return; } - const point = chartApi.pointFromClick(param, snap.value); - if (point.snappedSide) drawSide.value = point.snappedSide; - drawPoints.value.push(point); - if (drawPoints.value.length < 2) return; - chartApi.clearLinePreview(); - const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t); - if (start.t === end.t) { drawPoints.value = []; return; } + await createTrendline(result.start, result.end); + } + + async function createTrendline(first, second) { + const [start, end] = [first, second].sort((a, b) => a.t - b.t); + const side = start.snappedSide || end.snappedSide || drawSide.value; const temporaryId = `tmp_${Date.now()}`; const optimistic = { - id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value, - weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${drawSide.value}`, + id: temporaryId, kind: 'manual', tf: timeframe.value, side, + weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${side}`, anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t), points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false, color: drawColor.value, line_width: drawWidth.value, }; levels.value.push(optimistic); syncVisibleLevels(); - drawPoints.value = []; - drawMode.value = false; try { const response = await apiFetch('/api/lines', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }), + body: JSON.stringify({ tf: timeframe.value, side, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const saved = await response.json(); @@ -246,7 +247,10 @@ createApp({ if (!alertPrice.value) return; const response = await apiFetch('/api/lines/price', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ price: alertPrice.value, note: alertNote.value }), + body: JSON.stringify({ + price: alertPrice.value, note: alertNote.value, + color: levelColor.value, line_width: levelWidth.value, + }), }); if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; } const saved = await response.json(); @@ -402,13 +406,18 @@ createApp({ if (chartApi) chartApi.setSelectedLine(id); }); + watch(snap, value => { + if (chartApi) chartApi.setSnap(value); + }); + onMounted(() => { chartApi = new ConfluenceChart(); chartApi.create(document.getElementById('chart')); chartApi.setClickHandler(handleChartClick); - chartApi.setMoveHandler(handleChartMove); + chartApi.setToolCompleteHandler(handleToolComplete); chartApi.setLineChangeHandler(updateLineGeometry); chartApi.setLineEndHandler(endLineHere); + chartApi.setSnap(snap.value); window.addEventListener('keydown', handleKeydown); refreshStatus(); connect(); @@ -421,6 +430,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawName, drawColor, drawWidth, drawSide, snap, drawPoints, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, addPriceAlert, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle }; + return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index cf587c5..46178b4 100644 --- a/static/chart.js +++ b/static/chart.js @@ -9,7 +9,6 @@ class ConfluenceChart { this.bars = []; this.levels = []; this.onChartClick = null; - this.onChartMove = null; this.tooltip = null; this.chartEl = null; this.clickListener = null; @@ -23,6 +22,21 @@ class ConfluenceChart { this.contextCutoff = null; this.contextListener = null; this.onLineEnd = null; + // Tool arming: the sidebar decides what the next chart gesture creates. + this.armedTool = null; + this.gesture = null; + this.onToolComplete = null; + this.snapToBars = true; + this.priceTag = null; + this.toolDownListener = null; + this.toolMoveListener = null; + this.toolUpListener = null; + } + + static TICK = 0.25; + + static snapToTick(price) { + return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK; } create(el) { @@ -88,6 +102,16 @@ class ConfluenceChart { this.contextMenu.addEventListener('click', event => event.stopPropagation()); this.contextMenu.appendChild(endHere); el.appendChild(this.contextMenu); + this.priceTag = document.createElement('div'); + this.priceTag.className = 'chart-price-tag'; + this.priceTag.hidden = true; + el.appendChild(this.priceTag); + this.toolDownListener = event => this.startToolGesture(event); + this.toolMoveListener = event => this.moveToolGesture(event); + this.toolUpListener = event => this.finishToolGesture(event); + el.addEventListener('pointerdown', this.toolDownListener); + window.addEventListener('pointermove', this.toolMoveListener); + window.addEventListener('pointerup', this.toolUpListener); this.anchorMoveListener = event => this.moveAnchor(event); this.anchorUpListener = event => this.finishAnchorDrag(event); window.addEventListener('pointermove', this.anchorMoveListener); @@ -96,6 +120,9 @@ class ConfluenceChart { el.addEventListener('contextmenu', this.contextListener); this.clickListener = event => { this.hideContextMenu(); + // A placement gesture ends in a click too; selecting on it would pick + // whatever the new line happens to overlap. + if (this.armedTool) return; if (!this.onChartClick) return; const bounds = el.getBoundingClientRect(); const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }; @@ -109,7 +136,6 @@ class ConfluenceChart { return; } this.updateLineTooltip(param); - if (this.onChartMove) this.onChartMove(param); }); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles()); } @@ -214,9 +240,125 @@ class ConfluenceChart { this.renderAnchorHandles(); } - setClickHandler(handler) { this.onChartClick = handler; } + // --- tool arming and placement gestures --------------------------------- - setMoveHandler(handler) { this.onChartMove = handler; } + setToolCompleteHandler(handler) { this.onToolComplete = handler; } + + setSnap(enabled) { this.snapToBars = enabled; } + + /** + * Arm a tool so the next chart gesture places one. Panning is suspended + * while armed, otherwise the drag that draws a line also drags the chart out + * from under it. + */ + armTool(tool) { + this.armedTool = tool; + this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool }); + this.chartEl.classList.toggle('armed', Boolean(tool)); + this.clearGesture(); + } + + eventPoint(event) { + const bounds = this.chartEl.getBoundingClientRect(); + const x = event.clientX - bounds.left; + const y = event.clientY - bounds.top; + const price = this.candles.coordinateToPrice(y); + if (price == null) return null; + const time = this.chart.timeScale().coordinateToTime(x); + return { x, y, p: price, t: time == null ? null : Number(time) }; + } + + startToolGesture(event) { + if (!this.armedTool || event.button !== 0) return; + const point = this.eventPoint(event); + if (!point) return; + event.preventDefault(); + this.gesture = { start: point, end: point }; + this.chartEl.setPointerCapture?.(event.pointerId); + this.renderGesture(); + } + + moveToolGesture(event) { + if (!this.armedTool || !this.gesture) return; + const point = this.eventPoint(event); + if (!point) return; + this.gesture.end = point; + this.renderGesture(); + } + + finishToolGesture(event) { + if (!this.armedTool || !this.gesture) return; + const tool = this.armedTool; + const { start, end } = this.gesture; + this.clearGesture(); + this.chartEl.releasePointerCapture?.(event.pointerId); + + if (tool === 'level') { + // A click with no drag is a valid placement; the drag is only there to + // let you fine-tune the price before committing. + this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) }); + return; + } + // A stray click should not create a zero-length trendline. + if (Math.hypot(end.x - start.x, end.y - start.y) < 5) return; + const a = this.snapPoint(start); + const b = this.snapPoint(end); + if (a.t == null || b.t == null || a.t === b.t) return; + this.onToolComplete?.({ tool, start: a, end: b }); + } + + /** Trendline anchors snap to bar extremes; levels snap to the tick grid. */ + snapPoint(point) { + const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null; + const base = { t: fallbackT, p: point.p, snappedSide: null }; + if (!this.snapToBars || !this.bars.length || fallbackT == null) return base; + const nearest = this.bars.reduce( + (best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best), + ); + const candidates = [ + { p: nearest.h, side: 'resistance' }, + { p: nearest.l, side: 'support' }, + ]; + const snapped = candidates + .map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) })) + .sort((a, b) => a.distance - b.distance)[0]; + if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; + return base; + } + + renderGesture() { + if (!this.gesture) return; + const { start, end } = this.gesture; + if (this.armedTool === 'level') { + const price = ConfluenceChart.snapToTick(end.p); + const y = this.candles.priceToCoordinate(price); + if (y == null) return; + this.previewLine.removeAttribute('hidden'); + this.previewLine.setAttribute('x1', 0); + this.previewLine.setAttribute('y1', y); + this.previewLine.setAttribute('x2', this.chartEl.clientWidth); + this.previewLine.setAttribute('y2', y); + this.previewLine.setAttribute('stroke', '#e0a34a'); + this.previewLine.setAttribute('stroke-width', 2); + this.previewLine.setAttribute('stroke-dasharray', '6 4'); + this.priceTag.hidden = false; + this.priceTag.textContent = price.toFixed(2); + this.priceTag.style.top = `${y}px`; + this.priceTag.style.left = `${Math.min(end.x + 14, this.chartEl.clientWidth - 70)}px`; + return; + } + const a = this.snapPoint(start); + const b = this.snapPoint(end); + if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2); + } + + clearGesture() { + this.gesture = null; + this.clearLinePreview(); + if (this.priceTag) this.priceTag.hidden = true; + } + + setClickHandler(handler) { this.onChartClick = handler; } setLineChangeHandler(handler) { this.onLineChange = handler; } @@ -259,22 +401,6 @@ class ConfluenceChart { this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`; } - pointFromClick(param, snap) { - const rawPrice = this.candles.coordinateToPrice(param.point.y); - let point = { t: Number(param.time), p: rawPrice, snappedSide: null }; - if (!snap || !this.bars.length) return point; - const nearest = this.bars.reduce((best, bar) => Math.abs(bar.t - point.t) < Math.abs(best.t - point.t) ? bar : best); - const candidates = [ - { p: nearest.h, side: 'resistance' }, - { p: nearest.l, side: 'support' }, - ]; - const snapped = candidates - .map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - param.point.y) })) - .sort((a, b) => a.distance - b.distance)[0]; - if (snapped.distance <= 8) point = { t: nearest.t, p: snapped.p, snappedSide: snapped.side }; - return point; - } - hitTest(param) { let best = null; // Flat levels draw as price lines, which have no series geometry to hit — @@ -432,6 +558,9 @@ class ConfluenceChart { if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener); + if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener); + if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener); + if (this.toolUpListener) window.removeEventListener('pointerup', this.toolUpListener); if (this.chart) this.chart.remove(); } } diff --git a/static/index.html b/static/index.html index 54a2d6e..784caef 100644 --- a/static/index.html +++ b/static/index.html @@ -20,25 +20,53 @@
{{ status.symbol || 'ES=F' }}{{ price == null ? '—' : price.toFixed(2) }}
-
- - - - - - - - {{ drawPoints.length ? 'Place second point' : 'Place first point' }} - Line selected -
FEED {{ status.stream }} LAST BAR {{ barAge }} HELD {{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }} + Drag on the chart from one point to the other + Click or drag on the chart to set the price + Line selected — Delete removes it