Files
chart-label-tool/public/js/app.js
T
2026-05-27 14:48:19 +08:00

735 lines
20 KiB
JavaScript

(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 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
);
});
}
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() {
endPan();
isDraggingMarker = false;
dragIndex = -1;
canvas.classList.remove("can-drag", "can-pan");
}
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);
})();