(function () { "use strict"; const LINE_TYPES = { entry: { label: "入场线", color: "#00ff00" }, exit: { label: "出场线", color: "#0099ff" }, stop: { label: "止损线", color: "#ff3333" }, }; const LINE_WIDTH = 3; const HIT_TOLERANCE = 8; const fileInput = document.getElementById("file-input"); const dropZone = document.getElementById("drop-zone"); const dropPlaceholder = document.getElementById("drop-placeholder"); const canvasWrap = document.getElementById("canvas-wrap"); const chartImage = document.getElementById("chart-image"); const canvas = document.getElementById("overlay-canvas"); const ctx = canvas.getContext("2d"); const btnUndo = document.getElementById("btn-undo"); const btnClear = document.getElementById("btn-clear"); const btnDownload = document.getElementById("btn-download"); const statusHint = document.getElementById("status-hint"); const modeButtons = document.querySelectorAll(".mode-btn"); let currentMode = "entry"; let lines = []; let displayWidth = 0; let displayHeight = 0; let imageLoaded = false; let dragIndex = -1; let isDragging = false; let didDragMove = false; function setHint(text) { statusHint.textContent = text; } function updateButtons() { const hasImage = imageLoaded; const hasLines = lines.length > 0; btnUndo.disabled = !hasImage || !hasLines; btnClear.disabled = !hasImage || !hasLines; btnDownload.disabled = !hasImage; } function getCanvasPoint(event) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; return { x: (event.clientX - rect.left) * scaleX, y: (event.clientY - rect.top) * scaleY, }; } function findLineAtY(y) { for (let i = lines.length - 1; i >= 0; i--) { if (Math.abs(lines[i].y - y) <= HIT_TOLERANCE) { return i; } } return -1; } function redraw() { if (!imageLoaded) return; ctx.clearRect(0, 0, canvas.width, canvas.height); for (const line of lines) { ctx.beginPath(); ctx.strokeStyle = line.color; ctx.lineWidth = LINE_WIDTH; ctx.moveTo(0, line.y); ctx.lineTo(canvas.width, line.y); ctx.stroke(); } } function syncCanvasSize() { displayWidth = chartImage.offsetWidth; displayHeight = chartImage.offsetHeight; canvas.width = Math.round(displayWidth); canvas.height = Math.round(displayHeight); canvas.style.width = displayWidth + "px"; canvas.style.height = displayHeight + "px"; redraw(); } function clearAnnotations() { lines = []; dragIndex = -1; isDragging = false; redraw(); updateButtons(); } function loadImageFile(file) { if (!file) return; const validTypes = ["image/jpeg", "image/png"]; const ext = file.name.split(".").pop().toLowerCase(); const validExt = ["jpg", "jpeg", "png"].includes(ext); if (!validTypes.includes(file.type) && !validExt) { setHint("仅支持 JPG / PNG 格式"); return; } const reader = new FileReader(); reader.onload = function (e) { chartImage.onload = function () { imageLoaded = true; dropPlaceholder.classList.add("hidden"); canvasWrap.classList.remove("hidden"); dropZone.classList.remove("drag-over"); clearAnnotations(); requestAnimationFrame(function () { syncCanvasSize(); setHint( "当前模式:" + LINE_TYPES[currentMode].label + " — 在图上单击添加水平线,可拖拽调整" ); updateButtons(); }); }; chartImage.onerror = function () { setHint("图片加载失败,请换一张重试"); imageLoaded = false; updateButtons(); }; chartImage.src = e.target.result; }; reader.onerror = function () { setHint("文件读取失败"); }; reader.readAsDataURL(file); } function addLine(y) { const type = currentMode; const info = LINE_TYPES[type]; lines.push({ y: y, type: type, color: info.color }); redraw(); updateButtons(); setHint( "已添加 " + info.label + "(共 " + lines.length + " 条)— 当前模式:" + info.label ); } function exportImage() { if (!imageLoaded) return; const natW = chartImage.naturalWidth; const natH = chartImage.naturalHeight; const scaleY = natH / displayHeight; const exportCanvas = document.createElement("canvas"); exportCanvas.width = natW; exportCanvas.height = natH; const exCtx = exportCanvas.getContext("2d"); exCtx.drawImage(chartImage, 0, 0, natW, natH); for (const line of lines) { const y = line.y * scaleY; exCtx.beginPath(); exCtx.strokeStyle = line.color; exCtx.lineWidth = LINE_WIDTH; exCtx.moveTo(0, y); exCtx.lineTo(natW, y); exCtx.stroke(); } exportCanvas.toBlob(function (blob) { if (!blob) { setHint("导出失败,请重试"); return; } const url = URL.createObjectURL(blob); const a = document.createElement("a"); const ts = new Date(); const pad = (n) => String(n).padStart(2, "0"); const name = "kline-label-" + ts.getFullYear() + pad(ts.getMonth() + 1) + pad(ts.getDate()) + "-" + pad(ts.getHours()) + pad(ts.getMinutes()) + pad(ts.getSeconds()) + ".png"; a.href = url; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); setHint("标注图已下载:" + name); }, "image/png"); } function updateCursor(y) { const idx = findLineAtY(y); if (idx >= 0) { canvas.classList.add("can-drag"); } else { canvas.classList.remove("can-drag"); } } modeButtons.forEach(function (btn) { btn.addEventListener("click", function () { modeButtons.forEach(function (b) { b.classList.remove("active"); }); btn.classList.add("active"); currentMode = btn.dataset.mode; if (imageLoaded) { setHint("当前模式:" + LINE_TYPES[currentMode].label + " — 单击添加水平线"); } }); }); fileInput.addEventListener("change", function () { const file = fileInput.files[0]; loadImageFile(file); fileInput.value = ""; }); dropZone.addEventListener("dragover", function (e) { e.preventDefault(); dropZone.classList.add("drag-over"); }); dropZone.addEventListener("dragleave", function (e) { if (!dropZone.contains(e.relatedTarget)) { dropZone.classList.remove("drag-over"); } }); dropZone.addEventListener("drop", function (e) { e.preventDefault(); dropZone.classList.remove("drag-over"); const file = e.dataTransfer.files[0]; loadImageFile(file); }); canvas.addEventListener("mousedown", function (e) { if (!imageLoaded) return; e.preventDefault(); const pt = getCanvasPoint(e); const idx = findLineAtY(pt.y); if (idx >= 0) { dragIndex = idx; isDragging = true; didDragMove = false; canvas.classList.add("can-drag"); } }); canvas.addEventListener("mousemove", function (e) { if (!imageLoaded) return; const pt = getCanvasPoint(e); if (isDragging && dragIndex >= 0) { didDragMove = true; lines[dragIndex].y = Math.max(0, Math.min(canvas.height, pt.y)); redraw(); return; } updateCursor(pt.y); }); canvas.addEventListener("mouseup", function () { isDragging = false; dragIndex = -1; }); canvas.addEventListener("mouseleave", function () { isDragging = false; dragIndex = -1; canvas.classList.remove("can-drag"); }); canvas.addEventListener("click", function (e) { if (!imageLoaded || didDragMove) return; const pt = getCanvasPoint(e); if (findLineAtY(pt.y) >= 0) return; addLine(pt.y); }); btnUndo.addEventListener("click", function () { if (lines.length === 0) return; lines.pop(); redraw(); updateButtons(); setHint( lines.length ? "已撤销最后一条,剩余 " + lines.length + " 条" : "已撤销全部线条" ); }); btnClear.addEventListener("click", function () { if (lines.length === 0) return; if (!confirm("确定清空所有标注线条?")) return; clearAnnotations(); setHint("已清空全部标注"); }); btnDownload.addEventListener("click", exportImage); window.addEventListener("resize", function () { if (imageLoaded) { syncCanvasSize(); } }); dropPlaceholder.classList.remove("hidden"); updateButtons(); })();