From 26a4c04b883852f77a9461ae2ea85fff2846a6d7 Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 8 Jun 2026 12:41:52 +0800 Subject: [PATCH] feat: add vertical drawing toolbar on market chart Co-authored-by: Cursor --- manual_trading_hub/static/app.css | 78 +++ manual_trading_hub/static/chart.js | 32 ++ manual_trading_hub/static/chart_draw.js | 722 ++++++++++++++++++++++++ manual_trading_hub/static/index.html | 66 ++- 4 files changed, 887 insertions(+), 11 deletions(-) create mode 100644 manual_trading_hub/static/chart_draw.js diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 188f35b..d8000f6 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -2833,10 +2833,77 @@ body.login-page { .market-chart-body { flex: 1; display: flex; + flex-direction: row; min-height: 0; position: relative; } +.market-draw-toolbar { + flex: 0 0 40px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px 4px; + border-right: 1px solid var(--border-soft); + background: var(--chart-bar-bg); + z-index: 4; + overflow-y: auto; +} + +.market-draw-btn { + width: 32px; + height: 32px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--muted); + cursor: pointer; + flex-shrink: 0; +} + +.market-draw-btn svg { + width: 18px; + height: 18px; +} + +.market-draw-btn-text { + font-size: 0.82rem; + font-weight: 700; + font-family: var(--font); +} + +.market-draw-btn:hover { + color: var(--text); + background: var(--inset-surface); + border-color: var(--border-soft); +} + +.market-draw-btn.is-active { + color: var(--accent); + background: rgba(0, 255, 157, 0.1); + border-color: rgba(0, 255, 157, 0.35); +} + +.market-draw-sep { + width: 22px; + height: 1px; + background: var(--border-soft); + margin: 2px 0; +} + +.market-chart-main { + flex: 1; + min-width: 0; + height: 100%; + position: relative; + display: flex; +} + .market-chart-host { flex: 1; min-width: 0; @@ -2844,6 +2911,17 @@ body.login-page { position: relative; } +.market-draw-canvas { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; +} + +.market-draw-canvas.is-drawing { + cursor: crosshair; +} + .market-exchange-badge { position: absolute; left: 50%; diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index acc949d..df82e91 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -82,6 +82,11 @@ const chartHost = document.getElementById("market-chart"); if (!chartHost) return; + const elDrawToolbar = document.getElementById("market-draw-toolbar"); + const elDrawCanvas = document.getElementById("market-draw-canvas"); + const elChartMain = chartHost.closest(".market-chart-main"); + let drawAttached = false; + const elExchange = document.getElementById("market-exchange"); const elSymbol = document.getElementById("market-symbol"); const elTf = document.getElementById("market-timeframe"); @@ -233,10 +238,33 @@ syncChartWrapLayout(); } + function ensureDrawLayer() { + if (drawAttached || !window.HubChartDraw || !chart || !candleSeries) return; + window.HubChartDraw.attach({ + chart: chart, + series: candleSeries, + hostEl: chartHost, + mainEl: elChartMain, + canvasEl: elDrawCanvas, + toolbarEl: elDrawToolbar, + }); + window.HubChartDraw.setViewKey(currentChartViewKey()); + drawAttached = true; + } + + function syncDrawViewKey() { + if (window.HubChartDraw && drawAttached) { + window.HubChartDraw.setViewKey(currentChartViewKey()); + } + } + function resizeChart() { if (!chart || !chartHost) return; chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight }); updatePriceTag(); + if (window.HubChartDraw && drawAttached) { + window.HubChartDraw.resize(); + } } let resizeChartRaf = 0; @@ -1985,6 +2013,7 @@ scheduleChartResize(); }); scheduleChartResize(); + ensureDrawLayer(); return true; } @@ -2338,6 +2367,7 @@ } if (elUpdated) elUpdated.textContent = "数据 " + (meta.updated_at || "--"); tickLiveClock(); + if (window.HubChartDraw && drawAttached) window.HubChartDraw.redraw(); return true; } @@ -2660,6 +2690,8 @@ } applyCandlesToChart(alignCandlesToTick(data.candles), 0); lastViewKey = vKey; + ensureDrawLayer(); + syncDrawViewKey(); if (resetView) { applyDefaultVisibleRange(); } diff --git a/manual_trading_hub/static/chart_draw.js b/manual_trading_hub/static/chart_draw.js new file mode 100644 index 0000000..dabfbdc --- /dev/null +++ b/manual_trading_hub/static/chart_draw.js @@ -0,0 +1,722 @@ +/** + * 行情区左侧画线工具(canvas 叠加层,坐标与 Lightweight Charts 对齐)。 + */ +(function () { + const STORAGE_PREFIX = "hubMarketDraw:"; + const HIT_PX = 8; + const FIB_LEVELS = [0, 0.236, 0.382, 0.5, 0.618, 0.786, 1]; + + const TOOL_LABELS = { + cursor: "光标", + hline: "水平线", + cross: "十字线", + channel: "平行通道", + rect: "矩形", + brush: "画笔", + range: "价格测距", + text: "文字", + fib: "斐波那契", + trend: "趋势线", + path: "折线箭头", + erase: "删除选中", + clear: "清除全部", + }; + + let chart = null; + let series = null; + let hostEl = null; + let mainEl = null; + let canvasEl = null; + let toolbarEl = null; + let viewKey = ""; + let activeTool = "cursor"; + let drawings = []; + let draft = null; + let selectedId = null; + let redrawRaf = 0; + let unsubRange = null; + + function uid() { + return "d" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); + } + + function storageKey() { + return STORAGE_PREFIX + (viewKey || "default"); + } + + function loadDrawings() { + try { + const raw = localStorage.getItem(storageKey()); + if (!raw) return []; + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch (_) { + return []; + } + } + + function saveDrawings() { + try { + localStorage.setItem(storageKey(), JSON.stringify(drawings)); + } catch (_) {} + } + + function setChartInteraction(enabled) { + if (!chart) return; + const on = !!enabled; + chart.applyOptions({ + handleScroll: { + mouseWheel: on, + pressedMouseMove: on, + horzTouchDrag: on, + vertTouchDrag: false, + }, + handleScale: { + axisPressedMouseMove: on, + mouseWheel: on, + pinch: on, + }, + }); + } + + function syncCanvasSize() { + if (!canvasEl || !hostEl) return; + const w = hostEl.clientWidth; + const h = hostEl.clientHeight; + if (w < 1 || h < 1) return; + const dpr = window.devicePixelRatio || 1; + canvasEl.width = Math.floor(w * dpr); + canvasEl.height = Math.floor(h * dpr); + canvasEl.style.width = w + "px"; + canvasEl.style.height = h + "px"; + const ctx = canvasEl.getContext("2d"); + if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + + function timeToX(time) { + if (!chart || time == null) return null; + try { + return chart.timeScale().timeToCoordinate(time); + } catch (_) { + return null; + } + } + + function priceToY(price) { + if (!series || price == null || !Number.isFinite(Number(price))) return null; + try { + return series.priceToCoordinate(Number(price)); + } catch (_) { + return null; + } + } + + 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) }; + } + + function clientToLocal(ev) { + const rect = canvasEl.getBoundingClientRect(); + return { x: ev.clientX - rect.left, y: ev.clientY - rect.top }; + } + + function scheduleRedraw() { + if (redrawRaf) cancelAnimationFrame(redrawRaf); + redrawRaf = requestAnimationFrame(function () { + redrawRaf = 0; + redraw(); + }); + } + + function strokeStyle(selected) { + return selected ? "#f59e0b" : "#60a5fa"; + } + + function drawLine(ctx, x1, y1, x2, y2, selected) { + if (x1 == null || y1 == null || x2 == null || y2 == null) return; + ctx.beginPath(); + ctx.strokeStyle = strokeStyle(selected); + ctx.lineWidth = selected ? 2 : 1.5; + ctx.setLineDash([]); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + } + + function drawHLine(ctx, y, w, selected) { + if (y == null) return; + drawLine(ctx, 0, y, w, y, selected); + } + + function drawVLine(ctx, x, h, selected) { + if (x == null) return; + drawLine(ctx, x, 0, x, h, selected); + } + + function drawRect(ctx, x1, y1, x2, y2, selected) { + if (x1 == null || y1 == null || x2 == null || y2 == null) return; + const l = Math.min(x1, x2); + const t = Math.min(y1, y2); + const rw = Math.abs(x2 - x1); + const rh = Math.abs(y2 - y1); + ctx.strokeStyle = strokeStyle(selected); + ctx.lineWidth = selected ? 2 : 1.5; + ctx.setLineDash([]); + ctx.strokeRect(l, t, rw, rh); + ctx.fillStyle = selected ? "rgba(245,158,11,0.08)" : "rgba(96,165,250,0.06)"; + ctx.fillRect(l, t, rw, rh); + } + + function drawBrush(ctx, pts, selected) { + if (!pts || pts.length < 2) return; + ctx.beginPath(); + ctx.strokeStyle = strokeStyle(selected); + ctx.lineWidth = selected ? 2.5 : 2; + ctx.lineJoin = "round"; + ctx.lineCap = "round"; + let started = false; + pts.forEach(function (p) { + const x = timeToX(p.time); + const y = priceToY(p.price); + if (x == null || y == null) return; + if (!started) { + ctx.moveTo(x, y); + started = true; + } else { + ctx.lineTo(x, y); + } + }); + if (started) ctx.stroke(); + } + + function drawFib(ctx, p1, p2, w, selected) { + if (!p1 || !p2) return; + const top = Math.max(p1.price, p2.price); + const bot = Math.min(p1.price, p2.price); + const x1 = timeToX(p1.time); + const x2 = timeToX(p2.time); + if (x1 == null || x2 == null) return; + const left = Math.min(x1, x2); + const right = Math.max(x1, x2); + FIB_LEVELS.forEach(function (lv) { + const price = bot + (top - bot) * (1 - lv); + const y = priceToY(price); + if (y == null) return; + ctx.beginPath(); + ctx.strokeStyle = strokeStyle(selected); + ctx.lineWidth = 1; + ctx.setLineDash(lv === 0 || lv === 1 ? [] : [4, 4]); + ctx.moveTo(left, y); + ctx.lineTo(right, y); + ctx.stroke(); + ctx.fillStyle = "#9aa4b8"; + ctx.font = "10px sans-serif"; + ctx.fillText((lv * 100).toFixed(1) + "%", left + 4, y - 3); + }); + ctx.setLineDash([]); + } + + function drawRange(ctx, p1, p2, selected) { + const y1 = priceToY(p1.price); + const y2 = priceToY(p2.price); + const x = timeToX(p1.time) != null ? timeToX(p1.time) : timeToX(p2.time); + if (y1 == null || y2 == null || x == null) return; + const top = Math.min(y1, y2); + const bot = Math.max(y1, y2); + ctx.strokeStyle = strokeStyle(selected); + ctx.fillStyle = selected ? "rgba(245,158,11,0.12)" : "rgba(96,165,250,0.1)"; + ctx.lineWidth = 1.5; + ctx.fillRect(x - 14, top, 28, bot - top); + ctx.strokeRect(x - 14, top, 28, bot - top); + drawLine(ctx, x, top, x, bot, selected); + const pct = p1.price ? (((p2.price - p1.price) / p1.price) * 100).toFixed(2) : "0"; + ctx.fillStyle = "#e2e8f0"; + ctx.font = "10px sans-serif"; + ctx.fillText(pct + "%", x + 18, (top + bot) / 2); + } + + function drawText(ctx, p, text, selected) { + const x = timeToX(p.time); + const y = priceToY(p.price); + if (x == null || y == null) return; + ctx.font = "12px sans-serif"; + ctx.fillStyle = strokeStyle(selected); + ctx.fillText(String(text || ""), x + 4, y - 4); + } + + function parallelOffset(p1, p2, p3) { + const x1 = timeToX(p1.time); + const y1 = priceToY(p1.price); + const x2 = timeToX(p2.time); + const y2 = priceToY(p2.price); + const x3 = timeToX(p3.time); + const y3 = priceToY(p3.price); + if (x1 == null || y1 == null || x2 == null || y2 == null || x3 == null || y3 == null) { + return 0; + } + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + return (x3 - x1) * nx + (y3 - y1) * ny; + } + + function drawChannel(ctx, p1, p2, offset, w, selected) { + const x1 = timeToX(p1.time); + const y1 = priceToY(p1.price); + const x2 = timeToX(p2.time); + const y2 = priceToY(p2.price); + if (x1 == null || y1 == null || x2 == null || y2 == null) return; + const dx = x2 - x1; + const dy = y2 - y1; + const len = Math.hypot(dx, dy) || 1; + const nx = -dy / len; + const ny = dx / len; + const ox = nx * offset; + const oy = ny * offset; + drawLine(ctx, x1, y1, x2, y2, selected); + drawLine(ctx, x1 + ox, y1 + oy, x2 + ox, y2 + oy, selected); + ctx.fillStyle = selected ? "rgba(245,158,11,0.06)" : "rgba(96,165,250,0.05)"; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.lineTo(x2 + ox, y2 + oy); + ctx.lineTo(x1 + ox, y1 + oy); + ctx.closePath(); + ctx.fill(); + } + + function drawPath(ctx, pts, selected) { + drawBrush(ctx, pts, selected); + if (!pts || pts.length < 2) return; + const last = pts[pts.length - 1]; + const prev = pts[pts.length - 2]; + const x1 = timeToX(prev.time); + const y1 = priceToY(prev.price); + const x2 = timeToX(last.time); + const y2 = priceToY(last.price); + if (x1 == null || y1 == null || x2 == null || y2 == null) return; + const ang = Math.atan2(y2 - y1, x2 - x1); + const al = 8; + ctx.beginPath(); + ctx.fillStyle = strokeStyle(selected); + ctx.moveTo(x2, y2); + ctx.lineTo(x2 - al * Math.cos(ang - 0.4), y2 - al * Math.sin(ang - 0.4)); + ctx.lineTo(x2 - al * Math.cos(ang + 0.4), y2 - al * Math.sin(ang + 0.4)); + ctx.closePath(); + ctx.fill(); + } + + function renderDrawing(ctx, d, w, h, selected) { + const pts = d.points || []; + if (!pts.length) return; + switch (d.type) { + case "hline": + drawHLine(ctx, priceToY(pts[0].price), w, selected); + break; + case "cross": + drawHLine(ctx, priceToY(pts[0].price), w, selected); + drawVLine(ctx, timeToX(pts[0].time), h, selected); + break; + case "trend": + if (pts.length >= 2) { + drawLine( + ctx, + timeToX(pts[0].time), + priceToY(pts[0].price), + timeToX(pts[1].time), + priceToY(pts[1].price), + selected + ); + } + break; + case "channel": + if (pts.length >= 3) { + drawChannel(ctx, pts[0], pts[1], d.offset || 0, w, selected); + } else if (pts.length === 2) { + drawLine( + ctx, + timeToX(pts[0].time), + priceToY(pts[0].price), + timeToX(pts[1].time), + priceToY(pts[1].price), + selected + ); + } + break; + case "rect": + if (pts.length >= 2) { + drawRect( + ctx, + timeToX(pts[0].time), + priceToY(pts[0].price), + timeToX(pts[1].time), + priceToY(pts[1].price), + selected + ); + } + break; + case "brush": + drawBrush(ctx, pts, selected); + break; + case "range": + if (pts.length >= 2) drawRange(ctx, pts[0], pts[1], selected); + break; + case "text": + drawText(ctx, pts[0], d.text, selected); + break; + case "fib": + if (pts.length >= 2) drawFib(ctx, pts[0], pts[1], w, selected); + break; + case "path": + drawPath(ctx, pts, selected); + break; + default: + break; + } + } + + function redraw() { + if (!canvasEl) return; + syncCanvasSize(); + const ctx = canvasEl.getContext("2d"); + if (!ctx) return; + const w = hostEl.clientWidth; + const h = hostEl.clientHeight; + ctx.clearRect(0, 0, w, h); + drawings.forEach(function (d) { + renderDrawing(ctx, d, w, h, d.id === selectedId); + }); + if (draft) renderDrawing(ctx, draft, w, h, true); + } + + function commitDrawing(d) { + if (!d || !d.type) return; + d.id = uid(); + drawings.push(d); + draft = null; + saveDrawings(); + scheduleRedraw(); + } + + function pointsNeeded(tool) { + if (tool === "hline" || tool === "cross" || tool === "text") return 1; + if (tool === "trend" || tool === "rect" || tool === "range" || tool === "fib") return 2; + if (tool === "channel") return 3; + return 0; + } + + function onPointerDown(ev) { + if (activeTool === "cursor" || activeTool === "clear" || activeTool === "erase") return; + if (!chart || !series) return; + const loc = clientToLocal(ev); + const pt = xyToPoint(loc.x, loc.y); + if (!pt) return; + ev.preventDefault(); + + if (activeTool === "brush") { + draft = { type: "brush", points: [pt] }; + return; + } + if (activeTool === "path") { + if (!draft || draft.type !== "path") { + draft = { type: "path", points: [pt] }; + } else { + draft.points.push(pt); + } + scheduleRedraw(); + return; + } + + if (!draft) { + draft = { type: activeTool, points: [pt] }; + if (activeTool === "text") { + const text = window.prompt("输入标注文字", ""); + if (text && String(text).trim()) { + draft.text = String(text).trim(); + commitDrawing(draft); + } else { + draft = null; + } + } else if (pointsNeeded(activeTool) === 1) { + commitDrawing(draft); + } + scheduleRedraw(); + return; + } + + draft.points.push(pt); + if (activeTool === "channel" && draft.points.length === 3) { + draft.offset = parallelOffset(draft.points[0], draft.points[1], draft.points[2]); + commitDrawing(draft); + return; + } + if (draft.points.length >= pointsNeeded(activeTool)) { + commitDrawing(draft); + } + scheduleRedraw(); + } + + 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") { + 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) { + draft.points.push(pt); + scheduleRedraw(); + } + return; + } + if (pointsNeeded(activeTool) >= 2 && draft.points.length >= 1) { + draft.points[draft.points.length] = pt; + if (draft.points.length > pointsNeeded(activeTool)) { + draft.points.length = pointsNeeded(activeTool); + } + scheduleRedraw(); + } + } + + function onPointerUp(ev) { + if (activeTool === "brush" && draft && draft.type === "brush" && draft.points.length > 1) { + commitDrawing(draft); + } + } + + function onDblClick(ev) { + if (activeTool === "path" && draft && draft.type === "path" && draft.points.length > 1) { + commitDrawing(draft); + ev.preventDefault(); + } + } + + function distSeg(px, py, x1, y1, x2, y2) { + const dx = x2 - x1; + const dy = y2 - y1; + if (dx === 0 && dy === 0) return Math.hypot(px - x1, py - y1); + const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy))); + const nx = x1 + t * dx; + const ny = y1 + t * dy; + return Math.hypot(px - nx, py - ny); + } + + function hitTestDrawing(d, x, y) { + const pts = d.points || []; + if (!pts.length) return false; + const w = hostEl.clientWidth; + const h = hostEl.clientHeight; + switch (d.type) { + case "hline": { + const ly = priceToY(pts[0].price); + return ly != null && Math.abs(y - ly) <= HIT_PX; + } + case "cross": { + const ly = priceToY(pts[0].price); + const lx = timeToX(pts[0].time); + return ( + (ly != null && Math.abs(y - ly) <= HIT_PX) || + (lx != null && Math.abs(x - lx) <= HIT_PX) + ); + } + case "trend": + case "channel": + if (pts.length >= 2) { + const x1 = timeToX(pts[0].time); + const y1 = priceToY(pts[0].price); + const x2 = timeToX(pts[1].time); + const y2 = priceToY(pts[1].price); + if (x1 != null && y1 != null && x2 != null && y2 != null) { + if (distSeg(x, y, x1, y1, x2, y2) <= HIT_PX) return true; + } + } + return false; + case "rect": + if (pts.length >= 2) { + const x1 = timeToX(pts[0].time); + const y1 = priceToY(pts[0].price); + const x2 = timeToX(pts[1].time); + const y2 = priceToY(pts[1].price); + if (x1 == null || y1 == null || x2 == null || y2 == null) return false; + const l = Math.min(x1, x2) - HIT_PX; + const r = Math.max(x1, x2) + HIT_PX; + const t = Math.min(y1, y2) - HIT_PX; + const b = Math.max(y1, y2) + HIT_PX; + return x >= l && x <= r && y >= t && y <= b; + } + return false; + case "text": { + const tx = timeToX(pts[0].time); + const ty = priceToY(pts[0].price); + return tx != null && ty != null && Math.hypot(x - tx, y - ty) <= 14; + } + default: + return false; + } + } + + 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 setActiveTool(tool) { + if (!TOOL_LABELS[tool]) return; + if (tool === "clear") { + if (drawings.length && window.confirm("清除当前图表上的全部画线?")) { + drawings = []; + selectedId = null; + draft = null; + saveDrawings(); + scheduleRedraw(); + } + return; + } + if (tool === "erase") { + if (selectedId) { + drawings = drawings.filter(function (d) { + return d.id !== selectedId; + }); + selectedId = null; + saveDrawings(); + scheduleRedraw(); + } + return; + } + activeTool = tool; + draft = null; + if (toolbarEl) { + toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) { + 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); + scheduleRedraw(); + } + + function bindToolbar() { + if (!toolbarEl) return; + toolbarEl.querySelectorAll("[data-tool]").forEach(function (btn) { + btn.addEventListener("click", function () { + setActiveTool(btn.getAttribute("data-tool") || "cursor"); + }); + }); + } + + function bindCanvas() { + if (!canvasEl) return; + canvasEl.addEventListener("pointerdown", function (ev) { + if (activeTool === "cursor") { + onCursorPointerDown(ev); + return; + } + onPointerDown(ev); + }); + canvasEl.addEventListener("pointermove", onPointerMove); + canvasEl.addEventListener("pointerup", onPointerUp); + canvasEl.addEventListener("dblclick", onDblClick); + } + + function attach(opts) { + chart = opts.chart || null; + series = opts.series || null; + hostEl = opts.hostEl || null; + mainEl = opts.mainEl || null; + canvasEl = opts.canvasEl || null; + toolbarEl = opts.toolbarEl || null; + bindToolbar(); + bindCanvas(); + setActiveTool("cursor"); + if (chart && chart.timeScale) { + if (unsubRange) { + try { + unsubRange(); + } catch (_) {} + } + unsubRange = chart.timeScale().subscribeVisibleLogicalRangeChange(function () { + scheduleRedraw(); + }); + } + scheduleRedraw(); + } + + function setViewKey(key) { + viewKey = key || ""; + drawings = loadDrawings(); + selectedId = null; + draft = null; + scheduleRedraw(); + } + + function destroy() { + if (unsubRange) { + try { + unsubRange(); + } catch (_) {} + unsubRange = null; + } + setChartInteraction(true); + } + + window.HubChartDraw = { + attach: attach, + setViewKey: setViewKey, + resize: scheduleRedraw, + redraw: scheduleRedraw, + destroy: destroy, + }; +})(); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index ac403fe..691574f 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -169,17 +169,60 @@
- - -
-
@@ -349,7 +392,8 @@
- + +