diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index d8000f6..c22a7da 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -2909,17 +2909,23 @@ body.login-page { min-width: 0; height: 100%; position: relative; + overflow: hidden; } .market-draw-canvas { position: absolute; - inset: 0; - z-index: 3; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 20; pointer-events: none; + touch-action: none; } .market-draw-canvas.is-drawing { cursor: crosshair; + pointer-events: auto; } .market-exchange-badge { diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index df82e91..c83c89d 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -247,6 +247,9 @@ mainEl: elChartMain, canvasEl: elDrawCanvas, toolbarEl: elDrawToolbar, + getCandles: function () { + return lastCandles; + }, }); window.HubChartDraw.setViewKey(currentChartViewKey()); drawAttached = true; diff --git a/manual_trading_hub/static/chart_draw.js b/manual_trading_hub/static/chart_draw.js index dabfbdc..999f30c 100644 --- a/manual_trading_hub/static/chart_draw.js +++ b/manual_trading_hub/static/chart_draw.js @@ -35,6 +35,8 @@ let selectedId = null; let redrawRaf = 0; let unsubRange = null; + let getCandlesFn = null; + let brushPointerId = null; function uid() { return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); @@ -93,10 +95,19 @@ if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } + function getCandles() { + if (typeof getCandlesFn === "function") { + const rows = getCandlesFn(); + return Array.isArray(rows) ? rows : []; + } + return []; + } + function timeToX(time) { if (!chart || time == null) return null; try { - return chart.timeScale().timeToCoordinate(time); + const x = chart.timeScale().timeToCoordinate(time); + return x == null || !Number.isFinite(x) ? null : x; } catch (_) { return null; } @@ -105,29 +116,87 @@ function priceToY(price) { if (!series || price == null || !Number.isFinite(Number(price))) return null; try { - return series.priceToCoordinate(Number(price)); + const y = series.priceToCoordinate(Number(price)); + return y == null || !Number.isFinite(y) ? null : y; } catch (_) { return null; } } + function xToTime(x) { + if (!chart) return null; + try { + const direct = chart.timeScale().coordinateToTime(x); + if (direct != null) return direct; + } catch (_) {} + const candles = getCandles(); + if (!candles.length) return null; + let bestTime = candles[0].time; + let bestDist = Infinity; + candles.forEach(function (c) { + const cx = timeToX(c.time); + if (cx == null) return; + const d = Math.abs(cx - x); + if (d < bestDist) { + bestDist = d; + bestTime = c.time; + } + }); + return bestTime; + } + + function yToPrice(y) { + if (!series) return null; + try { + const direct = series.coordinateToPrice(y); + if (direct != null && Number.isFinite(Number(direct))) return Number(direct); + } catch (_) {} + const candles = getCandles(); + if (!candles.length) return null; + let lo = null; + let hi = null; + candles.forEach(function (c) { + const vals = [c.low, c.high, c.open, c.close]; + vals.forEach(function (v) { + const n = Number(v); + if (!Number.isFinite(n)) return; + if (lo == null || n < lo) lo = n; + if (hi == null || n > hi) hi = n; + }); + }); + if (lo == null || hi == null) return null; + const yLo = priceToY(lo); + const yHi = priceToY(hi); + if (yLo == null || yHi == null || Math.abs(yHi - yLo) < 1e-6) return (lo + hi) / 2; + const ratio = (y - yLo) / (yHi - yLo); + return lo + (hi - lo) * ratio; + } + function xyToPoint(x, y) { if (!chart || !series) return null; - let time = null; - let price = null; - try { - time = chart.timeScale().coordinateToTime(x); - price = series.coordinateToPrice(y); - } catch (_) {} - if (time == null || price == null || !Number.isFinite(Number(price))) return null; - return { time: time, price: Number(price) }; + const time = xToTime(x); + const price = yToPrice(y); + if (time == null || price == null || !Number.isFinite(price)) return null; + return { time: time, price: price }; } function clientToLocal(ev) { - const rect = canvasEl.getBoundingClientRect(); + const rect = (hostEl || canvasEl).getBoundingClientRect(); return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }; } + function mountCanvasOverlay() { + if (!canvasEl || !hostEl) return; + if (canvasEl.parentElement !== hostEl) { + hostEl.appendChild(canvasEl); + } + canvasEl.style.position = "absolute"; + canvasEl.style.top = "0"; + canvasEl.style.left = "0"; + canvasEl.style.width = "100%"; + canvasEl.style.height = "100%"; + } + function scheduleRedraw() { if (redrawRaf) cancelAnimationFrame(redrawRaf); redrawRaf = requestAnimationFrame(function () { @@ -415,13 +484,36 @@ return 0; } + function tryEraseAt(x, y) { + for (let i = drawings.length - 1; i >= 0; i--) { + if (hitTestDrawing(drawings[i], x, y)) { + drawings.splice(i, 1); + selectedId = null; + saveDrawings(); + scheduleRedraw(); + return true; + } + } + return false; + } + function onPointerDown(ev) { - if (activeTool === "cursor" || activeTool === "clear" || activeTool === "erase") return; - if (!chart || !series) return; + if (activeTool === "cursor" || activeTool === "clear") return; + if (!chart || !series || !canvasEl) return; const loc = clientToLocal(ev); + if (activeTool === "erase") { + tryEraseAt(loc.x, loc.y); + ev.preventDefault(); + return; + } const pt = xyToPoint(loc.x, loc.y); if (!pt) return; ev.preventDefault(); + ev.stopPropagation(); + try { + canvasEl.setPointerCapture(ev.pointerId); + brushPointerId = ev.pointerId; + } catch (_) {} if (activeTool === "brush") { draft = { type: "brush", points: [pt] }; @@ -467,22 +559,26 @@ } function onPointerMove(ev) { - if (!draft) return; - const loc = clientToLocal(ev); - const pt = xyToPoint(loc.x, loc.y); - if (!pt) return; - if (activeTool === "brush" && draft.type === "brush") { + if (activeTool === "brush" && draft && draft.type === "brush") { + const loc = clientToLocal(ev); + const pt = xyToPoint(loc.x, loc.y); + if (!pt) return; const last = draft.points[draft.points.length - 1]; const lx = timeToX(last.time); const ly = priceToY(last.price); const cx = timeToX(pt.time); const cy = priceToY(pt.price); - if (lx != null && ly != null && cx != null && cy != null && Math.hypot(cx - lx, cy - ly) > 3) { + if (lx != null && ly != null && cx != null && cy != null && Math.hypot(cx - lx, cy - ly) > 2) { draft.points.push(pt); scheduleRedraw(); } + ev.preventDefault(); return; } + if (!draft) return; + const loc = clientToLocal(ev); + const pt = xyToPoint(loc.x, loc.y); + if (!pt) return; if (pointsNeeded(activeTool) >= 2 && draft.points.length >= 1) { draft.points[draft.points.length] = pt; if (draft.points.length > pointsNeeded(activeTool)) { @@ -493,6 +589,12 @@ } function onPointerUp(ev) { + if (brushPointerId != null) { + try { + canvasEl.releasePointerCapture(brushPointerId); + } catch (_) {} + brushPointerId = null; + } if (activeTool === "brush" && draft && draft.type === "brush" && draft.points.length > 1) { commitDrawing(draft); } @@ -569,44 +671,11 @@ } } - function passthroughPointerToChart(ev) { - if (!hostEl) return; - canvasEl.style.pointerEvents = "none"; - const target = document.elementFromPoint(ev.clientX, ev.clientY); - if (target && target !== canvasEl) { - const opts = { - bubbles: true, - cancelable: true, - clientX: ev.clientX, - clientY: ev.clientY, - pointerId: ev.pointerId, - pointerType: ev.pointerType, - buttons: ev.buttons, - }; - target.dispatchEvent(new PointerEvent(ev.type, opts)); - } - const restore = function () { - if (activeTool === "cursor") canvasEl.style.pointerEvents = "auto"; - window.removeEventListener("pointerup", restore, true); - }; - window.addEventListener("pointerup", restore, true); - } - - function onCursorPointerDown(ev) { - if (activeTool !== "cursor") return; - const loc = clientToLocal(ev); - for (let i = drawings.length - 1; i >= 0; i--) { - if (hitTestDrawing(drawings[i], loc.x, loc.y)) { - selectedId = drawings[i].id; - scheduleRedraw(); - ev.preventDefault(); - ev.stopPropagation(); - return; - } - } - selectedId = null; - scheduleRedraw(); - passthroughPointerToChart(ev); + function syncCanvasPointerMode() { + if (!canvasEl) return; + const drawing = activeTool !== "cursor"; + canvasEl.classList.toggle("is-drawing", drawing); + canvasEl.style.pointerEvents = drawing ? "auto" : "none"; } function setActiveTool(tool) { @@ -639,12 +708,8 @@ btn.classList.toggle("is-active", btn.getAttribute("data-tool") === tool); }); } - const drawing = tool !== "cursor"; - if (canvasEl) { - canvasEl.classList.toggle("is-drawing", drawing); - canvasEl.style.pointerEvents = "auto"; - } - setChartInteraction(!drawing); + syncCanvasPointerMode(); + setChartInteraction(activeTool === "cursor"); scheduleRedraw(); } @@ -657,17 +722,15 @@ }); } + let canvasBound = false; + function bindCanvas() { - if (!canvasEl) return; - canvasEl.addEventListener("pointerdown", function (ev) { - if (activeTool === "cursor") { - onCursorPointerDown(ev); - return; - } - onPointerDown(ev); - }); + if (!canvasEl || canvasBound) return; + canvasBound = true; + canvasEl.addEventListener("pointerdown", onPointerDown); canvasEl.addEventListener("pointermove", onPointerMove); canvasEl.addEventListener("pointerup", onPointerUp); + canvasEl.addEventListener("pointercancel", onPointerUp); canvasEl.addEventListener("dblclick", onDblClick); } @@ -678,6 +741,8 @@ mainEl = opts.mainEl || null; canvasEl = opts.canvasEl || null; toolbarEl = opts.toolbarEl || null; + getCandlesFn = opts.getCandles || null; + mountCanvasOverlay(); bindToolbar(); bindCanvas(); setActiveTool("cursor"); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 691574f..799a620 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - +
@@ -392,8 +392,8 @@ - - + +