From 62bd58d2c42afde5bf3421b52328f94f6f19ed5b Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 27 May 2026 14:20:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20K=E7=BA=BF=E7=82=B9=E4=BD=8D=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E5=B7=A5=E5=85=B7=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E4=B8=8EUbuntu=20PM2=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯前端 Canvas 画线、拖拽、导出;Python venv + PM2 静态服务; 含部署脚本与使用/部署文档。 Co-authored-by: Cursor --- .gitignore | 8 + README.md | 69 ++++++++ deploy/ecosystem.config.cjs | 21 +++ deploy/install.sh | 70 ++++++++ docs/DEPLOY.md | 180 ++++++++++++++++++++ docs/USAGE.md | 105 ++++++++++++ public/css/style.css | 275 ++++++++++++++++++++++++++++++ public/index.html | 52 ++++++ public/js/app.js | 327 ++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 10 files changed, 1109 insertions(+) create mode 100644 .gitignore create mode 100644 deploy/ecosystem.config.cjs create mode 100644 deploy/install.sh create mode 100644 docs/DEPLOY.md create mode 100644 docs/USAGE.md create mode 100644 public/css/style.css create mode 100644 public/index.html create mode 100644 public/js/app.js create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71bea8f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +.DS_Store +Thumbs.db +node_modules/ +.pm2/ +*.log diff --git a/README.md b/README.md index e69de29..cecbe69 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,69 @@ +# K线点位标注工具(Web 版) + +纯前端 K 线截图标注工具:在浏览器本地完成图片上传、水平价位线绘制、拖拽调整与导出,**数据不上传外网**。 + +## 功能概览 + +| 功能 | 说明 | +|------|------| +| 图片上传 | 点击选择 / 拖拽,支持 JPG、PNG | +| 三种标注线 | 入场(绿)、出场(蓝)、止损(红) | +| 画线 | 选中模式后单击生成贯穿画布的水平线 | +| 拖拽 | 鼠标悬停线条变为上下调整光标,可拖动改价 | +| 撤销 / 清空 | 删除最后一条或全部线条 | +| 导出 | 下载「原图 + 标注」合并 PNG | + +## 技术栈 + +- HTML + CSS + JavaScript + Canvas +- 部署:Ubuntu + Python 虚拟环境 + PM2 + `http.server` + +## 目录结构 + +``` +chart-label-tool/ +├── public/ # 静态站点(对外服务目录) +│ ├── index.html +│ ├── css/style.css +│ └── js/app.js +├── deploy/ +│ ├── install.sh # 一键部署脚本 +│ └── ecosystem.config.cjs # PM2 配置 +├── docs/ +│ ├── DEPLOY.md # 部署文档 +│ └── USAGE.md # 使用说明 +└── requirements.txt +``` + +## 快速开始(本地开发) + +```bash +cd public +python3 -m http.server 8080 +``` + +浏览器访问: + +## 服务器部署(Ubuntu / 局域网) + +详见 [docs/DEPLOY.md](docs/DEPLOY.md)。 + +```bash +sudo git clone https://git.bz121.com/dekun/chart-label-tool.git /opt/chart-label-tool +cd /opt/chart-label-tool +sudo bash deploy/install.sh +``` + +局域网访问:`http://<服务器IP>:8080` + +## 使用说明 + +详见 [docs/USAGE.md](docs/USAGE.md)。 + +## 仓库地址 + + + +## 许可证 + +仅供内部 / 个人使用,按需自行调整。 diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs new file mode 100644 index 0000000..b62a004 --- /dev/null +++ b/deploy/ecosystem.config.cjs @@ -0,0 +1,21 @@ +/** + * PM2 进程配置 + * 使用 Python 虚拟环境中的 http.server 提供静态文件服务 + */ +module.exports = { + apps: [ + { + name: "chart-label-tool", + cwd: "/opt/chart-label-tool", + script: "/opt/chart-label-tool/venv/bin/python", + args: "-m http.server 8080 --bind 0.0.0.0 --directory /opt/chart-label-tool/public", + interpreter: "none", + autorestart: true, + watch: false, + max_memory_restart: "128M", + env: { + PYTHONUNBUFFERED: "1", + }, + }, + ], +}; diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..8067021 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,70 @@ +#!/bin/bash +# K线点位标注工具 - Ubuntu 一键部署脚本 +# 用法: sudo bash deploy/install.sh +# 部署目录: /opt/chart-label-tool + +set -euo pipefail + +APP_DIR="/opt/chart-label-tool" +REPO_URL="https://git.bz121.com/dekun/chart-label-tool.git" +PORT=8080 +SERVICE_USER="root" + +echo "==> K线点位标注工具 部署开始" + +if [[ "$(id -u)" -ne 0 ]]; then + echo "请使用 root 运行: sudo bash deploy/install.sh" + exit 1 +fi + +# 依赖 +apt-get update -qq +apt-get install -y -qq git python3 python3-venv python3-pip curl + +# Node.js + PM2(若未安装) +if ! command -v pm2 &>/dev/null; then + if ! command -v node &>/dev/null; then + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - + apt-get install -y -qq nodejs + fi + npm install -g pm2 +fi + +# 拉取代码 +if [[ -d "$APP_DIR/.git" ]]; then + echo "==> 更新已有仓库 $APP_DIR" + cd "$APP_DIR" + git pull --ff-only origin main || git pull --ff-only +else + echo "==> 克隆仓库到 $APP_DIR" + git clone "$REPO_URL" "$APP_DIR" + cd "$APP_DIR" +fi + +# Python 虚拟环境 +if [[ ! -d "$APP_DIR/venv" ]]; then + echo "==> 创建 Python 虚拟环境" + python3 -m venv "$APP_DIR/venv" +fi + +# PM2 启动/重载 +echo "==> 配置 PM2 守护进程" +pm2 delete chart-label-tool 2>/dev/null || true +pm2 start "$APP_DIR/deploy/ecosystem.config.cjs" +pm2 save +pm2 startup systemd -u "$SERVICE_USER" --hp "/root" 2>/dev/null || pm2 startup + +# 防火墙提示(可选) +if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then + ufw allow "$PORT/tcp" 2>/dev/null || true + echo "==> 已尝试放行 UFW 端口 $PORT" +fi + +echo "" +echo "==========================================" +echo " 部署完成" +echo " 访问地址: http://<服务器局域网IP>:$PORT" +echo " 应用目录: $APP_DIR" +echo " 查看状态: pm2 status" +echo " 查看日志: pm2 logs chart-label-tool" +echo "==========================================" diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..3eb7bb8 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,180 @@ +# 部署文档 + +本文说明如何在 **Ubuntu** 服务器上将 K 线点位标注工具部署到 **`/opt/chart-label-tool`**,使用 **Python 虚拟环境** 托管静态文件,并由 **PM2** 守护进程,在 **局域网** 内访问。 + +## 环境要求 + +| 项目 | 要求 | +|------|------| +| 操作系统 | Ubuntu 20.04 / 22.04 / 24.04(推荐) | +| 权限 | root 或 sudo | +| 网络 | 可访问 git 仓库;客户端与服务器在同一局域网 | +| 浏览器 | Chrome / Edge 等现代浏览器 | + +## 架构说明 + +``` +浏览器 (局域网) + ↓ HTTP :8080 +PM2 → Python venv → http.server + ↓ 静态文件 +/opt/chart-label-tool/public/ +``` + +- 无后端业务逻辑,仅静态资源服务 +- 标注数据全部在用户浏览器内存中处理,不上传服务器 + +## 方式一:一键部署(推荐) + +在**已能访问 Git 仓库**的 Ubuntu 机器上执行: + +```bash +# 若目录不存在,先克隆 +sudo git clone https://git.bz121.com/dekun/chart-label-tool.git /opt/chart-label-tool + +cd /opt/chart-label-tool +sudo bash deploy/install.sh +``` + +脚本将自动完成: + +1. 安装 `git`、`python3`、`python3-venv` +2. 安装 Node.js 与 PM2(若未安装) +3. 克隆/更新代码到 `/opt/chart-label-tool` +4. 创建 Python 虚拟环境 `venv/` +5. 使用 PM2 启动 `chart-label-tool` 进程(监听 `0.0.0.0:8080`) +6. 执行 `pm2 save` 与 `pm2 startup`(开机自启) + +部署成功后,在局域网内任意电脑浏览器访问: + +```text +http://<服务器局域网IP>:8080 +``` + +查看本机 IP: + +```bash +hostname -I +# 或 +ip addr show | grep "inet " +``` + +## 方式二:手动部署 + +### 1. 安装依赖 + +```bash +sudo apt-get update +sudo apt-get install -y git python3 python3-venv curl + +# 安装 Node.js 20 与 PM2 +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs +sudo npm install -g pm2 +``` + +### 2. 克隆项目 + +```bash +sudo mkdir -p /opt +sudo git clone https://git.bz121.com/dekun/chart-label-tool.git /opt/chart-label-tool +cd /opt/chart-label-tool +``` + +### 3. 创建虚拟环境 + +```bash +sudo python3 -m venv /opt/chart-label-tool/venv +``` + +> 本项目不依赖第三方 pip 包,`http.server` 来自 Python 标准库。 + +### 4. 启动 PM2 + +```bash +cd /opt/chart-label-tool +sudo pm2 start deploy/ecosystem.config.cjs +sudo pm2 save +sudo pm2 startup +``` + +按 `pm2 startup` 提示执行生成的 `sudo env PATH=...` 命令。 + +### 5. 验证 + +```bash +pm2 status +curl -I http://127.0.0.1:8080 +``` + +浏览器打开 `http://<服务器IP>:8080`,应能看到标注工具页面。 + +## 防火墙 + +若启用了 UFW,需放行 8080 端口: + +```bash +sudo ufw allow 8080/tcp +sudo ufw reload +``` + +## 常用运维命令 + +| 操作 | 命令 | +|------|------| +| 查看状态 | `pm2 status` | +| 查看日志 | `pm2 logs chart-label-tool` | +| 重启服务 | `pm2 restart chart-label-tool` | +| 停止服务 | `pm2 stop chart-label-tool` | +| 更新代码 | `cd /opt/chart-label-tool && sudo git pull && sudo pm2 restart chart-label-tool` | + +## 修改端口 + +编辑 `deploy/ecosystem.config.cjs` 中 `args` 里的端口号,例如改为 `9000`: + +```javascript +args: "-m http.server 9000 --bind 0.0.0.0 --directory /opt/chart-label-tool/public", +``` + +然后执行: + +```bash +pm2 restart chart-label-tool +``` + +## 故障排查 + +### 页面无法打开 + +1. `pm2 status` 确认进程为 `online` +2. `ss -tlnp | grep 8080` 确认端口在监听 +3. 检查服务器与客户端是否同一局域网 +4. 检查防火墙是否放行 8080 + +### PM2 启动失败 + +```bash +pm2 logs chart-label-tool --lines 50 +``` + +确认虚拟环境存在: + +```bash +ls -la /opt/chart-label-tool/venv/bin/python +``` + +手动测试: + +```bash +/opt/chart-label-tool/venv/bin/python -m http.server 8080 --bind 0.0.0.0 --directory /opt/chart-label-tool/public +``` + +### Git 拉取失败 + +检查仓库地址与凭据,或先在能访问的机器上打包上传到服务器。 + +## 安全说明 + +- 本工具为**内网静态站点**,请勿直接暴露到公网 +- 用户标注数据不经过服务器存储;服务器仅提供 HTML/JS/CSS 文件 +- 若需 HTTPS,可在前方增加 Nginx 反向代理并配置证书 diff --git a/docs/USAGE.md b/docs/USAGE.md new file mode 100644 index 0000000..61a457f --- /dev/null +++ b/docs/USAGE.md @@ -0,0 +1,105 @@ +# 使用说明 + +## 访问地址 + +- **局域网部署**:`http://<服务器IP>:8080` +- **本机调试**:`http://localhost:8080` + +推荐使用 **Chrome** 或 **Edge** 浏览器。 + +## 界面布局 + +``` +┌─────────────────────────────────────────────┐ +│ 顶部工具栏:上传 | 模式 | 撤销 | 清空 | 下载 │ +├─────────────────────────────────────────────┤ +│ │ +│ K 线截图 + 标注画布区域 │ +│ │ +├─────────────────────────────────────────────┤ +│ 底部图例:入场 / 出场 / 止损 颜色说明 │ +└─────────────────────────────────────────────┘ +``` + +## 操作步骤 + +### 1. 上传 K 线截图 + +两种方式任选: + +- 点击顶部 **「上传图片」**,选择本地 JPG / PNG 文件 +- 将图片 **拖拽** 到中间虚线框区域 + +上传后图片会按比例适应显示区域,**不会拉伸变形**。 + +> **注意**:上传新图片会自动 **清空上一轮所有标注线条**。 + +### 2. 选择标注模式 + +顶部三个模式按钮: + +| 按钮 | 线条颜色 | 用途示例 | +|------|----------|----------| +| 入场线 | 绿色 `#00ff00` | 标记买入/开仓价位 | +| 出场线 | 蓝色 `#0099ff` | 标记卖出/平仓价位 | +| 止损线 | 红色 `#ff3333` | 标记止损价位 | + +当前选中的模式会高亮显示。 + +### 3. 绘制水平线 + +1. 先选中一种标注模式(如「入场线」) +2. 在 K 线图对应价位处 **单击鼠标左键** +3. 系统会在该高度绘制一条 **贯穿整张图的水平线**(线宽 3px) + +可重复切换模式并单击,添加多条不同颜色的线。 + +### 4. 拖拽调整线条 + +- 将鼠标移到已有线条附近,光标变为 **上下箭头**(`ns-resize`),表示可拖拽 +- **按住左键** 上下拖动,即可调整该线价位 +- 松开鼠标完成调整 + +### 5. 撤销与清空 + +| 按钮 | 作用 | +|------|------| +| 撤销 | 删除 **最后绘制** 的一条线 | +| 清空全部 | 删除当前图片上的 **所有** 标注线(需确认) | + +无线条时这两个按钮为灰色不可用。 + +### 6. 下载标注图 + +点击 **「下载标注图」**,浏览器会将 **原始分辨率图片 + 所有标注线** 合并为一张 PNG 并保存到本地。 + +文件名示例:`kline-label-20260527-143052.png` + +> 导出使用原图像素尺寸,保证清晰度;线条位置会按显示比例自动换算到原图坐标。 + +## 隐私说明 + +- 所有图片与标注均在 **本机浏览器** 内处理 +- **不会上传到互联网或服务器**(服务器仅提供页面静态文件) +- 关闭或刷新页面后,未导出的标注将丢失,请及时下载保存 + +## 常见问题 + +**Q:为什么单击没有画线?** +A:请先上传图片,并确认已选中一种标注模式(入场/出场/止损)。 + +**Q:画线位置不准?** +A:请确保在图片区域内单击;导出时会按原图比例校正坐标。 + +**Q:拖拽时误加了新线?** +A:从线条上按下并拖动即可;仅在空白处单击才会新增线条。 + +**Q:支持哪些图片格式?** +A:JPG、JPEG、PNG。 + +**Q:换了一张图,之前的线还在吗?** +A:上传新图会自动清空所有旧标注。 + +## 快捷键 + +当前版本未定义键盘快捷键,全部通过鼠标与顶部按钮操作。 diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..5b66eb9 --- /dev/null +++ b/public/css/style.css @@ -0,0 +1,275 @@ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #1a1a1a; + --bg-panel: #252525; + --bg-hover: #333; + --border: #3a3a3a; + --text: #f0f0f0; + --text-muted: #999; + --accent: #4a9eff; + --entry: #00ff00; + --exit: #0099ff; + --stop: #ff3333; + --line-width: 3px; + --hit-tolerance: 8px; +} + +html, +body { + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Microsoft YaHei", sans-serif; + background: var(--bg); + color: var(--text); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.toolbar { + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + padding: 12px 20px; + flex-shrink: 0; +} + +.toolbar-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 10px; + letter-spacing: 0.02em; +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.toolbar-hint { + margin-top: 8px; + font-size: 0.8rem; + color: var(--text-muted); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 7px 14px; + font-size: 0.875rem; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-hover); + color: var(--text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + user-select: none; + white-space: nowrap; +} + +.btn:hover:not(:disabled) { + background: #404040; + border-color: #555; +} + +.btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.btn-primary { + background: #2d4a3e; + border-color: #3d6b55; +} + +.btn-primary:hover:not(:disabled) { + background: #3a5c4a; +} + +.btn-accent { + background: #1e3a5f; + border-color: var(--accent); +} + +.btn-accent:hover:not(:disabled) { + background: #264d7a; +} + +.mode-group { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.mode-btn { + border: none; + border-radius: 0; + border-right: 1px solid var(--border); + background: transparent; +} + +.mode-btn:last-child { + border-right: none; +} + +.mode-btn.active[data-mode="entry"] { + background: rgba(0, 255, 0, 0.15); + color: var(--entry); + box-shadow: inset 0 -2px 0 var(--entry); +} + +.mode-btn.active[data-mode="exit"] { + background: rgba(0, 153, 255, 0.15); + color: var(--exit); + box-shadow: inset 0 -2px 0 var(--exit); +} + +.mode-btn.active[data-mode="stop"] { + background: rgba(255, 51, 51, 0.15); + color: var(--stop); + box-shadow: inset 0 -2px 0 var(--stop); +} + +.workspace { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + overflow: auto; + min-height: 0; +} + +.drop-zone { + width: 100%; + max-width: 1400px; + min-height: 400px; + border: 2px dashed var(--border); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + transition: border-color 0.2s, background 0.2s; +} + +.drop-zone.drag-over { + border-color: var(--accent); + background: rgba(74, 158, 255, 0.06); +} + +.drop-placeholder { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); +} + +.drop-icon { + font-size: 3rem; + display: block; + margin-bottom: 12px; +} + +.drop-sub { + margin-top: 8px; + font-size: 0.8rem; +} + +.canvas-wrap { + position: relative; + line-height: 0; + max-width: 100%; +} + +.hidden { + display: none !important; +} + +.canvas-wrap.hidden { + display: none; +} + +#chart-image { + display: block; + max-width: 100%; + height: auto; + pointer-events: none; + user-select: none; +} + +#overlay-canvas { + position: absolute; + top: 0; + left: 0; + cursor: crosshair; +} + +#overlay-canvas.can-drag { + cursor: ns-resize; +} + +.footer { + flex-shrink: 0; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 16px; + padding: 10px 20px; + font-size: 0.8rem; + color: var(--text-muted); + border-top: 1px solid var(--border); + background: var(--bg-panel); +} + +.legend { + display: inline-block; + width: 24px; + height: 3px; + vertical-align: middle; + margin-left: 4px; + border-radius: 1px; +} + +.legend.entry { + background: var(--entry); +} + +.legend.exit { + background: var(--exit); +} + +.legend.stop { + background: var(--stop); +} + +.footer-note { + margin-left: auto; +} + +@media (max-width: 640px) { + .toolbar-actions { + gap: 6px; + } + + .btn { + padding: 6px 10px; + font-size: 0.8rem; + } + + .footer-note { + width: 100%; + margin-left: 0; + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6f029e0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,52 @@ + + + + + + K线点位标注工具 + + + +
+

