feat: add vertical drawing toolbar on market chart
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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%;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
@@ -15,7 +15,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260607-hub-archive-v5" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260608-market-draw-v1" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
@@ -169,17 +169,60 @@
|
||||
<div id="market-pos-orders" class="market-pos-orders"></div>
|
||||
</div>
|
||||
<div class="market-chart-body">
|
||||
<button type="button" id="market-chart-fs-exit" class="ghost market-fs-exit hidden" title="退出全屏 (Esc)">退出全屏</button>
|
||||
<div id="market-exchange-badge" class="market-exchange-badge" aria-hidden="true"></div>
|
||||
<div id="market-chart" class="market-chart-host"></div>
|
||||
<div id="market-price-tag" class="market-price-tag hidden" aria-hidden="true">
|
||||
<div class="market-price-tag-head">
|
||||
<span class="market-price-tag-label">现价</span>
|
||||
<span id="market-price-tag-value" class="market-price-tag-value"></span>
|
||||
<aside id="market-draw-toolbar" class="market-draw-toolbar" aria-label="画线工具">
|
||||
<button type="button" class="market-draw-btn is-active" data-tool="cursor" title="光标(拖动缩放图表)">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 4l7 16 2.5-5.5L20 12z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="hline" title="水平线">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.8" d="M4 12h16"/><circle cx="12" cy="12" r="2" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="cross" title="十字线">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" d="M12 4v16M4 12h16"/><rect x="6" y="6" width="12" height="12" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="channel" title="平行通道">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" d="M5 16L19 6M7 19L21 9"/><circle cx="5" cy="16" r="1.5" fill="currentColor"/><circle cx="19" cy="6" r="1.5" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="rect" title="矩形">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="7" width="14" height="10" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="5" cy="7" r="1.3" fill="currentColor"/><circle cx="19" cy="17" r="1.3" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="brush" title="画笔">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M4 18l8-2 2-8 6-2-2 6-8 2z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="range" title="价格测距">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" d="M12 5v14M8 8h8M8 16h8"/><path fill="currentColor" d="M11 4h2v3h-2zm0 13h2v3h-2z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn market-draw-btn-text" data-tool="text" title="文字">T</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="fib" title="斐波那契">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.4" d="M4 6h16M4 10h16M4 14h16M4 18h16"/><circle cx="20" cy="6" r="1.2" fill="currentColor"/><circle cx="20" cy="18" r="1.2" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="trend" title="趋势线">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.8" d="M5 18L19 6"/><circle cx="5" cy="18" r="1.5" fill="currentColor"/><circle cx="19" cy="6" r="1.5" fill="currentColor"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="path" title="折线箭头(双击结束)">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.6" d="M5 16L10 12L14 15L19 7"/><path fill="currentColor" d="M17 5l3 3-3 1z"/></svg>
|
||||
</button>
|
||||
<span class="market-draw-sep" aria-hidden="true"></span>
|
||||
<button type="button" class="market-draw-btn" data-tool="erase" title="删除选中">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M9 3h6l1 2h4v2H4V5h4zm1 6h2v9h-2zm4 0h2v9h-2zM7 7h10l-1 14H8z"/></svg>
|
||||
</button>
|
||||
<button type="button" class="market-draw-btn" data-tool="clear" title="清除全部画线">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="1.8" d="M6 6l12 12M18 6L6 18"/></svg>
|
||||
</button>
|
||||
</aside>
|
||||
<div class="market-chart-main">
|
||||
<button type="button" id="market-chart-fs-exit" class="ghost market-fs-exit hidden" title="退出全屏 (Esc)">退出全屏</button>
|
||||
<div id="market-exchange-badge" class="market-exchange-badge" aria-hidden="true"></div>
|
||||
<div id="market-chart" class="market-chart-host"></div>
|
||||
<canvas id="market-draw-canvas" class="market-draw-canvas" aria-hidden="true"></canvas>
|
||||
<div id="market-price-tag" class="market-price-tag hidden" aria-hidden="true">
|
||||
<div class="market-price-tag-head">
|
||||
<span class="market-price-tag-label">现价</span>
|
||||
<span id="market-price-tag-value" class="market-price-tag-value"></span>
|
||||
</div>
|
||||
<div id="market-price-tag-time" class="market-price-tag-time"></div>
|
||||
</div>
|
||||
<div id="market-price-tag-time" class="market-price-tag-time"></div>
|
||||
<button type="button" id="market-price-auto" class="market-price-auto is-on" title="开启:价格轴与视口随刷新自动调整;关闭:后台静默更新,保留当前缩放">自动</button>
|
||||
</div>
|
||||
<button type="button" id="market-price-auto" class="market-price-auto is-on" title="开启:价格轴与视口随刷新自动调整;关闭:后台静默更新,保留当前缩放">自动</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -349,7 +392,8 @@
|
||||
|
||||
<div id="toast"></div>
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="/assets/chart.js?v=20260608-clear-refetch"></script>
|
||||
<script src="/assets/chart_draw.js?v=20260608-market-draw-v1"></script>
|
||||
<script src="/assets/chart.js?v=20260608-market-draw-v1"></script>
|
||||
<script src="/assets/archive.js?v=20260607-hub-archive-v6"></script>
|
||||
<script src="/assets/ai_review_render.js?v=2"></script>
|
||||
<script src="/assets/app.js?v=20260607-hub-archive-v1"></script>
|
||||
|
||||
Reference in New Issue
Block a user