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
This commit is contained in:
2026-04-16 04:33:02 +08:00
commit ba927c2b2f
21 changed files with 2918 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
SERVER_PORT=3100
BASE_URL=http://localhost:3100

29
.gitignore vendored Normal file
View File

@@ -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/

24
Dockerfile Normal file
View File

@@ -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"]

276
README.md Normal file
View File

@@ -0,0 +1,276 @@
# Picoclaw Chart
一个轻量级的图表生成 API 服务支持多种图表类型返回多种格式ANSI、SVG、PNG、Markdown适配命令行和 Web 场景。
## 功能特性
- **多种图表类型**:折线图、柱状图、饼图、散点图、气泡图、圆环图、混合图、极区图、雷达图
- **多格式输出**ANSI终端、SVG矢量图、PNG位图、MarkdownWeb
- **轻量高效**Go 语言编写,单二进制部署
- **Docker 支持**:开箱即用的容器化部署
## 支持的图表类型
| 类型 | 英文名 | 说明 |
|------|--------|------|
| `line` | 折线图 | 展示数据趋势变化 |
| `bar` | 柱状图 | 对比分类数据 |
| `pie` | 饼图 | 展示占比关系 |
| `scatter` | 散点图 | 展示数据分布 |
| `bubble` | 气泡图 | 展示多维度数据 |
| `donut` | 圆环图 | 饼图的变体 |
| `mixed` | 混合图 | 柱状图+折线图组合 |
| `polar` | 极区图 | 展示方向/角度数据 |
| `radar` | 雷达图 | 展示多维度对比 |
## 快速开始
### Docker 部署(推荐)
```bash
# 克隆项目
git clone <your-repo-url>
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

74
agents.md Normal file
View File

@@ -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 次未解决,强制自动退出
- 交由用户操作

65
changelog.md Normal file
View File

@@ -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
**结论**:对于需要同时支持 WebPNG/SVG和 CLIANSI的场景gonum/plot + 自写 ANSI 渲染器是最佳组合。
### 设计模式应用
1. **策略模式**:不同的渲染器实现统一的 Renderer 接口
2. **工厂模式**RendererFactory 根据格式类型创建对应渲染器
### Docker 注意事项
- 使用 `golang:alpine` 减小镜像体积
- 多阶段构建:编译 -> 运行
- 端口映射3100:3100

17
docker-compose.yml Normal file
View File

@@ -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

33
go.mod Normal file
View File

@@ -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
)

123
go.sum Normal file
View File

@@ -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=

34
internal/config/config.go Normal file
View File

@@ -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
}

96
internal/handler/chart.go Normal file
View File

@@ -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",
})
}

245
internal/renderer/ansi.go Normal file
View File

@@ -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)
}
}

565
internal/renderer/png.go Normal file
View File

@@ -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}
}

View File

@@ -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
}

620
internal/renderer/svg.go Normal file
View File

@@ -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(`<?xml version="1.0" encoding="UTF-8"?>`)
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
if chart.Title != "" {
sb.WriteString(fmt.Sprintf("\n<text x=\"50%\" y=\"30\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", 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 d=\"%s\" fill=\"%s\" stroke=\"white\" stroke-width=\"2\"/>", 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 d=\"%s\" fill=\"%s\" stroke=\"white\" stroke-width=\"2\"/>", path, color))
sb.WriteString(fmt.Sprintf("\n<text x=\"%f\" y=\"%f\" text-anchor=\"middle\" font-size=\"12\" fill=\"white\">%.1f%%</text>",
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<rect x=\"%f\" y=\"%f\" width=\"15\" height=\"15\" fill=\"%s\"/>", 20, legendY+float64(i)*25, color))
sb.WriteString(fmt.Sprintf("\n<text x=\"40\" y=\"%f\" font-size=\"12\">%s: %.2f (%.1f%%)</text>", legendY+float64(i)*25+12, escapeXML(label), v, percentage))
startAngle = endAngle
}
sb.WriteString("\n</svg>")
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("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
case '"':
result.WriteString("&quot;")
case '\'':
result.WriteString("&apos;")
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(`<?xml version="1.0" encoding="UTF-8"?>`)
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
if chart.Title != "" {
sb.WriteString(fmt.Sprintf("\n<text x=\"50%%\" y=\"30\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", 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<rect x=\"%f\" y=\"%f\" width=\"%f\" height=\"%f\" fill=\"#f8f9fa\" stroke=\"#ddd\"/>", 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<circle cx=\"%f\" cy=\"%f\" r=\"%f\" fill=\"%s\" opacity=\"0.6\" stroke=\"%s\" stroke-width=\"2\"/>",
x, y, bubbleRadius, color, color))
sb.WriteString(fmt.Sprintf("\n<text x=\"%f\" y=\"%f\" text-anchor=\"middle\" font-size=\"10\" fill=\"#333\">%.0f</text>",
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<text x=\"%f\" y=\"%f\" text-anchor=\"middle\" font-size=\"11\" fill=\"#666\">%s</text>",
x, float64(r.height)-padding+20, escapeXML(label)))
}
}
sb.WriteString("\n</svg>")
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(`<?xml version="1.0" encoding="UTF-8"?>`)
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
if chart.Title != "" {
sb.WriteString(fmt.Sprintf("\n<text x=\"50%%\" y=\"25\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", escapeXML(chart.Title)))
}
sb.WriteString(fmt.Sprintf("\n<rect x=\"%f\" y=\"%f\" width=\"%f\" height=\"%f\" fill=\"#f8f9fa\" stroke=\"#ddd\"/>", 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<polyline points=\"%s\" fill=\"none\" stroke=\"%s\" stroke-width=\"3\"/>", 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<circle cx=\"%f\" cy=\"%f\" r=\"5\" fill=\"%s\"/>", 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<rect x=\"%f\" y=\"%f\" width=\"%f\" height=\"%f\" fill=\"%s\"/>", 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<text x=\"%f\" y=\"%f\" text-anchor=\"middle\" font-size=\"11\" fill=\"#666\">%s</text>",
x, float64(r.height)-padding+20, escapeXML(label)))
}
sb.WriteString("\n</svg>")
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(`<?xml version="1.0" encoding="UTF-8"?>`)
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
if chart.Title != "" {
sb.WriteString(fmt.Sprintf("\n<text x=\"50%%\" y=\"25\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", 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<polygon points=\"%s\" fill=\"none\" stroke=\"#ddd\" stroke-width=\"1\"/>", 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<text x=\"%f\" y=\"%f\" text-anchor=\"%s\" font-size=\"11\" fill=\"#666\">%s</text>",
labelX, labelY+4, anchor, escapeXML(label)))
}
color := getColor(0)
if dataset.Color != "" {
color = dataset.Color
}
sb.WriteString(fmt.Sprintf("\n<polygon points=\"%s\" fill=\"%s\" opacity=\"0.5\" stroke=\"%s\" stroke-width=\"2\"/>", nodes, color, color))
sb.WriteString("\n</svg>")
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(`<?xml version="1.0" encoding="UTF-8"?>`)
sb.WriteString(fmt.Sprintf("\n<svg width=\"%v\" height=\"%v\" xmlns=\"http://www.w3.org/2000/svg\">", int(r.width), int(r.height)))
if chart.Title != "" {
sb.WriteString(fmt.Sprintf("\n<text x=\"50%%\" y=\"25\" text-anchor=\"middle\" font-size=\"16\" font-weight=\"bold\">%s</text>", 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<polygon points=\"%s\" fill=\"none\" stroke=\"#ddd\" stroke-width=\"1\"/>", 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<line x1=\"%f\" y1=\"%f\" x2=\"%f\" y2=\"%f\" stroke=\"#ddd\" stroke-width=\"1\"/>", 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<text x=\"%f\" y=\"%f\" text-anchor=\"%s\" font-size=\"11\" fill=\"#666\">%s</text>",
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<polygon points=\"%s\" fill=\"%s\" opacity=\"0.3\" stroke=\"%s\" stroke-width=\"2\"/>", 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<circle cx=\"%f\" cy=\"%f\" r=\"4\" fill=\"%s\"/>", x, y, color))
}
sb.WriteString("\n</svg>")
return sb.Bytes(), nil
}

198
internal/renderer/text.go Normal file
View File

@@ -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))
}
}

View File

@@ -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)]
}

85
internal/service/chart.go Normal file
View File

@@ -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
}

61
internal/types/chart.go Normal file
View File

@@ -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
}

205
skill.md Normal file
View File

@@ -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 格式

143
taolun.md Normal file
View File

@@ -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
---
#### 问:开始
**答:**
开始构建项目。