Gonum/plot time ticker

72 views
Skip to first unread message

Sebastien Binet

unread,
May 22, 2023, 4:31:47 AM5/22/23
to gonum-dev
hi there,

For work, I am drowning in displays of timeseries.
Gonum/plot has a special axis ticker for time but, really, it's a hack.
The major ticks are just what happens to be "round" seconds since Epoch.

has anyone worked on adding a nicer time ticker ?
otherwise, I guess I'll work on something along the lines of:
https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/dates.py

-s

Sebastien Binet

unread,
Jun 2, 2023, 11:27:23 AM6/2/23
to Sebastien Binet, gonum-dev
so, I've come up with something:

- https://git.sr.ht/~sbinet/epok

```go
func ExampleTicks_yearlyPlot() {
cnv := epok.UTCUnixTimeConverter{}

p := plot.New()
p.Title.Text = "Time series"
p.Y.Label.Text = "Goroutines"

p.Y.Min = 0
p.Y.Max = 4
p.X.Tick.Marker = epok.Ticks{
Ruler: epok.Rules{
Major: epok.Rule{
Freq: epok.Yearly,
Range: epok.Range{Step: 5},
},
},
Format: "2006-01-02\n15:04:05",
Converter: cnv,
}

xysFrom := func(vs ...float64) plotter.XYs {
o := make(plotter.XYs, len(vs))
for i := range o {
o[i].X = vs[i]
o[i].Y = float64(i + 1)
}
return o
}
data := xysFrom(
cnv.FromTime(parse("2010-02-03 01:02:03")),
cnv.FromTime(parse("2015-02-03 04:05:06")),
cnv.FromTime(parse("2020-02-03 07:08:09")),
)

line, pnts, err := plotter.NewLinePoints(data)
if err != nil {
log.Fatalf("could not create plotter: %+v", err)
}

line.Color = color.RGBA{B: 255, A: 255}
pnts.Shape = draw.CircleGlyph{}
pnts.Color = color.RGBA{R: 255, A: 255}

p.Add(line, pnts, plotter.NewGrid())

err = p.Save(20*vg.Centimeter, 10*vg.Centimeter, "testdata/yearly.png")
if err != nil {
log.Fatalf("could not save plot: %+v", err)
}
}
```

with the linked output:
- https://git.sr.ht/~sbinet/epok/tree/main/item/testdata/yearly_golden.png

I had to extend the plot.TimeTicks.Time field to a 'Converter' interface
to be able to convert back and forth between time.Time and float64
values.

Building on github.com/teambition/rrule-go, I could relatively easily
generate ticks with a given frequency (yearly, monthly, ...).

I plan to provide an "AutoTicks" ticker that can figure out the best
(for some definition of best) frequency and range so the above example
could be simplified to:

```
p.X.Tick.Marker = epok.AutoTicks{
Format: "...", // perhaps, even w/o Format.
}
```

There's one hurdle though, as can be seen in the 'testdata/yearly.png'
output:

While epok.Ticks{} will generate the following ticks ('+' denotes a
major tick):

+2010-01-01 00:00:00 2011-01-01 00:00:00 2012-01-01 00:00:00
2013-01-01 00:00:00 2014-01-01 00:00:00 +2015-01-01 00:00:00
2016-01-01 00:00:00 2017-01-01 00:00:00 2018-01-01 00:00:00
2019-01-01 00:00:00 +2020-01-01 00:00:00

when given the below min/max x-values:
min=2010-02-03 01:02:03
max=2020-02-03 07:08:09

ie: it will "insert" 2010-01-01 as first major tick.

but the current gonum/plot will drop that first (major) tick as it
is outside of the data canvas area.

that's done here:
- https://github.com/gonum/plot/blob/v0.13.0/axis.go#L269
and here:
- https://github.com/gonum/plot/blob/v0.13.0/axis.go#L285

do you think axes should be allowed to automatically modify their
min/max range ?

-s

PS: I guess, at some point (for pl...@v0.14.0 ?), epok could be merged
into gonum/plot proper.

Dan Kortschak

unread,
Jun 2, 2023, 3:37:36 PM6/2/23
to gonu...@googlegroups.com
On Fri, 2023-06-02 at 17:27 +0200, Sebastien Binet wrote:
> do you think axes should be allowed to automatically modify their
> min/max range ?

Do you mean the axis changes its own min/max or changes the min/max of
the plot?

The first I think I'm OK with since axes have their own minds. I'm less
convinced of the latter. Though having some mechanism for an axis to
indicate its intention would be nice. Given the situation you have
here, if the user wanted, they could ask the axis where it was happy to
stop and give that information to the plot.

Sebastien Binet

