ohlc and scroll
This commit is contained in:
parent
ae596ebf8e
commit
baf2048867
4 changed files with 138 additions and 34 deletions
|
|
@ -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.
|
||||
|
|
|
|||
129
static/chart.js
129
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);
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue