ohlc and scroll

This commit is contained in:
Chris Amow 2026-08-16 21:33:09 -05:00
parent ae596ebf8e
commit baf2048867
4 changed files with 138 additions and 34 deletions

View file

@ -48,10 +48,9 @@ Complexity: medium. This should be mobile-only and preserve the desktop sidebar.
### P0: Predictable chart and page gestures ### P0: Predictable chart and page gestures
On coarse pointers, reserve one-finger vertical movement for page scrolling and On coarse pointers, one finger scrolls the page. Two-finger drag pans the
one-finger horizontal movement for chart panning. Retain pinch-to-zoom. If chart on both axes. Pinch still zooms (axis from the initial finger spread).
vertical chart manipulation remains important, provide an explicit chart-lock Do not give one finger two meanings.
or fullscreen mode rather than making one gesture mean two things.
Complexity: medium-high. Touch arbitration needs real iOS Safari and Android Complexity: medium-high. Touch arbitration needs real iOS Safari and Android
Chrome verification in addition to Chromium emulation. Chrome verification in addition to Chromium emulation.

View file

@ -190,7 +190,7 @@ class ConfluenceChart {
}, },
rightPriceScale: { borderVisible: false }, rightPriceScale: { borderVisible: false },
handleScale: { pinch: 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 // Every price-bearing series shares this scale. A second autoscaled price
// axis can place the same numeric price at a different y, which makes // axis can place the same numeric price at a different y, which makes
// daily context look comparable to intraday price when it is not. // daily context look comparable to intraday price when it is not.
@ -265,6 +265,10 @@ class ConfluenceChart {
this.tooltip = document.createElement('div'); this.tooltip = document.createElement('div');
this.tooltip.className = 'chart-tooltip'; this.tooltip.className = 'chart-tooltip';
this.overlayLayer.appendChild(this.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 = document.createElement('div');
this.currentPricePulse.className = 'current-price-pulse'; this.currentPricePulse.className = 'current-price-pulse';
this.currentPricePulse.hidden = true; this.currentPricePulse.hidden = true;
@ -412,8 +416,10 @@ class ConfluenceChart {
this.scheduleContextLabels(); this.scheduleContextLabels();
if (!param.point || !param.time) { if (!param.point || !param.time) {
this.tooltip.hidden = true; this.tooltip.hidden = true;
this.hideOhlc();
return; return;
} }
this.updateOhlc(param.time);
this.updateLineTooltip(param); this.updateLineTooltip(param);
}); });
requestAnimationFrame(() => this.observePlotCanvas()); requestAnimationFrame(() => this.observePlotCanvas());
@ -669,6 +675,14 @@ class ConfluenceChart {
x: Math.abs(touches[1].clientX - touches[0].clientX), x: Math.abs(touches[1].clientX - touches[0].clientX),
y: Math.abs(touches[1].clientY - touches[0].clientY), 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 => { this.touchStartListener = event => {
if (event.touches.length === 1) { if (event.touches.length === 1) {
const touch = event.touches[0]; const touch = event.touches[0];
@ -678,9 +692,11 @@ class ConfluenceChart {
at: Date.now(), at: Date.now(),
annotation: Boolean(event.target.closest?.('.chart-comment')), annotation: Boolean(event.target.closest?.('.chart-comment')),
}; };
this.touchTwo = null;
return; return;
} }
this.touchTap = null; this.touchTap = null;
this.touchTwo = null;
if (event.touches.length !== 2) return; if (event.touches.length !== 2) return;
const plot = this.plotCanvas()?.getBoundingClientRect(); const plot = this.plotCanvas()?.getBoundingClientRect();
if (!plot) return; if (!plot) return;
@ -692,25 +708,29 @@ class ConfluenceChart {
}); });
if (!insideChart) return; if (!insideChart) return;
const distances = separation(touches); const distances = separation(touches);
const mode = distances.x >= distances.y ? 'time' : 'price'; const mid = midpoint(touches);
const centerX = (touches[0].clientX + touches[1].clientX) / 2 - plot.left; const centerX = mid.x - plot.left;
const centerY = (touches[0].clientY + touches[1].clientY) / 2 - plot.top; const centerY = mid.y - plot.top;
const margins = this.chart.priceScale('right').options().scaleMargins; const margins = this.chart.priceScale('right').options().scaleMargins;
const top = this.candles.coordinateToPrice(plot.height * margins.top); const top = this.candles.coordinateToPrice(plot.height * margins.top);
const bottom = this.candles.coordinateToPrice(plot.height * (1 - margins.bottom)); const bottom = this.candles.coordinateToPrice(plot.height * (1 - margins.bottom));
const anchor = this.candles.coordinateToPrice(centerY); const anchor = this.candles.coordinateToPrice(centerY);
const timeRange = this.chart.timeScale().getVisibleLogicalRange(); const timeRange = this.chart.timeScale().getVisibleLogicalRange();
if ([top, bottom, anchor].some(value => value == null) || !timeRange) return; if ([top, bottom, anchor].some(value => value == null) || !timeRange) return;
this.pricePinch = { this.touchTwo = {
mode, kind: null,
distance: Math.max(distances[mode === 'time' ? 'x' : 'y'], 1), 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, anchor,
minValue: Math.min(top, bottom), minValue: Math.min(top, bottom),
maxValue: Math.max(top, bottom), maxValue: Math.max(top, bottom),
timeRange, timeRange,
timeAnchor: timeRange.from + (timeRange.to - timeRange.from) * centerX / plot.width, 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(); event.preventDefault();
}; };
this.touchMoveListener = event => { this.touchMoveListener = event => {
@ -720,33 +740,59 @@ class ConfluenceChart {
this.touchTap = null; this.touchTap = null;
} }
} }
if (!this.pricePinch || event.touches.length !== 2) return; if (!this.touchTwo || event.touches.length !== 2) return;
event.preventDefault(); event.preventDefault();
const distances = separation([...event.touches]); const touches = [...event.touches];
const { mode, distance, anchor, minValue, maxValue, timeRange, timeAnchor } = this.pricePinch; const mid = midpoint(touches);
const ratio = Math.max(0.1, distances[mode === 'time' ? 'x' : 'y'] / distance); const gesture = this.touchTwo;
if (mode === 'time') { if (!gesture.kind) {
// Hold the price range while changing the visible bars; otherwise the const midDelta = Math.hypot(mid.x - gesture.startMid.x, mid.y - gesture.startMid.y);
// chart's autoscale makes a horizontal-only gesture look linked to Y. const gapDelta = Math.abs(gap(touches) - gesture.startGap);
this.manualPriceRange = { minValue, maxValue }; if (midDelta < 10 && gapDelta < 10) return;
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); 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({ this.chart.timeScale().setVisibleLogicalRange({
from: timeAnchor + (timeRange.from - timeAnchor) / ratio, from: gesture.timeRange.from + shift,
to: timeAnchor + (timeRange.to - timeAnchor) / ratio, 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 = { this.manualPriceRange = {
minValue: anchor + (minValue - anchor) / ratio, minValue: gesture.minValue - dPrice,
maxValue: anchor + (maxValue - anchor) / ratio, maxValue: gesture.maxValue - dPrice,
}; };
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider }); 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.renderAnchorHandles();
this.renderComments(); this.renderComments();
this.renderContextLabels(); this.renderContextLabels();
}; };
this.touchEndListener = event => { 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; if (event.touches.length || !this.touchTap || this.armedTool) return;
const tap = this.touchTap; const tap = this.touchTap;
this.touchTap = null; this.touchTap = null;
@ -1169,7 +1215,10 @@ class ConfluenceChart {
*/ */
armTool(tool) { armTool(tool) {
this.armedTool = 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.hideSnapDot();
this.chartEl.classList.toggle('armed', Boolean(tool)); this.chartEl.classList.toggle('armed', Boolean(tool));
this.pendingAnchor = null; this.pendingAnchor = null;
@ -1670,6 +1719,38 @@ class ConfluenceChart {
if (this.previewLine) this.previewLine.setAttribute('hidden', ''); 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) { updateLineTooltip(param) {
const id = this.hitTest(param); const id = this.hitTest(param);
const line = this.levels.find(level => level.id === id); const line = this.levels.find(level => level.id === id);

View file

@ -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); } 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; } .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); } .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%); } } .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; } .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; } } @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); } } @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; } .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. */ /* 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; .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; } background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); } .arm-hint { color:var(--accent); }

View file

@ -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 cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(300); 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(); const panned = await state();
assert.ok(Math.abs(panned.timeSpan - vertical.timeSpan) < 0.1, assert.ok(Math.abs(panned.timeSpan - oneFinger.timeSpan) < 0.1,
'one-finger pan changed the time zoom'); 'two-finger pan changed the time zoom');
assert.ok(Math.abs(panned.timeFrom - vertical.timeFrom) > 0.5, assert.ok(Math.abs(panned.timeFrom - oneFinger.timeFrom) > 0.5,
'one-finger horizontal drag did not pan the chart'); 'two-finger drag did not pan the chart');
assertNoPageErrors(page, assert); assertNoPageErrors(page, assert);
}, { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true }); }, { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true });
}); });