This commit is contained in:
dekun
2026-05-27 14:33:52 +08:00
parent aa33cb5da6
commit dc1b4989b4
3 changed files with 125 additions and 67 deletions
+9 -8
View File
@@ -217,7 +217,7 @@ body {
} }
#overlay-canvas.can-drag { #overlay-canvas.can-drag {
cursor: ns-resize; cursor: move;
} }
.footer { .footer {
@@ -235,23 +235,24 @@ body {
.legend { .legend {
display: inline-block; display: inline-block;
width: 24px; width: 0;
height: 3px; height: 0;
vertical-align: middle; vertical-align: middle;
margin-left: 4px; margin-left: 6px;
border-radius: 1px; border-left: 6px solid transparent;
border-right: 6px solid transparent;
} }
.legend.entry { .legend.entry {
background: var(--entry); border-bottom: 10px solid var(--entry);
} }
.legend.exit { .legend.exit {
background: var(--exit); border-top: 10px solid var(--exit);
} }
.legend.stop { .legend.stop {
background: var(--stop); border-top: 10px solid var(--stop);
} }
.footer-note { .footer-note {
+4 -4
View File
@@ -15,9 +15,9 @@
<input type="file" id="file-input" accept="image/jpeg,image/png,.jpg,.jpeg,.png" hidden> <input type="file" id="file-input" accept="image/jpeg,image/png,.jpg,.jpeg,.png" hidden>
</label> </label>
<div class="mode-group" role="group" aria-label="标注模式"> <div class="mode-group" role="group" aria-label="标注模式">
<button type="button" class="btn mode-btn active" data-mode="entry">入场线</button> <button type="button" class="btn mode-btn active" data-mode="entry">入场</button>
<button type="button" class="btn mode-btn" data-mode="exit">出场线</button> <button type="button" class="btn mode-btn" data-mode="exit">出场</button>
<button type="button" class="btn mode-btn" data-mode="stop">止损线</button> <button type="button" class="btn mode-btn" data-mode="stop">止损</button>
</div> </div>
<button type="button" class="btn" id="btn-undo" disabled>撤销</button> <button type="button" class="btn" id="btn-undo" disabled>撤销</button>
<button type="button" class="btn" id="btn-clear" disabled>清空全部</button> <button type="button" class="btn" id="btn-clear" disabled>清空全部</button>
@@ -44,7 +44,7 @@
<span>入场 <i class="legend entry"></i></span> <span>入场 <i class="legend entry"></i></span>
<span>出场 <i class="legend exit"></i></span> <span>出场 <i class="legend exit"></i></span>
<span>止损 <i class="legend stop"></i></span> <span>止损 <i class="legend stop"></i></span>
<span class="footer-note">单击添加水平线 · 拖拽调整位置</span> <span class="footer-note">单击添加箭头 · 拖拽调整位置 · 可连续标注多个点位</span>
</footer> </footer>
<script src="js/app.js"></script> <script src="js/app.js"></script>
+112 -55
View File
@@ -1,14 +1,17 @@
(function () { (function () {
"use strict"; "use strict";
const LINE_TYPES = { const MARKER_TYPES = {
entry: { label: "入场线", color: "#00ff00" }, entry: { label: "入场", color: "#00ff00", direction: "up" },
exit: { label: "出场线", color: "#0099ff" }, exit: { label: "出场", color: "#0099ff", direction: "down" },
stop: { label: "止损线", color: "#ff3333" }, stop: { label: "止损", color: "#ff3333", direction: "down" },
}; };
const LINE_WIDTH = 3; const ARROW_HEAD = 12;
const HIT_TOLERANCE = 8; const ARROW_STEM = 14;
const ARROW_WIDTH = 9;
const LINE_WIDTH = 2;
const HIT_RADIUS = 18;
const fileInput = document.getElementById("file-input"); const fileInput = document.getElementById("file-input");
const dropZone = document.getElementById("drop-zone"); const dropZone = document.getElementById("drop-zone");
@@ -24,7 +27,7 @@
const modeButtons = document.querySelectorAll(".mode-btn"); const modeButtons = document.querySelectorAll(".mode-btn");
let currentMode = "entry"; let currentMode = "entry";
let lines = []; let markers = [];
let displayWidth = 0; let displayWidth = 0;
let displayHeight = 0; let displayHeight = 0;
let imageLoaded = false; let imageLoaded = false;
@@ -38,9 +41,9 @@
function updateButtons() { function updateButtons() {
const hasImage = imageLoaded; const hasImage = imageLoaded;
const hasLines = lines.length > 0; const hasMarkers = markers.length > 0;
btnUndo.disabled = !hasImage || !hasLines; btnUndo.disabled = !hasImage || !hasMarkers;
btnClear.disabled = !hasImage || !hasLines; btnClear.disabled = !hasImage || !hasMarkers;
btnDownload.disabled = !hasImage; btnDownload.disabled = !hasImage;
} }
@@ -54,25 +57,67 @@
}; };
} }
function findLineAtY(y) { function getMarkerDirection(type) {
for (let i = lines.length - 1; i >= 0; i--) { return MARKER_TYPES[type].direction;
if (Math.abs(lines[i].y - y) <= HIT_TOLERANCE) { }
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 i;
} }
} }
return -1; 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() { function redraw() {
if (!imageLoaded) return; if (!imageLoaded) return;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const line of lines) { for (const m of markers) {
ctx.beginPath(); drawArrowMarker(ctx, m.x, m.y, m.type, m.color);
ctx.strokeStyle = line.color;
ctx.lineWidth = LINE_WIDTH;
ctx.moveTo(0, line.y);
ctx.lineTo(canvas.width, line.y);
ctx.stroke();
} }
} }
@@ -87,9 +132,10 @@
} }
function clearAnnotations() { function clearAnnotations() {
lines = []; markers = [];
dragIndex = -1; dragIndex = -1;
isDragging = false; isDragging = false;
didDragMove = false;
redraw(); redraw();
updateButtons(); updateButtons();
} }
@@ -116,8 +162,8 @@
syncCanvasSize(); syncCanvasSize();
setHint( setHint(
"当前模式:" + "当前模式:" +
LINE_TYPES[currentMode].label + MARKER_TYPES[currentMode].label +
" — 在图上单击添加水平线,可拖拽调整" " — 在图上单击添加箭头标记,可拖拽调整"
); );
updateButtons(); updateButtons();
}); });
@@ -135,19 +181,18 @@
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
function addLine(y) { function addMarker(x, y) {
const type = currentMode; const type = currentMode;
const info = LINE_TYPES[type]; const info = MARKER_TYPES[type];
lines.push({ y: y, type: type, color: info.color }); markers.push({ x: x, y: y, type: type, color: info.color });
redraw(); redraw();
updateButtons(); updateButtons();
setHint( setHint(
"已添加 " + "已添加 " +
info.label + info.label +
"(共 " + " 标记(共 " +
lines.length + markers.length +
" )— 当前模式:" + " )— 可切换模式继续标注"
info.label
); );
} }
@@ -156,6 +201,7 @@
const natW = chartImage.naturalWidth; const natW = chartImage.naturalWidth;
const natH = chartImage.naturalHeight; const natH = chartImage.naturalHeight;
const scaleX = natW / displayWidth;
const scaleY = natH / displayHeight; const scaleY = natH / displayHeight;
const exportCanvas = document.createElement("canvas"); const exportCanvas = document.createElement("canvas");
@@ -165,14 +211,14 @@
exCtx.drawImage(chartImage, 0, 0, natW, natH); exCtx.drawImage(chartImage, 0, 0, natW, natH);
for (const line of lines) { for (const m of markers) {
const y = line.y * scaleY; drawArrowMarker(
exCtx.beginPath(); exCtx,
exCtx.strokeStyle = line.color; m.x * scaleX,
exCtx.lineWidth = LINE_WIDTH; m.y * scaleY,
exCtx.moveTo(0, y); m.type,
exCtx.lineTo(natW, y); m.color
exCtx.stroke(); );
} }
exportCanvas.toBlob(function (blob) { exportCanvas.toBlob(function (blob) {
@@ -204,8 +250,8 @@
}, "image/png"); }, "image/png");
} }
function updateCursor(y) { function updateCursor(x, y) {
const idx = findLineAtY(y); const idx = findMarkerAt(x, y);
if (idx >= 0) { if (idx >= 0) {
canvas.classList.add("can-drag"); canvas.classList.add("can-drag");
} else { } else {
@@ -214,14 +260,20 @@
} }
modeButtons.forEach(function (btn) { modeButtons.forEach(function (btn) {
btn.addEventListener("click", function () { btn.addEventListener("click", function (e) {
e.stopPropagation();
modeButtons.forEach(function (b) { modeButtons.forEach(function (b) {
b.classList.remove("active"); b.classList.remove("active");
}); });
btn.classList.add("active"); btn.classList.add("active");
currentMode = btn.dataset.mode; currentMode = btn.dataset.mode;
didDragMove = false;
if (imageLoaded) { if (imageLoaded) {
setHint("当前模式:" + LINE_TYPES[currentMode].label + " — 单击添加水平线"); setHint(
"当前模式:" +
MARKER_TYPES[currentMode].label +
" — 单击添加箭头,可连续标注多个点位"
);
} }
}); });
}); });
@@ -254,7 +306,7 @@
if (!imageLoaded) return; if (!imageLoaded) return;
e.preventDefault(); e.preventDefault();
const pt = getCanvasPoint(e); const pt = getCanvasPoint(e);
const idx = findLineAtY(pt.y); const idx = findMarkerAt(pt.x, pt.y);
if (idx >= 0) { if (idx >= 0) {
dragIndex = idx; dragIndex = idx;
isDragging = true; isDragging = true;
@@ -269,12 +321,13 @@
if (isDragging && dragIndex >= 0) { if (isDragging && dragIndex >= 0) {
didDragMove = true; didDragMove = true;
lines[dragIndex].y = Math.max(0, Math.min(canvas.height, pt.y)); 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(); redraw();
return; return;
} }
updateCursor(pt.y); updateCursor(pt.x, pt.y);
}); });
canvas.addEventListener("mouseup", function () { canvas.addEventListener("mouseup", function () {
@@ -289,27 +342,31 @@
}); });
canvas.addEventListener("click", function (e) { canvas.addEventListener("click", function (e) {
if (!imageLoaded || didDragMove) return; if (!imageLoaded) return;
if (didDragMove) {
didDragMove = false;
return;
}
const pt = getCanvasPoint(e); const pt = getCanvasPoint(e);
if (findLineAtY(pt.y) >= 0) return; if (findMarkerAt(pt.x, pt.y) >= 0) return;
addLine(pt.y); addMarker(pt.x, pt.y);
}); });
btnUndo.addEventListener("click", function () { btnUndo.addEventListener("click", function () {
if (lines.length === 0) return; if (markers.length === 0) return;
lines.pop(); markers.pop();
redraw(); redraw();
updateButtons(); updateButtons();
setHint( setHint(
lines.length markers.length
? "已撤销最后一,剩余 " + lines.length + " " ? "已撤销最后一个标记,剩余 " + markers.length + " "
: "已撤销全部线条" : "已撤销全部标记"
); );
}); });
btnClear.addEventListener("click", function () { btnClear.addEventListener("click", function () {
if (lines.length === 0) return; if (markers.length === 0) return;
if (!confirm("确定清空所有标注线条")) return; if (!confirm("确定清空所有标注?")) return;
clearAnnotations(); clearAnnotations();
setHint("已清空全部标注"); setHint("已清空全部标注");
}); });