(function () { "use strict"; const MARKER_TYPES = { entry: { label: "入场", color: "#00ff00" }, exit: { label: "出场", color: "#0099ff" }, stop: { label: "止损", color: "#ff3333" }, }; const ARROW_HEAD = 12; const ARROW_STEM = 14; const ARROW_WIDTH = 9; const LINE_WIDTH = 2; const HIT_RADIUS = 18; const ZOOM_MIN = 0.25; const ZOOM_MAX = 5; const ZOOM_STEP = 0.15; const fileInput = document.getElementById("file-input"); const dropZone = document.getElementById("drop-zone"); const dropPlaceholder = document.getElementById("drop-placeholder"); const viewport = document.getElementById("viewport"); const stage = document.getElementById("stage"); 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"); const zoomGroup = document.getElementById("zoom-group"); const zoomLabel = document.getElementById("zoom-label"); const btnZoomIn = document.getElementById("zoom-in"); const btnZoomOut = document.getElementById("zoom-out"); const btnZoomFit = document.getElementById("zoom-fit"); const directionRow = document.getElementById("direction-row"); const dirButtons = document.querySelectorAll(".dir-btn"); const dirCenter = document.getElementById("dir-center"); const angleSlider = document.getElementById("angle-slider"); const angleInput = document.getElementById("angle-input"); let currentMode = null; let pendingAngle = 0; let markers = []; let baseWidth = 0; let baseHeight = 0; let zoom = 1; let imageLoaded = false; let uploadedFileName = ""; let dragIndex = -1; let selectedIndex = -1; let isDraggingMarker = false; let didDragMove = false; let isPanning = false; let didPanMove = false; let panStartX = 0; let panStartY = 0; let panScrollLeft = 0; let panScrollTop = 0; const PAN_THRESHOLD = 4; function setHint(text) { statusHint.textContent = text; } function normalizeAngle(deg) { let a = Math.round(deg) % 360; if (a < 0) a += 360; return a; } function updateButtons() { const hasImage = imageLoaded; const hasMarkers = markers.length > 0; btnUndo.disabled = !hasImage || !hasMarkers; btnClear.disabled = !hasImage || !hasMarkers; btnDownload.disabled = !hasImage; btnZoomIn.disabled = !hasImage; btnZoomOut.disabled = !hasImage; btnZoomFit.disabled = !hasImage; } function getStageSize() { return { w: baseWidth * zoom, h: baseHeight * zoom, }; } function ratioToXY(m) { const { w, h } = getStageSize(); return { x: m.xRatio * w, y: m.yRatio * h }; } function xyToRatio(x, y) { const { w, h } = getStageSize(); return { xRatio: x / w, yRatio: y / h }; } 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 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--) { const p = ratioToXY(markers[i]); if (dist(x, y, p.x, p.y) <= HIT_RADIUS) { return i; } } return -1; } function findMarkerIndexByType(type) { return markers.findIndex(function (m) { return m.type === type; }); } function updateModeButtonStates() { modeButtons.forEach(function (btn) { const type = btn.dataset.mode; btn.classList.toggle("has-marker", findMarkerIndexByType(type) >= 0); btn.classList.toggle("active", currentMode === type); }); } function clearModeSelection() { currentMode = null; updateModeButtonStates(); } function setMode(mode) { if (currentMode === mode) { clearModeSelection(); setHint("已取消选择 · 拖动空白处平移画布 · 点击入场/出场/止损开始标注"); return; } currentMode = mode; const info = MARKER_TYPES[mode]; const existIdx = findMarkerIndexByType(mode); if (existIdx >= 0) { selectedIndex = existIdx; syncAngleUI(markers[existIdx].angle); setHint( info.label + " 已标注 · 单击图上可移动位置,或拖动箭头 · 再次点击「" + info.label + "」取消选择" ); } else { selectedIndex = -1; setHint( "已选择「" + info.label + "」· 在图上单击放置(仅一个)· 方向 " + pendingAngle + "°" ); } updateModeButtonStates(); redraw(); } function drawArrowMarker(targetCtx, x, y, angleDeg, color, highlight) { const w = ARROW_WIDTH; const h = ARROW_HEAD; const stem = ARROW_STEM; const rad = (angleDeg * Math.PI) / 180; targetCtx.save(); targetCtx.translate(x, y); targetCtx.rotate(rad); targetCtx.fillStyle = color; targetCtx.strokeStyle = color; targetCtx.lineWidth = highlight ? LINE_WIDTH + 1 : LINE_WIDTH; targetCtx.lineCap = "round"; targetCtx.lineJoin = "round"; targetCtx.beginPath(); targetCtx.moveTo(0, 0); targetCtx.lineTo(-w, h); targetCtx.lineTo(w, h); targetCtx.closePath(); targetCtx.fill(); targetCtx.beginPath(); targetCtx.moveTo(0, h); targetCtx.lineTo(0, h + stem); targetCtx.stroke(); if (highlight) { targetCtx.beginPath(); targetCtx.arc(0, 0, HIT_RADIUS * 0.55, 0, Math.PI * 2); targetCtx.strokeStyle = "rgba(255,255,255,0.45)"; targetCtx.lineWidth = 1.5; targetCtx.setLineDash([4, 3]); targetCtx.stroke(); targetCtx.setLineDash([]); } targetCtx.restore(); } function drawDragGuideLine(targetCtx, y, color) { const w = targetCtx.canvas.width; targetCtx.save(); targetCtx.strokeStyle = color; targetCtx.lineWidth = 1.5; targetCtx.setLineDash([10, 6]); targetCtx.globalAlpha = 0.9; targetCtx.beginPath(); targetCtx.moveTo(0, y); targetCtx.lineTo(w, y); targetCtx.stroke(); targetCtx.restore(); } function redraw() { if (!imageLoaded) return; ctx.clearRect(0, 0, canvas.width, canvas.height); markers.forEach(function (m, i) { const p = ratioToXY(m); drawArrowMarker( ctx, p.x, p.y, m.angle, m.color, i === selectedIndex ); }); if (isDraggingMarker && dragIndex >= 0) { const dragging = markers[dragIndex]; const p = ratioToXY(dragging); drawDragGuideLine(ctx, p.y, dragging.color); } } function updateZoomLabel() { zoomLabel.textContent = Math.round(zoom * 100) + "%"; } function calculateBaseSize() { const maxW = Math.max(viewport.clientWidth - 8, 320); const maxH = Math.max(viewport.clientHeight - 8, 280); const natW = chartImage.naturalWidth; const natH = chartImage.naturalHeight; const scale = Math.min(maxW / natW, maxH / natH); baseWidth = natW * scale; baseHeight = natH * scale; } function applyLayout() { if (!imageLoaded) return; const { w, h } = getStageSize(); const cw = Math.round(w); const ch = Math.round(h); stage.style.width = cw + "px"; stage.style.height = ch + "px"; chartImage.style.width = cw + "px"; chartImage.style.height = ch + "px"; canvas.width = cw; canvas.height = ch; canvas.style.width = cw + "px"; canvas.style.height = ch + "px"; updateZoomLabel(); redraw(); } function setZoom(newZoom, clientX, clientY) { const oldZoom = zoom; newZoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, newZoom)); if (Math.abs(newZoom - oldZoom) < 0.001) return; const rect = viewport.getBoundingClientRect(); const hasPointer = typeof clientX === "number" && typeof clientY === "number"; const scrollX = viewport.scrollLeft; const scrollY = viewport.scrollTop; const px = hasPointer ? clientX - rect.left + scrollX : scrollX + rect.width / 2; const py = hasPointer ? clientY - rect.top + scrollY : scrollY + rect.height / 2; const ratioX = px / (baseWidth * oldZoom); const ratioY = py / (baseHeight * oldZoom); zoom = newZoom; applyLayout(); if (hasPointer) { viewport.scrollLeft = ratioX * baseWidth * zoom - (clientX - rect.left); viewport.scrollTop = ratioY * baseHeight * zoom - (clientY - rect.top); } } function resetZoomFit() { zoom = 1; applyLayout(); viewport.scrollLeft = 0; viewport.scrollTop = 0; } function syncAngleUI(angle) { const a = normalizeAngle(angle); pendingAngle = a; angleSlider.value = String(a); angleInput.value = String(a); dirCenter.textContent = a + "°"; dirButtons.forEach(function (btn) { const btnAngle = parseInt(btn.dataset.angle, 10); btn.classList.toggle("active", btnAngle === a); }); } function applyAngleToTarget(angle) { const a = normalizeAngle(angle); syncAngleUI(a); if (selectedIndex >= 0) { markers[selectedIndex].angle = a; redraw(); setHint("已更新选中标记方向为 " + a + "°"); } } function selectMarker(index) { selectedIndex = index; if (index >= 0) { syncAngleUI(markers[index].angle); const label = MARKER_TYPES[markers[index].type].label; setHint( "已选中「" + label + "」· 可拖动移动或调整方向" + (currentMode ? "" : " · 点击对应类型按钮后可重新放置") ); } redraw(); } function clearAnnotations() { markers = []; dragIndex = -1; selectedIndex = -1; isDraggingMarker = false; didDragMove = false; didPanMove = false; clearModeSelection(); redraw(); updateButtons(); } function showEditorUI(show) { dropPlaceholder.classList.toggle("hidden", show); viewport.classList.toggle("hidden", !show); zoomGroup.hidden = !show; directionRow.hidden = !show; } function getDownloadFileName() { if (!uploadedFileName) { return "标注1.png"; } const lastDot = uploadedFileName.lastIndexOf("."); const base = lastDot > 0 ? uploadedFileName.slice(0, lastDot) : uploadedFileName; return base + "1.png"; } 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; } uploadedFileName = file.name; const reader = new FileReader(); reader.onload = function (e) { chartImage.onload = function () { imageLoaded = true; showEditorUI(true); dropZone.classList.remove("drag-over"); zoom = 1; clearAnnotations(); clearModeSelection(); syncAngleUI(0); requestAnimationFrame(function () { calculateBaseSize(); applyLayout(); viewport.scrollLeft = 0; viewport.scrollTop = 0; setHint( "滚轮缩放 · 拖动空白处平移 · 先点「入场/出场/止损」再单击图上放置(各仅一个)" ); updateButtons(); }); }; chartImage.onerror = function () { setHint("图片加载失败,请换一张重试"); imageLoaded = false; showEditorUI(false); updateButtons(); }; chartImage.src = e.target.result; }; reader.onerror = function () { setHint("文件读取失败"); }; reader.readAsDataURL(file); } function placeOrMoveMarker(x, y) { if (!currentMode) return; const type = currentMode; const info = MARKER_TYPES[type]; const ratio = xyToRatio(x, y); const existIdx = findMarkerIndexByType(type); if (existIdx >= 0) { markers[existIdx].xRatio = ratio.xRatio; markers[existIdx].yRatio = ratio.yRatio; markers[existIdx].angle = pendingAngle; selectedIndex = existIdx; setHint(info.label + " 已移动到当前位置(" + pendingAngle + "°)"); } else { markers.push({ xRatio: ratio.xRatio, yRatio: ratio.yRatio, type: type, color: info.color, angle: pendingAngle, }); selectedIndex = markers.length - 1; setHint("已放置「" + info.label + "」(" + pendingAngle + "°)"); } redraw(); updateButtons(); updateModeButtonStates(); } function exportImage() { if (!imageLoaded) return; const natW = chartImage.naturalWidth; const natH = chartImage.naturalHeight; 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.xRatio * natW, m.yRatio * natH, m.angle, m.color, false ); } exportCanvas.toBlob(function (blob) { if (!blob) { setHint("导出失败,请重试"); return; } const url = URL.createObjectURL(blob); const a = document.createElement("a"); const name = getDownloadFileName(); 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) { if (isPanning) { canvas.classList.remove("can-drag"); canvas.classList.add("can-pan"); return; } const idx = findMarkerAt(x, y); canvas.classList.remove("can-pan"); if (idx >= 0) { canvas.classList.add("can-drag"); } else { canvas.classList.remove("can-drag"); canvas.classList.add("can-pan"); } } modeButtons.forEach(function (btn) { btn.addEventListener("click", function (e) { e.stopPropagation(); didDragMove = false; didPanMove = false; if (!imageLoaded) return; setMode(btn.dataset.mode); }); }); dirButtons.forEach(function (btn) { btn.addEventListener("click", function () { applyAngleToTarget(parseInt(btn.dataset.angle, 10)); }); }); angleSlider.addEventListener("input", function () { applyAngleToTarget(parseInt(angleSlider.value, 10)); }); angleInput.addEventListener("change", function () { let v = parseInt(angleInput.value, 10); if (Number.isNaN(v)) v = 0; applyAngleToTarget(v); }); btnZoomIn.addEventListener("click", function () { const rect = viewport.getBoundingClientRect(); setZoom( zoom + ZOOM_STEP, rect.left + rect.width / 2, rect.top + rect.height / 2 ); }); btnZoomOut.addEventListener("click", function () { const rect = viewport.getBoundingClientRect(); setZoom( zoom - ZOOM_STEP, rect.left + rect.width / 2, rect.top + rect.height / 2 ); }); btnZoomFit.addEventListener("click", resetZoomFit); viewport.addEventListener( "wheel", function (e) { if (!imageLoaded) return; e.preventDefault(); const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP; setZoom(zoom + delta, e.clientX, e.clientY); }, { passive: false } ); fileInput.addEventListener("change", function () { loadImageFile(fileInput.files[0]); 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"); loadImageFile(e.dataTransfer.files[0]); }); function startPan(e) { if (!imageLoaded) return; isPanning = true; didPanMove = false; panStartX = e.clientX; panStartY = e.clientY; panScrollLeft = viewport.scrollLeft; panScrollTop = viewport.scrollTop; viewport.classList.add("is-panning"); e.preventDefault(); } canvas.addEventListener("mousedown", function (e) { if (!imageLoaded) return; if (e.button === 1 || e.button === 2) { startPan(e); return; } if (e.button !== 0) return; e.preventDefault(); didDragMove = false; didPanMove = false; const pt = getCanvasPoint(e); const idx = findMarkerAt(pt.x, pt.y); if (idx >= 0) { dragIndex = idx; isDraggingMarker = true; selectMarker(idx); canvas.classList.add("can-drag"); return; } selectMarker(-1); startPan(e); }); canvas.addEventListener("mousemove", function (e) { if (!imageLoaded) return; if (isPanning) { const dx = e.clientX - panStartX; const dy = e.clientY - panStartY; if (Math.abs(dx) > PAN_THRESHOLD || Math.abs(dy) > PAN_THRESHOLD) { didPanMove = true; } viewport.scrollLeft = panScrollLeft - dx; viewport.scrollTop = panScrollTop - dy; return; } const pt = getCanvasPoint(e); if (isDraggingMarker && dragIndex >= 0) { didDragMove = true; const ratio = xyToRatio( Math.max(0, Math.min(canvas.width, pt.x)), Math.max(0, Math.min(canvas.height, pt.y)) ); markers[dragIndex].xRatio = ratio.xRatio; markers[dragIndex].yRatio = ratio.yRatio; redraw(); return; } updateCursor(pt.x, pt.y); }); function endPan() { isPanning = false; viewport.classList.remove("is-panning"); } function endPointer() { const wasDraggingMarker = isDraggingMarker; endPan(); isDraggingMarker = false; dragIndex = -1; canvas.classList.remove("can-drag", "can-pan"); if (wasDraggingMarker && imageLoaded) { redraw(); } } canvas.addEventListener("mouseup", function () { endPointer(); }); canvas.addEventListener("mouseleave", function () { endPointer(); canvas.classList.remove("can-pan"); }); canvas.addEventListener("contextmenu", function (e) { e.preventDefault(); }); canvas.addEventListener("click", function (e) { if (!imageLoaded) return; if (didDragMove || didPanMove) { didDragMove = false; didPanMove = false; return; } if (!currentMode) return; const pt = getCanvasPoint(e); if (findMarkerAt(pt.x, pt.y) >= 0) return; placeOrMoveMarker(pt.x, pt.y); }); btnUndo.addEventListener("click", function () { if (markers.length === 0) return; const removed = markers.pop(); if (currentMode === removed.type) { selectedIndex = -1; } else if (selectedIndex >= markers.length) { selectedIndex = markers.length - 1; } redraw(); updateButtons(); updateModeButtonStates(); setHint( markers.length ? "已撤销「" + MARKER_TYPES[removed.type].label + "」" : "已撤销全部标记" ); }); btnClear.addEventListener("click", function () { if (markers.length === 0) return; if (!confirm("确定清空所有标注?")) return; clearAnnotations(); setHint("已清空全部标注"); }); btnDownload.addEventListener("click", exportImage); window.addEventListener("resize", function () { if (!imageLoaded) return; calculateBaseSize(); applyLayout(); }); window.addEventListener("mouseup", endPointer); showEditorUI(false); updateButtons(); updateModeButtonStates(); syncAngleUI(0); })();