修改
This commit is contained in:
+125
-13
@@ -57,12 +57,106 @@ body {
|
|||||||
gap: 8px;
|
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 {
|
.toolbar-hint {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted);
|
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 {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -146,25 +240,35 @@ body {
|
|||||||
.workspace {
|
.workspace {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
overflow: auto;
|
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
flex: 1;
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
border: 2px dashed var(--border);
|
border: 2px dashed var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: border-color 0.2s, background 0.2s;
|
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 {
|
.drop-zone.drag-over {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
background: rgba(74, 158, 255, 0.06);
|
background: rgba(74, 158, 255, 0.06);
|
||||||
@@ -187,24 +291,32 @@ body {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-wrap {
|
|
||||||
position: relative;
|
|
||||||
line-height: 0;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvas-wrap.hidden {
|
.viewport {
|
||||||
display: none;
|
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 {
|
#chart-image {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
user-select: 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>
|
<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 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="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 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>
|
</div>
|
||||||
<button type="button" class="btn" id="btn-undo" disabled>撤销</button>
|
<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" id="btn-clear" disabled>清空全部</button>
|
||||||
<button type="button" class="btn btn-accent" id="btn-download" disabled>下载标注图</button>
|
<button type="button" class="btn btn-accent" id="btn-download" disabled>下载标注图</button>
|
||||||
</div>
|
</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>
|
<p class="toolbar-hint" id="status-hint">请上传 K 线截图(JPG / PNG),支持点击或拖拽</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -33,18 +59,20 @@
|
|||||||
<p>将 K 线截图拖拽到此处,或点击顶部「上传图片」</p>
|
<p>将 K 线截图拖拽到此处,或点击顶部「上传图片」</p>
|
||||||
<p class="drop-sub">支持 JPG、PNG,数据仅在浏览器本地处理</p>
|
<p class="drop-sub">支持 JPG、PNG,数据仅在浏览器本地处理</p>
|
||||||
</div>
|
</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">
|
<img id="chart-image" alt="K线图" draggable="false">
|
||||||
<canvas id="overlay-canvas"></canvas>
|
<canvas id="overlay-canvas"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<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>
|
||||||
|
|||||||
+330
-65
@@ -2,9 +2,9 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const MARKER_TYPES = {
|
const MARKER_TYPES = {
|
||||||
entry: { label: "入场", color: "#00ff00", direction: "up" },
|
entry: { label: "入场", color: "#00ff00" },
|
||||||
exit: { label: "出场", color: "#0099ff", direction: "down" },
|
exit: { label: "出场", color: "#0099ff" },
|
||||||
stop: { label: "止损", color: "#ff3333", direction: "down" },
|
stop: { label: "止损", color: "#ff3333" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ARROW_HEAD = 12;
|
const ARROW_HEAD = 12;
|
||||||
@@ -12,11 +12,15 @@
|
|||||||
const ARROW_WIDTH = 9;
|
const ARROW_WIDTH = 9;
|
||||||
const LINE_WIDTH = 2;
|
const LINE_WIDTH = 2;
|
||||||
const HIT_RADIUS = 18;
|
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 fileInput = document.getElementById("file-input");
|
||||||
const dropZone = document.getElementById("drop-zone");
|
const dropZone = document.getElementById("drop-zone");
|
||||||
const dropPlaceholder = document.getElementById("drop-placeholder");
|
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 chartImage = document.getElementById("chart-image");
|
||||||
const canvas = document.getElementById("overlay-canvas");
|
const canvas = document.getElementById("overlay-canvas");
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
@@ -25,26 +29,70 @@
|
|||||||
const btnDownload = document.getElementById("btn-download");
|
const btnDownload = document.getElementById("btn-download");
|
||||||
const statusHint = document.getElementById("status-hint");
|
const statusHint = document.getElementById("status-hint");
|
||||||
const modeButtons = document.querySelectorAll(".mode-btn");
|
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 currentMode = "entry";
|
||||||
|
let pendingAngle = 0;
|
||||||
let markers = [];
|
let markers = [];
|
||||||
let displayWidth = 0;
|
let baseWidth = 0;
|
||||||
let displayHeight = 0;
|
let baseHeight = 0;
|
||||||
|
let zoom = 1;
|
||||||
let imageLoaded = false;
|
let imageLoaded = false;
|
||||||
let dragIndex = -1;
|
let dragIndex = -1;
|
||||||
|
let selectedIndex = -1;
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let didDragMove = false;
|
let didDragMove = false;
|
||||||
|
let isPanning = false;
|
||||||
|
let panStartX = 0;
|
||||||
|
let panStartY = 0;
|
||||||
|
let panScrollLeft = 0;
|
||||||
|
let panScrollTop = 0;
|
||||||
|
|
||||||
function setHint(text) {
|
function setHint(text) {
|
||||||
statusHint.textContent = text;
|
statusHint.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAngle(deg) {
|
||||||
|
let a = Math.round(deg) % 360;
|
||||||
|
if (a < 0) a += 360;
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
function updateButtons() {
|
function updateButtons() {
|
||||||
const hasImage = imageLoaded;
|
const hasImage = imageLoaded;
|
||||||
const hasMarkers = markers.length > 0;
|
const hasMarkers = markers.length > 0;
|
||||||
btnUndo.disabled = !hasImage || !hasMarkers;
|
btnUndo.disabled = !hasImage || !hasMarkers;
|
||||||
btnClear.disabled = !hasImage || !hasMarkers;
|
btnClear.disabled = !hasImage || !hasMarkers;
|
||||||
btnDownload.disabled = !hasImage;
|
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) {
|
function getCanvasPoint(event) {
|
||||||
@@ -57,10 +105,6 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMarkerDirection(type) {
|
|
||||||
return MARKER_TYPES[type].direction;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dist(x1, y1, x2, y2) {
|
function dist(x1, y1, x2, y2) {
|
||||||
const dx = x1 - x2;
|
const dx = x1 - x2;
|
||||||
const dy = y1 - y2;
|
const dy = y1 - y2;
|
||||||
@@ -69,77 +113,185 @@
|
|||||||
|
|
||||||
function findMarkerAt(x, y) {
|
function findMarkerAt(x, y) {
|
||||||
for (let i = markers.length - 1; i >= 0; i--) {
|
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 i;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawArrowMarker(targetCtx, x, y, type, color) {
|
function drawArrowMarker(targetCtx, x, y, angleDeg, color, highlight) {
|
||||||
const direction = getMarkerDirection(type);
|
|
||||||
const w = ARROW_WIDTH;
|
const w = ARROW_WIDTH;
|
||||||
const h = ARROW_HEAD;
|
const h = ARROW_HEAD;
|
||||||
const stem = ARROW_STEM;
|
const stem = ARROW_STEM;
|
||||||
|
const rad = (angleDeg * Math.PI) / 180;
|
||||||
|
|
||||||
|
targetCtx.save();
|
||||||
|
targetCtx.translate(x, y);
|
||||||
|
targetCtx.rotate(rad);
|
||||||
|
|
||||||
targetCtx.fillStyle = color;
|
targetCtx.fillStyle = color;
|
||||||
targetCtx.strokeStyle = color;
|
targetCtx.strokeStyle = color;
|
||||||
targetCtx.lineWidth = LINE_WIDTH;
|
targetCtx.lineWidth = highlight ? LINE_WIDTH + 1 : LINE_WIDTH;
|
||||||
targetCtx.lineCap = "round";
|
targetCtx.lineCap = "round";
|
||||||
targetCtx.lineJoin = "round";
|
targetCtx.lineJoin = "round";
|
||||||
|
|
||||||
if (direction === "up") {
|
|
||||||
targetCtx.beginPath();
|
targetCtx.beginPath();
|
||||||
targetCtx.moveTo(x, y);
|
targetCtx.moveTo(0, 0);
|
||||||
targetCtx.lineTo(x - w, y + h);
|
targetCtx.lineTo(-w, h);
|
||||||
targetCtx.lineTo(x + w, y + h);
|
targetCtx.lineTo(w, h);
|
||||||
targetCtx.closePath();
|
targetCtx.closePath();
|
||||||
targetCtx.fill();
|
targetCtx.fill();
|
||||||
|
|
||||||
targetCtx.beginPath();
|
targetCtx.beginPath();
|
||||||
targetCtx.moveTo(x, y + h);
|
targetCtx.moveTo(0, h);
|
||||||
targetCtx.lineTo(x, y + h + stem);
|
targetCtx.lineTo(0, h + stem);
|
||||||
targetCtx.stroke();
|
targetCtx.stroke();
|
||||||
} else {
|
|
||||||
|
if (highlight) {
|
||||||
targetCtx.beginPath();
|
targetCtx.beginPath();
|
||||||
targetCtx.moveTo(x, y);
|
targetCtx.arc(0, 0, HIT_RADIUS * 0.55, 0, Math.PI * 2);
|
||||||
targetCtx.lineTo(x - w, y - h);
|
targetCtx.strokeStyle = "rgba(255,255,255,0.45)";
|
||||||
targetCtx.lineTo(x + w, y - h);
|
targetCtx.lineWidth = 1.5;
|
||||||
targetCtx.closePath();
|
targetCtx.setLineDash([4, 3]);
|
||||||
targetCtx.fill();
|
|
||||||
targetCtx.beginPath();
|
|
||||||
targetCtx.moveTo(x, y - h);
|
|
||||||
targetCtx.lineTo(x, y - h - stem);
|
|
||||||
targetCtx.stroke();
|
targetCtx.stroke();
|
||||||
|
targetCtx.setLineDash([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetCtx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
function redraw() {
|
function redraw() {
|
||||||
if (!imageLoaded) return;
|
if (!imageLoaded) return;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
for (const m of markers) {
|
markers.forEach(function (m, i) {
|
||||||
drawArrowMarker(ctx, m.x, m.y, m.type, m.color);
|
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() {
|
function resetZoomFit() {
|
||||||
displayWidth = chartImage.offsetWidth;
|
zoom = 1;
|
||||||
displayHeight = chartImage.offsetHeight;
|
applyLayout();
|
||||||
canvas.width = Math.round(displayWidth);
|
viewport.scrollLeft = 0;
|
||||||
canvas.height = Math.round(displayHeight);
|
viewport.scrollTop = 0;
|
||||||
canvas.style.width = displayWidth + "px";
|
}
|
||||||
canvas.style.height = displayHeight + "px";
|
|
||||||
|
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();
|
redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAnnotations() {
|
function clearAnnotations() {
|
||||||
markers = [];
|
markers = [];
|
||||||
dragIndex = -1;
|
dragIndex = -1;
|
||||||
|
selectedIndex = -1;
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
didDragMove = false;
|
didDragMove = false;
|
||||||
redraw();
|
redraw();
|
||||||
updateButtons();
|
updateButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showEditorUI(show) {
|
||||||
|
dropPlaceholder.classList.toggle("hidden", show);
|
||||||
|
viewport.classList.toggle("hidden", !show);
|
||||||
|
zoomGroup.hidden = !show;
|
||||||
|
directionRow.hidden = !show;
|
||||||
|
}
|
||||||
|
|
||||||
function loadImageFile(file) {
|
function loadImageFile(file) {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
const validTypes = ["image/jpeg", "image/png"];
|
const validTypes = ["image/jpeg", "image/png"];
|
||||||
@@ -154,16 +306,20 @@
|
|||||||
reader.onload = function (e) {
|
reader.onload = function (e) {
|
||||||
chartImage.onload = function () {
|
chartImage.onload = function () {
|
||||||
imageLoaded = true;
|
imageLoaded = true;
|
||||||
dropPlaceholder.classList.add("hidden");
|
showEditorUI(true);
|
||||||
canvasWrap.classList.remove("hidden");
|
|
||||||
dropZone.classList.remove("drag-over");
|
dropZone.classList.remove("drag-over");
|
||||||
|
zoom = 1;
|
||||||
clearAnnotations();
|
clearAnnotations();
|
||||||
|
syncAngleUI(0);
|
||||||
requestAnimationFrame(function () {
|
requestAnimationFrame(function () {
|
||||||
syncCanvasSize();
|
calculateBaseSize();
|
||||||
|
applyLayout();
|
||||||
|
viewport.scrollLeft = 0;
|
||||||
|
viewport.scrollTop = 0;
|
||||||
setHint(
|
setHint(
|
||||||
"当前模式:" +
|
"滚轮缩放 · 右键/中键拖动画布 · 设置方向后单击添加 " +
|
||||||
MARKER_TYPES[currentMode].label +
|
MARKER_TYPES[currentMode].label +
|
||||||
" — 在图上单击添加箭头标记,可拖拽调整"
|
" 标记"
|
||||||
);
|
);
|
||||||
updateButtons();
|
updateButtons();
|
||||||
});
|
});
|
||||||
@@ -171,6 +327,7 @@
|
|||||||
chartImage.onerror = function () {
|
chartImage.onerror = function () {
|
||||||
setHint("图片加载失败,请换一张重试");
|
setHint("图片加载失败,请换一张重试");
|
||||||
imageLoaded = false;
|
imageLoaded = false;
|
||||||
|
showEditorUI(false);
|
||||||
updateButtons();
|
updateButtons();
|
||||||
};
|
};
|
||||||
chartImage.src = e.target.result;
|
chartImage.src = e.target.result;
|
||||||
@@ -184,15 +341,25 @@
|
|||||||
function addMarker(x, y) {
|
function addMarker(x, y) {
|
||||||
const type = currentMode;
|
const type = currentMode;
|
||||||
const info = MARKER_TYPES[type];
|
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();
|
redraw();
|
||||||
updateButtons();
|
updateButtons();
|
||||||
setHint(
|
setHint(
|
||||||
"已添加 " +
|
"已添加 " +
|
||||||
info.label +
|
info.label +
|
||||||
" 标记(共 " +
|
"(" +
|
||||||
|
pendingAngle +
|
||||||
|
"°,共 " +
|
||||||
markers.length +
|
markers.length +
|
||||||
" 个)— 可切换模式继续标注"
|
" 个)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,8 +368,6 @@
|
|||||||
|
|
||||||
const natW = chartImage.naturalWidth;
|
const natW = chartImage.naturalWidth;
|
||||||
const natH = chartImage.naturalHeight;
|
const natH = chartImage.naturalHeight;
|
||||||
const scaleX = natW / displayWidth;
|
|
||||||
const scaleY = natH / displayHeight;
|
|
||||||
|
|
||||||
const exportCanvas = document.createElement("canvas");
|
const exportCanvas = document.createElement("canvas");
|
||||||
exportCanvas.width = natW;
|
exportCanvas.width = natW;
|
||||||
@@ -214,10 +379,11 @@
|
|||||||
for (const m of markers) {
|
for (const m of markers) {
|
||||||
drawArrowMarker(
|
drawArrowMarker(
|
||||||
exCtx,
|
exCtx,
|
||||||
m.x * scaleX,
|
m.xRatio * natW,
|
||||||
m.y * scaleY,
|
m.yRatio * natH,
|
||||||
m.type,
|
m.angle,
|
||||||
m.color
|
m.color,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +417,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateCursor(x, y) {
|
function updateCursor(x, y) {
|
||||||
|
if (isPanning) return;
|
||||||
const idx = findMarkerAt(x, y);
|
const idx = findMarkerAt(x, y);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) {
|
||||||
canvas.classList.add("can-drag");
|
canvas.classList.add("can-drag");
|
||||||
@@ -270,17 +437,65 @@
|
|||||||
didDragMove = false;
|
didDragMove = false;
|
||||||
if (imageLoaded) {
|
if (imageLoaded) {
|
||||||
setHint(
|
setHint(
|
||||||
"当前模式:" +
|
"当前:" +
|
||||||
MARKER_TYPES[currentMode].label +
|
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 () {
|
fileInput.addEventListener("change", function () {
|
||||||
const file = fileInput.files[0];
|
loadImageFile(fileInput.files[0]);
|
||||||
loadImageFile(file);
|
|
||||||
fileInput.value = "";
|
fileInput.value = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -298,12 +513,29 @@
|
|||||||
dropZone.addEventListener("drop", function (e) {
|
dropZone.addEventListener("drop", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropZone.classList.remove("drag-over");
|
dropZone.classList.remove("drag-over");
|
||||||
const file = e.dataTransfer.files[0];
|
loadImageFile(e.dataTransfer.files[0]);
|
||||||
loadImageFile(file);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) {
|
canvas.addEventListener("mousedown", function (e) {
|
||||||
if (!imageLoaded) return;
|
if (!imageLoaded) return;
|
||||||
|
|
||||||
|
if (e.button === 1 || e.button === 2) {
|
||||||
|
startPan(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const pt = getCanvasPoint(e);
|
const pt = getCanvasPoint(e);
|
||||||
const idx = findMarkerAt(pt.x, pt.y);
|
const idx = findMarkerAt(pt.x, pt.y);
|
||||||
@@ -311,18 +543,32 @@
|
|||||||
dragIndex = idx;
|
dragIndex = idx;
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
didDragMove = false;
|
didDragMove = false;
|
||||||
|
selectMarker(idx);
|
||||||
canvas.classList.add("can-drag");
|
canvas.classList.add("can-drag");
|
||||||
|
} else {
|
||||||
|
selectMarker(-1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener("mousemove", function (e) {
|
canvas.addEventListener("mousemove", function (e) {
|
||||||
if (!imageLoaded) return;
|
if (!imageLoaded) return;
|
||||||
|
|
||||||
|
if (isPanning) {
|
||||||
|
viewport.scrollLeft = panScrollLeft - (e.clientX - panStartX);
|
||||||
|
viewport.scrollTop = panScrollTop - (e.clientY - panStartY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pt = getCanvasPoint(e);
|
const pt = getCanvasPoint(e);
|
||||||
|
|
||||||
if (isDragging && dragIndex >= 0) {
|
if (isDragging && dragIndex >= 0) {
|
||||||
didDragMove = true;
|
didDragMove = true;
|
||||||
markers[dragIndex].x = Math.max(0, Math.min(canvas.width, pt.x));
|
const ratio = xyToRatio(
|
||||||
markers[dragIndex].y = Math.max(0, Math.min(canvas.height, pt.y));
|
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();
|
redraw();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -330,17 +576,30 @@
|
|||||||
updateCursor(pt.x, pt.y);
|
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;
|
isDragging = false;
|
||||||
dragIndex = -1;
|
dragIndex = -1;
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener("mouseleave", function () {
|
canvas.addEventListener("mouseleave", function () {
|
||||||
|
endPan();
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
dragIndex = -1;
|
dragIndex = -1;
|
||||||
canvas.classList.remove("can-drag");
|
canvas.classList.remove("can-drag");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener("contextmenu", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
canvas.addEventListener("click", function (e) {
|
canvas.addEventListener("click", function (e) {
|
||||||
if (!imageLoaded) return;
|
if (!imageLoaded) return;
|
||||||
if (didDragMove) {
|
if (didDragMove) {
|
||||||
@@ -355,6 +614,9 @@
|
|||||||
btnUndo.addEventListener("click", function () {
|
btnUndo.addEventListener("click", function () {
|
||||||
if (markers.length === 0) return;
|
if (markers.length === 0) return;
|
||||||
markers.pop();
|
markers.pop();
|
||||||
|
if (selectedIndex >= markers.length) {
|
||||||
|
selectedIndex = markers.length - 1;
|
||||||
|
}
|
||||||
redraw();
|
redraw();
|
||||||
updateButtons();
|
updateButtons();
|
||||||
setHint(
|
setHint(
|
||||||
@@ -374,11 +636,14 @@
|
|||||||
btnDownload.addEventListener("click", exportImage);
|
btnDownload.addEventListener("click", exportImage);
|
||||||
|
|
||||||
window.addEventListener("resize", function () {
|
window.addEventListener("resize", function () {
|
||||||
if (imageLoaded) {
|
if (!imageLoaded) return;
|
||||||
syncCanvasSize();
|
calculateBaseSize();
|
||||||
}
|
applyLayout();
|
||||||
});
|
});
|
||||||
|
|
||||||
dropPlaceholder.classList.remove("hidden");
|
window.addEventListener("mouseup", endPan);
|
||||||
|
|
||||||
|
showEditorUI(false);
|
||||||
updateButtons();
|
updateButtons();
|
||||||
|
syncAngleUI(0);
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user