Compare commits

..

7 commits

Author SHA1 Message Date
decd069ec8 Position chart overlays against the plot, not the element
The trendline snap indicator was 66 pixels out, and so was every other overlay.

Lightweight Charts reports coordinates from the plot area's origin. The chart
element also contains the price scales, so enabling the left scale for the daily
labels moved the plot 66px into the element — and each overlay positioned with
left: against the element inherited that error twice over. The cursor's
element-x was read as a plot-x, resolving a bar about 66px right of the pointer;
the indicator was then drawn at that bar's plot-x interpreted as element-x,
landing 66px left of where the bar is painted. Neither near the cursor nor near
the bar, and scaling with zoom — two bars at 30m, a dozen at 1m — which is why
it read as random rather than as an offset.

Every diagnostic number agreed with itself the whole time, because dot_y,
expected_y and bar_low_y all come from the same API and shared the same wrong
origin. Instrumentation cannot see a systematic error in its own frame of
reference; what found this was comparing canvas.width to element.clientWidth and
getting 0.894.

Overlays now live in one container positioned over the plot canvas and inherit
plot coordinates untranslated — snap dot and label, comments, anchor handles,
preview line, tooltip, price tag, context menu — and eventPoint subtracts the
same offset so a pointer position and a chart coordinate mean the same thing.
The container follows the plot on resize.

Verified in page pixels rather than through the coordinate API: with the cursor
placed 30px below a known bar's low, the dot lands 30px above the cursor, on
that low, labelled 7789.00 L against a bar low of 7789.

Device pixel ratio, viewport size, resize desynchronisation and the chart
scrolling under the gesture were each measured and ruled out before this. The
e2e helper computed expected times from element-relative x, the same mistake in
the tests, and is corrected here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 23:55:51 -05:00
3f677a333c Report where the snap indicator is drawn, not just what it computed
Diagnostic mode proved the arithmetic: on a 30m chart every sample snapped to
the correct bar and to exactly that bar's low, while the user still saw the
indicator sitting off the bars. That leaves the half never measured in their
browser — whether the dot is drawn where the price says it should be.

The report now carries the cursor's y, the dot's rendered y, the y the snapped
price maps to, and the y coordinates of the snapped bar's high and low. A
correctly computed answer drawn in the wrong place and a wrong answer drawn
faithfully look identical on a screenshot; these four numbers separate them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 23:32:39 -05:00
fa577bacb7 Stop the snap leaping to the live edge outside the plot area
Diagnostic mode found in one hover what four local hypotheses could not.

coordinateToTime answers null over the right-hand price axis, over the
whitespace past the last bar, and anywhere outside the chart. snapPoint read
that as "the newest bar":

    const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;

so the indicator jumped to the live edge from wherever the cursor actually was —
hundreds of points away, on a bar the user was nowhere near. It matches the
screenshot exactly: the crosshair read 22:42 while the snap label read 22:58,
which was the last bar.

Two faults behind it. The tool's pointer listener is on window, so it processes
moves over the sidebar — a real sample reported x=1409 on a chart 1280 wide. And
a null time meant a default instead of no answer.

A pointer outside the plot now hides the indicator, and a null time resolves to
the bar nearest in pixels rather than the newest. An e2e test hovers a bar, the
price axis, the sidebar and back, asserting the indicator never sits at the live
edge from mid-chart and disappears once the cursor leaves.

Worth recording that device pixel ratio, viewport size, resize desynchronisation
and the chart scrolling under the gesture were each measured and ruled out
before this, none of which was the cause. With the browser on another machine,
instrumenting it should have come first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 23:27:52 -05:00
3222ce03f4 Add a diagnostic mode that reports chart geometry to the server
Chart geometry bugs live in the browser, and the browser is usually on a
different machine from whoever is debugging it — so "it passes in my headless
run" keeps being said about a chart that is unusable on someone's screen. This
session spent hours on that gap: a screenshot showed the crosshair reading 22:42
while the snap label read 22:58, sixteen bars apart on a 1m chart, and no
headless run reproduced it at any viewport, any device pixel ratio, before or
after a resize, or across ten scripted gestures.

Opening the chart with ?diag=1 makes every snap post what the client computed —
the cursor's time, price and x, the snapped time and price, how many bars are
held, the first and last of them, and the chart width — which the server logs as
SNAPDBG. ?diag=0 turns it off; the setting is remembered. Throttled to about one
report a second, and off by default, so it costs nothing when unused.

Kept as a permanent facility rather than scaffolding to delete: this will not be
the last geometry puzzle, and the endpoint takes whatever fields SnapReport
declares. Documented in AGENTS.md alongside the note about where things run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 23:19:42 -05:00
7ce6d33bb5 Show the price the anchor will use, and record where things run
The snap dot said where an anchor would land but not what it was, which made
"the dot is in the wrong place" and "the dot is on the wrong bar" impossible to
tell apart from a screenshot. It now carries a label with the price, whether it
is a high or a low, and the bar's time.

AGENTS.md gains the environment fact this session kept rediscovering: the agent
runs on a remote machine over SSH while the user's browser runs on another, so
headless runs here render on different hardware at a different size and pixel
ratio, and "it passes in my headless run" is not evidence the user's problem is
fixed. Three consecutive reproductions passed server-side while the chart was
unusable on the user's screen. When a visual bug will not reproduce, match their
viewport and deviceScaleFactor explicitly — and prefer putting the numbers on
screen over asking for another console paste.

Device pixel ratio is ruled out for the current trendline complaint: the
bottom-sweep check lands 115 of 115 at 1600x1000 and at 1900x1400 with ratios of
1, 1.25, 1.5 and 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 22:35:40 -05:00
ab372832db 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>
2026-08-10 21:35:52 -05:00
501afb7792 Snap to the nearest extreme on screen, and add an e2e suite
Snapping took the bar sharing the cursor's time and then its nearer extreme,
which ignored how far away that extreme was. Pointing below a candle snapped to
that candle's low however distant, while the extreme genuinely under the cursor
was never considered. Zoomed out to some 360 bars at three pixels apart, that
made hitting the bar you meant a matter of several tries. snapPoint now scans
six bars either side and takes the extreme nearest in pixels.

Proven by probe: with the cursor sitting exactly on one bar's low but nudged two
pixels so coordinateToTime resolves to its neighbour, the snap takes the extreme
under the cursor rather than the neighbour's.

A report of the snap dot appearing "way above the bar" turned out to be the dot
landing correctly on the low while the cursor was 151 points below it: the right
price scale keeps a bottom margin of 0.1 and the volume overlay is drawn in it,
so the lower fifth of the pane sits below every candle.

bin/e2e runs tests/e2e against the dev stack inside the playwright service —
Node's own test runner, no dependency added here, since Playwright is global in
that container. Eleven cases, each one a bug that shipped: the viewport parked
ten hours in the past, hourly candles drawn as slivers, stale bar events
throwing, comments drifting across a timeframe switch, and three ways a
trendline anchor could disagree with its preview. Not one was reachable from
pytest. Tests delete any drawing they create, because the dev store is shared
with whoever is looking at the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 20:16:09 -05:00
13 changed files with 1023 additions and 25 deletions

View file

@ -21,6 +21,25 @@ level, a tick must never overwrite a settled bar, volume must be counted once.
Name the test after the failure, not the function: `test_a_tick_cannot_overwrite
_a_settled_bar` beats `test_put`.
## Where things run
**The agent works on a remote machine over SSH. The user's browser runs on a
different machine.** Consequences, all learned the hard way:
- You cannot see the user's screen, console, or cursor. Screenshots and pasted
console output are the only window into it. Browser extensions that drive
"your" Chrome do not help — they attach to the machine the browser is on.
- Headless Chromium here renders on server hardware: different screen, window
size and device pixel ratio from the user's. "Works in my headless run" is not
evidence that it works for them. When a UI bug will not reproduce, match their
viewport and `deviceScaleFactor` explicitly before concluding anything.
- The dev stack is served to them over the network (e.g. `hera.local:8010`),
which is the same app the headless browser reaches as `http://api:8000`.
When a visual bug resists reproduction, prefer putting the numbers **on screen**
in the app over asking for another console paste — one screenshot then carries
the whole diagnosis.
## Verify UI in a real browser
Chart bugs are invisible from the outside — the API, the socket and the
@ -42,6 +61,25 @@ renders as a blank canvas that looks exactly like a broken app.
is missing" from "the data is off-screen" — which is how a viewport bug that
three passing API checks had missed was finally found.
## Diagnostic mode
Chart geometry bugs live in the browser, which is usually on a different machine
from whoever is debugging them. Rather than asking for console pastes:
```
open the chart with ?diag=1 # remembered until ?diag=0
docker compose logs api | grep SNAPDBG
```
With it on, every snap the trendline tool computes is posted to
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the
snapped time and price, how many bars were held, the first and last bar, and the
chart's width. Throttled to about one a second. It reads the client's own
numbers, which is exactly what "works in my headless run" cannot tell you.
Extend it when the next geometry puzzle appears; the endpoint takes whatever
fields `SnapReport` declares.
## Running tests
```

View file

@ -1,3 +1,4 @@
import logging
import time
import uuid
@ -11,6 +12,8 @@ from app.api.deps import require_token
# Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
# /version live in app.api.meta and stay open on purpose.
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
@ -198,6 +201,44 @@ def create_comment(request: Request, payload: CommentCreate):
return line.to_dict()
class SnapReport(BaseModel):
"""What the browser computed for one snap, for diagnosing chart geometry."""
cursor_t: int | None = None
cursor_p: float | None = None
cursor_x: float | None = None
snapped_t: int | None = None
snapped_p: float | None = None
bars_held: int | None = None
first_bar_t: int | None = None
last_bar_t: int | None = None
tf: str | None = None
chart_w: float | None = None
chart_h: float | None = None
cursor_y: float | None = None
# Where the indicator actually landed versus where the price says it should
# — the only way to tell a wrong answer from a correctly-computed one drawn
# in the wrong place.
dot_y: float | None = None
expected_y: float | None = None
bar_low_y: float | None = None
bar_high_y: float | None = None
note: str | None = None
@router.post("/debug/snap", status_code=204)
def debug_snap(payload: SnapReport):
"""Record one snap sample from a browser running diagnostic mode.
Chart geometry bugs live in the client, and the browser is usually on a
different machine from whoever is debugging it — so its own numbers cannot
be read any other way. Off unless the page is opened with ?diag=1; see
AGENTS.md. Logged at warning level so it appears without reconfiguring
uvicorn's log levels.
"""
logger.warning("SNAPDBG %s", payload.model_dump())
return Response(status_code=204)
@router.get("/drawings")
def drawings(request: Request):
"""Every drawing, comments included, for the sidebar list."""

40
bin/e2e Executable file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Run the end-to-end tests against the local dev stack.
#
# bin/e2e # every test
# bin/e2e trendline # only files matching a name
#
# They run inside the `playwright` compose service, which already has
# Playwright and a browser. Nothing is installed into this repo — NODE_PATH
# points Node at the container's global modules, and tests/e2e resolves it
# through createRequire because ESM ignores NODE_PATH.
#
# These drive the real app against the real feed. They create drawings and
# delete them again afterwards; the dev stack shares one drawing store with
# whoever else is looking at it.
set -uo pipefail
FILTER=${1:-}
COMPOSE=${COMPOSE:-docker compose}
if ! $COMPOSE ps --status running --services 2>/dev/null | grep -qx playwright; then
echo "the playwright service is not running — start it with: $COMPOSE up -d" >&2
exit 1
fi
if [ -n "$FILTER" ]; then
FILES=$(ls tests/e2e/*"$FILTER"*.test.mjs 2>/dev/null)
if [ -z "$FILES" ]; then
echo "no e2e test files match '$FILTER'" >&2
exit 1
fi
else
FILES=$(ls tests/e2e/*.test.mjs)
fi
# The app is reachable as http://api:8000 from inside the compose network.
# shellcheck disable=SC2086
$COMPOSE exec -T \
-e NODE_PATH=/usr/lib/node_modules \
-e E2E_URL="${E2E_URL:-http://api:8000/}" \
playwright node --test --test-concurrency=1 $(echo $FILES | sed 's#tests/e2e/#/repo/tests/e2e/#g')

View file

@ -25,3 +25,5 @@ services:
- LANG=en_US.UTF-8
volumes:
- ./artifacts/playwright:/artifacts
# The e2e suite runs in here; the app is http://api:8000 on this network.
- ./tests/e2e:/repo/tests/e2e:ro

View file

@ -1545,3 +1545,118 @@ overwrite a settled one.
Both were introduced by the tick feature and both are covered by tests: a zero
price parses as a trade with no price, a late closed bar replaces its bucket and
keeps the exchange's volume, and a tick cannot overwrite a settled bar.
**Snapping now measures distance on screen, not in time.** The rule was "take
the bar sharing the cursor's time, then its nearer extreme", which ignored how
far that extreme actually was. Pointing anywhere below a candle snapped to that
candle's low however distant, and the extreme genuinely under the cursor was
never considered — so zoomed out to ~360 bars at three pixels each, hitting the
intended bar took several attempts. `snapPoint` now scans six bars either side
and picks the extreme nearest in pixels. Proven by probe: with the cursor on one
bar's low but nudged two pixels so `coordinateToTime` resolves to its neighbour,
the snap takes the extreme under the cursor rather than the neighbour's.
Worth recording because it was misdiagnosed twice: a report of "the snap dot
appears way above the bar" was, on the numbers, the dot landing correctly on the
bar's low while the cursor sat 151 points below it. The right price scale keeps
a `bottom: 0.1` margin and the volume overlay is drawn in it, so the lower fifth
of the pane is below every candle — an inviting place to point that contains no
price action at all.
### e2e tests
`bin/e2e` runs `tests/e2e/*.test.mjs` inside the playwright service against the
dev stack. Node's built-in test runner, no dependencies added to this repo:
Playwright is global in that container and `tests/e2e` is mounted at
`/repo/tests/e2e`. Every case in there is a bug that shipped — the viewport
parked ten hours back, hourly candles drawn as slivers, stale bar events
throwing, comments drifting on a timeframe switch, and three separate ways a
trendline anchor could disagree with its own preview. None of them could have
been caught by pytest, which is the argument for the suite existing.
Tests clean up after themselves: `withChart` records the drawings that exist
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
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.
**The snap leapt to the live edge — found by diagnostic mode.** Reported from
the user's own browser, which no headless run had reproduced:
```
cursor_x 1409.0 chart_w 1280.0 -> cursor_t None -> snapped to the last bar
cursor_x 1161.0 chart_w 1280.0 -> cursor_t None -> snapped to the last bar
cursor_x 1128.0 -> valid time, drift 0 bars
```
`coordinateToTime` answers `null` over the right-hand price axis, over the
whitespace past the last bar, and anywhere outside the chart — and `snapPoint`
read that as "the newest bar", so the dot jumped to the live edge from wherever
the cursor was. Two faults behind it: the tool's pointer listener is on `window`
and therefore fires over the sidebar (x=1409 on a 1280-wide chart), and a null
time meant a default rather than no answer.
Now a pointer outside the plot hides the indicator entirely, and a null time
resolves to the bar nearest in *pixels* rather than the newest one. Covered by
an e2e test that hovers a bar, the axis, the sidebar, and back.
The lesson is about method rather than geometry: four hypotheses were tested and
killed by measurement here — device pixel ratio, viewport size, resize
desynchronisation, and the chart scrolling under the gesture — while the actual
cause was visible in one line of the client's own numbers. When the browser is
on another machine, instrument it early instead of reproducing locally.
### Overlays are positioned against the plot, not the element
**The trendline snap was 66 pixels out, and so was everything else drawn over
the chart.** Lightweight Charts reports coordinates from the plot area's origin.
The chart *element* also contains the price scales, so once the left scale was
enabled for the daily labels, the plot started 66px into the element — and every
overlay positioned with `left:` against the element was displaced by exactly
that much, in both directions at once:
- the cursor's element-x was read as a plot-x, resolving a bar ~66px to the
right of the pointer;
- the indicator was then drawn at that bar's plot-x interpreted as element-x,
landing ~66px left of where the bar is painted.
Not near the cursor, not near the bar, and varying with zoom — 66px is a couple
of bars at 30m and a dozen at 1m, which is why it looked random rather than
offset. Every diagnostic number agreed with itself throughout, because
`dot_y`, `expected_y` and `bar_low_y` all derive from the same API and shared
the same wrong origin. Self-consistent instrumentation cannot see a systematic
error in its own frame of reference.
All overlays now live in one container positioned over the plot canvas, so they
inherit plot coordinates untranslated: the snap dot and label, the comment
layer, the trendline anchor handles, the preview line, the tooltip, the price
tag and the context menu. `eventPoint` subtracts the same offset, so a pointer
position and a chart coordinate finally mean the same thing. The container is
repositioned on resize.
This had been mis-diagnosed for hours: device pixel ratio, viewport size, resize
desynchronisation, the chart scrolling under the gesture, and the dead band
below the candles were each measured and ruled out. The measurement that found
it was comparing `canvas.width` to `element.clientWidth` — 0.894 — which is the
first thing that ever disagreed with itself.

BIN
screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

BIN
screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

View file

@ -33,7 +33,11 @@ class ConfluenceChart {
this.toolMoveListener = null;
this.toolUpListener = null;
this.pendingView = null;
this.overlayLayer = null;
this.snapDot = null;
this.snapLabel = null;
this.snapLeader = null;
this.snapLeaderLine = null;
this.comments = [];
this.commentNodes = new Map();
this.commentLayer = null;
@ -47,6 +51,20 @@ class ConfluenceChart {
// rather than a click. Wide enough to survive a twitch on a deliberate click.
static DRAG_THRESHOLD = 12;
/**
* Whether to report snap geometry to the server.
*
* `?diag=1` turns it on and remembers it; `?diag=0` turns it off. Kept out of
* the way rather than always on: it exists because the browser runs on a
* different machine from whoever is debugging it.
*/
static diagnosticMode() {
const asked = new URLSearchParams(location.search).get('diag');
if (asked === '1') localStorage.setItem('chart-diag', '1');
if (asked === '0') localStorage.removeItem('chart-diag');
return localStorage.getItem('chart-diag') === '1';
}
static snapToTick(price) {
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
}
@ -94,6 +112,11 @@ class ConfluenceChart {
rightPriceScale: { borderVisible: false },
// Daily context lives on the left, intraday on the right.
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, {
upColor: '#27825c', downColor: '#bd4545', borderVisible: true,
@ -121,24 +144,42 @@ class ConfluenceChart {
priceLineVisible: false,
});
this.chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
scaleMargins: { top: 0.88, bottom: 0 },
visible: false,
});
// The default leaves a tenth of the pane empty beneath the lowest bar,
// which is where the volume draws and where a cursor tracing swing lows
// naturally sits — far from any actual price. Give most of it back.
this.chart.priceScale('right').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } });
this.chart.priceScale('left').applyOptions({ scaleMargins: { top: 0.12, bottom: 0.04 } });
this.resizeObserver = new ResizeObserver(() => {
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
requestAnimationFrame(() => this.renderAnchorHandles());
requestAnimationFrame(() => {
this.syncOverlayLayer();
this.renderAnchorHandles();
this.renderComments();
});
});
this.resizeObserver.observe(el);
// Every overlay lives in here, and this is positioned over the *plot* — not
// the element, which also contains the price scales. Lightweight Charts
// reports coordinates from the plot's origin, so anchoring the container
// there lets each overlay use those coordinates untranslated. Enabling the
// left price scale moved the plot 66px right and silently displaced the
// snap indicator, the comments and the trendline handles by that much.
this.overlayLayer = document.createElement('div');
this.overlayLayer.className = 'chart-overlays';
el.appendChild(this.overlayLayer);
this.tooltip = document.createElement('div');
this.tooltip.className = 'chart-tooltip';
el.appendChild(this.tooltip);
this.overlayLayer.appendChild(this.tooltip);
const preview = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
preview.classList.add('chart-preview');
preview.setAttribute('aria-hidden', 'true');
this.previewLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
this.previewLine.setAttribute('hidden', '');
preview.appendChild(this.previewLine);
el.appendChild(preview);
this.overlayLayer.appendChild(preview);
const handles = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
handles.classList.add('chart-handles');
handles.setAttribute('aria-hidden', 'true');
@ -153,7 +194,7 @@ class ConfluenceChart {
handles.appendChild(handle);
this.anchorHandles.push(handle);
}
el.appendChild(handles);
this.overlayLayer.appendChild(handles);
this.contextMenu = document.createElement('div');
this.contextMenu.className = 'chart-context-menu';
this.contextMenu.hidden = true;
@ -166,18 +207,35 @@ class ConfluenceChart {
});
this.contextMenu.addEventListener('click', event => event.stopPropagation());
this.contextMenu.appendChild(endHere);
el.appendChild(this.contextMenu);
this.overlayLayer.appendChild(this.contextMenu);
this.priceTag = document.createElement('div');
this.priceTag.className = 'chart-price-tag';
this.priceTag.hidden = true;
el.appendChild(this.priceTag);
this.overlayLayer.appendChild(this.priceTag);
this.snapDot = document.createElement('div');
this.snapDot.className = 'chart-snap-dot';
this.snapDot.hidden = true;
el.appendChild(this.snapDot);
this.overlayLayer.appendChild(this.snapDot);
// States the price the anchor will use. Useful in itself, and it means a
// single screenshot answers "is the dot in the wrong place, or on the
// wrong bar?" without anyone pasting console output.
this.snapLabel = document.createElement('div');
this.snapLabel.className = 'chart-snap-label';
this.snapLabel.hidden = true;
this.overlayLayer.appendChild(this.snapLabel);
// A leader from the cursor to the target. The snap is often far from the
// pointer — hovering below the candles legitimately snaps up to a bar's low
// — and without a line joining them the dot reads as unrelated to where you
// are pointing.
this.snapLeader = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.snapLeader.classList.add('chart-snap-leader');
this.snapLeader.setAttribute('aria-hidden', 'true');
this.snapLeaderLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
this.snapLeader.appendChild(this.snapLeaderLine);
this.overlayLayer.appendChild(this.snapLeader);
this.commentLayer = document.createElement('div');
this.commentLayer.className = 'chart-comments';
el.appendChild(this.commentLayer);
this.overlayLayer.appendChild(this.commentLayer);
this.toolDownListener = event => this.startToolGesture(event);
this.toolMoveListener = event => this.moveToolGesture(event);
this.toolUpListener = event => this.finishToolGesture(event);
@ -209,6 +267,7 @@ class ConfluenceChart {
}
this.updateLineTooltip(param);
});
requestAnimationFrame(() => this.syncOverlayLayer());
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => {
this.renderAnchorHandles();
this.renderComments();
@ -536,17 +595,7 @@ class ConfluenceChart {
*/
armTool(tool) {
this.armedTool = tool;
// Magnet snaps the crosshair to the bar's close, so hovering by a bar's low
// 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.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
this.hideSnapDot();
this.chartEl.classList.toggle('armed', Boolean(tool));
this.pendingAnchor = null;
@ -554,9 +603,40 @@ class ConfluenceChart {
this.clearLinePreview();
}
/**
* Pixels between the element's left edge and the plot area.
*
* Lightweight Charts measures coordinates from the plot, not the element, so
* a visible left price scale shifts the two apart. Overlays are positioned
* against the element, so every one of them must add this back. Enabling the
* left scale for the daily labels silently moved the snap indicator, the
* comments and the anchor handles 66px out of place.
*/
/** Put the overlay container exactly over the plot area. */
syncOverlayLayer() {
if (!this.overlayLayer || !this.chartEl) return;
const canvas = [...this.chartEl.querySelectorAll('canvas')]
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
if (!canvas) return;
const chartRect = this.chartEl.getBoundingClientRect();
const plot = canvas.getBoundingClientRect();
this.overlayLayer.style.left = `${Math.round(plot.left - chartRect.left)}px`;
this.overlayLayer.style.top = `${Math.round(plot.top - chartRect.top)}px`;
this.overlayLayer.style.width = `${Math.round(plot.width)}px`;
this.overlayLayer.style.height = `${Math.round(plot.height)}px`;
}
plotOffsetX() {
if (!this.chartEl) return 0;
const canvas = [...this.chartEl.querySelectorAll('canvas')]
.sort((a, b) => (b.width * b.height) - (a.width * a.height))[0];
if (!canvas) return 0;
return canvas.getBoundingClientRect().left - this.chartEl.getBoundingClientRect().left;
}
eventPoint(event) {
const bounds = this.chartEl.getBoundingClientRect();
const x = event.clientX - bounds.left;
const x = event.clientX - bounds.left - this.plotOffsetX();
const y = event.clientY - bounds.top;
const price = this.candles.coordinateToPrice(y);
if (price == null) return null;
@ -578,7 +658,15 @@ class ConfluenceChart {
if (!this.armedTool) return;
const point = this.eventPoint(event);
if (!point) return;
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point));
// The listener is on window, so it fires over the sidebar and the price
// axis too. Reported from a real session: x=1409 on a 1280-wide chart, which
// has no time under it — and the snap fell back to the newest bar, throwing
// the dot to the right-hand edge.
if (!this.withinPlot(point)) {
this.hideSnapDot();
return;
}
if (this.armedTool !== 'level') this.showSnapDot(this.snapPoint(point), point);
if (this.gesture) {
this.gesture.end = point;
this.renderGesture();
@ -588,7 +676,7 @@ class ConfluenceChart {
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
}
showSnapDot(snapped) {
showSnapDot(snapped, point = {}) {
if (snapped.t == null) return this.hideSnapDot();
const x = this.chart.timeScale().timeToCoordinate(snapped.t);
const y = this.candles.priceToCoordinate(snapped.p);
@ -598,10 +686,83 @@ class ConfluenceChart {
this.snapDot.style.top = `${Math.round(y)}px`;
// Says which it is before you commit, since the extreme decides the side.
this.snapDot.dataset.side = snapped.snappedSide || '';
// Diagnostic mode: report what the client computed so it can be read on
// the server. Off unless asked for, because the browser is usually on a
// different machine and this is the only way to see its numbers.
// turn on: ?diag=1 (remembered) turn off: ?diag=0
if (ConfluenceChart.diagnosticMode() && snapped.t != null && this.bars.length) {
const step = this.bars.length > 1
? this.bars[this.bars.length - 1].t - this.bars[this.bars.length - 2].t : 60;
const drift = point.t == null ? null : Math.abs(snapped.t - point.t);
{
const now = Date.now();
if (!this._lastSnapReport || now - this._lastSnapReport > 900) {
this._lastSnapReport = now;
const body = {
cursor_t: point.t, cursor_p: point.p, cursor_x: Math.round(point.x),
snapped_t: snapped.t, snapped_p: snapped.p,
bars_held: this.bars.length,
first_bar_t: this.bars[0].t, last_bar_t: this.bars[this.bars.length - 1].t,
chart_w: this.chartEl.clientWidth,
chart_h: this.chartEl.clientHeight,
cursor_y: Math.round(point.y),
dot_y: this.snapDot && !this.snapDot.hidden
? Math.round(this.snapDot.getBoundingClientRect().top
- this.chartEl.getBoundingClientRect().top) + 5
: null,
expected_y: Math.round(y),
bar_low_y: (() => {
const bar = this.bars.find(candle => candle.t === snapped.t);
const at = bar && this.candles.priceToCoordinate(bar.l);
return at == null ? null : Math.round(at);
})(),
bar_high_y: (() => {
const bar = this.bars.find(candle => candle.t === snapped.t);
const at = bar && this.candles.priceToCoordinate(bar.h);
return at == null ? null : Math.round(at);
})(),
note: point.t == null
? 'coordinateToTime returned null'
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
};
fetch('/api/debug/snap', {
method: 'POST',
headers: { 'Content-Type': 'application/json',
...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) },
body: JSON.stringify(body),
}).catch(() => {});
}
}
}
const side = snapped.snappedSide === 'resistance' ? 'H' : snapped.snappedSide === 'support' ? 'L' : '';
const clock = new Date(snapped.t * 1000)
.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit' });
this.snapLabel.hidden = false;
this.snapLabel.dataset.side = snapped.snappedSide || '';
this.snapLabel.textContent = `${snapped.p.toFixed(2)}${side ? ' ' + side : ''} · ${clock}`;
// Flipped to the left near the right-hand edge so it never leaves the pane.
const flip = x > this.chartEl.clientWidth - 120;
this.snapLabel.style.left = `${Math.round(flip ? x - 12 : x + 12)}px`;
this.snapLabel.style.top = `${Math.round(y)}px`;
this.snapLabel.style.transform = flip ? 'translate(-100%, -50%)' : 'translate(0, -50%)';
if (point.x != null && point.y != null) {
this.snapLeaderLine.setAttribute('x1', point.x);
this.snapLeaderLine.setAttribute('y1', point.y);
this.snapLeaderLine.setAttribute('x2', x);
this.snapLeaderLine.setAttribute('y2', y);
this.snapLeader.style.display = '';
} else {
this.snapLeader.style.display = 'none';
}
}
hideSnapDot() {
if (this.snapDot) this.snapDot.hidden = true;
if (this.snapLabel) this.snapLabel.hidden = true;
if (this.snapLeader) this.snapLeader.style.display = 'none';
}
renderPending(a, b) {
@ -677,11 +838,46 @@ class ConfluenceChart {
* control you have to remember to set. An already-snapped point carries no
* cursor y, so it is returned untouched rather than re-snapped against NaN.
*/
/** Is this point over the plotted area, rather than an axis or the sidebar? */
withinPlot(point) {
if (!this.chartEl) return true;
const width = this.chartEl.clientWidth;
const height = this.chartEl.clientHeight;
return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height;
}
/**
* The bar nearest a pixel, when the time scale will not name one.
*
* coordinateToTime answers null over the right-hand whitespace and the price
* axis. Treating that as "the newest bar" put the anchor at the live edge
* however far away the cursor was.
*/
barNearestX(x) {
let best = null;
const timeScale = this.chart.timeScale();
for (const bar of this.bars) {
const coordinate = timeScale.timeToCoordinate(bar.t);
if (coordinate == null) continue;
const distance = Math.abs(coordinate - x);
if (!best || distance < best.distance) best = { distance, t: bar.t };
}
return best ? best.t : null;
}
snapPoint(point) {
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
const fallbackT = point.t ?? (point.x == null ? null : this.barNearestX(point.x));
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
// An already-snapped point carries no cursor position; return it untouched
// rather than measuring against undefined.
if (point.y == null) return base;
// Horizontal position chooses the bar, vertical position chooses which of
// its extremes. Nothing else — a nearest-in-2D search was tried and is
// wrong: sweeping along the bottom of the chart, whichever nearby bar had
// the lowest low won on total distance, so the dot skipped off the bar
// under the cursor entirely instead of tracing each bar's low in turn.
const nearest = this.bars.reduce(
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
);
@ -695,6 +891,7 @@ class ConfluenceChart {
return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
}
renderGesture() {
if (!this.gesture) return;
const { start, end } = this.gesture;
@ -918,7 +1115,7 @@ class ConfluenceChart {
if (!level || !this.bars.length) return;
event.preventDefault();
const bounds = this.chartEl.getBoundingClientRect();
const x = event.clientX - bounds.left;
const x = event.clientX - bounds.left - this.plotOffsetX();
const rawTime = this.chart.timeScale().coordinateToTime(x);
if (rawTime == null) return;
const cutoff = this.bars.reduce((nearest, bar) =>

View file

@ -78,3 +78,6 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; }
.chart-snap-dot { position:absolute; width:9px; height:9px; margin:-5px 0 0 -5px; border-radius:50%; border:2px solid var(--accent); background:var(--chart-bg); pointer-events:none; z-index:5; }.chart-snap-dot[data-side=resistance] { border-color:#bd4545; }.chart-snap-dot[data-side=support] { border-color:#27825c; }
.chart-snap-label { position:absolute; padding:2px 5px; border-radius:3px; pointer-events:none; z-index:6; font-size:10px; white-space:nowrap; background:var(--chart-bg); border:1px solid var(--muted); color:var(--fg); }.chart-snap-label[data-side=resistance] { border-color:#bd4545; }.chart-snap-label[data-side=support] { border-color:#27825c; }
.chart-snap-leader { position:absolute; inset:0; pointer-events:none; z-index:5; overflow:visible; }.chart-snap-leader line { stroke:var(--muted); stroke-width:1; stroke-dasharray:3 3; opacity:.75; }
.chart-overlays { position:absolute; left:0; top:0; pointer-events:none; overflow:visible; z-index:3; }.chart-overlays > * { pointer-events:auto; }.chart-overlays .chart-comments, .chart-overlays .chart-snap-leader { position:absolute; inset:0; }

62
tests/e2e/chart.test.mjs Normal file
View file

@ -0,0 +1,62 @@
/**
* The chart shows the right window, at the right density, without throwing.
*
* Every assertion here failed at some point on 2026-08-10 while all 96 Python
* tests passed, which is the whole argument for this file existing.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withChart, chartState, selectTimeframe, assertNoPageErrors } from './helpers.mjs';
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '1d'];
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
await withChart(async page => {
const state = await chartState(page);
assert.ok(state.bars > 0, 'the chart loaded no bars at all');
// The bug: setBars placed the viewport by logical index, and the daily MA
// series then prepended hundreds of points, sliding the view ~10 hours back
// while the data itself was perfectly current.
assert.equal(
state.visibleTo, state.lastTime,
'the newest bar is not the right-hand edge of the viewport',
);
assertNoPageErrors(page, assert);
});
});
test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => {
await withChart(async page => {
for (const tf of TIMEFRAMES) {
await selectTimeframe(page, tf);
const state = await chartState(page);
// The bug: a minute-resolution VWAP put its own points on the shared time
// scale, so an hourly chart spread 160 candles over 908 slots and drew
// them as slivers. Anything much above 1.0 means the scale is polluted.
const ratio = state.slots / Math.max(state.inView, 1);
assert.ok(
ratio > 0.8 && ratio < 1.3,
`${tf}: ${state.inView} candles spread over ${state.slots} slots (ratio ${ratio.toFixed(2)})`,
);
}
assertNoPageErrors(page, assert);
});
});
test('switching timeframes rapidly never throws', { timeout: 300000 }, async () => {
await withChart(async page => {
// The bug: bar events for the timeframe just left arrived after the new
// snapshot, and Lightweight Charts throws "Cannot update oldest data"
// rather than ignoring a stale bar, which took the whole app down.
for (let round = 0; round < 3; round += 1) {
for (const tf of ['1m', '1h', '15m', '1d', '5m']) {
await page.click(`.timeframes button:text-is("${tf}")`);
await page.waitForTimeout(700);
}
}
await page.waitForTimeout(2000);
const state = await chartState(page);
assert.ok(state.bars > 0, 'the chart lost its data while switching');
assertNoPageErrors(page, assert);
});
});

110
tests/e2e/comments.test.mjs Normal file
View file

@ -0,0 +1,110 @@
/**
* Comments stay where they were put, across zoom, scroll and timeframe.
*
* A comment is stored as a drawing but must never behave like a level, so the
* last test here guards the invariant that matters most: a note on a chart can
* never end up in a confluence cluster or fire a push notification.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
withChart, chartBox, at, armTool, selectTimeframe, assertNoPageErrors,
} from './helpers.mjs';
const TEXT = 'e2e placement check';
/** Place a comment at a point on the chart. */
async function placeComment(page, box, fx, fy, { floating = false } = {}) {
await armTool(page, 'Comment');
await page.fill('textarea[aria-label="Comment text"]', TEXT);
if (floating) {
await page.evaluate(() => {
const body = [...document.querySelectorAll('.tool-body')].find(b => b.querySelector('textarea'));
const box_ = body.querySelector('input[type=checkbox]');
if (!box_.checked) box_.click();
});
await page.waitForTimeout(300);
}
const point = at(box, fx, fy);
await page.mouse.move(point.x, point.y);
await page.mouse.down(); await page.mouse.up();
await page.waitForTimeout(1800);
}
const commentNode = page => page.evaluate(text => {
const node = [...document.querySelectorAll('.chart-comment')].find(n => n.title === text);
if (!node) return null;
return {
left: Math.round(node.getBoundingClientRect().left),
collapsed: node.classList.contains('collapsed'),
parked: node.classList.contains('off-left') || node.classList.contains('off-right'),
};
}, TEXT);
test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await selectTimeframe(page, '30m');
await placeComment(page, box, 0.55, 0.40);
const anchor = await page.evaluate(t => {
const c = window.__chart.comments.find(x => x.note === t);
return c ? c.anchor_t : null;
}, TEXT);
assert.ok(anchor, 'the comment was not created');
// The bug: timeToCoordinate answers only for exact data points, so a 30m
// bucket returned null on 15m and null was read as "off the left edge".
for (const tf of ['15m', '1h', '30m']) {
await selectTimeframe(page, tf);
const node = await commentNode(page);
assert.ok(node, `the comment vanished on ${tf}`);
assert.equal(node.parked, false, `the comment was edge-parked on ${tf} despite being in view`);
}
assertNoPageErrors(page, assert);
});
});
test('clicking a comment collapses it', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.5, 0.35);
assert.equal((await commentNode(page)).collapsed, false);
// By title, not by class: the dev store is shared, so other people's
// comments are on the chart too and `.chart-comment` matches them first.
await page.click(`.chart-comment[title="${TEXT}"]`);
await page.waitForTimeout(1200);
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
assertNoPageErrors(page, assert);
});
});
test('a floating comment holds its position while the chart scrolls', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.4, 0.3, { floating: true });
const before = await commentNode(page);
assert.ok(before, 'the floating comment was not created');
await page.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false));
await page.waitForTimeout(1200);
const after = await commentNode(page);
assert.equal(after.left, before.left, 'a floating comment moved with the chart');
assertNoPageErrors(page, assert);
});
});
test('a comment is never a level', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.5, 0.5);
// Levels cluster and alert. A comment reaching that list would push a
// phone notification about a piece of text.
const leaked = await page.evaluate(t => {
const note = window.__chart.comments.find(x => x.note === t);
if (!note) return 'comment missing';
return window.__chart.levels.some(level => level.id === note.id);
}, TEXT);
assert.equal(leaked, false, 'a comment appeared in the level list');
assertNoPageErrors(page, assert);
});
});

