feat: 初始版本 - ttychart-mcp 终端图表 MCP 服务

- 支持三种图表: 折线图、柱状图、散点图
- MCP 协议支持 (stdio + HTTP)
- 完整的单元测试和集成测试
- Docker 支持
- Makefile 构建脚本
This commit is contained in:
2026-04-15 21:03:36 +08:00
commit d3e04c5d1a
15 changed files with 2038 additions and 0 deletions

488
tools/charts/line.go Normal file
View File

@@ -0,0 +1,488 @@
// Package charts 图表实现包
//
// 该包提供各种终端图表的纯 Go 实现
// 不依赖外部大型库,保持轻量级
//
// 作者: titor
// 创建日期: 2026-04-15
package charts
import (
"fmt"
"math"
"charm.land/lipgloss/v2"
)
// LineChart 折线图结构体
//
// 用于生成 ASCII 折线图
type LineChart struct {
// 图表宽度
Width int
// 图表高度
Height int
// Y轴数据
Data []float64
// 图表标题
Title string
// X轴标签
Labels []string
// 线条颜色
Color lipgloss.Style
}
// NewLineChart 创建新的折线图
//
// 参数:
// - width: 图表宽度0 表示自动)
// - height: 图表高度0 表示自动)
//
// 返回值:
// - *LineChart: 折线图实例
func NewLineChart(width, height int) *LineChart {
return &LineChart{
Width: width,
Height: height,
}
}
// SetData 设置数据
func (c *LineChart) SetData(data []float64) {
c.Data = data
}
// SetTitle 设置标题
func (c *LineChart) SetTitle(title string) {
c.Title = title
}
// SetLabels 设置标签
func (c *LineChart) SetLabels(labels []string) {
c.Labels = labels
}
// SetColor 设置颜色
func (c *LineChart) SetColor(style lipgloss.Style) {
c.Color = style
}
// Render 渲染图表
//
// 返回值:
// - string: 渲染后的 ASCII 图表
func (c *LineChart) Render() string {
if len(c.Data) == 0 {
return "错误: 没有数据"
}
// 计算合适的宽高
width := c.Width
height := c.Height
if width == 0 {
width = len(c.Data) * 3
if width < 30 {
width = 30
}
}
if height == 0 {
height = 10
}
// 计算数据范围
minVal, maxVal := c.findMinMax()
rangeVal := maxVal - minVal
if rangeVal == 0 {
rangeVal = 1
}
// 生成图表
var result string
// 添加标题
if c.Title != "" {
result += c.Title + "\n"
}
// Y轴标签宽度
labelWidth := 6
if maxVal >= 1000 {
labelWidth = 8
}
// 渲染每一行
for row := height - 1; row >= 0; row-- {
// 计算这一行对应的 value
value := minVal + (float64(row)/float64(height-1))*rangeVal
// 输出 Y轴标签
result += fmt.Sprintf("%*.*f ", -labelWidth, 1, value)
// 输出数据点
for i := 0; i < len(c.Data) && i < width; i++ {
pos := float64(i) / float64(width-1) * rangeVal
dataVal := c.Data[i] - minVal
// 计算字符
if pos <= dataVal && dataVal >= pos {
if row == height-1 && c.Data[i] >= maxVal {
result += "╭"
} else if row == 0 && c.Data[i] <= minVal {
result += "╰"
} else {
result += "│"
}
} else {
result += " "
}
}
result += "\n"
}
// 输出 X轴
if len(c.Data) > 0 {
result += fmt.Sprintf("%*s", labelWidth, "")
for i := 0; i < len(c.Data) && i < width; i++ {
result += "─"
}
result += "\n"
}
// 输出标签
if len(c.Labels) > 0 {
result += fmt.Sprintf("%*s", labelWidth, "")
for i, label := range c.Labels {
if i < width {
result += label
}
}
result += "\n"
}
return result
}
// findMinMax 查找数据的最小最大值
//
// 返回值:
// - min: 最小值
// - max: 最大值
func (c *LineChart) findMinMax() (float64, float64) {
if len(c.Data) == 0 {
return 0, 1
}
minVal := c.Data[0]
maxVal := c.Data[0]
for _, v := range c.Data {
if v < minVal {
minVal = v
}
if v > maxVal {
maxVal = v
}
}
// 添加一些边距
margin := (maxVal - minVal) * 0.1
if margin == 0 {
margin = 1
}
return minVal - margin, maxVal + margin
}
// BarDataItem 柱状图数据项
type BarDataItem struct {
// 标签
Label string
// 数值
Value float64
}
// BarChart 柱状图结构体
type BarChart struct {
Width int
Height int
Horizontal bool
Data []BarDataItem
Title string
Color lipgloss.Style
}
// NewBarChart 创建新的柱状图
func NewBarChart(width, height int, horizontal bool) *BarChart {
return &BarChart{
Width: width,
Height: height,
Horizontal: horizontal,
}
}
// SetData 设置数据
func (c *BarChart) SetData(data []BarDataItem) {
c.Data = data
}
// SetTitle 设置标题
func (c *BarChart) SetTitle(title string) {
c.Title = title
}
// SetColor 设置颜色
func (c *BarChart) SetColor(style lipgloss.Style) {
c.Color = style
}
// Render 渲染柱状图
func (c *BarChart) Render() string {
if len(c.Data) == 0 {
return "错误: 没有数据"
}
// 计算最大值
maxVal := float64(0)
for _, item := range c.Data {
if item.Value > maxVal {
maxVal = item.Value
}
}
if maxVal == 0 {
maxVal = 1
}
width := c.Width
if width == 0 {
width = 60
}
height := c.Height
if height == 0 {
height = len(c.Data)
if height < 5 {
height = 5
}
}
var result string
// 标题
if c.Title != "" {
result += c.Title + "\n"
}
// 渲染
barChar := "█"
if c.Horizontal {
// 水平柱状图
for i, item := range c.Data {
if i >= height {
break
}
barLen := int(item.Value / maxVal * float64(width-len(item.Label)-2))
if barLen < 1 {
barLen = 1
}
result += fmt.Sprintf("%-*s ", len(item.Label), item.Label)
for j := 0; j < barLen; j++ {
result += barChar
}
result += fmt.Sprintf(" %.2f\n", item.Value)
}
} else {
// 垂直柱状图
labelWidth := 8
for row := height - 1; row >= 0; row-- {
for i, item := range c.Data {
if i >= width {
break
}
barHeight := int(item.Value / maxVal * float64(height))
if row < barHeight {
result += barChar
} else {
result += " "
}
}
result += "\n"
}
// 标签
for _, item := range c.Data {
label := item.Label
if len(label) > labelWidth {
label = label[:labelWidth]
}
result += fmt.Sprintf("%-*s ", labelWidth, label)
}
result += "\n"
}
return result
}
// ScatterDataItem 散点图数据项
type ScatterDataItem struct {
X float64
Y float64
}
// ScatterChart 散点图结构体
type ScatterChart struct {
Width int
Height int
Data []ScatterDataItem
Title string
XLabel string
YLabel string
Color lipgloss.Style
}
// NewScatterChart 创建新的散点图
func NewScatterChart(width, height int) *ScatterChart {
return &ScatterChart{
Width: width,
Height: height,
}
}
// SetData 设置数据
func (c *ScatterChart) SetData(data []ScatterDataItem) {
c.Data = data
}
// SetTitle 设置标题
func (c *ScatterChart) SetTitle(title string) {
c.Title = title
}
// SetXLabel 设置 X 轴标签
func (c *ScatterChart) SetXLabel(label string) {
c.XLabel = label
}
// SetYLabel 设置 Y 轴标签
func (c *ScatterChart) SetYLabel(label string) {
c.YLabel = label
}
// SetColor 设置颜色
func (c *ScatterChart) SetColor(style lipgloss.Style) {
c.Color = style
}
// Render 渲染散点图
func (c *ScatterChart) Render() string {
if len(c.Data) == 0 {
return "错误: 没有数据"
}
width := c.Width
height := c.Height
if width == 0 {
width = 50
}
if height == 0 {
height = 20
}
// 找数据范围
minX, maxX := c.findMinMaxX()
minY, maxY := c.findMinMaxY()
// 添加边距
marginX := (maxX - minX) * 0.1
marginY := (maxY - minY) * 0.1
if marginX == 0 {
marginX = 1
}
if marginY == 0 {
marginY = 1
}
minX -= marginX
maxX += marginX
minY -= marginY
maxY += marginY
var result string
// 标题
if c.Title != "" {
result += c.Title + "\n"
}
// 创建点阵
grid := make([][]rune, height)
for i := range grid {
grid[i] = make([]rune, width)
for j := range grid[i] {
grid[i][j] = ' '
}
}
// 绘制数据点
for _, point := range c.Data {
// 映射到网格
x := int((point.X - minX) / (maxX - minX) * float64(width-1))
y := int((point.Y - minY) / (maxY - minY) * float64(height-1))
y = height - 1 - y // 反转 Y 轴
// 边界检查
if x >= 0 && x < width && y >= 0 && y < height {
grid[y][x] = '●'
}
}
// 输出图表
for _, row := range grid {
result += string(row) + "\n"
}
// X轴标签
if c.XLabel != "" {
result += c.XLabel + "\n"
}
return result
}
// findMinMaxX 查找 X 数据的最小最大值
func (c *ScatterChart) findMinMaxX() (float64, float64) {
if len(c.Data) == 0 {
return 0, 1
}
minVal := c.Data[0].X
maxVal := c.Data[0].X
for _, v := range c.Data {
if v.X < minVal {
minVal = v.X
}
if v.X > maxVal {
maxVal = v.X
}
}
if math.IsInf(minVal, 0) || math.IsInf(maxVal, 0) {
return 0, 1
}
return minVal, maxVal
}
// findMinMaxY 查找 Y 数据的最小最大值
func (c *ScatterChart) findMinMaxY() (float64, float64) {
if len(c.Data) == 0 {
return 0, 1
}
minVal := c.Data[0].Y
maxVal := c.Data[0].Y
for _, v := range c.Data {
if v.Y < minVal {
minVal = v.Y
}
if v.Y > maxVal {
maxVal = v.Y
}
}
if math.IsInf(minVal, 0) || math.IsInf(maxVal, 0) {
return 0, 1
}
return minVal, maxVal
}

