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>
| M | internal/ | 8 | ++- |
| M | internal/ | 9 | +++ |
| A | internal/ | 81 | +++++++++++++++++++++++ |
| A | internal/ | 106 | ++++++++++++++++++++++++++++++ |
| M | internal/ | 42 | ------------ |
@@ -23,11 +23,14 @@ const (
2323 ChartBar // horizontal bars2424 ChartLine // one line per series2525 ChartPie // the first series as slices of a whole26+ ChartArea // lines filled down to the axis27+ ChartScatter // points at X, Y: the first series is X2628 )2729 2830 // chartTypeNames names every type, as files store it, in the order the2931 // chart editor offers them.30-var chartTypeNames = [...]string{ChartColumn: "column", ChartBar: "bar", ChartLine: "line", ChartPie: "pie"}32+var chartTypeNames = [...]string{ChartColumn: "column", ChartBar: "bar", ChartLine: "line", ChartPie: "pie",33+ ChartArea: "area", ChartScatter: "scatter"}3134 3235 // ChartTypes lists the types in the order the chart editor offers them.3336 var ChartTypes = func() []ChartType {@@ -73,6 +76,9 @@ type Chart struct {
7376 Title string7477 At Addr // the cell under the top-left corner7578 W, H int // size in terminal cells79+ // ChartOptions are the stacking, axis and legend settings of Sheets'80+ // Customize tab; see chartopts.go.81+ ChartOptions7682 }7783 7884 // Chart size limits, in terminal cells.@@ -188,6 +188,10 @@ func TestChartFileRoundTrip(t *testing.T) {
188188 want := []Chart{189189 {Type: ChartLine, Data: rng("A1:C4"), Header: true, Labels: true, Title: "Spend", At: at("E2"), W: 44, H: 14},190190 {Type: ChartPie, Data: rng("B2:B4"), ByRow: true, At: at("A20"), W: 30, H: 12},191+ {Type: ChartArea, Data: rng("A1:C4"), At: at("A30"), W: 30, H: 12,192+ ChartOptions: ChartOptions{Stack: StackPercent, Min: -5, HasMin: true, Max: 0, HasMax: true, NoGrid: true, Legend: LegendRight}},193+ {Type: ChartScatter, Data: rng("B1:C4"), At: at("A45"), W: 30, H: 12,194+ ChartOptions: ChartOptions{Trend: true, Log: true, Legend: LegendNone}},191195 }192196 for _, c := range want {193197 s.AddChart(c)@@ -199,6 +203,11 @@ func TestChartFileRoundTrip(t *testing.T) {
199203 if !strings.Contains(buf.String(), `"charts": [`+"\n "+`{"type":"line","data":"A1:C4","at":"E2","width":44,"height":14,"header":true,"labels":true,"title":"Spend"}`) {200204 t.Errorf("charts not one per line:\n%s", buf.String())201205 }206+ // Options at their defaults are left out; the others are fields.207+ if !strings.Contains(buf.String(), `"stack":"percent","min":-5,"max":0,"gridlines":false,"legend":"right"}`) ||208+ !strings.Contains(buf.String(), `"type":"scatter","data":"B1:C4","at":"A45","width":30,"height":12,"trend":true,"log":true,"legend":"none"}`) {209+ t.Errorf("chart options:\n%s", buf.String())210+ }202211 got, err := Read(&buf)203212 if err != nil {204213 t.Fatal(err)@@ -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+}@@ -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+}@@ -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"`