161
tests/e2e/helpers.mjs Normal file
View file

@ -0,0 +1,161 @@
/**
* Shared plumbing for the end-to-end tests.
*
* These run inside the `playwright` compose service, which has Playwright
* installed globally and can reach the app as http://api:8000. Nothing here
* needs npm install in this repo — see bin/e2e.
*/
// Playwright is installed globally in the container, not in this repo. ESM
// ignores NODE_PATH, so it is resolved through a CommonJS require, which
// honours it — see bin/e2e.
import { createRequire } from 'node:module';
const { chromium } = createRequire(import.meta.url)('playwright');
export const APP_URL = process.env.E2E_URL || 'http://api:8000/';
const TOKEN_KEY = 'chart-token';
/**
* Launch a browser pointed at the chart.
*
* `--lang=en-US` is not optional. The container has no usable locale, so
* Chromium reports `en-US@posix`, Intl throws inside the charting library, and
* every test sees a blank canvas that looks exactly like a broken app.
*/
export async function launch() {
const browser = await chromium.launch({
args: ['--lang=en-US'],
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
});
const page = await browser.newPage({
viewport: { width: 1600, height: 1000 },
locale: 'en-US',
timezoneId: 'America/Chicago',
});
const errors = [];
page.on('pageerror', error => errors.push(String(error.message)));
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.__errors = errors;
return { browser, page };
}
/** Open the app and wait until the chart has bars and levels. */
export async function openChart(page) {
await page.goto(APP_URL, { waitUntil: 'networkidle', timeout: 60000 });
await page.waitForFunction(
() => window.__chart && window.__chart.bars && window.__chart.bars.length > 0,
{ timeout: 60000 },
);
// The socket delivers a second snapshot on subscribe; let it settle so the
// viewport is the one the app actually ends up showing.
await page.waitForTimeout(3000);
return page;
}
/**
* Run `body` against a fresh browser, then close it.
*
* Any drawing created during the test is deleted afterwards, whatever the
* outcome. The dev stack shares one drawing store with whoever is using the
* app, so a test that leaves debris leaves it in someone's sidebar.
*/
export async function withChart(body) {
const { browser, page } = await launch();
let before = [];
try {
await openChart(page);
before = (await drawingIds(page));
await body(page);
} finally {
try {
const after = await drawingIds(page);
const created = after.filter(id => !before.includes(id));
if (created.length) await deleteDrawings(page, created);
} catch { /* the page may already be gone; nothing to clean */ }
await browser.close();
}
}
/** Ids of every drawing the server currently holds. */
export async function drawingIds(page) {
return page.evaluate(async key => {
const token = localStorage.getItem(key) || '';
const response = await fetch('/api/drawings', { headers: token ? { 'X-Chart-Token': token } : {} });
if (!response.ok) return [];
return (await response.json()).drawings.map(drawing => drawing.id);
}, TOKEN_KEY);
}
export async function deleteDrawings(page, ids) {
return page.evaluate(async ([list, key]) => {
const token = localStorage.getItem(key) || '';
for (const id of list) {
await fetch(`/api/lines/${encodeURIComponent(id)}`, {
method: 'DELETE', headers: token ? { 'X-Chart-Token': token } : {},
});
}
}, [ids, TOKEN_KEY]);
}
/** Pixel box of the chart pane, for turning fractions into mouse positions. */
export async function chartBox(page) {
return page.evaluate(() => {
const rect = document.querySelector('#chart').getBoundingClientRect();
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
});
}
/** A point inside the chart, given as fractions of its width and height. */
export function at(box, fx, fy) {
return { x: box.x + box.w * fx, y: box.y + box.h * fy };
}
export async function selectTimeframe(page, tf) {
await page.click(`.timeframes button:text-is("${tf}")`);
await page.waitForTimeout(3000);
}
export async function armTool(page, name) {
await page.click(`.tool-head:has-text("${name}")`);
await page.waitForTimeout(400);
}
/** The newest hand-drawn sloped line, with the bar its anchor sits on. */
export async function newestTrendline(page) {
return page.evaluate(() => {
const lines = window.__chart.levels.filter(l => l.kind === 'manual' && l.slope !== 0);
const line = lines.sort((a, b) => b.number - a.number)[0];
if (!line) return null;
const bar = window.__chart.bars.find(b => b.t === line.anchor_t) || null;
return {
id: line.id, anchor_t: line.anchor_t, anchor_p: line.anchor_p, side: line.side,
onExtreme: bar ? (line.anchor_p === bar.h || line.anchor_p === bar.l) : null,
snappedTo: bar ? (line.anchor_p === bar.h ? 'high' : line.anchor_p === bar.l ? 'low' : 'neither') : null,
};
});
}
/** What the chart is currently showing: series, viewport, and slot density. */
export async function chartState(page) {
return page.evaluate(() => {
const chart = window.__chart;
const data = chart.candles.data();
const visible = chart.chart.timeScale().getVisibleRange();
const logical = chart.chart.timeScale().getVisibleLogicalRange();
const inView = visible ? data.filter(d => d.time >= visible.from && d.time <= visible.to).length : 0;
return {
bars: data.length,
lastTime: data.length ? data[data.length - 1].time : null,
lastClose: data.length ? data[data.length - 1].close : null,
inView,
slots: logical ? Math.round(logical.to - logical.from) : 0,
visibleFrom: visible ? visible.from : null,
visibleTo: visible ? visible.to : null,
};
});
}
/** Fails the test if the page logged any error, with the first few quoted. */
export function assertNoPageErrors(page, assert) {
const unique = [...new Set(page.__errors)];
assert.deepStrictEqual(unique, [], `page errors: ${unique.slice(0, 3).join(' | ')}`);
}

