(function () { "use strict"; const MARKER_TYPES = { entry: { label: "入场", color: "#00ff00", direction: "up" }, exit: { label: "出场", color: "#0099ff", direction: "down" }, stop: { label: "止损", color: "#ff3333", direction: "down" }, }; const ARROW_HEAD = 12; const ARROW_STEM = 14; const ARROW_WIDTH = 9; const LINE_WIDTH = 2; const HIT_RADIUS = 18; 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 markers = []; 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 hasMarkers = markers.length > 0; btnUndo.disabled = !hasImage || !hasMarkers; btnClear.disabled = !hasImage || !hasMarkers; 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 getMarkerDirection(type) { return MARKER_TYPES[type].direction; } function dist(x1, y1, x2, y2) { const dx = x1 - x2; const dy = y1 - y2; return Math.sqrt(dx * dx + dy * dy); } function findMarkerAt(x, y) { for (let i = markers.length - 1; i >= 0; i--) { if (dist(x, y, markers[i].x, markers[i].y) <= HIT_RADIUS) { return i; } } return -1; } function drawArrowMarker(targetCtx, x, y, type, color) { const direction = getMarkerDirection(type); const w = ARROW_WIDTH; const h = ARROW_HEAD; const stem = ARROW_STEM; targetCtx.fillStyle = color; targetCtx.strokeStyle = color; targetCtx.lineWidth = LINE_WIDTH; targetCtx.lineCap = "round"; targetCtx.lineJoin = "round"; if (direction === "up") { targetCtx.beginPath(); targetCtx.moveTo(x, y); targetCtx.lineTo(x - w, y + h); targetCtx.lineTo(x + w, y + h); targetCtx.closePath(); targetCtx.fill(); targetCtx.beginPath(); targetCtx.moveTo(x, y + h); targetCtx.lineTo(x, y + h + stem); targetCtx.stroke(); } else { targetCtx.beginPath(); targetCtx.moveTo(x, y); targetCtx.lineTo(x - w, y - h); targetCtx.lineTo(x + w, y - h); targetCtx.closePath(); targetCtx.fill(); targetCtx.beginPath(); targetCtx.moveTo(x, y - h); targetCtx.lineTo(x, y - h - stem); targetCtx.stroke(); } } function redraw() { if (!imageLoaded) return; ctx.clearRect(0, 0, canvas.width, canvas.height); for (const m of markers) { drawArrowMarker(ctx, m.x, m.y, m.type, m.color); } } 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() { markers = []; dragIndex = -1; isDragging = false; didDragMove = 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( "当前模式:" + MARKER_TYPES[currentMode].label + " — 在图上单击添加箭头标记,可拖拽调整" ); updateButtons(); }); }; chartImage.onerror = function () { setHint("图片加载失败,请换一张重试"); imageLoaded = false; updateButtons(); }; chartImage.src = e.target.result; }; reader.onerror = function () { setHint("文件读取失败"); }; reader.readAsDataURL(file); } function addMarker(x, y) { const type = currentMode; const info = MARKER_TYPES[type]; markers.push({ x: x, y: y, type: type, color: info.color }); redraw(); updateButtons(); setHint( "已添加 " + info.label + " 标记(共 " + markers.length + " 个)— 可切换模式继续标注" ); } function exportImage() { if (!imageLoaded) return; const natW = chartImage.naturalWidth; const natH = chartImage.naturalHeight; const scaleX = natW / displayWidth; 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 m of markers) { drawArrowMarker( exCtx, m.x * scaleX, m.y * scaleY, m.type, m.color ); } 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(x, y) { const idx = findMarkerAt(x, y); if (idx >= 0) { canvas.classList.add("can-drag"); } else { canvas.classList.remove("can-drag"); } } modeButtons.forEach(function (btn) { btn.addEventListener("click", function (e) { e.stopPropagation(); modeButtons.forEach(function (b) { b.classList.remove("active"); }); btn.classList.add("active"); currentMode = btn.dataset.mode; didDragMove = false; if (imageLoaded) { setHint( "当前模式:" + MARKER_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 = findMarkerAt(pt.x, 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; markers[dragIndex].x = Math.max(0, Math.min(canvas.width, pt.x)); markers[dragIndex].y = Math.max(0, Math.min(canvas.height, pt.y)); redraw(); return; } updateCursor(pt.x, 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) return; if (didDragMove) { didDragMove = false; return; } const pt = getCanvasPoint(e); if (findMarkerAt(pt.x, pt.y) >= 0) return; addMarker(pt.x, pt.y); }); btnUndo.addEventListener("click", function () { if (markers.length === 0) return; markers.pop(); redraw(); updateButtons(); setHint( markers.length ? "已撤销最后一个标记,剩余 " + markers.length + " 个" : "已撤销全部标记" ); }); btnClear.addEventListener("click", function () { if (markers.length === 0) return; if (!confirm("确定清空所有标注?")) return; clearAnnotations(); setHint("已清空全部标注"); }); btnDownload.addEventListener("click", exportImage); window.addEventListener("resize", function () { if (imageLoaded) { syncCanvasSize(); } }); dropPlaceholder.classList.remove("hidden"); updateButtons(); })();