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

650 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 = "entry";
let pendingAngle = 0;
let markers = [];
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) {
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 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);
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"];
const ext = file.name.split(".").pop().toLowerCase();
const validExt = ["jpg", "jpeg", "png"].includes(ext);
if (!validTypes.includes(file.type) && !validExt) {
setHint("仅支持 JPG / PNG 格式");
return;
}
const reader = new FileReader();
reader.onload = function (e) {
chartImage.onload = function () {
imageLoaded = true;
showEditorUI(true);
dropZone.classList.remove("drag-over");
zoom = 1;
clearAnnotations();
syncAngleUI(0);
requestAnimationFrame(function () {
calculateBaseSize();
applyLayout();
viewport.scrollLeft = 0;
viewport.scrollTop = 0;
setHint(
"滚轮缩放 · 右键/中键拖动画布 · 设置方向后单击添加 " +
MARKER_TYPES[currentMode].label +
" 标记"
);
updateButtons();
});
};
chartImage.onerror = function () {
setHint("图片加载失败,请换一张重试");
imageLoaded = false;
showEditorUI(false);
updateButtons();
};
chartImage.src = e.target.result;
};
reader.onerror = function () {
setHint("文件读取失败");
};
reader.readAsDataURL(file);
}
function addMarker(x, y) {
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;
redraw();
updateButtons();
setHint(
"已添加 " +
info.label +
"" +
pendingAngle +
"°,共 " +
markers.length +
" 个)"
);
}
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 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";
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) return;
const idx = findMarkerAt(x, y);
if (idx >= 0) {
canvas.classList.add("can-drag");
} else {
canvas.classList.remove("can-drag");
}
}
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 +
"° · 单击添加标记"
);
}
});
});
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;
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);
if (idx >= 0) {
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;
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");
}
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) {
didDragMove = false;
return;
}
const pt = getCanvasPoint(e);
if (findMarkerAt(pt.x, pt.y) >= 0) return;
addMarker(pt.x, pt.y);
});
btnUndo.addEventListener("click", function () {
if (markers.length === 0) return;
markers.pop();
if (selectedIndex >= markers.length) {
selectedIndex = markers.length - 1;
}
redraw();
updateButtons();
setHint(
markers.length
? "已撤销最后一个标记,剩余 " + markers.length + " 个"
: "已撤销全部标记"
);
});
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", endPan);
showEditorUI(false);
updateButtons();
syncAngleUI(0);
})();