package chart import ( "math" "strconv" "time" "github.com/egoist/mygo/ui" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/core" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/internal" "git.zacharyzhang.com/ZacharyZhang-NY/MujicaUI/theme" ) // ChartAxisKind is how an axis maps values. type ChartAxisKind int const ( AxisLinear ChartAxisKind = iota // AxisLog maps powers of ten; Min must be positive. AxisLog // AxisTime maps From..To; values are Unix seconds. AxisTime // AxisCategory gives each category a band; values are indices. AxisCategory ) // ChartSide is where an axis runs along the plot. type ChartSide int const ( SideBottom ChartSide = iota SideLeft SideRight SideTop ) // ChartAxisOptions configure a ChartAxis. type ChartAxisOptions struct { Kind ChartAxisKind Side ChartSide // Min and Max span a linear or log axis. Min, Max float64 // From and To span a time axis. From, To time.Time Categories []string // Ticks fixes the tick values; nil picks them. Ticks []float64 // Format overrides tick and readout labels. Format func(v float64) string } // ChartAxisPart maps values and draws an axis. type ChartAxisPart struct { opts ChartAxisOptions lo, hi float64 ticks []float64 step float64 layout string k theme.Tokens size float32 } // ChartAxis resolves an axis: its domain, ticks and labels. func ChartAxis(c *ui.Context, opts ChartAxisOptions) ChartAxisPart { a := ChartAxisPart{opts: opts, k: core.Tokens(c), size: core.FontSize(c, theme.CaptionSize)} switch opts.Kind { case AxisLinear, AxisLog: a.lo, a.hi = opts.Min, opts.Max if !(a.hi > a.lo) || math.IsInf(a.lo, 0) || math.IsInf(a.hi, 0) || opts.Kind == AxisLog && a.lo <= 0 { panic("mujica: ChartAxis needs Min < Max, and Min > 0 for a log axis") } case AxisTime: if !opts.To.After(opts.From) { panic("mujica: ChartAxis time axis needs From before To") } a.lo, a.hi = chartSeconds(opts.From), chartSeconds(opts.To) case AxisCategory: if len(opts.Categories) == 0 { panic("mujica: ChartAxis category axis needs Categories") } a.lo, a.hi = -0.5, float64(len(opts.Categories))-0.5 default: panic("mujica: unknown ChartAxisKind") } if opts.Side < SideBottom || opts.Side > SideTop { panic("mujica: unknown ChartSide") } a.ticks = opts.Ticks for i, v := range a.ticks { if math.IsNaN(v) || math.IsInf(v, 0) || v < a.lo || v > a.hi || i > 0 && v <= a.ticks[i-1] { panic("mujica: ChartAxis ticks must ascend inside the domain") } } if a.ticks == nil { a.ticks = a.autoTicks() } return a } func chartSeconds(t time.Time) float64 { return float64(t.UnixNano()) / 1e9 } // autoTicks picks round ticks inside the domain. func (a *ChartAxisPart) autoTicks() []float64 { var out []float64 switch a.opts.Kind { case AxisLog: // Short spans include powers of two and five. mants := []float64{1} if a.hi/a.lo < 1000 { mants = []float64{1, 2, 5} } for e := math.Floor(math.Log10(a.lo)); math.Pow(10, e) <= a.hi*(1+1e-9); e++ { for _, m := range mants { if v := m * math.Pow(10, e); v >= a.lo*(1-1e-9) && v <= a.hi*(1+1e-9) { out = append(out, v) } } } case AxisTime: ts, layout := internal.TimeTicks(a.opts.From, a.opts.To, 5) a.layout = layout for _, t := range ts { out = append(out, chartSeconds(t)) } case AxisCategory: n := len(a.opts.Categories) step := max(1, (n+63)/64) for i := 0; i < n; i += step { out = append(out, float64(i)) } if out[len(out)-1] != float64(n-1) { out = append(out, float64(n-1)) } default: lo, hi, step := internal.Nice(a.lo, a.hi, 5) a.step = step for _, t := range internal.Ticks(lo, hi, step) { if t >= a.lo-step*1e-9 && t <= a.hi+step*1e-9 { out = append(out, t) } } } return out } // horizontal reports whether the axis runs across the plot. func (a ChartAxisPart) horizontal() bool { return a.opts.Side == SideBottom || a.opts.Side == SideTop } // fraction returns the normalized position along the axis. func (a ChartAxisPart) fraction(v float64) float64 { if a.opts.Kind == AxisLog { return (math.Log10(v) - math.Log10(a.lo)) / (math.Log10(a.hi) - math.Log10(a.lo)) } return (v - a.lo) / (a.hi - a.lo) } // Map returns the pixel position of v. func (a ChartAxisPart) Map(plot ui.Rect, v float64) float32 { f := float32(a.fraction(v)) if a.horizontal() { return plot.X + f*plot.W } return plot.Y + plot.H - f*plot.H } // Invert returns the value at a pixel position. func (a ChartAxisPart) Invert(plot ui.Rect, px float32) float64 { f := float64((px - plot.X) / plot.W) if !a.horizontal() { f = float64((plot.Y + plot.H - px) / plot.H) } if a.opts.Kind == AxisLog { return math.Pow(10, math.Log10(a.lo)+f*(math.Log10(a.hi)-math.Log10(a.lo))) } return a.lo + f*(a.hi-a.lo) } // Ticks returns the tick values. func (a ChartAxisPart) Ticks() []float64 { return a.ticks } // Label writes v as the axis does. func (a ChartAxisPart) Label(v float64) string { switch { case a.opts.Format != nil: return a.opts.Format(v) case a.opts.Kind == AxisCategory: return a.opts.Categories[max(0, min(len(a.opts.Categories)-1, int(math.Round(v))))] case a.opts.Kind == AxisTime: layout := a.layout if layout == "" { layout = "2006-01-02 15:04" } return chartTime(v).Format(layout) case a.opts.Kind == AxisLog: return strconv.FormatFloat(v, 'f', -1, 64) case a.step > 0: return internal.FormatTick(v, a.step) } return strconv.FormatFloat(v, 'g', 6, 64) } // readout uses finer precision than ticks. func (a ChartAxisPart) readout(v float64) string { switch { case a.opts.Format != nil, a.opts.Kind == AxisCategory, a.opts.Kind == AxisTime: return a.Label(v) case a.opts.Kind == AxisLog: return strconv.FormatFloat(v, 'g', 3, 64) case a.step > 0: return internal.FormatTick(v, a.step/10) } return strconv.FormatFloat(v, 'g', 6, 64) } // Draw paints the axis line and tick labels. func (a ChartAxisPart) Draw(p *ui.Painter, plot ui.Rect) { spacing := a.size * 2 if a.horizontal() { spacing = a.size * 4 } ticks := a.drawTicks(plot, spacing) pos := make([]float32, len(ticks)) labels := make([]string, len(ticks)) for i, t := range ticks { pos[i], labels[i] = a.Map(plot, t), a.Label(t) } k := a.k switch a.opts.Side { case SideBottom: internal.Rule(p, plot.X, plot.X+plot.W, plot.Y+plot.H, k.ControlBorder) internal.LabelsX(p, plot, plot.Y+plot.H+6, pos, labels, false, a.size, k) case SideTop: internal.Rule(p, plot.X, plot.X+plot.W, plot.Y, k.ControlBorder) internal.LabelsX(p, plot, plot.Y-6, pos, labels, true, a.size, k) case SideLeft: internal.RuleV(p, plot.X, plot.Y, plot.Y+plot.H, k.ControlBorder) internal.LabelsY(p, plot.X-6, pos, labels, false, a.size, k) case SideRight: internal.RuleV(p, plot.X+plot.W, plot.Y, plot.Y+plot.H, k.ControlBorder) internal.LabelsY(p, plot.X+plot.W+6, pos, labels, true, a.size, k) } } func (a ChartAxisPart) drawTicks(plot ui.Rect, spacing float32) []float64 { length := plot.H if a.horizontal() { length = plot.W } limit := max(2, int(length/max(spacing, 1))) if len(a.ticks) <= limit { return a.ticks } step := (len(a.ticks) + limit - 1) / limit out := make([]float64, 0, limit+1) for i := 0; i < len(a.ticks); i += step { out = append(out, a.ticks[i]) } if out[len(out)-1] != a.ticks[len(a.ticks)-1] { out = append(out, a.ticks[len(a.ticks)-1]) } return out }