WIP mktchart 2
This commit is contained in:
9 files changed
+481
No files matched your search
@@ -0,0 +1,220 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// IndicatorPaneSpec is one lower pane: MACD, RSI, KDJ or OBV.
|
||||
type IndicatorPaneSpec struct {
|
||||
Kind IndicatorKind
|
||||
// Params nil uses the kind's defaults.
|
||||
Params []float64
|
||||
}
|
||||
|
||||
// IndicatorPanes is the caller-held stack of panes and their height shares.
|
||||
type IndicatorPanes struct {
|
||||
Panes []IndicatorPaneSpec
|
||||
// Sizes are the panes' height shares; they reset to equal when the pane count changes.
|
||||
Sizes []float32
|
||||
}
|
||||
|
||||
// IndicatorPaneOptions configure an IndicatorPane.
|
||||
type IndicatorPaneOptions struct {
|
||||
// Height is the whole stack's height; zero is 140 per pane.
|
||||
Height float32
|
||||
// Label prefixes each pane's accessible name, as the symbol.
|
||||
Label string
|
||||
Disabled bool
|
||||
}
|
||||
|
||||
// IndicatorPaneResult reports the stack's events.
|
||||
type IndicatorPaneResult struct {
|
||||
Element *ui.Element
|
||||
// Removed is the index of the pane closed this frame, -1 for none.
|
||||
Removed int
|
||||
// Resized reports finished height changes, for saving.
|
||||
Resized bool
|
||||
}
|
||||
|
||||
// IndicatorPane stacks indicator panes under a price chart, sharing its view; dividers between them resize.
|
||||
func IndicatorPane(c *ui.Context, candles []Candle, view *MarketView, panes *IndicatorPanes, opts IndicatorPaneOptions) IndicatorPaneResult {
|
||||
if view == nil || panes == nil {
|
||||
panic("mujica: IndicatorPane needs a view and panes")
|
||||
}
|
||||
res := IndicatorPaneResult{Removed: -1}
|
||||
n := len(panes.Panes)
|
||||
for _, p := range panes.Panes {
|
||||
if _ = p.Kind.String(); indicatorInfo[p.Kind].overlay {
|
||||
panic(fmt.Sprintf("mujica: %v is an overlay, not a pane indicator", p.Kind))
|
||||
}
|
||||
indicatorParams(p.Kind, p.Params)
|
||||
}
|
||||
if n == 0 {
|
||||
res.Element = ui.Column(c)
|
||||
return res
|
||||
}
|
||||
if len(panes.Sizes) != n {
|
||||
panes.Sizes = nil
|
||||
}
|
||||
h := opts.Height
|
||||
if h == 0 {
|
||||
h = 140 * float32(n)
|
||||
}
|
||||
build := func(i int) func() {
|
||||
return func() {
|
||||
col := ui.Column(c).Fill().MinHeight(0)
|
||||
b := col.Bounds()
|
||||
internal.Remeasure(c, col, b.W, b.H)
|
||||
u := Density(c).Unit()
|
||||
// The plot takes what the title, legend and readout rows leave.
|
||||
plotH := max(40, b.H-2*theme.MinHitSize-u*12)
|
||||
col.Children(func() {
|
||||
if indicatorPaneOne(c, candles, view, panes.Panes[i], opts, plotH) {
|
||||
res.Removed = i
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
box := ui.Column(c).Height(h).FillWidth().Disabled(opts.Disabled)
|
||||
box.Children(func() {
|
||||
if n == 1 {
|
||||
build(0)()
|
||||
return
|
||||
}
|
||||
list := make([]ResizablePanel, n)
|
||||
for i, p := range panes.Panes {
|
||||
list[i] = ResizablePanel{Label: indicatorName(p.Kind, indicatorParams(p.Kind, p.Params)), Min: 60, Build: build(i)}
|
||||
}
|
||||
r := ResizablePanelGroup(c, &panes.Sizes, list, ResizablePanelGroupOptions{Vertical: true})
|
||||
r.Element.Fill()
|
||||
res.Resized = r.Committed
|
||||
})
|
||||
if res.Removed >= 0 {
|
||||
slog.Debug("mujica: indicator_pane removed", "kind", panes.Panes[res.Removed].Kind)
|
||||
panes.Panes = append(panes.Panes[:res.Removed:res.Removed], panes.Panes[res.Removed+1:]...)
|
||||
panes.Sizes = nil
|
||||
c.Invalidate()
|
||||
}
|
||||
res.Element = box
|
||||
return res
|
||||
}
|
||||
|
||||
// indicatorPaneOne draws one pane's lines over the view's window; it reports the close button.
|
||||
func indicatorPaneOne(c *ui.Context, candles []Candle, view *MarketView, spec IndicatorPaneSpec, opts IndicatorPaneOptions, height float32) bool {
|
||||
k := Tokens(c)
|
||||
params := indicatorParams(spec.Kind, spec.Params)
|
||||
name := indicatorName(spec.Kind, params)
|
||||
closed := false
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit()).Children(func() {
|
||||
ui.Text(c, name).FontSize(FontSize(c, theme.CaptionSize)).FontWeight(600).TextColor(k.Text).Grow(1)
|
||||
closed = iconAction(c, icons.Must("x"), fmt.Sprintf(msg(c, "indicator_pane.remove"), name)).Clicked()
|
||||
})
|
||||
label := strings.TrimSpace(opts.Label + " " + name)
|
||||
ch := newChart(c, ChartFrame{Label: label, Height: height})
|
||||
series := chartMemo(ch, "series", fmt.Sprintf("%s|%v|%v", chartID(candles), spec.Kind, params), func() []ChartSeries {
|
||||
marketCheckCandles(candles)
|
||||
return indicatorSeries(spec.Kind, params, candles)
|
||||
})
|
||||
m := &mktCore{c: c, k: k, ch: ch, candles: candles, view: view}
|
||||
m.window()
|
||||
pal := internal.Palette(k)
|
||||
lines := make([]mktLine, len(series))
|
||||
keys := make([]chartKey, len(series))
|
||||
for i, s := range series {
|
||||
lines[i] = mktLine{name: s.Name, values: s.Values, color: pal[(i+1)%len(pal)], shape: i}
|
||||
keys[i] = chartKey{Name: s.Name, Color: lines[i].color, Shape: i}
|
||||
}
|
||||
yr := chartMemo(ch, "y", [3]any{chartID(candles), m.from, m.to}, func() [2]float64 {
|
||||
lo, hi := math.Inf(1), math.Inf(-1)
|
||||
for _, l := range lines {
|
||||
for i := m.from; i < m.to; i++ {
|
||||
if v := l.values[i]; !math.IsNaN(v) {
|
||||
lo, hi = min(lo, v), max(hi, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
if spec.Kind == IndicatorRSI {
|
||||
lo, hi = 0, 100
|
||||
}
|
||||
return [2]float64{lo, hi}
|
||||
})
|
||||
lo, hi := yr[0], yr[1]
|
||||
spec2 := chartSpec{n: len(candles), keys: keys}
|
||||
if m.to <= m.from || math.IsInf(lo, 0) {
|
||||
spec2.n = 0
|
||||
}
|
||||
m.lo, m.hi = lo, hi
|
||||
spec2.pick = func(r ui.Rect, x, y float32) int {
|
||||
g := m.paneGeom(r)
|
||||
if !g.plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return g.index(m, x)
|
||||
}
|
||||
spec2.readout = func(i int) string {
|
||||
parts := []string{candles[i].Time.Format("2006-01-02 15:04")}
|
||||
for _, l := range lines {
|
||||
parts = append(parts, l.name+" "+chartValue(c, l.values[i], func(v float64) string { return fmt.Sprintf("%.2f", v) }))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
spec2.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
g := m.paneGeom(r)
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
internal.AxisY(p, g.plot, g.ys, g.ticks, func(v float64) string { return volumeShort(v, g.step) }, size, k)
|
||||
p.Clip(g.plot, 0, func() {
|
||||
for i, l := range lines {
|
||||
if spec.Kind == IndicatorMACD && i == 2 {
|
||||
indicatorPaneBars(p, g, m, l.values, c)
|
||||
continue
|
||||
}
|
||||
mktPaintLine(p, g, m, l.values, l.color, i)
|
||||
}
|
||||
})
|
||||
if active >= m.from && active < m.to {
|
||||
x := internal.Snap(p, g.x(m, active))
|
||||
p.Line(x, g.plot.Y, x, g.plot.Y+g.plot.H, 1, k.TextMuted)
|
||||
}
|
||||
}
|
||||
ch.build(spec2)
|
||||
return closed && !ch.res.Element.IsDisabled()
|
||||
}
|
||||
|
||||
// paneGeom is geom without the time axis, for lower panes.
|
||||
func (m *mktCore) paneGeom(r ui.Rect) mktGeom {
|
||||
plot := chartInset(r, 60, 4)
|
||||
lo, hi, step := internal.Nice(m.lo, m.hi, 2)
|
||||
return mktGeom{plot: plot, band: internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
ys: internal.Linear{D0: lo, D1: hi, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}, ticks: internal.Ticks(lo, hi, step), step: step}
|
||||
}
|
||||
|
||||
// indicatorPaneBars draws MACD's histogram: hollow bars above zero, filled below.
|
||||
func indicatorPaneBars(p *ui.Painter, g mktGeom, m *mktCore, vals []float64, c *ui.Context) {
|
||||
k := Tokens(c)
|
||||
zero := g.ys.Map(0)
|
||||
w := max(1, g.band.Width())
|
||||
for i := m.from; i < m.to; i++ {
|
||||
v := vals[i]
|
||||
if math.IsNaN(v) {
|
||||
continue
|
||||
}
|
||||
y := g.ys.Map(v)
|
||||
x := internal.Snap(p, g.x(m, i))
|
||||
bar := ui.Rect{X: x - w/2, Y: min(y, zero), W: w, H: max(1, float32(math.Abs(float64(y-zero))))}
|
||||
if v >= 0 && w >= 3 {
|
||||
p.Fill(bar, k.Surface, 0)
|
||||
p.Stroke(bar, trendColor(c, 1), 0, 1)
|
||||
} else {
|
||||
p.Fill(bar, trendColor(c, trendSign(v)), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestIndicatorPaneStackReadAndRemove(t *testing.T) {
|
||||
candles := sampleCandles(300)
|
||||
view := MarketView{Count: 80}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorRSI, Params: []float64{6}}}}
|
||||
removed := -1
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if r := IndicatorPane(c, candles, &view, &panes, IndicatorPaneOptions{Label: "BTC"}); r.Removed >= 0 {
|
||||
removed = r.Removed
|
||||
}
|
||||
}, 800, 500)
|
||||
tt.Frame()
|
||||
if !tt.HasText("MACD(12,26,9)") || !tt.HasText("RSI(6)") || len(panes.Sizes) != 2 {
|
||||
t.Fatalf("texts %q sizes %v", tt.Texts(), panes.Sizes)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab) // remove MACD
|
||||
tt.Key(0, ui.KeyTab) // MACD plot
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.Contains(s, "DIF ") && strings.Contains(s, "DEA ")
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("MACD readout missing: %q", tt.Texts())
|
||||
}
|
||||
if err := tt.Click("Remove MACD(12,26,9)"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(panes.Panes) != 1 || panes.Panes[0].Kind != IndicatorRSI || removed != 0 {
|
||||
t.Fatalf("after remove %+v (%d)", panes.Panes, removed)
|
||||
}
|
||||
for name, p := range map[string]IndicatorPanes{
|
||||
"overlay kind": {Panes: []IndicatorPaneSpec{{Kind: IndicatorMA}}},
|
||||
"bad params": {Panes: []IndicatorPaneSpec{{Kind: IndicatorRSI, Params: []float64{1, 2}}}},
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Errorf("%s: no panic", name)
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); IndicatorPane(c, candles, &view, &p, IndicatorPaneOptions{}) }, 300, 200)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndicatorPaneSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(120)
|
||||
view := MarketView{Count: 80}
|
||||
panes := IndicatorPanes{Panes: []IndicatorPaneSpec{{Kind: IndicatorMACD}, {Kind: IndicatorKDJ}}}
|
||||
snapshot(t, "indicator_pane", 640, 340, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
IndicatorPane(c, candles, &view, &panes, IndicatorPaneOptions{Height: 316})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ func init() {
|
||||
"chart_sync.link": {"Sync with other charts", "与其他图表同步"},
|
||||
"chart_sync.linked": {"Synced", "已同步"},
|
||||
"chart_sync.unlinked": {"Not synced", "未同步"},
|
||||
"indicator_pane.remove": {"Remove %s", "移除 %s"},
|
||||
"drawing_tools.note": {"Note", "标注"},
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
+139
@@ -0,0 +1,139 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// VolumeChartOptions configure a VolumeChart.
|
||||
type VolumeChartOptions struct {
|
||||
Frame ChartFrame
|
||||
// Sync links the crosshair to other charts under SyncID.
|
||||
Sync *ChartSync
|
||||
SyncID string
|
||||
}
|
||||
|
||||
// VolumeChart draws the volume of the view's window as bars, hollow on rises and filled on falls; give it the price chart's view to share the time axis.
|
||||
func VolumeChart(c *ui.Context, candles []Candle, view *MarketView, opts VolumeChartOptions) ChartResult {
|
||||
if view == nil {
|
||||
panic("mujica: VolumeChart needs a view")
|
||||
}
|
||||
if opts.Sync != nil && opts.SyncID == "" {
|
||||
panic("mujica: VolumeChart Sync needs a SyncID")
|
||||
}
|
||||
if opts.Frame.Height == 0 {
|
||||
opts.Frame.Height = 120
|
||||
}
|
||||
ch := newChart(c, opts.Frame)
|
||||
chartMemo(ch, "check", chartID(candles), func() bool { marketCheckCandles(candles); return true })
|
||||
m := &mktCore{c: c, k: Tokens(c), ch: ch, candles: candles, view: view}
|
||||
if s := opts.Sync; s != nil {
|
||||
s.load(opts.SyncID, candles, view)
|
||||
}
|
||||
m.window()
|
||||
hi := chartMemo(ch, "vmax", [3]any{chartID(candles), m.from, m.to}, func() float64 {
|
||||
v := 0.0
|
||||
for i := m.from; i < m.to; i++ {
|
||||
v = max(v, candles[i].Volume)
|
||||
}
|
||||
return v
|
||||
})
|
||||
k := m.k
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
geom := func(r ui.Rect) (ui.Rect, internal.Band, internal.Linear, []float64, float64) {
|
||||
plot := chartInset(r, 60, 4)
|
||||
lo, top, step := internal.Nice(0, max(hi, 1), 2)
|
||||
return plot, internal.Band{N: m.to - m.from, R0: plot.X, R1: plot.X + plot.W, Pad: 0.3},
|
||||
internal.Linear{D0: lo, D1: top, R0: float64(plot.Y + plot.H), R1: float64(plot.Y)}, internal.Ticks(lo, top, step), step
|
||||
}
|
||||
spec := chartSpec{n: len(candles)}
|
||||
if m.to <= m.from {
|
||||
spec.n = 0
|
||||
}
|
||||
spec.pick = func(r ui.Rect, x, y float32) int {
|
||||
plot, band, _, _, _ := geom(r)
|
||||
if !plot.Contains(x, y) {
|
||||
return -1
|
||||
}
|
||||
return max(m.from, min(m.to-1, m.from+int((x-band.R0)/band.Step())))
|
||||
}
|
||||
spec.readout = func(i int) string {
|
||||
cd := candles[i]
|
||||
return strings.Join([]string{cd.Time.Format("2006-01-02 15:04"), msg(c, "candlestick_chart.volume") + " " + statisticFormat(cd.Volume, 0),
|
||||
trendWord(c, trendSign(cd.Close-cd.Open))}, " · ")
|
||||
}
|
||||
spec.draw = func(p *ui.Painter, r ui.Rect, active int) {
|
||||
plot, band, ys, ticks, step := geom(r)
|
||||
internal.AxisY(p, plot, ys, ticks, func(v float64) string { return volumeShort(v, step) }, size, k)
|
||||
w := max(1, band.Width())
|
||||
at := active
|
||||
if at < 0 && opts.Sync != nil {
|
||||
at = opts.Sync.cursorIndex(opts.SyncID, candles)
|
||||
}
|
||||
for i := m.from; i < m.to; i++ {
|
||||
cd := candles[i]
|
||||
x := internal.Snap(p, band.Center(i-m.from))
|
||||
y := ys.Map(cd.Volume)
|
||||
bar := ui.Rect{X: x - w/2, Y: y, W: w, H: plot.Y + plot.H - y}
|
||||
up := cd.Close >= cd.Open
|
||||
col := trendColor(c, 1)
|
||||
if !up {
|
||||
col = trendColor(c, -1)
|
||||
}
|
||||
if up && w >= 3 {
|
||||
p.Fill(bar, k.Surface, 0)
|
||||
p.Stroke(bar, col, 0, 1)
|
||||
} else {
|
||||
p.Fill(bar, col, 0)
|
||||
}
|
||||
}
|
||||
m.st0().drawn = m.to - m.from
|
||||
if at >= m.from && at < m.to {
|
||||
x := internal.Snap(p, band.Center(at-m.from))
|
||||
p.Line(x, plot.Y, x, plot.Y+plot.H, 1, k.TextMuted)
|
||||
}
|
||||
}
|
||||
res, plot := ch.build(spec)
|
||||
if plot != nil && !plot.IsDisabled() {
|
||||
room := m.room()
|
||||
switch {
|
||||
case plot.Shortcut(ui.Shift, ui.KeyLeft):
|
||||
marketPan(view, len(candles), room, -max(1, (m.to-m.from)/10))
|
||||
case plot.Shortcut(ui.Shift, ui.KeyRight):
|
||||
marketPan(view, len(candles), room, max(1, (m.to-m.from)/10))
|
||||
}
|
||||
if f := ch.st.focus; f >= 0 && (f < m.from || f >= m.to) {
|
||||
ch.st.focus = max(m.from, min(m.to-1, f))
|
||||
}
|
||||
}
|
||||
if s := opts.Sync; s != nil {
|
||||
s.store(opts.SyncID, candles, view, res.Point)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// st0 is the chart's market state, made on first use.
|
||||
func (m *mktCore) st0() *mktState {
|
||||
if m.st == nil {
|
||||
m.st = *ui.Local(m.ch.res.Element, "mujica.mkt", func() *mktState { return &mktState{grab: -1, focus: -1} })
|
||||
}
|
||||
return m.st
|
||||
}
|
||||
|
||||
// volumeShort writes a volume tick as 1.2K, 3M or 4B.
|
||||
func volumeShort(v, step float64) string {
|
||||
for _, u := range []struct {
|
||||
div float64
|
||||
unit string
|
||||
}{{1e9, "B"}, {1e6, "M"}, {1e3, "K"}} {
|
||||
if math.Abs(v) >= u.div {
|
||||
return internal.FormatTick(v/u.div, step/u.div) + u.unit
|
||||
}
|
||||
}
|
||||
return internal.FormatTick(v, step)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
func TestVolumeChartSharesViewAndReads(t *testing.T) {
|
||||
candles := sampleCandles(100000)
|
||||
view := MarketView{Count: 60}
|
||||
var price, vol ChartResult
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Width(800).Children(func() {
|
||||
price = CandlestickChart(c, candles, &view, CandlestickOptions{Frame: ChartFrame{Label: "Price"}})
|
||||
vol = VolumeChart(c, candles, &view, VolumeChartOptions{Frame: ChartFrame{Label: "Volume"}})
|
||||
})
|
||||
}, 820, 600)
|
||||
tt.Frame()
|
||||
if d := mktState0(vol).drawn; d != 60 {
|
||||
t.Fatalf("volume drew %d bars", d)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
found := false
|
||||
for _, s := range tt.Texts() {
|
||||
found = found || strings.HasPrefix(s, candles[99999].Time.Format("2006-01-02 15:04")+" · Vol ")
|
||||
}
|
||||
if !found || vol.Point != 99999 {
|
||||
t.Fatalf("readout %q point %d", tt.Texts(), vol.Point)
|
||||
}
|
||||
tt.Key(ui.Shift, ui.KeyLeft)
|
||||
if view.End == 0 {
|
||||
t.Fatal("Shift+Left on volume did not pan the shared view")
|
||||
}
|
||||
_ = price
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("nil view accepted")
|
||||
}
|
||||
}()
|
||||
ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); VolumeChart(c, candles, nil, VolumeChartOptions{}) }, 200, 100)
|
||||
}
|
||||
|
||||
func TestVolumeChartSnapshot(t *testing.T) {
|
||||
candles := sampleCandles(80)
|
||||
view := MarketView{Count: 60}
|
||||
snapshot(t, "volume_chart", 640, 180, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(12).Children(func() {
|
||||
VolumeChart(c, candles, &view, VolumeChartOptions{Frame: ChartFrame{Label: "Volume"}})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user