489 lines
8.5 KiB
Go
489 lines
8.5 KiB
Go
|
|
// 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
|
|||
|
|
}
|