This commit is contained in:
dekun
2026-05-27 14:48:19 +08:00
parent 518e517150
commit ebbb26b520
3 changed files with 191 additions and 77 deletions
+30 -1
View File
@@ -237,6 +237,31 @@ body {
box-shadow: inset 0 -2px 0 var(--stop); 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 { .workspace {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -325,7 +350,11 @@ body {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
cursor: crosshair; cursor: grab;
}
#overlay-canvas.can-pan {
cursor: grab;
} }
#overlay-canvas.can-drag { #overlay-canvas.can-drag {
+2 -2
View File
@@ -15,7 +15,7 @@
<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" 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>
@@ -72,7 +72,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>
+159 -74
View File
@@ -40,22 +40,25 @@
const angleSlider = document.getElementById("angle-slider"); const angleSlider = document.getElementById("angle-slider");
const angleInput = document.getElementById("angle-input"); const angleInput = document.getElementById("angle-input");
let currentMode = "entry"; let currentMode = null;
let pendingAngle = 0; let pendingAngle = 0;
let markers = []; let markers = [];
let baseWidth = 0; let baseWidth = 0;
let baseHeight = 0; let baseHeight = 0;
let zoom = 1; let zoom = 1;
let imageLoaded = false; let imageLoaded = false;
let uploadedFileName = "";
let dragIndex = -1; let dragIndex = -1;
let selectedIndex = -1; let selectedIndex = -1;
let isDragging = false; let isDraggingMarker = false;
let didDragMove = false; let didDragMove = false;
let isPanning = false; let isPanning = false;
let didPanMove = false;
let panStartX = 0; let panStartX = 0;
let panStartY = 0; let panStartY = 0;
let panScrollLeft = 0; let panScrollLeft = 0;
let panScrollTop = 0; let panScrollTop = 0;
const PAN_THRESHOLD = 4;
function setHint(text) { function setHint(text) {
statusHint.textContent = text; statusHint.textContent = text;
@@ -121,6 +124,60 @@
return -1; 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) { function drawArrowMarker(targetCtx, x, y, angleDeg, color, highlight) {
const w = ARROW_WIDTH; const w = ARROW_WIDTH;
const h = ARROW_HEAD; const h = ARROW_HEAD;
@@ -268,8 +325,14 @@
selectedIndex = index; selectedIndex = index;
if (index >= 0) { if (index >= 0) {
syncAngleUI(markers[index].angle); syncAngleUI(markers[index].angle);
const label = MARKER_TYPES[markers[index].type].label;
setHint( setHint(
"已选中标记,可调整方向或拖拽移动;点击空白处添加新标记" "已选中「" +
label +
"」· 可拖动移动或调整方向" +
(currentMode
? ""
: " · 点击对应类型按钮后可重新放置")
); );
} }
redraw(); redraw();
@@ -279,8 +342,10 @@
markers = []; markers = [];
dragIndex = -1; dragIndex = -1;
selectedIndex = -1; selectedIndex = -1;
isDragging = false; isDraggingMarker = false;
didDragMove = false; didDragMove = false;
didPanMove = false;
clearModeSelection();
redraw(); redraw();
updateButtons(); updateButtons();
} }
@@ -292,6 +357,16 @@
directionRow.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) { function loadImageFile(file) {
if (!file) return; if (!file) return;
const validTypes = ["image/jpeg", "image/png"]; const validTypes = ["image/jpeg", "image/png"];
@@ -302,6 +377,8 @@
return; return;
} }
uploadedFileName = file.name;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function (e) { reader.onload = function (e) {
chartImage.onload = function () { chartImage.onload = function () {
@@ -310,6 +387,7 @@
dropZone.classList.remove("drag-over"); dropZone.classList.remove("drag-over");
zoom = 1; zoom = 1;
clearAnnotations(); clearAnnotations();
clearModeSelection();
syncAngleUI(0); syncAngleUI(0);
requestAnimationFrame(function () { requestAnimationFrame(function () {
calculateBaseSize(); calculateBaseSize();
@@ -317,9 +395,7 @@
viewport.scrollLeft = 0; viewport.scrollLeft = 0;
viewport.scrollTop = 0; viewport.scrollTop = 0;
setHint( setHint(
"滚轮缩放 · 右键/中键拖动画布 · 设置方向后单击添加 " + "滚轮缩放 · 拖动空白处平移 · 先点「入场/出场/止损」再单击图上放置(各仅一个)"
MARKER_TYPES[currentMode].label +
" 标记"
); );
updateButtons(); updateButtons();
}); });
@@ -338,29 +414,35 @@
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
function addMarker(x, y) { function placeOrMoveMarker(x, y) {
if (!currentMode) return;
const type = currentMode; const type = currentMode;
const info = MARKER_TYPES[type]; const info = MARKER_TYPES[type];
const ratio = xyToRatio(x, y); const ratio = xyToRatio(x, y);
markers.push({ const existIdx = findMarkerIndexByType(type);
xRatio: ratio.xRatio,
yRatio: ratio.yRatio, if (existIdx >= 0) {
type: type, markers[existIdx].xRatio = ratio.xRatio;
color: info.color, markers[existIdx].yRatio = ratio.yRatio;
angle: pendingAngle, markers[existIdx].angle = pendingAngle;
}); selectedIndex = existIdx;
selectedIndex = markers.length - 1; 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(); redraw();
updateButtons(); updateButtons();
setHint( updateModeButtonStates();
"已添加 " +
info.label +
"" +
pendingAngle +
"°,共 " +
markers.length +
" 个)"
);
} }
function exportImage() { function exportImage() {
@@ -394,18 +476,7 @@
} }
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
const ts = new Date(); const name = getDownloadFileName();
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.href = url;
a.download = name; a.download = name;
document.body.appendChild(a); document.body.appendChild(a);
@@ -417,33 +488,28 @@
} }
function updateCursor(x, y) { 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); const idx = findMarkerAt(x, y);
canvas.classList.remove("can-pan");
if (idx >= 0) { if (idx >= 0) {
canvas.classList.add("can-drag"); canvas.classList.add("can-drag");
} else { } else {
canvas.classList.remove("can-drag"); canvas.classList.remove("can-drag");
canvas.classList.add("can-pan");
} }
} }
modeButtons.forEach(function (btn) { modeButtons.forEach(function (btn) {
btn.addEventListener("click", function (e) { btn.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
modeButtons.forEach(function (b) {
b.classList.remove("active");
});
btn.classList.add("active");
currentMode = btn.dataset.mode;
didDragMove = false; didDragMove = false;
if (imageLoaded) { didPanMove = false;
setHint( if (!imageLoaded) return;
"当前:" + setMode(btn.dataset.mode);
MARKER_TYPES[currentMode].label +
" · 方向 " +
pendingAngle +
"° · 单击添加标记"
);
}
}); });
}); });
@@ -519,6 +585,7 @@
function startPan(e) { function startPan(e) {
if (!imageLoaded) return; if (!imageLoaded) return;
isPanning = true; isPanning = true;
didPanMove = false;
panStartX = e.clientX; panStartX = e.clientX;
panStartY = e.clientY; panStartY = e.clientY;
panScrollLeft = viewport.scrollLeft; panScrollLeft = viewport.scrollLeft;
@@ -537,31 +604,41 @@
if (e.button !== 0) return; if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
didDragMove = false;
didPanMove = false;
const pt = getCanvasPoint(e); const pt = getCanvasPoint(e);
const idx = findMarkerAt(pt.x, pt.y); const idx = findMarkerAt(pt.x, pt.y);
if (idx >= 0) { if (idx >= 0) {
dragIndex = idx; dragIndex = idx;
isDragging = true; isDraggingMarker = true;
didDragMove = false;
selectMarker(idx); selectMarker(idx);
canvas.classList.add("can-drag"); canvas.classList.add("can-drag");
} else { return;
selectMarker(-1);
} }
selectMarker(-1);
startPan(e);
}); });
canvas.addEventListener("mousemove", function (e) { canvas.addEventListener("mousemove", function (e) {
if (!imageLoaded) return; if (!imageLoaded) return;
if (isPanning) { if (isPanning) {
viewport.scrollLeft = panScrollLeft - (e.clientX - panStartX); const dx = e.clientX - panStartX;
viewport.scrollTop = panScrollTop - (e.clientY - panStartY); 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; return;
} }
const pt = getCanvasPoint(e); const pt = getCanvasPoint(e);
if (isDragging && dragIndex >= 0) { if (isDraggingMarker && dragIndex >= 0) {
didDragMove = true; didDragMove = true;
const ratio = xyToRatio( const ratio = xyToRatio(
Math.max(0, Math.min(canvas.width, pt.x)), Math.max(0, Math.min(canvas.width, pt.x)),
@@ -581,19 +658,20 @@
viewport.classList.remove("is-panning"); viewport.classList.remove("is-panning");
} }
canvas.addEventListener("mouseup", function (e) { function endPointer() {
if (e.button === 1 || e.button === 2 || isPanning) { endPan();
endPan(); isDraggingMarker = false;
}
isDragging = false;
dragIndex = -1; dragIndex = -1;
canvas.classList.remove("can-drag", "can-pan");
}
canvas.addEventListener("mouseup", function () {
endPointer();
}); });
canvas.addEventListener("mouseleave", function () { canvas.addEventListener("mouseleave", function () {
endPan(); endPointer();
isDragging = false; canvas.classList.remove("can-pan");
dragIndex = -1;
canvas.classList.remove("can-drag");
}); });
canvas.addEventListener("contextmenu", function (e) { canvas.addEventListener("contextmenu", function (e) {
@@ -602,26 +680,32 @@
canvas.addEventListener("click", function (e) { canvas.addEventListener("click", function (e) {
if (!imageLoaded) return; if (!imageLoaded) return;
if (didDragMove) { if (didDragMove || didPanMove) {
didDragMove = false; didDragMove = false;
didPanMove = false;
return; return;
} }
if (!currentMode) return;
const pt = getCanvasPoint(e); const pt = getCanvasPoint(e);
if (findMarkerAt(pt.x, pt.y) >= 0) return; if (findMarkerAt(pt.x, pt.y) >= 0) return;
addMarker(pt.x, pt.y); placeOrMoveMarker(pt.x, pt.y);
}); });
btnUndo.addEventListener("click", function () { btnUndo.addEventListener("click", function () {
if (markers.length === 0) return; if (markers.length === 0) return;
markers.pop(); const removed = markers.pop();
if (selectedIndex >= markers.length) { if (currentMode === removed.type) {
selectedIndex = -1;
} else if (selectedIndex >= markers.length) {
selectedIndex = markers.length - 1; selectedIndex = markers.length - 1;
} }
redraw(); redraw();
updateButtons(); updateButtons();
updateModeButtonStates();
setHint( setHint(
markers.length markers.length
? "已撤销最后一个标记,剩余 " + markers.length + "" ? "已撤销「" + MARKER_TYPES[removed.type].label + ""
: "已撤销全部标记" : "已撤销全部标记"
); );
}); });
@@ -641,9 +725,10 @@
applyLayout(); applyLayout();
}); });
window.addEventListener("mouseup", endPan); window.addEventListener("mouseup", endPointer);
showEditorUI(false); showEditorUI(false);
updateButtons(); updateButtons();
updateModeButtonStates();
syncAngleUI(0); syncAngleUI(0);
})(); })();