K线点位标注工具

+
+ +
+ + + +
+ + + +
+

请上传 K 线截图(JPG / PNG),支持点击或拖拽

+
+ +
+
+
+ 📈 +

将 K 线截图拖拽到此处,或点击顶部「上传图片」

+

支持 JPG、PNG,数据仅在浏览器本地处理

+
+ +
+
+ +
+ 入场 + 出场 + 止损 + 单击添加水平线 · 拖拽调整位置 +
+ + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..042fc34 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,327 @@ +(function () { + "use strict"; + + const LINE_TYPES = { + entry: { label: "入场线", color: "#00ff00" }, + exit: { label: "出场线", color: "#0099ff" }, + stop: { label: "止损线", color: "#ff3333" }, + }; + + const LINE_WIDTH = 3; + const HIT_TOLERANCE = 8; + + 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 chartImage = document.getElementById("chart-image"); + const canvas = document.getElementById("overlay-canvas"); + const ctx = canvas.getContext("2d"); + const btnUndo = document.getElementById("btn-undo"); + const btnClear = document.getElementById("btn-clear"); + const btnDownload = document.getElementById("btn-download"); + const statusHint = document.getElementById("status-hint"); + const modeButtons = document.querySelectorAll(".mode-btn"); + + let currentMode = "entry"; + let lines = []; + let displayWidth = 0; + let displayHeight = 0; + let imageLoaded = false; + let dragIndex = -1; + let isDragging = false; + let didDragMove = false; + + function setHint(text) { + statusHint.textContent = text; + } + + function updateButtons() { + const hasImage = imageLoaded; + const hasLines = lines.length > 0; + btnUndo.disabled = !hasImage || !hasLines; + btnClear.disabled = !hasImage || !hasLines; + btnDownload.disabled = !hasImage; + } + + function getCanvasPoint(event) { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY, + }; + } + + function findLineAtY(y) { + for (let i = lines.length - 1; i >= 0; i--) { + if (Math.abs(lines[i].y - y) <= HIT_TOLERANCE) { + return i; + } + } + return -1; + } + + function redraw() { + if (!imageLoaded) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (const line of lines) { + ctx.beginPath(); + ctx.strokeStyle = line.color; + ctx.lineWidth = LINE_WIDTH; + ctx.moveTo(0, line.y); + ctx.lineTo(canvas.width, line.y); + ctx.stroke(); + } + } + + 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"; + redraw(); + } + + function clearAnnotations() { + lines = []; + dragIndex = -1; + isDragging = false; + redraw(); + updateButtons(); + } + + function loadImageFile(file) { + if (!file) return; + const validTypes = ["image/jpeg", "image/png"]; + const ext = file.name.split(".").pop().toLowerCase(); + const validExt = ["jpg", "jpeg", "png"].includes(ext); + if (!validTypes.includes(file.type) && !validExt) { + setHint("仅支持 JPG / PNG 格式"); + return; + } + + const reader = new FileReader(); + reader.onload = function (e) { + chartImage.onload = function () { + imageLoaded = true; + dropPlaceholder.classList.add("hidden"); + canvasWrap.classList.remove("hidden"); + dropZone.classList.remove("drag-over"); + clearAnnotations(); + requestAnimationFrame(function () { + syncCanvasSize(); + setHint( + "当前模式:" + + LINE_TYPES[currentMode].label + + " — 在图上单击添加水平线,可拖拽调整" + ); + updateButtons(); + }); + }; + chartImage.onerror = function () { + setHint("图片加载失败,请换一张重试"); + imageLoaded = false; + updateButtons(); + }; + chartImage.src = e.target.result; + }; + reader.onerror = function () { + setHint("文件读取失败"); + }; + reader.readAsDataURL(file); + } + + function addLine(y) { + const type = currentMode; + const info = LINE_TYPES[type]; + lines.push({ y: y, type: type, color: info.color }); + redraw(); + updateButtons(); + setHint( + "已添加 " + + info.label + + "(共 " + + lines.length + + " 条)— 当前模式:" + + info.label + ); + } + + function exportImage() { + if (!imageLoaded) return; + + const natW = chartImage.naturalWidth; + const natH = chartImage.naturalHeight; + const scaleY = natH / displayHeight; + + const exportCanvas = document.createElement("canvas"); + exportCanvas.width = natW; + exportCanvas.height = natH; + const exCtx = exportCanvas.getContext("2d"); + + exCtx.drawImage(chartImage, 0, 0, natW, natH); + + for (const line of lines) { + const y = line.y * scaleY; + exCtx.beginPath(); + exCtx.strokeStyle = line.color; + exCtx.lineWidth = LINE_WIDTH; + exCtx.moveTo(0, y); + exCtx.lineTo(natW, y); + exCtx.stroke(); + } + + exportCanvas.toBlob(function (blob) { + if (!blob) { + setHint("导出失败,请重试"); + return; + } + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const ts = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + const name = + "kline-label-" + + ts.getFullYear() + + pad(ts.getMonth() + 1) + + pad(ts.getDate()) + + "-" + + pad(ts.getHours()) + + pad(ts.getMinutes()) + + pad(ts.getSeconds()) + + ".png"; + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + setHint("标注图已下载:" + name); + }, "image/png"); + } + + function updateCursor(y) { + const idx = findLineAtY(y); + if (idx >= 0) { + canvas.classList.add("can-drag"); + } else { + canvas.classList.remove("can-drag"); + } + } + + modeButtons.forEach(function (btn) { + btn.addEventListener("click", function () { + modeButtons.forEach(function (b) { + b.classList.remove("active"); + }); + btn.classList.add("active"); + currentMode = btn.dataset.mode; + if (imageLoaded) { + setHint("当前模式:" + LINE_TYPES[currentMode].label + " — 单击添加水平线"); + } + }); + }); + + fileInput.addEventListener("change", function () { + const file = fileInput.files[0]; + loadImageFile(file); + fileInput.value = ""; + }); + + dropZone.addEventListener("dragover", function (e) { + e.preventDefault(); + dropZone.classList.add("drag-over"); + }); + + dropZone.addEventListener("dragleave", function (e) { + if (!dropZone.contains(e.relatedTarget)) { + dropZone.classList.remove("drag-over"); + } + }); + + dropZone.addEventListener("drop", function (e) { + e.preventDefault(); + dropZone.classList.remove("drag-over"); + const file = e.dataTransfer.files[0]; + loadImageFile(file); + }); + + canvas.addEventListener("mousedown", function (e) { + if (!imageLoaded) return; + e.preventDefault(); + const pt = getCanvasPoint(e); + const idx = findLineAtY(pt.y); + if (idx >= 0) { + dragIndex = idx; + isDragging = true; + didDragMove = false; + canvas.classList.add("can-drag"); + } + }); + + canvas.addEventListener("mousemove", function (e) { + if (!imageLoaded) return; + const pt = getCanvasPoint(e); + + if (isDragging && dragIndex >= 0) { + didDragMove = true; + lines[dragIndex].y = Math.max(0, Math.min(canvas.height, pt.y)); + redraw(); + return; + } + + updateCursor(pt.y); + }); + + canvas.addEventListener("mouseup", function () { + isDragging = false; + dragIndex = -1; + }); + + canvas.addEventListener("mouseleave", function () { + isDragging = false; + dragIndex = -1; + canvas.classList.remove("can-drag"); + }); + + canvas.addEventListener("click", function (e) { + if (!imageLoaded || didDragMove) return; + const pt = getCanvasPoint(e); + if (findLineAtY(pt.y) >= 0) return; + addLine(pt.y); + }); + + btnUndo.addEventListener("click", function () { + if (lines.length === 0) return; + lines.pop(); + redraw(); + updateButtons(); + setHint( + lines.length + ? "已撤销最后一条,剩余 " + lines.length + " 条" + : "已撤销全部线条" + ); + }); + + btnClear.addEventListener("click", function () { + if (lines.length === 0) return; + if (!confirm("确定清空所有标注线条?")) return; + clearAnnotations(); + setHint("已清空全部标注"); + }); + + btnDownload.addEventListener("click", exportImage); + + window.addEventListener("resize", function () { + if (imageLoaded) { + syncCanvasSize(); + } + }); + + dropPlaceholder.classList.remove("hidden"); + updateButtons(); +})(); diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c48cf67 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# 本项目静态资源由 Python 标准库 http.server 托管,无需额外 pip 依赖。 +# 虚拟环境仅用于与系统 Python 隔离,便于 PM2 指定解释器路径。