Snap by x for the bar and y for the extreme, and stop the crosshair magnetting
Sweeping the cursor along the bottom of the chart rarely landed on a bar's low, and often nowhere near the bar at all. The cause was a nearest-in-2D search I added an hour earlier to make bars easier to hit when zoomed out: whichever bar nearby had the lowest low won on total distance, so the dot skipped off the bar under the cursor instead of tracing each low in turn. Reverted. The rule is now written down rather than adjusted per complaint — x picks the bar, y picks which of its extremes — and an e2e test asserts it by sweeping a zoomed-out 1h chart and requiring every position to land on the low of the bar beneath it. 115 of 115. The crosshair is the other half of why this felt broken. Lightweight Charts defaults to Magnet, which snaps it to the bar's close, so hovering beside a low displayed a price several ticks from the one an anchor would use. Arming a tool already switched to Normal, but the chart is read before a tool is armed, which is when the misleading reading was being taken. Normal everywhere now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
501afb7792
commit
ab372832db
3 changed files with 80 additions and 64 deletions
|
|
@ -1578,3 +1578,25 @@ Tests clean up after themselves: `withChart` records the drawings that exist
|
||||||
before the body runs and deletes anything new afterwards, because the dev store
|
before the body runs and deletes anything new afterwards, because the dev store
|
||||||
is shared with whoever is using the app. Select by title rather than class when
|
is shared with whoever is using the app. Select by title rather than class when
|
||||||
asserting on chart overlays, for the same reason.
|
asserting on chart overlays, for the same reason.
|
||||||
|
|
||||||
|
### The snapping rule, stated once
|
||||||
|
|
||||||
|
**x picks the bar, y picks which extreme.** That is the whole rule. It is
|
||||||
|
written here because changing it reactively three times is what made trendlines
|
||||||
|
feel broken, not any inherent difficulty:
|
||||||
|
|
||||||
|
1. An 8px proximity gate meant a cursor between the high and the low snapped to
|
||||||
|
neither, so the anchor kept a raw mid-bar price and the side silently fell
|
||||||
|
back to the dropdown.
|
||||||
|
2. Removing the gate fixed that. Then a nearest-in-2D search was tried, to make
|
||||||
|
a bar easier to hit when zoomed out — and broke sweeping along the bottom,
|
||||||
|
because whichever nearby bar had the lowest low won on total distance and the
|
||||||
|
dot skipped off the bar under the cursor. Reverted.
|
||||||
|
3. What actually made it feel wrong was never the rule: the crosshair was in
|
||||||
|
Lightweight Charts' default Magnet mode, snapping to the bar's *close*, so
|
||||||
|
the feedback pointed somewhere the anchor would never go. It is Normal
|
||||||
|
everywhere now, with the snap dot showing the real target.
|
||||||
|
|
||||||
|
An e2e test sweeps the cursor along the bottom of a zoomed-out 1h chart and
|
||||||
|
requires every position to land on the low of the bar beneath it — 115 of 115.
|
||||||
|
That test is the rule, executable.
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,6 @@ class ConfluenceChart {
|
||||||
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
||||||
static DRAG_THRESHOLD = 12;
|
static DRAG_THRESHOLD = 12;
|
||||||
|
|
||||||
// Bars either side of the cursor considered when snapping. Wide enough to
|
|
||||||
// catch the neighbour you meant when bars are three pixels apart.
|
|
||||||
static SNAP_NEIGHBOURS = 6;
|
|
||||||
|
|
||||||
static snapToTick(price) {
|
static snapToTick(price) {
|
||||||
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
||||||
}
|
}
|
||||||
|
|
@ -98,6 +94,11 @@ class ConfluenceChart {
|
||||||
rightPriceScale: { borderVisible: false },
|
rightPriceScale: { borderVisible: false },
|
||||||
// Daily context lives on the left, intraday on the right.
|
// Daily context lives on the left, intraday on the right.
|
||||||
leftPriceScale: { visible: true, borderVisible: false },
|
leftPriceScale: { visible: true, borderVisible: false },
|
||||||
|
// Never magnet. The default snaps the crosshair to the bar's *close*, so
|
||||||
|
// hovering beside a low reads a price several ticks away, and everything
|
||||||
|
// that snaps in this app snaps to extremes. The crosshair tracks the
|
||||||
|
// cursor; the snap dot says where an anchor would actually land.
|
||||||
|
crosshair: { mode: LightweightCharts.CrosshairMode.Normal },
|
||||||
});
|
});
|
||||||
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
this.candles = this.chart.addSeries(LightweightCharts.CandlestickSeries, {
|
||||||
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
|
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
|
||||||
|
|
@ -540,17 +541,7 @@ class ConfluenceChart {
|
||||||
*/
|
*/
|
||||||
armTool(tool) {
|
armTool(tool) {
|
||||||
this.armedTool = tool;
|
this.armedTool = tool;
|
||||||
// Magnet snaps the crosshair to the bar's close, so hovering by a bar's low
|
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
|
||||||
// drew it mid-bar and made a correctly-placed anchor look wrong. While a
|
|
||||||
// tool is armed the crosshair tracks the cursor and the snap dot shows
|
|
||||||
// where the anchor will actually land.
|
|
||||||
this.chart.applyOptions({
|
|
||||||
handleScroll: !tool,
|
|
||||||
handleScale: !tool,
|
|
||||||
crosshair: {
|
|
||||||
mode: tool ? LightweightCharts.CrosshairMode.Normal : LightweightCharts.CrosshairMode.Magnet,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.hideSnapDot();
|
this.hideSnapDot();
|
||||||
this.chartEl.classList.toggle('armed', Boolean(tool));
|
this.chartEl.classList.toggle('armed', Boolean(tool));
|
||||||
this.pendingAnchor = null;
|
this.pendingAnchor = null;
|
||||||
|
|
@ -687,40 +678,27 @@ class ConfluenceChart {
|
||||||
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
|
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
|
||||||
// An already-snapped point carries no cursor position; return it untouched
|
// An already-snapped point carries no cursor position; return it untouched
|
||||||
// rather than measuring against undefined.
|
// rather than measuring against undefined.
|
||||||
if (point.y == null || point.x == null) return base;
|
if (point.y == null) return base;
|
||||||
|
|
||||||
// Nearest extreme by *screen* distance, across a few bars either side —
|
// Horizontal position chooses the bar, vertical position chooses which of
|
||||||
// not the extreme of whichever bar happens to share the cursor's time.
|
// its extremes. Nothing else — a nearest-in-2D search was tried and is
|
||||||
// Taking the bar by time alone meant pointing anywhere below a candle
|
// wrong: sweeping along the bottom of the chart, whichever nearby bar had
|
||||||
// snapped to that candle's low however far away it was, while the extreme
|
// the lowest low won on total distance, so the dot skipped off the bar
|
||||||
// actually under the cursor was ignored. Zoomed out to ~360 bars at three
|
// under the cursor entirely instead of tracing each bar's low in turn.
|
||||||
// pixels each, that made hitting the intended bar a matter of several
|
const nearest = this.bars.reduce(
|
||||||
// tries.
|
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
|
||||||
let lo = 0;
|
);
|
||||||
let hi = this.bars.length - 1;
|
const candidates = [
|
||||||
while (lo < hi) {
|
{ p: nearest.h, side: 'resistance' },
|
||||||
const mid = (lo + hi + 1) >> 1;
|
{ p: nearest.l, side: 'support' },
|
||||||
if (this.bars[mid].t <= fallbackT) lo = mid;
|
];
|
||||||
else hi = mid - 1;
|
const snapped = candidates
|
||||||
}
|
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
|
||||||
const timeScale = this.chart.timeScale();
|
.sort((a, b) => a.distance - b.distance)[0];
|
||||||
let best = null;
|
return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
|
||||||
const from = Math.max(0, lo - ConfluenceChart.SNAP_NEIGHBOURS);
|
|
||||||
const to = Math.min(this.bars.length - 1, lo + ConfluenceChart.SNAP_NEIGHBOURS);
|
|
||||||
for (let index = from; index <= to; index += 1) {
|
|
||||||
const bar = this.bars[index];
|
|
||||||
const x = timeScale.timeToCoordinate(bar.t);
|
|
||||||
if (x == null) continue;
|
|
||||||
for (const [price, side] of [[bar.h, 'resistance'], [bar.l, 'support']]) {
|
|
||||||
const y = this.candles.priceToCoordinate(price);
|
|
||||||
if (y == null) continue;
|
|
||||||
const distance = Math.hypot(x - point.x, y - point.y);
|
|
||||||
if (!best || distance < best.distance) best = { distance, t: bar.t, p: price, side };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best ? { t: best.t, p: best.p, snappedSide: best.side } : base;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
renderGesture() {
|
renderGesture() {
|
||||||
if (!this.gesture) return;
|
if (!this.gesture) return;
|
||||||
const { start, end } = this.gesture;
|
const { start, end } = this.gesture;
|
||||||
|
|
|
||||||
|
|
@ -125,14 +125,12 @@ test('hovering shows where the anchor will land', { timeout: 180000 }, async ()
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('snapping picks the extreme nearest on screen, not the bar under the cursor time',
|
test('sweeping the bottom traces the low of each bar under the cursor',
|
||||||
{ timeout: 180000 }, async () => {
|
{ timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
await page.click('.timeframes button:text-is("1h")');
|
await page.click('.timeframes button:text-is("1h")');
|
||||||
await page.waitForTimeout(3500);
|
await page.waitForTimeout(3500);
|
||||||
// Zoomed out, bars are a few pixels apart and the intended one is hard to
|
// Zoomed out to a few pixels per bar, which is where this fell apart.
|
||||||
// hit. Snapping used to take whichever bar shared the cursor's *time* and
|
|
||||||
// then its nearer extreme, ignoring how far away that was.
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
const c = window.__chart, ts = c.chart.timeScale();
|
const c = window.__chart, ts = c.chart.timeScale();
|
||||||
ts.setVisibleRange({
|
ts.setVisibleRange({
|
||||||
|
|
@ -142,23 +140,41 @@ test('snapping picks the extreme nearest on screen, not the bar under the cursor
|
||||||
});
|
});
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// The rule, stated once: x picks the bar, y picks which extreme. Running
|
||||||
|
// the cursor along the bottom must therefore trace each bar's low. A
|
||||||
|
// nearest-in-2D search was tried instead and broke exactly this — the
|
||||||
|
// lowest low nearby won on total distance and the dot skipped off the bar
|
||||||
|
// under the cursor.
|
||||||
const result = await page.evaluate(() => {
|
const result = await page.evaluate(() => {
|
||||||
const c = window.__chart, ts = c.chart.timeScale();
|
const c = window.__chart, ts = c.chart.timeScale();
|
||||||
const target = c.bars[c.bars.length - 150];
|
const y = document.querySelector('#chart').clientHeight * 0.93;
|
||||||
const x = ts.timeToCoordinate(target.t);
|
let hits = 0, total = 0;
|
||||||
const y = c.candles.priceToCoordinate(target.l);
|
const misses = [];
|
||||||
// Sit exactly on the target's low but nudged so the cursor's time
|
for (let x = 200; x < 1000; x += 7) {
|
||||||
// resolves to its neighbour.
|
const t = ts.coordinateToTime(x);
|
||||||
const point = { x: x - 2, y, p: c.candles.coordinateToPrice(y), t: Number(ts.coordinateToTime(x - 2)) };
|
if (t == null) continue;
|
||||||
const snapped = c.snapPoint(point);
|
const under = c.bars.reduce(
|
||||||
return {
|
(best, bar) => (Math.abs(bar.t - Number(t)) < Math.abs(best.t - Number(t)) ? bar : best));
|
||||||
resolvedToNeighbour: point.t !== target.t,
|
const snapped = c.snapPoint({ x, y, p: c.candles.coordinateToPrice(y), t: Number(t) });
|
||||||
snappedToTarget: snapped.t === target.t && snapped.p === target.l,
|
total += 1;
|
||||||
};
|
if (snapped.t === under.t && snapped.p === under.l) hits += 1;
|
||||||
|
else if (misses.length < 3) misses.push({ x, wanted: under.l, got: snapped.p });
|
||||||
|
}
|
||||||
|
return { hits, total, misses };
|
||||||
});
|
});
|
||||||
assert.equal(result.resolvedToNeighbour, true, 'the probe did not straddle two bars');
|
assert.ok(result.total > 50, 'the sweep did not probe enough positions');
|
||||||
assert.equal(result.snappedToTarget, true,
|
assert.equal(result.hits, result.total,
|
||||||
'snapped to the bar sharing the cursor time rather than the extreme under the cursor');
|
`only ${result.hits}/${result.total} landed on the low of the bar under the cursor: ` +
|
||||||
|
JSON.stringify(result.misses));
|
||||||
assertNoPageErrors(page, assert);
|
assertNoPageErrors(page, assert);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the crosshair never magnets to the close', { timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
// Magnet mode snaps the crosshair to the bar's close, so hovering beside a
|
||||||
|
// low reads a price several ticks from the one that would be used.
|
||||||
|
const mode = await page.evaluate(() => window.__chart.chart.options().crosshair.mode);
|
||||||
|
assert.equal(mode, 0, 'crosshair is not in Normal mode');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue