diff --git a/axis.go b/axis.go index 4eacdd78..3c329e7b 100644 --- a/axis.go +++ b/axis.go @@ -497,6 +497,129 @@ func (utt UnixTimeTicks) Ticks(min, max float64) []Tick { return ticks } +// TimeTicks is suitable for axes representing time values. +// TimeTicks expects values in Unix time seconds. It will +// adjust the number of ticks according to the specified Width. If +// not specified, Width defaults to 10 centimeters. +type TimeTicks struct { + // Width is the width of the underlying graph, used to calculate + // the number of ticks that can fit properly with their time + // shown. + Width vg.Length +} + +var _ Ticker = TimeTicks{} + +// Inspired by https://github.com/d3/d3-scale/blob/master/src/time.js +var tickRules = []tickRule{ + {time.Millisecond, "15:04:05.000", "15:04:05", ".000"}, + {200 * time.Millisecond, "15:04:05.000", "15:04:05", ".000"}, + {500 * time.Millisecond, "15:04:05.000", "15:04:05", ".000"}, + {time.Second, "15:04:05", "15:04", ":05"}, + {2 * time.Second, "15:04:05", "15:04", ":05"}, + {5 * time.Second, "15:04:05", "15:04", ":05"}, + {15 * time.Second, "Jan 02, 15:04", "Jan 02", "15:04"}, + {30 * time.Second, "15:04:05", "15:04", ":05"}, + {time.Minute, "15:04:05", "15:04", ":05"}, + {2 * time.Minute, "Jan 02, 3:04pm", "Jan 02", "3:04pm"}, + {5 * time.Minute, "Jan 02, 3:04pm", "Jan 02", "3:04pm"}, + {15 * time.Minute, "Jan 02, 3:04pm", "Jan 02", "3:04pm"}, + {30 * time.Minute, "Jan 02, 3:04pm", "Jan 02", "3:04pm"}, + {time.Hour, "Jan 2, 3pm", "Jan 2", "3pm"}, + {3 * time.Hour, "Jan 2, 3pm", "Jan 2", "3pm"}, + {6 * time.Hour, "Jan 2, 3pm", "Jan 2", "3pm"}, + {12 * time.Hour, "Jan 2", "Jan 2", "3pm"}, + {24 * time.Hour, "Jan 2", "Jan", "2"}, + {48 * time.Hour, "Jan 2", "Jan", "2"}, + {7 * 24 * time.Hour, "Jan 2", "Jan", "2"}, + {month, "Jan 2006", "2006", "Jan"}, + {3 * month, "Jan 2006", "2006", "Jan"}, + {6 * month, "Jan 2006", "2006", "Jan"}, + {12 * month, "2006", "", "2006"}, + {2 * year, "2006", "", "2006"}, + {5 * year, "2006", "", "2006"}, + {10 * year, "2006", "", "2006"}, +} + +const month = 31 * 24 * time.Hour +const year = 12 * month + +// tickRule defines a time display format for a given time window (per +// inch). +// +// This assumes a tick about each `durationPerInch`. The long format is +// shown each time the timestamp goes over a certain boundary +// (verified through `watchFormat`). This way you can show `Sep 2, +// 12pm` when you pass midnight after `11pm` on `Sep 1`. +type tickRule struct { + durationPerInch time.Duration // use this rule for a maximum Duration per inch, it is also used as an interval per ticks. + longFormat string // longer format + watchFormat string // show long format when watchFormat changes between ticks + shortFormat string // incremental format, shorter +} + +// Ticks implements plot.Ticker and displays appropriately spaced and +// formatted time labels. +func (t TimeTicks) Ticks(min, max float64) []Tick { + width := t.Width + if width == 0 { + width = 10 * vg.Centimeter + } + + minT := time.Unix(int64(min), 0).UTC() + maxT := time.Unix(int64(max), 0).UTC() + durationPerInch := maxT.Sub(minT) / time.Duration(width/vg.Inch) + + lastElement := len(tickRules) - 1 + rule := tickRules[lastElement] + for idx, tickRule := range tickRules[:lastElement] { + if durationPerInch < tickRules[idx+1].durationPerInch { + rule = tickRule + break + } + } + + timeWindow := rule.durationPerInch + delta := time.Month(timeWindow / month) // in months + start := minT.Truncate(timeWindow) + var lastWatch string + var ticks []Tick + for { + if delta > 0 { + // Count in Months now + start = time.Date(start.Year(), start.Month()+delta, 1, 0, 0, 0, 0, time.UTC) + } else { + start = start.Add(timeWindow) + } + + if start.Before(minT) { + continue + } + if start.After(maxT) { + break + } + + var label string + newWatch := start.Format(rule.watchFormat) + if lastWatch == newWatch { + label = start.Format(rule.shortFormat) + } else { + //TODO: overwrite the first tick with the long form if we + // haven't shown a lonform at all.. instead of always + // showing the longform first. + label = start.Format(rule.longFormat) + } + lastWatch = newWatch + + ticks = append(ticks, Tick{ + Value: float64(start.UnixNano()) / float64(time.Second), + Label: label, + }) + } + + return ticks +} + // A Tick is a single tick mark on an axis. type Tick struct { // Value is the data value marked by this Tick. diff --git a/axis_test.go b/axis_test.go index 32f49b2f..970a4f9f 100644 --- a/axis_test.go +++ b/axis_test.go @@ -8,6 +8,9 @@ import ( "math" "reflect" "testing" + "time" + + "github.com/gonum/plot/vg" ) func TestAxisSmallTick(t *testing.T) { @@ -59,3 +62,176 @@ func labelsOf(ticks []Tick) []string { } return labels } + +func allLabelsOf(ticks []Tick) []string { + var labels []string + for _, t := range ticks { + labels = append(labels, t.Label) + } + return labels +} + +func TestTimeTicks(t *testing.T) { + d := TimeTicks{Width: 4 * vg.Inch} + for _, test := range []struct { + min, max string + want []string + }{ + { + min: "2016-01-01 12:56:30", + max: "2016-01-01 12:56:31", + want: []string{"12:56:30.200", ".400", ".600", ".800", "12:56:31.000"}, + }, + { + min: "2016-01-01 12:56:01", + max: "2016-01-01 12:56:59", + want: []string{"12:56:05", ":10", ":15", ":20", ":25", ":30", ":35", ":40", ":45", ":50", ":55"}, + }, + { + min: "2016-01-01 12:56:30", + max: "2016-01-01 12:57:29", + want: []string{"12:56:35", ":40", ":45", ":50", ":55", "12:57:00", ":05", ":10", ":15", ":20", ":25"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 12:07:00", + want: []string{"12:02:00", "12:03:00", "12:04:00", "12:05:00", "12:06:00", "12:07:00"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 12:17:00", + want: []string{"Jan 01, 12:02pm", "12:04pm", "12:06pm", "12:08pm", "12:10pm", "12:12pm", "12:14pm", "12:16pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 12:28:00", + want: []string{"Jan 01, 12:05pm", "12:10pm", "12:15pm", "12:20pm", "12:25pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 12:35:00", + want: []string{"Jan 01, 12:05pm", "12:10pm", "12:15pm", "12:20pm", "12:25pm", "12:30pm", "12:35pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 12:40:00", + want: []string{"Jan 01, 12:05pm", "12:10pm", "12:15pm", "12:20pm", "12:25pm", "12:30pm", "12:35pm", "12:40pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 12:45:00", + want: []string{"Jan 01, 12:05pm", "12:10pm", "12:15pm", "12:20pm", "12:25pm", "12:30pm", "12:35pm", "12:40pm", "12:45pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 13:05:00", + want: []string{"Jan 01, 12:15pm", "12:30pm", "12:45pm", "1:00pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 13:05:00", + want: []string{"Jan 01, 12:15pm", "12:30pm", "12:45pm", "1:00pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-01 16:05:00", + want: []string{"Jan 1, 1pm", "2pm", "3pm", "4pm"}, + }, + { + min: "2016-01-01 20:01:05", + max: "2016-01-02 07:59:00", + want: []string{"Jan 1, 9pm", "10pm", "11pm", "Jan 2, 12am", "1am", "2am", "3am", "4am", "5am", "6am", "7am"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-02 13:59:00", + want: []string{"Jan 1, 6pm", "Jan 2, 12am", "6am", "12pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-04 13:59:00", + want: []string{"Jan 2", "12pm", "Jan 3", "12pm", "Jan 4", "12pm"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-06 13:59:00", + want: []string{"Jan 2", "3", "4", "5", "6"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-09 13:59:00", + want: []string{"Jan 2", "4", "6", "8"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-01-25 13:59:00", + want: []string{"Jan 2", "4", "6", "8", "10", "12", "14", "16", "18", "20", "22", "24"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-02-06 13:59:00", + want: []string{"Jan 4", "11", "18", "25", "Feb 1"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-02-28 13:59:00", + want: []string{"Jan 4", "11", "18", "25", "Feb 1", "8", "15", "22"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-04-28 13:59:00", + want: []string{"Jan 4", "11", "18", "25", "Feb 1", "8", "15", "22", "29", "Mar 7", "14", "21", "28", "Apr 4", "11", "18", "25"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-09-28 13:59:00", + want: []string{"Feb 2016", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2016-12-28 13:59:00", + want: []string{"Feb 2016", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2017-02-28 13:59:00", + want: []string{"Feb 2016", "May", "Aug", "Nov", "Feb 2017"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2017-08-28 13:59:00", + want: []string{"Feb 2016", "May", "Aug", "Nov", "Feb 2017", "May", "Aug"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2018-08-28 13:59:00", + want: []string{"Feb 2016", "Aug", "Feb 2017", "Aug", "Feb 2018", "Aug"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2020-08-28 13:59:00", + want: []string{"2016", "2017", "2018", "2019", "2020"}, + }, + { + min: "2016-01-01 12:01:05", + max: "2048-08-28 13:59:00", + want: []string{"2017", "2022", "2027", "2032", "2037", "2042", "2047"}, + }, + } { + //fmt.Println("For dates", test.min, test.max) + ticks := d.Ticks(dateToFloat64(test.min), dateToFloat64(test.max)) + got := allLabelsOf(ticks) + if !reflect.DeepEqual(got, test.want) { + t.Errorf("tick labels mismatch:\ndate1: %s\ndate2: %s\ngot: %#v\nwant:%q", test.min, test.max, got, test.want) + } + } +} + +func dateToFloat64(date string) float64 { + t, err := time.Parse("2006-01-02 15:04:05", date) + if err != nil { + panic(err) + } + + return float64(t.UTC().UnixNano()) / float64(time.Second) +}