From 087d9d9d2a4154b8eea2e7751b878f3755017a7c Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Fri, 14 Aug 2026 02:44:10 -0500 Subject: [PATCH] changed ntfy string --- app/api/ws.py | 14 ++- app/notify/ntfy.py | 2 +- docs/implementation.md | 18 ++++ static/app.js | 18 +++- static/chart.js | 180 +++++++++++++++++++++++++++++++++++ static/index.html | 128 +++++++++++++------------ static/style.css | 4 +- tests/e2e/chart.test.mjs | 27 ++++++ tests/e2e/comments.test.mjs | 2 +- tests/e2e/helpers.mjs | 7 +- tests/e2e/touch.test.mjs | 119 +++++++++++++++++++++++ tests/e2e/trendline.test.mjs | 2 +- tests/test_ws_preferences.py | 14 ++- 13 files changed, 462 insertions(+), 73 deletions(-) create mode 100644 tests/e2e/touch.test.mjs diff --git a/app/api/ws.py b/app/api/ws.py index f01eeb0..f0be5b6 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -46,6 +46,11 @@ def connection_clusters(runtime, prefs: dict | None): ) +def session_open(runtime) -> float | None: + bars = runtime.store.get(Timeframe.D1, 1) + return bars[-1].o if bars else None + + def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: return { "type": "snapshot", @@ -56,6 +61,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: "price": runtime.store.get(Timeframe.M1, 1)[-1].c if runtime.store.get(Timeframe.M1, 1) else None, + "session_open": session_open(runtime), } @@ -94,6 +100,7 @@ async def websocket_endpoint(websocket: WebSocket): { "type": "clusters", "price": runtime.price, + "session_open": session_open(runtime), "clusters": [cluster.to_dict() for cluster in clusters], } ) @@ -108,7 +115,11 @@ async def websocket_endpoint(websocket: WebSocket): break if event["type"] == "bar" and event["bar"].tf is tf: await websocket.send_json( - {"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()} + { + "type": "bar", "tf": tf.value, + "bar": event["bar"].to_dict(), + "session_open": session_open(runtime), + } ) elif event["type"] == "levels": await websocket.send_json( @@ -120,6 +131,7 @@ async def websocket_endpoint(websocket: WebSocket): { "type": "clusters", "price": runtime.price, + "session_open": session_open(runtime), "clusters": [cluster.to_dict() for cluster in clusters], } ) diff --git a/app/notify/ntfy.py b/app/notify/ntfy.py index c0c2972..2b84dab 100644 --- a/app/notify/ntfy.py +++ b/app/notify/ntfy.py @@ -8,6 +8,6 @@ async def send_ntfy(server: str, topic: str, message: str) -> None: response = await client.post( f"{server.rstrip('/')}/{topic}", content=message, - headers={"Title": "/ES confluence", "Priority": "high", "Tags": "chart_with_upwards_trend"}, + headers={"Title": "/ESsent", "Priority": "high", "Tags": "chart_with_upwards_trend"}, ) response.raise_for_status() diff --git a/docs/implementation.md b/docs/implementation.md index 2512df7..d482f2d 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -858,6 +858,24 @@ which clears a trendline's pending first anchor, preview and active gesture; Symbol's separate open panel is closed too. A palette no longer consumes the first Escape while leaving a half-drawn tool active behind it. +### 2026-08-13 — touch pinch scales both chart axes + +Lightweight Charts already scaled time for a two-finger pane pinch and scaled +price when the gesture began on the right axis, but the browser sometimes took +the latter as page zoom. The app now captures only an active two-finger chart +gesture, prevents its browser default, leaves the library's horizontal scaling +intact, and applies the same pinch ratio around the gesture's center price. +Single-finger page scrolling and every mouse path remain native and untouched. + +The manual price range resets on a timeframe change or a double click/tap on the +right axis. A separate touch tap path was required for selection: after chart +gesture handling, mobile browsers do not reliably synthesize the DOM click the +desktop selection path expects. A short stationary tap now hit-tests and selects +the drawing and opens its tooltip; moved touches, annotations, armed tools and +two-finger gestures are excluded. Verified with Chromium touch events: logical +time span and visible price span both changed while `visualViewport.scale` +remained constant. + ### 2026-08-11 — a time axis past the last bar, and the Yahoo bar it exposed Trendlines project into the whitespace right of the last candle, but the axis diff --git a/static/app.js b/static/app.js index cd5a582..8bfa556 100644 --- a/static/app.js +++ b/static/app.js @@ -106,6 +106,7 @@ createApp({ const status = ref({ stream: 'disconnected', bars_held: {} }); const dataReceivedAt = ref(null); const price = ref(null); + const sessionOpen = ref(null); const prefs = ref(loadPrefs()); const timeframe = ref(prefs.value.base_tf || '1m'); const levels = ref([]); @@ -138,6 +139,7 @@ createApp({ const levelColor = ref('#e0a34a'); const levelWidth = ref(2); const alertEarlyPoints = ref(null); + const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false'); const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d']; const drawingColorRows = [ { id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] }, @@ -207,6 +209,11 @@ createApp({ hour: 'numeric', minute: '2-digit', second: '2-digit', }).format(new Date(dataReceivedAt.value)); }); + const quoteChange = computed(() => { + if (price.value == null || !(sessionOpen.value > 0)) return null; + const points = price.value - sessionOpen.value; + return { points, percent: points / sessionOpen.value * 100 }; + }); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null); const allManualSelected = computed(() => manualLines.value.length + comments.value.length > 0 @@ -286,6 +293,7 @@ createApp({ } syncVisibleLevels(); price.value = message.price; + sessionOpen.value = message.session_open ?? sessionOpen.value; } else if (message.type === 'bar') { // Switching timeframe races: the server answers `subscribe` with a // fresh snapshot from one coroutine while another is still draining @@ -296,6 +304,7 @@ createApp({ dataReceivedAt.value = Date.now(); chartApi.updateBar(message.bar); price.value = message.bar.c; + sessionOpen.value = message.session_open ?? sessionOpen.value; status.value.last_bar_t = message.bar.t; } else if (message.type === 'levels') { // A delta, not a replacement: VWAP changes every minute while the @@ -308,6 +317,7 @@ createApp({ } else if (message.type === 'clusters') { clusters.value = message.clusters; price.value = message.price; + sessionOpen.value = message.session_open ?? sessionOpen.value; } else if (message.type === 'alert') { // Keyed on a counter, not the timestamp: two alerts inside the same // second would collide and Vue would reuse the wrong row. @@ -961,6 +971,11 @@ createApp({ localStorage.setItem('chart-color-row-labels', JSON.stringify(value)); }, { deep: true }); + watch(animateCurrentPrice, value => { + localStorage.setItem('chart-animate-current-price', String(value)); + if (chartApi) chartApi.setCurrentPriceAnimation(value); + }); + onMounted(async () => { chartApi = new ConfluenceChart(); // Deliberate debug handle. Chart geometry bugs are invisible from the @@ -978,6 +993,7 @@ createApp({ chartApi.setLineDuplicateHandler(duplicateLine); chartApi.setLineDeleteHandler(deleteLine); chartApi.setSnap(snap.value); + chartApi.setCurrentPriceAnimation(animateCurrentPrice.value); chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment }); window.addEventListener('keydown', handleKeydown); await migrateLegacyToken(); @@ -993,6 +1009,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index eea709d..50b914a 100644 --- a/static/chart.js +++ b/static/chart.js @@ -7,6 +7,10 @@ class ConfluenceChart { this.observedPlotCanvas = null; this.levelSeries = new Map(); this.priceLines = new Map(); + this.currentPriceLine = null; + this.currentPricePulse = null; + this.animateCurrentPrice = true; + this.lastCurrentPrice = null; this.previewLine = null; this.projectionLayer = null; this.bars = []; @@ -63,6 +67,19 @@ class ConfluenceChart { this.onCommentSelect = null; this.describeDrawingColor = null; this.lastSnapDiagnostic = null; + this.manualPriceRange = null; + this.pricePinch = null; + this.touchStartListener = null; + this.touchMoveListener = null; + this.touchEndListener = null; + this.priceResetListener = null; + this.touchTap = null; + this.suppressClickUntil = 0; + this.candleAutoscaleProvider = original => { + const info = original(); + if (!info || !this.manualPriceRange) return info; + return { ...info, priceRange: { ...this.manualPriceRange } }; + }; } static TICK = 0.25; @@ -163,6 +180,8 @@ class ConfluenceChart { }, }, rightPriceScale: { borderVisible: false }, + handleScale: { pinch: false }, + handleScroll: { horzTouchDrag: true, vertTouchDrag: false }, // Every price-bearing series shares this scale. A second autoscaled price // axis can place the same numeric price at a different y, which makes // daily context look comparable to intraday price when it is not. @@ -177,6 +196,9 @@ class ConfluenceChart { upColor: '#20b8a6', downColor: '#ef5b3f', borderVisible: true, borderUpColor: '#087f76', borderDownColor: '#b9342d', wickUpColor: '#087f76', wickDownColor: '#b9342d', + autoscaleInfoProvider: this.candleAutoscaleProvider, + lastValueVisible: false, + priceLineVisible: false, }); // Volume as an overlay on its own hidden scale, confined to the bottom // fifth. An overlay rather than a pane so it cannot alter the price scale: @@ -234,6 +256,10 @@ class ConfluenceChart { this.tooltip = document.createElement('div'); this.tooltip.className = 'chart-tooltip'; this.overlayLayer.appendChild(this.tooltip); + this.currentPricePulse = document.createElement('div'); + this.currentPricePulse.className = 'current-price-pulse'; + this.currentPricePulse.hidden = true; + this.overlayLayer.appendChild(this.currentPricePulse); const preview = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); preview.classList.add('chart-preview'); preview.setAttribute('aria-hidden', 'true'); @@ -350,6 +376,7 @@ class ConfluenceChart { el.addEventListener('contextmenu', this.contextListener); this.clickListener = event => { this.hideContextMenu(); + if (Date.now() < this.suppressClickUntil) return; // A placement gesture ends in a click too; selecting on it would pick // whatever the new line happens to overlap. if (this.armedTool) return; @@ -375,12 +402,17 @@ class ConfluenceChart { this.renderComments(); this.renderContextLabels(); }); + this.installPinchZoom(); } setBars(bars) { + if (this.bars[0]?.tf && bars[0]?.tf && this.bars[0].tf !== bars[0].tf) { + this.manualPriceRange = null; + } this.bars = bars; const candleData = bars.map(this.toCandle); this.candles.setData(candleData); + this.updateCurrentPrice(bars[bars.length - 1]?.c, false); this.volume.setData(bars.map(ConfluenceChart.toVolume)); this.futureSpace.setData(this.futureTimes(bars)); // Anchored by time, not by logical index. A logical index addresses the @@ -438,6 +470,7 @@ class ConfluenceChart { const last = this.bars[this.bars.length - 1]; if (last && bar.t < last.t) return; this.candles.update(this.toCandle(bar)); + this.updateCurrentPrice(bar.c); this.volume.update(ConfluenceChart.toVolume(bar)); if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; else this.bars.push(bar); @@ -451,12 +484,152 @@ class ConfluenceChart { this.renderComments(); } + setCurrentPriceAnimation(enabled) { this.animateCurrentPrice = Boolean(enabled); } + + updateCurrentPrice(price, pulse = true) { + if (price == null) return; + const options = { + price, + color: '#b7771d', + lineWidth: 3, + lineStyle: LightweightCharts.LineStyle.Solid, + axisLabelVisible: true, + title: '', + }; + if (this.currentPriceLine) this.currentPriceLine.applyOptions(options); + else this.currentPriceLine = this.candles.createPriceLine(options); + + const changed = this.lastCurrentPrice != null && price !== this.lastCurrentPrice; + this.lastCurrentPrice = price; + if (!pulse || !changed || !this.animateCurrentPrice || !this.currentPricePulse) return; + requestAnimationFrame(() => { + const y = this.candles.priceToCoordinate(price); + if (y == null) return; + this.currentPricePulse.hidden = false; + this.currentPricePulse.style.top = `${Math.round(y)}px`; + this.currentPricePulse.classList.remove('active'); + void this.currentPricePulse.offsetWidth; + this.currentPricePulse.classList.add('active'); + }); + } + setCommentHandlers({ onToggle, onMove, onSelect }) { this.onCommentToggle = onToggle; this.onCommentMove = onMove; this.onCommentSelect = onSelect; } + installPinchZoom() { + if (!this.chartEl) return; + const point = touch => ({ x: touch.clientX, y: touch.clientY }); + const separation = touches => ({ + x: Math.abs(touches[1].clientX - touches[0].clientX), + y: Math.abs(touches[1].clientY - touches[0].clientY), + }); + this.touchStartListener = event => { + if (event.touches.length === 1) { + const touch = event.touches[0]; + this.touchTap = { + x: touch.clientX, + y: touch.clientY, + at: Date.now(), + annotation: Boolean(event.target.closest?.('.chart-comment')), + }; + return; + } + this.touchTap = null; + if (event.touches.length !== 2) return; + const plot = this.plotCanvas()?.getBoundingClientRect(); + if (!plot) return; + const touches = [...event.touches]; + const insideChart = touches.every(touch => { + const p = point(touch); + return p.x >= plot.left && p.x <= this.chartEl.getBoundingClientRect().right + && p.y >= plot.top && p.y <= plot.bottom; + }); + if (!insideChart) return; + const distances = separation(touches); + const mode = distances.x >= distances.y ? 'time' : 'price'; + const centerX = (touches[0].clientX + touches[1].clientX) / 2 - plot.left; + const centerY = (touches[0].clientY + touches[1].clientY) / 2 - plot.top; + const margins = this.chart.priceScale('right').options().scaleMargins; + const top = this.candles.coordinateToPrice(plot.height * margins.top); + const bottom = this.candles.coordinateToPrice(plot.height * (1 - margins.bottom)); + const anchor = this.candles.coordinateToPrice(centerY); + const timeRange = this.chart.timeScale().getVisibleLogicalRange(); + if ([top, bottom, anchor].some(value => value == null) || !timeRange) return; + this.pricePinch = { + mode, + distance: Math.max(distances[mode === 'time' ? 'x' : 'y'], 1), + anchor, + minValue: Math.min(top, bottom), + maxValue: Math.max(top, bottom), + timeRange, + timeAnchor: timeRange.from + (timeRange.to - timeRange.from) * centerX / plot.width, + }; + // Prevent browser page zoom only after a two-finger chart gesture begins. + event.preventDefault(); + }; + this.touchMoveListener = event => { + if (event.touches.length === 1 && this.touchTap) { + const touch = event.touches[0]; + if (Math.hypot(touch.clientX - this.touchTap.x, touch.clientY - this.touchTap.y) > 10) { + this.touchTap = null; + } + } + if (!this.pricePinch || event.touches.length !== 2) return; + event.preventDefault(); + const distances = separation([...event.touches]); + const { mode, distance, anchor, minValue, maxValue, timeRange, timeAnchor } = this.pricePinch; + const ratio = Math.max(0.1, distances[mode === 'time' ? 'x' : 'y'] / distance); + if (mode === 'time') { + // Hold the price range while changing the visible bars; otherwise the + // chart's autoscale makes a horizontal-only gesture look linked to Y. + this.manualPriceRange = { minValue, maxValue }; + this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); + this.chart.timeScale().setVisibleLogicalRange({ + from: timeAnchor + (timeRange.from - timeAnchor) / ratio, + to: timeAnchor + (timeRange.to - timeAnchor) / ratio, + }); + } else { + this.manualPriceRange = { + minValue: anchor + (minValue - anchor) / ratio, + maxValue: anchor + (maxValue - anchor) / ratio, + }; + this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); + } + this.renderAnchorHandles(); + this.renderComments(); + this.renderContextLabels(); + }; + this.touchEndListener = event => { + if (event.touches.length < 2) this.pricePinch = null; + if (event.touches.length || !this.touchTap || this.armedTool) return; + const tap = this.touchTap; + this.touchTap = null; + if (tap.annotation || Date.now() - tap.at > 500) return; + const touch = event.changedTouches[0]; + const chartPoint = touch && this.eventPoint({ clientX: touch.clientX, clientY: touch.clientY }); + if (!chartPoint || !this.withinPlot(chartPoint) || chartPoint.t == null) return; + this.suppressClickUntil = Date.now() + 500; + const param = { point: chartPoint, time: chartPoint.t }; + if (this.onChartClick) this.onChartClick(param); + this.updateLineTooltip(param); + }; + this.chartEl.addEventListener('touchstart', this.touchStartListener, { passive: false, capture: true }); + this.chartEl.addEventListener('touchmove', this.touchMoveListener, { passive: false, capture: true }); + this.chartEl.addEventListener('touchend', this.touchEndListener, { passive: true, capture: true }); + this.chartEl.addEventListener('touchcancel', this.touchEndListener, { passive: true, capture: true }); + this.priceResetListener = event => { + if (!this.manualPriceRange) return; + const plot = this.plotCanvas()?.getBoundingClientRect(); + if (!plot || event.clientX < plot.right) return; + this.manualPriceRange = null; + this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); + }; + this.chartEl.addEventListener('dblclick', this.priceResetListener); + } + // Comments are DOM, not canvas: they hold arbitrary text, need to collapse, // and a floating one must ignore the time scale entirely. renderComments() { @@ -1755,6 +1928,13 @@ 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.touchStartListener) { + this.chartEl.removeEventListener('touchstart', this.touchStartListener, true); + this.chartEl.removeEventListener('touchmove', this.touchMoveListener, true); + this.chartEl.removeEventListener('touchend', this.touchEndListener, true); + this.chartEl.removeEventListener('touchcancel', this.touchEndListener, true); + this.chartEl.removeEventListener('dblclick', this.priceResetListener); + } if (this.lineMoveListener) window.removeEventListener('pointermove', this.lineMoveListener); if (this.lineUpListener) window.removeEventListener('pointerup', this.lineUpListener); if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener); diff --git a/static/index.html b/static/index.html index 9bb42ad..7e928e6 100644 --- a/static/index.html +++ b/static/index.html @@ -21,7 +21,7 @@
-
{{ status.symbol || 'ES=F' }}{{ price == null ? '—' : price.toFixed(2) }}
+
{{ status.symbol || 'ES=F' }}{{ price == null ? '—' : price.toFixed(2) }}
@@ -57,7 +57,70 @@