修改
This commit is contained in:
+125
-13
@@ -57,12 +57,106 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
min-width: 4.5em;
|
||||
}
|
||||
|
||||
.toolbar-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.zoom-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.zoom-label {
|
||||
min-width: 3.2em;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
min-width: 32px;
|
||||
padding: 7px 10px;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.direction-pad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 36px);
|
||||
grid-template-rows: repeat(3, 32px);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dir-btn {
|
||||
min-width: 0;
|
||||
padding: 4px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.dir-btn.active {
|
||||
background: rgba(74, 158, 255, 0.25);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dir-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.angle-slider-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.angle-slider-wrap input[type="range"] {
|
||||
width: 120px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.angle-input {
|
||||
width: 52px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.angle-unit {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -146,25 +240,35 @@ body {
|
||||
.workspace {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
flex: 1;
|
||||
min-height: 400px;
|
||||
border: 2px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.drop-zone:has(.viewport:not(.hidden)) {
|
||||
border-style: solid;
|
||||
border-color: var(--border);
|
||||
background: #111;
|
||||
padding: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.drop-zone.drag-over {
|
||||
border-color: var(--accent);
|
||||
background: rgba(74, 158, 255, 0.06);
|
||||
@@ -187,24 +291,32 @@ body {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.canvas-wrap.hidden {
|
||||
display: none;
|
||||
.viewport {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
max-height: calc(100vh - 240px);
|
||||
overflow: auto;
|
||||
background: #111;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.viewport.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
line-height: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#chart-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
+33
-5
@@ -15,14 +15,40 @@
|
||||
<input type="file" id="file-input" accept="image/jpeg,image/png,.jpg,.jpeg,.png" hidden>
|
||||
</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="exit">出场 ↓</button>
|
||||
<button type="button" class="btn mode-btn" data-mode="stop">止损 ↓</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="stop">止损</button>
|
||||
</div>
|
||||
<div class="zoom-group" id="zoom-group" hidden>
|
||||
<button type="button" class="btn btn-icon" id="zoom-out" title="缩小">−</button>
|
||||
<span class="zoom-label" id="zoom-label">100%</span>
|
||||
<button type="button" class="btn btn-icon" id="zoom-in" title="放大">+</button>
|
||||
<button type="button" class="btn" id="zoom-fit" title="适应窗口">适应</button>
|
||||
</div>
|
||||
<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 btn-accent" id="btn-download" disabled>下载标注图</button>
|
||||
</div>
|
||||
<div class="toolbar-row direction-row" id="direction-row" hidden>
|
||||
<span class="row-label">箭头方向</span>
|
||||
<div class="direction-pad" role="group" aria-label="箭头方向">
|
||||
<button type="button" class="btn dir-btn" data-angle="315" title="左上">↖</button>
|
||||
<button type="button" class="btn dir-btn active" data-angle="0" title="向上">↑</button>
|
||||
<button type="button" class="btn dir-btn" data-angle="45" title="右上">↗</button>
|
||||
<button type="button" class="btn dir-btn" data-angle="270" title="向左">←</button>
|
||||
<span class="dir-center" id="dir-center" title="当前角度">0°</span>
|
||||
<button type="button" class="btn dir-btn" data-angle="90" title="向右">→</button>
|
||||
<button type="button" class="btn dir-btn" data-angle="225" title="左下">↙</button>
|
||||
<button type="button" class="btn dir-btn" data-angle="180" title="向下">↓</button>
|
||||
<button type="button" class="btn dir-btn" data-angle="135" title="右下">↘</button>
|
||||
</div>
|
||||
<label class="angle-slider-wrap">
|
||||
<span>角度</span>
|
||||
<input type="range" id="angle-slider" min="0" max="359" value="0" step="1">
|
||||
<input type="number" id="angle-input" min="0" max="359" value="0" class="angle-input">
|
||||
<span class="angle-unit">°</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="toolbar-hint" id="status-hint">请上传 K 线截图(JPG / PNG),支持点击或拖拽</p>
|
||||
</header>
|
||||
|
||||
@@ -33,18 +59,20 @@
|
||||
<p>将 K 线截图拖拽到此处,或点击顶部「上传图片」</p>
|
||||
<p class="drop-sub">支持 JPG、PNG,数据仅在浏览器本地处理</p>
|
||||
</div>
|
||||
<div class="canvas-wrap hidden" id="canvas-wrap">
|
||||
<div class="viewport hidden" id="viewport">
|
||||
<div class="stage" id="stage">
|
||||
<img id="chart-image" alt="K线图" draggable="false">
|
||||
<canvas id="overlay-canvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<span>入场 <i class="legend entry"></i></span>
|
||||
<span>出场 <i class="legend exit"></i></span>
|
||||
<span>止损 <i class="legend stop"></i></span>
|
||||
<span class="footer-note">单击添加箭头 · 拖拽调整位置 · 可连续标注多个点位</span>
|
||||
<span class="footer-note">滚轮缩放 · 右键拖动画布 · 方向面板/滑块设置箭头朝向</span>
|
||||
</footer>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
|
||||
+330
-65
@@ -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(x, y);
|
||||
targetCtx.lineTo(x - w, y + h);
|
||||
targetCtx.lineTo(x + w, y + h);
|
||||
targetCtx.moveTo(0, 0);
|
||||
targetCtx.lineTo(-w, h);
|
||||
targetCtx.lineTo(w, h);
|
||||
targetCtx.closePath();
|
||||
targetCtx.fill();
|
||||
|
||||
targetCtx.beginPath();
|
||||
targetCtx.moveTo(x, y + h);
|
||||
targetCtx.lineTo(x, y + h + stem);
|
||||
targetCtx.moveTo(0, h);
|
||||
targetCtx.lineTo(0, h + stem);
|
||||
targetCtx.stroke();
|
||||
} else {
|
||||
|
||||
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.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);
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user