284
tools/charts/line_test.go Normal file
View File

@@ -0,0 +1,284 @@
// Package charts 图表单元测试
//
// 对所有图表类型进行单元测试
//
// 作者: titor
// 创建日期: 2026-04-15
package charts
import (
"testing"
)
// TestLineChart_NewLineChart 测试创建折线图
func TestLineChart_NewLineChart(t *testing.T) {
// 测试正常创建
chart := NewLineChart(50, 10)
if chart == nil {
t.Error("NewLineChart 返回 nil")
}
// 测试零值创建
chart0 := NewLineChart(0, 0)
if chart0 == nil {
t.Error("NewLineChart(0,0) 返回 nil")
}
}
// TestLineChart_SetData 测试设置数据
func TestLineChart_SetData(t *testing.T) {
chart := NewLineChart(0, 0)
// 测试设置数据
data := []float64{1.0, 2.0, 3.0, 4.0, 5.0}
chart.SetData(data)
// 验证数据设置正确
if len(chart.Data) != len(data) {
t.Errorf("数据长度不匹配: got %d, want %d", len(chart.Data), len(data))
}
// 测试空数据
chart.SetData(nil)
if chart.Data != nil {
t.Error("设置 nil 后数据不为 nil")
}
}
// TestLineChart_SetTitle 测试设置标题
func TestLineChart_SetTitle(t *testing.T) {
chart := NewLineChart(0, 0)
// 测试设置标题
title := "测试折线图"
chart.SetTitle(title)
// 验证标题设置正确
if chart.Title != title {
t.Errorf("标题不匹配: got %s, want %s", chart.Title, title)
}
}
// TestLineChart_SetLabels 测试设置标签
func TestLineChart_SetLabels(t *testing.T) {
chart := NewLineChart(0, 0)
// 测试设置标签
labels := []string{"周一", "周二", "周三", "周四", "周五"}
chart.SetLabels(labels)
// 验证标签设置正确
if len(chart.Labels) != len(labels) {
t.Errorf("标签长度不匹配: got %d, want %d", len(chart.Labels), len(labels))
}
}
// TestLineChart_Render 测试渲染折线图
func TestLineChart_Render(t *testing.T) {
// 测试空数据渲染
chart := NewLineChart(30, 10)
result := chart.Render()
if result == "" {
t.Error("空数据渲染结果为空")
}
// 测试有数据渲染
chart.SetData([]float64{10, 20, 15, 25, 30})
result = chart.Render()
if result == "" {
t.Error("有数据渲染结果为空")
}
// 测试有标题渲染
chart.SetTitle("温度趋势")
result = chart.Render()
if result == "" {
t.Error("有标题渲染结果为空")
}
// 验证包含标题
if len(result) < len("温度趋势") {
t.Error("渲染结果长度异常")
}
}
// TestLineChart_findMinMax 测试查找最小最大值
func TestLineChart_findMinMax(t *testing.T) {
chart := NewLineChart(0, 0)
// 测试空数据
minVal, maxVal := chart.findMinMax()
if minVal != 0 || maxVal != 1 {
t.Errorf("空数据范围错误: min=%f, max=%f", minVal, maxVal)
}
// 测试单点数据
chart.SetData([]float64{5.0})
minVal, maxVal = chart.findMinMax()
if minVal == maxVal {
t.Error("单点数据 min 和 max 相同")
}
// 测试多点数据
chart.SetData([]float64{1.0, 5.0, 3.0, 8.0, 2.0})
minVal, maxVal = chart.findMinMax()
if minVal >= maxVal {
t.Errorf("数据范围错误: min=%f, max=%f", minVal, maxVal)
}
}
// TestBarChart_NewBarChart 测试创建柱状图
func TestBarChart_NewBarChart(t *testing.T) {
// 测试垂直柱状图
barV := NewBarChart(60, 10, false)
if barV == nil {
t.Error("NewBarChart 垂直返回 nil")
}
// 测试水平柱状图
barH := NewBarChart(60, 10, true)
if barH == nil {
t.Error("NewBarChart 水平返回 nil")
}
}
// TestBarChart_SetData 测试设置柱状图数据
func TestBarChart_SetData(t *testing.T) {
chart := NewBarChart(60, 10, false)
// 测试设置数据
data := []BarDataItem{
{Label: "苹果", Value: 100},
{Label: "香蕉", Value: 80},
{Label: "橙子", Value: 120},
}
chart.SetData(data)
// 验证数据设置正确
if len(chart.Data) != len(data) {
t.Errorf("数据长度不匹配: got %d, want %d", len(chart.Data), len(data))
}
// 测试空数据
chart.SetData(nil)
if chart.Data != nil {
t.Error("设置 nil 后数据不为 nil")
}
}
// TestBarChart_Render 测试渲染柱状图
func TestBarChart_Render(t *testing.T) {
// 测试空数据渲染
chart := NewBarChart(60, 10, false)
result := chart.Render()
if result == "" {
t.Error("空数据渲染结果为空")
}
// 测试有数据渲染
chart.SetData([]BarDataItem{
{Label: "苹果", Value: 100},
{Label: "香蕉", Value: 80},
})
result = chart.Render()
if result == "" {
t.Error("有数据渲染结果为空")
}
// 测试有标题渲染
chart.SetTitle("水果销量")
result = chart.Render()
if result == "" {
t.Error("有标题渲染结果为空")
}
// 测试水平柱状图
chartH := NewBarChart(60, 10, true)
chartH.SetData([]BarDataItem{
{Label: "A", Value: 50},
{Label: "B", Value: 30},
})
result = chartH.Render()
if result == "" {
t.Error("水平柱状图渲染结果为空")
}
}
// TestScatterChart_NewScatterChart 测试创建散点图
func TestScatterChart_NewScatterChart(t *testing.T) {
chart := NewScatterChart(50, 20)
if chart == nil {
t.Error("NewScatterChart 返回 nil")
}
}
// TestScatterChart_SetData 测试设置散点图数据
func TestScatterChart_SetData(t *testing.T) {
chart := NewScatterChart(50, 20)
// 测试设置数据
data := []ScatterDataItem{
{X: 1.0, Y: 5.0},
{X: 2.0, Y: 8.0},
{X: 3.0, Y: 3.0},
}
chart.SetData(data)
// 验证数据设置正确
if len(chart.Data) != len(data) {
t.Errorf("数据长度不匹配: got %d, want %d", len(chart.Data), len(data))
}
}
// TestScatterChart_Render 测试渲染散点图
func TestScatterChart_Render(t *testing.T) {
// 测试空数据渲染
chart := NewScatterChart(50, 20)
result := chart.Render()
if result == "" {
t.Error("空数据渲染结果为空")
}
// 测试有数据渲染
chart.SetData([]ScatterDataItem{
{X: 1.0, Y: 5.0},
{X: 2.0, Y: 8.0},
{X: 3.0, Y: 3.0},
})
result = chart.Render()
if result == "" {
t.Error("有数据渲染结果为空")
}
// 测试有标题渲染
chart.SetTitle("成绩分布")
result = chart.Render()
if result == "" {
t.Error("有标题渲染结果为空")
}
}
// TestScatterChart_findMinMax 测试散点图最小最大值
func TestScatterChart_findMinMax(t *testing.T) {
chart := NewScatterChart(50, 20)
// 测试空数据
minX, maxX := chart.findMinMaxX()
minY, maxY := chart.findMinMaxY()
if minX != 0 || maxX != 1 || minY != 0 || maxY != 1 {
t.Errorf("空数据范围错误")
}
// 测试有数据
chart.SetData([]ScatterDataItem{
{X: 1.0, Y: 5.0},
{X: 2.0, Y: 8.0},
{X: 3.0, Y: 3.0},
})
minX, maxX = chart.findMinMaxX()
minY, maxY = chart.findMinMaxY()
if minX >= maxX || minY >= maxY {
t.Error("数<><E695B0><EFBFBD>范围错误")
}
}