You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cointop/cointop/chart.go

436 lines
9.7 KiB
Go

package cointop
import (
"fmt"
"strings"
"sync"
"time"
"github.com/miguelmota/cointop/cointop/common/filecache"
"github.com/miguelmota/cointop/cointop/common/gizak/termui"
"github.com/miguelmota/cointop/cointop/common/timeutil"
)
// ChartView is structure for chart view
type ChartView struct {
*View
}
// NewChartView returns a new chart view
func NewChartView() *ChartView {
return &ChartView{NewView("chart")}
}
var chartLock sync.Mutex
var chartPointsLock sync.Mutex
func chartRanges() []string {
return []string{
"1H",
"6H",
"24H",
"3D",
"7D",
"1M",
"3M",
"6M",
"1Y",
"YTD",
"All Time",
}
}
func chartRangesMap() map[string]time.Duration {
return map[string]time.Duration{
"All Time": time.Duration(24 * 7 * 4 * 12 * 5 * time.Hour),
"YTD": time.Duration(1 * time.Second), // this will be calculated
"1Y": time.Duration(24 * 7 * 4 * 12 * time.Hour),
"6M": time.Duration(24 * 7 * 4 * 6 * time.Hour),
"3M": time.Duration(24 * 7 * 4 * 3 * time.Hour),
"1M": time.Duration(24 * 7 * 4 * time.Hour),
"7D": time.Duration(24 * 7 * time.Hour),
"3D": time.Duration(24 * 3 * time.Hour),
"24H": time.Duration(24 * time.Hour),
"6H": time.Duration(6 * time.Hour),
"1H": time.Duration(1 * time.Hour),
}
}
// UpdateChart updates the chart view
func (ct *Cointop) UpdateChart() error {
ct.debuglog("UpdateChart()")
if ct.Views.Chart.Backing() == nil {
return nil
}
chartLock.Lock()
defer chartLock.Unlock()
if ct.State.portfolioVisible {
if err := ct.PortfolioChart(); err != nil {
return err
}
} else {
symbol := ct.selectedCoinSymbol()
name := ct.selectedCoinName()
ct.ChartPoints(symbol, name)
}
if len(ct.State.chartPoints) != 0 {
ct.Views.Chart.Backing().Clear()
}
var body string
if len(ct.State.chartPoints) == 0 {
body = "\n\n\n\n\nnot enough data for chart"
} else {
for i := range ct.State.chartPoints {
var s string
for j := range ct.State.chartPoints[i] {
p := ct.State.chartPoints[i][j]
s = fmt.Sprintf("%s%c", s, p.Ch)
}
body = fmt.Sprintf("%s%s\n", body, s)
}
}
ct.Update(func() {
if ct.Views.Chart.Backing() == nil {
return
}
fmt.Fprint(ct.Views.Chart.Backing(), ct.colorscheme.Chart(body))
})
return nil
}
// ChartPoints calculates the the chart points
func (ct *Cointop) ChartPoints(symbol string, name string) error {
ct.debuglog("ChartPoints()")
maxX := ct.ClampedWidth()
chartPointsLock.Lock()
defer chartPointsLock.Unlock()
// TODO: not do this (SoC)
go ct.updateMarketbar()
chart := termui.NewLineChart()
chart.Height = ct.State.chartHeight
chart.Border = false
// NOTE: empty list means don't show x-axis labels
chart.DataLabels = []string{""}
rangeseconds := ct.chartRangesMap[ct.State.selectedChartRange]
if ct.State.selectedChartRange == "YTD" {
ytd := time.Now().Unix() - int64(timeutil.BeginningOfYear().Unix())
rangeseconds = time.Duration(ytd) * time.Second
}
now := time.Now()
nowseconds := now.Unix()
start := nowseconds - int64(rangeseconds.Seconds())
end := nowseconds
var data []float64
keyname := symbol
if keyname == "" {
keyname = "globaldata"
}
cachekey := ct.CacheKey(fmt.Sprintf("%s_%s", keyname, strings.Replace(ct.State.selectedChartRange, " ", "", -1)))
cached, found := ct.cache.Get(cachekey)
if found {
// cache hit
data, _ = cached.([]float64)
ct.debuglog("soft cache hit")
}
if len(data) == 0 {
if symbol == "" {
graphData, err := ct.api.GetGlobalMarketGraphData(start, end)
if err != nil {
return nil
}
for i := range graphData.MarketCapByAvailableSupply {
price := graphData.MarketCapByAvailableSupply[i][1]
data = append(data, price/1e9)
}
} else {
graphData, err := ct.api.GetCoinGraphData(symbol, name, start, end)
if err != nil {
return nil
}
// NOTE: edit `termui.LineChart.shortenFloatVal(float64)` to not
// use exponential notation.
for i := range graphData.PriceUSD {
price := graphData.PriceUSD[i][1]
data = append(data, price)
}
}
ct.cache.Set(cachekey, data, 10*time.Second)
go func() {
filecache.Set(cachekey, data, 24*time.Hour)
}()
}
chart.Data = data
termui.Body = termui.NewGrid()
termui.Body.Width = maxX
termui.Body.AddRows(
termui.NewRow(
termui.NewCol(12, 0, chart),
),
)
var points [][]termui.Cell
// calculate layout
termui.Body.Align()
w := termui.Body.Width
h := chart.Height
row := termui.Body.Rows[0]
b := row.Buffer()
for i := 0; i < h; i = i + 1 {
var rowpoints []termui.Cell
for j := 0; j < w; j = j + 1 {
p := b.At(j, i)
rowpoints = append(rowpoints, p)
}
points = append(points, rowpoints)
}
ct.State.chartPoints = points
return nil
}
// PortfolioChart renders the portfolio chart
func (ct *Cointop) PortfolioChart() error {
ct.debuglog("PortfolioChart()")
maxX := ct.ClampedWidth()
chartPointsLock.Lock()
defer chartPointsLock.Unlock()
// TODO: not do this (SoC)
go ct.updateMarketbar()
chart := termui.NewLineChart()
chart.Height = ct.State.chartHeight
chart.Border = false
// NOTE: empty list means don't show x-axis labels
chart.DataLabels = []string{""}
rangeseconds := ct.chartRangesMap[ct.State.selectedChartRange]
if ct.State.selectedChartRange == "YTD" {
ytd := time.Now().Unix() - int64(timeutil.BeginningOfYear().Unix())
rangeseconds = time.Duration(ytd) * time.Second
}
now := time.Now()
nowseconds := now.Unix()
start := nowseconds - int64(rangeseconds.Seconds())
end := nowseconds
var data []float64
portfolio := ct.getPortfolioSlice()
chartname := ct.selectedCoinName()
for _, p := range portfolio {
// filter by selected chart if selected
if chartname != "" {
if chartname != p.Name {
continue
}
}
if p.Holdings <= 0 {
continue
}
var graphData []float64
cachekey := strings.ToLower(fmt.Sprintf("%s_%s", p.Symbol, strings.Replace(ct.State.selectedChartRange, " ", "", -1)))
cached, found := ct.cache.Get(cachekey)
if found {
// cache hit
graphData, _ = cached.([]float64)
ct.debuglog("soft cache hit")
} else {
filecache.Get(cachekey, &graphData)
if len(graphData) == 0 {
time.Sleep(2 * time.Second)
apiGraphData, err := ct.api.GetCoinGraphData(p.Symbol, p.Name, start, end)
if err != nil {
return err
}
for i := range apiGraphData.PriceUSD {
price := apiGraphData.PriceUSD[i][1]
graphData = append(graphData, price)
}
}
ct.cache.Set(cachekey, graphData, 10*time.Second)
go func() {
filecache.Set(cachekey, graphData, 24*time.Hour)
}()
}
for i := range graphData {
price := graphData[i]
sum := p.Holdings * price
if len(data)-1 >= i {
data[i] += sum
}
data = append(data, sum)
}
}
chart.Data = data
termui.Body = termui.NewGrid()
termui.Body.Width = maxX
termui.Body.AddRows(
termui.NewRow(
termui.NewCol(12, 0, chart),
),
)
var points [][]termui.Cell
// calculate layout
termui.Body.Align()
w := termui.Body.Width
h := chart.Height
row := termui.Body.Rows[0]
b := row.Buffer()
for i := 0; i < h; i = i + 1 {
var rowpoints []termui.Cell
for j := 0; j < w; j = j + 1 {
p := b.At(j, i)
rowpoints = append(rowpoints, p)
}
points = append(points, rowpoints)
}
ct.State.chartPoints = points
return nil
}
// ShortenChart decreases the chart height by one row
func (ct *Cointop) ShortenChart() error {
ct.debuglog("ShortenChart()")
candidate := ct.State.chartHeight - 1
if candidate < 5 {
return nil
}
ct.State.chartHeight = candidate
go ct.UpdateChart()
return nil
}
// EnlargeChart increases the chart height by one row
func (ct *Cointop) EnlargeChart() error {
ct.debuglog("EnlargeChart()")
candidate := ct.State.chartHeight + 1
if candidate > 30 {
return nil
}
ct.State.chartHeight = candidate
go ct.UpdateChart()
return nil
}
// NextChartRange sets the chart to the next range option
func (ct *Cointop) NextChartRange() error {
ct.debuglog("NextChartRange()")
sel := 0
max := len(ct.chartRanges)
for i, k := range ct.chartRanges {
if k == ct.State.selectedChartRange {
sel = i + 1
break
}
}
if sel > max-1 {
sel = 0
}
ct.State.selectedChartRange = ct.chartRanges[sel]
go ct.UpdateChart()
return nil
}
// PrevChartRange sets the chart to the prevous range option
func (ct *Cointop) PrevChartRange() error {
ct.debuglog("PrevChartRange()")
sel := 0
for i, k := range ct.chartRanges {
if k == ct.State.selectedChartRange {
sel = i - 1
break
}
}
if sel < 0 {
sel = len(ct.chartRanges) - 1
}
ct.State.selectedChartRange = ct.chartRanges[sel]
go ct.UpdateChart()
return nil
}
// FirstChartRange sets the chart to the first range option
func (ct *Cointop) FirstChartRange() error {
ct.debuglog("FirstChartRange()")
ct.State.selectedChartRange = ct.chartRanges[0]
go ct.UpdateChart()
return nil
}
// LastChartRange sets the chart to the last range option
func (ct *Cointop) LastChartRange() error {
ct.debuglog("LastChartRange()")
ct.State.selectedChartRange = ct.chartRanges[len(ct.chartRanges)-1]
go ct.UpdateChart()
return nil
}
// ToggleCoinChart toggles between the global chart and the coin chart
func (ct *Cointop) ToggleCoinChart() error {
ct.debuglog("ToggleCoinChart()")
highlightedcoin := ct.HighlightedRowCoin()
if ct.State.selectedCoin == highlightedcoin {
ct.State.selectedCoin = nil
} else {
ct.State.selectedCoin = highlightedcoin
}
go ct.ShowChartLoader()
go ct.UpdateChart()
go ct.updateMarketbar()
return nil
}
// ShowChartLoader shows chart loading indicator
func (ct *Cointop) ShowChartLoader() error {
ct.debuglog("ShowChartLoader()")
ct.Update(func() {
if ct.Views.Chart.Backing() == nil {
return
}
content := "\n\nLoading..."
ct.Views.Chart.Backing().Clear()
fmt.Fprint(ct.Views.Chart.Backing(), ct.colorscheme.Chart(content))
})
return nil
}