diff --git a/public/css/style.css b/public/css/style.css index 747fd78..bd08c93 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -237,6 +237,31 @@ body { box-shadow: inset 0 -2px 0 var(--stop); } +.mode-btn.has-marker::after { + content: "●"; + margin-left: 4px; + font-size: 0.55rem; + vertical-align: super; + opacity: 0.85; +} + +.mode-btn[data-mode="entry"].has-marker::after { + color: var(--entry); +} + +.mode-btn[data-mode="exit"].has-marker::after { + color: var(--exit); +} + +.mode-btn[data-mode="stop"].has-marker::after { + color: var(--stop); +} + +.viewport.is-panning, +.viewport.is-panning #overlay-canvas { + cursor: grabbing; +} + .workspace { flex: 1; display: flex; @@ -325,7 +350,11 @@ body { position: absolute; top: 0; left: 0; - cursor: crosshair; + cursor: grab; +} + +#overlay-canvas.can-pan { + cursor: grab; } #overlay-canvas.can-drag { diff --git a/public/index.html b/public/index.html index 6b94c49..cea8d65 100644 --- a/public/index.html +++ b/public/index.html @@ -15,7 +15,7 @@
- +
@@ -72,7 +72,7 @@ 入场 出场 止损 - 滚轮缩放 · 右键拖动画布 · 方向面板/滑块设置箭头朝向 + 空白处拖动平移 · 选中类型后单击放置(各仅一个) diff --git a/public/js/app.js b/public/js/app.js index ffef101..f2ac6ee 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -40,22 +40,25 @@ const angleSlider = document.getElementById("angle-slider"); const angleInput = document.getElementById("angle-input"); - let currentMode = "entry"; + 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 isDragging = false; + 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; @@ -121,6 +124,60 @@ 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; @@ -268,8 +325,14 @@ selectedIndex = index; if (index >= 0) { syncAngleUI(markers[index].angle); + const label = MARKER_TYPES[markers[index].type].label; setHint( - "已选中标记,可调整方向或拖拽移动;点击空白处添加新标记" + "已选中「" + + label + + "」· 可拖动移动或调整方向" + + (currentMode + ? "" + : " · 点击对应类型按钮后可重新放置") ); } redraw(); @@ -279,8 +342,10 @@ markers = []; dragIndex = -1; selectedIndex = -1; - isDragging = false; + isDraggingMarker = false; didDragMove = false; + didPanMove = false; + clearModeSelection(); redraw(); updateButtons(); } @@ -292,6 +357,16 @@ 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"]; @@ -302,6 +377,8 @@ return; } + uploadedFileName = file.name; + const reader = new FileReader(); reader.onload = function (e) { chartImage.onload = function () { @@ -310,6 +387,7 @@ dropZone.classList.remove("drag-over"); zoom = 1; clearAnnotations(); + clearModeSelection(); syncAngleUI(0); requestAnimationFrame(function () { calculateBaseSize(); @@ -317,9 +395,7 @@ viewport.scrollLeft = 0; viewport.scrollTop = 0; setHint( - "滚轮缩放 · 右键/中键拖动画布 · 设置方向后单击添加 " + - MARKER_TYPES[currentMode].label + - " 标记" + "滚轮缩放 · 拖动空白处平移 · 先点「入场/出场/止损」再单击图上放置(各仅一个)" ); updateButtons(); }); @@ -338,29 +414,35 @@ reader.readAsDataURL(file); } - function addMarker(x, y) { + function placeOrMoveMarker(x, y) { + if (!currentMode) return; + const type = currentMode; const info = MARKER_TYPES[type]; const ratio = xyToRatio(x, y); - markers.push({ - xRatio: ratio.xRatio, - yRatio: ratio.yRatio, - type: type, - color: info.color, - angle: pendingAngle, - }); - selectedIndex = markers.length - 1; + 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(); - setHint( - "已添加 " + - info.label + - "(" + - pendingAngle + - "°,共 " + - markers.length + - " 个)" - ); + updateModeButtonStates(); } function exportImage() { @@ -394,18 +476,7 @@ } 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"; + const name = getDownloadFileName(); a.href = url; a.download = name; document.body.appendChild(a); @@ -417,33 +488,28 @@ } function updateCursor(x, y) { - if (isPanning) return; + 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(); - 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 + - " · 方向 " + - pendingAngle + - "° · 单击添加标记" - ); - } + didPanMove = false; + if (!imageLoaded) return; + setMode(btn.dataset.mode); }); }); @@ -519,6 +585,7 @@ function startPan(e) { if (!imageLoaded) return; isPanning = true; + didPanMove = false; panStartX = e.clientX; panStartY = e.clientY; panScrollLeft = viewport.scrollLeft; @@ -537,31 +604,41 @@ 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; - isDragging = true; - didDragMove = false; + isDraggingMarker = true; selectMarker(idx); canvas.classList.add("can-drag"); - } else { - selectMarker(-1); + return; } + + selectMarker(-1); + startPan(e); }); canvas.addEventListener("mousemove", function (e) { if (!imageLoaded) return; if (isPanning) { - viewport.scrollLeft = panScrollLeft - (e.clientX - panStartX); - viewport.scrollTop = panScrollTop - (e.clientY - panStartY); + 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 (isDragging && dragIndex >= 0) { + if (isDraggingMarker && dragIndex >= 0) { didDragMove = true; const ratio = xyToRatio( Math.max(0, Math.min(canvas.width, pt.x)), @@ -581,19 +658,20 @@ viewport.classList.remove("is-panning"); } - canvas.addEventListener("mouseup", function (e) { - if (e.button === 1 || e.button === 2 || isPanning) { - endPan(); - } - isDragging = false; + function endPointer() { + endPan(); + isDraggingMarker = false; dragIndex = -1; + canvas.classList.remove("can-drag", "can-pan"); + } + + canvas.addEventListener("mouseup", function () { + endPointer(); }); canvas.addEventListener("mouseleave", function () { - endPan(); - isDragging = false; - dragIndex = -1; - canvas.classList.remove("can-drag"); + endPointer(); + canvas.classList.remove("can-pan"); }); canvas.addEventListener("contextmenu", function (e) { @@ -602,26 +680,32 @@ canvas.addEventListener("click", function (e) { if (!imageLoaded) return; - if (didDragMove) { + if (didDragMove || didPanMove) { didDragMove = false; + didPanMove = false; return; } + if (!currentMode) return; + const pt = getCanvasPoint(e); if (findMarkerAt(pt.x, pt.y) >= 0) return; - addMarker(pt.x, pt.y); + placeOrMoveMarker(pt.x, pt.y); }); btnUndo.addEventListener("click", function () { if (markers.length === 0) return; - markers.pop(); - if (selectedIndex >= markers.length) { + 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 - ? "已撤销最后一个标记,剩余 " + markers.length + " 个" + ? "已撤销「" + MARKER_TYPES[removed.type].label + "」" : "已撤销全部标记" ); }); @@ -641,9 +725,10 @@ applyLayout(); }); - window.addEventListener("mouseup", endPan); + window.addEventListener("mouseup", endPointer); showEditorUI(false); updateButtons(); + updateModeButtonStates(); syncAngleUI(0); })();