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);
})();