// Copyright ©2015 The Gonum Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package plot import ( "image/color" "math" "strconv" "time" "gonum.org/v1/plot/font" "gonum.org/v1/plot/text" "gonum.org/v1/plot/vg" "gonum.org/v1/plot/vg/draw" ) // Ticker creates Ticks in a specified range type Ticker interface { // Ticks returns Ticks in a specified range Ticks(min, max float64) []Tick } // Normalizer rescales values from the data coordinate system to the // normalized coordinate system. type Normalizer interface { // Normalize transforms a value x in the data coordinate system to // the normalized coordinate system. Normalize(min, max, x float64) float64 } // An Axis represents either a horizontal or vertical // axis of a plot. type Axis struct { // Min and Max are the minimum and maximum data // values represented by the axis. Min, Max float64 Label struct { // Text is the axis label string. Text string // Padding is the distance between the label and the axis. Padding vg.Length // TextStyle is the style of the axis label text. // For the vertical axis, one quarter turn // counterclockwise will be added to the label // text before drawing. TextStyle text.Style // Position is where the axis label string should be drawn. // The default value is draw.PosCenter, displaying the label // at the center of the axis. // Valid values are [-1,+1], with +1 being the far right/top // of the axis, and -1 the far left/bottom of the axis. Position float64 } // LineStyle is the style of the axis line. draw.LineStyle // Padding between the axis line and the data. Having // non-zero padding ensures that the data is never drawn // on the axis, thus making it easier to see. Padding vg.Length Tick struct { // Label is the TextStyle on the tick labels. Label text.Style // LineStyle is the LineStyle of the tick lines. draw.LineStyle // Length is the length of a major tick mark. // Minor tick marks are half of the length of major // tick marks. Length vg.Length // Marker returns the tick marks. Any tick marks // returned by the Marker function that are not in // range of the axis are not drawn. Marker Ticker } // Scale transforms a value given in the data coordinate system // to the normalized coordinate system of the axis—its distance // along the axis as a fraction of the axis range. Scale Normalizer // AutoRescale enables an axis to automatically adapt its minimum // and maximum boundaries, according to its underlying Ticker. AutoRescale bool } // makeAxis returns a default Axis. // // The default range is (∞, ­∞), and thus any finite // value is less than Min and greater than Max. func makeAxis(o orientation) Axis { a := Axis{ Min: math.Inf(+1), Max: math.Inf(-1), LineStyle: draw.LineStyle{ Color: color.Black, Width: vg.Points(0.5), }, Padding: vg.Points(5), Scale: LinearScale{}, } a.Label.TextStyle = text.Style{ Color: color.Black, Font: font.From(DefaultFont, 12), XAlign: draw.XCenter, YAlign: draw.YBottom, Handler: DefaultTextHandler, } a.Label.Position = draw.PosCenter var ( xalign draw.XAlignment yalign draw.YAlignment ) switch o { case vertical: xalign = draw.XRight yalign = draw.YCenter case horizontal: xalign = draw.XCenter yalign = draw.YTop } a.Tick.Label = text.Style{ Color: color.Black, Font: font.From(DefaultFont, 10), XAlign: xalign, YAlign: yalign, Handler: DefaultTextHandler, } a.Tick.LineStyle = draw.LineStyle{ Color: color.Black, Width: vg.Points(0.5), } a.Tick.Length = vg.Points(8) a.Tick.Marker = DefaultTicks{} return a } // sanitizeRange ensures that the range of the // axis makes sense. func (a *Axis) sanitizeRange() { if math.IsInf(a.Min, 0) { a.Min = 0 } if math.IsInf(a.Max, 0) { a.Max = 0 } if a.Min > a.Max { a.Min, a.Max = a.Max, a.Min } if a.Min == a.Max { a.Min-- a.Max++ } if a.AutoRescale { marks := a.Tick.Marker.Ticks(a.Min, a.Max) for _, t := range marks { a.Min = math.Min(a.Min, t.Value) a.Max = math.Max(a.Max, t.Value) } } } // LinearScale an be used as the value of an Axis.Scale function to // set the axis to a standard linear scale. type LinearScale struct{} var _ Normalizer = LinearScale{} // Normalize returns the fractional distance of x between min and max. func (LinearScale) Normalize(min, max, x float64) float64 { return (x - min) / (max - min) } // LogScale can be used as the value of an Axis.Scale function to // set the axis to a log scale. type LogScale struct{} var _ Normalizer = LogScale{} // Normalize returns the fractional logarithmic distance of // x between min and max. func (LogScale) Normalize(min, max, x float64) float64 { if min <= 0 || max <= 0 || x <= 0 { panic("Values must be greater than 0 for a log scale.") } logMin := math.Log(min) return (math.Log(x) - logMin) / (math.Log(max) - logMin) } // InvertedScale can be used as the value of an Axis.Scale function to // invert the axis using any Normalizer. type InvertedScale struct{ Normalizer } var _ Normalizer = InvertedScale{} // Normalize returns a normalized [0, 1] value for the position of x. func (is InvertedScale) Normalize(min, max, x float64) float64 { return is.Normalizer.Normalize(max, min, x) } // Norm returns the value of x, given in the data coordinate // system, normalized to its distance as a fraction of the // range of this axis. For example, if x is a.Min then the return // value is 0, and if x is a.Max then the return value is 1. func (a Axis) Norm(x float64) float64 { return a.Scale.Normalize(a.Min, a.Max, x) } // drawTicks returns true if the tick marks should be drawn. func (a Axis) drawTicks() bool { return a.Tick.Width > 0 && a.Tick.Length > 0 } // A horizontalAxis draws horizontally across the bottom // of a plot. type horizontalAxis struct { Axis } // size returns the height of the axis. func (a horizontalAxis) size() (h vg.Length) { if a.Label.Text != "" { // We assume that the label isn't rotated. h += a.Label.TextStyle.FontExtents().Descent h += a.Label.TextStyle.Height(a.Label.Text) h += a.Label.Padding } marks := a.Tick.Marker.Ticks(a.Min, a.Max) if len(marks) > 0 { if a.drawTicks() { h += a.Tick.Length } h += tickLabelHeight(a.Tick.Label, marks) } h += a.Width / 2 h += a.Padding return h } // draw draws the axis along the lower edge of a draw.Canvas. func (a horizontalAxis) draw(c draw.Canvas) { var ( x vg.Length y = c.Min.Y ) switch a.Label.Position { case draw.PosCenter: x = c.Center().X case draw.PosRight: x = c.Max.X x -= a.Label.TextStyle.Width(a.Label.Text) / 2 } if a.Label.Text != "" { descent := a.Label.TextStyle.FontExtents().Descent c.FillText(a.Label.TextStyle, vg.Point{X: x, Y: y + descent}, a.Label.Text) y += a.Label.TextStyle.Height(a.Label.Text) y += a.Label.Padding } marks := a.Tick.Marker.Ticks(a.Min, a.Max) ticklabelheight := tickLabelHeight(a.Tick.Label, marks) descent := a.Tick.Label.FontExtents().Descent for _, t := range marks { x := c.X(a.Norm(t.Value)) if !c.ContainsX(x) || t.IsMinor() { continue } c.FillText(a.Tick.Label, vg.Point{X: x, Y: y + ticklabelheight + descent}, t.Label) } if len(marks) > 0 { y += ticklabelheight } else { y += a.Width / 2 } if len(marks) > 0 && a.drawTicks() { len := a.Tick.Length for _, t := range marks { x := c.X(a.Norm(t.Value)) if !c.ContainsX(x) { continue } start := t.lengthOffset(len) c.StrokeLine2(a.Tick.LineStyle, x, y+start, x, y+len) } y += len } c.StrokeLine2(a.LineStyle, c.Min.X, y, c.Max.X, y) } // GlyphBoxes returns the GlyphBoxes for the tick labels. func (a horizontalAxis) GlyphBoxes(p *Plot) []GlyphBox { var ( boxes []GlyphBox yoff font.Length ) if a.Label.Text != "" { x := a.Norm(p.X.Max) switch a.Label.Position { case draw.PosCenter: x = a.Norm(0.5 * (p.X.Max + p.X.Min)) case draw.PosRight: x -= a.Norm(0.5 * a.Label.TextStyle.Width(a.Label.Text).Points()) // FIXME(sbinet): want data coordinates } descent := a.Label.TextStyle.FontExtents().Descent boxes = append(boxes, GlyphBox{ X: x, Rectangle: a.Label.TextStyle.Rectangle(a.Label.Text).Add(vg.Point{Y: yoff + descent}), }) yoff += a.Label.TextStyle.Height(a.Label.Text) yoff += a.Label.Padding } var ( marks = a.Tick.Marker.Ticks(a.Min, a.Max) height = tickLabelHeight(a.Tick.Label, marks) descent = a.Tick.Label.FontExtents().Descent ) for _, t := range marks { if t.IsMinor() { continue } box := GlyphBox{ X: a.Norm(t.Value), Rectangle: a.Tick.Label.Rectangle(t.Label).Add(vg.Point{Y: yoff + height + descent}), } boxes = append(boxes, box) } return boxes } // A verticalAxis is drawn vertically up the left side of a plot. type verticalAxis struct { Axis } // size returns the width of the axis. func (a verticalAxis) size() (w vg.Length) { if a.Label.Text != "" { // We assume that the label isn't rotated. w += a.Label.TextStyle.FontExtents().Descent w += a.Label.TextStyle.Height(a.Label.Text) w += a.Label.Padding } marks := a.Tick.Marker.Ticks(a.Min, a.Max) if len(marks) > 0 { if lwidth := tickLabelWidth(a.Tick.Label, marks); lwidth > 0 { w += lwidth w += a.Label.TextStyle.Width(" ") } if a.drawTicks() { w += a.Tick.Length } } w += a.Width / 2 w += a.Padding return w } // draw draws the axis along the left side of a draw.Canvas. func (a verticalAxis) draw(c draw.Canvas) { var ( x = c.Min.X y vg.Length ) if a.Label.Text != "" { sty := a.Label.TextStyle sty.Rotation += math.Pi / 2 x += a.Label.TextStyle.Height(a.Label.Text) switch a.Label.Position { case draw.PosCenter: y = c.Center().Y case draw.PosTop: y = c.Max.Y y -= a.Label.TextStyle.Width(a.Label.Text) / 2 } descent := a.Label.TextStyle.FontExtents().Descent c.FillText(sty, vg.Point{X: x - descent, Y: y}, a.Label.Text) x += descent x += a.Label.Padding } marks := a.Tick.Marker.Ticks(a.Min, a.Max) if w := tickLabelWidth(a.Tick.Label, marks); len(marks) > 0 && w > 0 { x += w } major := false descent := a.Tick.Label.FontExtents().Descent for _, t := range marks { y := c.Y(a.Norm(t.Value)) if !c.ContainsY(y) || t.IsMinor() { continue } c.FillText(a.Tick.Label, vg.Point{X: x, Y: y + descent}, t.Label) major = true } if major { x += a.Tick.Label.Width(" ") } if a.drawTicks() && len(marks) > 0 { len := a.Tick.Length for _, t := range marks { y := c.Y(a.Norm(t.Value)) if !c.ContainsY(y) { continue } start := t.lengthOffset(len) c.StrokeLine2(a.Tick.LineStyle, x+start, y, x+len, y) } x += len } c.StrokeLine2(a.LineStyle, x, c.Min.Y, x, c.Max.Y) } // GlyphBoxes returns the GlyphBoxes for the tick labels func (a verticalAxis) GlyphBoxes(p *Plot) []GlyphBox { var ( boxes []GlyphBox xoff font.Length ) if a.Label.Text != "" { yoff := a.Norm(p.Y.Max) switch a.Label.Position { case draw.PosCenter: yoff = a.Norm(0.5 * (p.Y.Max + p.Y.Min)) case draw.PosTop: yoff -= a.Norm(0.5 * a.Label.TextStyle.Width(a.Label.Text).Points()) // FIXME(sbinet): want data coordinates } sty := a.Label.TextStyle sty.Rotation += math.Pi / 2 xoff += a.Label.TextStyle.Height(a.Label.Text) descent := a.Label.TextStyle.FontExtents().Descent boxes = append(boxes, GlyphBox{ Y: yoff, Rectangle: sty.Rectangle(a.Label.Text).Add(vg.Point{X: xoff - descent}), }) xoff += descent xoff += a.Label.Padding } marks := a.Tick.Marker.Ticks(a.Min, a.Max) if w := tickLabelWidth(a.Tick.Label, marks); len(marks) != 0 && w > 0 { xoff += w } var ( ext = a.Tick.Label.FontExtents() desc = ext.Height - ext.Ascent // descent + linegap ) for _, t := range marks { if t.IsMinor() { continue } box := GlyphBox{ Y: a.Norm(t.Value), Rectangle: a.Tick.Label.Rectangle(t.Label).Add(vg.Point{X: xoff, Y: desc}), } boxes = append(boxes, box) } return boxes } // DefaultTicks is suitable for the Tick.Marker field of an Axis, // it returns a reasonable default set of tick marks. type DefaultTicks struct{} var _ Ticker = DefaultTicks{} // Ticks returns Ticks in the specified range. func (DefaultTicks) Ticks(min, max float64) []Tick { if max <= min { panic("illegal range") } const suggestedTicks = 3 labels, step, q, mag := talbotLinHanrahan(min, max, suggestedTicks, withinData, nil, nil, nil) majorDelta := step * math.Pow10(mag) if q == 0 { // Simple fall back was chosen, so // majorDelta is the label distance. majorDelta = labels[1] - labels[0] } // Choose a reasonable, but ad // hoc formatting for labels. fc := byte('f') var off int if mag < -1 || 6 < mag { off = 1 fc = 'g' } if math.Trunc(q) != q { off += 2 } prec := minInt(6, maxInt(off, -mag)) ticks := make([]Tick, len(labels)) for i, v := range labels { ticks[i] = Tick{Value: v, Label: strconv.FormatFloat(v, fc, prec, 64)} } var minorDelta float64 // See talbotLinHanrahan for the values used here. switch step { case 1, 2.5: minorDelta = majorDelta / 5 case 2, 3, 4, 5: minorDelta = majorDelta / step default: if majorDelta/2 < dlamchP { return ticks } minorDelta = majorDelta / 2 } // Find the first minor tick not greater // than the lowest data value. var i float64 for labels[0]+(i-1)*minorDelta > min { i-- } // Add ticks at minorDelta intervals when // they are not within minorDelta/2 of a // labelled tick. for { val := labels[0] + i*minorDelta if val > max { break } found := false for _, t := range ticks { if math.Abs(t.Value-val) < minorDelta/2 { found = true } } if !found { ticks = append(ticks, Tick{Value: val}) } i++ } return ticks } func minInt(a, b int) int { if a < b { return a } return b } func maxInt(a, b int) int { if a > b { return a } return b } // LogTicks is suitable for the Tick.Marker field of an Axis, // it returns tick marks suitable for a log-scale axis. type LogTicks struct { // Prec specifies the precision of tick rendering // according to the documentation for strconv.FormatFloat. Prec int } var _ Ticker = LogTicks{} // Ticks returns Ticks in a specified range func (t LogTicks) Ticks(min, max float64) []Tick { if min <= 0 || max <= 0 { panic("Values must be greater than 0 for a log scale.") } val := math.Pow10(int(math.Log10(min))) max = math.Pow10(int(math.Ceil(math.Log10(max)))) var ticks []Tick for val < max { for i := 1; i < 10; i++ { if i == 1 { ticks = append(ticks, Tick{Value: val, Label: formatFloatTick(val, t.Prec)}) } ticks = append(ticks, Tick{Value: val * float64(i)}) } val *= 10 } ticks = append(ticks, Tick{Value: val, Label: formatFloatTick(val, t.Prec)}) return ticks } // ConstantTicks is suitable for the Tick.Marker field of an Axis. // This function returns the given set of ticks. type ConstantTicks []Tick var _ Ticker = ConstantTicks{} // Ticks returns Ticks in a specified range func (ts ConstantTicks) Ticks(float64, float64) []Tick { return ts } // UnixTimeIn returns a time conversion function for the given location. func UnixTimeIn(loc *time.Location) func(t float64) time.Time { return func(t float64) time.Time { return time.Unix(int64(t), 0).In(loc) } } // UTCUnixTime is the default time conversion for TimeTicks. var UTCUnixTime = UnixTimeIn(time.UTC) // TimeTicks is suitable for axes representing time values. type TimeTicks struct { // Ticker is used to generate a set of ticks. // If nil, DefaultTicks will be used. Ticker Ticker // Format is the textual representation of the time value. // If empty, time.RFC3339 will be used Format string // Time takes a float64 value and converts it into a time.Time. // If nil, UTCUnixTime is used. Time func(t float64) time.Time } var _ Ticker = TimeTicks{} // Ticks implements plot.Ticker. func (t TimeTicks) Ticks(min, max float64) []Tick { if t.Ticker == nil { t.Ticker = DefaultTicks{} } if t.Format == "" { t.Format = time.RFC3339 } if t.Time == nil { t.Time = UTCUnixTime } ticks := t.Ticker.Ticks(min, max) for i := range ticks { tick := &ticks[i] if tick.Label == "" { continue } tick.Label = t.Time(tick.Value).Format(t.Format) } return ticks } // A Tick is a single tick mark on an axis. type Tick struct { // Value is the data value marked by this Tick. Value float64 // Label is the text to display at the tick mark. // If Label is an empty string then this is a minor // tick mark. Label string } // IsMinor returns true if this is a minor tick mark. func (t Tick) IsMinor() bool { return t.Label == "" } // lengthOffset returns an offset that should be added to the // tick mark's line to accout for its length. I.e., the start of // the line for a minor tick mark must be shifted by half of // the length. func (t Tick) lengthOffset(len vg.Length) vg.Length { if t.IsMinor() { return len / 2 } return 0 } // tickLabelHeight returns height of the tick mark labels. func tickLabelHeight(sty text.Style, ticks []Tick) vg.Length { maxHeight := vg.Length(0) for _, t := range ticks { if t.IsMinor() { continue } r := sty.Rectangle(t.Label) h := r.Max.Y - r.Min.Y if h > maxHeight { maxHeight = h } } return maxHeight } // tickLabelWidth returns the width of the widest tick mark label. func tickLabelWidth(sty text.Style, ticks []Tick) vg.Length { maxWidth := vg.Length(0) for _, t := range ticks { if t.IsMinor() { continue } r := sty.Rectangle(t.Label) w := r.Max.X - r.Min.X if w > maxWidth { maxWidth = w } } return maxWidth } // formatFloatTick returns a g-formated string representation of v // to the specified precision. func formatFloatTick(v float64, prec int) string { return strconv.FormatFloat(v, 'g', prec, 64) } // TickerFunc is suitable for the Tick.Marker field of an Axis. // It is an adapter which allows to quickly setup a Ticker using a function with an appropriate signature. type TickerFunc func(min, max float64) []Tick var _ Ticker = TickerFunc(nil) // Ticks implements plot.Ticker. func (f TickerFunc) Ticks(min, max float64) []Tick { return f(min, max) }