diff --git a/docs/mobile_enhance.md b/docs/mobile_enhance.md index dc48bad..585b6d9 100644 --- a/docs/mobile_enhance.md +++ b/docs/mobile_enhance.md @@ -48,10 +48,9 @@ Complexity: medium. This should be mobile-only and preserve the desktop sidebar. ### P0: Predictable chart and page gestures -On coarse pointers, reserve one-finger vertical movement for page scrolling and -one-finger horizontal movement for chart panning. Retain pinch-to-zoom. If -vertical chart manipulation remains important, provide an explicit chart-lock -or fullscreen mode rather than making one gesture mean two things. +On coarse pointers, one finger scrolls the page. Two-finger drag pans the +chart on both axes. Pinch still zooms (axis from the initial finger spread). +Do not give one finger two meanings. Complexity: medium-high. Touch arbitration needs real iOS Safari and Android Chrome verification in addition to Chromium emulation. diff --git a/static/chart.js b/static/chart.js index d3d7828..b89f508 100644 --- a/static/chart.js +++ b/static/chart.js @@ -190,7 +190,7 @@ class ConfluenceChart { }, rightPriceScale: { borderVisible: false }, handleScale: { pinch: false }, - handleScroll: { horzTouchDrag: true, vertTouchDrag: false }, + handleScroll: { horzTouchDrag: false, 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. @@ -265,6 +265,10 @@ class ConfluenceChart { this.tooltip = document.createElement('div'); this.tooltip.className = 'chart-tooltip'; this.overlayLayer.appendChild(this.tooltip); + this.ohlcReadout = document.createElement('div'); + this.ohlcReadout.className = 'chart-ohlc'; + this.ohlcReadout.hidden = true; + this.overlayLayer.appendChild(this.ohlcReadout); this.currentPricePulse = document.createElement('div'); this.currentPricePulse.className = 'current-price-pulse'; this.currentPricePulse.hidden = true; @@ -412,8 +416,10 @@ class ConfluenceChart { this.scheduleContextLabels(); if (!param.point || !param.time) { this.tooltip.hidden = true; + this.hideOhlc(); return; } + this.updateOhlc(param.time); this.updateLineTooltip(param); }); requestAnimationFrame(() => this.observePlotCanvas()); @@ -669,6 +675,14 @@ class ConfluenceChart { x: Math.abs(touches[1].clientX - touches[0].clientX), y: Math.abs(touches[1].clientY - touches[0].clientY), }); + const midpoint = touches => ({ + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2, + }); + const gap = touches => Math.hypot( + touches[1].clientX - touches[0].clientX, + touches[1].clientY - touches[0].clientY, + ); this.touchStartListener = event => { if (event.touches.length === 1) { const touch = event.touches[0]; @@ -678,9 +692,11 @@ class ConfluenceChart { at: Date.now(), annotation: Boolean(event.target.closest?.('.chart-comment')), }; + this.touchTwo = null; return; } this.touchTap = null; + this.touchTwo = null; if (event.touches.length !== 2) return; const plot = this.plotCanvas()?.getBoundingClientRect(); if (!plot) return; @@ -692,25 +708,29 @@ class ConfluenceChart { }); 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 mid = midpoint(touches); + const centerX = mid.x - plot.left; + const centerY = mid.y - 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), + this.touchTwo = { + kind: null, + mode: distances.x >= distances.y ? 'time' : 'price', + distance: Math.max(distances[distances.x >= distances.y ? 'x' : 'y'], 1), + startGap: Math.max(gap(touches), 1), + startMid: mid, + startPrice: this.candles.coordinateToPrice(centerY), anchor, minValue: Math.min(top, bottom), maxValue: Math.max(top, bottom), timeRange, timeAnchor: timeRange.from + (timeRange.to - timeRange.from) * centerX / plot.width, + plotWidth: plot.width, }; - // Prevent browser page zoom only after a two-finger chart gesture begins. event.preventDefault(); }; this.touchMoveListener = event => { @@ -720,33 +740,59 @@ class ConfluenceChart { this.touchTap = null; } } - if (!this.pricePinch || event.touches.length !== 2) return; + if (!this.touchTwo || 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 }); + const touches = [...event.touches]; + const mid = midpoint(touches); + const gesture = this.touchTwo; + if (!gesture.kind) { + const midDelta = Math.hypot(mid.x - gesture.startMid.x, mid.y - gesture.startMid.y); + const gapDelta = Math.abs(gap(touches) - gesture.startGap); + if (midDelta < 10 && gapDelta < 10) return; + gesture.kind = midDelta > gapDelta ? 'pan' : 'pinch'; + } + if (gesture.kind === 'pan') { + const span = gesture.timeRange.to - gesture.timeRange.from; + const shift = -(mid.x - gesture.startMid.x) / gesture.plotWidth * span; this.chart.timeScale().setVisibleLogicalRange({ - from: timeAnchor + (timeRange.from - timeAnchor) / ratio, - to: timeAnchor + (timeRange.to - timeAnchor) / ratio, + from: gesture.timeRange.from + shift, + to: gesture.timeRange.to + shift, }); - } else { + const nowPrice = this.candles.coordinateToPrice( + mid.y - this.plotCanvas().getBoundingClientRect().top, + ); + const dPrice = nowPrice == null || gesture.startPrice == null + ? 0 : nowPrice - gesture.startPrice; this.manualPriceRange = { - minValue: anchor + (minValue - anchor) / ratio, - maxValue: anchor + (maxValue - anchor) / ratio, + minValue: gesture.minValue - dPrice, + maxValue: gesture.maxValue - dPrice, }; this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); + } else { + const distances = separation(touches); + const { mode, distance, anchor, minValue, maxValue, timeRange, timeAnchor } = gesture; + const ratio = Math.max(0.1, distances[mode === 'time' ? 'x' : 'y'] / distance); + if (mode === 'time') { + 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 < 2) this.touchTwo = null; if (event.touches.length || !this.touchTap || this.armedTool) return; const tap = this.touchTap; this.touchTap = null; @@ -1169,7 +1215,10 @@ class ConfluenceChart { */ armTool(tool) { this.armedTool = tool; - this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool }); + this.chart.applyOptions({ + handleScroll: tool ? false : { horzTouchDrag: false, vertTouchDrag: false }, + handleScale: tool ? false : { pinch: false }, + }); this.hideSnapDot(); this.chartEl.classList.toggle('armed', Boolean(tool)); this.pendingAnchor = null; @@ -1670,6 +1719,38 @@ class ConfluenceChart { if (this.previewLine) this.previewLine.setAttribute('hidden', ''); } + barAtTime(time) { + if (time == null || !this.bars.length) return null; + let lo = 0; + let hi = this.bars.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const t = this.bars[mid].t; + if (t === time) return this.bars[mid]; + if (t < time) lo = mid + 1; + else hi = mid - 1; + } + return null; + } + + hideOhlc() { + if (this.ohlcReadout) this.ohlcReadout.hidden = true; + } + + updateOhlc(time) { + const bar = this.barAtTime(time); + if (!bar || !this.ohlcReadout) { + this.hideOhlc(); + return; + } + const up = bar.c >= bar.o; + this.ohlcReadout.hidden = false; + this.ohlcReadout.classList.toggle('up', up); + this.ohlcReadout.classList.toggle('down', !up); + this.ohlcReadout.textContent = + `O ${bar.o.toFixed(2)} H ${bar.h.toFixed(2)} L ${bar.l.toFixed(2)} C ${bar.c.toFixed(2)}`; + } + updateLineTooltip(param) { const id = this.hitTest(param); const line = this.levels.find(level => level.id === id); diff --git a/static/style.css b/static/style.css index bcacba4..57df70c 100644 --- a/static/style.css +++ b/static/style.css @@ -13,8 +13,9 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); } -#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } +#chart { position:relative; height:calc(100vh - 190px); min-height:420px; touch-action:pan-y; }.chart-preview,.chart-line-bridges,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-line-bridges { z-index:4; }.chart-line-bridges line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { fill:none; stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; } .current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:3px; transform:translateY(-1.5px); overflow:hidden; opacity:0; background:repeating-linear-gradient(110deg,transparent 0 8px,color-mix(in srgb,var(--accent) 55%,white) 8px 13px,transparent 13px 19px); mask-image:linear-gradient(90deg,transparent 0,#000 8%,#000 100%); pointer-events:none; }.current-price-pulse::after { content:''; position:absolute; top:-2px; bottom:-2px; left:-24%; width:24%; background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--accent) 35%,white),transparent); opacity:.8; }.current-price-pulse.active { opacity:.9; animation:current-price-flow 1.5s linear infinite; }.current-price-pulse.active::after { animation:current-price-scan 2.8s linear infinite; }@keyframes current-price-flow { to { background-position-x:57px; } }@keyframes current-price-scan { to { transform:translateX(520%); } } +.chart-ohlc { position:absolute; top:6px; left:8px; z-index:5; padding:2px 6px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 90%,transparent); color:var(--fg); font-size:10px; font-variant-numeric:tabular-nums; pointer-events:none; white-space:nowrap; }.chart-ohlc.up { color:var(--green); }.chart-ohlc.down { color:var(--red); }.chart-ohlc[hidden] { display:none; } .projection-diagnostic { position:absolute; top:8px; left:8px; z-index:9; margin:0; padding:5px 7px; border:1px solid var(--line); background:color-mix(in srgb,var(--panel) 92%,transparent); color:var(--fg); font:9px/1.35 "IBM Plex Mono", monospace; white-space:pre; } @media (prefers-reduced-motion:reduce) { .current-price-pulse.active { animation:none; } } @keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } } @@ -64,7 +65,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; .price-row button { font-size:11px; padding:5px 12px; align-self:end; } /* Armed tools take over the pointer, so the chart must not look draggable. */ -#chart.armed { cursor:crosshair; } +#chart.armed { cursor:crosshair; touch-action:none; } .chart-price-tag { position:absolute; z-index:5; transform:translateY(-50%); padding:2px 6px; border-radius:4px; background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; } .arm-hint { color:var(--accent); } diff --git a/tests/e2e/touch.test.mjs b/tests/e2e/touch.test.mjs index 1922026..abadd1c 100644 --- a/tests/e2e/touch.test.mjs +++ b/tests/e2e/touch.test.mjs @@ -109,11 +109,34 @@ test('touch tap selects a drawing and horizontal and vertical pinches scale only } await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); await page.waitForTimeout(300); + const oneFinger = await state(); + assert.ok(Math.abs(oneFinger.timeFrom - vertical.timeFrom) < 0.5, + 'one-finger drag panned the chart'); + + const midY = plot.top + plot.height * 0.5; + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [ + { x: plot.left + plot.width * 0.4, y: midY, id: 1 }, + { x: plot.left + plot.width * 0.6, y: midY, id: 2 }, + ], + }); + for (let step = 1; step <= 8; step += 1) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [ + { x: plot.left + plot.width * 0.4 + step * 10, y: midY, id: 1 }, + { x: plot.left + plot.width * 0.6 + step * 10, y: midY, id: 2 }, + ], + }); + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); + await page.waitForTimeout(300); const panned = await state(); - assert.ok(Math.abs(panned.timeSpan - vertical.timeSpan) < 0.1, - 'one-finger pan changed the time zoom'); - assert.ok(Math.abs(panned.timeFrom - vertical.timeFrom) > 0.5, - 'one-finger horizontal drag did not pan the chart'); + assert.ok(Math.abs(panned.timeSpan - oneFinger.timeSpan) < 0.1, + 'two-finger pan changed the time zoom'); + assert.ok(Math.abs(panned.timeFrom - oneFinger.timeFrom) > 0.5, + 'two-finger drag did not pan the chart'); assertNoPageErrors(page, assert); }, { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true }); });