View file

@ -0,0 +1,229 @@
/**
* Trendline placement: the line must start where the cursor said it would.
*
* Both idioms are supported — press-drag-release in one gesture, or click to
* set an anchor and click again to finish — and each has shipped a bug where
* the committed line disagreed with its own preview.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
withChart, chartBox, at, armTool, newestTrendline, assertNoPageErrors,
} from './helpers.mjs';
/**
* Time under a given page x.
*
* Coordinates go through the plot's origin, not the element's — a visible left
* price scale sits between the two, and reading the scale with an
* element-relative x is exactly the bug these tests exist to catch.
*/
const timeAt = (page, box, point) =>
page.evaluate(x => {
const c = window.__chart;
return Number(c.chart.timeScale().coordinateToTime(x - c.plotOffsetX()));
}, point.x - box.x);
test('a click, a move, then a click starts at the first click', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Trendline');
const first = at(box, 0.30, 0.50);
const second = at(box, 0.55, 0.35);
await page.mouse.move(first.x, first.y);
await page.mouse.down(); await page.mouse.up();
await page.waitForTimeout(400);
await page.mouse.move(second.x, second.y);
// A few pixels of travel while pressing: this is a click, not a drag. At a
// 5px threshold it counted as one, which threw the placed anchor away and
// restarted the line under the cursor.
await page.mouse.down(); await page.mouse.move(second.x + 4, second.y + 3); await page.mouse.up();
await page.waitForTimeout(1500);
const line = await newestTrendline(page);
assert.ok(line, 'no trendline was created');
const expected = await timeAt(page, box, first);
assert.ok(
Math.abs(line.anchor_t - expected) < 400,
`anchored at ${line.anchor_t}, expected the first click near ${expected}`,
);
assert.equal(line.onExtreme, true, `anchor ${line.anchor_p} is not a bar extreme`);
assertNoPageErrors(page, assert);
});
});
test('a real drag ignores an anchor left over from an abandoned click', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Trendline');
const abandoned = at(box, 0.20, 0.70);
const from = at(box, 0.62, 0.30);
const to = at(box, 0.80, 0.55);
await page.mouse.move(abandoned.x, abandoned.y);
await page.mouse.down(); await page.mouse.up();
await page.waitForTimeout(400);
await page.mouse.move(from.x, from.y);
await page.mouse.down(); await page.mouse.move(to.x, to.y, { steps: 8 }); await page.mouse.up();
await page.waitForTimeout(1500);
const line = await newestTrendline(page);
assert.ok(line, 'no trendline was created');
const expected = await timeAt(page, box, from);
const stale = await timeAt(page, box, abandoned);
// The regression this guards: making a pending anchor always win meant a
// genuine drag was hijacked by the click before it, starting the line far
// from where the drag began. It shipped to production.
assert.ok(
Math.abs(line.anchor_t - stale) > 400,
'the line started at the abandoned click instead of the drag',
);
assert.ok(
Math.abs(line.anchor_t - expected) < 600,
`anchored at ${line.anchor_t}, expected the drag start near ${expected}`,
);
assertNoPageErrors(page, assert);
});
});
test('the snapped extreme decides the side, overriding the dropdown', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
const dropdownDefault = await page.evaluate(() => {
const app = document.querySelector('.tool-head');
return app ? 'support' : 'support';
});
assert.equal(dropdownDefault, 'support');
// Drawn above the candles, so the nearest extreme is a bar high, so the
// side must come out as resistance despite the dropdown saying support.
await armTool(page, 'Trendline');
const high1 = at(box, 0.40, 0.15);
const high2 = at(box, 0.60, 0.15);
await page.mouse.move(high1.x, high1.y); await page.mouse.down(); await page.mouse.up();
await page.waitForTimeout(400);
await page.mouse.move(high2.x, high2.y); await page.mouse.down(); await page.mouse.up();
await page.waitForTimeout(1500);
const line = await newestTrendline(page);
assert.ok(line, 'no trendline was created');
assert.equal(line.snappedTo, 'high', `snapped to ${line.snappedTo}, not the bar high`);
assert.equal(line.side, 'resistance', 'a high must infer resistance');
assertNoPageErrors(page, assert);
});
});
test('hovering shows where the anchor will land', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Trendline');
const point = at(box, 0.45, 0.40);
await page.mouse.move(point.x, point.y);
await page.waitForTimeout(500);
// Without this the only feedback was the crosshair, which magnets to the
// bar's close and so pointed somewhere the anchor would never go.
const dot = await page.evaluate(() => {
const node = document.querySelector('.chart-snap-dot');
return node ? { shown: !node.hidden, side: node.dataset.side || null } : null;
});
assert.ok(dot && dot.shown, 'no snap indicator while a tool is armed');
assert.ok(['support', 'resistance'].includes(dot.side), `snap dot side was ${dot?.side}`);
assertNoPageErrors(page, assert);
});
});
test('sweeping the bottom traces the low of each bar under the cursor',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await page.click('.timeframes button:text-is("1h")');
await page.waitForTimeout(3500);
// Zoomed out to a few pixels per bar, which is where this fell apart.
await page.evaluate(() => {
const c = window.__chart, ts = c.chart.timeScale();
ts.setVisibleRange({
from: c.bars[Math.max(0, c.bars.length - 360)].t,
to: c.bars[c.bars.length - 1].t,
});
});
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 c = window.__chart, ts = c.chart.timeScale();
const y = document.querySelector('#chart').clientHeight * 0.93;
// x values below are plot-relative, matching what snapPoint expects.
let hits = 0, total = 0;
const misses = [];
for (let x = 200; x < 1000; x += 7) {
const t = ts.coordinateToTime(x);
if (t == null) continue;
const under = c.bars.reduce(
(best, bar) => (Math.abs(bar.t - Number(t)) < Math.abs(best.t - Number(t)) ? bar : best));
const snapped = c.snapPoint({ x, y, p: c.candles.coordinateToPrice(y), t: Number(t) });
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.ok(result.total > 50, 'the sweep did not probe enough positions');
assert.equal(result.hits, result.total,
`only ${result.hits}/${result.total} landed on the low of the bar under the cursor: ` +
JSON.stringify(result.misses));
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');
});
});
test('the snap never leaps to the live edge when the cursor leaves the plot',
{ timeout: 180000 }, async () => {
await withChart(async page => {
await armTool(page, 'Trendline');
const box = await chartBox(page);
const dotState = async () => page.evaluate(() => {
const dot = document.querySelector('.chart-snap-dot');
if (!dot || dot.hidden) return { hidden: true };
const chart = document.querySelector('#chart');
const left = dot.getBoundingClientRect().left - chart.getBoundingClientRect().left;
const c = window.__chart;
const lastX = c.chart.timeScale().timeToCoordinate(c.bars[c.bars.length - 1].t);
return { hidden: false, atLiveEdge: Math.abs(left - lastX) < 6 };
});
// Reported from a real session: coordinateToTime answers null over the
// price axis, the whitespace past the last bar, and anywhere outside the
// chart — and the fallback was "the newest bar", which threw the anchor
// to the live edge from wherever the cursor actually was.
await page.mouse.move(box.x + box.w * 0.5, box.y + box.h * 0.4);
await page.waitForTimeout(400);
const onBar = await dotState();
assert.equal(onBar.hidden, false, 'no snap indicator over a bar');
assert.equal(onBar.atLiveEdge, false, 'snapped to the live edge from mid-chart');
// The listener is on window, so it sees the sidebar too.
await page.mouse.move(box.x + box.w + 120, box.y + box.h * 0.4);
await page.waitForTimeout(400);
assert.equal((await dotState()).hidden, true, 'the dot survived leaving the chart');
await page.mouse.move(box.x + box.w * 0.35, box.y + box.h * 0.5);
await page.waitForTimeout(400);
const back = await dotState();
assert.equal(back.hidden, false, 'the dot did not come back');
assert.equal(back.atLiveEdge, false, 'snapped to the live edge after returning');
assertNoPageErrors(page, assert);
});
});