From ba927c2b2f53f7c3c1768aba0857ca4182b44681 Mon Sep 17 00:00:00 2001 From: titor Date: Thu, 16 Apr 2026 04:33:02 +0800 Subject: [PATCH] feat: initial release v0.3.0 - Support 9 chart types: line, bar, pie, scatter, bubble, donut, mixed, polar, radar - Multi-format output: ANSI, SVG, PNG, Markdown - Go + Fiber + gonum/plot - Docker support - Morandi color palette --- .env.example | 2 + .gitignore | 29 ++ Dockerfile | 24 ++ README.md | 276 +++++++++++++++ agents.md | 74 ++++ changelog.md | 65 ++++ docker-compose.yml | 17 + go.mod | 33 ++ go.sum | 123 +++++++ internal/config/config.go | 34 ++ internal/handler/chart.go | 96 ++++++ internal/renderer/ansi.go | 245 ++++++++++++++ internal/renderer/png.go | 565 +++++++++++++++++++++++++++++++ internal/renderer/renderer.go | 14 + internal/renderer/svg.go | 620 ++++++++++++++++++++++++++++++++++ internal/renderer/text.go | 198 +++++++++++ internal/renderer/utils.go | 9 + internal/service/chart.go | 85 +++++ internal/types/chart.go | 61 ++++ skill.md | 205 +++++++++++ taolun.md | 143 ++++++++ 21 files changed, 2918 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 agents.md create mode 100644 changelog.md create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/handler/chart.go create mode 100644 internal/renderer/ansi.go create mode 100644 internal/renderer/png.go create mode 100644 internal/renderer/renderer.go create mode 100644 internal/renderer/svg.go create mode 100644 internal/renderer/text.go create mode 100644 internal/renderer/utils.go create mode 100644 internal/service/chart.go create mode 100644 internal/types/chart.go create mode 100644 skill.md create mode 100644 taolun.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6ff267b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +SERVER_PORT=3100 +BASE_URL=http://localhost:3100 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fcf9c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +server + +# Test binary +*.test + +# Output +*.out + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env + +# Build +dist/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..85a95db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache gcc musl-dev + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server + +FROM alpine:3.19 + +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +COPY --from=builder /app/server . + +EXPOSE 3100 + +CMD ["./server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8eecd58 --- /dev/null +++ b/README.md @@ -0,0 +1,276 @@ +# Picoclaw Chart + +一个轻量级的图表生成 API 服务,支持多种图表类型,返回多种格式(ANSI、SVG、PNG、Markdown),适配命令行和 Web 场景。 + +## 功能特性 + +- **多种图表类型**:折线图、柱状图、饼图、散点图、气泡图、圆环图、混合图、极区图、雷达图 +- **多格式输出**:ANSI(终端)、SVG(矢量图)、PNG(位图)、Markdown(Web) +- **轻量高效**:Go 语言编写,单二进制部署 +- **Docker 支持**:开箱即用的容器化部署 + +## 支持的图表类型 + +| 类型 | 英文名 | 说明 | +|------|--------|------| +| `line` | 折线图 | 展示数据趋势变化 | +| `bar` | 柱状图 | 对比分类数据 | +| `pie` | 饼图 | 展示占比关系 | +| `scatter` | 散点图 | 展示数据分布 | +| `bubble` | 气泡图 | 展示多维度数据 | +| `donut` | 圆环图 | 饼图的变体 | +| `mixed` | 混合图 | 柱状图+折线图组合 | +| `polar` | 极区图 | 展示方向/角度数据 | +| `radar` | 雷达图 | 展示多维度对比 | + +## 快速开始 + +### Docker 部署(推荐) + +```bash +# 克隆项目 +git clone +cd picoclaw-chart + +# 启动服务 +docker-compose up -d + +# 查看日志 +docker-compose logs -f +``` + +### 本地运行 + +```bash +# 下载依赖 +go mod download + +# 运行服务 +go run ./cmd/server + +# 或者编译后运行 +go build -o server ./cmd/server +./server +``` + +### 环境变量 + +复制 `.env.example` 为 `.env` 并修改: + +```bash +SERVER_PORT=3100 +BASE_URL=http://localhost:3100 +``` + +## API 文档 + +### 基本信息 + +| 属性 | 值 | +|------|-----| +| 端点 | `http://localhost:3100/api/v1/chart` | +| 方法 | `POST` | +| 内容类型 | `application/json` | + +### 请求参数 + +```json +{ + "type": "bar", + "title": "图表标题", + "data": { + "labels": ["一月", "二月", "三月"], + "datasets": [ + { + "name": "销售额", + "values": [100, 150, 120], + "color": "#f9c2c8" + } + ] + }, + "options": { + "width": 600, + "height": 400 + } +} +``` + +### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `type` | string | 否 | 图表类型,默认 `line` | +| `title` | string | 否 | 图表标题 | +| `data.labels` | array | 是 | 数据标签 | +| `data.datasets` | array | 是 | 数据系列 | +| `datasets[].name` | string | 否 | 系列名称 | +| `datasets[].values` | array | **是** | 数值数组 | +| `datasets[].color` | string | 否 | 十六进制颜色 | +| `options.width` | integer | 否 | 图表宽度 | +| `options.height` | integer | 否 | 图表高度 | + +### 响应字段 + +| 字段 | 说明 | +|------|------| +| `chart_id` | 图表唯一标识 | +| `text` | 纯文本描述 | +| `ansi` | ANSI 彩色文本(终端) | +| `svg` | SVG 矢量图代码 | +| `png_url` | PNG 下载 URL | +| `markdown` | Markdown 格式 | + +### API 示例 + +```bash +# 创建柱状图 +curl -X POST http://localhost:3100/api/v1/chart \ + -H "Content-Type: application/json" \ + -d '{ + "type": "bar", + "title": "月度销售额", + "data": { + "labels": ["一月", "二月", "三月", "四月"], + "datasets": [ + { "name": "销售额", "values": [100, 150, 120, 180] } + ] + } + }' + +# 下载 PNG +curl -O http://localhost:3100/api/v1/chart/{chart_id}.png + +# 健康检查 +curl http://localhost:3100/api/v1/health +``` + +### API 端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/v1/chart` | 创建图表 | +| GET | `/api/v1/chart/:id` | 获取 SVG | +| GET | `/api/v1/chart/:id.png` | 下载 PNG | +| GET | `/api/v1/health` | 健康检查 | + +## 使用示例 + +### 折线图 + +```json +{ + "type": "line", + "title": "温度趋势", + "data": { + "labels": ["周一", "周二", "周三", "周四", "周五"], + "datasets": [ + { "name": "最高温", "values": [22, 25, 28, 27, 30], "color": "#f9c2c8" }, + { "name": "最低温", "values": [15, 17, 18, 16, 19], "color": "#8fa3c4" } + ] + } +} +``` + +### 饼图 + +```json +{ + "type": "pie", + "title": "市场份额", + "data": { + "labels": ["苹果", "三星", "华为", "其他"], + "datasets": [ + { "name": "销量", "values": [35, 25, 20, 20] } + ] + } +} +``` + +### 雷达图 + +```json +{ + "type": "radar", + "title": "员工能力评估", + "data": { + "labels": ["速度", "力量", "耐力", "智力", "敏捷"], + "datasets": [ + { "name": "张三", "values": [80, 70, 90, 85, 75] } + ] + } +} +``` + +### 混合图 + +```json +{ + "type": "mixed", + "title": "销售与目标对比", + "data": { + "labels": ["一月", "二月", "三月", "四月"], + "datasets": [ + { "name": "实际", "values": [100, 150, 120, 180] }, + { "name": "目标", "values": [120, 140, 130, 160] } + ] + } +} +``` + +## 默认配色 + +柔和莫兰迪色系: + +| 索引 | 颜色 | HEX | +|------|------|-----| +| 0 | 粉橙 | `#f9c2c8` | +| 1 | 杏色 | `#f3d2b1` | +| 2 | 淡绿 | `#e1e9c5` | +| 3 | 浅蓝 | `#b2d5e8` | +| 4 | 灰蓝 | `#8fa3c4` | +| 5 | 青绿 | `#75B4A0` | +| 6 | 深青 | `#3b7f8d` | +| 7 | 棕灰 | `#a18b7c` | + +## 项目结构 + +``` +picoclaw-chart/ +├── cmd/server/ # 程序入口 +├── internal/ +│ ├── config/ # 配置管理 +│ ├── handler/ # HTTP 处理层 +│ ├── renderer/ # 图表渲染器 +│ │ ├── svg.go # SVG 渲染 +│ │ ├── png.go # PNG 渲染 +│ │ ├── ansi.go # ANSI 渲染 +│ │ ├── text.go # 文本渲染 +│ │ └── utils.go # 工具函数 +│ ├── service/ # 业务逻辑层 +│ └── types/ # 数据结构 +├── .env.example # 环境变量示例 +├── Dockerfile +├── docker-compose.yml +├── skill.md # Skill 文档 +└── README.md +``` + +## 与 picoclaw 集成 + +将此服务与 picoclaw Agent 集成,只需在 picoclaw 的 skills 配置中添加 `skill.md` 的内容即可。 + +Agent 可以通过 curl 调用此 API 生成图表,根据使用场景选择返回的格式: +- 终端显示:使用 `ansi` 字段 +- Web 展示:使用 `markdown` 字段 +- 内嵌 SVG:使用 `svg` 字段 + +## 技术栈 + +- **语言**:Go 1.21+ +- **框架**:Fiber v2 +- **图表库**:gonum/plot +- **容器**:Docker, Docker Compose + +## License + +MIT License diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..3061bb2 --- /dev/null +++ b/agents.md @@ -0,0 +1,74 @@ +# agents.md + +## AI 行为规范 + +### 1. 代码规范 + +- 使用 OOP 风格 + 设计模式 +- 遵循 Go 编码规范(fmt, vet, lint) +- 所有公共函数/类型必须有注释 +- 错误处理:返回有意义错误,使用 errors.Wrap + +### 2. 文件结构 + +``` +picoclaw-chart/ +├── cmd/server/ # 入口点 +├── internal/ +│ ├── handler/ # HTTP 处理层 +│ ├── service/ # 业务逻辑层 +│ ├── renderer/ # 渲染器(策略模式) +│ │ ├── renderer.go # 接口定义 +│ │ ├── svg.go # SVG 渲染 +│ │ ├── png.go # PNG 渲染 +│ │ ├── ansi.go # ANSI 彩色渲染 +│ │ └── text.go # 纯文本渲染 +│ └── types/ # 数据结构 +├── .env.example # 环境变量示例 +├── Dockerfile +├── docker-compose.yml +└── go.mod +``` + +### 3. API 设计 + +- 路由前缀:`/api/v1` +- POST `/chart` - 创建图表 +- GET `/chart/:id` - 获取图表 +- GET `/chart/:id.png` - 下载 PNG +- GET `/health` - 健康检查 + +### 4. 响应格式 + +```json +{ + "chart_id": "string", + "text": "string", + "ansi": "string", + "svg": "string", + "png_url": "string", + "markdown": "string" +} +``` + +### 5. 错误处理 + +- HTTP 4xx: 客户端错误(参数错误等) +- HTTP 5xx: 服务端错误 +- 错误响应格式:`{"error": "message"}` + +### 6. 配置管理 + +- 使用 `.env` 文件 +- 支持环境变量覆盖 +- 配置结构:`internal/config/config.go` + +### 7. 测试策略 + +- 单元测试覆盖核心渲染器 +- 集成测试覆盖 API 端点 + +### 8. 调试策略 + +- 一个问题循环 3 次未解决,强制自动退出 +- 交由用户操作 diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..67882d7 --- /dev/null +++ b/changelog.md @@ -0,0 +1,65 @@ +# changelog.md + +## 待实现 (Todo) + +- [ ] 单元测试 +- [ ] 集成测试 +- [ ] 配置文件热加载 +- [ ] 图表缓存机制 + +## 已实现 (Done) + +- [x] 项目结构搭建 +- [x] 核心类型定义 +- [x] SVG 渲染器 +- [x] PNG 渲染器 +- [x] ANSI 渲染器 +- [x] 纯文本渲染器 +- [x] API Handler +- [x] Docker 部署配置 +- [x] 饼图 (pie) 支持 +- [x] 散点图 (scatter) 支持 +- [x] 圆环图 (donut) 支持 +- [x] 气泡图 (bubble) 支持 +- [x] 混合图 (mixed) 支持 +- [x] 极区图 (polar) 支持 +- [x] 雷达图 (radar) 支持 + +## 版本记录 + +### v0.3.0 (2026-04-16) + +- 新增图表类型:圆环图、气泡图、混合图、极区图、雷达图 +- 支持 9 种图表类型 + +### v0.2.0 (2026-04-16) + +- 新增图表类型:饼图、散点图 +- 优化 ANSI 渲染器 + +### v0.1.0 (2026-04-16) + +- 初始版本 +- 支持图表类型:line, bar +- 支持输出格式:text, ansi, svg, png_url, markdown + +## 认知纠正 (Knowledge) + +### Go 图表库选型 + +1. **gonum/plot** - 纯 Go,无外部依赖,适合生成静态图片(PNG/SVG) +2. **ntcharts** - 专为 Bubble Tea TUI 设计,终端内渲染,依赖较多 +3. **pterm** - 终端美化库,有 BarChart 组件,但不支持 PNG/SVG + +**结论**:对于需要同时支持 Web(PNG/SVG)和 CLI(ANSI)的场景,gonum/plot + 自写 ANSI 渲染器是最佳组合。 + +### 设计模式应用 + +1. **策略模式**:不同的渲染器实现统一的 Renderer 接口 +2. **工厂模式**:RendererFactory 根据格式类型创建对应渲染器 + +### Docker 注意事项 + +- 使用 `golang:alpine` 减小镜像体积 +- 多阶段构建:编译 -> 运行 +- 端口映射:3100:3100 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3bcd62e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + chart-api: + build: . + container_name: picoclaw-chart + ports: + - "${SERVER_PORT:-3100}:3100" + environment: + - SERVER_PORT=${SERVER_PORT:-3100} + - BASE_URL=${BASE_URL:-http://localhost:3100} + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3100/api/v1/health"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e78034f --- /dev/null +++ b/go.mod @@ -0,0 +1,33 @@ +module github.com/picoclaw/chart + +go 1.21 + +require ( + github.com/gofiber/fiber/v2 v2.52.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + gonum.org/v1/plot v0.12.0 +) + +require ( + git.sr.ht/~sbinet/gg v0.3.1 // indirect + github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b // indirect + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/go-fonts/liberation v0.3.0 // indirect + github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 // indirect + github.com/go-pdf/fpdf v0.6.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/klauspost/compress v1.17.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/exp v0.0.0-20230321023759-10a507213a29 // indirect + golang.org/x/image v0.6.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + gonum.org/v1/gonum v0.14.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..20e15d2 --- /dev/null +++ b/go.sum @@ -0,0 +1,123 @@ +git.sr.ht/~sbinet/gg v0.3.1 h1:LNhjNn8DerC8f9DHLz6lS0YYul/b602DUxDgGkd/Aik= +git.sr.ht/~sbinet/gg v0.3.1/go.mod h1:KGYtlADtqsqANL9ueOFkWymvzUvLMQllU5Ixo+8v3pc= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= +github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= +github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-fonts/dejavu v0.1.0 h1:JSajPXURYqpr+Cu8U9bt8K+XcACIHWqWrvWCKyeFmVQ= +github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= +github.com/go-fonts/latin-modern v0.3.0 h1:CIDlMm0djMO3XIKHVz2na9lFKt3kdC/YCy7k7lLpyjE= +github.com/go-fonts/latin-modern v0.3.0/go.mod h1:ysEQXnuT/sCDOAONxC7ImeEDVINbltClhasMAqEtRK0= +github.com/go-fonts/liberation v0.3.0 h1:3BI2iaE7R/s6uUUtzNCjo3QijJu3aS4wmrMgfSpYQ+8= +github.com/go-fonts/liberation v0.3.0/go.mod h1:jdJ+cqF+F4SUL2V+qxBth8fvBpBDS7yloUL5Fi8GTGY= +github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9 h1:NxXI5pTAtpEaU49bpLpQoDsu1zrteW/vxzTz8Cd2UAs= +github.com/go-latex/latex v0.0.0-20230307184459-12ec69307ad9/go.mod h1:gWuR/CrFDDeVRFQwHPvsv9soJVB/iqymhuZQuJ3a9OM= +github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= +github.com/go-pdf/fpdf v0.6.0/go.mod h1:HzcnA+A23uwogo0tp9yU+l3V+KXhiESpt1PMayhOh5M= +github.com/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= +github.com/phpdave11/gofpdi v1.0.12/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29 h1:ooxPy7fPvB4kwsA2h+iBNHkAbp/4JxTSwCmvdjEYmug= +golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20210607152325-775e3b0c77b9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.6.0 h1:bR8b5okrPI3g/gyZakLZHeWxAR8Dn5CyxXv1hLH5g/4= +golang.org/x/image v0.6.0/go.mod h1:MXLdDR43H7cDJq5GEGXEVeeNhPgi+YYEQ2pC1byI1x0= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.14.0 h1:2NiG67LD1tEH0D7kM+ps2V+fXmsAnpUeec7n8tcr4S0= +gonum.org/v1/gonum v0.14.0/go.mod h1:AoWeoz0becf9QMWtE8iWXNXc27fK4fNeHNf/oMejGfU= +gonum.org/v1/plot v0.12.0 h1:y1ZNmfz/xHuHvtgFe8USZVyykQo5ERXPnspQNVK15Og= +gonum.org/v1/plot v0.12.0/go.mod h1:PgiMf9+3A3PnZdJIciIXmyN1FwdAA6rXELSN761oQkw= +honnef.co/go/tools v0.1.3/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..9e70ec7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,34 @@ +package config + +import ( + "os" + "strconv" +) + +type Config struct { + ServerPort string + BaseURL string +} + +func Load() *Config { + return &Config{ + ServerPort: getEnv("SERVER_PORT", "3100"), + BaseURL: getEnv("BASE_URL", "http://localhost:3100"), + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvAsInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} diff --git a/internal/handler/chart.go b/internal/handler/chart.go new file mode 100644 index 0000000..12671c9 --- /dev/null +++ b/internal/handler/chart.go @@ -0,0 +1,96 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + "github.com/picoclaw/chart/internal/service" + "github.com/picoclaw/chart/internal/types" +) + +type ChartHandler struct { + chartService *service.ChartService +} + +func NewChartHandler(chartService *service.ChartService) *ChartHandler { + return &ChartHandler{ + chartService: chartService, + } +} + +func (h *ChartHandler) CreateChart(c *fiber.Ctx) error { + var req types.ChartRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(types.ErrorResponse{ + Error: "invalid request body: " + err.Error(), + }) + } + + if req.Type == "" { + req.Type = types.ChartTypeLine + } + + if len(req.Data.Datasets) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(types.ErrorResponse{ + Error: "datasets are required", + }) + } + + for _, dataset := range req.Data.Datasets { + if len(dataset.Values) == 0 { + return c.Status(fiber.StatusBadRequest).JSON(types.ErrorResponse{ + Error: "dataset values cannot be empty", + }) + } + } + + resp, err := h.chartService.CreateChart(&req) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(types.ErrorResponse{ + Error: err.Error(), + }) + } + + return c.Status(fiber.StatusCreated).JSON(resp) +} + +func (h *ChartHandler) GetChart(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(types.ErrorResponse{ + Error: "chart id is required", + }) + } + + chart, err := h.chartService.GetChart(id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(types.ErrorResponse{ + Error: err.Error(), + }) + } + + return c.Send(chart.SVG) +} + +func (h *ChartHandler) GetChartPNG(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return c.Status(fiber.StatusBadRequest).JSON(types.ErrorResponse{ + Error: "chart id is required", + }) + } + + pngData, err := h.chartService.GetChartPNG(id) + if err != nil { + return c.Status(fiber.StatusNotFound).JSON(types.ErrorResponse{ + Error: err.Error(), + }) + } + + c.Set("Content-Type", "image/png") + return c.Send(pngData) +} + +func (h *ChartHandler) Health(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + }) +} diff --git a/internal/renderer/ansi.go b/internal/renderer/ansi.go new file mode 100644 index 0000000..4f2e83e --- /dev/null +++ b/internal/renderer/ansi.go @@ -0,0 +1,245 @@ +package renderer + +import ( + "fmt" + "strings" + + "github.com/picoclaw/chart/internal/types" +) + +type ANSIRenderer struct { + width int + height int +} + +func NewANSIRenderer() *ANSIRenderer { + return &ANSIRenderer{ + width: 60, + height: 15, + } +} + +func (r *ANSIRenderer) Render(chart *types.Chart) ([]byte, error) { + var sb strings.Builder + + ansiGreen := "\033[32m" + ansiBlue := "\033[34m" + ansiYellow := "\033[33m" + ansiRed := "\033[31m" + ansiReset := "\033[0m" + ansiBold := "\033[1m" + ansiCyan := "\033[36m" + ansiMagenta := "\033[35m" + + if chart.Title != "" { + sb.WriteString(fmt.Sprintf("%s%s📊 %s%s\n", ansiBold, ansiGreen, chart.Title, ansiReset)) + } + + if len(chart.Data.Datasets) == 0 { + return []byte(sb.String()), nil + } + + switch chart.Type { + case types.ChartTypePie, types.ChartTypeDonut: + r.renderPieChart(&sb, &chart.Data, ansiBold, ansiReset) + case types.ChartTypeBubble: + r.renderBubbleChart(&sb, &chart.Data, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiRed, ansiReset) + case types.ChartTypeMixed: + r.renderMixedChart(&sb, &chart.Data, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiReset) + case types.ChartTypePolar, types.ChartTypeRadar: + r.renderRadarLikeChart(&sb, &chart.Data, ansiBold, ansiCyan, ansiMagenta, ansiYellow, ansiReset) + default: + r.renderBarLikeChart(&sb, &chart.Data, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiRed, ansiReset) + } + + return []byte(sb.String()), nil +} + +func (r *ANSIRenderer) renderPieChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiReset string) { + dataset := data.Datasets[0] + values := dataset.Values + labels := data.Labels + + total := 0.0 + for _, v := range values { + total += v + } + + colors := []string{"\033[31m", "\033[32m", "\033[34m", "\033[33m", "\033[35m", "\033[36m"} + + for i, v := range values { + label := fmt.Sprintf("Item %d", i+1) + if i < len(labels) { + label = labels[i] + } + + percentage := v / total * 100 + color := colors[i%len(colors)] + + barLen := int(percentage / 4) + if barLen > 15 { + barLen = 15 + } + bar := strings.Repeat("█", barLen) + + sb.WriteString(fmt.Sprintf("%s%-12s %s| %s%s [%.1f%%]%s\n", + color, label, ansiReset, + color, bar, percentage, ansiReset, + )) + } +} + +func (r *ANSIRenderer) renderBubbleChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiRed, ansiReset string) { + dataset := data.Datasets[0] + labels := data.Labels + if len(labels) == 0 { + labels = make([]string, len(dataset.Values)) + for i := range labels { + labels[i] = fmt.Sprintf("X%d", i+1) + } + } + + maxValue := dataset.Values[0] + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + + colors := []string{ansiGreen, ansiBlue, ansiYellow, ansiRed} + + for i, value := range dataset.Values { + label := labels[i] + bubbleSize := 3 + int((value/maxValue)*5) + + color := colors[i%len(colors)] + bubble := strings.Repeat("●", bubbleSize) + + sb.WriteString(fmt.Sprintf("%s%-8s%s %s%s %s%.2f%s\n", + ansiBold, label, ansiReset, + color, bubble, ansiReset, + ansiYellow, value, ansiReset, + )) + } +} + +func (r *ANSIRenderer) renderMixedChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiReset string) { + dataset := data.Datasets[0] + labels := data.Labels + if len(labels) == 0 { + labels = make([]string, len(dataset.Values)) + for i := range labels { + labels[i] = fmt.Sprintf("X%d", i+1) + } + } + + maxValue := dataset.Values[0] + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + + colors := []string{ansiGreen, ansiBlue} + + for i, value := range dataset.Values { + label := labels[i] + + barLen := int((value / maxValue) * float64(r.width-20)) + if barLen < 1 { + barLen = 1 + } + + isLine := i%2 == 1 + var bar string + if isLine { + bar = strings.Repeat("●", barLen/2) + } else { + bar = strings.Repeat("█", barLen) + } + + color := colors[i%len(colors)] + + sb.WriteString(fmt.Sprintf("%s%-8s%s %s%s %s%.2f [%s]%s\n", + ansiBold, label, ansiReset, + color, bar, ansiReset, + ansiYellow, value, ansiReset, + "mixed", + )) + } +} + +func (r *ANSIRenderer) renderRadarLikeChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiCyan, ansiMagenta, ansiYellow, ansiReset string) { + dataset := data.Datasets[0] + values := dataset.Values + labels := data.Labels + + maxValue := values[0] + for _, v := range values { + if v > maxValue { + maxValue = v + } + } + + sb.WriteString(fmt.Sprintf("%s%s雷达图/极区图 (共 %d 维度)%s\n", + ansiBold, ansiCyan, len(values), ansiReset)) + + for i, v := range values { + label := fmt.Sprintf("维度 %d", i+1) + if i < len(labels) { + label = labels[i] + } + + barLen := int((v / maxValue) * float64(r.width-25)) + if barLen < 1 { + barLen = 1 + } + + bar := strings.Repeat("◆", barLen) + + sb.WriteString(fmt.Sprintf("%s%-12s%s %s%s %s%.2f%s\n", + ansiBold, label, ansiReset, + ansiMagenta, bar, ansiReset, + ansiYellow, v, ansiReset, + )) + } +} + +func (r *ANSIRenderer) renderBarLikeChart(sb *strings.Builder, data *types.ChartData, ansiBold, ansiGreen, ansiBlue, ansiYellow, ansiRed, ansiReset string) { + dataset := data.Datasets[0] + labels := data.Labels + if len(labels) == 0 { + labels = make([]string, len(dataset.Values)) + for i := range labels { + labels[i] = fmt.Sprintf("X%d", i+1) + } + } + + maxValue := dataset.Values[0] + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + + colors := []string{ansiGreen, ansiBlue, ansiYellow, ansiRed} + for i, value := range dataset.Values { + label := labels[i] + if i >= len(labels) { + label = fmt.Sprintf("X%d", i+1) + } + + barLen := int((value / maxValue) * float64(r.width-20)) + if barLen < 1 { + barLen = 1 + } + + color := colors[i%len(colors)] + bar := strings.Repeat("█", barLen) + + line := ansiBold + fmt.Sprintf("%-8s", label) + ansiReset + " " + + color + bar + ansiReset + " " + + ansiYellow + fmt.Sprintf("%.2f", value) + ansiReset + "\n" + sb.WriteString(line) + } +} diff --git a/internal/renderer/png.go b/internal/renderer/png.go new file mode 100644 index 0000000..442eddd --- /dev/null +++ b/internal/renderer/png.go @@ -0,0 +1,565 @@ +package renderer + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/png" + "math" + "strconv" + + "github.com/picoclaw/chart/internal/types" + "gonum.org/v1/plot" + "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/vg" + "gonum.org/v1/plot/vg/draw" + "gonum.org/v1/plot/vg/vgimg" +) + +type PNGRenderer struct { + width vg.Length + height vg.Length +} + +func NewPNGRenderer() *PNGRenderer { + return &PNGRenderer{ + width: 600, + height: 400, + } +} + +func (r *PNGRenderer) Render(chart *types.Chart) ([]byte, error) { + switch chart.Type { + case types.ChartTypePie, types.ChartTypeDonut: + return r.renderPieChart(chart, chart.Type == types.ChartTypeDonut) + case types.ChartTypeBubble: + return r.renderBubbleChart(chart) + case types.ChartTypeMixed: + return r.renderMixedChart(chart) + case types.ChartTypePolar: + return r.renderPolarChart(chart) + case types.ChartTypeRadar: + return r.renderRadarChart(chart) + } + + p := plot.New() + + if chart.Title != "" { + p.Title.Text = chart.Title + } + + p.X.Label.Text = "" + p.Y.Label.Text = "" + + width := r.width + height := r.height + if chart.Data.Options != nil { + if chart.Data.Options.Width > 0 { + width = vg.Length(chart.Data.Options.Width) + } + if chart.Data.Options.Height > 0 { + height = vg.Length(chart.Data.Options.Height) + } + } + + switch chart.Type { + case types.ChartTypeLine: + r.addLineChart(p, &chart.Data) + case types.ChartTypeBar: + r.addBarChart(p, &chart.Data) + case types.ChartTypeScatter: + r.addScatterChart(p, &chart.Data) + default: + r.addLineChart(p, &chart.Data) + } + + canvas := vgimg.New(width, height) + p.Draw(draw.New(canvas)) + + img := canvas.Image() + + var buf bytes.Buffer + err := png.Encode(&buf, img) + if err != nil { + return nil, fmt.Errorf("failed to encode png: %w", err) + } + + return buf.Bytes(), nil +} + +func (r *PNGRenderer) addLineChart(p *plot.Plot, data *types.ChartData) { + for i, dataset := range data.Datasets { + pts := make(plotter.XYs, len(dataset.Values)) + for j, v := range dataset.Values { + pts[j].X = float64(j) + pts[j].Y = v + } + + line, err := plotter.NewLine(pts) + if err != nil { + continue + } + + colorStr := getColor(i) + if dataset.Color != "" { + colorStr = dataset.Color + } + line.Color = parseColor(colorStr) + + p.Add(line) + } + + if len(data.Labels) > 0 { + p.NominalX(data.Labels...) + } +} + +func (r *PNGRenderer) addBarChart(p *plot.Plot, data *types.ChartData) { + if len(data.Datasets) == 0 { + return + } + + dataset := data.Datasets[0] + labels := data.Labels + if len(labels) == 0 { + labels = make([]string, len(dataset.Values)) + for i := range labels { + labels[i] = fmt.Sprintf("%d", i) + } + } + + barChart, err := plotter.NewBarChart(plotter.Values(dataset.Values), vg.Points(20)) + if err != nil { + return + } + barChart.Color = parseColor(getColor(0)) + if len(data.Datasets) > 0 && data.Datasets[0].Color != "" { + barChart.Color = parseColor(data.Datasets[0].Color) + } + + p.Add(barChart) + p.NominalX(labels...) +} + +func (r *PNGRenderer) addScatterChart(p *plot.Plot, data *types.ChartData) { + for i, dataset := range data.Datasets { + pts := make(plotter.XYs, len(dataset.Values)) + for j, v := range dataset.Values { + pts[j].X = float64(j) + pts[j].Y = v + } + + scatter, err := plotter.NewScatter(pts) + if err != nil { + continue + } + + colorStr := getColor(i) + if dataset.Color != "" { + colorStr = dataset.Color + } + scatter.Color = parseColor(colorStr) + scatter.GlyphStyle.Radius = vg.Points(4) + + p.Add(scatter) + } + + if len(data.Labels) > 0 { + p.NominalX(data.Labels...) + } +} + +func (r *PNGRenderer) renderPieChart(chart *types.Chart, isDonut bool) ([]byte, error) { + if len(chart.Data.Datasets) == 0 || len(chart.Data.Datasets[0].Values) == 0 { + return []byte{}, nil + } + + dataset := chart.Data.Datasets[0] + values := dataset.Values + + total := 0.0 + for _, v := range values { + total += v + } + + imgWidth := int(r.width) + imgHeight := int(r.height) + img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) + + for y := 0; y < imgHeight; y++ { + for x := 0; x < imgWidth; x++ { + img.Set(x, y, color.White) + } + } + + cx := float64(imgWidth) / 2 + cy := float64(imgHeight)/2 - 30 + radius := math.Min(cx, cy) * 0.6 + innerRadius := radius * 0.5 + + if !isDonut { + innerRadius = 0 + } + + startAngle := -90.0 + + for i, v := range values { + angle := v / total * 360 + + colorStr := getColor(i) + col := parseHexColor(colorStr) + + endAngle := startAngle + angle + r.drawPieSlice(img, cx, cy, radius, innerRadius, startAngle, endAngle, col) + + startAngle = endAngle + } + + var buf bytes.Buffer + err := png.Encode(&buf, img) + if err != nil { + return nil, fmt.Errorf("failed to encode png: %w", err) + } + + return buf.Bytes(), nil +} + +func (r *PNGRenderer) drawPieSlice(img *image.RGBA, cx, cy, radius, innerRadius, startAngle, endAngle float64, col color.RGBA) { + maxRadius := int(radius) + 1 + for dy := -maxRadius; dy <= maxRadius; dy++ { + for dx := -maxRadius; dx <= maxRadius; dx++ { + dist := math.Sqrt(float64(dx*dx + dy*dy)) + if dist <= radius && dist >= innerRadius { + px, py := cx+float64(dx), cy+float64(dy) + pointAngle := math.Atan2(float64(dy), float64(dx)) * 180 / math.Pi + + normalizedAngle := pointAngle + if normalizedAngle < -90 { + normalizedAngle += 360 + } + + normalizedStart := startAngle + if normalizedStart < -90 { + normalizedStart += 360 + } + + normalizedEnd := endAngle + if normalizedEnd < -90 { + normalizedEnd += 360 + } + + var inSlice bool + if normalizedEnd > normalizedStart { + inSlice = normalizedAngle >= normalizedStart && normalizedAngle <= normalizedEnd + } else { + inSlice = normalizedAngle >= normalizedStart || normalizedAngle <= normalizedEnd + } + + if inSlice { + img.Set(int(px), int(py), col) + } + } + } + } +} + +func (r *PNGRenderer) renderBubbleChart(chart *types.Chart) ([]byte, error) { + if len(chart.Data.Datasets) == 0 { + return []byte{}, nil + } + + imgWidth := int(r.width) + imgHeight := int(r.height) + img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) + + for y := 0; y < imgHeight; y++ { + for x := 0; x < imgWidth; x++ { + img.Set(x, y, color.White) + } + } + + padding := 60.0 + chartWidth := float64(imgWidth) - 2*padding + chartHeight := float64(imgHeight) - 2*padding + + maxValue := 0.0 + for _, dataset := range chart.Data.Datasets { + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + } + if maxValue == 0 { + maxValue = 100 + } + + for i, dataset := range chart.Data.Datasets { + colorStr := getColor(i) + col := parseHexColor(colorStr) + + for j, v := range dataset.Values { + x := padding + (float64(j)/float64(len(dataset.Values)))*chartWidth + y := padding + chartHeight - (v/maxValue)*chartHeight + bubbleRadius := 10 + (v/maxValue)*30 + + for dy := -int(bubbleRadius); dy <= int(bubbleRadius); dy++ { + for dx := -int(bubbleRadius); dx <= int(bubbleRadius); dx++ { + if float64(dx*dx+dy*dy) <= bubbleRadius*bubbleRadius { + px, py := int(x)+dx, int(y)+dy + if px >= 0 && px < imgWidth && py >= 0 && py < imgHeight { + img.Set(px, py, col) + } + } + } + } + } + } + + var buf bytes.Buffer + err := png.Encode(&buf, img) + if err != nil { + return nil, fmt.Errorf("failed to encode png: %w", err) + } + + return buf.Bytes(), nil +} + +func (r *PNGRenderer) renderMixedChart(chart *types.Chart) ([]byte, error) { + if len(chart.Data.Datasets) == 0 { + return []byte{}, nil + } + + imgWidth := int(r.width) + imgHeight := int(r.height) + img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) + + for y := 0; y < imgHeight; y++ { + for x := 0; x < imgWidth; x++ { + img.Set(x, y, color.White) + } + } + + padding := 60.0 + chartWidth := float64(imgWidth) - 2*padding + chartHeight := float64(imgHeight) - 2*padding + + labels := chart.Data.Labels + if len(labels) == 0 { + labels = make([]string, len(chart.Data.Datasets[0].Values)) + for i := range labels { + labels[i] = fmt.Sprintf("%d", i+1) + } + } + + maxValue := 0.0 + for _, dataset := range chart.Data.Datasets { + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + } + if maxValue == 0 { + maxValue = 100 + } + + barWidth := chartWidth / float64(len(labels)*len(chart.Data.Datasets)) + seriesWidth := barWidth * float64(len(labels)) + + for i, dataset := range chart.Data.Datasets { + colorStr := getColor(i) + col := parseHexColor(colorStr) + + isLine := i%2 == 1 + + if isLine { + for j := 0; j < len(dataset.Values)-1; j++ { + x1 := padding + float64(j)*seriesWidth + seriesWidth/2 + y1 := padding + chartHeight - (dataset.Values[j]/maxValue)*chartHeight + x2 := padding + float64(j+1)*seriesWidth + seriesWidth/2 + y2 := padding + chartHeight - (dataset.Values[j+1]/maxValue)*chartHeight + + r.drawLine(img, x1, y1, x2, y2, col) + } + } else { + for j, v := range dataset.Values { + x := padding + float64(j)*seriesWidth + float64(i)*barWidth + barHeight := (v / maxValue) * chartHeight + y := padding + chartHeight - barHeight + + for py := int(y); py < int(y+barHeight); py++ { + for px := int(x); px < int(x+barWidth*0.8); px++ { + if px >= 0 && px < imgWidth && py >= 0 && py < imgHeight { + img.Set(px, py, col) + } + } + } + } + } + } + + var buf bytes.Buffer + err := png.Encode(&buf, img) + if err != nil { + return nil, fmt.Errorf("failed to encode png: %w", err) + } + + return buf.Bytes(), nil +} + +func (r *PNGRenderer) drawLine(img *image.RGBA, x1, y1, x2, y2 float64, col color.RGBA) { + dx := math.Abs(x2 - x1) + dy := math.Abs(y2 - y1) + + var steps float64 + if dx > dy { + steps = dx + } else { + steps = dy + } + + if steps == 0 { + return + } + + for i := 0.0; i <= steps; i++ { + t := i / steps + x := x1 + t*(x2-x1) + y := y1 + t*(y2-y1) + px, py := int(x), int(y) + if px >= 0 && px < int(img.Bounds().Dx()) && py >= 0 && py < int(img.Bounds().Dy()) { + img.Set(px, py, col) + } + } +} + +func (r *PNGRenderer) renderPolarChart(chart *types.Chart) ([]byte, error) { + return r.renderRadarLikeChart(chart, true) +} + +func (r *PNGRenderer) renderRadarChart(chart *types.Chart) ([]byte, error) { + return r.renderRadarLikeChart(chart, false) +} + +func (r *PNGRenderer) renderRadarLikeChart(chart *types.Chart, polar bool) ([]byte, error) { + if len(chart.Data.Datasets) == 0 || len(chart.Data.Datasets[0].Values) == 0 { + return []byte{}, nil + } + + dataset := chart.Data.Datasets[0] + values := dataset.Values + + maxValue := 0.0 + for _, v := range values { + if v > maxValue { + maxValue = v + } + } + if maxValue == 0 { + maxValue = 100 + } + + imgWidth := int(r.width) + imgHeight := int(r.height) + img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight)) + + for y := 0; y < imgHeight; y++ { + for x := 0; x < imgWidth; x++ { + img.Set(x, y, color.White) + } + } + + cx, cy := float64(imgWidth)/2, float64(imgHeight)/2 + radius := math.Min(cx, cy) * 0.5 + + numCategories := len(values) + angleStep := 360.0 / float64(numCategories) + + for level := 1; level <= 4; level++ { + rLevel := radius * float64(level) / 4 + for i := 0; i <= numCategories; i++ { + angle := -90 + float64(i)*angleStep + x := cx + rLevel*math.Cos(angle*math.Pi/180) + y := cy + rLevel*math.Sin(angle*math.Pi/180) + if i < numCategories { + nextAngle := -90 + float64(i+1)*angleStep + nextX := cx + rLevel*math.Cos(nextAngle*math.Pi/180) + nextY := cy + rLevel*math.Sin(nextAngle*math.Pi/180) + r.drawLine(img, x, y, nextX, nextY, color.RGBA{R: 200, G: 200, B: 200, A: 255}) + } + } + } + + for i := 0; i < len(values); i++ { + angle := -90 + float64(i)*angleStep + x := cx + radius*math.Cos(angle*math.Pi/180) + y := cy + radius*math.Sin(angle*math.Pi/180) + r.drawLine(img, cx, cy, x, y, color.RGBA{R: 200, G: 200, B: 200, A: 255}) + } + + colorStr := getColor(0) + col := parseHexColor(colorStr) + + prevX, prevY := cx, cy + for i, v := range values { + angle := -90 + float64(i)*angleStep + normalizedValue := v / maxValue + x := cx + radius*normalizedValue*math.Cos(angle*math.Pi/180) + y := cy + radius*normalizedValue*math.Sin(angle*math.Pi/180) + + if i > 0 { + r.drawLine(img, prevX, prevY, x, y, col) + } + prevX, prevY = x, y + + for dy := -3.0; dy <= 3.0; dy++ { + for dx := -3.0; dx <= 3.0; dx++ { + if dx*dx+dy*dy <= 9 { + px, py := int(x+dx), int(y+dy) + if px >= 0 && px < imgWidth && py >= 0 && py < imgHeight { + img.Set(px, py, col) + } + } + } + } + } + + firstAngle := -90.0 + firstX := cx + radius*(values[0]/maxValue)*math.Cos(firstAngle*math.Pi/180) + firstY := cy + radius*(values[0]/maxValue)*math.Sin(firstAngle*math.Pi/180) + r.drawLine(img, prevX, prevY, firstX, firstY, col) + + var buf bytes.Buffer + err := png.Encode(&buf, img) + if err != nil { + return nil, fmt.Errorf("failed to encode png: %w", err) + } + + return buf.Bytes(), nil +} + +func parseColor(hex string) color.Color { + if len(hex) != 7 || hex[0] != '#' { + return color.RGBA{R: 76, G: 175, B: 80, A: 255} + } + + r, _ := strconv.ParseUint(hex[1:3], 16, 8) + g, _ := strconv.ParseUint(hex[3:5], 16, 8) + b, _ := strconv.ParseUint(hex[5:7], 16, 8) + + return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} +} + +func parseHexColor(hex string) color.RGBA { + if len(hex) != 7 || hex[0] != '#' { + return color.RGBA{R: 76, G: 175, B: 80, A: 255} + } + + r, _ := strconv.ParseUint(hex[1:3], 16, 8) + g, _ := strconv.ParseUint(hex[3:5], 16, 8) + b, _ := strconv.ParseUint(hex[5:7], 16, 8) + + return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255} +} diff --git a/internal/renderer/renderer.go b/internal/renderer/renderer.go new file mode 100644 index 0000000..052c592 --- /dev/null +++ b/internal/renderer/renderer.go @@ -0,0 +1,14 @@ +package renderer + +import "github.com/picoclaw/chart/internal/types" + +type Renderer interface { + Render(chart *types.Chart) ([]byte, error) +} + +type RenderResult struct { + Text string + ANSI string + SVG string + PNG []byte +} diff --git a/internal/renderer/svg.go b/internal/renderer/svg.go new file mode 100644 index 0000000..b613590 --- /dev/null +++ b/internal/renderer/svg.go @@ -0,0 +1,620 @@ +package renderer + +import ( + "bytes" + "fmt" + "math" + + "github.com/picoclaw/chart/internal/types" + "gonum.org/v1/plot" + "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/vg" + "gonum.org/v1/plot/vg/draw" + "gonum.org/v1/plot/vg/vgsvg" +) + +type SVGRenderer struct { + width vg.Length + height vg.Length +} + +func NewSVGRenderer() *SVGRenderer { + return &SVGRenderer{ + width: 600, + height: 400, + } +} + +func (r *SVGRenderer) Render(chart *types.Chart) ([]byte, error) { + p := plot.New() + + if chart.Title != "" { + p.Title.Text = chart.Title + } + + p.X.Label.Text = "" + p.Y.Label.Text = "" + + width := r.width + height := r.height + if chart.Data.Options != nil { + if chart.Data.Options.Width > 0 { + width = vg.Length(chart.Data.Options.Width) + } + if chart.Data.Options.Height > 0 { + height = vg.Length(chart.Data.Options.Height) + } + } + + switch chart.Type { + case types.ChartTypeLine: + r.addLineChart(p, &chart.Data) + case types.ChartTypeBar: + r.addBarChart(p, &chart.Data) + case types.ChartTypePie: + return r.renderPieChart(chart, false) + case types.ChartTypeScatter: + r.addScatterChart(p, &chart.Data) + case types.ChartTypeBubble: + return r.renderBubbleChart(chart) + case types.ChartTypeDonut: + return r.renderPieChart(chart, true) + case types.ChartTypeMixed: + return r.renderMixedChart(chart) + case types.ChartTypePolar: + return r.renderPolarChart(chart) + case types.ChartTypeRadar: + return r.renderRadarChart(chart) + default: + r.addLineChart(p, &chart.Data) + } + + var buf bytes.Buffer + canvas := vgsvg.New(width, height) + p.Draw(draw.New(canvas)) + + _, err := canvas.WriteTo(&buf) + if err != nil { + return nil, fmt.Errorf("failed to write svg: %w", err) + } + return buf.Bytes(), nil +} + +func (r *SVGRenderer) addLineChart(p *plot.Plot, data *types.ChartData) { + for i, dataset := range data.Datasets { + pts := make(plotter.XYs, len(dataset.Values)) + for j, v := range dataset.Values { + pts[j].X = float64(j) + pts[j].Y = v + } + + line, err := plotter.NewLine(pts) + if err != nil { + continue + } + + colorStr := getColor(i) + if dataset.Color != "" { + colorStr = dataset.Color + } + line.Color = parseColor(colorStr) + + p.Add(line) + } + + if len(data.Labels) > 0 { + p.NominalX(data.Labels...) + } +} + +func (r *SVGRenderer) addBarChart(p *plot.Plot, data *types.ChartData) { + if len(data.Datasets) == 0 { + return + } + + dataset := data.Datasets[0] + labels := data.Labels + if len(labels) == 0 { + labels = make([]string, len(dataset.Values)) + for i := range labels { + labels[i] = fmt.Sprintf("%d", i) + } + } + + barChart, err := plotter.NewBarChart(plotter.Values(dataset.Values), vg.Points(20)) + if err != nil { + return + } + barChart.Color = parseColor(getColor(0)) + if len(data.Datasets) > 0 && data.Datasets[0].Color != "" { + barChart.Color = parseColor(data.Datasets[0].Color) + } + + p.Add(barChart) + p.NominalX(labels...) +} + +func (r *SVGRenderer) renderPieChart(chart *types.Chart, isDonut bool) ([]byte, error) { + if len(chart.Data.Datasets) == 0 || len(chart.Data.Datasets[0].Values) == 0 { + return []byte{}, nil + } + + dataset := chart.Data.Datasets[0] + values := dataset.Values + labels := chart.Data.Labels + + total := 0.0 + for _, v := range values { + total += v + } + + var sb bytes.Buffer + sb.WriteString(``) + sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) + + if chart.Title != "" { + sb.WriteString(fmt.Sprintf("\n%s", escapeXML(chart.Title))) + } + + cx, cy := float64(r.width)/2, float64(r.height)/2-20 + radius := math.Min(cx, cy) * 0.7 + innerRadius := radius * 0.5 + + if !isDonut { + innerRadius = 0 + } + + startAngle := -90.0 + legendY := float64(r.height) - 80 + + for i, v := range values { + label := fmt.Sprintf("Item %d", i+1) + if i < len(labels) { + label = labels[i] + } + + percentage := v / total * 100 + angle := v / total * 360 + + color := getColor(i) + if i < len(dataset.Color) && dataset.Color != "" { + color = dataset.Color + } + + endAngle := startAngle + angle + largeArc := 0 + if angle > 180 { + largeArc = 1 + } + + if isDonut { + x1 := cx + radius*math.Cos(startAngle*math.Pi/180) + y1 := cy + radius*math.Sin(startAngle*math.Pi/180) + x2 := cx + radius*math.Cos(endAngle*math.Pi/180) + y2 := cy + radius*math.Sin(endAngle*math.Pi/180) + x3 := cx + innerRadius*math.Cos(endAngle*math.Pi/180) + y3 := cy + innerRadius*math.Sin(endAngle*math.Pi/180) + x4 := cx + innerRadius*math.Cos(startAngle*math.Pi/180) + y4 := cy + innerRadius*math.Sin(startAngle*math.Pi/180) + + path := fmt.Sprintf("M %f,%f L %f,%f A %f,%f 0 %d,1 %f,%f L %f,%f A %f,%f 0 %d,0 %f,%f Z", + x4, y4, x1, y1, radius, radius, largeArc, x2, y2, x3, y3, innerRadius, innerRadius, largeArc, x4, y4) + sb.WriteString(fmt.Sprintf("\n", path, color)) + } else { + x1 := cx + radius*math.Cos(startAngle*math.Pi/180) + y1 := cy + radius*math.Sin(startAngle*math.Pi/180) + x2 := cx + radius*math.Cos(endAngle*math.Pi/180) + y2 := cy + radius*math.Sin(endAngle*math.Pi/180) + + path := fmt.Sprintf("M %f,%f L %f,%f A %f,%f 0 %d,1 %f,%f Z", + cx, cy, x1, y1, radius, radius, largeArc, x2, y2) + sb.WriteString(fmt.Sprintf("\n", path, color)) + sb.WriteString(fmt.Sprintf("\n%.1f%%", + cx+radius*0.5*math.Cos((startAngle+angle/2)*math.Pi/180), + cy+radius*0.5*math.Sin((startAngle+angle/2)*math.Pi/180), + percentage)) + } + + sb.WriteString(fmt.Sprintf("\n", 20, legendY+float64(i)*25, color)) + sb.WriteString(fmt.Sprintf("\n%s: %.2f (%.1f%%)", legendY+float64(i)*25+12, escapeXML(label), v, percentage)) + + startAngle = endAngle + } + + sb.WriteString("\n") + return sb.Bytes(), nil +} + +func (r *SVGRenderer) addScatterChart(p *plot.Plot, data *types.ChartData) { + for i, dataset := range data.Datasets { + pts := make(plotter.XYs, len(dataset.Values)) + for j, v := range dataset.Values { + pts[j].X = float64(j) + pts[j].Y = v + } + + scatter, err := plotter.NewScatter(pts) + if err != nil { + continue + } + + colorStr := getColor(i) + if dataset.Color != "" { + colorStr = dataset.Color + } + scatter.Color = parseColor(colorStr) + scatter.GlyphStyle.Radius = vg.Points(4) + + p.Add(scatter) + } + + if len(data.Labels) > 0 { + p.NominalX(data.Labels...) + } +} + +func escapeXML(s string) string { + var result bytes.Buffer + for _, c := range s { + switch c { + case '<': + result.WriteString("<") + case '>': + result.WriteString(">") + case '&': + result.WriteString("&") + case '"': + result.WriteString(""") + case '\'': + result.WriteString("'") + default: + result.WriteRune(c) + } + } + return result.String() +} + +func (r *SVGRenderer) renderBubbleChart(chart *types.Chart) ([]byte, error) { + if len(chart.Data.Datasets) == 0 { + return []byte{}, nil + } + + var sb bytes.Buffer + sb.WriteString(``) + sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) + + if chart.Title != "" { + sb.WriteString(fmt.Sprintf("\n%s", escapeXML(chart.Title))) + } + + padding := 60.0 + chartWidth := float64(r.width) - 2*padding + chartHeight := float64(r.height) - 2*padding + + maxValue := 0.0 + maxBubble := 0.0 + for _, dataset := range chart.Data.Datasets { + for i, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + if i < len(dataset.Values) && float64(i+1)*50 > maxBubble { + maxBubble = float64(i+1) * 50 + } + } + } + if maxValue == 0 { + maxValue = 100 + } + if maxBubble == 0 { + maxBubble = 100 + } + + sb.WriteString(fmt.Sprintf("\n", padding, padding, chartWidth, chartHeight)) + + for i, dataset := range chart.Data.Datasets { + color := getColor(i) + if dataset.Color != "" { + color = dataset.Color + } + + for j, v := range dataset.Values { + x := padding + (float64(j)/float64(len(dataset.Values)))*chartWidth + y := padding + chartHeight - (v/maxValue)*chartHeight + bubbleRadius := 10 + (v/maxValue)*30 + + sb.WriteString(fmt.Sprintf("\n", + x, y, bubbleRadius, color, color)) + sb.WriteString(fmt.Sprintf("\n%.0f", + x, y+4, v)) + } + } + + labels := chart.Data.Labels + if len(labels) > 0 { + labelSpacing := chartWidth / float64(len(labels)-1) + for i, label := range labels { + x := padding + float64(i)*labelSpacing + sb.WriteString(fmt.Sprintf("\n%s", + x, float64(r.height)-padding+20, escapeXML(label))) + } + } + + sb.WriteString("\n") + return sb.Bytes(), nil +} + +func (r *SVGRenderer) renderMixedChart(chart *types.Chart) ([]byte, error) { + if len(chart.Data.Datasets) == 0 { + return []byte{}, nil + } + + p := plot.New() + + if chart.Title != "" { + p.Title.Text = chart.Title + } + + p.X.Label.Text = "" + p.Y.Label.Text = "" + + padding := 60.0 + chartWidth := float64(r.width) - 2*padding + chartHeight := float64(r.height) - 2*padding + + labels := chart.Data.Labels + if len(labels) == 0 { + labels = make([]string, len(chart.Data.Datasets[0].Values)) + for i := range labels { + labels[i] = fmt.Sprintf("%d", i+1) + } + } + + maxValue := 0.0 + for _, dataset := range chart.Data.Datasets { + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + } + if maxValue == 0 { + maxValue = 100 + } + + var sb bytes.Buffer + sb.WriteString(``) + sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) + + if chart.Title != "" { + sb.WriteString(fmt.Sprintf("\n%s", escapeXML(chart.Title))) + } + + sb.WriteString(fmt.Sprintf("\n", padding, padding, chartWidth, chartHeight)) + + barWidth := chartWidth / float64(len(labels)*len(chart.Data.Datasets)+len(chart.Data.Datasets)) + seriesWidth := barWidth * float64(len(labels)) + + for i, dataset := range chart.Data.Datasets { + color := getColor(i) + if dataset.Color != "" { + color = dataset.Color + } + + isLine := i%2 == 1 + + if isLine { + points := "" + for j, v := range dataset.Values { + x := padding + float64(j)*seriesWidth + seriesWidth/2 + y := padding + chartHeight - (v/maxValue)*chartHeight + points += fmt.Sprintf("%f,%f ", x, y) + } + sb.WriteString(fmt.Sprintf("\n", points, color)) + + for j, v := range dataset.Values { + x := padding + float64(j)*seriesWidth + seriesWidth/2 + y := padding + chartHeight - (v/maxValue)*chartHeight + sb.WriteString(fmt.Sprintf("\n", x, y, color)) + } + } else { + for j, v := range dataset.Values { + x := padding + float64(j)*seriesWidth + float64(i)*barWidth + barHeight := (v / maxValue) * chartHeight + y := padding + chartHeight - barHeight + sb.WriteString(fmt.Sprintf("\n", x, y, barWidth*0.8, barHeight, color)) + } + } + } + + labelSpacing := chartWidth / float64(len(labels)) + for i, label := range labels { + x := padding + float64(i)*labelSpacing + labelSpacing/2 + sb.WriteString(fmt.Sprintf("\n%s", + x, float64(r.height)-padding+20, escapeXML(label))) + } + + sb.WriteString("\n") + return sb.Bytes(), nil +} + +func (r *SVGRenderer) renderPolarChart(chart *types.Chart) ([]byte, error) { + if len(chart.Data.Datasets) == 0 || len(chart.Data.Datasets[0].Values) == 0 { + return []byte{}, nil + } + + dataset := chart.Data.Datasets[0] + values := dataset.Values + labels := chart.Data.Labels + + maxValue := 0.0 + for _, v := range values { + if v > maxValue { + maxValue = v + } + } + if maxValue == 0 { + maxValue = 100 + } + + var sb bytes.Buffer + sb.WriteString(``) + sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) + + if chart.Title != "" { + sb.WriteString(fmt.Sprintf("\n%s", escapeXML(chart.Title))) + } + + cx, cy := float64(r.width)/2, float64(r.height)/2 + radius := math.Min(cx, cy) * 0.6 + + numCategories := len(values) + angleStep := 360.0 / float64(numCategories) + + for i := 1; i <= 4; i++ { + rRing := radius * float64(i) / 4 + points := "" + for j := 0; j <= numCategories; j++ { + angle := -90 + float64(j)*angleStep + x := cx + rRing*math.Cos(angle*math.Pi/180) + y := cy + rRing*math.Sin(angle*math.Pi/180) + points += fmt.Sprintf("%f,%f ", x, y) + } + sb.WriteString(fmt.Sprintf("\n", points)) + } + + nodes := "" + for i, v := range values { + angle := -90 + float64(i)*angleStep + normalizedValue := v / maxValue + x := cx + radius*normalizedValue*math.Cos(angle*math.Pi/180) + y := cy + radius*normalizedValue*math.Sin(angle*math.Pi/180) + nodes += fmt.Sprintf("%f,%f ", x, y) + + labelRadius := radius + 25 + labelX := cx + labelRadius*math.Cos(angle*math.Pi/180) + labelY := cy + labelRadius*math.Sin(angle*math.Pi/180) + + label := fmt.Sprintf("Item %d", i+1) + if i < len(labels) { + label = labels[i] + } + + anchor := "middle" + if math.Cos(angle*math.Pi/180) > 0.1 { + anchor = "start" + } else if math.Cos(angle*math.Pi/180) < -0.1 { + anchor = "end" + } + + sb.WriteString(fmt.Sprintf("\n%s", + labelX, labelY+4, anchor, escapeXML(label))) + } + + color := getColor(0) + if dataset.Color != "" { + color = dataset.Color + } + sb.WriteString(fmt.Sprintf("\n", nodes, color, color)) + + sb.WriteString("\n") + return sb.Bytes(), nil +} + +func (r *SVGRenderer) renderRadarChart(chart *types.Chart) ([]byte, error) { + if len(chart.Data.Datasets) == 0 || len(chart.Data.Datasets[0].Values) == 0 { + return []byte{}, nil + } + + dataset := chart.Data.Datasets[0] + values := dataset.Values + labels := chart.Data.Labels + + maxValue := 0.0 + for _, v := range values { + if v > maxValue { + maxValue = v + } + } + if maxValue == 0 { + maxValue = 100 + } + + var sb bytes.Buffer + sb.WriteString(``) + sb.WriteString(fmt.Sprintf("\n", int(r.width), int(r.height))) + + if chart.Title != "" { + sb.WriteString(fmt.Sprintf("\n%s", escapeXML(chart.Title))) + } + + cx, cy := float64(r.width)/2, float64(r.height)/2 + radius := math.Min(cx, cy) * 0.5 + + numAxes := len(values) + angleStep := 360.0 / float64(numAxes) + + for level := 1; level <= 5; level++ { + rLevel := radius * float64(level) / 5 + points := "" + for i := 0; i <= numAxes; i++ { + angle := -90 + float64(i)*angleStep + x := cx + rLevel*math.Cos(angle*math.Pi/180) + y := cy + rLevel*math.Sin(angle*math.Pi/180) + points += fmt.Sprintf("%f,%f ", x, y) + } + sb.WriteString(fmt.Sprintf("\n", points)) + } + + for i := 0; i < len(values); i++ { + angle := -90 + float64(i)*angleStep + x := cx + radius*math.Cos(angle*math.Pi/180) + y := cy + radius*math.Sin(angle*math.Pi/180) + sb.WriteString(fmt.Sprintf("\n", cx, cy, x, y)) + + labelRadius := radius + 20 + labelX := cx + labelRadius*math.Cos(angle*math.Pi/180) + labelY := cy + labelRadius*math.Sin(angle*math.Pi/180) + + label := fmt.Sprintf("Item %d", i+1) + if i < len(labels) { + label = labels[i] + } + + anchor := "middle" + if math.Cos(angle*math.Pi/180) > 0.1 { + anchor = "start" + } else if math.Cos(angle*math.Pi/180) < -0.1 { + anchor = "end" + } + + sb.WriteString(fmt.Sprintf("\n%s", + labelX, labelY+4, anchor, escapeXML(label))) + } + + nodes := "" + for i, v := range values { + angle := -90 + float64(i)*angleStep + normalizedValue := v / maxValue + x := cx + radius*normalizedValue*math.Cos(angle*math.Pi/180) + y := cy + radius*normalizedValue*math.Sin(angle*math.Pi/180) + nodes += fmt.Sprintf("%f,%f ", x, y) + } + + color := getColor(0) + if dataset.Color != "" { + color = dataset.Color + } + sb.WriteString(fmt.Sprintf("\n", nodes, color, color)) + + for i, v := range values { + angle := -90 + float64(i)*angleStep + normalizedValue := v / maxValue + x := cx + radius*normalizedValue*math.Cos(angle*math.Pi/180) + y := cy + radius*normalizedValue*math.Sin(angle*math.Pi/180) + sb.WriteString(fmt.Sprintf("\n", x, y, color)) + } + + sb.WriteString("\n") + return sb.Bytes(), nil +} diff --git a/internal/renderer/text.go b/internal/renderer/text.go new file mode 100644 index 0000000..110ccd5 --- /dev/null +++ b/internal/renderer/text.go @@ -0,0 +1,198 @@ +package renderer + +import ( + "fmt" + "strings" + + "github.com/picoclaw/chart/internal/types" +) + +type TextRenderer struct { + width int + height int +} + +func NewTextRenderer() *TextRenderer { + return &TextRenderer{ + width: 60, + height: 15, + } +} + +func (r *TextRenderer) Render(chart *types.Chart) ([]byte, error) { + var sb strings.Builder + + if chart.Title != "" { + sb.WriteString(fmt.Sprintf("[ Chart: %s ]\n", chart.Title)) + } + + if len(chart.Data.Datasets) == 0 { + sb.WriteString("No data available.\n") + return []byte(sb.String()), nil + } + + switch chart.Type { + case types.ChartTypePie, types.ChartTypeDonut: + r.renderPieChart(&sb, &chart.Data) + case types.ChartTypeBubble: + r.renderBubbleChart(&sb, &chart.Data) + case types.ChartTypeMixed: + r.renderMixedChart(&sb, &chart.Data) + case types.ChartTypePolar, types.ChartTypeRadar: + r.renderRadarLikeChart(&sb, &chart.Data) + default: + r.renderBarLikeChart(&sb, &chart.Data) + } + + return []byte(sb.String()), nil +} + +func (r *TextRenderer) renderPieChart(sb *strings.Builder, data *types.ChartData) { + dataset := data.Datasets[0] + values := dataset.Values + labels := data.Labels + + total := 0.0 + for _, v := range values { + total += v + } + + for i, v := range values { + label := fmt.Sprintf("Item %d", i+1) + if i < len(labels) { + label = labels[i] + } + + percentage := v / total * 100 + + barLen := int(percentage / 4) + if barLen > 12 { + barLen = 12 + } + bar := strings.Repeat("#", barLen) + + sb.WriteString(fmt.Sprintf("%-12s |%s [%.1f%%]\n", label, bar, percentage)) + } +} + +func (r *TextRenderer) renderBubbleChart(sb *strings.Builder, data *types.ChartData) { + dataset := data.Datasets[0] + labels := data.Labels + if len(labels) == 0 { + labels = make([]string, len(dataset.Values)) + for i := range labels { + labels[i] = fmt.Sprintf("X%d", i+1) + } + } + + maxValue := dataset.Values[0] + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + + for i, value := range dataset.Values { + label := labels[i] + bubbleSize := 2 + int((value/maxValue)*5) + + bubble := strings.Repeat("o", bubbleSize) + + sb.WriteString(fmt.Sprintf("%-8s |%s %.2f\n", label, bubble, value)) + } +} + +func (r *TextRenderer) renderMixedChart(sb *strings.Builder, data *types.ChartData) { + dataset := data.Datasets[0] + labels := data.Labels + if len(labels) == 0 { + labels = make([]string, len(dataset.Values)) + for i := range labels { + labels[i] = fmt.Sprintf("X%d", i+1) + } + } + + maxValue := dataset.Values[0] + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + + for i, value := range dataset.Values { + label := labels[i] + + barLen := int((value / maxValue) * float64(r.width-20)) + if barLen < 1 { + barLen = 1 + } + + bar := strings.Repeat("#", barLen) + + sb.WriteString(fmt.Sprintf("%-8s |%s %.2f [mixed]\n", label, bar, value)) + } +} + +func (r *TextRenderer) renderRadarLikeChart(sb *strings.Builder, data *types.ChartData) { + dataset := data.Datasets[0] + values := dataset.Values + labels := data.Labels + + maxValue := values[0] + for _, v := range values { + if v > maxValue { + maxValue = v + } + } + + sb.WriteString(fmt.Sprintf("[ Radar/Polar Chart: %d dimensions ]\n", len(values))) + + for i, v := range values { + label := fmt.Sprintf("Dim %d", i+1) + if i < len(labels) { + label = labels[i] + } + + barLen := int((v / maxValue) * float64(r.width-25)) + if barLen < 1 { + barLen = 1 + } + + bar := strings.Repeat("#", barLen) + + sb.WriteString(fmt.Sprintf("%-12s |%s %.2f\n", label, bar, v)) + } +} + +func (r *TextRenderer) renderBarLikeChart(sb *strings.Builder, data *types.ChartData) { + dataset := data.Datasets[0] + labels := data.Labels + if len(labels) == 0 { + labels = make([]string, len(dataset.Values)) + for i := range labels { + labels[i] = fmt.Sprintf("X%d", i+1) + } + } + + maxValue := dataset.Values[0] + for _, v := range dataset.Values { + if v > maxValue { + maxValue = v + } + } + + for i, value := range dataset.Values { + label := labels[i] + if i >= len(labels) { + label = fmt.Sprintf("X%d", i+1) + } + + barLen := int((value / maxValue) * float64(r.width-20)) + if barLen < 1 { + barLen = 1 + } + + bar := strings.Repeat("#", barLen) + sb.WriteString(fmt.Sprintf("%-8s |%s %.2f\n", label, bar, value)) + } +} diff --git a/internal/renderer/utils.go b/internal/renderer/utils.go new file mode 100644 index 0000000..793f505 --- /dev/null +++ b/internal/renderer/utils.go @@ -0,0 +1,9 @@ +package renderer + +func getColor(index int) string { + colors := []string{ + "#f9c2c8", "#f3d2b1", "#e1e9c5", "#b2d5e8", + "#8fa3c4", "#75B4A0", "#3b7f8d", "#a18b7c", + } + return colors[index%len(colors)] +} diff --git a/internal/service/chart.go b/internal/service/chart.go new file mode 100644 index 0000000..ecd33b9 --- /dev/null +++ b/internal/service/chart.go @@ -0,0 +1,85 @@ +package service + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/picoclaw/chart/internal/renderer" + "github.com/picoclaw/chart/internal/types" +) + +type ChartService struct { + svgRenderer *renderer.SVGRenderer + pngRenderer *renderer.PNGRenderer + ansiRenderer *renderer.ANSIRenderer + textRenderer *renderer.TextRenderer + chartCache map[string]*types.Chart +} + +func NewChartService() *ChartService { + return &ChartService{ + svgRenderer: renderer.NewSVGRenderer(), + pngRenderer: renderer.NewPNGRenderer(), + ansiRenderer: renderer.NewANSIRenderer(), + textRenderer: renderer.NewTextRenderer(), + chartCache: make(map[string]*types.Chart), + } +} + +func (s *ChartService) CreateChart(req *types.ChartRequest) (*types.ChartResponse, error) { + chart := &types.Chart{ + ID: uuid.New().String(), + Type: req.Type, + Title: req.Title, + Data: req.Data, + } + + svgData, err := s.svgRenderer.Render(chart) + if err != nil { + return nil, fmt.Errorf("failed to render SVG: %w", err) + } + chart.SVG = svgData + + pngData, err := s.pngRenderer.Render(chart) + if err != nil { + return nil, fmt.Errorf("failed to render PNG: %w", err) + } + chart.PNG = pngData + + ansiData, err := s.ansiRenderer.Render(chart) + if err != nil { + return nil, fmt.Errorf("failed to render ANSI: %w", err) + } + + textData, err := s.textRenderer.Render(chart) + if err != nil { + return nil, fmt.Errorf("failed to render text: %w", err) + } + + s.chartCache[chart.ID] = chart + + return &types.ChartResponse{ + ChartID: chart.ID, + Text: string(textData), + ANSI: string(ansiData), + SVG: string(chart.SVG), + PNGURL: fmt.Sprintf("/api/v1/chart/%s.png", chart.ID), + Markdown: fmt.Sprintf("![%s](/api/v1/chart/%s.png)", chart.Title, chart.ID), + }, nil +} + +func (s *ChartService) GetChart(id string) (*types.Chart, error) { + chart, ok := s.chartCache[id] + if !ok { + return nil, fmt.Errorf("chart not found: %s", id) + } + return chart, nil +} + +func (s *ChartService) GetChartPNG(id string) ([]byte, error) { + chart, err := s.GetChart(id) + if err != nil { + return nil, err + } + return chart.PNG, nil +} diff --git a/internal/types/chart.go b/internal/types/chart.go new file mode 100644 index 0000000..d0b0e7c --- /dev/null +++ b/internal/types/chart.go @@ -0,0 +1,61 @@ +package types + +type ChartType string + +const ( + ChartTypeLine ChartType = "line" + ChartTypeBar ChartType = "bar" + ChartTypePie ChartType = "pie" + ChartTypeScatter ChartType = "scatter" + ChartTypeBubble ChartType = "bubble" + ChartTypeDonut ChartType = "donut" + ChartTypeMixed ChartType = "mixed" + ChartTypePolar ChartType = "polar" + ChartTypeRadar ChartType = "radar" +) + +type ChartRequest struct { + Type ChartType `json:"type"` + Title string `json:"title"` + Data ChartData `json:"data"` + Options *ChartOptions `json:"options,omitempty"` +} + +type ChartData struct { + Labels []string `json:"labels"` + Datasets []Dataset `json:"datasets"` + Options *ChartOptions `json:"options,omitempty"` +} + +type Dataset struct { + Name string `json:"name"` + Values []float64 `json:"values"` + Color string `json:"color,omitempty"` +} + +type ChartOptions struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} + +type ChartResponse struct { + ChartID string `json:"chart_id"` + Text string `json:"text"` + ANSI string `json:"ansi"` + SVG string `json:"svg"` + PNGURL string `json:"png_url"` + Markdown string `json:"markdown"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} + +type Chart struct { + ID string + Type ChartType + Title string + Data ChartData + SVG []byte + PNG []byte +} diff --git a/skill.md b/skill.md new file mode 100644 index 0000000..52fd5bc --- /dev/null +++ b/skill.md @@ -0,0 +1,205 @@ +# Chart Skill - 图表生成工具 + +## 概述 + +用于生成各种类型的图表,返回多种格式以适配不同场景: +- **ANSI 格式**:终端彩色显示 +- **SVG 格式**:Web 内嵌矢量图 +- **PNG 格式**:位图下载 +- **Markdown 格式**:Web 对话窗口显示 + +## 支持的图表类型 + +| 类型 | 说明 | +|------|------| +| `line` | 折线图 | +| `bar` | 柱状图 | +| `pie` | 饼图 | +| `scatter` | 散点图 | +| `bubble` | 气泡图 | +| `donut` | 圆环图 | +| `mixed` | 混合图(柱状+折线) | +| `polar` | 极区图 | +| `radar` | 雷达图 | + +## 基本信息 + +| 属性 | 值 | +|------|-----| +| 端点 | `http://localhost:3100/api/v1/chart` | +| 方法 | `POST` | +| 内容类型 | `application/json` | + +## 请求参数 + +### 参数结构 + +```json +{ + "type": "bar", + "title": "图表标题", + "data": { + "labels": ["标签1", "标签2", "标签3"], + "datasets": [ + { + "name": "数据系列名称", + "values": [100, 150, 120], + "color": "#4CAF50" + } + ], + "options": { + "width": 600, + "height": 400 + } + } +} +``` + +### 参数说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `type` | string | 否 | 图表类型,默认 `line` | +| `title` | string | 否 | 图表标题 | +| `data.labels` | array | 是 | 标签数组 | +| `data.datasets` | array | 是 | 数据系列数组 | +| `datasets[].name` | string | 否 | 数据系列名称 | +| `datasets[].values` | array | **是** | 数值数组 | +| `datasets[].color` | string | 否 | 十六进制颜色,如 `#4CAF50` | +| `options.width` | integer | 否 | 图表宽度(像素) | +| `options.height` | integer | 否 | 图表高度(像素) | + +## 响应字段 + +| 字段 | 说明 | +|------|------| +| `chart_id` | 图表唯一标识 | +| `text` | 纯文本描述 | +| `ansi` | ANSI 彩色文本(用于终端) | +| `svg` | SVG 矢量图代码 | +| `png_url` | PNG 图片下载 URL | +| `markdown` | Markdown 格式(用于 Web) | + +## 使用示例 + +### 示例 1:柱状图 + +```json +{ + "type": "bar", + "title": "月度销售额", + "data": { + "labels": ["一月", "二月", "三月", "四月"], + "datasets": [ + { "name": "销售额", "values": [100, 150, 120, 180], "color": "#4CAF50" } + ] + } +} +``` + +### 示例 2:饼图 + +```json +{ + "type": "pie", + "title": "市场份额", + "data": { + "labels": ["苹果", "三星", "华为", "其他"], + "datasets": [ + { "name": "销量", "values": [35, 25, 20, 20] } + ] + } +} +``` + +### 示例 3:圆环图 + +```json +{ + "type": "donut", + "title": "预算分配", + "data": { + "labels": ["人力", "设备", "营销", "研发"], + "datasets": [ + { "name": "预算", "values": [40, 20, 25, 15], "color": "#2196F3" } + ] + } +} +``` + +### 示例 4:雷达图 + +```json +{ + "type": "radar", + "title": "员工能力评估", + "data": { + "labels": ["速度", "力量", "耐力", "智力", "敏捷"], + "datasets": [ + { "name": "张三", "values": [80, 70, 90, 85, 75] } + ] + } +} +``` + +### 示例 5:气泡图 + +```json +{ + "type": "bubble", + "title": "产品分析", + "data": { + "labels": ["产品A", "产品B", "产品C", "产品D"], + "datasets": [ + { "name": "销量/利润", "values": [100, 250, 150, 300] } + ] + } +} +``` + +### 示例 6:混合图 + +```json +{ + "type": "mixed", + "title": "销售与目标对比", + "data": { + "labels": ["一月", "二月", "三月", "四月"], + "datasets": [ + { "name": "实际", "values": [100, 150, 120, 180] }, + { "name": "目标", "values": [120, 140, 130, 160] } + ] + } +} +``` + +### 示例 7:极区图 + +```json +{ + "type": "polar", + "title": "风向分布", + "data": { + "labels": ["北", "东北", "东", "东南", "南"], + "datasets": [ + { "name": "风速", "values": [10, 20, 15, 25, 30] } + ] + } +} +``` + +## 使用场景 + +| 场景 | 推荐字段 | 说明 | +|------|---------|------| +| 终端显示 | `ansi` | 彩色 ASCII 图表 | +| Web 内嵌 | `svg` | 内联 SVG 代码 | +| Web 展示 | `markdown` | 图片链接 | +| 文件下载 | `png_url` | 下载 PNG 文件 | + +## 注意事项 + +1. 每次请求生成新的图表,图表会缓存在服务器内存中 +2. 图表数据通过 `datasets[].values` 传递,支持多系列 +3. 颜色使用十六进制格式,如 `#FF5722` +4. 终端使用 ANSI 格式,Web 使用 Markdown 格式 diff --git a/taolun.md b/taolun.md new file mode 100644 index 0000000..6e84454 --- /dev/null +++ b/taolun.md @@ -0,0 +1,143 @@ +# taolun.md + +## 会话记录 + +### 2026-04-16: picoclaw-chart 项目启动 + +#### 问:我想给我的命令行做一个扩展,也就是给picoclaw使用的一个tools扩展。 + +**答:** +了解了,你希望创建一个图表 API 服务,让 AI Agent 可以通过 curl 调用来生成各种图表(折线图、柱状图、散点图等)。 + +--- + +#### 问:我的想法是,我们提供一个在线的api,通过curl传入需要的参数,之后将结果返回给Agent。 + +**答:** +这个方向不错。关键是确定返回格式,需要兼容命令行和 Web 显示。 + +--- + +#### 问:为了能够兼容所有的命令行显示,和 web显示,我们应该返回什么样的内容?是json,还是markdown。主要是,我想要添加彩色效果。 + +**答:** +建议方案: +- 返回 JSON,包含多种格式(text, ansi, svg, markdown) +- Agent 根据目标环境选择合适的格式 +- ANSI 用于纯命令行,Markdown + 图片 URL 用于富终端或 Web + +--- + +#### 问:数据源是动态的,由AI自动使用,因为我会在本地装一个skill来解释它的用法 + +**答:** +明白了。需要提供清晰的 API 文档和 skill 定义。 + +--- + +#### 问:返回格式,我觉得使用 纯文本、SVG、Markdown、ANSI 转义码 如何? + +**答:** +同意,这个组合覆盖了所有场景。 + +--- + +#### 问:用GO,编译后使用单二进制运行 + +**答:** +好的,使用 Go + Fiber 框架。 + +--- + +#### 问:Fiber + +**答:** +确认使用 Fiber 框架。 + +--- + +#### 问:可以 + +**答:** +确认 API 请求/响应格式。 + +--- + +#### 问:需要 + +**答:** +确认需要 PNG 下载功能。 + +--- + +#### 问:3100端口吧。需要,最后使用 docker部署,使用 .env配置吧,你觉得呢? + +**答:** +确认: +- 端口:3100 +- Docker 部署 +- .env 配置 + +--- + +#### 问:使用oop + 设计模式 + +**答:** +好的,将使用策略模式和工厂模式: +- ChartRenderer 接口 +- SVGRenderer、PNGRenderer、ANSIRenderer、TextRenderer 实现 + +--- + +#### 问:建立taolun.md + agents.md + +**答:** +确认创建: +- taolun.md: 会话记录 +- agents.md: AI 遵守规则 +- changelog.md: 变更记录 + +--- + +#### 问:一个问题循环3次未解决,强制自动退出,交由用户操作 + +**答:** +确认,调试策略。 + +--- + +#### 问:你觉得我们使用哪个图表库,比较好? + +**答:** +对比了 ntcharts、pterm、gonum/plot,建议: +- ANSI/Text: 自写渲染器 +- PNG/SVG: gonum/plot(纯 Go,单二进制) + +--- + +#### 问:和 ntcharts、pterm 这两个库比较呢? + +**答:** +对比结果: +- ntcharts: 依赖 Bubble Tea,适合 TUI +- pterm: 适合终端美化,无 PNG/SVG +- gonum/plot: 纯 Go,适合科学绘图 + +建议使用 gonum/plot 生成 PNG/SVG,自写 ANSI 渲染器。 + +--- + +#### 问:我希望web也能显示 + +**答:** +确认最终方案: +- API 返回 text, ansi, svg, png_url, markdown +- Web 端使用 Markdown + 图片 URL +- 命令行使用 ANSI + +--- + +#### 问:开始 + +**答:** +开始构建项目。