diff --git a/public/css/style.css b/public/css/style.css index 93fc75a..747fd78 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -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; } diff --git a/public/index.html b/public/index.html index 6b4287c..6b94c49 100644 --- a/public/index.html +++ b/public/index.html @@ -15,14 +15,40 @@
将 K 线截图拖拽到此处,或点击顶部「上传图片」
支持 JPG、PNG,数据仅在浏览器本地处理
- @@ -44,7 +72,7 @@ 入场 出场 止损 - + diff --git a/public/js/app.js b/public/js/app.js index 04fdd22..ffef101 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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(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.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.stroke(); - } else { - 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); })();