mirror of
https://github.com/sstent/aicyclingcoach-go.git
synced 2026-03-18 10:55:31 +00:00
sync
This commit is contained in:
@@ -4,9 +4,10 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/sstent/aicyclingcoach-go/fitness-tui/internal/types"
|
||||
"github.com/sstent/fitness-tui/internal/types"
|
||||
)
|
||||
|
||||
// Chart represents an ASCII chart component
|
||||
@@ -16,112 +17,209 @@ type Chart struct {
|
||||
Width int
|
||||
Height int
|
||||
Color lipgloss.Color
|
||||
downsampler *types.Downsampler
|
||||
Min, Max float64
|
||||
Downsampled []types.DownsampledPoint
|
||||
Unit string
|
||||
Mode string // "bar" or "sparkline"
|
||||
XLabels []string
|
||||
YMax float64
|
||||
}
|
||||
|
||||
// NewChart creates a new Chart instance
|
||||
func NewChart(data []float64, title string) *Chart {
|
||||
return &Chart{
|
||||
Data: data,
|
||||
Title: title,
|
||||
Width: 0, // Will be set based on terminal size
|
||||
Height: 10,
|
||||
Color: lipgloss.Color("39"), // Default blue
|
||||
downsampler: types.NewDownsampler(),
|
||||
// NewChart creates a new chart instance
|
||||
func NewChart(data []float64, title, unit string, width, height int, color lipgloss.Color) *Chart {
|
||||
c := &Chart{
|
||||
Data: data,
|
||||
Title: title,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Color: color,
|
||||
Unit: unit,
|
||||
Mode: "sparkline",
|
||||
}
|
||||
}
|
||||
|
||||
// WithSize sets the chart dimensions
|
||||
func (c *Chart) WithSize(width, height int) *Chart {
|
||||
c.Width = width
|
||||
c.Height = height
|
||||
if len(data) > 0 {
|
||||
// Use downsampled data for min/max to improve performance
|
||||
// Using empty timestamps array since we only need value downsampling
|
||||
downsampled := types.DownsampleLTTB(data, make([]time.Time, len(data)), width)
|
||||
c.Downsampled = downsampled
|
||||
values := make([]float64, len(downsampled))
|
||||
for i, point := range downsampled {
|
||||
values[i] = point.Value
|
||||
}
|
||||
c.Min, c.Max = minMax(values)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// WithColor sets the chart color
|
||||
func (c *Chart) WithColor(color lipgloss.Color) *Chart {
|
||||
c.Color = color
|
||||
// NewBarChart creates a bar chart with axis labels
|
||||
func NewBarChart(data []float64, title string, width, height int, color lipgloss.Color, xLabels []string, yMax float64) *Chart {
|
||||
c := NewChart(data, title, "", width, height, color)
|
||||
c.Mode = "bar"
|
||||
c.XLabels = xLabels
|
||||
c.YMax = yMax
|
||||
return c
|
||||
}
|
||||
|
||||
// View renders the chart
|
||||
func (c *Chart) View() string {
|
||||
if len(c.Data) == 0 {
|
||||
return fmt.Sprintf("%s\nNo data available", c.Title)
|
||||
return c.renderNoData()
|
||||
}
|
||||
|
||||
// Downsample data if needed
|
||||
processedData := c.downsampler.Process(c.Data, c.Width)
|
||||
if c.Width <= 10 || c.Height <= 4 {
|
||||
return c.renderTooSmall()
|
||||
}
|
||||
|
||||
// Normalize data to chart height
|
||||
min, max := minMax(processedData)
|
||||
normalized := normalize(processedData, min, max, c.Height-1)
|
||||
if c.Mode == "bar" && c.YMax == 0 {
|
||||
return c.renderNoData()
|
||||
}
|
||||
|
||||
// Build chart
|
||||
// Recalculate if dimensions changed
|
||||
if len(c.Downsampled) != c.Width {
|
||||
c.Downsampled = types.DownsampleLTTB(c.Data, make([]time.Time, len(c.Data)), c.Width)
|
||||
values := make([]float64, len(c.Downsampled))
|
||||
for i, point := range c.Downsampled {
|
||||
values[i] = point.Value
|
||||
}
|
||||
c.Min, c.Max = minMax(values)
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
MaxWidth(c.Width).
|
||||
Render(c.renderTitle() + "\n" + c.renderChart())
|
||||
}
|
||||
|
||||
func (c *Chart) renderTitle() string {
|
||||
return lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(c.Color).
|
||||
Render(c.Title)
|
||||
}
|
||||
|
||||
func (c *Chart) renderChart() string {
|
||||
if c.Max == c.Min && c.Mode != "bar" {
|
||||
return c.renderConstantData()
|
||||
}
|
||||
|
||||
if c.Mode == "bar" {
|
||||
return c.renderBarChart()
|
||||
}
|
||||
return c.renderSparkline()
|
||||
}
|
||||
|
||||
func (c *Chart) renderBarChart() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(c.Title + "\n")
|
||||
chartHeight := c.Height - 3 // Reserve space for title and labels
|
||||
|
||||
// Create Y-axis labels
|
||||
yLabels := createYAxisLabels(min, max, c.Height-1)
|
||||
// Calculate scaling factor
|
||||
maxValue := c.YMax
|
||||
if maxValue == 0 {
|
||||
maxValue = c.Max
|
||||
}
|
||||
scale := float64(chartHeight-1) / maxValue
|
||||
|
||||
for i := c.Height - 1; i >= 0; i-- {
|
||||
// Y-axis labels
|
||||
yIncrement := maxValue / float64(chartHeight-1)
|
||||
for i := chartHeight - 1; i >= 0; i-- {
|
||||
label := fmt.Sprintf("%3.0f │", maxValue-(yIncrement*float64(i)))
|
||||
sb.WriteString(label)
|
||||
if i == 0 {
|
||||
sb.WriteString("└") // Bottom-left corner
|
||||
} else if i == c.Height-1 {
|
||||
sb.WriteString("↑") // Top axis indicator
|
||||
} else {
|
||||
sb.WriteString("│") // Y-axis line
|
||||
sb.WriteString(" " + strings.Repeat("─", c.Width))
|
||||
break
|
||||
}
|
||||
|
||||
// Add Y-axis label
|
||||
if i < len(yLabels) {
|
||||
sb.WriteString(yLabels[i])
|
||||
} else {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
|
||||
// Add chart bars
|
||||
for j := 0; j < len(normalized); j++ {
|
||||
if i == 0 {
|
||||
sb.WriteString("─") // X-axis
|
||||
// Draw bars
|
||||
for _, point := range c.Downsampled {
|
||||
barHeight := int(math.Round(point.Value * scale))
|
||||
if barHeight >= i {
|
||||
sb.WriteString("#")
|
||||
} else {
|
||||
if normalized[j] >= float64(i) {
|
||||
sb.WriteString("█") // Full block
|
||||
} else {
|
||||
// Gradient blocks based on fractional part
|
||||
frac := normalized[j] - math.Floor(normalized[j])
|
||||
if normalized[j] >= float64(i-1) && frac > 0.75 {
|
||||
sb.WriteString("▇")
|
||||
} else if normalized[j] >= float64(i-1) && frac > 0.5 {
|
||||
sb.WriteString("▅")
|
||||
} else if normalized[j] >= float64(i-1) && frac > 0.25 {
|
||||
sb.WriteString("▃")
|
||||
} else if normalized[j] >= float64(i-1) && frac > 0 {
|
||||
sb.WriteString("▁")
|
||||
} else {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Add X-axis title
|
||||
sb.WriteString(" " + strings.Repeat(" ", len(yLabels[0])+1) + "→ Time\n")
|
||||
// X-axis labels
|
||||
sb.WriteString("\n ")
|
||||
for i, label := range c.XLabels {
|
||||
if i >= len(c.Downsampled) {
|
||||
break
|
||||
}
|
||||
if i == 0 {
|
||||
sb.WriteString(" ")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%-*s", c.Width/len(c.XLabels), label))
|
||||
}
|
||||
|
||||
// Apply color styling
|
||||
style := lipgloss.NewStyle().Foreground(c.Color)
|
||||
return style.Render(sb.String())
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (c *Chart) renderSparkline() string {
|
||||
var sb strings.Builder
|
||||
chartHeight := c.Height - 3 // Reserve rows for title and labels
|
||||
|
||||
minLabel := fmt.Sprintf("%.0f%s", c.Min, c.Unit)
|
||||
maxLabel := fmt.Sprintf("%.0f%s", c.Max, c.Unit)
|
||||
sb.WriteString(fmt.Sprintf("%5s ", maxLabel))
|
||||
|
||||
for i, point := range c.Downsampled {
|
||||
if i >= c.Width {
|
||||
break
|
||||
}
|
||||
|
||||
normalized := (point.Value - c.Min) / (c.Max - c.Min)
|
||||
barHeight := int(math.Round(normalized * float64(chartHeight-1)))
|
||||
sb.WriteString(c.renderBar(barHeight, chartHeight))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Add X axis with min label
|
||||
sb.WriteString(fmt.Sprintf("%5s ", minLabel))
|
||||
sb.WriteString(strings.Repeat("─", c.Width))
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (c *Chart) renderBar(height, maxHeight int) string {
|
||||
if height <= 0 {
|
||||
return " "
|
||||
}
|
||||
|
||||
// Use Unicode block characters for better resolution
|
||||
blocks := []string{" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
|
||||
index := int(float64(height) / float64(maxHeight) * 8)
|
||||
if index >= len(blocks) {
|
||||
index = len(blocks) - 1
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(c.Color).
|
||||
Render(blocks[index])
|
||||
}
|
||||
|
||||
func (c *Chart) renderNoData() string {
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("240")).
|
||||
Render(fmt.Sprintf("%s: No data", c.Title))
|
||||
}
|
||||
|
||||
func (c *Chart) renderTooSmall() string {
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("196")).
|
||||
Render(fmt.Sprintf("%s: Terminal too small", c.Title))
|
||||
}
|
||||
|
||||
func (c *Chart) renderConstantData() string {
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("214")).
|
||||
Render(fmt.Sprintf("%s: Constant value %.2f", c.Title, c.Data[0]))
|
||||
}
|
||||
|
||||
// minMax finds min and max values in a slice
|
||||
func minMax(data []float64) (min, max float64) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
min = data[0]
|
||||
max = data[0]
|
||||
min, max = data[0], data[0]
|
||||
for _, v := range data {
|
||||
if v < min {
|
||||
min = v
|
||||
@@ -132,34 +230,3 @@ func minMax(data []float64) (min, max float64) {
|
||||
}
|
||||
return min, max
|
||||
}
|
||||
|
||||
// normalize scales values to fit within chart height
|
||||
func normalize(data []float64, min, max float64, height int) []float64 {
|
||||
if max == min || height <= 0 {
|
||||
return make([]float64, len(data))
|
||||
}
|
||||
|
||||
scale := float64(height) / (max - min)
|
||||
normalized := make([]float64, len(data))
|
||||
for i, v := range data {
|
||||
normalized[i] = (v - min) * scale
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// createYAxisLabels creates labels for Y-axis
|
||||
func createYAxisLabels(min, max float64, height int) []string {
|
||||
labels := make([]string, height+1)
|
||||
step := (max - min) / float64(height)
|
||||
|
||||
for i := 0; i <= height; i++ {
|
||||
value := min + float64(i)*step
|
||||
label := fmt.Sprintf("%.0f", value)
|
||||
// Pad to consistent width (5 characters)
|
||||
if len(label) < 5 {
|
||||
label = strings.Repeat(" ", 5-len(label)) + label
|
||||
}
|
||||
labels[height-i] = label
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user