cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Chart area and scatter types and chart options in the sheet

Dami committed 886c08c
Charts gain two types (area, scatter) and the options of Sheets'
Customize tab: stacking (stacked, 100%), a scatter trend line, fixed
ends and a log scale for the value axis, gridlines and the legend
position. Options are plain fields so charts stay comparable, and the
file writes only those off their defaults, so no version bump.
commit
886c08c6099566618a7cb91eb7dcb1f24dbd7771
parent
fb9d110c486f6ce4a9cc78d2cf661559fe39da83
author
Dami <dami.etoile@gmail.com>
5 files changed, +203 -43
Minternal/sheet/chart.go8++-
Minternal/sheet/chart_test.go9+++
Ainternal/sheet/chartfile.go81+++++++++++++++++++++++
Ainternal/sheet/chartopts.go106++++++++++++++++++++++++++++++
Minternal/sheet/file.go42------------
M internal/sheet/chart.go +7 -1
M internal/sheet/chart_test.go +9 -0
A internal/sheet/chartfile.go +81 -0
@@ -0,0 +1,81 @@
1+package sheet2+3+import (4+ "encoding/json"5+ "fmt"6+)7+8+// fileChart is a chart, one per line after the cells:9+//10+// {"type": "column", "data": "A1:C7", "at": "E2", "width": 44, "height": 14, "header": true, "labels": true}11+//12+// Options at their defaults are left out, so they need no version bump:13+// older versions of 012 draw such a chart with the defaults, and older14+// versions still ignore the field and drop the charts. A type they don't15+// know makes them refuse the file.16+type fileChart struct {17+ Type string `json:"type"`18+ Data string `json:"data"`19+ At string `json:"at"`20+ Width int `json:"width"`21+ Height int `json:"height"`22+ ByRow bool `json:"byRow,omitempty"`23+ Header bool `json:"header,omitempty"`24+ Labels bool `json:"labels,omitempty"`25+ Title string `json:"title,omitempty"`26+ // Options: "stacked" or "percent"; a trend line; the ends of the value27+ // axis; a log scale; gridlines, only when off; "right" or "none".28+ Stack string `json:"stack,omitempty"`29+ Trend bool `json:"trend,omitempty"`30+ Min *float64 `json:"min,omitempty"`31+ Max *float64 `json:"max,omitempty"`32+ Log bool `json:"log,omitempty"`33+ Gridlines *bool `json:"gridlines,omitempty"`34+ Legend string `json:"legend,omitempty"`35+}36+37+func encodeChart(c Chart) ([]byte, error) {38+ fc := fileChart{39+ Type: c.Type.String(), Data: c.Data.String(), At: c.At.String(), Width: c.W, Height: c.H,40+ ByRow: c.ByRow, Header: c.Header, Labels: c.Labels, Title: c.Title,41+ Stack: c.Stack.String(), Trend: c.Trend, Log: c.Log,42+ Min: axisEnd(c.Min, c.HasMin), Max: axisEnd(c.Max, c.HasMax),43+ }44+ if c.NoGrid {45+ fc.Gridlines = new(bool)46+ }47+ if c.Legend != LegendBottom {48+ fc.Legend = c.Legend.String()49+ }50+ return json.Marshal(fc)51+}52+53+func decodeChart(fc fileChart) (Chart, error) {54+ t, ok := ParseChartType(fc.Type)55+ if !ok {56+ return Chart{}, fmt.Errorf("unknown chart type %q", fc.Type)57+ }58+ data, ok := ParseRange(fc.Data)59+ if !ok {60+ return Chart{}, fmt.Errorf("invalid chart range %q", fc.Data)61+ }62+ at, ok := ParseAddr(fc.At)63+ if !ok {64+ return Chart{}, fmt.Errorf("invalid chart position %q", fc.At)65+ }66+ c := Chart{Type: t, Data: data, At: at, W: fc.Width, H: fc.Height,67+ ByRow: fc.ByRow, Header: fc.Header, Labels: fc.Labels, Title: fc.Title}68+ // Options a later build writes and this one doesn't know are left at69+ // their defaults, as an older build would.70+ c.Stack, _ = ParseChartStack(fc.Stack)71+ c.Legend, _ = ParseChartLegend(fc.Legend)72+ c.Trend, c.Log = fc.Trend, fc.Log73+ c.NoGrid = fc.Gridlines != nil && !*fc.Gridlines74+ if fc.Min != nil {75+ c.Min, c.HasMin = *fc.Min, true76+ }77+ if fc.Max != nil {78+ c.Max, c.HasMax = *fc.Max, true79+ }80+ return c.clamped(), nil81+}
A internal/sheet/chartopts.go +106 -0
@@ -0,0 +1,106 @@
1+package sheet2+3+import (4+ "math"5+ "slices"6+ "strings"7+)8+9+// ChartOptions are a chart's settings beyond its type and data, as in10+// the Customize tab of Sheets' chart editor. The zero value is Sheets'11+// default: not stacked, no trend line, an automatic linear value axis12+// with gridlines, and the legend at the bottom. Every field is a plain13+// value, so charts stay comparable with ==.14+type ChartOptions struct {15+ // Stack stacks the series of column, bar and area charts.16+ Stack ChartStack17+ // Trend draws a linear trend line through each series of a scatter18+ // chart.19+ Trend bool20+ // Min and Max fix the ends of the value axis when HasMin and HasMax21+ // are set; otherwise the axis fits the data.22+ Min, Max float6423+ HasMin, HasMax bool24+ // Log puts the value axis on a logarithmic scale, leaving out values25+ // that aren't positive.26+ Log bool27+ // NoGrid hides the gridlines.28+ NoGrid bool29+ // Legend is where the legend goes.30+ Legend ChartLegend31+}32+33+// ChartStack is how the series of a chart pile up.34+type ChartStack int35+36+const (37+ StackNone ChartStack = iota // side by side38+ StackNormal // on top of each other39+ StackPercent // on top of each other, as shares of 100%40+)41+42+var chartStackNames = [...]string{StackNone: "", StackNormal: "stacked", StackPercent: "percent"}43+44+// String is the stacking as files store it, "" for none.45+func (s ChartStack) String() string {46+ if s < 0 || int(s) >= len(chartStackNames) {47+ return ""48+ }49+ return chartStackNames[s]50+}51+52+// Title is the stacking as the chart editor shows it.53+func (s ChartStack) Title() string {54+ switch s {55+ case StackNormal:56+ return "Stacked"57+ case StackPercent:58+ return "100% stacked"59+ }60+ return "Not stacked"61+}62+63+// ParseChartStack parses a stacking as String writes it.64+func ParseChartStack(s string) (ChartStack, bool) {65+ i := slices.Index(chartStackNames[:], strings.ToLower(s))66+ return ChartStack(max(i, 0)), i >= 067+}68+69+// Stackable reports whether charts of type t can stack their series.70+func (t ChartType) Stackable() bool {71+ return t == ChartColumn || t == ChartBar || t == ChartArea72+}73+74+// ChartLegend is where a chart's legend goes.75+type ChartLegend int76+77+const (78+ LegendBottom ChartLegend = iota // under the plot, centered79+ LegendRight // a column right of the plot80+ LegendNone // no legend81+)82+83+var chartLegendNames = [...]string{LegendBottom: "bottom", LegendRight: "right", LegendNone: "none"}84+85+// String is the position as files store it.86+func (l ChartLegend) String() string {87+ if l < 0 || int(l) >= len(chartLegendNames) {88+ return chartLegendNames[0]89+ }90+ return chartLegendNames[l]91+}92+93+// ParseChartLegend parses a position as String writes it.94+func ParseChartLegend(s string) (ChartLegend, bool) {95+ i := slices.Index(chartLegendNames[:], strings.ToLower(s))96+ return ChartLegend(max(i, 0)), i >= 097+}98+99+// axisEnd is an optional end of the value axis as files store it: nil100+// for automatic.101+func axisEnd(v float64, ok bool) *float64 {102+ if !ok || math.IsNaN(v) || math.IsInf(v, 0) {103+ return nil104+ }105+ return &v106+}
M internal/sheet/file.go +0 -42
@@ -69,48 +69,6 @@ type fileSheet struct {
6969 Pivot *filePivot `json:"pivot,omitempty"` // version 57070 }7171 72-// fileChart is a chart, one per line after the cells:73-//74-// {"type": "column", "data": "A1:C7", "at": "E2", "width": 44, "height": 14, "header": true, "labels": true}75-//76-// Older versions of 012 ignore the field and drop the charts.77-type fileChart struct {78- Type string `json:"type"`79- Data string `json:"data"`80- At string `json:"at"`81- Width int `json:"width"`82- Height int `json:"height"`83- ByRow bool `json:"byRow,omitempty"`84- Header bool `json:"header,omitempty"`85- Labels bool `json:"labels,omitempty"`86- Title string `json:"title,omitempty"`87-}88-89-func encodeChart(c Chart) ([]byte, error) {90- return json.Marshal(fileChart{91- Type: c.Type.String(), Data: c.Data.String(), At: c.At.String(), Width: c.W, Height: c.H,92- ByRow: c.ByRow, Header: c.Header, Labels: c.Labels, Title: c.Title,93- })94-}95-96-func decodeChart(fc fileChart) (Chart, error) {97- t, ok := ParseChartType(fc.Type)98- if !ok {99- return Chart{}, fmt.Errorf("unknown chart type %q", fc.Type)100- }101- data, ok := ParseRange(fc.Data)102- if !ok {103- return Chart{}, fmt.Errorf("invalid chart range %q", fc.Data)104- }105- at, ok := ParseAddr(fc.At)106- if !ok {107- return Chart{}, fmt.Errorf("invalid chart position %q", fc.At)108- }109- c := Chart{Type: t, Data: data, At: at, W: fc.Width, H: fc.Height,110- ByRow: fc.ByRow, Header: fc.Header, Labels: fc.Labels, Title: fc.Title}111- return c.clamped(), nil112-}113-11472 type fileCell struct {11573 Input string `json:"input,omitempty"`11674 Format string `json:"format,omitempty"`