This commit is contained in:
dekun
2026-05-27 14:41:13 +08:00
parent dc1b4989b4
commit 518e517150
3 changed files with 495 additions and 90 deletions
+335 -70
View File
@@ -2,9 +2,9 @@
"use strict";
const MARKER_TYPES = {
entry: { label: "入场", color: "#00ff00", direction: "up" },
exit: { label: "出场", color: "#0099ff", direction: "down" },
stop: { label: "止损", color: "#ff3333", direction: "down" },
entry: { label: "入场", color: "#00ff00" },
exit: { label: "出场", color: "#0099ff" },
stop: { label: "止损", color: "#ff3333" },
};
const ARROW_HEAD = 12;
@@ -12,11 +12,15 @@
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 canvasWrap = document.getElementById("canvas-wrap");
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");
@@ -25,26 +29,70 @@
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 = "entry";
let pendingAngle = 0;
let markers = [];
let displayWidth = 0;
let displayHeight = 0;
let baseWidth = 0;
let baseHeight = 0;
let zoom = 1;
let imageLoaded = false;
let dragIndex = -1;
let selectedIndex = -1;
let isDragging = false;
let didDragMove = false;
let isPanning = false;
let panStartX = 0;
let panStartY = 0;
let panScrollLeft = 0;
let panScrollTop = 0;
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) {
@@ -57,10 +105,6 @@
};
}
function getMarkerDirection(type) {
return MARKER_TYPES[type].direction;
}
function dist(x1, y1, x2, y2) {
const dx = x1 - x2;
const dy = y1 - y2;
@@ -69,77 +113,185 @@
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) {
const p = ratioToXY(markers[i]);
if (dist(x, y, p.x, p.y) <= HIT_RADIUS) {
return i;
}
}
return -1;
}
function drawArrowMarker(targetCtx, x, y, type, color) {
const direction = getMarkerDirection(type);
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 = LINE_WIDTH;
targetCtx.lineWidth = highlight ? LINE_WIDTH + 1 : LINE_WIDTH;
targetCtx.lineCap = "round";
targetCtx.lineJoin = "round";
if (direction === "up") {
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.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.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 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);
markers.forEach(function (m, i) {
const p = ratioToXY(m);
drawArrowMarker(
ctx,
p.x,
p.y,
m.angle,
m.color,
i === selectedIndex
);
});
}
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 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";
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);
setHint(
"已选中标记,可调整方向或拖拽移动;点击空白处添加新标记"
);
}
redraw();
}
function clearAnnotations() {
markers = [];
dragIndex = -1;
selectedIndex = -1;
isDragging = false;
didDragMove = false;
redraw();
updateButtons();
}
function showEditorUI(show) {
dropPlaceholder.classList.toggle("hidden", show);
viewport.classList.toggle("hidden", !show);
zoomGroup.hidden = !show;
directionRow.hidden = !show;
}
function loadImageFile(file) {
if (!file) return;
const validTypes = ["image/jpeg", "image/png"];
@@ -154,16 +306,20 @@
reader.onload = function (e) {
chartImage.onload = function () {
imageLoaded = true;
dropPlaceholder.classList.add("hidden");
canvasWrap.classList.remove("hidden");
showEditorUI(true);
dropZone.classList.remove("drag-over");
zoom = 1;
clearAnnotations();
syncAngleUI(0);
requestAnimationFrame(function () {
syncCanvasSize();
calculateBaseSize();
applyLayout();
viewport.scrollLeft = 0;
viewport.scrollTop = 0;
setHint(
"当前模式:" +
"滚轮缩放 · 右键/中键拖动画布 · 设置方向后单击添加 " +
MARKER_TYPES[currentMode].label +
" — 在图上单击添加箭头标记,可拖拽调整"
" 标记"
);
updateButtons();
});
@@ -171,6 +327,7 @@
chartImage.onerror = function () {
setHint("图片加载失败,请换一张重试");
imageLoaded = false;
showEditorUI(false);
updateButtons();
};
chartImage.src = e.target.result;
@@ -184,15 +341,25 @@
function addMarker(x, y) {
const type = currentMode;
const info = MARKER_TYPES[type];
markers.push({ x: x, y: y, type: type, color: info.color });
const ratio = xyToRatio(x, y);
markers.push({
xRatio: ratio.xRatio,
yRatio: ratio.yRatio,
type: type,
color: info.color,
angle: pendingAngle,
});
selectedIndex = markers.length - 1;
redraw();
updateButtons();
setHint(
"已添加 " +
info.label +
" 标记(共 " +
"" +
pendingAngle +
"°,共 " +
markers.length +
" 个)— 可切换模式继续标注"
" 个)"
);
}
@@ -201,8 +368,6 @@
const natW = chartImage.naturalWidth;
const natH = chartImage.naturalHeight;
const scaleX = natW / displayWidth;
const scaleY = natH / displayHeight;
const exportCanvas = document.createElement("canvas");
exportCanvas.width = natW;
@@ -214,10 +379,11 @@
for (const m of markers) {
drawArrowMarker(
exCtx,
m.x * scaleX,
m.y * scaleY,
m.type,
m.color
m.xRatio * natW,
m.yRatio * natH,
m.angle,
m.color,
false
);
}
@@ -251,6 +417,7 @@
}
function updateCursor(x, y) {
if (isPanning) return;
const idx = findMarkerAt(x, y);
if (idx >= 0) {
canvas.classList.add("can-drag");
@@ -270,17 +437,65 @@
didDragMove = false;
if (imageLoaded) {
setHint(
"当前模式" +
"当前:" +
MARKER_TYPES[currentMode].label +
" — 单击添加箭头,可连续标注多个点位"
" · 方向 " +
pendingAngle +
"° · 单击添加标记"
);
}
});
});
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 () {
const file = fileInput.files[0];
loadImageFile(file);
loadImageFile(fileInput.files[0]);
fileInput.value = "";
});
@@ -298,12 +513,29 @@
dropZone.addEventListener("drop", function (e) {
e.preventDefault();
dropZone.classList.remove("drag-over");
const file = e.dataTransfer.files[0];
loadImageFile(file);
loadImageFile(e.dataTransfer.files[0]);
});
function startPan(e) {
if (!imageLoaded) return;
isPanning = true;
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();
const pt = getCanvasPoint(e);
const idx = findMarkerAt(pt.x, pt.y);
@@ -311,18 +543,32 @@
dragIndex = idx;
isDragging = true;
didDragMove = false;
selectMarker(idx);
canvas.classList.add("can-drag");
} else {
selectMarker(-1);
}
});
canvas.addEventListener("mousemove", function (e) {
if (!imageLoaded) return;
if (isPanning) {
viewport.scrollLeft = panScrollLeft - (e.clientX - panStartX);
viewport.scrollTop = panScrollTop - (e.clientY - panStartY);
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));
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;
}
@@ -330,17 +576,30 @@
updateCursor(pt.x, pt.y);
});
canvas.addEventListener("mouseup", function () {
function endPan() {
isPanning = false;
viewport.classList.remove("is-panning");
}
canvas.addEventListener("mouseup", function (e) {
if (e.button === 1 || e.button === 2 || isPanning) {
endPan();
}
isDragging = false;
dragIndex = -1;
});
canvas.addEventListener("mouseleave", function () {
endPan();
isDragging = false;
dragIndex = -1;
canvas.classList.remove("can-drag");
});
canvas.addEventListener("contextmenu", function (e) {
e.preventDefault();
});
canvas.addEventListener("click", function (e) {
if (!imageLoaded) return;
if (didDragMove) {
@@ -355,6 +614,9 @@
btnUndo.addEventListener("click", function () {
if (markers.length === 0) return;
markers.pop();
if (selectedIndex >= markers.length) {
selectedIndex = markers.length - 1;
}
redraw();
updateButtons();
setHint(
@@ -374,11 +636,14 @@
btnDownload.addEventListener("click", exportImage);
window.addEventListener("resize", function () {
if (imageLoaded) {
syncCanvasSize();
}
if (!imageLoaded) return;
calculateBaseSize();
applyLayout();
});
dropPlaceholder.classList.remove("hidden");
window.addEventListener("mouseup", endPan);
showEditorUI(false);
updateButtons();
syncAngleUI(0);
})();