From 91677127a0557f58ec0852417a303345d1fd9a48 Mon Sep 17 00:00:00 2001 From: Chris Amow Date: Tue, 11 Aug 2026 04:08:02 -0500 Subject: [PATCH] Unify chart geometry and deepen 30m history --- .env.example | 1 + README.md | 6 +- app/api/meta.py | 4 +- app/config.py | 1 + app/market/yahoo.py | 4 +- app/runtime.py | 6 ++ docs/IMPLEMENTATION_PLAN.md | 44 ++++++++ docs/NEXT_STEPS.md | 30 +----- docs/mobile_enhance.md | 13 ++- static/app.js | 20 +++- static/chart.js | 184 +++++++++++++++++++++------------ static/index.html | 1 + static/style.css | 3 +- tests/e2e/chart.test.mjs | 22 ++++ tests/e2e/price-scale.test.mjs | 70 +++++++++++++ tests/e2e/trendline.test.mjs | 100 ++++++++++++++---- tests/test_runtime_start.py | 30 ++++++ tests/test_yahoo.py | 20 ++++ 18 files changed, 434 insertions(+), 125 deletions(-) create mode 100644 tests/e2e/price-scale.test.mjs create mode 100644 tests/test_runtime_start.py diff --git a/.env.example b/.env.example index e4d2232..c0d4cbe 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,7 @@ SEED_SOURCE=yahoo YAHOO_SYMBOL=ES=F YAHOO_POLL_SECONDS=20 SEED_1H_RANGE=730d +SEED_30M_RANGE=60d SEED_1M_RANGE=8d # Schwab. Only read once LIVE_SOURCE=schwab; blank is fine until then. diff --git a/README.md b/README.md index 556e288..6558344 100644 --- a/README.md +++ b/README.md @@ -277,8 +277,10 @@ Both sources run together. This is the intended configuration, not a fallback: - **Schwab** streams real-time `/ES` minute bars over `CHART_FUTURES` (`delayed: false`), but serves **no futures history at all** — everything it knows starts when you connect. -- **Yahoo** has roughly 730 days of hourly data, which is what makes a 200-day - moving average warm at startup rather than in ten months. It lags ~10 minutes. +- **Yahoo** has roughly 730 days of hourly data, 60 days of native 30-minute + bars, and eight days of minute data. Hourly history makes a 200-day moving + average warm at startup; the native 30-minute seed avoids limiting that chart + to the minute endpoint's eight-day window. Yahoo lags ~10 minutes. Switch the live feed with `LIVE_SOURCE=schwab`; seeding stays on Yahoo whatever you set, because Schwab has nothing to seed from. The symbols differ — Yahoo says diff --git a/app/api/meta.py b/app/api/meta.py index 916f1bd..9093b6c 100644 --- a/app/api/meta.py +++ b/app/api/meta.py @@ -6,6 +6,7 @@ commit live yet". Neither endpoint exposes anything about the market data or the configuration. """ import os +from datetime import datetime, timezone from fastapi import APIRouter @@ -13,6 +14,7 @@ router = APIRouter(prefix="/api") # Coolify injects the deployed commit; absent when running locally. SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev") +STARTED_AT = datetime.now(timezone.utc).replace(microsecond=0).isoformat() @router.get("/health") @@ -22,4 +24,4 @@ def health(): @router.get("/version") def version(): - return {"commit": SOURCE_COMMIT} + return {"commit": SOURCE_COMMIT, "started_at": STARTED_AT} diff --git a/app/config.py b/app/config.py index a407a3e..f7f5412 100644 --- a/app/config.py +++ b/app/config.py @@ -25,6 +25,7 @@ class Settings(BaseSettings): yahoo_symbol: str = "ES=F" yahoo_poll_seconds: float = 20 seed_1h_range: str = "730d" + seed_30m_range: str = "60d" seed_1m_range: str = "8d" timeframes: str = "1m,5m,15m,30m,1h,1d" base_timeframes: str = "1m,30m,1d" diff --git a/app/market/yahoo.py b/app/market/yahoo.py index 1de3064..3b9641f 100644 --- a/app/market/yahoo.py +++ b/app/market/yahoo.py @@ -81,8 +81,8 @@ class YahooSource: *, range_: str | None = None, ) -> list[Bar]: - if tf not in (Timeframe.M1, Timeframe.H1): - raise ValueError("YahooSource history supports only 1m and 1h inputs") + if tf not in (Timeframe.M1, Timeframe.M30, Timeframe.H1): + raise ValueError("YahooSource history supports only 1m, 30m and 1h inputs") interval = tf.value if range_ is not None: return parse_chart( diff --git a/app/runtime.py b/app/runtime.py index 9e5d397..e88096d 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -239,6 +239,12 @@ class Runtime: await self.stream.seed( source, Timeframe.H1, self.settings.seed_1h_range, seed_symbol ) + # One-hour history cannot reconstruct a 30-minute candle. Yahoo + # supplies roughly 60 days natively; the newer overlap is replaced + # below by bars aggregated from the finer 1m seed. + await self.stream.seed( + source, Timeframe.M30, self.settings.seed_30m_range, seed_symbol + ) await self.stream.seed( source, Timeframe.M1, self.settings.seed_1m_range, seed_symbol ) diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 9a04a86..0cae3ba 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -1825,3 +1825,47 @@ it, the implementation/session log and `docs/mobile_enhance.md` directly from `AGENTS.md`, which every coding agent reads before working in this repository. This keeps current advice visible without turning the historical implementation plan into an undifferentiated backlog. + +### 2026-08-11 03:39 CDT — one price scale, left labels and editable-line snapping + +The left side was introduced to keep daily moving-average, VWAP and prior-day +labels away from the intraday labels on the right. That decluttering intent was +correct; implementing it as a second Lightweight Charts price scale was not. +Each scale autoscaled independently once its own overlays were attached, so the +same numeric price could occupy a different y-coordinate on each side. Daily +and intraday structure then looked directly comparable while being geometrically +unrelated. + +All price-bearing series and flat levels now share the candle series' right +scale. Long-term labels remain on the left as DOM overlays whose y positions are +computed through `candles.priceToCoordinate()`, so they preserve the intended +separation without creating another coordinate system. Context series suppress +their built-in right-side titles. A browser invariant verifies every overlay's +scale id, every flat level's host, and sub-pixel agreement between an MA price +and the candle coordinate for that same price. + +Editing a selected trendline had a separate defect: handle dragging used +element-relative x, snapped only to the nearest bar time, and retained a raw +cursor price. Initial placement used plot-relative coordinates and high/low +snapping, so moving an anchor could visibly jump off the bar and change the line +to an unusable slope. Placement, selection, handle dragging and context actions +now all use `eventPoint()` against the measured plot canvas. Handle movement +passes through the same `snapPoint()` high/low rule and displays the same snap +feedback as placement. This also closes the known right-price-axis containment +bug; a page-region test now proves the dot disappears over the actual axis and +returns over the plot. + +The 30-minute chart had only about one week of history because it was derived +solely from Yahoo's eight-day 1-minute seed. The 730-day hourly seed cannot +reconstruct half-hour candles. Yahoo's native `30m` interval was verified live +at `range=60d` (2,843 bars on 2026-08-11), so startup now seeds H1/730d, +M30/60d, then M1/8d. The finer minute aggregation replaces the recent overlap; +the native feed supplies older 30-minute bars. Regression tests pin both Yahoo's +native interval request and the startup seed sequence. Final verification: 105 +backend tests and 22 browser tests passing. + +Daily moving averages had also been rendered with `WithSteps` on every base +timeframe. Holding a daily value constant is correct when projecting it over +intraday candles, but on the daily chart it made the SMA itself look like a +staircase. MA line type is now timeframe-aware: stepped on intraday charts and +simple point-to-point lines on `1d`, with both modes pinned by browser coverage. diff --git a/docs/NEXT_STEPS.md b/docs/NEXT_STEPS.md index 8b9267c..9a03c10 100644 --- a/docs/NEXT_STEPS.md +++ b/docs/NEXT_STEPS.md @@ -1,31 +1,12 @@ # Current recommendations -Last reviewed: 2026-08-11 03:19 CDT. +Last reviewed: 2026-08-11 03:39 CDT. This file is the short list of work worth considering next. Verified history, measurements and completed work remain in `docs/IMPLEMENTATION_PLAN.md`. ## Fix next -### Keep snapping out of the price axes - -`ConfluenceChart.withinPlot()` currently checks plot-relative coordinates -against `chartEl.clientWidth` and `clientHeight`. The element also contains the -price and time axes, so hovering the right price axis can leave the snap dot -visible at the plot edge. - -Recommended fix: - -1. Compare against the measured plot canvas width and height. -2. Add a Playwright page-pixel test that moves from a bar into the actual right - axis rectangle and expects the snap dot to disappear, then return over the - plot and expect it to reappear. -3. Reuse the same plot-relative conversion for selection and anchor dragging; - do not add another coordinate frame. - -This was discovered by test research but deliberately not committed as a -failing test because it requires production code to correct. - ### Show unambiguous market freshness The best location is the status strip directly below the chart. Keep the header @@ -65,12 +46,11 @@ subtract Yahoo's declared delay before calling delayed data overdue. The detailed plan is in `docs/mobile_enhance.md`. Recommended first tranche: -1. Use one plot-relative pointer path for placement, selection and anchor drag. -2. Add touch-sized invisible hit regions without enlarging the visual marks. -3. Keep the first tap's trendline anchor visible with price/time, Cancel and +1. Add touch-sized invisible hit regions without enlarging the visual marks. +2. Keep the first tap's trendline anchor visible with price/time, Cancel and Undo anchor. -4. Add a compact sticky mobile tool rail next to the chart. -5. Add a selected-drawing action bar so End here and Delete do not require +3. Add a compact sticky mobile tool rail next to the chart. +4. Add a selected-drawing action bar so End here and Delete do not require right-click or a hardware keyboard. Prefer tap-tap trendlines on touch devices. Reserve one-finger vertical movement diff --git a/docs/mobile_enhance.md b/docs/mobile_enhance.md index 07532e8..1f8ac52 100644 --- a/docs/mobile_enhance.md +++ b/docs/mobile_enhance.md @@ -68,9 +68,12 @@ can remain shortcuts. ### P0: One plot-relative coordinate path -Placement, selection, anchor dragging, comments, and context actions should all -use the same plot-relative pointer conversion. Build mobile hit regions around -painted geometry rather than compensating for different coordinate frames. +Status: completed 2026-08-11. Placement, selection, anchor dragging and context +actions now use the measured plot canvas as their shared pointer frame. + +Placement, selection, anchor dragging, and context actions use the same +plot-relative pointer conversion. Keep future chart-bound interactions in that +frame and build mobile hit regions around painted geometry. Complexity: medium. This is a correctness improvement on both mobile and desktop and should have page-pixel regression coverage. @@ -144,8 +147,8 @@ invariants rather than arbitrary CSS dimensions. ## Incremental rollout -1. Add mobile diagnostics/tests, unify pointer coordinates, and enlarge - invisible hit regions. +1. Add mobile diagnostics/tests and enlarge invisible hit regions. Pointer + coordinates are already unified for chart drawing interactions. 2. Add persistent first-anchor feedback, Cancel, and Undo anchor. 3. Add the mobile tool rail, keyboard-safe sizing, and chart/page gesture policy. 4. Add the selected-drawing action bar and touch-accessible End here flow. diff --git a/static/app.js b/static/app.js index fffb055..8f28c47 100644 --- a/static/app.js +++ b/static/app.js @@ -71,6 +71,7 @@ function loadPrefs() { createApp({ setup() { const status = ref({ stream: 'disconnected', bars_held: {} }); + const deployment = ref(null); const price = ref(null); const prefs = ref(loadPrefs()); const timeframe = ref(prefs.value.base_tf || '1m'); @@ -110,6 +111,17 @@ createApp({ const seconds = Math.max(0, Math.floor(now.value / 1000 - status.value.last_bar_t)); return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`; }); + const updatedAt = computed(() => { + if (!deployment.value?.started_at) return '—'; + return new Intl.DateTimeFormat(undefined, { + month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', + }).format(new Date(deployment.value.started_at)); + }); + const deploymentTitle = computed(() => { + if (!deployment.value?.started_at) return ''; + const timestamp = new Date(deployment.value.started_at).toLocaleString(); + return `Running since ${timestamp} · ${deployment.value.commit}`; + }); const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual')); const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); @@ -150,6 +162,11 @@ createApp({ if (response.ok) status.value = await response.json(); } + async function refreshDeployment() { + const response = await apiFetch('/api/version'); + if (response.ok) deployment.value = await response.json(); + } + function connect() { const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const query = authToken ? `?token=${encodeURIComponent(authToken)}` : ''; @@ -541,6 +558,7 @@ createApp({ refreshComments(); window.addEventListener('keydown', handleKeydown); refreshStatus(); + refreshDeployment(); connect(); timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000); }); @@ -551,6 +569,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; + return { status, price, barAge, updatedAt, deploymentTitle, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index b5c92ba..f9d1e22 100644 --- a/static/chart.js +++ b/static/chart.js @@ -43,6 +43,8 @@ class ConfluenceChart { this.comments = []; this.commentNodes = new Map(); this.commentLayer = null; + this.contextLabelLayer = null; + this.contextLabelFrame = null; this.onCommentToggle = null; this.onCommentMove = null; } @@ -78,6 +80,7 @@ class ConfluenceChart { layout: { background: { color: getComputedStyle(document.documentElement).getPropertyValue('--chart-bg').trim() }, textColor: getComputedStyle(document.documentElement).getPropertyValue('--muted').trim(), + attributionLogo: false, }, grid: { vertLines: { color: 'rgba(128,128,128,.10)' }, @@ -112,8 +115,10 @@ class ConfluenceChart { }), }, rightPriceScale: { borderVisible: false }, - // Daily context lives on the left, intraday on the right. - leftPriceScale: { visible: true, borderVisible: 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. + leftPriceScale: { visible: 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 @@ -125,16 +130,6 @@ class ConfluenceChart { borderUpColor: '#1d6849', borderDownColor: '#963737', wickUpColor: '#1d6849', wickDownColor: '#963737', }); - // A price scale derives its range from the series on it, so levels moved to - // the left would be drawn against a different range and sit at the wrong - // height. This transparent copy of the candles gives the left scale exactly - // the same input as the right, which keeps one price at one y. - this.leftMirror = this.chart.addSeries(LightweightCharts.CandlestickSeries, { - priceScaleId: 'left', - upColor: 'transparent', downColor: 'transparent', borderVisible: false, - wickUpColor: 'transparent', wickDownColor: 'transparent', - lastValueVisible: false, priceLineVisible: false, - }); // Volume as an overlay on its own hidden scale, confined to the bottom // fifth. An overlay rather than a pane so it cannot alter the price scale: // volumes are five figures and prices four, and sharing a scale would @@ -153,25 +148,28 @@ class ConfluenceChart { // 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.observePlotCanvas(); this.renderAnchorHandles(); this.renderComments(); + this.renderContextLabels(); }); }); 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. + // there lets each overlay use those coordinates untranslated. Keep the + // measurement even with no left scale: future layout changes must not + // silently displace snap indicators, comments or trendline handles. this.overlayLayer = document.createElement('div'); this.overlayLayer.className = 'chart-overlays'; el.appendChild(this.overlayLayer); + this.contextLabelLayer = document.createElement('div'); + this.contextLabelLayer.className = 'chart-context-labels'; + this.overlayLayer.appendChild(this.contextLabelLayer); this.tooltip = document.createElement('div'); this.tooltip.className = 'chart-tooltip'; this.overlayLayer.appendChild(this.tooltip); @@ -256,13 +254,14 @@ class ConfluenceChart { // whatever the new line happens to overlap. if (this.armedTool) return; if (!this.onChartClick) return; - const bounds = el.getBoundingClientRect(); - const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }; - const time = this.chart.timeScale().coordinateToTime(point.x); - if (time != null) this.onChartClick({ point, time }); + const point = this.eventPoint(event); + if (point && this.withinPlot(point) && point.t != null) { + this.onChartClick({ point, time: point.t }); + } }; el.addEventListener('click', this.clickListener); this.chart.subscribeCrosshairMove(param => { + this.scheduleContextLabels(); if (!param.point || !param.time) { this.tooltip.hidden = true; return; @@ -273,6 +272,7 @@ class ConfluenceChart { this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => { this.renderAnchorHandles(); this.renderComments(); + this.renderContextLabels(); }); } @@ -280,7 +280,6 @@ class ConfluenceChart { this.bars = bars; const candleData = bars.map(this.toCandle); this.candles.setData(candleData); - this.leftMirror.setData(candleData); this.volume.setData(bars.map(ConfluenceChart.toVolume)); // Anchored by time, not by logical index. A logical index addresses the // chart's *shared* scale — the union of every series' time points — not @@ -301,6 +300,7 @@ class ConfluenceChart { requestAnimationFrame(() => this.renderAnchorHandles()); // The bar grid just changed underneath every pinned comment. this.renderComments(); + this.renderContextLabels(); } updateBar(bar) { @@ -311,11 +311,11 @@ class ConfluenceChart { const last = this.bars[this.bars.length - 1]; if (last && bar.t < last.t) return; this.candles.update(this.toCandle(bar)); - this.leftMirror.update(this.toCandle(bar)); this.volume.update(ConfluenceChart.toVolume(bar)); if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar; else this.bars.push(bar); this.renderAnchorHandles(); + this.renderContextLabels(); } setComments(comments) { @@ -448,6 +448,10 @@ class ConfluenceChart { return level.kind === 'horizontal' || (level.kind === 'manual' && level.slope === 0); } + static isContextLevel(level) { + return level.kind === 'ma' || level.kind === 'vwap' || level.kind === 'horizontal'; + } + // Flat levels are drawn as price lines rather than two-point series: they // span the whole chart regardless of scroll and get a price-axis label. syncPriceLines(levels) { @@ -465,16 +469,13 @@ class ConfluenceChart { color: ConfluenceChart.levelColor(level), lineWidth: level.line_width || 1, lineStyle: LightweightCharts.LineStyle.Dashed, - axisLabelVisible: true, - title: level.label, + axisLabelVisible: level.kind !== 'horizontal', + title: ConfluenceChart.isContextLevel(level) ? '' : level.label, }; const existing = this.priceLines.get(level.id); if (existing) existing.line.applyOptions(options); else { - // Prior-day levels are daily context and move to the left scale; a - // hand-drawn price level is intraday and keeps the right, which is the - // side being kept clear for it. - const host = level.kind === 'horizontal' ? this.leftMirror : this.candles; + const host = this.candles; this.priceLines.set(level.id, { host, line: host.createPriceLine(options) }); } } @@ -534,25 +535,27 @@ class ConfluenceChart { // value between closes while VWAP moves continuously. const hasPoints = level.kind === 'ma' || level.kind === 'vwap'; const isMa = level.kind === 'ma'; + const dailyChart = this.bars[0]?.tf === '1d'; const options = { color: ConfluenceChart.levelColor(level), lineWidth: level.line_width || (level.tf === '1d' ? 2 : 1), - lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple, + // A daily value is held constant while projected over intraday bars, + // but on the daily chart each point is a new sample and should connect + // normally rather than drawing a staircase. + lineType: isMa && !dailyChart + ? LightweightCharts.LineType.WithSteps + : LightweightCharts.LineType.Simple, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, priceLineVisible: false, - lastValueVisible: level.cutoff_t == null, - title: level.label, + lastValueVisible: !ConfluenceChart.isContextLevel(level) && level.cutoff_t == null, + title: ConfluenceChart.isContextLevel(level) ? '' : level.label, // Overlays outside the visible price range must not flatten the candles. autoscaleInfoProvider: () => null, }; if (!entry) { - // Daily averages and session VWAP are daily/session context, so their - // last-value labels belong on the left. priceScaleId is fixed at - // creation, which is why it is not in the options reapplied below. - const scale = (isMa || level.kind === 'vwap') ? 'left' : 'right'; entry = { series: this.chart.addSeries(LightweightCharts.LineSeries, - { ...options, priceScaleId: scale }), + { ...options, priceScaleId: 'right' }), }; this.levelSeries.set(level.id, entry); } else { @@ -582,6 +585,50 @@ class ConfluenceChart { this.pendingView = null; } this.renderAnchorHandles(); + this.renderContextLabels(); + } + + renderContextLabels() { + if (!this.contextLabelLayer || !this.candles) return; + const height = this.plotCanvas()?.getBoundingClientRect().height || 0; + const labels = this.levels + .filter(level => !level.hidden && ConfluenceChart.isContextLevel(level)) + .map(level => { + const points = level.points || []; + const value = level.current_p ?? points[points.length - 1]?.[1] ?? level.anchor_p; + const y = value == null ? null : this.candles.priceToCoordinate(value); + return { level, value, y }; + }) + .filter(item => item.y != null && item.y >= 0 && item.y <= height) + .sort((a, b) => a.y - b.y); + + const gap = 18; + const top = 10; + const bottom = Math.max(top, height - 10); + labels.forEach((item, index) => { + item.placedY = index === 0 ? Math.max(top, item.y) : Math.max(item.y, labels[index - 1].placedY + gap); + }); + if (labels.length && labels[labels.length - 1].placedY > bottom) { + const shift = labels[labels.length - 1].placedY - bottom; + labels.forEach(item => { item.placedY -= shift; }); + } + + this.contextLabelLayer.replaceChildren(...labels.map(({ level, value, y, placedY }) => { + const node = document.createElement('div'); + node.className = 'chart-context-label'; + node.textContent = `${level.label} ${Number(value).toFixed(2)}`; + node.style.top = `${Math.max(top, placedY)}px`; + node.style.borderColor = ConfluenceChart.levelColor(level); + return node; + })); + } + + scheduleContextLabels() { + if (this.contextLabelFrame != null) return; + this.contextLabelFrame = requestAnimationFrame(() => { + this.contextLabelFrame = null; + this.renderContextLabels(); + }); } // --- tool arming and placement gestures --------------------------------- @@ -608,11 +655,9 @@ class ConfluenceChart { /** * 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. + * Lightweight Charts measures coordinates from the plot, not the element. + * The helper remains the single source of truth if axes or panes change the + * plot bounds again. */ /** Put the overlay container exactly over the plot area. */ syncOverlayLayer() { @@ -630,10 +675,9 @@ class ConfluenceChart { /** * Keep overlays aligned as Lightweight Charts sizes its internal price scales. * - * The chart element itself does not resize when a price label makes the left - * scale wider; the plot canvas does. Observing the element therefore leaves a - * stale offset from the first layout pass, while observing this canvas catches - * both initial settling and later digit-width changes. + * The chart element itself does not resize when an axis label changes the + * plot bounds; the plot canvas does. Observing the canvas catches both initial + * settling and later digit-width changes. */ observePlotCanvas() { const canvas = this.plotCanvas(); @@ -645,6 +689,7 @@ class ConfluenceChart { this.syncOverlayLayer(); this.renderAnchorHandles(); this.renderComments(); + this.renderContextLabels(); if (this.armedTool) this.renderGesture(); }); this.plotResizeObserver.observe(canvas); @@ -666,9 +711,10 @@ class ConfluenceChart { } eventPoint(event) { - const bounds = this.chartEl.getBoundingClientRect(); - const x = event.clientX - bounds.left - this.plotOffsetX(); - const y = event.clientY - bounds.top; + const plot = this.plotCanvas()?.getBoundingClientRect(); + if (!plot) return null; + const x = event.clientX - plot.left; + const y = event.clientY - plot.top; const price = this.candles.coordinateToPrice(y); if (price == null) return null; const time = this.chart.timeScale().coordinateToTime(x); @@ -871,9 +917,10 @@ class ConfluenceChart { */ /** 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; + const plot = this.plotCanvas()?.getBoundingClientRect(); + if (!plot) return true; + const width = plot.width; + const height = plot.height; return point.x >= 0 && point.x <= width && point.y >= 0 && point.y <= height; } @@ -1099,21 +1146,23 @@ class ConfluenceChart { event.preventDefault(); event.stopPropagation(); this.draggingAnchor = { id: this.selectedLineId, anchor }; + event.currentTarget.setPointerCapture?.(event.pointerId); } moveAnchor(event) { if (!this.draggingAnchor || !this.chartEl) return; const level = this.levels.find(value => value.id === this.draggingAnchor.id); if (!level || !this.bars.length) return; - const bounds = this.chartEl.getBoundingClientRect(); - const x = Math.max(0, Math.min(bounds.width, event.clientX - bounds.left)); - const y = Math.max(0, Math.min(bounds.height, event.clientY - bounds.top)); - const rawTime = this.chart.timeScale().coordinateToTime(x); - const price = this.candles.coordinateToPrice(y); - if (rawTime == null || price == null) return; - const time = this.bars.reduce((nearest, bar) => - Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest - ).t; + const point = this.eventPoint(event); + if (!point || !this.withinPlot(point)) { + this.hideSnapDot(); + return; + } + const snapped = this.snapPoint(point); + if (snapped.t == null || snapped.p == null) return; + const time = snapped.t; + const price = snapped.p; + if (this.snapToBars) this.showSnapDot(snapped, point); const secondPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); if (this.draggingAnchor.anchor === 'start') { if (time >= level.last_t) return; @@ -1136,6 +1185,7 @@ class ConfluenceChart { event.preventDefault(); const level = this.levels.find(value => value.id === this.draggingAnchor.id); this.draggingAnchor = null; + this.hideSnapDot(); if (level && this.onLineChange) this.onLineChange({ ...level }); } @@ -1145,12 +1195,10 @@ class ConfluenceChart { ); if (!level || !this.bars.length) return; event.preventDefault(); - const bounds = this.chartEl.getBoundingClientRect(); - const x = event.clientX - bounds.left - this.plotOffsetX(); - const rawTime = this.chart.timeScale().coordinateToTime(x); - if (rawTime == null) return; + const point = this.eventPoint(event); + if (!point || !this.withinPlot(point) || point.t == null) return; const cutoff = this.bars.reduce((nearest, bar) => - Math.abs(bar.t - Number(rawTime)) < Math.abs(nearest.t - Number(rawTime)) ? bar : nearest + Math.abs(bar.t - point.t) < Math.abs(nearest.t - point.t) ? bar : nearest ).t; if (cutoff <= level.last_t) { this.hideContextMenu(); @@ -1158,8 +1206,9 @@ class ConfluenceChart { } this.contextCutoff = cutoff; this.contextMenu.hidden = false; - this.contextMenu.style.left = `${Math.min(x, bounds.width - 170)}px`; - this.contextMenu.style.top = `${Math.min(event.clientY - bounds.top, bounds.height - 40)}px`; + const plot = this.plotCanvas().getBoundingClientRect(); + this.contextMenu.style.left = `${Math.min(point.x, plot.width - 170)}px`; + this.contextMenu.style.top = `${Math.min(point.y, plot.height - 40)}px`; } hideContextMenu() { @@ -1193,6 +1242,7 @@ class ConfluenceChart { destroy() { if (this.resizeObserver) this.resizeObserver.disconnect(); if (this.plotResizeObserver) this.plotResizeObserver.disconnect(); + if (this.contextLabelFrame != null) cancelAnimationFrame(this.contextLabelFrame); if (this.chartEl && this.clickListener) this.chartEl.removeEventListener('click', this.clickListener); if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); diff --git a/static/index.html b/static/index.html index e727d96..46d22d5 100644 --- a/static/index.html +++ b/static/index.html @@ -32,6 +32,7 @@ Drag on the chart from one point to the other Click or drag on the chart to set the price Line selected — Delete removes it + UPDATED {{ updatedAt }}