feat: support line chart draw function
This commit is contained in:
parent
4ac419fce9
commit
ccdaf70dcb
34 changed files with 1780 additions and 4672 deletions
458
axis.go
458
axis.go
|
|
@ -1,6 +1,6 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 Tree Xie
|
||||
// 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
|
||||
|
|
@ -23,176 +23,336 @@
|
|||
package charts
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
type (
|
||||
// AxisData string
|
||||
XAxis struct {
|
||||
// data value of axis
|
||||
Data []string
|
||||
// number of segments
|
||||
SplitNumber int
|
||||
}
|
||||
)
|
||||
|
||||
type YAxisOption struct {
|
||||
// formater of axis
|
||||
Formater chart.ValueFormatter
|
||||
// disabled axis
|
||||
Disabled bool
|
||||
// min value of axis
|
||||
Min *float64
|
||||
// max value of axis
|
||||
Max *float64
|
||||
type AxisStyle struct {
|
||||
BoundaryGap *bool
|
||||
Show *bool
|
||||
Position string
|
||||
ClassName string
|
||||
StrokeColor drawing.Color
|
||||
StrokeWidth float64
|
||||
TickLength int
|
||||
TickShow *bool
|
||||
LabelMargin int
|
||||
FontSize float64
|
||||
Font *truetype.Font
|
||||
FontColor drawing.Color
|
||||
SplitLineShow bool
|
||||
SplitLineColor drawing.Color
|
||||
}
|
||||
|
||||
const axisStrokeWidth = 1
|
||||
|
||||
// GetXAxisAndValues returns x axis by theme, and the values of axis.
|
||||
func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme string) (chart.XAxis, []float64) {
|
||||
data := xAxis.Data
|
||||
originalSize := len(data)
|
||||
// 如果居中,则需要多添加一个值
|
||||
if tickPosition == chart.TickPositionBetweenTicks {
|
||||
data = append([]string{
|
||||
"",
|
||||
}, data...)
|
||||
}
|
||||
|
||||
size := len(data)
|
||||
|
||||
xValues := make([]float64, size)
|
||||
ticks := make([]chart.Tick, 0)
|
||||
|
||||
// tick width
|
||||
maxTicks := maxInt(xAxis.SplitNumber, 10)
|
||||
|
||||
// 计息最多每个unit至少放多个
|
||||
minUnitSize := originalSize / maxTicks
|
||||
if originalSize%maxTicks != 0 {
|
||||
minUnitSize++
|
||||
}
|
||||
unitSize := minUnitSize
|
||||
// 尽可能选择一格展示更多的块
|
||||
for i := minUnitSize; i < 2*minUnitSize; i++ {
|
||||
if originalSize%i == 0 {
|
||||
unitSize = i
|
||||
}
|
||||
}
|
||||
|
||||
for index, key := range data {
|
||||
f := float64(index)
|
||||
xValues[index] = f
|
||||
if index%unitSize == 0 || index == size-1 {
|
||||
ticks = append(ticks, chart.Tick{
|
||||
Value: f,
|
||||
Label: key,
|
||||
})
|
||||
}
|
||||
}
|
||||
return chart.XAxis{
|
||||
Ticks: ticks,
|
||||
TickPosition: tickPosition,
|
||||
Style: chart.Style{
|
||||
FontColor: getAxisColor(theme),
|
||||
StrokeColor: getAxisColor(theme),
|
||||
StrokeWidth: axisStrokeWidth,
|
||||
},
|
||||
}, xValues
|
||||
func (as *AxisStyle) GetLabelMargin() int {
|
||||
return getDefaultInt(as.LabelMargin, 8)
|
||||
}
|
||||
|
||||
func defaultFloatFormater(v interface{}) string {
|
||||
value, ok := v.(float64)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
// 大于10的则直接取整展示
|
||||
if value >= 10 {
|
||||
return humanize.CommafWithDigits(value, 0)
|
||||
}
|
||||
return humanize.CommafWithDigits(value, 2)
|
||||
func (as *AxisStyle) GetTickLength() int {
|
||||
return getDefaultInt(as.TickLength, 5)
|
||||
}
|
||||
|
||||
func newYContinuousRange(option *YAxisOption) *YContinuousRange {
|
||||
m := YContinuousRange{}
|
||||
m.Min = -math.MaxFloat64
|
||||
m.Max = math.MaxFloat64
|
||||
if option != nil {
|
||||
if option.Min != nil {
|
||||
m.Min = *option.Min
|
||||
func (as *AxisStyle) Style(f *truetype.Font) chart.Style {
|
||||
s := chart.Style{
|
||||
ClassName: as.ClassName,
|
||||
StrokeColor: as.StrokeColor,
|
||||
StrokeWidth: as.StrokeWidth,
|
||||
FontSize: as.FontSize,
|
||||
FontColor: as.FontColor,
|
||||
Font: as.Font,
|
||||
}
|
||||
if option.Max != nil {
|
||||
m.Max = *option.Max
|
||||
if s.FontSize == 0 {
|
||||
s.FontSize = chart.DefaultFontSize
|
||||
}
|
||||
if s.Font == nil {
|
||||
s.Font = f
|
||||
}
|
||||
return &m
|
||||
return s
|
||||
}
|
||||
|
||||
// GetSecondaryYAxis returns the secondary y axis by theme
|
||||
func GetSecondaryYAxis(theme string, option *YAxisOption) chart.YAxis {
|
||||
strokeColor := getGridColor(theme)
|
||||
yAxis := chart.YAxis{
|
||||
Range: newYContinuousRange(option),
|
||||
ValueFormatter: defaultFloatFormater,
|
||||
AxisType: chart.YAxisSecondary,
|
||||
GridMajorStyle: chart.Style{
|
||||
StrokeColor: strokeColor,
|
||||
StrokeWidth: axisStrokeWidth,
|
||||
},
|
||||
GridMinorStyle: chart.Style{
|
||||
StrokeColor: strokeColor,
|
||||
StrokeWidth: axisStrokeWidth,
|
||||
},
|
||||
Style: chart.Style{
|
||||
FontColor: getAxisColor(theme),
|
||||
// alpha 0,隐藏
|
||||
StrokeColor: hiddenColor,
|
||||
StrokeWidth: axisStrokeWidth,
|
||||
},
|
||||
type AxisData struct {
|
||||
Text string
|
||||
}
|
||||
type AxisDataList []AxisData
|
||||
|
||||
func (l AxisDataList) TextList() []string {
|
||||
textList := make([]string, len(l))
|
||||
for index, item := range l {
|
||||
textList[index] = item.Text
|
||||
}
|
||||
setYAxisOption(&yAxis, option)
|
||||
return yAxis
|
||||
return textList
|
||||
}
|
||||
|
||||
func setYAxisOption(yAxis *chart.YAxis, option *YAxisOption) {
|
||||
if option == nil {
|
||||
type axisOption struct {
|
||||
data *AxisDataList
|
||||
style *AxisStyle
|
||||
textMaxWith int
|
||||
textMaxHeight int
|
||||
boundaryGap bool
|
||||
}
|
||||
|
||||
func NewAxisDataListFromStringList(textList []string) AxisDataList {
|
||||
list := make(AxisDataList, len(textList))
|
||||
for index, text := range textList {
|
||||
list[index] = AxisData{
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func (d *Draw) axisLabel(opt *axisOption) {
|
||||
style := opt.style
|
||||
data := *opt.data
|
||||
if style.FontColor.IsZero() || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
if option.Formater != nil {
|
||||
yAxis.ValueFormatter = option.Formater
|
||||
r := d.Render
|
||||
|
||||
s := style.Style(d.Font)
|
||||
s.GetTextOptions().WriteTextOptionsToRenderer(r)
|
||||
|
||||
width := d.Box.Width()
|
||||
height := d.Box.Height()
|
||||
textList := data.TextList()
|
||||
count := len(textList)
|
||||
boundaryGap := opt.boundaryGap
|
||||
if !boundaryGap {
|
||||
count--
|
||||
}
|
||||
|
||||
labelMargin := style.GetLabelMargin()
|
||||
|
||||
// 轴线
|
||||
labelHeight := labelMargin + opt.textMaxHeight
|
||||
labelWidth := labelMargin + opt.textMaxWith
|
||||
|
||||
// 坐标轴文本
|
||||
position := style.Position
|
||||
switch position {
|
||||
case PositionLeft:
|
||||
fallthrough
|
||||
case PositionRight:
|
||||
values := autoDivide(height, count)
|
||||
textList := data.TextList()
|
||||
// 由下往上
|
||||
reverseIntSlice(values)
|
||||
for index, text := range textList {
|
||||
y := values[index] - 2
|
||||
b := r.MeasureText(text)
|
||||
if boundaryGap {
|
||||
height := y - values[index+1]
|
||||
y -= (height - b.Height()) >> 1
|
||||
} else {
|
||||
y += b.Height() >> 1
|
||||
}
|
||||
// 左右位置的x不一样
|
||||
x := width - opt.textMaxWith
|
||||
if position == PositionLeft {
|
||||
x = labelWidth - b.Width() - 1
|
||||
}
|
||||
d.text(text, x, y)
|
||||
}
|
||||
default:
|
||||
// 定位bottom,重新计算y0的定位
|
||||
y0 := height - labelMargin
|
||||
if position == PositionTop {
|
||||
y0 = labelHeight - labelMargin
|
||||
}
|
||||
values := autoDivide(width, count)
|
||||
for index, text := range data.TextList() {
|
||||
x := values[index]
|
||||
leftOffset := 0
|
||||
b := r.MeasureText(text)
|
||||
if boundaryGap {
|
||||
width := values[index+1] - x
|
||||
leftOffset = (width - b.Width()) >> 1
|
||||
} else {
|
||||
// 左移文本长度
|
||||
leftOffset = -b.Width() >> 1
|
||||
}
|
||||
d.text(text, x+leftOffset, y0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetYAxis returns the primary y axis by theme
|
||||
func GetYAxis(theme string, option *YAxisOption) chart.YAxis {
|
||||
disabled := false
|
||||
if option != nil {
|
||||
disabled = option.Disabled
|
||||
}
|
||||
hidden := chart.Hidden()
|
||||
func (d *Draw) axisLine(opt *axisOption) {
|
||||
r := d.Render
|
||||
style := opt.style
|
||||
s := style.Style(d.Font)
|
||||
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||
|
||||
yAxis := chart.YAxis{
|
||||
Range: newYContinuousRange(option),
|
||||
ValueFormatter: defaultFloatFormater,
|
||||
AxisType: chart.YAxisPrimary,
|
||||
GridMajorStyle: hidden,
|
||||
GridMinorStyle: hidden,
|
||||
Style: chart.Style{
|
||||
FontColor: getAxisColor(theme),
|
||||
// alpha 0,隐藏
|
||||
StrokeColor: hiddenColor,
|
||||
StrokeWidth: axisStrokeWidth,
|
||||
},
|
||||
x0 := 0
|
||||
y0 := 0
|
||||
x1 := 0
|
||||
y1 := 0
|
||||
width := d.Box.Width()
|
||||
height := d.Box.Height()
|
||||
labelMargin := style.GetLabelMargin()
|
||||
|
||||
// 轴线
|
||||
labelHeight := labelMargin + opt.textMaxHeight
|
||||
labelWidth := labelMargin + opt.textMaxWith
|
||||
tickLength := style.GetTickLength()
|
||||
switch style.Position {
|
||||
case PositionLeft:
|
||||
x0 = tickLength + labelWidth
|
||||
x1 = x0
|
||||
y0 = 0
|
||||
y1 = height
|
||||
case PositionRight:
|
||||
x0 = width - labelWidth
|
||||
x1 = x0
|
||||
y0 = 0
|
||||
y1 = height
|
||||
case PositionTop:
|
||||
x0 = 0
|
||||
x1 = width
|
||||
y0 = labelHeight
|
||||
y1 = y0
|
||||
// bottom
|
||||
default:
|
||||
x0 = 0
|
||||
x1 = width
|
||||
y0 = height - tickLength - labelHeight
|
||||
y1 = y0
|
||||
}
|
||||
// 如果禁用,则默认为隐藏,并设置range
|
||||
if disabled {
|
||||
yAxis.Range = &HiddenRange{}
|
||||
yAxis.Style.Hidden = true
|
||||
}
|
||||
setYAxisOption(&yAxis, option)
|
||||
return yAxis
|
||||
|
||||
d.moveTo(x0, y0)
|
||||
d.lineTo(x1, y1)
|
||||
r.FillStroke()
|
||||
}
|
||||
|
||||
func (d *Draw) axisTick(opt *axisOption) {
|
||||
r := d.Render
|
||||
|
||||
style := opt.style
|
||||
s := style.Style(d.Font)
|
||||
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||
|
||||
width := d.Box.Width()
|
||||
height := d.Box.Height()
|
||||
data := *opt.data
|
||||
tickCount := len(data)
|
||||
if !opt.boundaryGap {
|
||||
tickCount--
|
||||
}
|
||||
labelMargin := style.GetLabelMargin()
|
||||
|
||||
tickLengthValue := style.GetTickLength()
|
||||
labelHeight := labelMargin + opt.textMaxHeight
|
||||
labelWidth := labelMargin + opt.textMaxWith
|
||||
position := style.Position
|
||||
switch position {
|
||||
case PositionLeft:
|
||||
fallthrough
|
||||
case PositionRight:
|
||||
values := autoDivide(height, tickCount)
|
||||
// 左右仅是x0的位置不一样
|
||||
x0 := width - labelWidth
|
||||
if style.Position == PositionLeft {
|
||||
x0 = labelWidth
|
||||
}
|
||||
for _, v := range values {
|
||||
x := x0
|
||||
y := v
|
||||
d.moveTo(x, y)
|
||||
d.lineTo(x+tickLengthValue, y)
|
||||
r.Stroke()
|
||||
}
|
||||
// 辅助线
|
||||
if style.SplitLineShow && !style.SplitLineColor.IsZero() {
|
||||
r.SetStrokeColor(style.SplitLineColor)
|
||||
splitLineWidth := width - labelWidth
|
||||
if position == PositionRight {
|
||||
x0 = 0
|
||||
splitLineWidth = width - labelWidth - 1
|
||||
}
|
||||
for _, v := range values[0 : len(values)-1] {
|
||||
x := x0
|
||||
y := v
|
||||
d.moveTo(x, y)
|
||||
d.lineTo(x+splitLineWidth, y)
|
||||
r.Stroke()
|
||||
}
|
||||
}
|
||||
default:
|
||||
values := autoDivide(width, tickCount)
|
||||
// 上下仅是y0的位置不一样
|
||||
y0 := height - labelHeight
|
||||
if position == PositionTop {
|
||||
y0 = labelHeight
|
||||
}
|
||||
for _, v := range values {
|
||||
x := v
|
||||
y := y0
|
||||
d.moveTo(x, y-tickLengthValue)
|
||||
d.lineTo(x, y)
|
||||
r.Stroke()
|
||||
}
|
||||
// 辅助线
|
||||
if style.SplitLineShow && !style.SplitLineColor.IsZero() {
|
||||
r.SetStrokeColor(style.SplitLineColor)
|
||||
y0 = 0
|
||||
splitLineHeight := height - labelHeight - tickLengthValue
|
||||
if position == PositionTop {
|
||||
y0 = labelHeight
|
||||
splitLineHeight = height - labelHeight
|
||||
}
|
||||
|
||||
for _, v := range values {
|
||||
x := v
|
||||
y := y0
|
||||
|
||||
d.moveTo(x, y)
|
||||
d.lineTo(x, y0+splitLineHeight)
|
||||
r.Stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Draw) axisMeasureTextMaxWidthHeight(data AxisDataList, style AxisStyle) (int, int) {
|
||||
r := d.Render
|
||||
s := style.Style(d.Font)
|
||||
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||
s.GetTextOptions().WriteTextOptionsToRenderer(r)
|
||||
return measureTextMaxWidthHeight(data.TextList(), r)
|
||||
}
|
||||
|
||||
// measureAxis return the measurement of axis.
|
||||
// If the position is left or right, it will be textMaxWidth + labelMargin + tickLength.
|
||||
// If the position is top or bottom, it will be textMaxHeight + labelMargin + tickLength.
|
||||
func (d *Draw) measureAxis(data AxisDataList, style AxisStyle) int {
|
||||
value := style.GetLabelMargin() + style.GetTickLength()
|
||||
textMaxWidth, textMaxHeight := d.axisMeasureTextMaxWidthHeight(data, style)
|
||||
if style.Position == PositionLeft ||
|
||||
style.Position == PositionRight {
|
||||
return textMaxWidth + value
|
||||
}
|
||||
return textMaxHeight + value
|
||||
}
|
||||
|
||||
func (d *Draw) Axis(data AxisDataList, style AxisStyle) {
|
||||
if style.Show != nil && !*style.Show {
|
||||
return
|
||||
}
|
||||
textMaxWidth, textMaxHeight := d.axisMeasureTextMaxWidthHeight(data, style)
|
||||
opt := &axisOption{
|
||||
data: &data,
|
||||
style: &style,
|
||||
textMaxWith: textMaxWidth,
|
||||
textMaxHeight: textMaxHeight,
|
||||
boundaryGap: true,
|
||||
}
|
||||
if style.BoundaryGap != nil && !*style.BoundaryGap {
|
||||
opt.boundaryGap = false
|
||||
}
|
||||
|
||||
// 坐标轴线
|
||||
d.axisLine(opt)
|
||||
d.axisTick(opt)
|
||||
// 坐标文本
|
||||
d.axisLabel(opt)
|
||||
}
|
||||
|
|
|
|||
284
axis_test.go
284
axis_test.go
|
|
@ -1,6 +1,6 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 Tree Xie
|
||||
// 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
|
||||
|
|
@ -23,150 +23,202 @@
|
|||
package charts
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/freetype/truetype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
func TestGetXAxisAndValues(t *testing.T) {
|
||||
func TestAxisStyle(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
genLabels := func(count int) []string {
|
||||
arr := make([]string, count)
|
||||
for i := 0; i < count; i++ {
|
||||
arr[i] = strconv.Itoa(i)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
genValues := func(count int, betweenTicks bool) []float64 {
|
||||
if betweenTicks {
|
||||
count++
|
||||
}
|
||||
arr := make([]float64, count)
|
||||
for i := 0; i < count; i++ {
|
||||
arr[i] = float64(i)
|
||||
}
|
||||
return arr
|
||||
}
|
||||
genTicks := func(count int, betweenTicks bool) []chart.Tick {
|
||||
arr := make([]chart.Tick, 0)
|
||||
offset := 0
|
||||
if betweenTicks {
|
||||
offset = 1
|
||||
arr = append(arr, chart.Tick{})
|
||||
}
|
||||
for i := 0; i < count; i++ {
|
||||
arr = append(arr, chart.Tick{
|
||||
Value: float64(i + offset),
|
||||
Label: strconv.Itoa(i),
|
||||
})
|
||||
}
|
||||
return arr
|
||||
}
|
||||
as := AxisStyle{}
|
||||
|
||||
assert.Equal(8, as.GetLabelMargin())
|
||||
as.LabelMargin = 10
|
||||
assert.Equal(10, as.GetLabelMargin())
|
||||
|
||||
assert.Equal(5, as.GetTickLength())
|
||||
as.TickLength = 6
|
||||
assert.Equal(6, as.GetTickLength())
|
||||
|
||||
f := &truetype.Font{}
|
||||
style := as.Style(f)
|
||||
assert.Equal(float64(10), style.FontSize)
|
||||
assert.Equal(f, style.Font)
|
||||
}
|
||||
|
||||
func TestAxisDataList(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
textList := []string{
|
||||
"a",
|
||||
"b",
|
||||
}
|
||||
data := NewAxisDataListFromStringList(textList)
|
||||
assert.Equal(textList, data.TextList())
|
||||
}
|
||||
|
||||
func TestAxis(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
data := NewAxisDataListFromStringList([]string{
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat",
|
||||
"Sun",
|
||||
})
|
||||
getDefaultOption := func() *axisOption {
|
||||
return &axisOption{
|
||||
data: &data,
|
||||
style: &AxisStyle{
|
||||
StrokeColor: drawing.ColorBlack,
|
||||
StrokeWidth: 1,
|
||||
FontColor: drawing.ColorBlack,
|
||||
Show: TrueFlag(),
|
||||
TickShow: TrueFlag(),
|
||||
SplitLineShow: true,
|
||||
SplitLineColor: drawing.ColorBlack.WithAlpha(60),
|
||||
},
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
xAxis XAxis
|
||||
tickPosition chart.TickPosition
|
||||
theme string
|
||||
result chart.XAxis
|
||||
values []float64
|
||||
newOption func() *axisOption
|
||||
result string
|
||||
}{
|
||||
// 文本按起始位置展示
|
||||
// axis位于bottom
|
||||
{
|
||||
xAxis: XAxis{
|
||||
Data: genLabels(5),
|
||||
newOption: func() *axisOption {
|
||||
opt := getDefaultOption()
|
||||
opt.style.BoundaryGap = FalseFlag()
|
||||
return opt
|
||||
},
|
||||
values: genValues(5, false),
|
||||
result: chart.XAxis{
|
||||
Ticks: genTicks(5, false),
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 270\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 270\nL 5 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 70 270\nL 70 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 135 270\nL 135 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 200 270\nL 200 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 265 270\nL 265 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 330 270\nL 330 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 270\nL 395 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 5 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 70 5\nL 70 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 135 5\nL 135 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 200 5\nL 200 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 265 5\nL 265 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 330 5\nL 330 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 5\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"-8\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"59\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"122\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"189\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"257\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"320\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"384\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
|
||||
},
|
||||
},
|
||||
// 居中
|
||||
// 文本居中展示
|
||||
// axis位于bottom
|
||||
{
|
||||
xAxis: XAxis{
|
||||
Data: genLabels(5),
|
||||
},
|
||||
tickPosition: chart.TickPositionBetweenTicks,
|
||||
// 居中因此value多一个
|
||||
values: genValues(5, true),
|
||||
result: chart.XAxis{
|
||||
Ticks: genTicks(5, true),
|
||||
newOption: func() *axisOption {
|
||||
opt := getDefaultOption()
|
||||
return opt
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 270\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 270\nL 5 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 61 270\nL 61 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 117 270\nL 117 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 173 270\nL 173 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 229 270\nL 229 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 285 270\nL 285 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 340 270\nL 340 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 270\nL 395 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 5 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 61 5\nL 61 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 117 5\nL 117 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 173 5\nL 173 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 229 5\nL 229 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 285 5\nL 285 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 340 5\nL 340 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 5\nL 395 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"20\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"78\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"132\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"190\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"249\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"303\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"356\" y=\"287\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
|
||||
},
|
||||
// 文本按起始位置展示
|
||||
// axis位于top
|
||||
{
|
||||
xAxis: XAxis{
|
||||
Data: genLabels(20),
|
||||
newOption: func() *axisOption {
|
||||
opt := getDefaultOption()
|
||||
opt.style.Position = PositionTop
|
||||
opt.style.BoundaryGap = FalseFlag()
|
||||
return opt
|
||||
},
|
||||
// 居中因此value多一个
|
||||
values: genValues(20, false),
|
||||
result: chart.XAxis{
|
||||
Ticks: []chart.Tick{
|
||||
{Value: 0, Label: "0"}, {Value: 2, Label: "2"}, {Value: 4, Label: "4"}, {Value: 6, Label: "6"}, {Value: 8, Label: "8"}, {Value: 10, Label: "10"}, {Value: 12, Label: "12"}, {Value: 14, Label: "14"}, {Value: 16, Label: "16"}, {Value: 18, Label: "18"}, {Value: 19, Label: "19"}},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 25\nL 395 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 20\nL 5 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 70 20\nL 70 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 135 20\nL 135 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 200 20\nL 200 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 265 20\nL 265 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 330 20\nL 330 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 20\nL 395 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 25\nL 5 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 70 25\nL 70 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 135 25\nL 135 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 200 25\nL 200 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 265 25\nL 265 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 330 25\nL 330 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 25\nL 395 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"-8\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"59\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"122\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"189\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"257\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"320\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"384\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
|
||||
},
|
||||
// 文本居中展示
|
||||
// axis位于top
|
||||
{
|
||||
newOption: func() *axisOption {
|
||||
opt := getDefaultOption()
|
||||
opt.style.Position = PositionTop
|
||||
return opt
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 5 25\nL 395 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 20\nL 5 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 61 20\nL 61 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 117 20\nL 117 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 173 20\nL 173 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 229 20\nL 229 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 285 20\nL 285 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 340 20\nL 340 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 395 20\nL 395 25\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 25\nL 5 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 61 25\nL 61 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 117 25\nL 117 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 173 25\nL 173 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 229 25\nL 229 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 285 25\nL 285 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 340 25\nL 340 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 395 25\nL 395 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"20\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"78\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"132\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"190\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"249\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"303\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"356\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
|
||||
},
|
||||
// 文本按起始位置展示
|
||||
// axis位于left
|
||||
{
|
||||
newOption: func() *axisOption {
|
||||
opt := getDefaultOption()
|
||||
opt.style.Position = PositionLeft
|
||||
opt.style.BoundaryGap = FalseFlag()
|
||||
return opt
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 44 5\nL 44 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 5\nL 44 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 54\nL 44 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 103\nL 44 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 151\nL 44 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 199\nL 44 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 247\nL 44 247\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 295\nL 44 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 5\nL 395 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 54\nL 395 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 103\nL 395 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 151\nL 395 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 199\nL 395 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 247\nL 395 247\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"12\" y=\"299\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"16\" y=\"251\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"12\" y=\"203\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"16\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"23\" y=\"107\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"19\" y=\"58\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"16\" y=\"9\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
|
||||
},
|
||||
// 文本居中展示
|
||||
// axis位于left
|
||||
{
|
||||
newOption: func() *axisOption {
|
||||
opt := getDefaultOption()
|
||||
opt.style.Position = PositionLeft
|
||||
return opt
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 44 5\nL 44 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 5\nL 44 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 47\nL 44 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 89\nL 44 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 131\nL 44 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 172\nL 44 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 213\nL 44 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 254\nL 44 254\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 295\nL 44 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 39 5\nL 395 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 47\nL 395 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 89\nL 395 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 131\nL 395 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 172\nL 395 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 213\nL 395 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 39 254\nL 395 254\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"12\" y=\"280\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"16\" y=\"239\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"12\" y=\"198\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"16\" y=\"157\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"23\" y=\"115\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"19\" y=\"73\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"16\" y=\"31\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
|
||||
},
|
||||
// 文本按起始位置展示
|
||||
// axis位于right
|
||||
{
|
||||
newOption: func() *axisOption {
|
||||
opt := getDefaultOption()
|
||||
opt.style.Position = PositionRight
|
||||
opt.style.BoundaryGap = FalseFlag()
|
||||
return opt
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 361 5\nL 361 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 5\nL 366 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 54\nL 366 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 103\nL 366 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 151\nL 366 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 199\nL 366 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 247\nL 366 247\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 295\nL 366 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 360 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 54\nL 360 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 103\nL 360 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 151\nL 360 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 199\nL 360 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 247\nL 360 247\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"369\" y=\"299\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"369\" y=\"251\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"369\" y=\"203\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"369\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"369\" y=\"107\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"369\" y=\"58\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"369\" y=\"9\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
|
||||
},
|
||||
// 文本居中展示
|
||||
// axis位于right
|
||||
{
|
||||
newOption: func() *axisOption {
|
||||
opt := getDefaultOption()
|
||||
opt.style.Position = PositionRight
|
||||
return opt
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 361 5\nL 361 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 5\nL 366 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 47\nL 366 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 89\nL 366 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 131\nL 366 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 172\nL 366 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 213\nL 366 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 254\nL 366 254\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 361 295\nL 366 295\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 5 5\nL 360 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 47\nL 360 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 89\nL 360 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 131\nL 360 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 172\nL 360 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 213\nL 360 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 5 254\nL 360 254\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><text x=\"369\" y=\"280\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"369\" y=\"239\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text><text x=\"369\" y=\"198\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Wed</text><text x=\"369\" y=\"157\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Thu</text><text x=\"369\" y=\"115\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Fri</text><text x=\"369\" y=\"73\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sat</text><text x=\"369\" y=\"31\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Sun</text></svg>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
xAxis, values := GetXAxisAndValues(tt.xAxis, tt.tickPosition, tt.theme)
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
}, PaddingOption(chart.Box{
|
||||
Left: 5,
|
||||
Top: 5,
|
||||
Right: 5,
|
||||
Bottom: 5,
|
||||
}))
|
||||
assert.Nil(err)
|
||||
opt := tt.newOption()
|
||||
|
||||
assert.Equal(tt.result.Ticks, xAxis.Ticks)
|
||||
assert.Equal(tt.tickPosition, xAxis.TickPosition)
|
||||
assert.Equal(tt.values, values)
|
||||
d.Axis(*opt.data, *opt.style)
|
||||
|
||||
result, err := d.Bytes()
|
||||
assert.Nil(err)
|
||||
assert.Equal(tt.result, string(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultFloatFormater(t *testing.T) {
|
||||
func TestMeasureAxis(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal("", defaultFloatFormater(1))
|
||||
|
||||
assert.Equal("0.1", defaultFloatFormater(0.1))
|
||||
assert.Equal("0.12", defaultFloatFormater(0.123))
|
||||
assert.Equal("10", defaultFloatFormater(10.1))
|
||||
}
|
||||
|
||||
func TestSetYAxisOption(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
min := 10.0
|
||||
max := 20.0
|
||||
opt := &YAxisOption{
|
||||
Formater: func(v interface{}) string {
|
||||
return ""
|
||||
},
|
||||
Min: &min,
|
||||
Max: &max,
|
||||
}
|
||||
yAxis := &chart.YAxis{
|
||||
Range: newYContinuousRange(opt),
|
||||
}
|
||||
setYAxisOption(yAxis, opt)
|
||||
|
||||
assert.NotEmpty(yAxis.ValueFormatter)
|
||||
assert.Equal(max, yAxis.Range.GetMax())
|
||||
assert.Equal(min, yAxis.Range.GetMin())
|
||||
}
|
||||
|
||||
func TestGetYAxis(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
yAxis := GetYAxis(ThemeDark, nil)
|
||||
|
||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
||||
assert.False(yAxis.Style.Hidden)
|
||||
|
||||
yAxis = GetYAxis(ThemeDark, &YAxisOption{
|
||||
Disabled: true,
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
})
|
||||
assert.Nil(err)
|
||||
data := NewAxisDataListFromStringList([]string{
|
||||
"Mon",
|
||||
"Sun",
|
||||
})
|
||||
f, _ := chart.GetDefaultFont()
|
||||
width := d.measureAxis(data, AxisStyle{
|
||||
FontSize: 12,
|
||||
Font: f,
|
||||
Position: PositionLeft,
|
||||
})
|
||||
assert.Equal(44, width)
|
||||
|
||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
||||
assert.True(yAxis.Style.Hidden)
|
||||
|
||||
// secondary yAxis
|
||||
yAxis = GetSecondaryYAxis(ThemeDark, nil)
|
||||
assert.False(yAxis.GridMajorStyle.Hidden)
|
||||
assert.False(yAxis.GridMajorStyle.Hidden)
|
||||
assert.True(yAxis.Style.StrokeColor.IsTransparent())
|
||||
height := d.measureAxis(data, AxisStyle{
|
||||
FontSize: 12,
|
||||
Font: f,
|
||||
Position: PositionTop,
|
||||
})
|
||||
assert.Equal(28, height)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 Tree Xie
|
||||
// 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
|
||||
|
|
@ -24,26 +24,34 @@ package charts
|
|||
|
||||
import (
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
type LineSeries struct {
|
||||
BaseSeries
|
||||
type BarStyle struct {
|
||||
ClassName string
|
||||
StrokeDashArray []float64
|
||||
FillColor drawing.Color
|
||||
}
|
||||
|
||||
func (ls LineSeries) getXRange(xrange chart.Range) chart.Range {
|
||||
if ls.TickPosition != chart.TickPositionBetweenTicks {
|
||||
return xrange
|
||||
func (bs *BarStyle) Style() chart.Style {
|
||||
return chart.Style{
|
||||
ClassName: bs.ClassName,
|
||||
StrokeDashArray: bs.StrokeDashArray,
|
||||
StrokeColor: bs.FillColor,
|
||||
StrokeWidth: 1,
|
||||
FillColor: bs.FillColor,
|
||||
}
|
||||
// 如果是居中,画线时重新调整
|
||||
return wrapRange(xrange, ls.TickPosition)
|
||||
}
|
||||
|
||||
func (ls LineSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
|
||||
style := ls.Style.InheritFrom(defaults)
|
||||
xrange = ls.getXRange(xrange)
|
||||
chart.Draw.LineSeries(r, canvasBox, xrange, yrange, style, ls)
|
||||
lr := LabelRenderer{
|
||||
Options: ls.Label,
|
||||
}
|
||||
lr.Render(r, canvasBox, xrange, yrange, style, ls)
|
||||
func (d *Draw) Bar(b chart.Box, style BarStyle) {
|
||||
s := style.Style()
|
||||
|
||||
r := d.Render
|
||||
s.GetFillAndStrokeOptions().WriteToRenderer(r)
|
||||
d.moveTo(b.Left, b.Top)
|
||||
d.lineTo(b.Right, b.Top)
|
||||
d.lineTo(b.Right, b.Bottom)
|
||||
d.lineTo(b.Left, b.Bottom)
|
||||
d.lineTo(b.Left, b.Top)
|
||||
d.Render.FillStroke()
|
||||
}
|
||||
148
bar_series.go
148
bar_series.go
|
|
@ -1,148 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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/dustin/go-humanize"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
const defaultBarMargin = 10
|
||||
|
||||
type BarSeriesCustomStyle struct {
|
||||
PointIndex int
|
||||
Index int
|
||||
Style chart.Style
|
||||
}
|
||||
|
||||
type BarSeries struct {
|
||||
BaseSeries
|
||||
Count int
|
||||
Index int
|
||||
// 间隔
|
||||
Margin int
|
||||
// 偏移量
|
||||
Offset int
|
||||
// 宽度
|
||||
BarWidth int
|
||||
CustomStyles []BarSeriesCustomStyle
|
||||
}
|
||||
|
||||
type barSeriesWidthValues struct {
|
||||
columnWidth int
|
||||
columnMargin int
|
||||
margin int
|
||||
barWidth int
|
||||
}
|
||||
|
||||
func (bs BarSeries) GetBarStyle(index, pointIndex int) chart.Style {
|
||||
// 指定样式
|
||||
for _, item := range bs.CustomStyles {
|
||||
if item.Index == index && item.PointIndex == pointIndex {
|
||||
return item.Style
|
||||
}
|
||||
}
|
||||
// 其它非指定样式
|
||||
return chart.Style{}
|
||||
}
|
||||
|
||||
func (bs BarSeries) getWidthValues(width int) barSeriesWidthValues {
|
||||
columnWidth := width / bs.Len()
|
||||
// 块间隔
|
||||
columnMargin := columnWidth / 10
|
||||
minColumnMargin := 2
|
||||
if columnMargin < minColumnMargin {
|
||||
columnMargin = minColumnMargin
|
||||
}
|
||||
margin := bs.Margin
|
||||
if margin <= 0 {
|
||||
margin = defaultBarMargin
|
||||
}
|
||||
// 如果margin大于column margin
|
||||
if margin > columnMargin {
|
||||
margin = columnMargin
|
||||
}
|
||||
|
||||
allBarMarginWidth := (bs.Count - 1) * margin
|
||||
barWidth := ((columnWidth - 2*columnMargin) - allBarMarginWidth) / bs.Count
|
||||
if bs.BarWidth > 0 && bs.BarWidth < barWidth {
|
||||
barWidth = bs.BarWidth
|
||||
// 重新计息columnMargin
|
||||
columnMargin = (columnWidth - allBarMarginWidth - (bs.Count * barWidth)) / 2
|
||||
}
|
||||
return barSeriesWidthValues{
|
||||
columnWidth: columnWidth,
|
||||
columnMargin: columnMargin,
|
||||
margin: margin,
|
||||
barWidth: barWidth,
|
||||
}
|
||||
}
|
||||
|
||||
func (bs BarSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
|
||||
if bs.Len() == 0 || bs.Count <= 0 {
|
||||
return
|
||||
}
|
||||
style := bs.Style.InheritFrom(defaults)
|
||||
style.FillColor = style.StrokeColor
|
||||
if !style.ShouldDrawStroke() {
|
||||
return
|
||||
}
|
||||
|
||||
cb := canvasBox.Bottom
|
||||
cl := canvasBox.Left
|
||||
widthValues := bs.getWidthValues(canvasBox.Width())
|
||||
labelValues := make([]LabelValue, 0)
|
||||
|
||||
for i := 0; i < bs.Len(); i++ {
|
||||
vx, vy := bs.GetValues(i)
|
||||
customStyle := bs.GetBarStyle(bs.Index, i)
|
||||
cloneStyle := style
|
||||
if !customStyle.IsZero() {
|
||||
cloneStyle.FillColor = customStyle.FillColor
|
||||
cloneStyle.StrokeColor = customStyle.StrokeColor
|
||||
}
|
||||
|
||||
x := cl + xrange.Translate(vx)
|
||||
// 由于bar是居中展示,因此需要往前移一个显示块
|
||||
x += (-widthValues.columnWidth + widthValues.columnMargin)
|
||||
// 计算是第几个bar,位置右偏
|
||||
x += bs.Index * (widthValues.margin + widthValues.barWidth)
|
||||
y := cb - yrange.Translate(vy)
|
||||
|
||||
chart.Draw.Box(r, chart.Box{
|
||||
Left: x,
|
||||
Top: y,
|
||||
Right: x + widthValues.barWidth,
|
||||
Bottom: canvasBox.Bottom - 1,
|
||||
}, cloneStyle)
|
||||
labelValues = append(labelValues, LabelValue{
|
||||
Left: x + widthValues.barWidth/2,
|
||||
Top: y,
|
||||
Text: humanize.CommafWithDigits(vy, 2),
|
||||
})
|
||||
}
|
||||
lr := LabelRenderer{
|
||||
Options: bs.Label,
|
||||
}
|
||||
lr.CustomizeRender(r, style, labelValues)
|
||||
}
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
func TestBarSeries(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
customStyle := chart.Style{
|
||||
StrokeColor: drawing.ColorBlue,
|
||||
}
|
||||
bs := BarSeries{
|
||||
CustomStyles: []BarSeriesCustomStyle{
|
||||
{
|
||||
PointIndex: 1,
|
||||
Style: customStyle,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(customStyle, bs.GetBarStyle(0, 1))
|
||||
|
||||
assert.True(bs.GetBarStyle(1, 0).IsZero())
|
||||
}
|
||||
|
||||
func TestBarSeriesGetWidthValues(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
bs := BarSeries{
|
||||
Count: 1,
|
||||
BaseSeries: BaseSeries{
|
||||
XValues: []float64{
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
},
|
||||
},
|
||||
}
|
||||
widthValues := bs.getWidthValues(300)
|
||||
assert.Equal(barSeriesWidthValues{
|
||||
columnWidth: 100,
|
||||
columnMargin: 10,
|
||||
margin: 10,
|
||||
barWidth: 80,
|
||||
}, widthValues)
|
||||
|
||||
// 指定margin
|
||||
bs.Margin = 5
|
||||
widthValues = bs.getWidthValues(300)
|
||||
assert.Equal(barSeriesWidthValues{
|
||||
columnWidth: 100,
|
||||
columnMargin: 10,
|
||||
margin: 5,
|
||||
barWidth: 80,
|
||||
}, widthValues)
|
||||
|
||||
// 指定bar的宽度
|
||||
bs.BarWidth = 60
|
||||
widthValues = bs.getWidthValues(300)
|
||||
assert.Equal(barSeriesWidthValues{
|
||||
columnWidth: 100,
|
||||
columnMargin: 20,
|
||||
margin: 5,
|
||||
barWidth: 60,
|
||||
}, widthValues)
|
||||
}
|
||||
|
||||
func TestBarSeriesRender(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
width := 800
|
||||
height := 400
|
||||
|
||||
r, err := chart.SVG(width, height)
|
||||
assert.Nil(err)
|
||||
|
||||
bs := BarSeries{
|
||||
Count: 1,
|
||||
CustomStyles: []BarSeriesCustomStyle{
|
||||
{
|
||||
Index: 0,
|
||||
PointIndex: 1,
|
||||
Style: chart.Style{
|
||||
StrokeColor: SeriesColorsLight[1],
|
||||
},
|
||||
},
|
||||
},
|
||||
BaseSeries: BaseSeries{
|
||||
TickPosition: chart.TickPositionBetweenTicks,
|
||||
Style: chart.Style{
|
||||
StrokeColor: SeriesColorsLight[0],
|
||||
StrokeWidth: 1,
|
||||
},
|
||||
XValues: []float64{
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
},
|
||||
YValues: []float64{
|
||||
// 第一个点为占位点
|
||||
0,
|
||||
120,
|
||||
200,
|
||||
150,
|
||||
80,
|
||||
70,
|
||||
110,
|
||||
130,
|
||||
},
|
||||
},
|
||||
}
|
||||
xrange := &chart.ContinuousRange{
|
||||
Min: 0,
|
||||
Max: 7,
|
||||
Domain: 753,
|
||||
}
|
||||
yrange := &chart.ContinuousRange{
|
||||
Min: 70,
|
||||
Max: 200,
|
||||
Domain: 362,
|
||||
}
|
||||
bs.Render(r, chart.Box{
|
||||
Top: 11,
|
||||
Left: 42,
|
||||
Right: 795,
|
||||
Bottom: 373,
|
||||
}, xrange, yrange, chart.Style{})
|
||||
|
||||
buffer := bytes.Buffer{}
|
||||
err = r.Save(&buffer)
|
||||
assert.Nil(err)
|
||||
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"400\">\\n<path d=\"M 53 233\nL 140 233\nL 140 372\nL 53 372\nL 53 233\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 161 11\nL 248 11\nL 248 372\nL 161 372\nL 161 11\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:none\"/><path d=\"M 268 150\nL 355 150\nL 355 372\nL 268 372\nL 268 150\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 376 345\nL 463 345\nL 463 372\nL 376 372\nL 376 345\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 483 373\nL 570 373\nL 570 372\nL 483 372\nL 483 373\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 591 261\nL 678 261\nL 678 372\nL 591 372\nL 591 261\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 698 205\nL 785 205\nL 785 372\nL 698 372\nL 698 205\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>", buffer.String())
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 Tree Xie
|
||||
// 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
|
||||
|
|
@ -23,55 +23,56 @@
|
|||
package charts
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
func TestRange(t *testing.T) {
|
||||
func TestBarStyle(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
r := Range{
|
||||
ContinuousRange: chart.ContinuousRange{
|
||||
Min: 0,
|
||||
Max: 5,
|
||||
Domain: 500,
|
||||
bs := BarStyle{
|
||||
ClassName: "test",
|
||||
StrokeDashArray: []float64{
|
||||
1.0,
|
||||
},
|
||||
FillColor: drawing.ColorBlack,
|
||||
}
|
||||
|
||||
assert.Equal(100, r.Translate(1))
|
||||
|
||||
r.TickPosition = chart.TickPositionBetweenTicks
|
||||
assert.Equal(50, r.Translate(1))
|
||||
assert.Equal(chart.Style{
|
||||
ClassName: "test",
|
||||
StrokeDashArray: []float64{
|
||||
1.0,
|
||||
},
|
||||
StrokeWidth: 1,
|
||||
FillColor: drawing.ColorBlack,
|
||||
StrokeColor: drawing.ColorBlack,
|
||||
}, bs.Style())
|
||||
}
|
||||
|
||||
func TestHiddenRange(t *testing.T) {
|
||||
func TestDrawBar(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
r := HiddenRange{}
|
||||
|
||||
assert.Equal(float64(0), r.GetDelta())
|
||||
}
|
||||
|
||||
func TestYContinuousRange(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
r := YContinuousRange{}
|
||||
r.Min = -math.MaxFloat64
|
||||
r.Max = math.MaxFloat64
|
||||
|
||||
assert.True(r.IsZero())
|
||||
|
||||
r.SetMin(1.0)
|
||||
assert.Equal(1.0, r.GetMin())
|
||||
// 再次设置无效
|
||||
r.SetMin(2.0)
|
||||
assert.Equal(1.0, r.GetMin())
|
||||
|
||||
r.SetMax(5.0)
|
||||
// *1.2
|
||||
assert.Equal(6.0, r.GetMax())
|
||||
// 再次设置无效
|
||||
r.SetMax(10.0)
|
||||
assert.Equal(6.0, r.GetMax())
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
}, PaddingOption(chart.Box{
|
||||
Left: 10,
|
||||
Top: 20,
|
||||
Right: 30,
|
||||
Bottom: 40,
|
||||
}))
|
||||
assert.Nil(err)
|
||||
d.Bar(chart.Box{
|
||||
Left: 0,
|
||||
Top: 0,
|
||||
Right: 20,
|
||||
Bottom: 200,
|
||||
}, BarStyle{
|
||||
FillColor: drawing.ColorBlack,
|
||||
})
|
||||
data, err := d.Bytes()
|
||||
assert.Nil(err)
|
||||
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 10 20\nL 30 20\nL 30 220\nL 10 220\nL 10 20\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/></svg>", string(data))
|
||||
}
|
||||
140
base_series.go
140
base_series.go
|
|
@ -1,140 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"fmt"
|
||||
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
// Interface Assertions.
|
||||
var (
|
||||
_ chart.Series = (*BaseSeries)(nil)
|
||||
_ chart.FirstValuesProvider = (*BaseSeries)(nil)
|
||||
_ chart.LastValuesProvider = (*BaseSeries)(nil)
|
||||
)
|
||||
|
||||
type SeriesLabel struct {
|
||||
Show bool
|
||||
Offset chart.Box
|
||||
}
|
||||
|
||||
// BaseSeries represents a line on a chart.
|
||||
type BaseSeries struct {
|
||||
Name string
|
||||
Style chart.Style
|
||||
TickPosition chart.TickPosition
|
||||
|
||||
YAxis chart.YAxisType
|
||||
|
||||
XValueFormatter chart.ValueFormatter
|
||||
YValueFormatter chart.ValueFormatter
|
||||
|
||||
XValues []float64
|
||||
YValues []float64
|
||||
|
||||
Label SeriesLabel
|
||||
}
|
||||
|
||||
// GetName returns the name of the time series.
|
||||
func (bs BaseSeries) GetName() string {
|
||||
return bs.Name
|
||||
}
|
||||
|
||||
// GetStyle returns the line style.
|
||||
func (bs BaseSeries) GetStyle() chart.Style {
|
||||
return bs.Style
|
||||
}
|
||||
|
||||
// Len returns the number of elements in the series.
|
||||
func (bs BaseSeries) Len() int {
|
||||
offset := 0
|
||||
if bs.TickPosition == chart.TickPositionBetweenTicks {
|
||||
offset = -1
|
||||
}
|
||||
return len(bs.XValues) + offset
|
||||
}
|
||||
|
||||
// GetValues gets the x,y values at a given index.
|
||||
func (bs BaseSeries) GetValues(index int) (float64, float64) {
|
||||
if bs.TickPosition == chart.TickPositionBetweenTicks {
|
||||
index++
|
||||
}
|
||||
return bs.XValues[index], bs.YValues[index]
|
||||
}
|
||||
|
||||
// GetFirstValues gets the first x,y values.
|
||||
func (bs BaseSeries) GetFirstValues() (float64, float64) {
|
||||
index := 0
|
||||
if bs.TickPosition == chart.TickPositionBetweenTicks {
|
||||
index++
|
||||
}
|
||||
return bs.XValues[index], bs.YValues[index]
|
||||
}
|
||||
|
||||
// GetLastValues gets the last x,y values.
|
||||
func (bs BaseSeries) GetLastValues() (float64, float64) {
|
||||
return bs.XValues[len(bs.XValues)-1], bs.YValues[len(bs.YValues)-1]
|
||||
}
|
||||
|
||||
// GetValueFormatters returns value formatter defaults for the series.
|
||||
func (bs BaseSeries) GetValueFormatters() (x, y chart.ValueFormatter) {
|
||||
if bs.XValueFormatter != nil {
|
||||
x = bs.XValueFormatter
|
||||
} else {
|
||||
x = chart.FloatValueFormatter
|
||||
}
|
||||
if bs.YValueFormatter != nil {
|
||||
y = bs.YValueFormatter
|
||||
} else {
|
||||
y = chart.FloatValueFormatter
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetYAxis returns which YAxis the series draws on.
|
||||
func (bs BaseSeries) GetYAxis() chart.YAxisType {
|
||||
return bs.YAxis
|
||||
}
|
||||
|
||||
// Render renders the series.
|
||||
func (bs BaseSeries) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, defaults chart.Style) {
|
||||
fmt.Println("should be override the function")
|
||||
}
|
||||
|
||||
// Validate validates the series.
|
||||
func (bs BaseSeries) Validate() error {
|
||||
if len(bs.XValues) == 0 {
|
||||
return fmt.Errorf("continuous series; must have xvalues set")
|
||||
}
|
||||
|
||||
if len(bs.YValues) == 0 {
|
||||
return fmt.Errorf("continuous series; must have yvalues set")
|
||||
}
|
||||
|
||||
if len(bs.XValues) != len(bs.YValues) {
|
||||
return fmt.Errorf("continuous series; must have same length xvalues as yvalues")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
func TestBaseSeries(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
bs := BaseSeries{
|
||||
XValues: []float64{
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
},
|
||||
YValues: []float64{
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
},
|
||||
}
|
||||
assert.Equal(3, bs.Len())
|
||||
bs.TickPosition = chart.TickPositionBetweenTicks
|
||||
assert.Equal(2, bs.Len())
|
||||
|
||||
bs.TickPosition = chart.TickPositionUnset
|
||||
x, y := bs.GetValues(1)
|
||||
assert.Equal(float64(2), x)
|
||||
assert.Equal(float64(20), y)
|
||||
bs.TickPosition = chart.TickPositionBetweenTicks
|
||||
x, y = bs.GetValues(1)
|
||||
assert.Equal(float64(3), x)
|
||||
assert.Equal(float64(30), y)
|
||||
|
||||
bs.TickPosition = chart.TickPositionUnset
|
||||
x, y = bs.GetFirstValues()
|
||||
assert.Equal(float64(1), x)
|
||||
assert.Equal(float64(10), y)
|
||||
bs.TickPosition = chart.TickPositionBetweenTicks
|
||||
x, y = bs.GetFirstValues()
|
||||
assert.Equal(float64(2), x)
|
||||
assert.Equal(float64(20), y)
|
||||
|
||||
bs.TickPosition = chart.TickPositionUnset
|
||||
x, y = bs.GetLastValues()
|
||||
assert.Equal(float64(3), x)
|
||||
assert.Equal(float64(30), y)
|
||||
bs.TickPosition = chart.TickPositionBetweenTicks
|
||||
x, y = bs.GetLastValues()
|
||||
assert.Equal(float64(3), x)
|
||||
assert.Equal(float64(30), y)
|
||||
|
||||
xFormater, yFormater := bs.GetValueFormatters()
|
||||
assert.Equal(reflect.ValueOf(chart.FloatValueFormatter).Pointer(), reflect.ValueOf(xFormater).Pointer())
|
||||
assert.Equal(reflect.ValueOf(chart.FloatValueFormatter).Pointer(), reflect.ValueOf(yFormater).Pointer())
|
||||
formater := func(v interface{}) string {
|
||||
return ""
|
||||
}
|
||||
bs.XValueFormatter = formater
|
||||
bs.YValueFormatter = formater
|
||||
xFormater, yFormater = bs.GetValueFormatters()
|
||||
assert.Equal(reflect.ValueOf(formater).Pointer(), reflect.ValueOf(xFormater).Pointer())
|
||||
assert.Equal(reflect.ValueOf(formater).Pointer(), reflect.ValueOf(yFormater).Pointer())
|
||||
|
||||
assert.Equal(chart.YAxisPrimary, bs.GetYAxis())
|
||||
|
||||
assert.Nil(bs.Validate())
|
||||
}
|
||||
145
chart.go
Normal file
145
chart.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// 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/dustin/go-humanize"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
type XAxisOption struct {
|
||||
BoundaryGap *bool
|
||||
Data []string
|
||||
// TODO split number
|
||||
}
|
||||
|
||||
type SeriesData struct {
|
||||
Value float64
|
||||
Style chart.Style
|
||||
}
|
||||
type Point struct {
|
||||
X int
|
||||
Y int
|
||||
}
|
||||
|
||||
type Range struct {
|
||||
originalMin float64
|
||||
originalMax float64
|
||||
divideCount int
|
||||
Min float64
|
||||
Max float64
|
||||
Size int
|
||||
Boundary bool
|
||||
}
|
||||
|
||||
func (r *Range) getHeight(value float64) int {
|
||||
v := 1 - value/(r.Max-r.Min)
|
||||
return int(v * float64(r.Size))
|
||||
}
|
||||
|
||||
func (r *Range) 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))
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
Type string
|
||||
Name string
|
||||
Data []SeriesData
|
||||
YAxisIndex int
|
||||
Style chart.Style
|
||||
}
|
||||
|
||||
type ChartOption struct {
|
||||
Theme string
|
||||
XAxis XAxisOption
|
||||
Width int
|
||||
Height int
|
||||
Parent *Draw
|
||||
Padding chart.Box
|
||||
SeriesList []Series
|
||||
BackgroundColor drawing.Color
|
||||
}
|
||||
|
||||
func (o *ChartOption) getWidth() int {
|
||||
if o.Width == 0 {
|
||||
return 600
|
||||
}
|
||||
return o.Width
|
||||
}
|
||||
|
||||
func (o *ChartOption) getHeight() int {
|
||||
if o.Height == 0 {
|
||||
return 400
|
||||
}
|
||||
return o.Height
|
||||
}
|
||||
|
||||
func (o *ChartOption) getYRange(axisIndex int) Range {
|
||||
min := float64(0)
|
||||
max := float64(0)
|
||||
|
||||
for _, series := range o.SeriesList {
|
||||
if series.YAxisIndex != axisIndex {
|
||||
continue
|
||||
}
|
||||
for _, item := range series.Data {
|
||||
if item.Value > max {
|
||||
max = item.Value
|
||||
}
|
||||
if item.Value < min {
|
||||
min = item.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO 对于小数的处理
|
||||
|
||||
divideCount := 6
|
||||
r := Range{
|
||||
originalMin: min,
|
||||
originalMax: max,
|
||||
Min: float64(int(min * 0.8)),
|
||||
Max: max * 1.2,
|
||||
divideCount: divideCount,
|
||||
}
|
||||
value := int((r.Max - r.Min) / float64(divideCount))
|
||||
r.Max = float64(int(float64(value*divideCount) + r.Min))
|
||||
return r
|
||||
}
|
||||
|
||||
func (r Range) 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 := humanize.CommafWithDigits(v, 2)
|
||||
values = append(values, value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
292
charts.go
292
charts.go
|
|
@ -1,292 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/golang/freetype/truetype"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
const (
|
||||
ThemeLight = "light"
|
||||
ThemeDark = "dark"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultChartWidth = 800
|
||||
DefaultChartHeight = 400
|
||||
)
|
||||
|
||||
type (
|
||||
Title struct {
|
||||
Text string
|
||||
Style chart.Style
|
||||
Font *truetype.Font
|
||||
Left string
|
||||
Top string
|
||||
}
|
||||
Legend struct {
|
||||
Data []string
|
||||
Align string
|
||||
Padding chart.Box
|
||||
Left string
|
||||
Right string
|
||||
Top string
|
||||
Bottom string
|
||||
}
|
||||
Options struct {
|
||||
Padding chart.Box
|
||||
Width int
|
||||
Height int
|
||||
Theme string
|
||||
XAxis XAxis
|
||||
YAxisOptions []*YAxisOption
|
||||
Series []Series
|
||||
Title Title
|
||||
Legend Legend
|
||||
TickPosition chart.TickPosition
|
||||
Log chart.Logger
|
||||
Font *truetype.Font
|
||||
}
|
||||
)
|
||||
|
||||
var fonts = sync.Map{}
|
||||
var ErrFontNotExists = errors.New("font is not exists")
|
||||
|
||||
// InstallFont installs the font for charts
|
||||
func InstallFont(fontFamily string, data []byte) error {
|
||||
font, err := truetype.Parse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fonts.Store(fontFamily, font)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFont returns the font of font family
|
||||
func GetFont(fontFamily string) (*truetype.Font, error) {
|
||||
value, ok := fonts.Load(fontFamily)
|
||||
if !ok {
|
||||
return nil, ErrFontNotExists
|
||||
}
|
||||
f, ok := value.(*truetype.Font)
|
||||
if !ok {
|
||||
return nil, ErrFontNotExists
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
type Graph interface {
|
||||
Render(rp chart.RendererProvider, w io.Writer) error
|
||||
}
|
||||
|
||||
func (o *Options) validate() error {
|
||||
if len(o.Series) == 0 {
|
||||
return errors.New("series can not be empty")
|
||||
}
|
||||
xAxisCount := len(o.XAxis.Data)
|
||||
|
||||
for _, item := range o.Series {
|
||||
if item.Type != SeriesPie && len(item.Data) != xAxisCount {
|
||||
return errors.New("series and xAxis is not matched")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Options) getWidth() int {
|
||||
width := o.Width
|
||||
if width <= 0 {
|
||||
width = DefaultChartWidth
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func (o *Options) getHeight() int {
|
||||
height := o.Height
|
||||
if height <= 0 {
|
||||
height = DefaultChartHeight
|
||||
}
|
||||
return height
|
||||
}
|
||||
|
||||
func (o *Options) getBackground() chart.Style {
|
||||
bg := chart.Style{
|
||||
Padding: o.Padding,
|
||||
}
|
||||
return bg
|
||||
}
|
||||
|
||||
func render(g Graph, rp chart.RendererProvider) ([]byte, error) {
|
||||
buf := bytes.Buffer{}
|
||||
err := g.Render(rp, &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func ToPNG(g Graph) ([]byte, error) {
|
||||
return render(g, chart.PNG)
|
||||
}
|
||||
|
||||
func ToSVG(g Graph) ([]byte, error) {
|
||||
return render(g, chart.SVG)
|
||||
}
|
||||
|
||||
func newTitleRenderable(title Title, font *truetype.Font, textColor drawing.Color) chart.Renderable {
|
||||
if title.Text == "" || title.Style.Hidden {
|
||||
return nil
|
||||
}
|
||||
title.Font = font
|
||||
if title.Style.FontColor.IsZero() {
|
||||
title.Style.FontColor = textColor
|
||||
}
|
||||
return NewTitleCustomize(title)
|
||||
}
|
||||
|
||||
func newPieChart(opt Options) *chart.PieChart {
|
||||
values := make(chart.Values, len(opt.Series))
|
||||
for index, item := range opt.Series {
|
||||
label := item.Name
|
||||
if item.Label.Show {
|
||||
label += ":" + humanize.CommafWithDigits(item.Data[0].Value, 2)
|
||||
}
|
||||
values[index] = chart.Value{
|
||||
Value: item.Data[0].Value,
|
||||
Label: label,
|
||||
}
|
||||
}
|
||||
p := &chart.PieChart{
|
||||
Font: opt.Font,
|
||||
Background: opt.getBackground(),
|
||||
Width: opt.getWidth(),
|
||||
Height: opt.getHeight(),
|
||||
Values: values,
|
||||
ColorPalette: &PieThemeColorPalette{
|
||||
ThemeColorPalette: ThemeColorPalette{
|
||||
Theme: opt.Theme,
|
||||
},
|
||||
},
|
||||
}
|
||||
// pie 图表默认设置为居中
|
||||
if opt.Title.Left == "" {
|
||||
opt.Title.Left = "center"
|
||||
}
|
||||
titleColor := drawing.ColorBlack
|
||||
if opt.Theme == ThemeDark {
|
||||
titleColor = drawing.ColorWhite
|
||||
}
|
||||
titleRender := newTitleRenderable(opt.Title, p.GetFont(), titleColor)
|
||||
if titleRender != nil {
|
||||
p.Elements = []chart.Renderable{
|
||||
titleRender,
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func newChart(opt Options) *chart.Chart {
|
||||
tickPosition := opt.TickPosition
|
||||
|
||||
xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme)
|
||||
|
||||
legendSize := len(opt.Legend.Data)
|
||||
for index, item := range opt.Series {
|
||||
if len(item.XValues) == 0 {
|
||||
opt.Series[index].XValues = xValues
|
||||
}
|
||||
if index < legendSize && opt.Series[index].Name == "" {
|
||||
opt.Series[index].Name = opt.Legend.Data[index]
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryYAxisOption *YAxisOption
|
||||
if len(opt.YAxisOptions) != 0 {
|
||||
secondaryYAxisOption = opt.YAxisOptions[0]
|
||||
}
|
||||
|
||||
yAxisOption := &YAxisOption{
|
||||
Disabled: true,
|
||||
}
|
||||
if len(opt.YAxisOptions) > 1 {
|
||||
yAxisOption = opt.YAxisOptions[1]
|
||||
}
|
||||
|
||||
c := &chart.Chart{
|
||||
Font: opt.Font,
|
||||
Log: opt.Log,
|
||||
Background: opt.getBackground(),
|
||||
ColorPalette: &ThemeColorPalette{
|
||||
Theme: opt.Theme,
|
||||
},
|
||||
Width: opt.getWidth(),
|
||||
Height: opt.getHeight(),
|
||||
XAxis: xAxis,
|
||||
YAxis: GetYAxis(opt.Theme, yAxisOption),
|
||||
YAxisSecondary: GetSecondaryYAxis(opt.Theme, secondaryYAxisOption),
|
||||
Series: GetSeries(opt.Series, tickPosition, opt.Theme),
|
||||
}
|
||||
|
||||
elements := make([]chart.Renderable, 0)
|
||||
|
||||
if legendSize != 0 {
|
||||
elements = append(elements, NewLegendCustomize(c.Series, LegendOption{
|
||||
Theme: opt.Theme,
|
||||
IconDraw: DefaultLegendIconDraw,
|
||||
Align: opt.Legend.Align,
|
||||
Padding: opt.Legend.Padding,
|
||||
Left: opt.Legend.Left,
|
||||
Right: opt.Legend.Right,
|
||||
Top: opt.Legend.Top,
|
||||
Bottom: opt.Legend.Bottom,
|
||||
}))
|
||||
}
|
||||
titleRender := newTitleRenderable(opt.Title, c.GetFont(), c.GetColorPalette().TextColor())
|
||||
if titleRender != nil {
|
||||
elements = append(elements, titleRender)
|
||||
}
|
||||
if len(elements) != 0 {
|
||||
c.Elements = elements
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func New(opt Options) (Graph, error) {
|
||||
err := opt.validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opt.Series[0].Type == SeriesPie {
|
||||
return newPieChart(opt), nil
|
||||
}
|
||||
|
||||
return newChart(opt), nil
|
||||
}
|
||||
156
charts_test.go
156
charts_test.go
|
|
@ -1,156 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/roboto"
|
||||
)
|
||||
|
||||
func TestFont(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
fontFamily := "roboto"
|
||||
err := InstallFont(fontFamily, roboto.Roboto)
|
||||
assert.Nil(err)
|
||||
|
||||
font, err := GetFont(fontFamily)
|
||||
assert.Nil(err)
|
||||
assert.NotNil(font)
|
||||
}
|
||||
|
||||
func TestChartsOptions(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
o := Options{}
|
||||
|
||||
assert.Equal(errors.New("series can not be empty"), o.validate())
|
||||
|
||||
o.Series = []Series{
|
||||
{
|
||||
Data: []SeriesData{
|
||||
{
|
||||
Value: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
assert.Equal(errors.New("series and xAxis is not matched"), o.validate())
|
||||
o.XAxis.Data = []string{
|
||||
"1",
|
||||
}
|
||||
assert.Nil(o.validate())
|
||||
|
||||
assert.Equal(DefaultChartWidth, o.getWidth())
|
||||
o.Width = 10
|
||||
assert.Equal(10, o.getWidth())
|
||||
|
||||
assert.Equal(DefaultChartHeight, o.getHeight())
|
||||
o.Height = 10
|
||||
assert.Equal(10, o.getHeight())
|
||||
|
||||
padding := chart.NewBox(10, 10, 10, 10)
|
||||
o.Padding = padding
|
||||
assert.Equal(padding, o.getBackground().Padding)
|
||||
}
|
||||
|
||||
func TestNewPieChart(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
data := []Series{
|
||||
{
|
||||
Data: []SeriesData{
|
||||
{
|
||||
Value: 10,
|
||||
},
|
||||
},
|
||||
Name: "chrome",
|
||||
},
|
||||
{
|
||||
Data: []SeriesData{
|
||||
{
|
||||
Value: 2,
|
||||
},
|
||||
},
|
||||
Name: "edge",
|
||||
},
|
||||
}
|
||||
pie := newPieChart(Options{
|
||||
Series: data,
|
||||
})
|
||||
for index, item := range pie.Values {
|
||||
assert.Equal(data[index].Name, item.Label)
|
||||
assert.Equal(data[index].Data[0].Value, item.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewChart(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
data := []Series{
|
||||
{
|
||||
Data: []SeriesData{
|
||||
{
|
||||
Value: 10,
|
||||
},
|
||||
{
|
||||
Value: 20,
|
||||
},
|
||||
},
|
||||
Name: "chrome",
|
||||
},
|
||||
{
|
||||
Data: []SeriesData{
|
||||
{
|
||||
Value: 2,
|
||||
},
|
||||
{
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
Name: "edge",
|
||||
},
|
||||
}
|
||||
|
||||
c := newChart(Options{
|
||||
Series: data,
|
||||
})
|
||||
assert.Empty(c.Elements)
|
||||
for index, series := range c.Series {
|
||||
assert.Equal(data[index].Name, series.GetName())
|
||||
}
|
||||
|
||||
c = newChart(Options{
|
||||
Legend: Legend{
|
||||
Data: []string{
|
||||
"chrome",
|
||||
"edge",
|
||||
},
|
||||
},
|
||||
})
|
||||
assert.Equal(1, len(c.Elements))
|
||||
}
|
||||
487
draw.go
487
draw.go
|
|
@ -24,6 +24,7 @@ package charts
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
|
||||
"github.com/golang/freetype/truetype"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
|
|
@ -37,152 +38,78 @@ const (
|
|||
PositionBottom = "bottom"
|
||||
)
|
||||
|
||||
type draw struct {
|
||||
type Draw struct {
|
||||
Render chart.Renderer
|
||||
Box chart.Box
|
||||
parent *draw
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
X int
|
||||
Y int
|
||||
}
|
||||
|
||||
type LineStyle struct {
|
||||
ClassName string
|
||||
StrokeDashArray []float64
|
||||
StrokeColor drawing.Color
|
||||
StrokeWidth float64
|
||||
FillColor drawing.Color
|
||||
DotWidth float64
|
||||
DotColor drawing.Color
|
||||
DotFillColor drawing.Color
|
||||
}
|
||||
|
||||
func (ls *LineStyle) Style() chart.Style {
|
||||
return chart.Style{
|
||||
ClassName: ls.ClassName,
|
||||
StrokeDashArray: ls.StrokeDashArray,
|
||||
StrokeColor: ls.StrokeColor,
|
||||
StrokeWidth: ls.StrokeWidth,
|
||||
FillColor: ls.FillColor,
|
||||
DotWidth: ls.DotWidth,
|
||||
DotColor: ls.DotColor,
|
||||
}
|
||||
}
|
||||
|
||||
type BarStyle struct {
|
||||
ClassName string
|
||||
StrokeDashArray []float64
|
||||
FillColor drawing.Color
|
||||
}
|
||||
|
||||
func (bs *BarStyle) Style() chart.Style {
|
||||
return chart.Style{
|
||||
ClassName: bs.ClassName,
|
||||
StrokeDashArray: bs.StrokeDashArray,
|
||||
StrokeColor: bs.FillColor,
|
||||
StrokeWidth: 1,
|
||||
FillColor: bs.FillColor,
|
||||
}
|
||||
}
|
||||
|
||||
type AxisStyle struct {
|
||||
BoundaryGap *bool
|
||||
Show *bool
|
||||
Position string
|
||||
Offset int
|
||||
ClassName string
|
||||
StrokeColor drawing.Color
|
||||
StrokeWidth float64
|
||||
TickLength int
|
||||
TickShow *bool
|
||||
LabelMargin int
|
||||
FontSize float64
|
||||
Font *truetype.Font
|
||||
FontColor drawing.Color
|
||||
parent *Draw
|
||||
}
|
||||
|
||||
func (as *AxisStyle) GetLabelMargin() int {
|
||||
return getDefaultInt(as.LabelMargin, 8)
|
||||
type DrawOption struct {
|
||||
Type string
|
||||
Parent *Draw
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
func (as *AxisStyle) GetTickLength() int {
|
||||
return getDefaultInt(as.TickLength, 5)
|
||||
}
|
||||
type Option func(*Draw) error
|
||||
|
||||
func (as *AxisStyle) Style() chart.Style {
|
||||
s := chart.Style{
|
||||
ClassName: as.ClassName,
|
||||
StrokeColor: as.StrokeColor,
|
||||
StrokeWidth: as.StrokeWidth,
|
||||
FontSize: as.FontSize,
|
||||
FontColor: as.FontColor,
|
||||
Font: as.Font,
|
||||
}
|
||||
if s.FontSize == 0 {
|
||||
s.FontSize = chart.DefaultFontSize
|
||||
}
|
||||
if s.Font == nil {
|
||||
s.Font, _ = chart.GetDefaultFont()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type AxisData struct {
|
||||
Text string
|
||||
}
|
||||
type AxisDataList []AxisData
|
||||
|
||||
func (l AxisDataList) TextList() []string {
|
||||
textList := make([]string, len(l))
|
||||
for index, item := range l {
|
||||
textList[index] = item.Text
|
||||
}
|
||||
return textList
|
||||
}
|
||||
|
||||
type axisOption struct {
|
||||
data *AxisDataList
|
||||
style *AxisStyle
|
||||
textMaxWith int
|
||||
textMaxHeight int
|
||||
}
|
||||
|
||||
func NewAxisDataListFromStringList(textList []string) AxisDataList {
|
||||
list := make(AxisDataList, len(textList))
|
||||
for index, text := range textList {
|
||||
list[index] = AxisData{
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
type Option func(*draw)
|
||||
|
||||
func ParentOption(p *draw) Option {
|
||||
return func(d *draw) {
|
||||
d.parent = p
|
||||
func PaddingOption(padding chart.Box) Option {
|
||||
return func(d *Draw) error {
|
||||
d.Box.Left += padding.Left
|
||||
d.Box.Top += padding.Top
|
||||
d.Box.Right -= padding.Right
|
||||
d.Box.Bottom -= padding.Bottom
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func NewDraw(r chart.Renderer, box chart.Box, opts ...Option) *draw {
|
||||
d := &draw{
|
||||
Render: r,
|
||||
Box: box,
|
||||
func NewDraw(opt DrawOption, opts ...Option) (*Draw, error) {
|
||||
if opt.Parent == nil && (opt.Width <= 0 || opt.Height <= 0) {
|
||||
return nil, errors.New("parent and width/height can not be nil")
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(d)
|
||||
font, _ := chart.GetDefaultFont()
|
||||
d := &Draw{
|
||||
Font: font,
|
||||
}
|
||||
return d
|
||||
width := opt.Width
|
||||
height := opt.Height
|
||||
if opt.Parent != nil {
|
||||
d.parent = opt.Parent
|
||||
d.Render = d.parent.Render
|
||||
d.Box = opt.Parent.Box.Clone()
|
||||
}
|
||||
if width != 0 && height != 0 {
|
||||
d.Box.Right = width + d.Box.Left
|
||||
d.Box.Bottom = height + d.Box.Top
|
||||
}
|
||||
// 创建render
|
||||
if d.parent == nil {
|
||||
fn := chart.SVG
|
||||
if opt.Type == "png" {
|
||||
fn = chart.PNG
|
||||
}
|
||||
r, err := fn(d.Box.Right, d.Box.Bottom)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Render = r
|
||||
}
|
||||
|
||||
for _, o := range opts {
|
||||
err := o(d)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *draw) Parent() *draw {
|
||||
func (d *Draw) Parent() *Draw {
|
||||
return d.parent
|
||||
}
|
||||
|
||||
func (d *draw) Top() *draw {
|
||||
func (d *Draw) Top() *Draw {
|
||||
if d.parent == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -197,7 +124,7 @@ func (d *draw) Top() *draw {
|
|||
return t
|
||||
}
|
||||
|
||||
func (d *draw) Bytes() ([]byte, error) {
|
||||
func (d *Draw) Bytes() ([]byte, error) {
|
||||
buffer := bytes.Buffer{}
|
||||
err := d.Render.Save(&buffer)
|
||||
if err != nil {
|
||||
|
|
@ -206,23 +133,23 @@ func (d *draw) Bytes() ([]byte, error) {
|
|||
return buffer.Bytes(), err
|
||||
}
|
||||
|
||||
func (d *draw) moveTo(x, y int) {
|
||||
func (d *Draw) moveTo(x, y int) {
|
||||
d.Render.MoveTo(x+d.Box.Left, y+d.Box.Top)
|
||||
}
|
||||
|
||||
func (d *draw) lineTo(x, y int) {
|
||||
func (d *Draw) lineTo(x, y int) {
|
||||
d.Render.LineTo(x+d.Box.Left, y+d.Box.Top)
|
||||
}
|
||||
|
||||
func (d *draw) circle(radius float64, x, y int) {
|
||||
func (d *Draw) circle(radius float64, x, y int) {
|
||||
d.Render.Circle(radius, x+d.Box.Left, y+d.Box.Top)
|
||||
}
|
||||
|
||||
func (d *draw) text(body string, x, y int) {
|
||||
func (d *Draw) text(body string, x, y int) {
|
||||
d.Render.Text(body, x+d.Box.Left, y+d.Box.Top)
|
||||
}
|
||||
|
||||
func (d *draw) lineStroke(points []Point, style LineStyle) {
|
||||
func (d *Draw) lineStroke(points []Point, style LineStyle) {
|
||||
s := style.Style()
|
||||
if !s.ShouldDrawStroke() {
|
||||
return
|
||||
|
|
@ -241,290 +168,16 @@ func (d *draw) lineStroke(points []Point, style LineStyle) {
|
|||
r.Stroke()
|
||||
}
|
||||
|
||||
func (d *draw) lineFill(points []Point, style LineStyle) {
|
||||
s := style.Style()
|
||||
if !(s.ShouldDrawStroke() && s.ShouldDrawFill()) {
|
||||
return
|
||||
}
|
||||
func (d *Draw) setBackground(width, height int, color drawing.Color) {
|
||||
r := d.Render
|
||||
var x, y int
|
||||
s.GetFillOptions().WriteDrawingOptionsToRenderer(r)
|
||||
for index, point := range points {
|
||||
x = point.X
|
||||
y = point.Y
|
||||
if index == 0 {
|
||||
d.moveTo(x, y)
|
||||
} else {
|
||||
d.lineTo(x, y)
|
||||
s := chart.Style{
|
||||
FillColor: color,
|
||||
}
|
||||
}
|
||||
height := d.Box.Height()
|
||||
d.lineTo(x, height)
|
||||
x0 := points[0].X
|
||||
y0 := points[0].Y
|
||||
d.lineTo(x0, height)
|
||||
d.lineTo(x0, y0)
|
||||
r.Fill()
|
||||
}
|
||||
|
||||
func (d *draw) lineDot(points []Point, style LineStyle) {
|
||||
s := style.Style()
|
||||
if !s.ShouldDrawDot() {
|
||||
return
|
||||
}
|
||||
r := d.Render
|
||||
dotWith := s.GetDotWidth()
|
||||
|
||||
s.GetDotOptions().WriteDrawingOptionsToRenderer(r)
|
||||
for _, point := range points {
|
||||
if !style.DotFillColor.IsZero() {
|
||||
r.SetFillColor(style.DotFillColor)
|
||||
}
|
||||
r.SetStrokeColor(s.DotColor)
|
||||
d.circle(dotWith, point.X, point.Y)
|
||||
r.FillStroke()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *draw) Line(points []Point, style LineStyle) {
|
||||
if len(points) == 0 {
|
||||
return
|
||||
}
|
||||
d.lineFill(points, style)
|
||||
d.lineStroke(points, style)
|
||||
d.lineDot(points, style)
|
||||
}
|
||||
|
||||
func (d *draw) Bar(b chart.Box, style BarStyle) {
|
||||
s := style.Style()
|
||||
|
||||
r := d.Render
|
||||
s.GetFillAndStrokeOptions().WriteToRenderer(r)
|
||||
|
||||
d.moveTo(b.Left, b.Top)
|
||||
d.lineTo(b.Right, b.Top)
|
||||
d.lineTo(b.Right, b.Bottom)
|
||||
d.lineTo(b.Left, b.Bottom)
|
||||
d.lineTo(b.Left, b.Top)
|
||||
s.WriteToRenderer(r)
|
||||
r.MoveTo(0, 0)
|
||||
r.LineTo(width, 0)
|
||||
r.LineTo(width, height)
|
||||
r.LineTo(0, height)
|
||||
r.LineTo(0, 0)
|
||||
r.FillStroke()
|
||||
}
|
||||
|
||||
func (d *draw) axisLabel(opt *axisOption) {
|
||||
style := opt.style
|
||||
data := *opt.data
|
||||
if style.FontColor.IsZero() || len(data) == 0 {
|
||||
return
|
||||
}
|
||||
r := d.Render
|
||||
|
||||
s := style.Style()
|
||||
s.GetTextOptions().WriteTextOptionsToRenderer(r)
|
||||
|
||||
width := d.Box.Width()
|
||||
height := d.Box.Height()
|
||||
textList := data.TextList()
|
||||
count := len(textList)
|
||||
x0 := 0
|
||||
y0 := 0
|
||||
tickLength := style.GetTickLength()
|
||||
|
||||
// 坐标轴文本
|
||||
switch style.Position {
|
||||
case PositionLeft:
|
||||
values := autoDivide(height, count)
|
||||
textList := data.TextList()
|
||||
// 由下往上
|
||||
reverseIntSlice(values)
|
||||
for index, text := range textList {
|
||||
y := values[index]
|
||||
height := y - values[index+1]
|
||||
b := r.MeasureText(text)
|
||||
y -= (height - b.Height()) >> 1
|
||||
x := x0 + opt.textMaxWith - (b.Width())
|
||||
d.text(text, x, y)
|
||||
}
|
||||
case PositionRight:
|
||||
values := autoDivide(height, count)
|
||||
textList := data.TextList()
|
||||
// 由下往上
|
||||
reverseIntSlice(values)
|
||||
for index, text := range textList {
|
||||
y := values[index]
|
||||
height := y - values[index+1]
|
||||
b := r.MeasureText(text)
|
||||
y -= (height - b.Height()) >> 1
|
||||
x := width - opt.textMaxWith
|
||||
d.text(text, x, y)
|
||||
}
|
||||
case PositionTop:
|
||||
y0 = tickLength + style.Offset
|
||||
values := autoDivide(width, count)
|
||||
maxIndex := len(values) - 2
|
||||
for index, text := range data.TextList() {
|
||||
if index > maxIndex {
|
||||
break
|
||||
}
|
||||
x := values[index]
|
||||
width := values[index+1] - x
|
||||
b := r.MeasureText(text)
|
||||
leftOffset := (width - b.Width()) >> 1
|
||||
d.text(text, x+leftOffset, y0)
|
||||
}
|
||||
default:
|
||||
// 定位bottom,重新计算y0的定位
|
||||
y0 = height - tickLength + style.Offset
|
||||
values := autoDivide(width, count)
|
||||
maxIndex := len(values) - 2
|
||||
for index, text := range data.TextList() {
|
||||
if index > maxIndex {
|
||||
break
|
||||
}
|
||||
x := values[index]
|
||||
width := values[index+1] - x
|
||||
b := r.MeasureText(text)
|
||||
leftOffset := (width - b.Width()) >> 1
|
||||
d.text(text, x+leftOffset, y0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *draw) axisLine(opt *axisOption) {
|
||||
|
||||
r := d.Render
|
||||
style := opt.style
|
||||
s := style.Style()
|
||||
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||
|
||||
x0 := 0
|
||||
y0 := 0
|
||||
x1 := 0
|
||||
y1 := 0
|
||||
width := d.Box.Width()
|
||||
height := d.Box.Height()
|
||||
labelMargin := style.GetLabelMargin()
|
||||
|
||||
// 轴线
|
||||
labelHeight := labelMargin + opt.textMaxHeight
|
||||
labelWidth := labelMargin + opt.textMaxWith
|
||||
tickLength := style.GetTickLength()
|
||||
switch style.Position {
|
||||
case PositionLeft:
|
||||
x0 = tickLength + style.Offset + labelWidth
|
||||
x1 = x0
|
||||
y0 = 0
|
||||
y1 = height
|
||||
case PositionRight:
|
||||
x0 = width + style.Offset - labelWidth
|
||||
x1 = x0
|
||||
y0 = 0
|
||||
y1 = height
|
||||
case PositionTop:
|
||||
x0 = 0
|
||||
x1 = width
|
||||
y0 = style.Offset + labelHeight
|
||||
y1 = y0
|
||||
// bottom
|
||||
default:
|
||||
x0 = 0
|
||||
x1 = width
|
||||
y0 = height - tickLength + style.Offset - labelHeight
|
||||
y1 = y0
|
||||
}
|
||||
|
||||
d.moveTo(x0, y0)
|
||||
d.lineTo(x1, y1)
|
||||
r.FillStroke()
|
||||
}
|
||||
|
||||
func (d *draw) axisTick(opt *axisOption) {
|
||||
r := d.Render
|
||||
|
||||
style := opt.style
|
||||
s := style.Style()
|
||||
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||
|
||||
x0 := 0
|
||||
y0 := 0
|
||||
width := d.Box.Width()
|
||||
height := d.Box.Height()
|
||||
data := *opt.data
|
||||
tickCount := len(data)
|
||||
labelMargin := style.GetLabelMargin()
|
||||
|
||||
tickLengthValue := style.GetTickLength()
|
||||
labelHeight := labelMargin + opt.textMaxHeight
|
||||
labelWidth := labelMargin + opt.textMaxWith
|
||||
switch style.Position {
|
||||
case PositionLeft:
|
||||
x0 += labelWidth
|
||||
values := autoDivide(height, tickCount)
|
||||
for _, v := range values {
|
||||
x := x0
|
||||
y := v
|
||||
d.moveTo(x, y)
|
||||
d.lineTo(x+tickLengthValue, y)
|
||||
r.Stroke()
|
||||
}
|
||||
case PositionRight:
|
||||
values := autoDivide(height, tickCount)
|
||||
x0 = width - labelWidth
|
||||
for _, v := range values {
|
||||
x := x0
|
||||
y := v
|
||||
d.moveTo(x, y)
|
||||
d.lineTo(x+tickLengthValue, y)
|
||||
r.Stroke()
|
||||
}
|
||||
case PositionTop:
|
||||
values := autoDivide(width, tickCount)
|
||||
y0 = style.Offset + labelHeight
|
||||
for _, v := range values {
|
||||
x := v
|
||||
y := y0
|
||||
d.moveTo(x, y-tickLengthValue)
|
||||
d.lineTo(x, y)
|
||||
r.Stroke()
|
||||
}
|
||||
|
||||
default:
|
||||
values := autoDivide(width, tickCount)
|
||||
y0 = height + style.Offset - labelHeight
|
||||
for _, v := range values {
|
||||
x := v
|
||||
y := y0
|
||||
d.moveTo(x, y-tickLengthValue)
|
||||
d.lineTo(x, y)
|
||||
r.Stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *draw) axisMeasureTextMaxWidthHeight(data AxisDataList, style AxisStyle) (int, int) {
|
||||
r := d.Render
|
||||
s := style.Style()
|
||||
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||
s.GetTextOptions().WriteTextOptionsToRenderer(r)
|
||||
return measureTextMaxWidthHeight(data.TextList(), r)
|
||||
}
|
||||
|
||||
func (d *draw) Axis(data AxisDataList, style AxisStyle) {
|
||||
if style.Show != nil && !*style.Show {
|
||||
return
|
||||
}
|
||||
r := d.Render
|
||||
s := style.Style()
|
||||
s.GetTextOptions().WriteTextOptionsToRenderer(r)
|
||||
textMaxWidth, textMaxHeight := d.axisMeasureTextMaxWidthHeight(data, style)
|
||||
opt := &axisOption{
|
||||
data: &data,
|
||||
style: &style,
|
||||
textMaxWith: textMaxWidth,
|
||||
textMaxHeight: textMaxHeight,
|
||||
}
|
||||
|
||||
// 坐标轴线
|
||||
d.axisLine(opt)
|
||||
d.axisTick(opt)
|
||||
// 坐标文本
|
||||
d.axisLabel(opt)
|
||||
}
|
||||
|
|
|
|||
242
draw_test.go
Normal file
242
draw_test.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
func TestParentOption(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
p, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
})
|
||||
assert.Nil(err)
|
||||
|
||||
d, err := NewDraw(DrawOption{
|
||||
Parent: p,
|
||||
})
|
||||
assert.Nil(err)
|
||||
assert.Equal(p, d.parent)
|
||||
}
|
||||
|
||||
func TestWidthHeightOption(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
// no parent
|
||||
width := 300
|
||||
height := 200
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: width,
|
||||
Height: height,
|
||||
})
|
||||
assert.Nil(err)
|
||||
assert.Equal(chart.Box{
|
||||
Top: 0,
|
||||
Left: 0,
|
||||
Right: width,
|
||||
Bottom: height,
|
||||
}, d.Box)
|
||||
|
||||
width = 500
|
||||
height = 600
|
||||
// with parent
|
||||
p, err := NewDraw(
|
||||
DrawOption{
|
||||
Width: width,
|
||||
Height: height,
|
||||
},
|
||||
PaddingOption(chart.NewBox(5, 5, 5, 5)),
|
||||
)
|
||||
assert.Nil(err)
|
||||
d, err = NewDraw(
|
||||
DrawOption{
|
||||
Parent: p,
|
||||
},
|
||||
PaddingOption(chart.NewBox(1, 2, 3, 4)),
|
||||
)
|
||||
assert.Nil(err)
|
||||
assert.Equal(chart.Box{
|
||||
Top: 6,
|
||||
Left: 7,
|
||||
Right: 492,
|
||||
Bottom: 591,
|
||||
}, d.Box)
|
||||
}
|
||||
|
||||
func TestPaddingOption(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
})
|
||||
assert.Nil(err)
|
||||
|
||||
// 默认的box
|
||||
assert.Equal(chart.Box{
|
||||
Right: 400,
|
||||
Bottom: 300,
|
||||
}, d.Box)
|
||||
|
||||
// 设置padding之后的box
|
||||
d, err = NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
}, PaddingOption(chart.Box{
|
||||
Left: 1,
|
||||
Top: 2,
|
||||
Right: 3,
|
||||
Bottom: 4,
|
||||
}))
|
||||
assert.Nil(err)
|
||||
assert.Equal(chart.Box{
|
||||
Top: 2,
|
||||
Left: 1,
|
||||
Right: 397,
|
||||
Bottom: 296,
|
||||
}, d.Box)
|
||||
|
||||
p := d
|
||||
// 设置父元素之后的box
|
||||
d, err = NewDraw(
|
||||
DrawOption{
|
||||
Parent: p,
|
||||
},
|
||||
PaddingOption(chart.Box{
|
||||
Left: 1,
|
||||
Top: 2,
|
||||
Right: 3,
|
||||
Bottom: 4,
|
||||
}),
|
||||
)
|
||||
assert.Nil(err)
|
||||
assert.Equal(chart.Box{
|
||||
Top: 4,
|
||||
Left: 2,
|
||||
Right: 394,
|
||||
Bottom: 292,
|
||||
}, d.Box)
|
||||
}
|
||||
|
||||
func TestParentTop(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
d1, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
})
|
||||
assert.Nil(err)
|
||||
|
||||
d2, err := NewDraw(DrawOption{
|
||||
Parent: d1,
|
||||
})
|
||||
assert.Nil(err)
|
||||
|
||||
d3, err := NewDraw(DrawOption{
|
||||
Parent: d2,
|
||||
})
|
||||
assert.Nil(err)
|
||||
|
||||
assert.Equal(d2, d3.Parent())
|
||||
assert.Equal(d1, d2.Parent())
|
||||
assert.Equal(d1, d3.Top())
|
||||
assert.Equal(d1, d2.Top())
|
||||
}
|
||||
|
||||
func TestDraw(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
tests := []struct {
|
||||
fn func(d *Draw)
|
||||
result string
|
||||
}{
|
||||
// moveTo, lineTo
|
||||
{
|
||||
fn: func(d *Draw) {
|
||||
d.moveTo(1, 1)
|
||||
d.lineTo(2, 2)
|
||||
d.Render.Stroke()
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 6 11\nL 7 12\" style=\"stroke-width:0;stroke:none;fill:none\"/></svg>",
|
||||
},
|
||||
// circle
|
||||
{
|
||||
fn: func(d *Draw) {
|
||||
d.circle(5, 2, 3)
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"7\" cy=\"13\" r=\"5\" style=\"stroke-width:0;stroke:none;fill:none\"/></svg>",
|
||||
},
|
||||
// text
|
||||
{
|
||||
fn: func(d *Draw) {
|
||||
d.text("hello world!", 3, 6)
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"8\" y=\"16\" style=\"stroke-width:0;stroke:none;fill:none\">hello world!</text></svg>",
|
||||
},
|
||||
// line stroke
|
||||
{
|
||||
fn: func(d *Draw) {
|
||||
d.lineStroke([]Point{
|
||||
{
|
||||
X: 1,
|
||||
Y: 2,
|
||||
},
|
||||
{
|
||||
X: 3,
|
||||
Y: 4,
|
||||
},
|
||||
}, LineStyle{
|
||||
StrokeColor: drawing.ColorBlack,
|
||||
StrokeWidth: 1,
|
||||
})
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 6 12\nL 8 14\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/></svg>",
|
||||
},
|
||||
// set background
|
||||
{
|
||||
fn: func(d *Draw) {
|
||||
d.setBackground(400, 300, chart.ColorWhite)
|
||||
},
|
||||
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 0\nL 400 0\nL 400 300\nL 0 300\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(255,255,255,1.0)\"/></svg>",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
}, PaddingOption(chart.Box{
|
||||
Left: 5,
|
||||
Top: 10,
|
||||
}))
|
||||
assert.Nil(err)
|
||||
tt.fn(d)
|
||||
data, err := d.Bytes()
|
||||
assert.Nil(err)
|
||||
assert.Equal(tt.result, string(data))
|
||||
}
|
||||
}
|
||||
403
echarts.go
403
echarts.go
|
|
@ -1,403 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
type EChartStyle struct {
|
||||
Color string `json:"color"`
|
||||
}
|
||||
type ECharsSeriesData struct {
|
||||
Value float64 `json:"value"`
|
||||
Name string `json:"name"`
|
||||
ItemStyle EChartStyle `json:"itemStyle"`
|
||||
}
|
||||
type _ECharsSeriesData ECharsSeriesData
|
||||
|
||||
func convertToArray(data []byte) []byte {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if data[0] != '[' {
|
||||
data = []byte("[" + string(data) + "]")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
|
||||
data = bytes.TrimSpace(data)
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if regexp.MustCompile(`^\d+`).Match(data) {
|
||||
v, err := strconv.ParseFloat(string(data), 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
es.Value = v
|
||||
return nil
|
||||
}
|
||||
v := _ECharsSeriesData{}
|
||||
err := json.Unmarshal(data, &v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
es.Name = v.Name
|
||||
es.Value = v.Value
|
||||
es.ItemStyle = v.ItemStyle
|
||||
return nil
|
||||
}
|
||||
|
||||
type EChartsPadding struct {
|
||||
box chart.Box
|
||||
}
|
||||
|
||||
type Position string
|
||||
|
||||
func (lp *Position) UnmarshalJSON(data []byte) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
if regexp.MustCompile(`^\d+`).Match(data) {
|
||||
data = []byte(fmt.Sprintf(`"%s"`, string(data)))
|
||||
}
|
||||
s := (*string)(lp)
|
||||
return json.Unmarshal(data, s)
|
||||
}
|
||||
|
||||
func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||
data = convertToArray(data)
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
arr := make([]int, 0)
|
||||
err := json.Unmarshal(data, &arr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return nil
|
||||
}
|
||||
switch len(arr) {
|
||||
case 1:
|
||||
ep.box = chart.Box{
|
||||
Left: arr[0],
|
||||
Top: arr[0],
|
||||
Bottom: arr[0],
|
||||
Right: arr[0],
|
||||
}
|
||||
case 2:
|
||||
ep.box = chart.Box{
|
||||
Top: arr[0],
|
||||
Bottom: arr[0],
|
||||
Left: arr[1],
|
||||
Right: arr[1],
|
||||
}
|
||||
default:
|
||||
result := make([]int, 4)
|
||||
copy(result, arr)
|
||||
if len(arr) == 3 {
|
||||
result[3] = result[1]
|
||||
}
|
||||
// 上右下左
|
||||
ep.box = chart.Box{
|
||||
Top: result[0],
|
||||
Right: result[1],
|
||||
Bottom: result[2],
|
||||
Left: result[3],
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type EChartsYAxis struct {
|
||||
Data []struct {
|
||||
Min *float64 `json:"min"`
|
||||
Max *float64 `json:"max"`
|
||||
// Interval int `json:"interval"`
|
||||
AxisLabel struct {
|
||||
Formatter string `json:"formatter"`
|
||||
} `json:"axisLabel"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
|
||||
data = convertToArray(data)
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &ey.Data)
|
||||
}
|
||||
|
||||
type EChartsXAxis struct {
|
||||
Data []struct {
|
||||
// Type string `json:"type"`
|
||||
BoundaryGap *bool `json:"boundaryGap"`
|
||||
SplitNumber int `json:"splitNumber"`
|
||||
Data []string `json:"data"`
|
||||
}
|
||||
}
|
||||
|
||||
func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error {
|
||||
data = convertToArray(data)
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, &ex.Data)
|
||||
}
|
||||
|
||||
type EChartsLabelOption struct {
|
||||
Show bool `json:"show"`
|
||||
Distance int `json:"distance"`
|
||||
}
|
||||
|
||||
func (elo EChartsLabelOption) ToLabel() SeriesLabel {
|
||||
if !elo.Show {
|
||||
return SeriesLabel{}
|
||||
}
|
||||
return SeriesLabel{
|
||||
Show: true,
|
||||
Offset: chart.Box{
|
||||
// 默认位置为top,因此设置为负
|
||||
Top: -elo.Distance,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type ECharsOptions struct {
|
||||
Theme string `json:"theme"`
|
||||
Padding EChartsPadding `json:"padding"`
|
||||
Title struct {
|
||||
Text string `json:"text"`
|
||||
Left Position `json:"left"`
|
||||
Top Position `json:"top"`
|
||||
TextStyle struct {
|
||||
Color string `json:"color"`
|
||||
FontFamily string `json:"fontFamily"`
|
||||
FontSize float64 `json:"fontSize"`
|
||||
Height float64 `json:"height"`
|
||||
} `json:"textStyle"`
|
||||
} `json:"title"`
|
||||
XAxis EChartsXAxis `json:"xAxis"`
|
||||
YAxis EChartsYAxis `json:"yAxis"`
|
||||
Legend struct {
|
||||
Data []string `json:"data"`
|
||||
Align string `json:"align"`
|
||||
Padding EChartsPadding `json:"padding"`
|
||||
Left Position `json:"left"`
|
||||
Right Position `json:"right"`
|
||||
// Top string `json:"top"`
|
||||
// Bottom string `json:"bottom"`
|
||||
} `json:"legend"`
|
||||
Series []struct {
|
||||
Data []ECharsSeriesData `json:"data"`
|
||||
Type string `json:"type"`
|
||||
YAxisIndex int `json:"yAxisIndex"`
|
||||
ItemStyle EChartStyle `json:"itemStyle"`
|
||||
// label的配置
|
||||
Label EChartsLabelOption `json:"label"`
|
||||
} `json:"series"`
|
||||
}
|
||||
|
||||
func convertEChartsSeries(e *ECharsOptions) ([]Series, chart.TickPosition) {
|
||||
tickPosition := chart.TickPositionUnset
|
||||
|
||||
if len(e.Series) == 0 {
|
||||
return nil, tickPosition
|
||||
}
|
||||
seriesType := e.Series[0].Type
|
||||
if seriesType == SeriesPie {
|
||||
series := make([]Series, len(e.Series[0].Data))
|
||||
label := e.Series[0].Label.ToLabel()
|
||||
for index, item := range e.Series[0].Data {
|
||||
style := chart.Style{}
|
||||
if item.ItemStyle.Color != "" {
|
||||
c := parseColor(item.ItemStyle.Color)
|
||||
style.FillColor = c
|
||||
style.StrokeColor = c
|
||||
}
|
||||
|
||||
series[index] = Series{
|
||||
Style: style,
|
||||
Data: []SeriesData{
|
||||
{
|
||||
Value: item.Value,
|
||||
},
|
||||
},
|
||||
Type: seriesType,
|
||||
Name: item.Name,
|
||||
Label: label,
|
||||
}
|
||||
}
|
||||
return series, tickPosition
|
||||
}
|
||||
series := make([]Series, len(e.Series))
|
||||
for index, item := range e.Series {
|
||||
// bar默认tick居中
|
||||
if item.Type == SeriesBar {
|
||||
tickPosition = chart.TickPositionBetweenTicks
|
||||
}
|
||||
style := chart.Style{}
|
||||
if item.ItemStyle.Color != "" {
|
||||
c := parseColor(item.ItemStyle.Color)
|
||||
style.FillColor = c
|
||||
style.StrokeColor = c
|
||||
}
|
||||
data := make([]SeriesData, len(item.Data))
|
||||
for j, itemData := range item.Data {
|
||||
sd := SeriesData{
|
||||
Value: itemData.Value,
|
||||
}
|
||||
if itemData.ItemStyle.Color != "" {
|
||||
c := parseColor(itemData.ItemStyle.Color)
|
||||
sd.Style.FillColor = c
|
||||
sd.Style.StrokeColor = c
|
||||
}
|
||||
data[j] = sd
|
||||
}
|
||||
series[index] = Series{
|
||||
Style: style,
|
||||
YAxisIndex: item.YAxisIndex,
|
||||
Data: data,
|
||||
Type: item.Type,
|
||||
Label: item.Label.ToLabel(),
|
||||
}
|
||||
}
|
||||
return series, tickPosition
|
||||
}
|
||||
|
||||
func (e *ECharsOptions) ToOptions() Options {
|
||||
o := Options{
|
||||
Theme: e.Theme,
|
||||
Padding: e.Padding.box,
|
||||
}
|
||||
|
||||
titleTextStyle := e.Title.TextStyle
|
||||
o.Title = Title{
|
||||
Text: e.Title.Text,
|
||||
Left: string(e.Title.Left),
|
||||
Top: string(e.Title.Top),
|
||||
Style: chart.Style{
|
||||
FontColor: parseColor(titleTextStyle.Color),
|
||||
FontSize: titleTextStyle.FontSize,
|
||||
},
|
||||
}
|
||||
if e.Title.TextStyle.FontFamily != "" {
|
||||
// 如果获取字体失败忽略
|
||||
o.Font, _ = GetFont(e.Title.TextStyle.FontFamily)
|
||||
}
|
||||
|
||||
if titleTextStyle.FontSize != 0 && titleTextStyle.Height > titleTextStyle.FontSize {
|
||||
padding := int(titleTextStyle.Height-titleTextStyle.FontSize) / 2
|
||||
o.Title.Style.Padding.Top = padding
|
||||
o.Title.Style.Padding.Bottom = padding
|
||||
}
|
||||
|
||||
boundaryGap := false
|
||||
if len(e.XAxis.Data) != 0 {
|
||||
xAxis := e.XAxis.Data[0]
|
||||
o.XAxis = XAxis{
|
||||
Data: xAxis.Data,
|
||||
SplitNumber: xAxis.SplitNumber,
|
||||
}
|
||||
if xAxis.BoundaryGap == nil || *xAxis.BoundaryGap {
|
||||
boundaryGap = true
|
||||
}
|
||||
}
|
||||
|
||||
o.Legend = Legend{
|
||||
Data: e.Legend.Data,
|
||||
Align: e.Legend.Align,
|
||||
Padding: e.Legend.Padding.box,
|
||||
Left: string(e.Legend.Left),
|
||||
Right: string(e.Legend.Right),
|
||||
}
|
||||
if len(e.YAxis.Data) != 0 {
|
||||
yAxisOptions := make([]*YAxisOption, len(e.YAxis.Data))
|
||||
for index, item := range e.YAxis.Data {
|
||||
opt := &YAxisOption{
|
||||
Max: item.Max,
|
||||
Min: item.Min,
|
||||
}
|
||||
template := item.AxisLabel.Formatter
|
||||
if template != "" {
|
||||
opt.Formater = func(v interface{}) string {
|
||||
str := defaultFloatFormater(v)
|
||||
return strings.ReplaceAll(template, "{value}", str)
|
||||
}
|
||||
}
|
||||
yAxisOptions[index] = opt
|
||||
}
|
||||
o.YAxisOptions = yAxisOptions
|
||||
}
|
||||
|
||||
series, tickPosition := convertEChartsSeries(e)
|
||||
|
||||
o.Series = series
|
||||
|
||||
if boundaryGap {
|
||||
tickPosition = chart.TickPositionBetweenTicks
|
||||
}
|
||||
o.TickPosition = tickPosition
|
||||
return o
|
||||
}
|
||||
|
||||
func ParseECharsOptions(options string) (Options, error) {
|
||||
e := ECharsOptions{}
|
||||
err := json.Unmarshal([]byte(options), &e)
|
||||
if err != nil {
|
||||
return Options{}, err
|
||||
}
|
||||
|
||||
return e.ToOptions(), nil
|
||||
}
|
||||
|
||||
func echartsRender(options string, rp chart.RendererProvider) ([]byte, error) {
|
||||
o, err := ParseECharsOptions(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g, err := New(o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return render(g, rp)
|
||||
}
|
||||
|
||||
func RenderEChartsToPNG(options string) ([]byte, error) {
|
||||
return echartsRender(options, chart.PNG)
|
||||
}
|
||||
|
||||
func RenderEChartsToSVG(options string) ([]byte, error) {
|
||||
return echartsRender(options, chart.SVG)
|
||||
}
|
||||
455
echarts_test.go
455
echarts_test.go
|
|
@ -1,455 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
func TestConvertToArray(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Nil(convertToArray([]byte(" ")))
|
||||
|
||||
assert.Equal([]byte("[{}]"), convertToArray([]byte("{}")))
|
||||
assert.Equal([]byte("[{}]"), convertToArray([]byte("[{}]")))
|
||||
}
|
||||
|
||||
func TestECharsSeriesData(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
es := ECharsSeriesData{}
|
||||
err := es.UnmarshalJSON([]byte(" "))
|
||||
assert.Nil(err)
|
||||
assert.Equal(ECharsSeriesData{}, es)
|
||||
|
||||
es = ECharsSeriesData{}
|
||||
err = es.UnmarshalJSON([]byte("12.1"))
|
||||
assert.Nil(err)
|
||||
assert.Equal(ECharsSeriesData{
|
||||
Value: 12.1,
|
||||
}, es)
|
||||
|
||||
es = ECharsSeriesData{}
|
||||
err = es.UnmarshalJSON([]byte(`{
|
||||
"value": 12.1,
|
||||
"name": "test",
|
||||
"itemStyle": {
|
||||
"color": "#333"
|
||||
}
|
||||
}`))
|
||||
assert.Nil(err)
|
||||
assert.Equal(ECharsSeriesData{
|
||||
Value: 12.1,
|
||||
Name: "test",
|
||||
ItemStyle: EChartStyle{
|
||||
Color: "#333",
|
||||
},
|
||||
}, es)
|
||||
}
|
||||
|
||||
func TestEChartsPadding(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ep := EChartsPadding{}
|
||||
err := ep.UnmarshalJSON([]byte(" "))
|
||||
assert.Nil(err)
|
||||
assert.Equal(EChartsPadding{}, ep)
|
||||
|
||||
ep = EChartsPadding{}
|
||||
err = ep.UnmarshalJSON([]byte("1"))
|
||||
assert.Nil(err)
|
||||
assert.Equal(EChartsPadding{
|
||||
box: chart.Box{
|
||||
Top: 1,
|
||||
Left: 1,
|
||||
Right: 1,
|
||||
Bottom: 1,
|
||||
},
|
||||
}, ep)
|
||||
|
||||
ep = EChartsPadding{}
|
||||
err = ep.UnmarshalJSON([]byte("[1, 2]"))
|
||||
assert.Nil(err)
|
||||
assert.Equal(EChartsPadding{
|
||||
box: chart.Box{
|
||||
Top: 1,
|
||||
Left: 2,
|
||||
Right: 2,
|
||||
Bottom: 1,
|
||||
},
|
||||
}, ep)
|
||||
|
||||
ep = EChartsPadding{}
|
||||
err = ep.UnmarshalJSON([]byte("[1, 2, 3]"))
|
||||
assert.Nil(err)
|
||||
assert.Equal(EChartsPadding{
|
||||
box: chart.Box{
|
||||
Top: 1,
|
||||
Right: 2,
|
||||
Bottom: 3,
|
||||
Left: 2,
|
||||
},
|
||||
}, ep)
|
||||
|
||||
ep = EChartsPadding{}
|
||||
err = ep.UnmarshalJSON([]byte("[1, 2, 3, 4]"))
|
||||
assert.Nil(err)
|
||||
assert.Equal(EChartsPadding{
|
||||
box: chart.Box{
|
||||
Top: 1,
|
||||
Right: 2,
|
||||
Bottom: 3,
|
||||
Left: 4,
|
||||
},
|
||||
}, ep)
|
||||
}
|
||||
|
||||
func TestConvertEChartsSeries(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
seriesList, tickPosition := convertEChartsSeries(&ECharsOptions{})
|
||||
assert.Empty(seriesList)
|
||||
assert.Equal(chart.TickPositionUnset, tickPosition)
|
||||
|
||||
e := ECharsOptions{}
|
||||
err := json.Unmarshal([]byte(`{
|
||||
"title": {
|
||||
"text": "Referer of a Website"
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"name": "Access From",
|
||||
"type": "pie",
|
||||
"radius": "50%",
|
||||
"data": [
|
||||
{
|
||||
"value": 1048,
|
||||
"name": "Search Engine"
|
||||
},
|
||||
{
|
||||
"value": 735,
|
||||
"name": "Direct"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`), &e)
|
||||
assert.Nil(err)
|
||||
seriesList, tickPosition = convertEChartsSeries(&e)
|
||||
assert.Equal(chart.TickPositionUnset, tickPosition)
|
||||
assert.Equal([]Series{
|
||||
{
|
||||
Data: []SeriesData{
|
||||
{
|
||||
Value: 1048,
|
||||
},
|
||||
},
|
||||
Type: SeriesPie,
|
||||
Name: "Search Engine",
|
||||
},
|
||||
{
|
||||
Data: []SeriesData{
|
||||
{
|
||||
Value: 735,
|
||||
},
|
||||
},
|
||||
Type: SeriesPie,
|
||||
Name: "Direct",
|
||||
},
|
||||
}, seriesList)
|
||||
|
||||
err = json.Unmarshal([]byte(`{
|
||||
"series": [
|
||||
{
|
||||
"name": "Evaporation",
|
||||
"type": "bar",
|
||||
"data": [2, {
|
||||
"value": 4.9,
|
||||
"itemStyle": {
|
||||
"color": "#a90000"
|
||||
}
|
||||
}, 7, 23.2, 25.6, 76.7, 135.6]
|
||||
},
|
||||
{
|
||||
"name": "Precipitation",
|
||||
"type": "bar",
|
||||
"data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6]
|
||||
},
|
||||
{
|
||||
"name": "Temperature",
|
||||
"type": "line",
|
||||
"yAxisIndex": 1,
|
||||
"data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3]
|
||||
}
|
||||
]
|
||||
}`), &e)
|
||||
assert.Nil(err)
|
||||
bar1Data := NewSeriesDataListFromFloat([]float64{
|
||||
2, 4.9, 7, 23.2, 25.6, 76.7, 135.6,
|
||||
})
|
||||
bar1Data[1].Style.FillColor = parseColor("#a90000")
|
||||
bar1Data[1].Style.StrokeColor = bar1Data[1].Style.FillColor
|
||||
|
||||
seriesList, tickPosition = convertEChartsSeries(&e)
|
||||
assert.Equal(chart.TickPositionBetweenTicks, tickPosition)
|
||||
assert.Equal([]Series{
|
||||
{
|
||||
Data: bar1Data,
|
||||
Type: SeriesBar,
|
||||
},
|
||||
{
|
||||
Data: NewSeriesDataListFromFloat([]float64{
|
||||
2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6,
|
||||
}),
|
||||
Type: SeriesBar,
|
||||
},
|
||||
{
|
||||
Data: NewSeriesDataListFromFloat([]float64{
|
||||
2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3,
|
||||
}),
|
||||
Type: SeriesLine,
|
||||
YAxisIndex: 1,
|
||||
},
|
||||
}, seriesList)
|
||||
|
||||
}
|
||||
|
||||
func TestParseECharsOptions(t *testing.T) {
|
||||
|
||||
assert := assert.New(t)
|
||||
options, err := ParseECharsOptions(`{
|
||||
"theme": "dark",
|
||||
"padding": [5, 10],
|
||||
"title": {
|
||||
"text": "Multi Line",
|
||||
"textAlign": "left",
|
||||
"textStyle": {
|
||||
"color": "#333",
|
||||
"fontSize": 24,
|
||||
"height": 40
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"align": "left",
|
||||
"padding": [5, 0, 0, 50],
|
||||
"data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
"splitNumber": 10
|
||||
},
|
||||
"yAxis": [
|
||||
{
|
||||
"min": 0,
|
||||
"max": 250
|
||||
},
|
||||
{
|
||||
"min": 0,
|
||||
"max": 25
|
||||
}
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "Evaporation",
|
||||
"type": "bar",
|
||||
"data": [2, {
|
||||
"value": 4.9,
|
||||
"itemStyle": {
|
||||
"color": "#a90000"
|
||||
}
|
||||
}, 7, 23.2, 25.6, 76.7, 135.6]
|
||||
},
|
||||
{
|
||||
"name": "Precipitation",
|
||||
"type": "bar",
|
||||
"itemStyle": {
|
||||
"color": "#0052d9"
|
||||
},
|
||||
"data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6]
|
||||
},
|
||||
{
|
||||
"name": "Temperature",
|
||||
"type": "line",
|
||||
"yAxisIndex": 1,
|
||||
"data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
assert.Nil(err)
|
||||
|
||||
min1 := float64(0)
|
||||
max1 := float64(250)
|
||||
min2 := float64(0)
|
||||
max2 := float64(25)
|
||||
|
||||
bar1Data := NewSeriesDataListFromFloat([]float64{
|
||||
2, 4.9, 7, 23.2, 25.6, 76.7, 135.6,
|
||||
})
|
||||
bar1Data[1].Style.FillColor = parseColor("#a90000")
|
||||
bar1Data[1].Style.StrokeColor = bar1Data[1].Style.FillColor
|
||||
|
||||
assert.Equal(Options{
|
||||
Theme: ThemeDark,
|
||||
Padding: chart.Box{
|
||||
Top: 5,
|
||||
Bottom: 5,
|
||||
Left: 10,
|
||||
Right: 10,
|
||||
},
|
||||
Title: Title{
|
||||
Text: "Multi Line",
|
||||
Style: chart.Style{
|
||||
FontColor: parseColor("#333"),
|
||||
FontSize: 24,
|
||||
Padding: chart.Box{
|
||||
Top: 8,
|
||||
Bottom: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
Legend: Legend{
|
||||
Data: []string{
|
||||
"Email", "Union Ads", "Video Ads", "Direct", "Search Engine",
|
||||
},
|
||||
Align: "left",
|
||||
Padding: chart.Box{
|
||||
Top: 5,
|
||||
Right: 0,
|
||||
Bottom: 0,
|
||||
Left: 50,
|
||||
},
|
||||
},
|
||||
XAxis: XAxis{
|
||||
Data: []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"},
|
||||
SplitNumber: 10,
|
||||
},
|
||||
TickPosition: chart.TickPositionBetweenTicks,
|
||||
YAxisOptions: []*YAxisOption{
|
||||
{
|
||||
Min: &min1,
|
||||
Max: &max1,
|
||||
},
|
||||
{
|
||||
Min: &min2,
|
||||
Max: &max2,
|
||||
},
|
||||
},
|
||||
Series: []Series{
|
||||
{
|
||||
Data: bar1Data,
|
||||
Type: SeriesBar,
|
||||
},
|
||||
{
|
||||
Data: NewSeriesDataListFromFloat([]float64{
|
||||
2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6,
|
||||
}),
|
||||
Type: SeriesBar,
|
||||
Style: chart.Style{
|
||||
StrokeColor: drawing.Color{
|
||||
R: 0,
|
||||
G: 82,
|
||||
B: 217,
|
||||
A: 255,
|
||||
},
|
||||
FillColor: drawing.Color{
|
||||
R: 0,
|
||||
G: 82,
|
||||
B: 217,
|
||||
A: 255,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Data: NewSeriesDataListFromFloat([]float64{
|
||||
2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3,
|
||||
}),
|
||||
Type: SeriesLine,
|
||||
YAxisIndex: 1,
|
||||
},
|
||||
},
|
||||
}, options)
|
||||
}
|
||||
|
||||
func TestUnmarshalJSON(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
var lp Position
|
||||
err := lp.UnmarshalJSON([]byte("123"))
|
||||
assert.Nil(err)
|
||||
assert.Equal("123", string(lp))
|
||||
|
||||
err = lp.UnmarshalJSON([]byte(`"234"`))
|
||||
assert.Nil(err)
|
||||
assert.Equal("234", string(lp))
|
||||
}
|
||||
|
||||
func BenchmarkEChartsRenderPNG(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := RenderEChartsToPNG(`{
|
||||
"title": {
|
||||
"text": "Line"
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"data": [150, 230, 224, 218, 135, 147, 260]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEChartsRenderSVG(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := RenderEChartsToSVG(`{
|
||||
"title": {
|
||||
"text": "Line"
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"data": [150, 230, 224, 218, 135, 147, 260]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
charts "github.com/vicanso/go-charts"
|
||||
)
|
||||
|
||||
func main() {
|
||||
buf, err := charts.RenderEChartsToPNG(`{
|
||||
"title": {
|
||||
"text": "Line"
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"data": [150, 230, 224, 218, 135, 147, 260]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
file, err := os.Create("output.png")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer file.Close()
|
||||
file.Write(buf)
|
||||
}
|
||||
|
|
@ -1,394 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
|
||||
charts "github.com/vicanso/go-charts"
|
||||
)
|
||||
|
||||
var html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link type="text/css" rel="styleSheet" href="https://unpkg.com/normalize.css@8.0.1/normalize.css" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<style>
|
||||
.charts {
|
||||
width: 830px;
|
||||
margin: 10px auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
.grid {
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.grid svg {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
||||
pre {
|
||||
width: 100%;
|
||||
margin: auto auto 20px auto;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
display: block;
|
||||
}
|
||||
svg{
|
||||
margin: auto auto 50px auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<title>go-charts</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="charts">{{body}}</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
var chartOptions = []map[string]string{
|
||||
{
|
||||
"title": "折线图",
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "Line\nHello World",
|
||||
"left": "right",
|
||||
"textStyle": {
|
||||
"fontSize": 24,
|
||||
"height": 40
|
||||
}
|
||||
},
|
||||
"yAxis": {
|
||||
"min": 0,
|
||||
"max": 300
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"data": [150, 230, 224, 218, 135, 147, 260],
|
||||
"type": "line"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "多折线图",
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "Multi Line"
|
||||
},
|
||||
"legend": {
|
||||
"align": "left",
|
||||
"right": 0,
|
||||
"data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"boundaryGap": false,
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"type": "line",
|
||||
"data": [120, 132, 101, 134, 90, 230, 210]
|
||||
},
|
||||
{
|
||||
"data": [220, 182, 191, 234, 290, 330, 310]
|
||||
},
|
||||
{
|
||||
"data": [150, 232, 201, 154, 190, 330, 410]
|
||||
},
|
||||
{
|
||||
"data": [320, 332, 301, 334, 390, 330, 320]
|
||||
},
|
||||
{
|
||||
"data": [820, 932, 901, 934, 1290, 1330, 1320]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "柱状图",
|
||||
"option": `{
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"data": [120, 200, 150, 80, 70, 110, 130],
|
||||
"type": "bar"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "柱状图(自定义颜色)",
|
||||
"option": `{
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"data": [
|
||||
120,
|
||||
{
|
||||
"value": 200,
|
||||
"itemStyle": {
|
||||
"color": "#a90000"
|
||||
}
|
||||
},
|
||||
150,
|
||||
80,
|
||||
70,
|
||||
110,
|
||||
130
|
||||
],
|
||||
"type": "bar"
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "多柱状图",
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "Rainfall vs Evaporation"
|
||||
},
|
||||
"legend": {
|
||||
"data": ["Rainfall", "Evaporation"]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"splitNumber": 12,
|
||||
"data": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"name": "Rainfall",
|
||||
"type": "bar",
|
||||
"data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 20, 6.4, 3.3]
|
||||
},
|
||||
{
|
||||
"name": "Evaporation",
|
||||
"type": "bar",
|
||||
"data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6, 2.3]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "折柱混合",
|
||||
"option": `{
|
||||
"legend": {
|
||||
"data": [
|
||||
"Evaporation",
|
||||
"Precipitation",
|
||||
"Temperature"
|
||||
]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"yAxis": [
|
||||
{
|
||||
"type": "value",
|
||||
"name": "Precipitation",
|
||||
"min": 0,
|
||||
"max": 250,
|
||||
"interval": 50,
|
||||
"axisLabel": {
|
||||
"formatter": "{value} ml"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "value",
|
||||
"name": "Temperature",
|
||||
"min": 0,
|
||||
"max": 25,
|
||||
"interval": 5,
|
||||
"axisLabel": {
|
||||
"formatter": "{value} °C"
|
||||
}
|
||||
}
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "Evaporation",
|
||||
"type": "bar",
|
||||
"itemStyle": {
|
||||
"color": "#0052d9"
|
||||
},
|
||||
"data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6]
|
||||
},
|
||||
{
|
||||
"name": "Precipitation",
|
||||
"type": "bar",
|
||||
"data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6]
|
||||
},
|
||||
{
|
||||
"name": "Temperature",
|
||||
"type": "line",
|
||||
"yAxisIndex": 1,
|
||||
"data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "降雨量",
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "降雨量"
|
||||
},
|
||||
"legend": {
|
||||
"data": ["GZ", "SH"]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"splitNumber": 6,
|
||||
"data": ["01-01","01-02","01-03","01-04","01-05","01-06","01-07","01-08","01-09","01-10","01-11","01-12","01-13","01-14","01-15","01-16","01-17","01-18","01-19","01-20","01-21","01-22","01-23","01-24","01-25","01-26","01-27","01-28","01-29","01-30","01-31"]
|
||||
},
|
||||
"yAxis": {
|
||||
"axisLabel": {
|
||||
"formatter": "{value} mm"
|
||||
}
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"type": "bar",
|
||||
"data": [928,821,889,600,547,783,197,853,430,346,63,465,309,334,141,538,792,58,922,807,298,243,744,885,812,231,330,220,984,221,429]
|
||||
},
|
||||
{
|
||||
"type": "bar",
|
||||
"data": [749,201,296,579,255,159,902,246,149,158,507,776,186,79,390,222,601,367,221,411,714,620,966,73,203,631,833,610,487,677,596]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "饼图",
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "Referer of a Website"
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"name": "Access From",
|
||||
"type": "pie",
|
||||
"radius": "50%",
|
||||
"data": [
|
||||
{
|
||||
"value": 1048,
|
||||
"name": "Search Engine"
|
||||
},
|
||||
{
|
||||
"value": 735,
|
||||
"name": "Direct"
|
||||
},
|
||||
{
|
||||
"value": 580,
|
||||
"name": "Email"
|
||||
},
|
||||
{
|
||||
"value": 484,
|
||||
"name": "Union Ads"
|
||||
},
|
||||
{
|
||||
"value": 300,
|
||||
"name": "Video Ads"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
type renderOptions struct {
|
||||
theme string
|
||||
width int
|
||||
height int
|
||||
onlyCharts bool
|
||||
}
|
||||
|
||||
func render(opts renderOptions) ([]byte, error) {
|
||||
data := bytes.Buffer{}
|
||||
for _, m := range chartOptions {
|
||||
chartHTML := []byte(`<div>
|
||||
<h1>{{title}}</h1>
|
||||
<pre>{{option}}</pre>
|
||||
{{svg}}
|
||||
</div>`)
|
||||
if opts.onlyCharts {
|
||||
chartHTML = []byte(`<div class="grid">
|
||||
{{svg}}
|
||||
</div>`)
|
||||
}
|
||||
o, err := charts.ParseECharsOptions(m["option"])
|
||||
if opts.width > 0 {
|
||||
o.Width = opts.width
|
||||
}
|
||||
if opts.height > 0 {
|
||||
o.Height = opts.height
|
||||
}
|
||||
|
||||
o.Theme = opts.theme
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g, err := charts.New(o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf, err := charts.ToSVG(g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = bytes.ReplaceAll(chartHTML, []byte("{{svg}}"), buf)
|
||||
buf = bytes.ReplaceAll(buf, []byte("{{title}}"), []byte(m["title"]))
|
||||
buf = bytes.ReplaceAll(buf, []byte("{{option}}"), []byte(m["option"]))
|
||||
data.Write(buf)
|
||||
}
|
||||
return data.Bytes(), nil
|
||||
}
|
||||
|
||||
func indexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
opts := renderOptions{
|
||||
theme: query.Get("theme"),
|
||||
}
|
||||
if query.Get("view") == "grid" {
|
||||
opts.width = 400
|
||||
opts.height = 200
|
||||
opts.onlyCharts = true
|
||||
}
|
||||
|
||||
buf, err := render(opts)
|
||||
if err != nil {
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), buf)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
func main() {
|
||||
http.HandleFunc("/", indexHandler)
|
||||
http.ListenAndServe(":3012", nil)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
|
||||
charts "github.com/vicanso/go-charts"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
var html = `<!DOCTYPE html>
|
||||
|
|
@ -18,6 +19,10 @@ var html = `<!DOCTYPE html>
|
|||
body {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.charts {
|
||||
width: 810px;
|
||||
margin: 10px auto;
|
||||
|
|
@ -56,321 +61,117 @@ var html = `<!DOCTYPE html>
|
|||
</html>
|
||||
`
|
||||
|
||||
var chartOptions = []map[string]string{
|
||||
{
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "Line",
|
||||
"left": "center"
|
||||
},
|
||||
"yAxis": {
|
||||
"min": 0,
|
||||
"max": 300
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"data": [150, 230, 224, 218, 135, 147, 260],
|
||||
"type": "line",
|
||||
"label": {
|
||||
"show": true,
|
||||
"distance": 5
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"option": `{
|
||||
"legend": {
|
||||
"align": "left",
|
||||
"left": 0,
|
||||
"data": ["Email", "Union Ads", "Video Ads", "Direct", "Search Engine"]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"boundaryGap": false,
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"type": "line",
|
||||
"data": [120, 132, 101, 134, 90, 230, 210]
|
||||
},
|
||||
{
|
||||
"data": [220, 182, 191, 234, 290, 330, 310]
|
||||
},
|
||||
{
|
||||
"data": [150, 232, 201, 154, 190, 330, 410]
|
||||
},
|
||||
{
|
||||
"data": [320, 332, 301, 334, 390, 330, 320]
|
||||
},
|
||||
{
|
||||
"data": [820, 932, 901, 934, 1290, 1330, 1320]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "柱状图(自定义颜色)",
|
||||
"option": `{
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"data": [
|
||||
120,
|
||||
{
|
||||
"value": 200,
|
||||
"itemStyle": {
|
||||
"color": "#a90000"
|
||||
}
|
||||
},
|
||||
150,
|
||||
80,
|
||||
70,
|
||||
110,
|
||||
130
|
||||
],
|
||||
"type": "bar",
|
||||
"label": {
|
||||
"show": true,
|
||||
"distance": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "多柱状图",
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "Rainfall vs Evaporation",
|
||||
"top": 10
|
||||
},
|
||||
"legend": {
|
||||
"data": ["Rainfall", "Evaporation"]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"splitNumber": 12,
|
||||
"data": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"name": "Rainfall",
|
||||
"type": "bar",
|
||||
"data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6, 162.2, 32.6, 20, 6.4, 3.3]
|
||||
},
|
||||
{
|
||||
"name": "Evaporation",
|
||||
"type": "bar",
|
||||
"data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6, 182.2, 48.7, 18.8, 6, 2.3]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "折柱混合",
|
||||
"option": `{
|
||||
"legend": {
|
||||
"data": [
|
||||
"Evaporation",
|
||||
"Precipitation",
|
||||
"Temperature"
|
||||
]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"data": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||
},
|
||||
"yAxis": [
|
||||
{
|
||||
"type": "value",
|
||||
"name": "Precipitation",
|
||||
"min": 0,
|
||||
"max": 250,
|
||||
"interval": 50,
|
||||
"axisLabel": {
|
||||
"formatter": "{value} ml"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "value",
|
||||
"name": "Temperature",
|
||||
"min": 0,
|
||||
"max": 25,
|
||||
"interval": 5,
|
||||
"axisLabel": {
|
||||
"formatter": "{value} °C"
|
||||
}
|
||||
}
|
||||
],
|
||||
"series": [
|
||||
{
|
||||
"name": "Evaporation",
|
||||
"type": "bar",
|
||||
"itemStyle": {
|
||||
"color": "#0052d9"
|
||||
},
|
||||
"data": [2, 4.9, 7, 23.2, 25.6, 76.7, 135.6]
|
||||
},
|
||||
{
|
||||
"name": "Precipitation",
|
||||
"type": "bar",
|
||||
"data": [2.6, 5.9, 9, 26.4, 28.7, 70.7, 175.6]
|
||||
},
|
||||
{
|
||||
"name": "Temperature",
|
||||
"type": "line",
|
||||
"yAxisIndex": 1,
|
||||
"data": [2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "降雨量",
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "Rainfall",
|
||||
"left": "right"
|
||||
},
|
||||
"legend": {
|
||||
"data": ["GZ", "SH"]
|
||||
},
|
||||
"xAxis": {
|
||||
"type": "category",
|
||||
"splitNumber": 6,
|
||||
"data": ["01-01","01-02","01-03","01-04","01-05","01-06","01-07","01-08","01-09","01-10","01-11","01-12","01-13","01-14","01-15","01-16","01-17","01-18","01-19","01-20","01-21","01-22","01-23","01-24","01-25","01-26","01-27","01-28","01-29","01-30","01-31"]
|
||||
},
|
||||
"yAxis": {
|
||||
"axisLabel": {
|
||||
"formatter": "{value} mm"
|
||||
}
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"type": "bar",
|
||||
"data": [928,821,889,600,547,783,197,853,430,346,63,465,309,334,141,538,792,58,922,807,298,243,744,885,812,231,330,220,984,221,429]
|
||||
},
|
||||
{
|
||||
"type": "bar",
|
||||
"data": [749,201,296,579,255,159,902,246,149,158,507,776,186,79,390,222,601,367,221,411,714,620,966,73,203,631,833,610,487,677,596]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
"title": "饼图",
|
||||
"option": `{
|
||||
"title": {
|
||||
"text": "Referer of a Website"
|
||||
},
|
||||
"series": [
|
||||
{
|
||||
"name": "Access From",
|
||||
"type": "pie",
|
||||
"radius": "50%",
|
||||
"label": {
|
||||
"show": true
|
||||
},
|
||||
"data": [
|
||||
{
|
||||
"value": 1048,
|
||||
"name": "Search Engine"
|
||||
},
|
||||
{
|
||||
"value": 735,
|
||||
"name": "Direct"
|
||||
},
|
||||
{
|
||||
"value": 580,
|
||||
"name": "Email"
|
||||
},
|
||||
{
|
||||
"value": 484,
|
||||
"name": "Union Ads"
|
||||
},
|
||||
{
|
||||
"value": 300,
|
||||
"name": "Video Ads"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
type renderOptions struct {
|
||||
theme string
|
||||
width int
|
||||
height int
|
||||
onlyCharts bool
|
||||
}
|
||||
|
||||
func render(opts renderOptions) ([]byte, error) {
|
||||
data := bytes.Buffer{}
|
||||
for _, m := range chartOptions {
|
||||
chartHTML := []byte(`<div class="grid">
|
||||
{{svg}}
|
||||
</div>`)
|
||||
o, err := charts.ParseECharsOptions(m["option"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if opts.width > 0 {
|
||||
o.Width = opts.width
|
||||
}
|
||||
if opts.height > 0 {
|
||||
o.Height = opts.height
|
||||
}
|
||||
|
||||
for _, theme := range []string{
|
||||
charts.ThemeDark,
|
||||
charts.ThemeLight,
|
||||
} {
|
||||
o.Theme = theme
|
||||
g, err := charts.New(o)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf, err := charts.ToSVG(g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buf = bytes.ReplaceAll(chartHTML, []byte("{{svg}}"), buf)
|
||||
data.Write(buf)
|
||||
}
|
||||
}
|
||||
return data.Bytes(), nil
|
||||
}
|
||||
|
||||
func indexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
opts := renderOptions{
|
||||
theme: query.Get("theme"),
|
||||
width: 400,
|
||||
height: 200,
|
||||
onlyCharts: true,
|
||||
}
|
||||
buf, err := render(opts)
|
||||
|
||||
d, err := charts.NewLineChart(charts.LineChartOption{
|
||||
ChartOption: charts.ChartOption{
|
||||
Theme: "dark",
|
||||
Padding: chart.Box{
|
||||
Left: 5,
|
||||
Top: 15,
|
||||
Bottom: 5,
|
||||
Right: 10,
|
||||
},
|
||||
XAxis: charts.XAxisOption{
|
||||
Data: []string{
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat",
|
||||
"Sun",
|
||||
},
|
||||
// BoundaryGap: charts.FalseFlag(),
|
||||
},
|
||||
SeriesList: []charts.Series{
|
||||
{
|
||||
Data: []charts.SeriesData{
|
||||
{
|
||||
Value: 150,
|
||||
},
|
||||
{
|
||||
Value: 230,
|
||||
},
|
||||
{
|
||||
Value: 224,
|
||||
},
|
||||
{
|
||||
Value: 218,
|
||||
},
|
||||
{
|
||||
Value: 135,
|
||||
},
|
||||
{
|
||||
Value: 147,
|
||||
},
|
||||
{
|
||||
Value: 260,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Data: []charts.SeriesData{
|
||||
{
|
||||
Value: 220,
|
||||
},
|
||||
{
|
||||
Value: 182,
|
||||
},
|
||||
{
|
||||
Value: 191,
|
||||
},
|
||||
{
|
||||
Value: 234,
|
||||
},
|
||||
{
|
||||
Value: 290,
|
||||
},
|
||||
{
|
||||
Value: 330,
|
||||
},
|
||||
{
|
||||
Value: 310,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Data: []charts.SeriesData{
|
||||
{
|
||||
Value: 150,
|
||||
},
|
||||
{
|
||||
Value: 232,
|
||||
},
|
||||
{
|
||||
Value: 201,
|
||||
},
|
||||
{
|
||||
Value: 154,
|
||||
},
|
||||
{
|
||||
Value: 190,
|
||||
},
|
||||
{
|
||||
Value: 330,
|
||||
},
|
||||
{
|
||||
Value: 410,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(400)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
panic(err)
|
||||
}
|
||||
|
||||
buf, _ := d.Bytes()
|
||||
|
||||
data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), buf)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write(data)
|
||||
|
|
|
|||
77
label.go
77
label.go
|
|
@ -1,77 +0,0 @@
|
|||
// 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/dustin/go-humanize"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
type LabelRenderer struct {
|
||||
Options SeriesLabel
|
||||
Offset chart.Box
|
||||
}
|
||||
type LabelValue struct {
|
||||
Left int
|
||||
Top int
|
||||
Text string
|
||||
}
|
||||
|
||||
func (l LabelRenderer) Render(r chart.Renderer, canvasBox chart.Box, xrange, yrange chart.Range, style chart.Style, vs chart.ValuesProvider) {
|
||||
if !l.Options.Show {
|
||||
return
|
||||
}
|
||||
r.SetFontColor(style.FontColor)
|
||||
r.SetFontSize(style.FontSize)
|
||||
r.SetFont(style.Font)
|
||||
offsetX := l.Options.Offset.Left + l.Offset.Left
|
||||
offsetY := l.Options.Offset.Top + l.Offset.Top
|
||||
for i := 0; i < vs.Len(); i++ {
|
||||
vx, vy := vs.GetValues(i)
|
||||
x := canvasBox.Left + xrange.Translate(vx) + offsetX
|
||||
y := canvasBox.Bottom - yrange.Translate(vy) + offsetY
|
||||
|
||||
text := humanize.CommafWithDigits(vy, 2)
|
||||
// 往左移一半距离
|
||||
x -= r.MeasureText(text).Width() >> 1
|
||||
r.Text(text, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func (l LabelRenderer) CustomizeRender(r chart.Renderer, style chart.Style, values []LabelValue) {
|
||||
if !l.Options.Show {
|
||||
return
|
||||
}
|
||||
r.SetFont(style.Font)
|
||||
r.SetFontColor(style.FontColor)
|
||||
r.SetFontSize(style.FontSize)
|
||||
offsetX := l.Options.Offset.Left + l.Offset.Left
|
||||
offsetY := l.Options.Offset.Top + l.Offset.Top
|
||||
for _, value := range values {
|
||||
x := value.Left + offsetX
|
||||
y := value.Top + offsetY
|
||||
text := value.Text
|
||||
x -= r.MeasureText(text).Width() >> 1
|
||||
r.Text(text, x, y)
|
||||
}
|
||||
}
|
||||
249
legend.go
249
legend.go
|
|
@ -1,249 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
type LegendOption struct {
|
||||
Style chart.Style
|
||||
Padding chart.Box
|
||||
Left string
|
||||
Right string
|
||||
Top string
|
||||
Bottom string
|
||||
Align string
|
||||
Theme string
|
||||
IconDraw LegendIconDraw
|
||||
}
|
||||
|
||||
type LegendIconDrawOption struct {
|
||||
Box chart.Box
|
||||
Style chart.Style
|
||||
Index int
|
||||
Theme string
|
||||
}
|
||||
|
||||
const (
|
||||
LegendAlignLeft = "left"
|
||||
LegendAlignRight = "right"
|
||||
)
|
||||
|
||||
type LegendIconDraw func(r chart.Renderer, opt LegendIconDrawOption)
|
||||
|
||||
func DefaultLegendIconDraw(r chart.Renderer, opt LegendIconDrawOption) {
|
||||
if opt.Box.IsZero() {
|
||||
return
|
||||
}
|
||||
r.SetStrokeColor(opt.Style.GetStrokeColor())
|
||||
strokeWidth := opt.Style.GetStrokeWidth()
|
||||
r.SetStrokeWidth(strokeWidth)
|
||||
height := opt.Box.Bottom - opt.Box.Top
|
||||
ly := opt.Box.Top - (height / 2) + 2
|
||||
r.MoveTo(opt.Box.Left, ly)
|
||||
r.LineTo(opt.Box.Right, ly)
|
||||
r.Stroke()
|
||||
r.SetFillColor(getBackgroundColor(opt.Theme))
|
||||
r.Circle(5, (opt.Box.Left+opt.Box.Right)/2, ly)
|
||||
r.FillStroke()
|
||||
}
|
||||
|
||||
func convertPercent(value string) float64 {
|
||||
if !strings.HasSuffix(value, "%") {
|
||||
return -1
|
||||
}
|
||||
v, err := strconv.Atoi(strings.ReplaceAll(value, "%", ""))
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return float64(v) / 100
|
||||
}
|
||||
|
||||
func getLegendLeft(canvasWidth, legendBoxWidth int, opt LegendOption) int {
|
||||
left := (canvasWidth - legendBoxWidth) / 2
|
||||
leftValue := opt.Left
|
||||
if leftValue == "auto" || leftValue == "center" {
|
||||
leftValue = ""
|
||||
}
|
||||
if leftValue == "left" {
|
||||
leftValue = "0"
|
||||
}
|
||||
|
||||
rightValue := opt.Right
|
||||
if rightValue == "auto" || leftValue == "center" {
|
||||
rightValue = ""
|
||||
}
|
||||
if rightValue == "right" {
|
||||
rightValue = "0"
|
||||
}
|
||||
if leftValue == "" && rightValue == "" {
|
||||
return left
|
||||
}
|
||||
if leftValue != "" {
|
||||
percent := convertPercent(leftValue)
|
||||
if percent >= 0 {
|
||||
return int(float64(canvasWidth) * percent)
|
||||
}
|
||||
v, _ := strconv.Atoi(leftValue)
|
||||
return v
|
||||
}
|
||||
if rightValue != "" {
|
||||
percent := convertPercent(rightValue)
|
||||
if percent >= 0 {
|
||||
return canvasWidth - legendBoxWidth - int(float64(canvasWidth)*percent)
|
||||
}
|
||||
v, _ := strconv.Atoi(rightValue)
|
||||
return canvasWidth - legendBoxWidth - v
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func getLegendTop(height, legendBoxHeight int, opt LegendOption) int {
|
||||
// TODO 支持top的处理
|
||||
return 0
|
||||
}
|
||||
|
||||
func NewLegendCustomize(series []chart.Series, opt LegendOption) chart.Renderable {
|
||||
return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
|
||||
legendDefaults := chart.Style{
|
||||
FontColor: getTextColor(opt.Theme),
|
||||
FontSize: 8.0,
|
||||
StrokeColor: chart.DefaultAxisColor,
|
||||
}
|
||||
|
||||
legendStyle := opt.Style.InheritFrom(chartDefaults.InheritFrom(legendDefaults))
|
||||
|
||||
r.SetFont(legendStyle.GetFont())
|
||||
r.SetFontColor(legendStyle.GetFontColor())
|
||||
r.SetFontSize(legendStyle.GetFontSize())
|
||||
|
||||
var labels []string
|
||||
var lines []chart.Style
|
||||
// 计算label和lines
|
||||
for _, s := range series {
|
||||
if !s.GetStyle().Hidden {
|
||||
if _, isAnnotationSeries := s.(chart.AnnotationSeries); !isAnnotationSeries {
|
||||
labels = append(labels, s.GetName())
|
||||
lines = append(lines, s.GetStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var textHeight int
|
||||
var textBox chart.Box
|
||||
labelWidth := 0
|
||||
// 计算文本宽度与高度(取最大值)
|
||||
for x := 0; x < len(labels); x++ {
|
||||
if len(labels[x]) > 0 {
|
||||
textBox = r.MeasureText(labels[x])
|
||||
labelWidth += textBox.Width()
|
||||
textHeight = chart.MaxInt(textBox.Height(), textHeight)
|
||||
}
|
||||
}
|
||||
|
||||
legendBoxHeight := textHeight + legendStyle.Padding.Top + legendStyle.Padding.Bottom
|
||||
chartPadding := cb.Top
|
||||
legendYMargin := (chartPadding - legendBoxHeight) >> 1
|
||||
|
||||
iconWidth := 25
|
||||
lineTextGap := 5
|
||||
|
||||
iconAllWidth := iconWidth * len(labels)
|
||||
spaceAllWidth := (chart.DefaultMinimumTickHorizontalSpacing + lineTextGap) * (len(labels) - 1)
|
||||
|
||||
legendBoxWidth := labelWidth + iconAllWidth + spaceAllWidth
|
||||
|
||||
left := getLegendLeft(cb.Width(), legendBoxWidth, opt)
|
||||
top := getLegendTop(cb.Height(), legendBoxHeight, opt)
|
||||
|
||||
left += (opt.Padding.Left + cb.Left)
|
||||
top += (opt.Padding.Top + cb.Top)
|
||||
|
||||
legendBox := chart.Box{
|
||||
Left: left,
|
||||
Right: left + legendBoxWidth,
|
||||
Top: top,
|
||||
Bottom: top + legendBoxHeight,
|
||||
}
|
||||
|
||||
chart.Draw.Box(r, legendBox, legendDefaults)
|
||||
|
||||
r.SetFont(legendStyle.GetFont())
|
||||
r.SetFontColor(legendStyle.GetFontColor())
|
||||
r.SetFontSize(legendStyle.GetFontSize())
|
||||
|
||||
startX := legendBox.Left + legendStyle.Padding.Left
|
||||
ty := top + legendYMargin + legendStyle.Padding.Top + textHeight
|
||||
var label string
|
||||
var x int
|
||||
iconDraw := opt.IconDraw
|
||||
if iconDraw == nil {
|
||||
iconDraw = DefaultLegendIconDraw
|
||||
}
|
||||
align := opt.Align
|
||||
if align == "" {
|
||||
align = LegendAlignLeft
|
||||
}
|
||||
for index := range labels {
|
||||
label = labels[index]
|
||||
if len(label) > 0 {
|
||||
x = startX
|
||||
|
||||
// 如果图例标记靠右展示
|
||||
if align == LegendAlignRight {
|
||||
textBox = r.MeasureText(label)
|
||||
r.Text(label, x, ty)
|
||||
x = startX + textBox.Width() + lineTextGap
|
||||
}
|
||||
|
||||
// 图标
|
||||
iconDraw(r, LegendIconDrawOption{
|
||||
Theme: opt.Theme,
|
||||
Index: index,
|
||||
Style: lines[index],
|
||||
Box: chart.Box{
|
||||
Left: x,
|
||||
Top: ty,
|
||||
Right: x + iconWidth,
|
||||
Bottom: ty + textHeight,
|
||||
},
|
||||
})
|
||||
x += (iconWidth + lineTextGap)
|
||||
|
||||
// 如果图例标记靠左展示
|
||||
if align == LegendAlignLeft {
|
||||
textBox = r.MeasureText(label)
|
||||
r.Text(label, x, ty)
|
||||
x += textBox.Width()
|
||||
}
|
||||
|
||||
// 计算下一个legend的位置
|
||||
startX = x + chart.DefaultMinimumTickHorizontalSpacing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
legend_test.go
113
legend_test.go
|
|
@ -1,113 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
func TestNewLegendCustomize(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
series := GetSeries([]Series{
|
||||
{
|
||||
Name: "chrome",
|
||||
},
|
||||
{
|
||||
Name: "edge",
|
||||
},
|
||||
}, chart.TickPositionBetweenTicks, "")
|
||||
|
||||
tests := []struct {
|
||||
align string
|
||||
svg string
|
||||
}{
|
||||
{
|
||||
align: LegendAlignLeft,
|
||||
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<path d=\"M 449 111\nL 582 111\nL 582 121\nL 449 121\nL 449 111\" style=\"stroke-width:0;stroke:rgba(51,51,51,1.0);fill:none\"/><path d=\"M 449 118\nL 474 118\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"461\" cy=\"118\" r=\"5\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"479\" y=\"121\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">chrome</text><path d=\"M 534 118\nL 559 118\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"546\" cy=\"118\" r=\"5\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"564\" y=\"121\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">edge</text></svg>",
|
||||
},
|
||||
{
|
||||
align: LegendAlignRight,
|
||||
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<path d=\"M 449 111\nL 582 111\nL 582 121\nL 449 121\nL 449 111\" style=\"stroke-width:0;stroke:rgba(51,51,51,1.0);fill:none\"/><text x=\"449\" y=\"121\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">chrome</text><path d=\"M 489 118\nL 514 118\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"501\" cy=\"118\" r=\"5\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"539\" y=\"121\" style=\"stroke-width:0;stroke:none;fill:rgba(51,51,51,1.0);font-size:10.2px;font-family:'Roboto Medium',sans-serif\">edge</text><path d=\"M 567 118\nL 592 118\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"579\" cy=\"118\" r=\"5\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/></svg>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
r, err := chart.SVG(800, 600)
|
||||
assert.Nil(err)
|
||||
fn := NewLegendCustomize(series, LegendOption{
|
||||
Align: tt.align,
|
||||
IconDraw: DefaultLegendIconDraw,
|
||||
Padding: chart.Box{
|
||||
Left: 100,
|
||||
Top: 100,
|
||||
},
|
||||
})
|
||||
fn(r, chart.NewBox(11, 47, 784, 373), chart.Style{
|
||||
Font: chart.StyleTextDefaults().Font,
|
||||
})
|
||||
buf := bytes.Buffer{}
|
||||
err = r.Save(&buf)
|
||||
assert.Nil(err)
|
||||
assert.Equal(tt.svg, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertPercent(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal(-1.0, convertPercent("12"))
|
||||
|
||||
assert.Equal(0.12, convertPercent("12%"))
|
||||
}
|
||||
|
||||
func TestGetLegendLeft(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal(150, getLegendLeft(500, 200, LegendOption{}))
|
||||
|
||||
assert.Equal(0, getLegendLeft(500, 200, LegendOption{
|
||||
Left: "left",
|
||||
}))
|
||||
assert.Equal(100, getLegendLeft(500, 200, LegendOption{
|
||||
Left: "20%",
|
||||
}))
|
||||
assert.Equal(20, getLegendLeft(500, 200, LegendOption{
|
||||
Left: "20",
|
||||
}))
|
||||
|
||||
assert.Equal(300, getLegendLeft(500, 200, LegendOption{
|
||||
Right: "right",
|
||||
}))
|
||||
assert.Equal(200, getLegendLeft(500, 200, LegendOption{
|
||||
Right: "20%",
|
||||
}))
|
||||
assert.Equal(280, getLegendLeft(500, 200, LegendOption{
|
||||
Right: "20",
|
||||
}))
|
||||
|
||||
}
|
||||
105
line.go
Normal file
105
line.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// 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"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
type LineStyle struct {
|
||||
ClassName string
|
||||
StrokeDashArray []float64
|
||||
StrokeColor drawing.Color
|
||||
StrokeWidth float64
|
||||
FillColor drawing.Color
|
||||
DotWidth float64
|
||||
DotColor drawing.Color
|
||||
DotFillColor drawing.Color
|
||||
}
|
||||
|
||||
func (ls *LineStyle) Style() chart.Style {
|
||||
return chart.Style{
|
||||
ClassName: ls.ClassName,
|
||||
StrokeDashArray: ls.StrokeDashArray,
|
||||
StrokeColor: ls.StrokeColor,
|
||||
StrokeWidth: ls.StrokeWidth,
|
||||
FillColor: ls.FillColor,
|
||||
DotWidth: ls.DotWidth,
|
||||
DotColor: ls.DotColor,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Draw) lineFill(points []Point, style LineStyle) {
|
||||
s := style.Style()
|
||||
if !(s.ShouldDrawStroke() && s.ShouldDrawFill()) {
|
||||
return
|
||||
}
|
||||
r := d.Render
|
||||
var x, y int
|
||||
s.GetFillOptions().WriteDrawingOptionsToRenderer(r)
|
||||
for index, point := range points {
|
||||
x = point.X
|
||||
y = point.Y
|
||||
if index == 0 {
|
||||
d.moveTo(x, y)
|
||||
} else {
|
||||
d.lineTo(x, y)
|
||||
}
|
||||
}
|
||||
height := d.Box.Height()
|
||||
d.lineTo(x, height)
|
||||
x0 := points[0].X
|
||||
y0 := points[0].Y
|
||||
d.lineTo(x0, height)
|
||||
d.lineTo(x0, y0)
|
||||
r.Fill()
|
||||
}
|
||||
|
||||
func (d *Draw) lineDot(points []Point, style LineStyle) {
|
||||
s := style.Style()
|
||||
if !s.ShouldDrawDot() {
|
||||
return
|
||||
}
|
||||
r := d.Render
|
||||
dotWith := s.GetDotWidth()
|
||||
|
||||
s.GetDotOptions().WriteDrawingOptionsToRenderer(r)
|
||||
for _, point := range points {
|
||||
if !style.DotFillColor.IsZero() {
|
||||
r.SetFillColor(style.DotFillColor)
|
||||
}
|
||||
r.SetStrokeColor(s.DotColor)
|
||||
d.circle(dotWith, point.X, point.Y)
|
||||
r.FillStroke()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Draw) Line(points []Point, style LineStyle) {
|
||||
if len(points) == 0 {
|
||||
return
|
||||
}
|
||||
d.lineFill(points, style)
|
||||
d.lineStroke(points, style)
|
||||
d.lineDot(points, style)
|
||||
}
|
||||
174
line_chart.go
Normal file
174
line_chart.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// 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"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
type LineChartOption struct {
|
||||
ChartOption
|
||||
}
|
||||
|
||||
const YAxisWidth = 50
|
||||
|
||||
func drawXAxis(d *Draw, opt *XAxisOption, theme *Theme) (int, *Range, error) {
|
||||
dXAxis, err := NewDraw(
|
||||
DrawOption{
|
||||
Parent: d,
|
||||
},
|
||||
PaddingOption(chart.Box{
|
||||
Left: YAxisWidth,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
data := NewAxisDataListFromStringList(opt.Data)
|
||||
style := AxisStyle{
|
||||
BoundaryGap: opt.BoundaryGap,
|
||||
StrokeColor: theme.GetAxisStrokeColor(),
|
||||
FontColor: theme.GetAxisStrokeColor(),
|
||||
StrokeWidth: 1,
|
||||
}
|
||||
|
||||
boundary := true
|
||||
max := float64(len(opt.Data))
|
||||
if opt.BoundaryGap != nil && !*opt.BoundaryGap {
|
||||
boundary = false
|
||||
max--
|
||||
}
|
||||
|
||||
dXAxis.Axis(data, style)
|
||||
return d.measureAxis(data, style), &Range{
|
||||
divideCount: len(opt.Data),
|
||||
Min: 0,
|
||||
Max: max,
|
||||
Size: dXAxis.Box.Width(),
|
||||
Boundary: boundary,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func drawYAxis(d *Draw, opt *ChartOption, theme *Theme, xAxisHeight int) (*Range, error) {
|
||||
yRange := opt.getYRange(0)
|
||||
data := NewAxisDataListFromStringList(yRange.Values())
|
||||
style := AxisStyle{
|
||||
Position: PositionLeft,
|
||||
BoundaryGap: FalseFlag(),
|
||||
// StrokeColor: theme.GetAxisStrokeColor(),
|
||||
FontColor: theme.GetAxisStrokeColor(),
|
||||
StrokeWidth: 1,
|
||||
SplitLineColor: theme.GetAxisSplitLineColor(),
|
||||
SplitLineShow: true,
|
||||
}
|
||||
width := d.measureAxis(data, style)
|
||||
|
||||
dYAxis, err := NewDraw(
|
||||
DrawOption{
|
||||
Parent: d,
|
||||
Width: d.Box.Width(),
|
||||
// 减去x轴的高
|
||||
Height: d.Box.Height() - xAxisHeight,
|
||||
},
|
||||
PaddingOption(chart.Box{
|
||||
Left: YAxisWidth - width,
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dYAxis.Axis(data, style)
|
||||
yRange.Size = dYAxis.Box.Height()
|
||||
return &yRange, nil
|
||||
}
|
||||
|
||||
func NewLineChart(opt LineChartOption) (*Draw, error) {
|
||||
d, err := NewDraw(
|
||||
DrawOption{
|
||||
Parent: opt.Parent,
|
||||
Width: opt.getWidth(),
|
||||
Height: opt.getHeight(),
|
||||
},
|
||||
PaddingOption(opt.Padding),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
theme := Theme{
|
||||
mode: opt.Theme,
|
||||
}
|
||||
// 设置背景色
|
||||
bg := opt.BackgroundColor
|
||||
if bg.IsZero() {
|
||||
bg = theme.GetBackgroundColor()
|
||||
}
|
||||
if opt.Parent == nil {
|
||||
d.setBackground(opt.getWidth(), opt.getHeight(), bg)
|
||||
}
|
||||
|
||||
xAxisHeight, xRange, err := drawXAxis(d, &opt.XAxis, &theme)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 暂时仅支持单一yaxis
|
||||
yRange, err := drawYAxis(d, &opt.ChartOption, &theme, xAxisHeight)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sd, err := NewDraw(DrawOption{
|
||||
Parent: d,
|
||||
}, PaddingOption(chart.Box{
|
||||
Left: YAxisWidth,
|
||||
}))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, series := range opt.SeriesList {
|
||||
points := make([]Point, 0)
|
||||
for j, item := range series.Data {
|
||||
y := yRange.getHeight(item.Value)
|
||||
points = append(points, Point{
|
||||
Y: y,
|
||||
X: xRange.getWidth(float64(j)),
|
||||
})
|
||||
seriesColor := theme.GetSeriesColor(i)
|
||||
dotFillColor := drawing.ColorWhite
|
||||
if theme.IsDark() {
|
||||
dotFillColor = seriesColor
|
||||
}
|
||||
sd.Line(points, LineStyle{
|
||||
StrokeColor: seriesColor,
|
||||
StrokeWidth: 2,
|
||||
DotColor: seriesColor,
|
||||
DotWidth: 2,
|
||||
DotFillColor: dotFillColor,
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
// fmt.Println(yRange)
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
func TestLineSeries(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ls := LineSeries{}
|
||||
|
||||
originalRange := &chart.ContinuousRange{}
|
||||
xrange := ls.getXRange(originalRange)
|
||||
assert.Equal(originalRange, xrange)
|
||||
|
||||
ls.TickPosition = chart.TickPositionBetweenTicks
|
||||
xrange = ls.getXRange(originalRange)
|
||||
value, ok := xrange.(*Range)
|
||||
assert.True(ok)
|
||||
assert.Equal(originalRange, &value.ContinuousRange)
|
||||
}
|
||||
165
line_test.go
Normal file
165
line_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
func TestLineStyle(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ls := LineStyle{
|
||||
ClassName: "test",
|
||||
StrokeDashArray: []float64{
|
||||
1.0,
|
||||
},
|
||||
StrokeColor: drawing.ColorBlack,
|
||||
StrokeWidth: 1,
|
||||
FillColor: drawing.ColorBlack.WithAlpha(60),
|
||||
DotWidth: 2,
|
||||
DotColor: drawing.ColorBlack,
|
||||
DotFillColor: drawing.ColorWhite,
|
||||
}
|
||||
|
||||
assert.Equal(chart.Style{
|
||||
ClassName: "test",
|
||||
StrokeDashArray: []float64{
|
||||
1.0,
|
||||
},
|
||||
StrokeColor: drawing.ColorBlack,
|
||||
StrokeWidth: 1,
|
||||
FillColor: drawing.ColorBlack.WithAlpha(60),
|
||||
DotWidth: 2,
|
||||
DotColor: drawing.ColorBlack,
|
||||
}, ls.Style())
|
||||
}
|
||||
|
||||
func TestDrawLineFill(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ls := LineStyle{
|
||||
StrokeColor: drawing.ColorBlack,
|
||||
StrokeWidth: 1,
|
||||
FillColor: drawing.ColorBlack.WithAlpha(60),
|
||||
DotWidth: 2,
|
||||
DotColor: drawing.ColorBlack,
|
||||
DotFillColor: drawing.ColorWhite,
|
||||
}
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
})
|
||||
assert.Nil(err)
|
||||
d.lineFill([]Point{
|
||||
{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
},
|
||||
{
|
||||
X: 10,
|
||||
Y: 20,
|
||||
},
|
||||
{
|
||||
X: 50,
|
||||
Y: 60,
|
||||
},
|
||||
}, ls)
|
||||
data, err := d.Bytes()
|
||||
assert.Nil(err)
|
||||
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 0\nL 10 20\nL 50 60\nL 50 300\nL 0 300\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,0.2)\"/></svg>", string(data))
|
||||
}
|
||||
|
||||
func TestDrawLineDot(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ls := LineStyle{
|
||||
StrokeColor: drawing.ColorBlack,
|
||||
StrokeWidth: 1,
|
||||
FillColor: drawing.ColorBlack.WithAlpha(60),
|
||||
DotWidth: 2,
|
||||
DotColor: drawing.ColorBlack,
|
||||
DotFillColor: drawing.ColorWhite,
|
||||
}
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
})
|
||||
assert.Nil(err)
|
||||
d.lineDot([]Point{
|
||||
{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
},
|
||||
{
|
||||
X: 10,
|
||||
Y: 20,
|
||||
},
|
||||
{
|
||||
X: 50,
|
||||
Y: 60,
|
||||
},
|
||||
}, ls)
|
||||
data, err := d.Bytes()
|
||||
assert.Nil(err)
|
||||
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"0\" cy=\"0\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"10\" cy=\"20\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"50\" cy=\"60\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", string(data))
|
||||
}
|
||||
|
||||
func TestDrawLine(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
ls := LineStyle{
|
||||
StrokeColor: drawing.ColorBlack,
|
||||
StrokeWidth: 1,
|
||||
FillColor: drawing.ColorBlack.WithAlpha(60),
|
||||
DotWidth: 2,
|
||||
DotColor: drawing.ColorBlack,
|
||||
DotFillColor: drawing.ColorWhite,
|
||||
}
|
||||
d, err := NewDraw(DrawOption{
|
||||
Width: 400,
|
||||
Height: 300,
|
||||
})
|
||||
assert.Nil(err)
|
||||
d.Line([]Point{
|
||||
{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
},
|
||||
{
|
||||
X: 10,
|
||||
Y: 20,
|
||||
},
|
||||
{
|
||||
X: 50,
|
||||
Y: 60,
|
||||
},
|
||||
}, ls)
|
||||
data, err := d.Bytes()
|
||||
assert.Nil(err)
|
||||
assert.Equal("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<path d=\"M 0 0\nL 10 20\nL 50 60\nL 50 300\nL 0 300\nL 0 0\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,0.2)\"/><path d=\"M 0 0\nL 10 20\nL 50 60\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><circle cx=\"0\" cy=\"0\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"10\" cy=\"20\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"50\" cy=\"60\" r=\"2\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", string(data))
|
||||
}
|
||||
93
range.go
93
range.go
|
|
@ -1,93 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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"
|
||||
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
type Range struct {
|
||||
TickPosition chart.TickPosition
|
||||
chart.ContinuousRange
|
||||
}
|
||||
|
||||
func wrapRange(r chart.Range, tickPosition chart.TickPosition) chart.Range {
|
||||
xr, ok := r.(*chart.ContinuousRange)
|
||||
if !ok {
|
||||
return r
|
||||
}
|
||||
return &Range{
|
||||
TickPosition: tickPosition,
|
||||
ContinuousRange: *xr,
|
||||
}
|
||||
}
|
||||
|
||||
// Translate maps a given value into the ContinuousRange space.
|
||||
func (r Range) Translate(value float64) int {
|
||||
v := r.ContinuousRange.Translate(value)
|
||||
if r.TickPosition == chart.TickPositionBetweenTicks {
|
||||
v -= int(float64(r.Domain) / (r.GetDelta() * 2))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
type HiddenRange struct {
|
||||
chart.ContinuousRange
|
||||
}
|
||||
|
||||
func (r HiddenRange) GetDelta() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Y轴使用的continuous range
|
||||
// min 与max只允许设置一次
|
||||
// 如果是计算得出的max,增加20%的值并取整
|
||||
type YContinuousRange struct {
|
||||
chart.ContinuousRange
|
||||
}
|
||||
|
||||
func (m YContinuousRange) IsZero() bool {
|
||||
// 默认返回true,允许修改
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *YContinuousRange) SetMin(min float64) {
|
||||
// 如果已修改,则忽略
|
||||
if m.Min != -math.MaxFloat64 {
|
||||
return
|
||||
}
|
||||
m.Min = min
|
||||
}
|
||||
|
||||
func (m *YContinuousRange) SetMax(max float64) {
|
||||
// 如果已修改,则忽略
|
||||
if m.Max != math.MaxFloat64 {
|
||||
return
|
||||
}
|
||||
// 此处为计算得来的最大值,放大20%
|
||||
v := int(max * 1.2)
|
||||
// TODO 是否要取整十整百
|
||||
m.Max = float64(v)
|
||||
}
|
||||
135
series.go
135
series.go
|
|
@ -1,135 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 SeriesData struct {
|
||||
Value float64
|
||||
Style chart.Style
|
||||
}
|
||||
|
||||
type Series struct {
|
||||
Type string
|
||||
Name string
|
||||
Data []SeriesData
|
||||
XValues []float64
|
||||
YAxisIndex int
|
||||
Style chart.Style
|
||||
Label SeriesLabel
|
||||
}
|
||||
|
||||
const lineStrokeWidth = 2
|
||||
const dotWith = 2
|
||||
|
||||
const (
|
||||
SeriesBar = "bar"
|
||||
SeriesLine = "line"
|
||||
SeriesPie = "pie"
|
||||
)
|
||||
|
||||
func NewSeriesDataListFromFloat(values []float64) []SeriesData {
|
||||
dataList := make([]SeriesData, len(values))
|
||||
for index, value := range values {
|
||||
dataList[index] = SeriesData{
|
||||
Value: value,
|
||||
}
|
||||
}
|
||||
return dataList
|
||||
}
|
||||
func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) []chart.Series {
|
||||
arr := make([]chart.Series, len(series))
|
||||
barCount := 0
|
||||
barIndex := 0
|
||||
for _, item := range series {
|
||||
if item.Type == SeriesBar {
|
||||
barCount++
|
||||
}
|
||||
}
|
||||
|
||||
for index, item := range series {
|
||||
style := chart.Style{
|
||||
StrokeWidth: lineStrokeWidth,
|
||||
StrokeColor: getSeriesColor(theme, index),
|
||||
// TODO 调整为通过dot with color 生成
|
||||
DotColor: getSeriesColor(theme, index),
|
||||
DotWidth: dotWith,
|
||||
FontColor: getAxisColor(theme),
|
||||
}
|
||||
if !item.Style.StrokeColor.IsZero() {
|
||||
style.StrokeColor = item.Style.StrokeColor
|
||||
style.DotColor = item.Style.StrokeColor
|
||||
}
|
||||
pointIndexOffset := 0
|
||||
// 如果居中,需要多增加一个点
|
||||
if tickPosition == chart.TickPositionBetweenTicks {
|
||||
item.Data = append([]SeriesData{
|
||||
{
|
||||
Value: 0.0,
|
||||
},
|
||||
}, item.Data...)
|
||||
pointIndexOffset = -1
|
||||
}
|
||||
yValues := make([]float64, len(item.Data))
|
||||
barCustomStyles := make([]BarSeriesCustomStyle, 0)
|
||||
for i, item := range item.Data {
|
||||
yValues[i] = item.Value
|
||||
if !item.Style.IsZero() {
|
||||
barCustomStyles = append(barCustomStyles, BarSeriesCustomStyle{
|
||||
PointIndex: i + pointIndexOffset,
|
||||
Index: barIndex,
|
||||
Style: item.Style,
|
||||
})
|
||||
}
|
||||
}
|
||||
baseSeries := BaseSeries{
|
||||
Name: item.Name,
|
||||
XValues: item.XValues,
|
||||
Style: style,
|
||||
YValues: yValues,
|
||||
TickPosition: tickPosition,
|
||||
YAxis: chart.YAxisSecondary,
|
||||
Label: item.Label,
|
||||
}
|
||||
if item.YAxisIndex != 0 {
|
||||
baseSeries.YAxis = chart.YAxisPrimary
|
||||
}
|
||||
switch item.Type {
|
||||
case SeriesBar:
|
||||
arr[index] = BarSeries{
|
||||
Count: barCount,
|
||||
Index: barIndex,
|
||||
BaseSeries: baseSeries,
|
||||
CustomStyles: barCustomStyles,
|
||||
}
|
||||
barIndex++
|
||||
default:
|
||||
arr[index] = LineSeries{
|
||||
BaseSeries: baseSeries,
|
||||
}
|
||||
}
|
||||
}
|
||||
return arr
|
||||
}
|
||||
125
series_test.go
125
series_test.go
|
|
@ -1,125 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
func TestNewSeriesDataListFromFloat(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal([]SeriesData{
|
||||
{
|
||||
Value: 1,
|
||||
},
|
||||
{
|
||||
Value: 2,
|
||||
},
|
||||
}, NewSeriesDataListFromFloat([]float64{
|
||||
1,
|
||||
2,
|
||||
}))
|
||||
}
|
||||
|
||||
func TestGetSeries(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
xValues := []float64{
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
}
|
||||
|
||||
barData := NewSeriesDataListFromFloat([]float64{
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
40,
|
||||
50,
|
||||
})
|
||||
barData[1].Style = chart.Style{
|
||||
FillColor: AxisColorDark,
|
||||
}
|
||||
seriesList := GetSeries([]Series{
|
||||
{
|
||||
Type: SeriesBar,
|
||||
Data: barData,
|
||||
XValues: xValues,
|
||||
YAxisIndex: 1,
|
||||
},
|
||||
{
|
||||
Data: NewSeriesDataListFromFloat([]float64{
|
||||
11,
|
||||
21,
|
||||
31,
|
||||
41,
|
||||
51,
|
||||
}),
|
||||
XValues: xValues,
|
||||
},
|
||||
}, chart.TickPositionBetweenTicks, "")
|
||||
|
||||
assert.Equal(seriesList[0].GetYAxis(), chart.YAxisPrimary)
|
||||
assert.Equal(seriesList[1].GetYAxis(), chart.YAxisSecondary)
|
||||
|
||||
barSeries, ok := seriesList[0].(BarSeries)
|
||||
assert.True(ok)
|
||||
// 居中前置多插入一个点
|
||||
assert.Equal([]float64{
|
||||
0,
|
||||
10,
|
||||
20,
|
||||
30,
|
||||
40,
|
||||
50,
|
||||
}, barSeries.YValues)
|
||||
assert.Equal(xValues, barSeries.XValues)
|
||||
assert.Equal(1, barSeries.Count)
|
||||
assert.Equal(0, barSeries.Index)
|
||||
assert.Equal([]BarSeriesCustomStyle{
|
||||
{
|
||||
PointIndex: 1,
|
||||
Index: 0,
|
||||
Style: barData[1].Style,
|
||||
},
|
||||
}, barSeries.CustomStyles)
|
||||
|
||||
lineSeries, ok := seriesList[1].(LineSeries)
|
||||
assert.True(ok)
|
||||
// 居中前置多插入一个点
|
||||
assert.Equal([]float64{
|
||||
0,
|
||||
11,
|
||||
21,
|
||||
31,
|
||||
41,
|
||||
51,
|
||||
}, lineSeries.YValues)
|
||||
assert.Equal(xValues, lineSeries.XValues)
|
||||
}
|
||||
184
theme.go
184
theme.go
|
|
@ -1,6 +1,6 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 Tree Xie
|
||||
// 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
|
||||
|
|
@ -22,73 +22,60 @@
|
|||
|
||||
package charts
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
import "github.com/wcharczuk/go-chart/v2/drawing"
|
||||
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
const ThemeDark = "dark"
|
||||
const ThemeLight = "light"
|
||||
|
||||
var hiddenColor = drawing.Color{R: 255, G: 255, B: 255, A: 0}
|
||||
|
||||
var AxisColorLight = drawing.Color{
|
||||
R: 110,
|
||||
G: 112,
|
||||
B: 121,
|
||||
A: 255,
|
||||
type Theme struct {
|
||||
mode string
|
||||
}
|
||||
var AxisColorDark = drawing.Color{
|
||||
|
||||
func (t *Theme) IsDark() bool {
|
||||
return t.mode == ThemeDark
|
||||
}
|
||||
|
||||
func (t *Theme) GetAxisStrokeColor() drawing.Color {
|
||||
if t.IsDark() {
|
||||
return drawing.Color{
|
||||
R: 185,
|
||||
G: 184,
|
||||
B: 206,
|
||||
A: 255,
|
||||
}
|
||||
}
|
||||
return drawing.Color{
|
||||
R: 110,
|
||||
G: 112,
|
||||
B: 121,
|
||||
A: 255,
|
||||
}
|
||||
}
|
||||
|
||||
var GridColorDark = drawing.Color{
|
||||
func (t *Theme) GetAxisSplitLineColor() drawing.Color {
|
||||
if t.IsDark() {
|
||||
return drawing.Color{
|
||||
R: 72,
|
||||
G: 71,
|
||||
B: 83,
|
||||
A: 255,
|
||||
}
|
||||
|
||||
var GridColorLight = drawing.Color{
|
||||
}
|
||||
}
|
||||
return drawing.Color{
|
||||
R: 224,
|
||||
G: 230,
|
||||
B: 241,
|
||||
B: 242,
|
||||
A: 255,
|
||||
}
|
||||
|
||||
var BackgroundColorDark = drawing.Color{
|
||||
R: 16,
|
||||
G: 12,
|
||||
B: 42,
|
||||
A: 255,
|
||||
}
|
||||
|
||||
var TextColorDark = drawing.Color{
|
||||
R: 204,
|
||||
G: 204,
|
||||
B: 204,
|
||||
A: 255,
|
||||
}
|
||||
|
||||
func getAxisColor(theme string) drawing.Color {
|
||||
if theme == ThemeDark {
|
||||
return AxisColorDark
|
||||
}
|
||||
return AxisColorLight
|
||||
}
|
||||
|
||||
func getGridColor(theme string) drawing.Color {
|
||||
if theme == ThemeDark {
|
||||
return GridColorDark
|
||||
}
|
||||
return GridColorLight
|
||||
func (t *Theme) GetSeriesColor(index int) drawing.Color {
|
||||
colors := t.GetSeriesColors()
|
||||
return colors[index%len(colors)]
|
||||
}
|
||||
|
||||
var SeriesColorsLight = []drawing.Color{
|
||||
func (t *Theme) GetSeriesColors() []drawing.Color {
|
||||
return []drawing.Color{
|
||||
{
|
||||
R: 84,
|
||||
G: 112,
|
||||
|
|
@ -119,104 +106,17 @@ var SeriesColorsLight = []drawing.Color{
|
|||
B: 222,
|
||||
A: 255,
|
||||
},
|
||||
}
|
||||
|
||||
func getBackgroundColor(theme string) drawing.Color {
|
||||
if theme == ThemeDark {
|
||||
return BackgroundColorDark
|
||||
}
|
||||
return chart.DefaultBackgroundColor
|
||||
}
|
||||
|
||||
func getTextColor(theme string) drawing.Color {
|
||||
if theme == ThemeDark {
|
||||
return TextColorDark
|
||||
}
|
||||
return chart.DefaultTextColor
|
||||
}
|
||||
|
||||
type ThemeColorPalette struct {
|
||||
Theme string
|
||||
}
|
||||
|
||||
type PieThemeColorPalette struct {
|
||||
ThemeColorPalette
|
||||
}
|
||||
|
||||
func (tp PieThemeColorPalette) TextColor() drawing.Color {
|
||||
return getTextColor("")
|
||||
}
|
||||
|
||||
func (tp ThemeColorPalette) BackgroundColor() drawing.Color {
|
||||
return getBackgroundColor(tp.Theme)
|
||||
}
|
||||
|
||||
func (tp ThemeColorPalette) BackgroundStrokeColor() drawing.Color {
|
||||
return chart.DefaultBackgroundStrokeColor
|
||||
}
|
||||
|
||||
func (tp ThemeColorPalette) CanvasColor() drawing.Color {
|
||||
if tp.Theme == ThemeDark {
|
||||
return BackgroundColorDark
|
||||
}
|
||||
return chart.DefaultCanvasColor
|
||||
}
|
||||
|
||||
func (tp ThemeColorPalette) CanvasStrokeColor() drawing.Color {
|
||||
return chart.DefaultCanvasStrokeColor
|
||||
}
|
||||
|
||||
func (tp ThemeColorPalette) AxisStrokeColor() drawing.Color {
|
||||
if tp.Theme == ThemeDark {
|
||||
return BackgroundColorDark
|
||||
}
|
||||
return chart.DefaultAxisColor
|
||||
}
|
||||
|
||||
func (tp ThemeColorPalette) TextColor() drawing.Color {
|
||||
return getTextColor(tp.Theme)
|
||||
}
|
||||
|
||||
func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color {
|
||||
return getSeriesColor(tp.Theme, index)
|
||||
}
|
||||
|
||||
func getSeriesColor(theme string, index int) drawing.Color {
|
||||
return SeriesColorsLight[index%len(SeriesColorsLight)]
|
||||
}
|
||||
|
||||
func parseColor(color string) drawing.Color {
|
||||
c := drawing.Color{}
|
||||
if color == "" {
|
||||
return c
|
||||
}
|
||||
if strings.HasPrefix(color, "#") {
|
||||
return drawing.ColorFromHex(color[1:])
|
||||
}
|
||||
reg := regexp.MustCompile(`\((\S+)\)`)
|
||||
result := reg.FindAllStringSubmatch(color, 1)
|
||||
if len(result) == 0 || len(result[0]) != 2 {
|
||||
return c
|
||||
}
|
||||
arr := strings.Split(result[0][1], ",")
|
||||
if len(arr) < 3 {
|
||||
return c
|
||||
}
|
||||
// 设置默认为255
|
||||
c.A = 255
|
||||
for index, v := range arr {
|
||||
value, _ := strconv.Atoi(strings.TrimSpace(v))
|
||||
ui8 := uint8(value)
|
||||
switch index {
|
||||
case 0:
|
||||
c.R = ui8
|
||||
case 1:
|
||||
c.G = ui8
|
||||
case 2:
|
||||
c.B = ui8
|
||||
default:
|
||||
c.A = ui8
|
||||
func (t *Theme) GetBackgroundColor() drawing.Color {
|
||||
if t.IsDark() {
|
||||
return drawing.Color{
|
||||
R: 16,
|
||||
G: 12,
|
||||
B: 42,
|
||||
A: 255,
|
||||
}
|
||||
}
|
||||
return c
|
||||
return drawing.ColorWhite
|
||||
}
|
||||
|
|
|
|||
122
theme_test.go
122
theme_test.go
|
|
@ -1,122 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
func TestThemeColors(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal(AxisColorDark, getAxisColor(ThemeDark))
|
||||
assert.Equal(AxisColorLight, getAxisColor(""))
|
||||
|
||||
assert.Equal(GridColorDark, getGridColor(ThemeDark))
|
||||
assert.Equal(GridColorLight, getGridColor(""))
|
||||
|
||||
assert.Equal(BackgroundColorDark, getBackgroundColor(ThemeDark))
|
||||
assert.Equal(chart.DefaultBackgroundColor, getBackgroundColor(""))
|
||||
|
||||
assert.Equal(TextColorDark, getTextColor(ThemeDark))
|
||||
assert.Equal(chart.DefaultTextColor, getTextColor(""))
|
||||
}
|
||||
|
||||
func TestThemeColorPalette(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
dark := ThemeColorPalette{
|
||||
Theme: ThemeDark,
|
||||
}
|
||||
assert.Equal(BackgroundColorDark, dark.BackgroundColor())
|
||||
assert.Equal(chart.DefaultBackgroundStrokeColor, dark.BackgroundStrokeColor())
|
||||
assert.Equal(BackgroundColorDark, dark.CanvasColor())
|
||||
assert.Equal(chart.DefaultCanvasStrokeColor, dark.CanvasStrokeColor())
|
||||
assert.Equal(BackgroundColorDark, dark.AxisStrokeColor())
|
||||
assert.Equal(TextColorDark, dark.TextColor())
|
||||
// series 使用统一的color
|
||||
assert.Equal(SeriesColorsLight[0], dark.GetSeriesColor(0))
|
||||
|
||||
light := ThemeColorPalette{}
|
||||
assert.Equal(chart.DefaultBackgroundColor, light.BackgroundColor())
|
||||
assert.Equal(chart.DefaultBackgroundStrokeColor, light.BackgroundStrokeColor())
|
||||
assert.Equal(chart.DefaultCanvasColor, light.CanvasColor())
|
||||
assert.Equal(chart.DefaultCanvasStrokeColor, light.CanvasStrokeColor())
|
||||
assert.Equal(chart.DefaultAxisColor, light.AxisStrokeColor())
|
||||
assert.Equal(chart.DefaultTextColor, light.TextColor())
|
||||
// series 使用统一的color
|
||||
assert.Equal(SeriesColorsLight[0], light.GetSeriesColor(0))
|
||||
}
|
||||
|
||||
func TestPieThemeColorPalette(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
p := PieThemeColorPalette{}
|
||||
|
||||
// pie无认哪种theme,文本的颜色都一样
|
||||
assert.Equal(chart.DefaultTextColor, p.TextColor())
|
||||
p.Theme = ThemeDark
|
||||
assert.Equal(chart.DefaultTextColor, p.TextColor())
|
||||
}
|
||||
|
||||
func TestParseColor(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
c := parseColor("")
|
||||
assert.True(c.IsZero())
|
||||
|
||||
c = parseColor("#333")
|
||||
assert.Equal(drawing.Color{
|
||||
R: 51,
|
||||
G: 51,
|
||||
B: 51,
|
||||
A: 255,
|
||||
}, c)
|
||||
|
||||
c = parseColor("#313233")
|
||||
assert.Equal(drawing.Color{
|
||||
R: 49,
|
||||
G: 50,
|
||||
B: 51,
|
||||
A: 255,
|
||||
}, c)
|
||||
|
||||
c = parseColor("rgb(31,32,33)")
|
||||
assert.Equal(drawing.Color{
|
||||
R: 31,
|
||||
G: 32,
|
||||
B: 33,
|
||||
A: 255,
|
||||
}, c)
|
||||
|
||||
c = parseColor("rgba(50,51,52,250)")
|
||||
assert.Equal(drawing.Color{
|
||||
R: 50,
|
||||
G: 51,
|
||||
B: 52,
|
||||
A: 250,
|
||||
}, c)
|
||||
}
|
||||
103
title.go
103
title.go
|
|
@ -1,103 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
type titleMeasureOption struct {
|
||||
width int
|
||||
height int
|
||||
text string
|
||||
}
|
||||
|
||||
func NewTitleCustomize(title Title) chart.Renderable {
|
||||
return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
|
||||
if len(title.Text) == 0 || title.Style.Hidden {
|
||||
return
|
||||
}
|
||||
font := title.Font
|
||||
if font == nil {
|
||||
font, _ = chart.GetDefaultFont()
|
||||
}
|
||||
r.SetFont(font)
|
||||
r.SetFontColor(title.Style.FontColor)
|
||||
titleFontSize := title.Style.GetFontSize(chart.DefaultTitleFontSize)
|
||||
r.SetFontSize(titleFontSize)
|
||||
|
||||
arr := strings.Split(title.Text, "\n")
|
||||
textWidth := 0
|
||||
textHeight := 0
|
||||
measureOptions := make([]titleMeasureOption, len(arr))
|
||||
for index, str := range arr {
|
||||
textBox := r.MeasureText(str)
|
||||
|
||||
w := textBox.Width()
|
||||
h := textBox.Height()
|
||||
if w > textWidth {
|
||||
textWidth = w
|
||||
}
|
||||
if h > textHeight {
|
||||
textHeight = h
|
||||
}
|
||||
measureOptions[index] = titleMeasureOption{
|
||||
text: str,
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
}
|
||||
|
||||
titleX := 0
|
||||
switch title.Left {
|
||||
case "right":
|
||||
titleX = cb.Left + cb.Width() - textWidth
|
||||
case "center":
|
||||
titleX = cb.Left + cb.Width()>>1 - (textWidth >> 1)
|
||||
default:
|
||||
if strings.HasSuffix(title.Left, "%") {
|
||||
value, _ := strconv.Atoi(strings.ReplaceAll(title.Left, "%", ""))
|
||||
titleX = cb.Left + cb.Width()*value/100
|
||||
} else {
|
||||
value, _ := strconv.Atoi(title.Left)
|
||||
titleX = cb.Left + value
|
||||
}
|
||||
}
|
||||
|
||||
titleY := cb.Top + title.Style.Padding.GetTop(chart.DefaultTitleTop) + (textHeight >> 1)
|
||||
// TOP 暂只支持数值
|
||||
if title.Top != "" {
|
||||
value, _ := strconv.Atoi(title.Top)
|
||||
titleY += value
|
||||
}
|
||||
|
||||
for _, item := range measureOptions {
|
||||
x := titleX + (textWidth-item.width)>>1
|
||||
r.Text(item.text, x, titleY)
|
||||
titleY += textHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
// MIT License
|
||||
|
||||
// Copyright (c) 2021 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 (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||
)
|
||||
|
||||
func TestTitleCustomize(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
tests := []struct {
|
||||
title Title
|
||||
svg string
|
||||
}{
|
||||
// 单行标题
|
||||
{
|
||||
title: Title{
|
||||
Text: "Hello World!",
|
||||
Style: chart.Style{
|
||||
FontColor: drawing.ColorBlack,
|
||||
},
|
||||
},
|
||||
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<text x=\"50\" y=\"71\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:23.0px;font-family:'Roboto Medium',sans-serif\">Hello World!</text></svg>",
|
||||
},
|
||||
// 多行标题,靠右
|
||||
{
|
||||
title: Title{
|
||||
Text: "Hello World!\nHello World",
|
||||
Style: chart.Style{
|
||||
FontColor: drawing.ColorBlack,
|
||||
},
|
||||
Left: "right",
|
||||
},
|
||||
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<text x=\"474\" y=\"71\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:23.0px;font-family:'Roboto Medium',sans-serif\">Hello World!</text><text x=\"477\" y=\"94\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:23.0px;font-family:'Roboto Medium',sans-serif\">Hello World</text></svg>",
|
||||
},
|
||||
// 标题居中
|
||||
{
|
||||
title: Title{
|
||||
Text: "Hello World!",
|
||||
Style: chart.Style{
|
||||
FontColor: drawing.ColorBlack,
|
||||
},
|
||||
Left: "center",
|
||||
},
|
||||
svg: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"800\" height=\"600\">\\n<text x=\"262\" y=\"71\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:23.0px;font-family:'Roboto Medium',sans-serif\">Hello World!</text></svg>",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
r, err := chart.SVG(800, 600)
|
||||
assert.Nil(err)
|
||||
fn := NewTitleCustomize(tt.title)
|
||||
fn(r, chart.NewBox(50, 50, 600, 400), chart.Style{
|
||||
Font: chart.StyleTextDefaults().Font,
|
||||
})
|
||||
buf := bytes.Buffer{}
|
||||
err = r.Save(&buf)
|
||||
assert.Nil(err)
|
||||
assert.Equal(tt.svg, buf.String())
|
||||
}
|
||||
}
|
||||
10
util.go
10
util.go
|
|
@ -24,6 +24,16 @@ package charts
|
|||
|
||||
import "github.com/wcharczuk/go-chart/v2"
|
||||
|
||||
func TrueFlag() *bool {
|
||||
t := true
|
||||
return &t
|
||||
}
|
||||
|
||||
func FalseFlag() *bool {
|
||||
f := false
|
||||
return &f
|
||||
}
|
||||
|
||||
func getDefaultInt(value, defaultValue int) int {
|
||||
if value == 0 {
|
||||
return defaultValue
|
||||
|
|
|
|||
121
util_test.go
Normal file
121
util_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/wcharczuk/go-chart/v2"
|
||||
)
|
||||
|
||||
func TestGetDefaultInt(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal(1, getDefaultInt(0, 1))
|
||||
assert.Equal(10, getDefaultInt(10, 1))
|
||||
}
|
||||
|
||||
func TestAutoDivide(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal([]int{
|
||||
0,
|
||||
86,
|
||||
172,
|
||||
258,
|
||||
344,
|
||||
430,
|
||||
515,
|
||||
600,
|
||||
}, autoDivide(600, 7))
|
||||
}
|
||||
|
||||
func TestMaxInt(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
assert.Equal(5, maxInt(1, 3, 5, 2))
|
||||
}
|
||||
|
||||
func TestMeasureTextMaxWidthHeight(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
r, err := chart.SVG(400, 300)
|
||||
assert.Nil(err)
|
||||
style := chart.Style{
|
||||
FontSize: 10,
|
||||
}
|
||||
style.Font, _ = chart.GetDefaultFont()
|
||||
style.WriteToRenderer(r)
|
||||
|
||||
maxWidth, maxHeight := measureTextMaxWidthHeight([]string{
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat",
|
||||
"Sun",
|
||||
}, r)
|
||||
assert.Equal(26, maxWidth)
|
||||
assert.Equal(12, maxHeight)
|
||||
}
|
||||
|
||||
func TestReverseSlice(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
arr := []string{
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat",
|
||||
"Sun",
|
||||
}
|
||||
reverseStringSlice(arr)
|
||||
assert.Equal([]string{
|
||||
"Sun",
|
||||
"Sat",
|
||||
"Fri",
|
||||
"Thu",
|
||||
"Wed",
|
||||
"Tue",
|
||||
"Mon",
|
||||
}, arr)
|
||||
|
||||
numbers := []int{
|
||||
1,
|
||||
3,
|
||||
5,
|
||||
7,
|
||||
9,
|
||||
}
|
||||
reverseIntSlice(numbers)
|
||||
assert.Equal([]int{
|
||||
9,
|
||||
7,
|
||||
5,
|
||||
3,
|
||||
1,
|
||||
}, numbers)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue