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 otherClick or drag on the chart to set the priceLine selected — Delete removes it
+ UPDATED {{ updatedAt }}