unread,
Jun 5, 2023, 3:41:34 AM6/5/23
to Dan Kortschak, gonu...@googlegroups.com
On Fri Jun 2, 2023 at 21:37 CET, 'Dan Kortschak' via gonum-dev wrote:
> On Fri, 2023-06-02 at 17:27 +0200, Sebastien Binet wrote:
> > do you think axes should be allowed to automatically modify their
> > min/max range ?
>
> Do you mean the axis changes its own min/max or changes the min/max of
> the plot?

changing Axis.{Min,Max} would suffice:

```patch
diff --git a/axis.go b/axis.go
index ee921a1..0ec6b3f 100644
--- a/axis.go
+++ b/axis.go
@@ -220,6 +220,24 @@ type horizontalAxis struct {
Axis
}

+func (a horizontalAxis) minmax() (min, max float64) {
+ min = a.Min
+ max = a.Max
+ marks := a.Tick.Marker.Ticks(a.Min, a.Max)
+ if len(marks) <= 0 {
+ return min, max
+ }
+
+ for _, t := range marks {
+ min = math.Min(min, t.Value)
+ max = math.Max(max, t.Value)
+ }
+ return min, max
+}
+
// 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.
diff --git a/plot.go b/plot.go
index be19dd7..934bf9d 100644
--- a/plot.go
+++ b/plot.go
@@ -165,6 +165,7 @@ func (p *Plot) Draw(c draw.Canvas) {
ywidth := y.size()

xheight := x.size()
+ x.Min, x.Max = x.minmax()
x.draw(padX(p, draw.Crop(c, ywidth, 0, 0, 0)))
y.draw(padY(p, draw.Crop(c, 0, 0, xheight, 0)))

@@ -189,6 +190,7 @@ func (p *Plot) DataCanvas(da draw.Canvas) draw.Canvas {
x := horizontalAxis{p.X}
p.Y.sanitizeRange()
y := verticalAxis{p.Y}
+ x.Min, x.Max = x.minmax()
return padY(p, padX(p, draw.Crop(da, y.size(), 0, x.size(), 0)))
}

@@ -236,6 +238,7 @@ func (p *Plot) DrawGlyphBoxes(c draw.Canvas) {

x := horizontalAxis{p.X}
y := verticalAxis{p.Y}
+ x.Min, x.Max = x.minmax()

ywidth := y.size()
xheight := x.size()
```

this could probably be integrated into a new function, say
'newHorizontalAxis(a Axis) horizontalAxis'
(and a newVerticalAxis) which also does the 'sanitizeRange' dance.

-s

Sebastien Binet

unread,
Jun 5, 2023, 4:55:14 AM6/5/23
to Sebastien Binet, Dan Kortschak, gonu...@googlegroups.com
On Mon Jun 5, 2023 at 09:41 CET, 'Sebastien Binet' via gonum-dev wrote:
> On Fri Jun 2, 2023 at 21:37 CET, 'Dan Kortschak' via gonum-dev wrote:
> > On Fri, 2023-06-02 at 17:27 +0200, Sebastien Binet wrote:
> > > do you think axes should be allowed to automatically modify their
> > > min/max range ?
> >
> > Do you mean the axis changes its own min/max or changes the min/max of
> > the plot?
>
> changing Axis.{Min,Max} would suffice:

actually, it wouldn't.
at least, not in the general case: one may end up with an inconsistent
state.

for example, with the change in:
https://github.com/gonum/plot/compare/master...sbinet-gonum:plot:auto-axes?expand=1

one ends up with, e.g., a garbled "plotter/testdata/logscale.png" (among
others).

that's because plotter.Grid and plotter.Function rely on
plot.Plot.{X,Y}.{Min,Max} and on draw.DataCanvas.{X,Y}.{Min,Max} to plot
stuff (see their Plot(...) method).
if we don't update those to be consistent with the "local" X/Y Min/Max
axes' values, then the plot doesn't make sense anymore...

so, (at least) 2 ways to address this:
- introduce a new field to plot.Axis (or plot.Axis.Tick ?), say:
'AutoRescale bool'
- introduce a new interface 'AutoRescaler { AutoRescale() bool }'
that is tested against Axis.Tick.Marker

although, doing it that way makes it more difficult to auto-adjust axes
in an harmonized way on both axes at once. (is it something one would
really want to do ? not sure.)

or implement it some other way ?

-s

Dan Kortschak

unread,
Jun 5, 2023, 6:38:30 AM6/5/23
to gonu...@googlegroups.com
On Mon, 2023-06-05 at 10:54 +0200, 'Sebastien Binet' via gonum-dev
wrote:
>
Yeah, I think this was the intuited concern I had.

Sebastien Binet

unread,
Jun 5, 2023, 7:46:49 AM6/5/23
to Dan Kortschak, gonu...@googlegroups.com
that's now a proper PR:
- https://github.com/gonum/plot/pull/767

-s
Reply all
Reply to author
Forward
0 new messages