Commit acd70ae1 authored by liming6's avatar liming6
Browse files

feature 添加折线图功能

parent fbb93034
......@@ -20,3 +20,52 @@ func main() {
}
os.Exit(0)
}
// type TickMsg time.Time
// type model struct {
// c *tui.MyTimeChart
// }
// func (m *model) Init() tea.Cmd {
// m.c = tui.New(100, 20, 0, 100, map[string]lipgloss.Color{"default": lipgloss.Color("#ff2222ff"), "other": lipgloss.Color("#0037ffff")})
// return tea.Batch(m.c.Init(), tea.Tick(time.Second, func(t time.Time) tea.Msg {
// return TickMsg(t)
// }))
// }
// func (m *model) Update(inputMsg tea.Msg) (tea.Model, tea.Cmd) {
// switch msg := inputMsg.(type) {
// case TickMsg:
// randPoint1 := rand.Float64() * 100.0
// randPoint2 := rand.Float64() * 100.0
// now := time.Now()
// timePoint1 := tchart.TimePoint{Time: now, Value: randPoint1}
// timePoint2 := tchart.TimePoint{Time: now, Value: randPoint2}
// tmsg := tui.MyTimeChartMsg{Points: map[string][]tchart.TimePoint{"default": {timePoint1}, "other": {timePoint2}}}
// mm, cmd := m.c.Update(tmsg)
// m.c = mm.(*tui.MyTimeChart)
// return m, tea.Batch(cmd, tea.Tick(time.Second, func(t time.Time) tea.Msg {
// return TickMsg(t)
// }))
// case tea.KeyMsg:
// switch msg.String() {
// case "q", "ctrl+c":
// return m, tea.Quit
// }
// }
// return m, nil
// }
// func (m *model) View() string {
// sty := lipgloss.NewStyle().Border(lipgloss.NormalBorder())
// st := sty.SetString(m.c.View()).String()
// return st + "\n"
// }
// func main() {
// _, err := tea.NewProgram(&model{}).Run()
// if err != nil {
// log.Fatal(err)
// }
// }
package tchart
import (
"time"
"github.com/NimbleMarkets/ntcharts/canvas/runes"
"github.com/NimbleMarkets/ntcharts/linechart"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
)
// Option is used to set options when initializing a timeserieslinechart. Example:
//
// tslc := New(width, height, minX, maxX, minY, maxY, WithStyles(someLineStyle, someLipglossStyle))
type Option func(*Model)
// WithLineChart sets internal linechart to given linechart.
func WithLineChart(lc *linechart.Model) Option {
return func(m *Model) {
m.Model = *lc
}
}
// WithUpdateHandler sets the UpdateHandler used
// when processing bubbletea Msg events in Update().
func WithUpdateHandler(h linechart.UpdateHandler) Option {
return func(m *Model) {
m.UpdateHandler = h
}
}
// WithZoneManager sets the bubblezone Manager used
// when processing bubbletea Msg mouse events in Update().
func WithZoneManager(zm *zone.Manager) Option {
return func(m *Model) {
m.SetZoneManager(zm)
}
}
// WithXLabelFormatter sets the default X label formatter for displaying X values as strings.
func WithXLabelFormatter(fmter linechart.LabelFormatter) Option {
return func(m *Model) {
m.XLabelFormatter = fmter
}
}
// WithYLabelFormatter sets the default Y label formatter for displaying Y values as strings.
func WithYLabelFormatter(fmter linechart.LabelFormatter) Option {
return func(m *Model) {
m.YLabelFormatter = fmter
}
}
// WithAxesStyles sets the axes line and line label styles.
func WithAxesStyles(as lipgloss.Style, ls lipgloss.Style) Option {
return func(m *Model) {
m.AxisStyle = as
m.LabelStyle = ls
}
}
// WithXYSteps sets the number of steps when drawing X and Y axes values.
// If X steps 0, then X axis will be hidden.
// If Y steps 0, then Y axis will be hidden.
func WithXYSteps(x, y int) Option {
return func(m *Model) {
m.SetXStep(x)
m.SetYStep(y)
}
}
// WithYRange sets expected and displayed
// minimum and maximum Y value range.
func WithYRange(min, max float64) Option {
return func(m *Model) {
m.SetYRange(min, max)
m.SetViewYRange(min, max)
}
}
// WithTimeRange sets expected and displayed minimun and maximum
// time values range on the X axis.
func WithTimeRange(min, max time.Time) Option {
return func(m *Model) {
m.SetTimeRange(min, max)
m.SetViewTimeRange(min, max)
}
}
// WithLineStyle sets the default line style of data sets.
func WithLineStyle(ls runes.LineStyle) Option {
return func(m *Model) {
m.SetLineStyle(ls)
}
}
// WithDataSetLineStyle sets the line style of the data set given by name.
func WithDataSetLineStyle(n string, ls runes.LineStyle) Option {
return func(m *Model) {
m.SetDataSetLineStyle(n, ls)
}
}
// WithStyle sets the default lipgloss style of data sets.
func WithStyle(s lipgloss.Style) Option {
return func(m *Model) {
m.SetStyle(s)
}
}
// WithDataSetStyle sets the lipgloss style of the data set given by name.
func WithDataSetStyle(n string, s lipgloss.Style) Option {
return func(m *Model) {
m.SetDataSetStyle(n, s)
}
}
// WithTimeSeries adds []TimePoint values to the default data set.
func WithTimeSeries(p []TimePoint) Option {
return func(m *Model) {
for _, v := range p {
m.Push(v)
}
}
}
// WithDataSetTimeSeries adds []TimePoint data points to the data set given by name.
func WithDataSetTimeSeries(n string, p []TimePoint) Option {
return func(m *Model) {
for _, v := range p {
m.PushDataSet(n, v)
}
}
}
package tchart
import (
"fmt"
"math"
"sort"
"time"
"github.com/NimbleMarkets/ntcharts/canvas"
"github.com/NimbleMarkets/ntcharts/canvas/buffer"
"github.com/NimbleMarkets/ntcharts/canvas/graph"
"github.com/NimbleMarkets/ntcharts/canvas/runes"
"github.com/NimbleMarkets/ntcharts/linechart"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const DefaultDataSetName = "default"
func DateTimeLabelFormatter() linechart.LabelFormatter {
var yearLabel string
return func(i int, v float64) string {
if i == 0 { // reset year labeling if redisplaying values
yearLabel = ""
}
t := time.Unix(int64(v), 0).UTC()
monthDay := t.Format("01/02")
year := t.Format("'06")
if yearLabel != year { // apply year label if first time seeing year
yearLabel = year
return fmt.Sprintf("%s %s", yearLabel, monthDay)
} else {
return monthDay
}
}
}
func HourTimeLabelFormatter() linechart.LabelFormatter {
return func(i int, v float64) string {
t := time.Unix(int64(v), 0).UTC()
return t.Format("15:04:05")
}
}
type TimePoint struct {
Time time.Time
Value float64
}
// cAverage tracks cumulative average
type cAverage struct {
Avg float64
Count float64
}
// Add adds a float64 to current cumulative average
func (a *cAverage) Add(f float64) float64 {
a.Count += 1
a.Avg += (f - a.Avg) / a.Count
return a.Avg
}
type dataSet struct {
LineStyle runes.LineStyle // type of line runes to draw
Style lipgloss.Style
// stores TimePoints as FloatPoint64{X:time.Time, Y: value}
// time.Time will be converted to seconds since epoch.
// both time and value will be scaled to fit the graphing area
tBuf *buffer.Float64PointScaleBuffer
}
// Model contains state of a timeserieslinechart with an embedded linechart.Model
// The X axis contains time.Time values and the Y axis contains float64 values.
// A data set consists of a sequence TimePoints in chronological order.
// If multiple TimePoints map to the same column, then average value the time points
// will be used as the Y value of the column.
// The X axis contains a time range and the Y axis contains a numeric value range.
// Uses linechart Model UpdateHandler() for processing keyboard and mouse messages.
type Model struct {
linechart.Model
dLineStyle runes.LineStyle // default data set LineStyletype
dStyle lipgloss.Style // default data set Style
dSets map[string]*dataSet // maps names to data sets
}
// New returns a timeserieslinechart Model initialized from
// width, height, Y value range and various options.
// By default, the chart will set time.Now() as the minimum time,
// enable auto set X and Y value ranges,
// and only allow moving viewport on X axis.
func New(w, h int, opts ...Option) Model {
min := time.Now()
max := min.Add(time.Second)
m := Model{
Model: linechart.New(w, h, float64(min.Unix()), float64(max.Unix()), 0, 1,
linechart.WithXYSteps(4, 2),
linechart.WithXLabelFormatter(DateTimeLabelFormatter()),
linechart.WithAutoXYRange(), // automatically adjust value ranges
linechart.WithUpdateHandler(DateUpdateHandler(1))), // only scroll on X axis, increments by 1 day
dLineStyle: runes.ArcLineStyle,
dStyle: lipgloss.NewStyle(),
dSets: make(map[string]*dataSet),
}
for _, opt := range opts {
opt(&m)
}
m.UpdateGraphSizes()
m.rescaleData()
if _, ok := m.dSets[DefaultDataSetName]; !ok {
m.dSets[DefaultDataSetName] = m.newDataSet()
}
return m
}
// newDataSet returns a new initialize *dataSet.
func (m *Model) newDataSet() *dataSet {
xs := float64(m.GraphWidth()) / (m.ViewMaxX() - m.ViewMinX()) // x scale factor
ys := float64(m.Origin().Y) / (m.ViewMaxY() - m.ViewMinY()) // y scale factor
offset := canvas.Float64Point{X: m.ViewMinX(), Y: m.ViewMinY()}
scale := canvas.Float64Point{X: xs, Y: ys}
return &dataSet{
LineStyle: m.dLineStyle,
Style: m.dStyle,
tBuf: buffer.NewFloat64PointScaleBuffer(offset, scale),
}
}
// rescaleData will reinitialize time chunks and
// map time points into graph columns for display
func (m *Model) rescaleData() {
// rescale time points buffer
xs := float64(m.GraphWidth()) / (m.ViewMaxX() - m.ViewMinX()) // x scale factor
ys := float64(m.Origin().Y) / (m.ViewMaxY() - m.ViewMinY()) // y scale factor
offset := canvas.Float64Point{X: m.ViewMinX(), Y: m.ViewMinY()}
scale := canvas.Float64Point{X: xs, Y: ys}
for _, ds := range m.dSets {
if ds.tBuf.Offset() != offset {
ds.tBuf.SetOffset(offset)
}
if ds.tBuf.Scale() != scale {
ds.tBuf.SetScale(scale)
}
}
}
// ClearAllData will reset stored data values in all data sets.
func (m *Model) ClearAllData() {
for _, ds := range m.dSets {
ds.tBuf.Clear()
}
m.dSets[DefaultDataSetName] = m.newDataSet()
}
// ClearDataSet will erase stored data set given by name string.
func (m *Model) ClearDataSet(n string) {
if ds, ok := m.dSets[n]; ok {
ds.tBuf.Clear()
}
}
// SetTimeRange updates the minimum and maximum expected time values.
// Existing data will be rescaled.
func (m *Model) SetTimeRange(min, max time.Time) {
m.Model.SetXRange(float64(min.Unix()), float64(max.Unix()))
m.rescaleData()
}
// SetYRange updates the minimum and maximum expected Y values.
// Existing data will be rescaled.
func (m *Model) SetYRange(min, max float64) {
m.Model.SetYRange(min, max)
m.rescaleData()
}
// SetViewTimeRange updates the displayed minimum and maximum time values.
// Existing data will be rescaled.
func (m *Model) SetViewTimeRange(min, max time.Time) {
m.Model.SetViewXRange(float64(min.Unix()), float64(max.Unix()))
m.rescaleData()
}
// SetViewYRange updates the displayed minimum and maximum Y values.
// Existing data will be rescaled.
func (m *Model) SetViewYRange(min, max float64) {
m.Model.SetViewYRange(min, max)
m.rescaleData()
}
// SetViewTimeAndYRange updates the displayed minimum and maximum time and Y values.
// Existing data will be rescaled.
func (m *Model) SetViewTimeAndYRange(minX, maxX time.Time, minY, maxY float64) {
m.Model.SetViewXRange(float64(minX.Unix()), float64(maxX.Unix()))
m.Model.SetViewYRange(minY, maxY)
m.rescaleData()
}
// Resize will change timeserieslinechart display width and height.
// Existing data will be rescaled.
func (m *Model) Resize(w, h int) {
m.Model.Resize(w, h)
m.rescaleData()
}
// SetLineStyle will set the default line styles of data sets.
func (m *Model) SetLineStyle(ls runes.LineStyle) {
m.dLineStyle = ls
m.SetDataSetLineStyle(DefaultDataSetName, ls)
}
// SetStyle will set the default lipgloss styles of data sets.
func (m *Model) SetStyle(s lipgloss.Style) {
m.dStyle = s
m.SetDataSetStyle(DefaultDataSetName, s)
}
// SetDataSetLineStyle will set the line style of the given data set by name string.
func (m *Model) SetDataSetLineStyle(n string, ls runes.LineStyle) {
if _, ok := m.dSets[n]; !ok {
m.dSets[n] = m.newDataSet()
}
ds := m.dSets[n]
ds.LineStyle = ls
}
// SetDataSetStyle will set the lipgloss style of the given data set by name string.
func (m *Model) SetDataSetStyle(n string, s lipgloss.Style) {
if _, ok := m.dSets[n]; !ok {
m.dSets[n] = m.newDataSet()
}
ds := m.dSets[n]
ds.Style = s
}
// Push will push a TimePoint data value to the default data set
// to be displayed with Draw.
func (m *Model) Push(t TimePoint) {
m.PushDataSet(DefaultDataSetName, t)
}
// Push will push a TimePoint data value to a data set
// to be displayed with Draw. Using given data set by name string.
func (m *Model) PushDataSet(n string, t TimePoint) {
f := canvas.Float64Point{X: float64(t.Time.Unix()), Y: t.Value}
// auto adjust x and y ranges if enabled
if m.AutoAdjustRange(f) {
m.UpdateGraphSizes()
m.rescaleData()
}
if _, ok := m.dSets[n]; !ok {
m.dSets[n] = m.newDataSet()
}
ds := m.dSets[n]
ds.tBuf.Push(f)
}
func (m *Model) SetDataSet(n string, t []TimePoint) {
f := make([]canvas.Float64Point, len(t))
for k, v := range t {
ff := canvas.Float64Point{X: float64(v.Time.Unix()), Y: v.Value}
if m.AutoAdjustRange(ff) {
m.UpdateGraphSizes()
m.rescaleData()
}
f[k] = ff
}
if _, ok := m.dSets[n]; !ok {
m.dSets[n] = m.newDataSet()
}
ds := m.dSets[n]
ds.tBuf.SetData(f)
}
// Draw will draw lines runes displayed from left to right
// of the graphing area of the canvas. Uses default data set.
func (m *Model) Draw() {
m.DrawDataSets([]string{DefaultDataSetName})
}
// DrawAll will draw lines runes for all data sets
// from left to right of the graphing area of the canvas.
func (m *Model) DrawAll() {
names := make([]string, 0, len(m.dSets))
for n, ds := range m.dSets {
if ds.tBuf.Length() > 0 {
names = append(names, n)
}
}
sort.Strings(names)
m.DrawDataSets(names)
}
// DrawDataSets will draw lines runes from left to right
// of the graphing area of the canvas for each data set given
// by name strings.
func (m *Model) DrawDataSets(names []string) {
if len(names) == 0 {
return
}
m.Clear()
m.DrawXYAxisAndLabel()
for _, n := range names {
if ds, ok := m.dSets[n]; ok {
dataPoints := ds.tBuf.ReadAll()
dataLen := len(dataPoints)
if dataLen == 0 {
return
}
// get sequence of line values for graphing
seqY := m.getLineSequence(dataPoints)
// convert to canvas coordinates and avoid drawing below X axis
yCoords := canvas.CanvasYCoordinates(m.Origin().Y, seqY)
if m.XStep() > 0 {
for i, v := range yCoords {
if v > m.Origin().Y {
yCoords[i] = m.Origin().Y
}
}
}
startX := m.Canvas.Width() - len(yCoords)
graph.DrawLineSequence(&m.Canvas,
(startX == m.Origin().X),
startX,
yCoords,
ds.LineStyle,
ds.Style)
}
}
}
// DrawBraille will draw braille runes displayed from left to right
// of the graphing area of the canvas. Uses default data set.
func (m *Model) DrawBraille() {
m.DrawBrailleDataSets([]string{DefaultDataSetName})
}
// DrawBrailleAll will draw braille runes for all data sets
// from left to right of the graphing area of the canvas.
func (m *Model) DrawBrailleAll() {
names := make([]string, 0, len(m.dSets))
for n, ds := range m.dSets {
if ds.tBuf.Length() > 0 {
names = append(names, n)
}
}
sort.Strings(names)
m.DrawBrailleDataSets(names)
}
// DrawBraille will draw braille runes displayed from left to right
// of the graphing area of the canvas.
// Requires four data sets containing candlestick data open, high, low, close values.
// Assumes that all data sets have the same number of TimePoints and
// the TimePoint at the same index of each data set has the same Time value.
func (m *Model) DrawCandle(openName, highName, lowName, closeName string, bullStyle, bearStyle lipgloss.Style) {
if len(openName) == 0 || len(highName) == 0 || len(lowName) == 0 || len(closeName) == 0 {
return
}
if _, ok := m.dSets[openName]; !ok {
return
}
if _, ok := m.dSets[highName]; !ok {
return
}
if _, ok := m.dSets[lowName]; !ok {
return
}
if _, ok := m.dSets[closeName]; !ok {
return
}
// only draws up to the number of candles of the data sets with the lowest
// amount of data values if length if data is not the same across all data sets
oData := m.dSets[openName].tBuf.ReadAll()
hData := m.dSets[highName].tBuf.ReadAll()
lData := m.dSets[lowName].tBuf.ReadAll()
cData := m.dSets[closeName].tBuf.ReadAll()
limit := len(oData)
if len(hData) < limit {
limit = len(hData)
}
if len(lData) < limit {
limit = len(lData)
}
if len(cData) < limit {
limit = len(cData)
}
m.Clear()
m.DrawXYAxisAndLabel()
for i := 0; i < limit; i++ {
// assuming all time values are the same, can just any of the values to check
// if data point is outside of the current graph view to ignore
if oData[i].X < 0 {
continue
}
var s lipgloss.Style
var bl, bh float64
if oData[i].Y > cData[i].Y { // check if bearish or bullish candle
s = bearStyle
bl = cData[i].Y
bh = oData[i].Y
} else {
s = bullStyle
bh = cData[i].Y
bl = oData[i].Y
}
drawX := int(oData[i].X) + m.Origin().X
if m.YStep() > 0 {
drawX += 1
}
graph.DrawCandlestickBottomToTop(&m.Canvas,
canvas.Point{X: drawX, Y: m.Origin().Y - 1},
lData[i].Y, bl, bh, hData[i].Y, s)
}
}
// DrawBrailleDataSets will draw braille runes from left to right
// of the graphing area of the canvas for each data set given
// by name strings.
func (m *Model) DrawBrailleDataSets(names []string) {
if len(names) == 0 {
return
}
m.Clear()
m.DrawXYAxisAndLabel()
for _, n := range names {
if ds, ok := m.dSets[n]; ok {
dataPoints := ds.tBuf.ReadAll()
dataLen := len(dataPoints)
if dataLen == 0 {
return
}
// draw lines from each point to the next point
bGrid := graph.NewBrailleGrid(m.GraphWidth(), m.GraphHeight(),
0, float64(m.GraphWidth()), // X values already scaled to graph
0, float64(m.GraphHeight())) // Y values already scaled to graph
for i := 0; i < dataLen; i++ {
j := i + 1
if j >= dataLen {
j = i
}
p1 := dataPoints[i]
p2 := dataPoints[j]
// ignore points that will not be displayed
bothBeforeMin := (p1.X < 0 && p2.X < 0)
bothAfterMax := (p1.X > float64(m.GraphWidth()) && p2.X > float64(m.GraphWidth()))
if bothBeforeMin || bothAfterMax {
continue
}
// get braille grid points from two Float64Point data points
gp1 := bGrid.GridPoint(p1)
gp2 := bGrid.GridPoint(p2)
// set all points in the braille grid
// between two points that approximates a line
points := graph.GetLinePoints(gp1, gp2)
for _, p := range points {
bGrid.Set(p)
}
}
// get all rune patterns for braille grid
// and draw them on to the canvas
startX := 0
if m.YStep() > 0 {
startX = m.Origin().X + 1
}
patterns := bGrid.BraillePatterns()
graph.DrawBraillePatterns(&m.Canvas,
canvas.Point{X: startX, Y: 0}, patterns, ds.Style)
}
}
}
// Set column background style to given lipgloss.Style background
// corresponding to timestamp at given time.Time.
func (m *Model) SetColumnBackgroundStyle(ts time.Time, s lipgloss.Style) {
f := canvas.Float64Point{X: float64(ts.Unix()), Y: 0}
if f.X < m.ViewMinX() || f.X > m.ViewMaxX() {
return
}
// use default dataset to scale the given time to the canvas (any dataset do)
sf := m.dSets[DefaultDataSetName].tBuf.ScaleDatum(f)
drawX := int(sf.X) + m.Origin().X
if m.YStep() > 0 {
drawX += 1
}
for i := range m.Origin().Y {
cs := m.Canvas.Cell(canvas.Point{X: drawX, Y: i}).Style.Copy() // copy cell foreground
cs = cs.Background(s.GetBackground()) // set cell background to given style background
m.Canvas.SetCellStyle(canvas.Point{X: drawX, Y: i}, cs)
}
}
// getLineSequence returns a sequence of Y values
// to draw line runes from a given set of scaled []FloatPoint64.
func (m *Model) getLineSequence(points []canvas.Float64Point) []int {
width := m.Width() - m.Origin().X // line runes can draw on axes
if width <= 0 {
return []int{}
}
dataLen := len(points)
// each index of the bucket corresponds to a graph column.
// each index value is the average of data point values
// that is mapped to that graph column.
buckets := make([]cAverage, width)
for i := 0; i < dataLen; i++ {
j := i + 1
if j >= dataLen {
j = i
}
p1 := canvas.NewPointFromFloat64Point(points[i])
p2 := canvas.NewPointFromFloat64Point(points[j])
// ignore points that will not be displayed on the graph
bothBeforeMin := (p1.X < 0 && p2.X < 0)
bothAfterMax := (p1.X > m.GraphWidth() && p2.X > m.GraphWidth())
if bothBeforeMin || bothAfterMax {
continue
}
// place all points between two points
// that approximates a line into buckets
points := graph.GetLinePoints(p1, p2)
for _, p := range points {
if (p.X >= 0) && (p.X) < width {
buckets[p.X].Add(float64(p.Y))
}
}
}
// populate sequence of Y values for drawing lines
r := make([]int, width)
for i, v := range buckets {
r[i] = int(math.Round(v.Avg))
}
return r
}
// Update processes bubbletea Msg by invoking
// UpdateHandlerFunc callback if linechart is focused.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.Focused() {
return m, nil
}
m.UpdateHandler(&m.Model, msg)
m.rescaleData()
return m, nil
}
package tchart
import (
"github.com/NimbleMarkets/ntcharts/linechart"
)
// DateUpdateHandler is used by timeserieslinechart to enable
// zooming in and out with the mouse wheel or page up and page down,
// moving the viewing window by holding down mouse button and moving,
// and moving the viewing window with the arrow keys.
// There is only movement along the X axis by day increments.
// Uses linechart Canvas Keymap for keyboard messages.
func DateUpdateHandler(i int) linechart.UpdateHandler {
daySeconds := 86400 * i // number of seconds in a day
return linechart.XAxisUpdateHandler(float64(daySeconds))
}
// DateNoZoomUpdateHandler is used by timeserieslinechart to enable
// moving the viewing window by using the mouse scroll wheel,
// holding down mouse button and moving,
// and moving the viewing window with the arrow keys.
// There is only movement along the X axis by day increments.
// Uses linechart Canvas Keymap for keyboard messages.
func DateNoZoomUpdateHandler(i int) linechart.UpdateHandler {
daySeconds := 86400 * i // number of seconds in a day
return linechart.XAxisNoZoomUpdateHandler(float64(daySeconds))
}
// HourUpdateHandler is used by timeserieslinechart to enable
// zooming in and out with the mouse wheel or page up and page down,
// moving the viewing window by holding down mouse button and moving,
// and moving the viewing window with the arrow keys.
// There is only movement along the X axis by hour increments.
// Uses linechart Canvas Keymap for keyboard messages.
func HourUpdateHandler(i int) linechart.UpdateHandler {
hourSeconds := 3600 * i // number of seconds in a hour
return linechart.XAxisUpdateHandler(float64(hourSeconds))
}
// HourNoZoomUpdateHandler is used by timeserieslinechart to enable
// moving the viewing window by using the mouse scroll wheel,
// holding down mouse button and moving,
// and moving the viewing window with the arrow keys.
// There is only movement along the X axis by hour increments.
// Uses linechart Canvas Keymap for keyboard messages.
func HourNoZoomUpdateHandler(i int) linechart.UpdateHandler {
hourSeconds := 3600 * i // number of seconds in a hour
return linechart.XAxisNoZoomUpdateHandler(float64(hourSeconds))
}
// SecondUpdateHandler is used by timeserieslinechart to enable
// zooming in and out with the mouse wheel or page up and page down,
// moving the viewing window by holding down mouse button and moving,
// and moving the viewing window with the arrow keys.
// There is only movement along the X axis by second increments.
// Uses linechart Canvas Keymap for keyboard messages.
func SecondUpdateHandler(i int) linechart.UpdateHandler {
return linechart.XAxisUpdateHandler(float64(i))
}
// SecondNoZoomUpdateHandler is used by timeserieslinechart to enable
// moving the viewing window by using the mouse scroll wheel,
// holding down mouse button and moving,
// and moving the viewing window with the arrow keys.
// There is only movement along the X axis by second increments.
// Uses linechart Canvas Keymap for keyboard messages.
func SecondNoZoomUpdateHandler(i int) linechart.UpdateHandler {
return linechart.XAxisNoZoomUpdateHandler(float64(i))
}
......@@ -3,6 +3,7 @@ package tui
import (
"get-container/cmd/dcutop/backend"
"get-container/gpu"
"get-container/utils"
"time"
tea "github.com/charmbracelet/bubbletea"
......@@ -21,6 +22,7 @@ type ModelMsg struct {
MyVersion string
DCUInfo map[int]backend.DCUInfo // DCU全量信息
// DCUPidInfo []gpu.DCUPidInfo // 使用dcu的进程信息
systemInfo *utils.SysInfo // 系统信息
}
type TickMsg time.Time
......@@ -30,7 +32,7 @@ type ModelMain struct {
width, height int // 终端尺寸
Header *ModelHeader
DCUInfo *ModelDCUInfo
// SysLoad *ModelSysLoad
SysLoad *ModelSysLoad
// ProcessInfo *ModelProcess
index uint64 // 记录update次数的值
modelMsg *ModelMsg // 记录模型信息
......@@ -42,6 +44,7 @@ func NewModelMain(width, height int) ModelMain {
result.height = height
result.Header = &ModelHeader{}
result.DCUInfo = &ModelDCUInfo{width: width, height: height, info: make(map[int]backend.DCUInfo)}
result.SysLoad = NewModelSysLoad(100)
return result
}
......@@ -71,6 +74,11 @@ func (m *ModelMain) Init() tea.Cmd {
if c := m.DCUInfo.Init(); c != nil {
cmds = append(cmds, c)
}
if c := m.SysLoad.Init(); c != nil {
cmds = append(cmds, c)
}
m.modelMsg = &modelMsg
// 将调用初始化方法
cmds = append(cmds, tickCmd(), sendMsgCmd(&modelMsg))
......@@ -97,21 +105,19 @@ func (m *ModelMain) Update(inputMsg tea.Msg) (tea.Model, tea.Cmd) {
case ModelMsg: // 初始化
header, _ := m.Header.Update(m.modelMsg)
dcuInfo, _ := m.DCUInfo.Update(m.modelMsg)
sysLoad, _ := m.SysLoad.Update(m.modelMsg)
m.Header = header.(*ModelHeader)
m.DCUInfo = dcuInfo.(*ModelDCUInfo)
m.SysLoad = sysLoad.(*ModelSysLoad)
return m, nil
}
return m, nil
}
func (m *ModelMain) View() string {
return m.Header.View() + m.DCUInfo.View()
return m.Header.View() + m.DCUInfo.View() + m.SysLoad.View() + "\n"
}
// type ModelDCUInfo struct{}
// type ModelSysLoad struct{}
// type ModelProcess struct{}
var myBorder = lipgloss.Border{
Top: "═",
TopLeft: "╒",
......@@ -143,7 +149,8 @@ func initModelInfo(model *ModelMsg) error {
backend.UpdateDCUInfo(false)
}
model.DCUInfo = backend.GetDCUInfo()
return nil
model.systemInfo, err = utils.GetSysInfo()
return err
}
// updateModelInfo 更新模型信息
......@@ -156,4 +163,5 @@ func updateModelInfo(modelMsg *ModelMsg, index uint64, t time.Time) {
backend.UpdateDCUInfo(false)
}
modelMsg.DCUInfo = backend.GetDCUInfo()
modelMsg.systemInfo, _ = utils.GetSysInfo()
}
package tui
import (
"fmt"
"get-container/cmd/dcutop/backend"
"get-container/cmd/dcutop/tchart"
"get-container/utils"
"maps"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/emirpasic/gods/v2/maps/linkedhashmap"
"github.com/emirpasic/gods/v2/trees/binaryheap"
)
const (
SysLoadHeight = 5 // 固定图表高度
SysLoadWidth = 77 // 固定图表宽度,不包含左右的边框
SysLoadCap = 500 // 记录
)
// ModelSysLoad 系统负载组件
type ModelSysLoad struct {
SysMem *MyTimeChart
SysCPU *MyTimeChart
DCU *MyTimeChart
DCUMem *MyTimeChart
Cache map[int]backend.DCUInfo
SysInfo *linkedhashmap.Map[time.Time, SysLoadInfo]
Keys *binaryheap.Heap[time.Time]
current *SysLoadInfo
line string
style lipgloss.Style
}
type SysLoadInfo struct {
T time.Time
DCUUsage map[int]float32
DCUMemUsage map[int]float32
Load1, Load5, Load15 float64
MemTotal, SwapTotal uint64
MemUsed, SwapUsed uint64
MemUsedPercent, SwapUsedPercent float64
CPUPercent float64
DCUUsageAvg float32
DCUMemUsageAvg float32
}
func NewModelSysLoad(width int) *ModelSysLoad {
result := ModelSysLoad{}
result.Cache = make(map[int]backend.DCUInfo)
result.SysInfo = linkedhashmap.New[time.Time, SysLoadInfo]()
result.Keys = binaryheap.NewWith(func(a, b time.Time) int {
if a.After(b) {
return 1
}
if a.Before(b) {
return -1
}
return 0
})
result.SysMem = New(SysLoadWidth, SysLoadHeight, 0, 100, map[string]lipgloss.Color{"default": lipgloss.Color("#0400ffff")})
result.SysCPU = New(SysLoadWidth, SysLoadHeight, 0, 100, map[string]lipgloss.Color{"default": lipgloss.Color("#8400ffff")})
result.DCU = New(width, SysLoadHeight, 0, 100, map[string]lipgloss.Color{"default": lipgloss.Color("#00ff00ff")})
result.DCUMem = New(width, SysLoadHeight, 0, 100, map[string]lipgloss.Color{"default": lipgloss.Color("#eeff00ff")})
result.line = genXAxis(SysLoadWidth) + myBorder.Middle + genXAxis(width)
result.style = lipgloss.NewStyle()
return &result
}
func (m *ModelSysLoad) Init() tea.Cmd {
result := make([]tea.Cmd, 0)
if c := m.DCU.Init(); c != nil {
result = append(result, c)
}
if c := m.DCUMem.Init(); c != nil {
result = append(result, c)
}
if c := m.SysMem.Init(); c != nil {
result = append(result, c)
}
if c := m.SysCPU.Init(); c != nil {
result = append(result, c)
}
if len(result) > 0 {
return tea.Batch(result...)
}
sysInfo, _ := utils.GetSysInfo()
s := SysLoadInfo{}
s.Load1 = sysInfo.LoadAverage1
s.Load5 = sysInfo.LoadAverage5
s.Load15 = sysInfo.LoadAverage15
s.MemTotal = sysInfo.MemTotal
s.MemUsed = sysInfo.MemUsage
s.SwapTotal = sysInfo.SwapTotal
s.SwapUsed = sysInfo.SwapUsage
s.MemUsedPercent = sysInfo.MemUsagePercent
s.SwapUsedPercent = sysInfo.SwapUsagePercent
s.T = time.Now()
s.CPUPercent = sysInfo.CPUPercent
s.DCUUsageAvg = 0
s.DCUMemUsageAvg = 0
m.current = &s
return nil
}
func (m *ModelSysLoad) Update(inputMsg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := inputMsg.(type) {
case *ModelMsg:
clear(m.Cache)
maps.Copy(m.Cache, msg.DCUInfo)
m.updateInfo(msg.t)
return m, nil
}
return m, nil
}
func (m *ModelSysLoad) View() string {
load := fmt.Sprintf("Load Average: %.2f %.2f %.2f", m.current.Load1, m.current.Load5, m.current.Load15)
mem := fmt.Sprintf("MEM: %s (%.1f%%)", "todo", 12.3)
dcuMem := fmt.Sprintf("AVG DCU MEM: %.1f%%", m.current.DCUMemUsageAvg)
dcu := fmt.Sprintf("AVG DCU UTL: %.1f%%", m.current.DCUUsageAvg)
load = StackPosition(load, m.SysCPU.View(), lipgloss.Top, lipgloss.Left)
mem = StackPosition(mem, m.SysMem.View(), lipgloss.Bottom, lipgloss.Left)
dcuMem = StackPosition(dcuMem, m.DCUMem.View(), lipgloss.Top, lipgloss.Left)
dcu = StackPosition(dcu, m.DCU.View(), lipgloss.Bottom, lipgloss.Left)
load = m.style.Border(myBorder, false, true, false).Render(load)
mem = m.style.Render(mem)
dcuMem = m.style.Border(myBorder, false, true, false, false).UnsetBorderRight().Render(dcuMem)
dcu = m.style.Render(dcu)
up := lipgloss.JoinHorizontal(lipgloss.Top, load, dcuMem)
down := lipgloss.JoinHorizontal(lipgloss.Top, mem, dcu)
return lipgloss.JoinVertical(lipgloss.Left, up, m.line, down)
}
// updateInfo
func (m *ModelSysLoad) updateInfo(t time.Time) {
sysInfo, _ := utils.GetSysInfo()
s := SysLoadInfo{}
s.Load1 = sysInfo.LoadAverage1
s.Load5 = sysInfo.LoadAverage5
s.Load15 = sysInfo.LoadAverage15
s.MemTotal = sysInfo.MemTotal
s.MemUsed = sysInfo.MemUsage
s.SwapTotal = sysInfo.SwapTotal
s.SwapUsed = sysInfo.SwapUsage
s.MemUsedPercent = sysInfo.MemUsagePercent
s.SwapUsedPercent = sysInfo.SwapUsagePercent
s.T = t
s.CPUPercent = sysInfo.CPUPercent
s.DCUUsage = make(map[int]float32)
s.DCUMemUsage = make(map[int]float32)
s.DCUMemUsageAvg, s.DCUUsageAvg = 0, 0
for k, v := range m.Cache {
s.DCUMemUsageAvg += v.MemUsedPerent
s.DCUMemUsage[k] = v.MemUsedPerent
s.DCUMemUsageAvg += v.DCUUTil
s.DCUUsage[k] = v.DCUUTil
}
l := len(m.Cache)
s.DCUMemUsageAvg /= float32(l)
s.DCUUsageAvg /= float32(l)
m.current = &s
wg := sync.WaitGroup{}
wg.Add(4)
go func() {
defer wg.Done()
m1, _ := m.SysMem.Update(MyTimeChartMsg{Points: map[string][]tchart.TimePoint{"default": {{Time: t, Value: s.MemUsedPercent}}}})
m.SysMem = m1.(*MyTimeChart)
}()
go func() {
defer wg.Done()
m2, _ := m.SysCPU.Update(MyTimeChartMsg{Points: map[string][]tchart.TimePoint{"default": {{Time: t, Value: s.CPUPercent}}}})
m.SysCPU = m2.(*MyTimeChart)
}()
go func() {
defer wg.Done()
m3, _ := m.DCU.Update(MyTimeChartMsg{Points: map[string][]tchart.TimePoint{"default": {{Time: t, Value: float64(s.DCUUsageAvg)}}}})
m.DCU = m3.(*MyTimeChart)
}()
go func() {
defer wg.Done()
m4, _ := m.DCUMem.Update(MyTimeChartMsg{Points: map[string][]tchart.TimePoint{"default": {{Time: t, Value: float64(s.DCUMemUsageAvg)}}}})
m.DCUMem = m4.(*MyTimeChart)
}()
// todo
wg.Wait()
}
......@@ -4,10 +4,12 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"get-container/cmd/dcutop/tchart"
"github.com/NimbleMarkets/ntcharts/canvas/runes"
tchart "github.com/NimbleMarkets/ntcharts/linechart/timeserieslinechart"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
......@@ -54,19 +56,23 @@ func genXAxis(l int) string {
// MyTimeChartMsg 时间流表消息,用于插入数据
type MyTimeChartMsg struct {
point tchart.TimePoint
Points map[string][]tchart.TimePoint
}
// MyTimeChart 特化的时间流表,时间区域就是宽度的2倍,单位是秒
type MyTimeChart struct {
chart *tchart.Model // 原始图表
zM *zone.Manager // 区域管理
points []tchart.TimePoint // 数据点
width, height int // 图表的高度和宽度
max, min float64 // y轴的最值
chart *tchart.Model // 原始图表
zM *zone.Manager // 区域管理
dataSet map[string][]tchart.TimePoint // 数据点
width, height int // 图表的高度和宽度
max, min float64 // y轴的最值
dataStyle map[string]lipgloss.Style // 记录每个数据集的样式
lockDataSet sync.RWMutex // 保护dataSet的并发读写
lockChart sync.RWMutex // 保护chart的数据集的并发读写
}
func New(width, height int, vmin, vmax float64) *MyTimeChart {
// New 新建图表,其中dataSet的Key为数据集名称,value为数据集的颜色
func New(width, height int, vmin, vmax float64, dataSet map[string]lipgloss.Color) *MyTimeChart {
result := MyTimeChart{}
result.max = vmax
result.min = vmin
......@@ -74,66 +80,115 @@ func New(width, height int, vmin, vmax float64) *MyTimeChart {
result.height = height
zoneManager := zone.New()
result.zM = zoneManager
result.points = make([]tchart.TimePoint, 0)
result.dataSet = make(map[string][]tchart.TimePoint)
result.dataStyle = make(map[string]lipgloss.Style)
result.lockChart = sync.RWMutex{}
result.lockDataSet = sync.RWMutex{}
result.lockDataSet.Lock()
for k, v := range dataSet {
result.dataSet[k] = make([]tchart.TimePoint, (result.width*2)+1)
result.dataStyle[k] = lipgloss.NewStyle().Foreground(v)
}
initPoints := make([]tchart.TimePoint, (result.width*2)+1)
// 准备数据,数据间隔为1秒
now := time.Now()
for i := result.width * 2; i >= 0; i-- {
result.points = append(result.points, tchart.TimePoint{Time: now.Add(-time.Duration(i) * time.Second), Value: vmin})
initPoints[i] = tchart.TimePoint{Time: now.Add(time.Duration(-i) * time.Second), Value: vmin}
}
for k := range dataSet {
copy(result.dataSet[k], initPoints)
}
result.lockDataSet.Unlock()
s := tchart.New(width, height,
tchart.WithLineStyle(runes.ThinLineStyle),
tchart.WithZoneManager(zoneManager),
tchart.WithYRange(vmin, vmax),
tchart.WithTimeSeries(result.points),
tchart.WithXYSteps(0, 0),
)
wg := sync.WaitGroup{}
wg.Add(len(dataSet))
for k := range dataSet {
points := result.dataSet[k]
style := result.dataStyle[k]
go func(ps []tchart.TimePoint, st lipgloss.Style, key string) {
result.lockChart.Lock()
s.SetDataSet(key, ps)
s.SetDataSetStyle(key, st)
result.lockChart.Unlock()
wg.Done()
}(points, style, k)
}
wg.Wait()
result.chart = &s
return &result
}
func (m *MyTimeChart) PutPoint(timePoint tchart.TimePoint) {
m.points = append(m.points, timePoint)
// 排序
sort.Slice(m.points, func(i, j int) bool {
return m.points[i].Time.Before(m.points[j].Time)
})
// 剔除不要的数据
threshold := time.Now().Add(time.Second * time.Duration(-2*m.width))
targetIndex := 0
for i, p := range m.points {
if !p.Time.Before(threshold) {
targetIndex = i
break
}
}
points := append(make([]tchart.TimePoint, 0), m.points[targetIndex:]...)
m.points = points
func (m *MyTimeChart) PutPoint(points map[string][]tchart.TimePoint) {
// 更新chart
s := tchart.New(m.width, m.height,
tchart.WithLineStyle(runes.ThinLineStyle),
tchart.WithZoneManager(m.zM),
tchart.WithYRange(m.min, m.max),
tchart.WithTimeSeries(m.points),
tchart.WithXYSteps(0, 0),
)
m.chart = &s
wg := sync.WaitGroup{}
wg.Add(len(m.dataSet))
keys, index := make([]string, len(m.dataSet)), 0
for k := range m.dataSet {
keys[index] = k
index++
}
for _, k := range keys {
newPoints := points[k]
oldPoints := m.dataSet[k]
go func(ops, nps []tchart.TimePoint, ds map[string][]tchart.TimePoint, key string) {
ops = append(ops, nps...)
sort.Slice(ops, func(i, j int) bool {
return ops[i].Time.Before(ops[j].Time)
})
threshold := time.Now().Add(time.Second * time.Duration(-2*m.width))
targetIndex := 0
for i, p := range ops {
if !p.Time.Before(threshold) {
targetIndex = i
break
}
}
nps = append(make([]tchart.TimePoint, 0), ops[targetIndex:]...)
m.lockDataSet.Lock()
ds[key] = nps
m.lockDataSet.Unlock()
m.lockChart.Lock()
s.SetDataSet(key, nps)
s.SetDataSetStyle(key, m.dataStyle[key])
m.lockChart.Unlock()
wg.Done()
}(oldPoints, newPoints, m.dataSet, k)
}
wg.Wait()
m.chart.DrawXYAxisAndLabel()
m.chart.DrawBrailleAll()
}
func (m *MyTimeChart) Init() tea.Cmd {
m.chart.DrawXYAxisAndLabel()
m.chart.DrawBraille()
m.chart.DrawBrailleAll()
return nil
}
func (m *MyTimeChart) Update(inputMsg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := inputMsg.(type) {
case MyTimeChartMsg:
m.PutPoint(msg.point)
m.PutPoint(msg.Points)
return m, nil
}
return m, nil
}
func (m *MyTimeChart) View() string {
rl := m.lockChart.RLocker()
rl.Lock()
defer rl.Unlock()
return m.zM.Scan(m.chart.View())
}
......@@ -2,9 +2,13 @@ package tui
import (
"fmt"
"get-container/cmd/dcutop/tchart"
"testing"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/emirpasic/gods/v2/maps/linkedhashmap"
"github.com/emirpasic/gods/v2/trees/binaryheap"
)
const S = `├───────────────────────────────┼──────────────────────┼──────────────────────┼
......@@ -46,3 +50,62 @@ func TestModel(t *testing.T) {
str := m.View()
t.Log(str)
}
func TestMyTimeChart(t *testing.T) {
chart := New(100, 20, 0.0, 100.0, map[string]lipgloss.Color{"default": lipgloss.Color("#ff2222ff"), "other": lipgloss.Color("#0037ffff")})
chart.Init()
s := chart.View()
t.Logf("%s", s)
time.Sleep(time.Second)
now := time.Now()
points := make(map[string][]tchart.TimePoint)
points["default"] = []tchart.TimePoint{{Time: now, Value: 20.0}}
points["other"] = []tchart.TimePoint{{Time: now, Value: 30.0}}
chart.Update(MyTimeChartMsg{Points: points})
_ = len(chart.dataSet)
t.Logf("%s", chart.View())
}
func TestBinaryHeap(t *testing.T) {
heap := binaryheap.NewWith(func(a, b time.Time) int {
if a.After(b) {
return 1
}
if a.Before(b) {
return -1
}
return 0
})
now := time.Now()
for i := range 5 {
heap.Push(now.Add(time.Duration(i) * time.Second))
}
for {
tt, ok := heap.Pop()
if ok {
t.Logf("%s", tt)
} else {
break
}
}
}
func TestLinkedHashMap(t *testing.T) {
m := linkedhashmap.New[time.Time, int]()
now := time.Now()
for i := range 5 {
m.Put(now.Add(time.Duration(i)*time.Second), 5-i)
}
mi := m.Iterator()
for {
if mi.Next() {
t.Logf("%v: %d", mi.Key(), mi.Value())
} else {
break
}
}
t.Log(m.Size())
}
......@@ -11,6 +11,8 @@ require (
github.com/shirou/gopsutil/v4 v4.25.9
)
require github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
require (
github.com/charmbracelet/bubbles v0.20.0
github.com/charmbracelet/harmonica v0.2.0 // indirect
......
package utils
import (
"github.com/shirou/gopsutil/v4/cpu"
"testing"
"time"
"github.com/shirou/gopsutil/v4/cpu"
)
func TestGetSysUsers(t *testing.T) {
......@@ -12,3 +14,25 @@ func TestGetSysUsers(t *testing.T) {
}
t.Logf("%+v", a)
}
func TestGetSysInfo(t *testing.T) {
start := time.Now
info, err := GetSysInfo()
d := time.Since(start())
if err != nil {
t.Error(err)
}
t.Logf("%d: %+v", d.Nanoseconds(), *info)
}
func BenchmarkGetSysInfo(b *testing.B) {
result := make([]float64, b.N)
for i := 0; i < b.N; i++ {
info, err := GetSysInfo()
if err != nil {
b.Error(err)
}
result[i] = info.CPUPercent
}
b.Logf("%+v", result)
}
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment