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:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
SERVER_PORT=3100
|
||||
BASE_URL=http://localhost:3100
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
24
Dockerfile
Normal 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
276
README.md
Normal file
@@ -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 <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
74
agents.md
Normal 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
65
changelog.md
Normal 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
|
||||
|
||||
**结论**:对于需要同时支持 Web(PNG/SVG)和 CLI(ANSI)的场景,gonum/plot + 自写 ANSI 渲染器是最佳组合。
|
||||
|
||||
### 设计模式应用
|
||||
|
||||
1. **策略模式**:不同的渲染器实现统一的 Renderer 接口
|
||||
2. **工厂模式**:RendererFactory 根据格式类型创建对应渲染器
|
||||
|
||||
### Docker 注意事项
|
||||
|
||||
- 使用 `golang:alpine` 减小镜像体积
|
||||
- 多阶段构建:编译 -> 运行
|
||||
- 端口映射:3100:3100
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal 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
33
go.mod
Normal 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
123
go.sum
Normal 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
34
internal/config/config.go
Normal 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
96
internal/handler/chart.go
Normal 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
245
internal/renderer/ansi.go
Normal 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
565
internal/renderer/png.go
Normal 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}
|
||||
}
|
||||
14
internal/renderer/renderer.go
Normal file
14
internal/renderer/renderer.go
Normal 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
620
internal/renderer/svg.go
Normal 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("<")
|
||||
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(`<?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
198
internal/renderer/text.go
Normal 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))
|
||||
}
|
||||
}
|
||||
9
internal/renderer/utils.go
Normal file
9
internal/renderer/utils.go
Normal 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
85
internal/service/chart.go
Normal 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("", 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
61
internal/types/chart.go
Normal 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
205
skill.md
Normal 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
143
taolun.md
Normal 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
|
||||
|
||||
---
|
||||
|
||||
#### 问:开始
|
||||
|
||||
**答:**
|
||||
开始构建项目。
|
||||
Reference in New Issue
Block a user