From c4045cfbbee1f8b8125ed8ee30e1c90f5f884eb1 Mon Sep 17 00:00:00 2001 From: vicanso Date: Sun, 12 Jun 2022 11:55:37 +0800 Subject: [PATCH] feat: support line chart render function --- axis.go | 154 ++++++++++++++------ charts.go | 23 +++ examples/line_chart/main.go | 111 +++++++++++++++ examples/painter/main.go | 54 +++++++- line_chart.go | 134 ++++++++++++++++++ painter.go | 4 +- range.go | 127 +++++++++++++++++ series.go | 270 ++++++++++++++++++++++++++++++++++++ theme.go | 27 +++- xaxis.go | 88 ++++++++++++ yaxis.go | 66 +++++++++ 11 files changed, 1012 insertions(+), 46 deletions(-) create mode 100644 examples/line_chart/main.go create mode 100644 line_chart.go create mode 100644 range.go create mode 100644 series.go create mode 100644 xaxis.go create mode 100644 yaxis.go diff --git a/axis.go b/axis.go index d2b559b..bb2e6a3 100644 --- a/axis.go +++ b/axis.go @@ -39,6 +39,8 @@ func NewAxisPainter(p *Painter, opt AxisPainterOption) *axisPainter { } type AxisPainterOption struct { + // The theme of chart + Theme ColorPalette // The label of axis Data []string // The boundary gap on both sides of a coordinate axis. @@ -70,13 +72,31 @@ type AxisPainterOption struct { func (a *axisPainter) Render() (Box, error) { opt := a.opt - p := a.p + top := a.p + theme := opt.Theme strokeWidth := opt.StrokeWidth if strokeWidth == 0 { strokeWidth = 1 } + font := opt.Font + if font == nil { + font = theme.GetFont() + } + fontColor := opt.FontColor + if fontColor.IsZero() { + fontColor = theme.GetTextColor() + } + fontSize := opt.FontSize + if fontSize == 0 { + fontSize = theme.GetFontSize() + } + strokeColor := opt.StrokeColor + if strokeColor.IsZero() { + strokeColor = theme.GetAxisStrokeColor() + } + tickCount := opt.SplitNumber if tickCount == 0 { tickCount = len(opt.Data) @@ -86,12 +106,17 @@ func (a *axisPainter) Render() (Box, error) { if opt.BoundaryGap != nil && !*opt.BoundaryGap { boundaryGap = false } + isVertical := opt.Position == PositionLeft || + opt.Position == PositionRight labelPosition := "" if !boundaryGap { tickCount-- labelPosition = PositionLeft } + if isVertical && boundaryGap { + labelPosition = PositionCenter + } // TODO 计算unit unit := 1 @@ -99,84 +124,104 @@ func (a *axisPainter) Render() (Box, error) { tickLength := getDefaultInt(opt.TickLength, 5) labelMargin := getDefaultInt(opt.LabelMargin, 5) - textMaxWidth, textMaxHeight := p.MeasureTextMaxWidthHeight(opt.Data) + style := Style{ + StrokeColor: strokeColor, + StrokeWidth: strokeWidth, + Font: font, + FontColor: fontColor, + FontSize: fontSize, + } + top.SetDrawingStyle(style).OverrideTextStyle(style) + + textMaxWidth, textMaxHeight := top.MeasureTextMaxWidthHeight(opt.Data) width := 0 height := 0 // 垂直 - if opt.Position == PositionLeft || - opt.Position == PositionRight { + if isVertical { width = textMaxWidth + tickLength<<1 - height = p.Height() + height = top.Height() } else { - width = p.Width() + width = top.Width() height = tickLength<<1 + textMaxHeight } padding := Box{} switch opt.Position { case PositionTop: - padding.Top = p.Height() - height + padding.Top = top.Height() - height case PositionLeft: - padding.Right = p.Width() - width + padding.Right = top.Width() - width + case PositionRight: + padding.Left = top.Width() - width } - p = p.Child(PainterPaddingOption(padding)) - p.SetDrawingStyle(Style{ - StrokeColor: opt.StrokeColor, - StrokeWidth: strokeWidth, - }).OverrideTextStyle(Style{ - Font: opt.Font, - FontColor: opt.FontColor, - FontSize: opt.FontSize, - }) + + p := top.Child(PainterPaddingOption(padding)) x0 := 0 y0 := 0 x1 := 0 y1 := 0 - ticksPadding := 0 - labelPadding := 0 + ticksPaddingTop := 0 + ticksPaddingLeft := 0 + labelPaddingTop := 0 + labelPaddingLeft := 0 + labelPaddingRight := 0 orient := "" textAlign := "" switch opt.Position { case PositionTop: - labelPadding = labelMargin + labelPaddingTop = labelMargin x1 = p.Width() y0 = labelMargin + int(opt.FontSize) - ticksPadding = int(opt.FontSize) + ticksPaddingTop = int(opt.FontSize) y1 = y0 orient = OrientHorizontal case PositionLeft: + x0 = p.Width() + y0 = 0 + x1 = p.Width() + y1 = p.Height() orient = OrientVertical textAlign = AlignRight + ticksPaddingLeft = textMaxWidth + tickLength + labelPaddingRight = width - textMaxWidth + case PositionRight: + orient = OrientVertical + y1 = p.Height() + labelPaddingLeft = width - textMaxWidth default: - labelPadding = height + labelPaddingTop = height x1 = p.Width() orient = OrientHorizontal } - p.Child(PainterPaddingOption(Box{ - Top: ticksPadding, - })).Ticks(TicksOption{ - Count: tickCount, - Length: tickLength, - Unit: unit, - Orient: orient, - }) - - p.LineStroke([]Point{ - { - X: x0, - Y: y0, - }, - { - X: x1, - Y: y1, - }, - }) + if strokeWidth > 0 { + p.Child(PainterPaddingOption(Box{ + Top: ticksPaddingTop, + Left: ticksPaddingLeft, + })).Ticks(TicksOption{ + Count: tickCount, + Length: tickLength, + Unit: unit, + Orient: orient, + }) + p.LineStroke([]Point{ + { + X: x0, + Y: y0, + }, + { + X: x1, + Y: y1, + }, + }) + } p.Child(PainterPaddingOption(Box{ - Top: labelPadding, + Left: labelPaddingLeft, + Top: labelPaddingTop, + Right: labelPaddingRight, })).MultiText(MultiTextOption{ Align: textAlign, TextList: opt.Data, @@ -184,6 +229,31 @@ func (a *axisPainter) Render() (Box, error) { Unit: unit, Position: labelPosition, }) + // 显示辅助线 + if opt.SplitLineShow { + style.StrokeColor = opt.SplitLineColor + top.OverrideDrawingStyle(style) + if isVertical { + x0 := p.Width() + x1 := top.Width() + if opt.Position == PositionRight { + x0 = 0 + x1 = top.Width() - p.Width() + } + for _, y := range autoDivide(height, tickCount) { + top.LineStroke([]Point{ + { + X: x0, + Y: y, + }, + { + X: x1, + Y: y, + }, + }) + } + } + } return Box{ Bottom: height, diff --git a/charts.go b/charts.go index 445dd7e..591ebea 100644 --- a/charts.go +++ b/charts.go @@ -25,3 +25,26 @@ package charts type Renderer interface { Render() (Box, error) } + +type defaultRenderOption struct { + Theme ColorPalette + Padding Box +} + +func defaultRender(p *Painter, opt defaultRenderOption) *Painter { + p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor()) + if !opt.Padding.IsZero() { + p = p.Child(PainterPaddingOption(opt.Padding)) + } + return p +} + +func doRender(renderers ...Renderer) error { + for _, r := range renderers { + _, err := r.Render() + if err != nil { + return err + } + } + return nil +} diff --git a/examples/line_chart/main.go b/examples/line_chart/main.go new file mode 100644 index 0000000..e15500c --- /dev/null +++ b/examples/line_chart/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + + "github.com/vicanso/go-charts" +) + +func writeFile(buf []byte) error { + tmpPath := "./tmp" + err := os.MkdirAll(tmpPath, 0700) + if err != nil { + return err + } + + file := filepath.Join(tmpPath, "line-chart.png") + err = ioutil.WriteFile(file, buf, 0600) + if err != nil { + return err + } + return nil +} + +func main() { + p, err := charts.NewPainter(charts.PainterOptions{ + Width: 800, + Height: 600, + Type: charts.ChartOutputPNG, + }) + if err != nil { + panic(err) + } + _, err = charts.NewLineChart(p, charts.LineChartOption{ + Padding: charts.Box{ + Left: 10, + Top: 10, + Right: 10, + Bottom: 10, + }, + XAxis: charts.NewXAxisOption([]string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }), + SeriesList: charts.SeriesList{ + charts.NewSeriesFromValues([]float64{ + 120, + 132, + 101, + 134, + 90, + 230, + 210, + }), + charts.NewSeriesFromValues([]float64{ + 220, + 182, + 191, + 234, + 290, + 330, + 310, + }), + charts.NewSeriesFromValues([]float64{ + 150, + 232, + 201, + 154, + 190, + 330, + 410, + }), + charts.NewSeriesFromValues([]float64{ + 320, + 332, + 301, + 334, + 390, + 330, + 320, + }), + charts.NewSeriesFromValues([]float64{ + 820, + 932, + 901, + 934, + 1290, + 1330, + 1320, + }), + }, + }).Render() + if err != nil { + panic(err) + } + + buf, err := p.Bytes() + if err != nil { + panic(err) + } + err = writeFile(buf) + if err != nil { + panic(err) + } +} diff --git a/examples/painter/main.go b/examples/painter/main.go index acbb3ef..5022584 100644 --- a/examples/painter/main.go +++ b/examples/painter/main.go @@ -409,6 +409,7 @@ func main() { Bottom: 20, }) + // grid top += 50 charts.NewGridPainter(p.Child(charts.PainterBoxOption(charts.Box{ Top: top, @@ -422,6 +423,7 @@ func main() { StrokeColor: drawing.ColorBlue, }).Render() + // legend top += 100 charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{ Top: top, @@ -440,6 +442,7 @@ func main() { FontColor: drawing.ColorBlack, }).Render() + // legend top += 30 charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{ Top: top, @@ -460,6 +463,7 @@ func main() { FontColor: drawing.ColorBlack, }).Render() + // legend top += 30 charts.NewLegendPainter(p.Child(charts.PainterBoxOption(charts.Box{ Top: top, @@ -479,6 +483,7 @@ func main() { FontColor: drawing.ColorBlack, }).Render() + // axis bottom top += 100 charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ Top: top, @@ -500,6 +505,7 @@ func main() { FontColor: drawing.ColorBlack, }).Render() + // axis top top += 50 charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ Top: top, @@ -523,11 +529,12 @@ func main() { FontColor: drawing.ColorBlack, }).Render() + // axis left top += 50 charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ Top: top, Left: 10, - Right: p.Width() - 1, + Right: 60, Bottom: top + 200, })), charts.AxisPainterOption{ Position: charts.PositionLeft, @@ -544,6 +551,51 @@ func main() { FontSize: 12, FontColor: drawing.ColorBlack, }).Render() + // axis right + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 100, + Right: 150, + Bottom: top + 200, + })), charts.AxisPainterOption{ + Position: charts.PositionRight, + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + StrokeColor: drawing.ColorBlack, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + + // axis left no tick + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 150, + Right: 300, + Bottom: top + 200, + })), charts.AxisPainterOption{ + BoundaryGap: charts.FalseFlag(), + Position: charts.PositionLeft, + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + FontSize: 12, + FontColor: drawing.ColorBlack, + SplitLineShow: true, + SplitLineColor: drawing.ColorBlack.WithAlpha(100), + }).Render() buf, err := p.Bytes() if err != nil { diff --git a/line_chart.go b/line_chart.go new file mode 100644 index 0000000..9640087 --- /dev/null +++ b/line_chart.go @@ -0,0 +1,134 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/wcharczuk/go-chart/v2" +) + +type lineChart struct { + p *Painter + opt *LineChartOption +} + +func NewLineChart(p *Painter, opt LineChartOption) *lineChart { + if opt.Theme == nil { + opt.Theme = NewTheme("") + } + return &lineChart{ + p: p, + opt: &opt, + } +} + +type LineChartOption struct { + Theme ColorPalette + // The data series list + SeriesList SeriesList + // The x axis option + XAxis XAxisOption + // The padding of line chart + Padding Box + // The y axis option + YAxis YAxisOption +} + +func (l *lineChart) Render() (Box, error) { + p := l.p + opt := l.opt + p = defaultRender(p, defaultRenderOption{ + Theme: opt.Theme, + Padding: opt.Padding, + }) + + seriesList := opt.SeriesList + seriesList.init() + // 过滤前先计算最大最小值 + max, min := seriesList.GetMaxMin() + + seriesList = seriesList.Filter(ChartTypeLine) + + // Y轴 + yr := NewRange(AxisRangeOption{ + Min: min, + Max: max, + // 高度需要减去x轴的高度 + Size: p.Height() - defaultXAxisHeight, + DivideCount: defaultAxisDivideCount, + }) + if opt.YAxis.Theme == nil { + opt.YAxis.Theme = opt.Theme + } + opt.YAxis.Data = yr.Values() + reverseStringSlice(opt.YAxis.Data) + yAxis := NewLeftYAxis(p, opt.YAxis) + yAxisBox, err := yAxis.Render() + if err != nil { + return chart.BoxZero, err + } + seriesPainter := p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, + Left: yAxisBox.Width(), + })) + + if opt.XAxis.Theme == nil { + opt.XAxis.Theme = opt.Theme + } + xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{ + Left: yAxisBox.Width(), + })), opt.XAxis) + + xDivideValues := autoDivide(seriesPainter.Width(), len(opt.XAxis.Data)) + xValues := make([]int, len(xDivideValues)-1) + for i := 0; i < len(xDivideValues)-1; i++ { + xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1 + } + for index, series := range seriesList { + seriesColor := opt.Theme.GetSeriesColor(index) + seriesPainter.SetDrawingStyle(Style{ + StrokeColor: seriesColor, + StrokeWidth: 2, + FillColor: seriesColor, + }) + points := make([]Point, 0) + for i, item := range series.Data { + h := yr.getRestHeight(item.Value) + p := Point{ + X: xValues[i], + Y: h, + } + points = append(points, p) + } + seriesPainter.LineStroke(points) + seriesPainter.Dots(points) + } + + err = doRender( + xAxis, + ) + if err != nil { + return chart.BoxZero, err + } + + return p.box, nil +} diff --git a/painter.go b/painter.go index fb18510..75d4a38 100644 --- a/painter.go +++ b/painter.go @@ -636,7 +636,7 @@ func (p *Painter) MultiText(opt MultiTextOption) *Painter { x := 0 y := 0 if isVertical { - y = start - box.Height()>>1 + y = start + box.Height()>>1 switch opt.Align { case AlignRight: x = width - box.Width() @@ -700,7 +700,7 @@ func (p *Painter) Grid(opt GridOption) *Painter { func (p *Painter) Dots(points []Point) *Painter { for _, item := range points { - p.Circle(5, item.X, item.Y) + p.Circle(3, item.X, item.Y) } p.FillStroke() return p diff --git a/range.go b/range.go new file mode 100644 index 0000000..399c449 --- /dev/null +++ b/range.go @@ -0,0 +1,127 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "math" +) + +const defaultAxisDivideCount = 6 + +type axisRange struct { + divideCount int + min float64 + max float64 + size int + boundary bool +} + +type AxisRangeOption struct { + Min float64 + Max float64 + Size int + Boundary bool + DivideCount int +} + +func NewRange(opt AxisRangeOption) axisRange { + max := opt.Max + min := opt.Min + + max += math.Abs(max * 0.1) + min -= math.Abs(min * 0.1) + divideCount := opt.DivideCount + r := math.Abs(max - min) + + // 最小单位计算 + unit := 2 + if r > 10 { + unit = 4 + } + if r > 30 { + unit = 5 + } + if r > 100 { + unit = 10 + } + if r > 200 { + unit = 20 + } + unit = int((r/float64(divideCount))/float64(unit))*unit + unit + + if min != 0 { + isLessThanZero := min < 0 + min = float64(int(min/float64(unit)) * unit) + // 如果是小于0,int的时候向上取整了,因此调整 + if min < 0 || + (isLessThanZero && min == 0) { + min -= float64(unit) + } + } + max = min + float64(unit*divideCount) + return axisRange{ + divideCount: divideCount, + min: min, + max: max, + size: opt.Size, + boundary: opt.Boundary, + } +} + +func (r axisRange) Values() []string { + offset := (r.max - r.min) / float64(r.divideCount) + values := make([]string, 0) + for i := 0; i <= r.divideCount; i++ { + v := r.min + float64(i)*offset + value := commafWithDigits(v) + values = append(values, value) + } + return values +} + +func (r *axisRange) getHeight(value float64) int { + v := (value - r.min) / (r.max - r.min) + return int(v * float64(r.size)) +} + +func (r *axisRange) getRestHeight(value float64) int { + return r.size - r.getHeight(value) +} + +func (r *axisRange) GetRange(index int) (float64, float64) { + unit := float64(r.size) / float64(r.divideCount) + return unit * float64(index), unit * float64(index+1) +} +func (r *axisRange) AutoDivide() []int { + return autoDivide(r.size, r.divideCount) +} + +func (r *axisRange) getWidth(value float64) int { + v := value / (r.max - r.min) + // 移至居中 + if r.boundary && + r.divideCount != 0 { + v += 1 / float64(r.divideCount*2) + } + return int(v * float64(r.size)) +} diff --git a/series.go b/series.go new file mode 100644 index 0000000..07bd5d9 --- /dev/null +++ b/series.go @@ -0,0 +1,270 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +package charts + +import ( + "math" + "strings" + + "github.com/dustin/go-humanize" + "github.com/wcharczuk/go-chart/v2" +) + +type SeriesData struct { + // The value of series data + Value float64 + // The style of series data + Style Style +} + +func NewSeriesFromValues(values []float64, chartType ...string) Series { + s := Series{ + Data: NewSeriesDataFromValues(values), + } + if len(chartType) != 0 { + s.Type = chartType[0] + } + return s +} + +func NewSeriesDataFromValues(values []float64) []SeriesData { + data := make([]SeriesData, len(values)) + for index, value := range values { + data[index] = SeriesData{ + Value: value, + } + } + return data +} + +type SeriesLabel struct { + // Data label formatter, which supports string template. + // {b}: the name of a data item. + // {c}: the value of a data item. + // {d}: the percent of a data item(pie chart). + Formatter string + // The color for label + Color Color + // Show flag for label + Show bool + // Distance to the host graphic element. + Distance int +} + +const ( + SeriesMarkDataTypeMax = "max" + SeriesMarkDataTypeMin = "min" + SeriesMarkDataTypeAverage = "average" +) + +type SeriesMarkData struct { + // The mark data type, it can be "max", "min", "average". + // The "average" is only for mark line + Type string +} +type SeriesMarkPoint struct { + // The width of symbol, default value is 30 + SymbolSize int + // The mark data of series mark point + Data []SeriesMarkData +} +type SeriesMarkLine struct { + // The mark data of series mark line + Data []SeriesMarkData +} +type Series struct { + index int + // The type of series, it can be "line", "bar" or "pie". + // Default value is "line" + Type string + // The data list of series + Data []SeriesData + // The Y axis index, it should be 0 or 1. + // Default value is 1 + YAxisIndex int + // The style for series + Style chart.Style + // The label for series + Label SeriesLabel + // The name of series + Name string + // Radius for Pie chart, e.g.: 40%, default is "40%" + Radius string + // Mark point for series + MarkPoint SeriesMarkPoint + // Make line for series + MarkLine SeriesMarkLine + // Max value of series + Min *float64 + // Min value of series + Max *float64 +} +type SeriesList []Series + +func (sl SeriesList) init() { + if sl[len(sl)-1].index != 0 { + return + } + for i := 0; i < len(sl); i++ { + if sl[i].Type == "" { + sl[i].Type = ChartTypeLine + } + sl[i].index = i + } +} + +func (sl SeriesList) Filter(chartType string) SeriesList { + arr := make(SeriesList, 0) + for index, item := range sl { + if item.Type == chartType { + arr = append(arr, sl[index]) + } + } + return arr +} + +// GetMaxMin get max and min value of series list +func (sl SeriesList) GetMaxMin() (float64, float64) { + min := math.MaxFloat64 + max := -math.MaxFloat64 + for _, series := range sl { + for _, item := range series.Data { + if item.Value > max { + max = item.Value + } + if item.Value < min { + min = item.Value + } + } + } + return max, min +} + +type PieSeriesOption struct { + Radius string + Label SeriesLabel + Names []string +} + +func NewPieSeriesList(values []float64, opts ...PieSeriesOption) SeriesList { + result := make([]Series, len(values)) + var opt PieSeriesOption + if len(opts) != 0 { + opt = opts[0] + } + for index, v := range values { + name := "" + if index < len(opt.Names) { + name = opt.Names[index] + } + s := Series{ + Type: ChartTypePie, + Data: []SeriesData{ + { + Value: v, + }, + }, + Radius: opt.Radius, + Label: opt.Label, + Name: name, + } + result[index] = s + } + return result +} + +type seriesSummary struct { + MaxIndex int + MaxValue float64 + MinIndex int + MinValue float64 + AverageValue float64 +} + +func (s *Series) Summary() seriesSummary { + minIndex := -1 + maxIndex := -1 + minValue := math.MaxFloat64 + maxValue := -math.MaxFloat64 + sum := float64(0) + for j, item := range s.Data { + if item.Value < minValue { + minIndex = j + minValue = item.Value + } + if item.Value > maxValue { + maxIndex = j + maxValue = item.Value + } + sum += item.Value + } + return seriesSummary{ + MaxIndex: maxIndex, + MaxValue: maxValue, + MinIndex: minIndex, + MinValue: minValue, + AverageValue: sum / float64(len(s.Data)), + } +} + +func (sl SeriesList) Names() []string { + names := make([]string, len(sl)) + for index, s := range sl { + names[index] = s.Name + } + return names +} + +type LabelFormatter func(index int, value float64, percent float64) string + +func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter { + if len(layout) == 0 { + layout = "{b}: {d}" + } + return NewLabelFormatter(seriesNames, layout) +} + +func NewValueLabelFormater(seriesNames []string, layout string) LabelFormatter { + if len(layout) == 0 { + layout = "{c}" + } + return NewLabelFormatter(seriesNames, layout) +} + +func NewLabelFormatter(seriesNames []string, layout string) LabelFormatter { + return func(index int, value, percent float64) string { + // 如果无percent的则设置为<0 + percentText := "" + if percent >= 0 { + percentText = humanize.FtoaWithDigits(percent*100, 2) + "%" + } + valueText := humanize.FtoaWithDigits(value, 2) + name := "" + if len(seriesNames) > index { + name = seriesNames[index] + } + text := strings.ReplaceAll(layout, "{c}", valueText) + text = strings.ReplaceAll(text, "{d}", percentText) + text = strings.ReplaceAll(text, "{b}", name) + return text + } +} diff --git a/theme.go b/theme.go index bb05249..544588a 100644 --- a/theme.go +++ b/theme.go @@ -22,7 +22,11 @@ package charts -import "github.com/wcharczuk/go-chart/v2/drawing" +import ( + "github.com/golang/freetype/truetype" + "github.com/wcharczuk/go-chart/v2" + "github.com/wcharczuk/go-chart/v2/drawing" +) const ThemeDark = "dark" const ThemeLight = "light" @@ -36,6 +40,8 @@ type ColorPalette interface { GetSeriesColor(int) Color GetBackgroundColor() Color GetTextColor() Color + GetFontSize() float64 + GetFont() *truetype.Font } type themeColorPalette struct { @@ -45,10 +51,14 @@ type themeColorPalette struct { backgroundColor Color textColor Color seriesColors []Color + fontSize float64 + font *truetype.Font } var palettes = map[string]ColorPalette{} +const defaultFontSize = 12.0 + func init() { echartSeriesColors := []Color{ parseColor("#5470c6"), @@ -233,3 +243,18 @@ func (t *themeColorPalette) GetBackgroundColor() Color { func (t *themeColorPalette) GetTextColor() Color { return t.textColor } + +func (t *themeColorPalette) GetFontSize() float64 { + if t.fontSize != 0 { + return t.fontSize + } + return defaultFontSize +} + +func (t *themeColorPalette) GetFont() *truetype.Font { + if t.font != nil { + return t.font + } + f, _ := chart.GetDefaultFont() + return f +} diff --git a/xaxis.go b/xaxis.go new file mode 100644 index 0000000..a8c28c0 --- /dev/null +++ b/xaxis.go @@ -0,0 +1,88 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import ( + "github.com/golang/freetype/truetype" +) + +type XAxisOption struct { + // The font of x axis + Font *truetype.Font + // The boundary gap on both sides of a coordinate axis. + // Nil or *true means the center part of two axis ticks + BoundaryGap *bool + // The data value of x axis + Data []string + // The theme of chart + Theme ColorPalette + // The font size of x axis label + FontSize float64 + // Hidden x axis + Hidden bool + // Number of segments that the axis is split into. Note that this number serves only as a recommendation. + SplitNumber int + // The position of axis, it can be 'top' or 'bottom' + Position string + // The line color of axis + StrokeColor Color + // The color of label + FontColor Color +} + +const defaultXAxisHeight = 30 + +func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption { + opt := XAxisOption{ + Data: data, + } + if len(boundaryGap) != 0 { + opt.BoundaryGap = boundaryGap[0] + } + return opt +} + +func (opt *XAxisOption) ToAxisPainterOption() AxisPainterOption { + position := PositionBottom + if opt.Position == PositionTop { + position = PositionTop + } + return AxisPainterOption{ + Theme: opt.Theme, + Data: opt.Data, + BoundaryGap: opt.BoundaryGap, + Position: position, + SplitNumber: opt.SplitNumber, + StrokeColor: opt.StrokeColor, + FontSize: opt.FontSize, + Font: opt.Font, + FontColor: opt.FontColor, + } +} + +func NewBottomXAxis(p *Painter, opt XAxisOption) *axisPainter { + p = p.Child(PainterPaddingOption(Box{ + Top: p.Height() - defaultXAxisHeight, + })) + return NewAxisPainter(p, opt.ToAxisPainterOption()) +} diff --git a/yaxis.go b/yaxis.go new file mode 100644 index 0000000..653f6ec --- /dev/null +++ b/yaxis.go @@ -0,0 +1,66 @@ +// MIT License + +// Copyright (c) 2022 Tree Xie + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package charts + +import "github.com/golang/freetype/truetype" + +type YAxisOption struct { + // The font of y axis + Font *truetype.Font + // The data value of x axis + Data []string + // The theme of chart + Theme ColorPalette + // The font size of x axis label + FontSize float64 + // The position of axis, it can be 'left' or 'right' + Position string + // The color of label + FontColor Color +} + +func (opt *YAxisOption) ToAxisPainterOption() AxisPainterOption { + position := PositionLeft + if opt.Position == PositionRight { + position = PositionRight + } + return AxisPainterOption{ + Theme: opt.Theme, + Data: opt.Data, + Position: position, + FontSize: opt.FontSize, + StrokeWidth: -1, + Font: opt.Font, + FontColor: opt.FontColor, + BoundaryGap: FalseFlag(), + SplitLineShow: true, + SplitLineColor: opt.Theme.GetAxisSplitLineColor(), + } +} + +func NewLeftYAxis(p *Painter, opt YAxisOption) *axisPainter { + p = p.Child(PainterPaddingOption(Box{ + Bottom: defaultXAxisHeight, + })) + return NewAxisPainter(p, opt.ToAxisPainterOption()) +}