commit
9763d48eef
52 changed files with 8018 additions and 3591 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 223 KiB |
BIN
assets/go-line-chart.png
Normal file
BIN
assets/go-line-chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
570
axis.go
570
axis.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -25,184 +25,444 @@ package charts
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/golang/freetype/truetype"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type AxisOption struct {
|
||||||
// AxisData string
|
// The boundary gap on both sides of a coordinate axis.
|
||||||
XAxis struct {
|
// Nil or *true means the center part of two axis ticks
|
||||||
// data value of axis
|
BoundaryGap *bool
|
||||||
Data []string
|
// The flag for show axis, set this to *false will hide axis
|
||||||
// number of segments
|
Show *bool
|
||||||
|
// The position of axis, it can be 'left', 'top', 'right' or 'bottom'
|
||||||
|
Position string
|
||||||
|
// Number of segments that the axis is split into. Note that this number serves only as a recommendation.
|
||||||
SplitNumber int
|
SplitNumber int
|
||||||
}
|
ClassName string
|
||||||
)
|
// The line color of axis
|
||||||
|
StrokeColor drawing.Color
|
||||||
type YAxisOption struct {
|
// The line width
|
||||||
// formater of axis
|
StrokeWidth float64
|
||||||
Formater chart.ValueFormatter
|
// The length of the axis tick
|
||||||
// disabled axis
|
TickLength int
|
||||||
Disabled bool
|
// The flag for show axis tick, set this to *false will hide axis tick
|
||||||
// min value of axis
|
TickShow *bool
|
||||||
Min *float64
|
// The margin value of label
|
||||||
// max value of axis
|
LabelMargin int
|
||||||
Max *float64
|
// The font size of label
|
||||||
|
FontSize float64
|
||||||
|
// The font of label
|
||||||
|
Font *truetype.Font
|
||||||
|
// The color of label
|
||||||
|
FontColor drawing.Color
|
||||||
|
// The flag for show axis split line, set this to true will show axis split line
|
||||||
|
SplitLineShow bool
|
||||||
|
// The color of split line
|
||||||
|
SplitLineColor drawing.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
const axisStrokeWidth = 1
|
type axis struct {
|
||||||
|
d *Draw
|
||||||
func maxInt(values ...int) int {
|
data *AxisDataList
|
||||||
result := 0
|
option *AxisOption
|
||||||
for _, v := range values {
|
}
|
||||||
if v > result {
|
type axisMeasurement struct {
|
||||||
result = v
|
Width int
|
||||||
}
|
Height int
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetXAxisAndValues returns x axis by theme, and the values of axis.
|
// NewAxis creates a new axis with data and style options
|
||||||
func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme string) (chart.XAxis, []float64) {
|
func NewAxis(d *Draw, data AxisDataList, option AxisOption) *axis {
|
||||||
data := xAxis.Data
|
return &axis{
|
||||||
originalSize := len(data)
|
d: d,
|
||||||
// 如果居中,则需要多添加一个值
|
data: &data,
|
||||||
if tickPosition == chart.TickPositionBetweenTicks {
|
option: &option,
|
||||||
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 defaultFloatFormater(v interface{}) string {
|
// GetLabelMargin returns the label margin value
|
||||||
value, ok := v.(float64)
|
func (as *AxisOption) GetLabelMargin() int {
|
||||||
if !ok {
|
return getDefaultInt(as.LabelMargin, 8)
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// 大于10的则直接取整展示
|
|
||||||
if value >= 10 {
|
|
||||||
return humanize.CommafWithDigits(value, 0)
|
|
||||||
}
|
|
||||||
return humanize.CommafWithDigits(value, 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newYContinuousRange(option *YAxisOption) *YContinuousRange {
|
// GetTickLength returns the tick length value
|
||||||
m := YContinuousRange{}
|
func (as *AxisOption) GetTickLength() int {
|
||||||
m.Min = -math.MaxFloat64
|
return getDefaultInt(as.TickLength, 5)
|
||||||
m.Max = math.MaxFloat64
|
|
||||||
if option != nil {
|
|
||||||
if option.Min != nil {
|
|
||||||
m.Min = *option.Min
|
|
||||||
}
|
|
||||||
if option.Max != nil {
|
|
||||||
m.Max = *option.Max
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &m
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecondaryYAxis returns the secondary y axis by theme
|
// Style returns the style of axis
|
||||||
func GetSecondaryYAxis(theme string, option *YAxisOption) chart.YAxis {
|
func (as *AxisOption) Style(f *truetype.Font) chart.Style {
|
||||||
strokeColor := getGridColor(theme)
|
s := chart.Style{
|
||||||
yAxis := chart.YAxis{
|
ClassName: as.ClassName,
|
||||||
Range: newYContinuousRange(option),
|
StrokeColor: as.StrokeColor,
|
||||||
ValueFormatter: defaultFloatFormater,
|
StrokeWidth: as.StrokeWidth,
|
||||||
AxisType: chart.YAxisSecondary,
|
FontSize: as.FontSize,
|
||||||
GridMajorStyle: chart.Style{
|
FontColor: as.FontColor,
|
||||||
StrokeColor: strokeColor,
|
Font: as.Font,
|
||||||
StrokeWidth: axisStrokeWidth,
|
|
||||||
},
|
|
||||||
GridMinorStyle: chart.Style{
|
|
||||||
StrokeColor: strokeColor,
|
|
||||||
StrokeWidth: axisStrokeWidth,
|
|
||||||
},
|
|
||||||
Style: chart.Style{
|
|
||||||
FontColor: getAxisColor(theme),
|
|
||||||
// alpha 0,隐藏
|
|
||||||
StrokeColor: hiddenColor,
|
|
||||||
StrokeWidth: axisStrokeWidth,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
setYAxisOption(&yAxis, option)
|
if s.FontSize == 0 {
|
||||||
return yAxis
|
s.FontSize = chart.DefaultFontSize
|
||||||
|
}
|
||||||
|
if s.Font == nil {
|
||||||
|
s.Font = f
|
||||||
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func setYAxisOption(yAxis *chart.YAxis, option *YAxisOption) {
|
type AxisData struct {
|
||||||
if option == nil {
|
// The text value of axis
|
||||||
|
Text string
|
||||||
|
}
|
||||||
|
type AxisDataList []AxisData
|
||||||
|
|
||||||
|
// TextList returns the text list of axis data
|
||||||
|
func (l AxisDataList) TextList() []string {
|
||||||
|
textList := make([]string, len(l))
|
||||||
|
for index, item := range l {
|
||||||
|
textList[index] = item.Text
|
||||||
|
}
|
||||||
|
return textList
|
||||||
|
}
|
||||||
|
|
||||||
|
type axisRenderOption struct {
|
||||||
|
textMaxWith int
|
||||||
|
textMaxHeight int
|
||||||
|
boundaryGap bool
|
||||||
|
unitCount int
|
||||||
|
modValue int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAxisDataListFromStringList creates a new axis data list from string list
|
||||||
|
func NewAxisDataListFromStringList(textList []string) AxisDataList {
|
||||||
|
list := make(AxisDataList, len(textList))
|
||||||
|
for index, text := range textList {
|
||||||
|
list[index] = AxisData{
|
||||||
|
Text: text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *axis) axisLabel(renderOpt *axisRenderOption) {
|
||||||
|
option := a.option
|
||||||
|
data := *a.data
|
||||||
|
d := a.d
|
||||||
|
if option.FontColor.IsZero() || len(data) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if option.Formater != nil {
|
r := d.Render
|
||||||
yAxis.ValueFormatter = option.Formater
|
|
||||||
|
s := option.Style(d.Font)
|
||||||
|
s.GetTextOptions().WriteTextOptionsToRenderer(r)
|
||||||
|
|
||||||
|
width := d.Box.Width()
|
||||||
|
height := d.Box.Height()
|
||||||
|
textList := data.TextList()
|
||||||
|
count := len(textList)
|
||||||
|
|
||||||
|
boundaryGap := renderOpt.boundaryGap
|
||||||
|
if !boundaryGap {
|
||||||
|
count--
|
||||||
|
}
|
||||||
|
|
||||||
|
unitCount := renderOpt.unitCount
|
||||||
|
modValue := renderOpt.modValue
|
||||||
|
labelMargin := option.GetLabelMargin()
|
||||||
|
|
||||||
|
// 轴线
|
||||||
|
labelHeight := labelMargin + renderOpt.textMaxHeight
|
||||||
|
labelWidth := labelMargin + renderOpt.textMaxWith
|
||||||
|
|
||||||
|
// 坐标轴文本
|
||||||
|
position := option.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 - renderOpt.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() {
|
||||||
|
if unitCount != 0 && index%unitCount != modValue {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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 (a *axis) axisLine(renderOpt *axisRenderOption) {
|
||||||
func GetYAxis(theme string, option *YAxisOption) chart.YAxis {
|
d := a.d
|
||||||
disabled := false
|
r := d.Render
|
||||||
if option != nil {
|
option := a.option
|
||||||
disabled = option.Disabled
|
s := option.Style(d.Font)
|
||||||
}
|
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||||
hidden := chart.Hidden()
|
|
||||||
|
|
||||||
yAxis := chart.YAxis{
|
x0 := 0
|
||||||
Range: newYContinuousRange(option),
|
y0 := 0
|
||||||
ValueFormatter: defaultFloatFormater,
|
x1 := 0
|
||||||
AxisType: chart.YAxisPrimary,
|
y1 := 0
|
||||||
GridMajorStyle: hidden,
|
width := d.Box.Width()
|
||||||
GridMinorStyle: hidden,
|
height := d.Box.Height()
|
||||||
Style: chart.Style{
|
labelMargin := option.GetLabelMargin()
|
||||||
FontColor: getAxisColor(theme),
|
|
||||||
// alpha 0,隐藏
|
// 轴线
|
||||||
StrokeColor: hiddenColor,
|
labelHeight := labelMargin + renderOpt.textMaxHeight
|
||||||
StrokeWidth: axisStrokeWidth,
|
labelWidth := labelMargin + renderOpt.textMaxWith
|
||||||
},
|
tickLength := option.GetTickLength()
|
||||||
|
switch option.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 {
|
d.moveTo(x0, y0)
|
||||||
yAxis.Range = &HiddenRange{}
|
d.lineTo(x1, y1)
|
||||||
yAxis.Style.Hidden = true
|
r.FillStroke()
|
||||||
}
|
}
|
||||||
setYAxisOption(&yAxis, option)
|
|
||||||
return yAxis
|
func (a *axis) axisTick(renderOpt *axisRenderOption) {
|
||||||
|
d := a.d
|
||||||
|
r := d.Render
|
||||||
|
|
||||||
|
option := a.option
|
||||||
|
s := option.Style(d.Font)
|
||||||
|
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||||
|
|
||||||
|
width := d.Box.Width()
|
||||||
|
height := d.Box.Height()
|
||||||
|
data := *a.data
|
||||||
|
tickCount := len(data)
|
||||||
|
if tickCount == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !renderOpt.boundaryGap {
|
||||||
|
tickCount--
|
||||||
|
}
|
||||||
|
labelMargin := option.GetLabelMargin()
|
||||||
|
tickShow := true
|
||||||
|
if isFalse(option.TickShow) {
|
||||||
|
tickShow = false
|
||||||
|
}
|
||||||
|
unitCount := renderOpt.unitCount
|
||||||
|
|
||||||
|
tickLengthValue := option.GetTickLength()
|
||||||
|
labelHeight := labelMargin + renderOpt.textMaxHeight
|
||||||
|
labelWidth := labelMargin + renderOpt.textMaxWith
|
||||||
|
position := option.Position
|
||||||
|
switch position {
|
||||||
|
case PositionLeft:
|
||||||
|
fallthrough
|
||||||
|
case PositionRight:
|
||||||
|
values := autoDivide(height, tickCount)
|
||||||
|
// 左右仅是x0的位置不一样
|
||||||
|
x0 := width - labelWidth
|
||||||
|
if option.Position == PositionLeft {
|
||||||
|
x0 = labelWidth
|
||||||
|
}
|
||||||
|
if tickShow {
|
||||||
|
for _, v := range values {
|
||||||
|
x := x0
|
||||||
|
y := v
|
||||||
|
d.moveTo(x, y)
|
||||||
|
d.lineTo(x+tickLengthValue, y)
|
||||||
|
r.Stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 辅助线
|
||||||
|
if option.SplitLineShow && !option.SplitLineColor.IsZero() {
|
||||||
|
r.SetStrokeColor(option.SplitLineColor)
|
||||||
|
splitLineWidth := width - labelWidth - tickLengthValue
|
||||||
|
x0 = labelWidth + tickLengthValue
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if tickShow {
|
||||||
|
for index, v := range values {
|
||||||
|
if index%unitCount != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x := v
|
||||||
|
y := y0
|
||||||
|
d.moveTo(x, y-tickLengthValue)
|
||||||
|
d.lineTo(x, y)
|
||||||
|
r.Stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 辅助线
|
||||||
|
if option.SplitLineShow && !option.SplitLineColor.IsZero() {
|
||||||
|
r.SetStrokeColor(option.SplitLineColor)
|
||||||
|
y0 = 0
|
||||||
|
splitLineHeight := height - labelHeight - tickLengthValue
|
||||||
|
if position == PositionTop {
|
||||||
|
y0 = labelHeight
|
||||||
|
splitLineHeight = height - labelHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
for index, v := range values {
|
||||||
|
if index%unitCount != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x := v
|
||||||
|
y := y0
|
||||||
|
|
||||||
|
d.moveTo(x, y)
|
||||||
|
d.lineTo(x, y0+splitLineHeight)
|
||||||
|
r.Stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *axis) measureTextMaxWidthHeight() (int, int) {
|
||||||
|
d := a.d
|
||||||
|
r := d.Render
|
||||||
|
s := a.option.Style(d.Font)
|
||||||
|
data := a.data
|
||||||
|
s.GetStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||||
|
s.GetTextOptions().WriteTextOptionsToRenderer(r)
|
||||||
|
return measureTextMaxWidthHeight(data.TextList(), r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// measure returns the measurement of axis.
|
||||||
|
// Width will be textMaxWidth + labelMargin + tickLength for position left or right.
|
||||||
|
// Height will be textMaxHeight + labelMargin + tickLength for position top or bottom.
|
||||||
|
func (a *axis) measure() axisMeasurement {
|
||||||
|
option := a.option
|
||||||
|
value := option.GetLabelMargin() + option.GetTickLength()
|
||||||
|
textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight()
|
||||||
|
info := axisMeasurement{}
|
||||||
|
if option.Position == PositionLeft ||
|
||||||
|
option.Position == PositionRight {
|
||||||
|
info.Width = textMaxWidth + value
|
||||||
|
} else {
|
||||||
|
info.Height = textMaxHeight + value
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render renders the axis for chart
|
||||||
|
func (a *axis) Render() {
|
||||||
|
option := a.option
|
||||||
|
if isFalse(option.Show) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
textMaxWidth, textMaxHeight := a.measureTextMaxWidthHeight()
|
||||||
|
opt := &axisRenderOption{
|
||||||
|
textMaxWith: textMaxWidth,
|
||||||
|
textMaxHeight: textMaxHeight,
|
||||||
|
boundaryGap: true,
|
||||||
|
}
|
||||||
|
if isFalse(option.BoundaryGap) {
|
||||||
|
opt.boundaryGap = false
|
||||||
|
}
|
||||||
|
|
||||||
|
unitCount := chart.MaxInt(option.SplitNumber, 1)
|
||||||
|
width := a.d.Box.Width()
|
||||||
|
textList := a.data.TextList()
|
||||||
|
count := len(textList)
|
||||||
|
|
||||||
|
position := option.Position
|
||||||
|
switch position {
|
||||||
|
case PositionLeft:
|
||||||
|
fallthrough
|
||||||
|
case PositionRight:
|
||||||
|
default:
|
||||||
|
maxCount := width / (opt.textMaxWith + 10)
|
||||||
|
// 可以显示所有
|
||||||
|
if maxCount >= count {
|
||||||
|
unitCount = 1
|
||||||
|
} else if maxCount < count/unitCount {
|
||||||
|
unitCount = int(math.Ceil(float64(count) / float64(maxCount)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
boundaryGap := opt.boundaryGap
|
||||||
|
modValue := 0
|
||||||
|
if boundaryGap && unitCount > 1 {
|
||||||
|
// 如果是居中,unit count需要设置为奇数
|
||||||
|
if unitCount%2 == 0 {
|
||||||
|
unitCount++
|
||||||
|
}
|
||||||
|
modValue = unitCount / 2
|
||||||
|
}
|
||||||
|
opt.modValue = modValue
|
||||||
|
opt.unitCount = unitCount
|
||||||
|
|
||||||
|
// 坐标轴线
|
||||||
|
a.axisLine(opt)
|
||||||
|
a.axisTick(opt)
|
||||||
|
// 坐标文本
|
||||||
|
a.axisLabel(opt)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
333
axis_test.go
333
axis_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,150 +23,237 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGetXAxisAndValues(t *testing.T) {
|
func TestAxisOption(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
genLabels := func(count int) []string {
|
as := AxisOption{}
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
axisData := NewAxisDataListFromStringList([]string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
})
|
||||||
|
getDefaultOption := func() AxisOption {
|
||||||
|
return AxisOption{
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
Show: TrueFlag(),
|
||||||
|
TickShow: TrueFlag(),
|
||||||
|
SplitLineShow: true,
|
||||||
|
SplitLineColor: drawing.ColorBlack.WithAlpha(60),
|
||||||
|
}
|
||||||
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
xAxis XAxis
|
newOption func() AxisOption
|
||||||
tickPosition chart.TickPosition
|
newData func() AxisDataList
|
||||||
theme string
|
result string
|
||||||
result chart.XAxis
|
|
||||||
values []float64
|
|
||||||
}{
|
}{
|
||||||
|
// 文本按起始位置展示
|
||||||
|
// axis位于bottom
|
||||||
{
|
{
|
||||||
xAxis: XAxis{
|
newOption: func() AxisOption {
|
||||||
Data: genLabels(5),
|
opt := getDefaultOption()
|
||||||
|
opt.BoundaryGap = FalseFlag()
|
||||||
|
return opt
|
||||||
},
|
},
|
||||||
values: genValues(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>",
|
||||||
result: chart.XAxis{
|
|
||||||
Ticks: genTicks(5, false),
|
|
||||||
},
|
},
|
||||||
},
|
// 文本居中展示
|
||||||
// 居中
|
// axis位于bottom
|
||||||
{
|
{
|
||||||
xAxis: XAxis{
|
newOption: func() AxisOption {
|
||||||
Data: genLabels(5),
|
opt := getDefaultOption()
|
||||||
},
|
return opt
|
||||||
tickPosition: chart.TickPositionBetweenTicks,
|
|
||||||
// 居中因此value多一个
|
|
||||||
values: genValues(5, true),
|
|
||||||
result: chart.XAxis{
|
|
||||||
Ticks: genTicks(5, true),
|
|
||||||
},
|
},
|
||||||
|
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{
|
newOption: func() AxisOption {
|
||||||
Data: genLabels(20),
|
opt := getDefaultOption()
|
||||||
|
opt.Position = PositionTop
|
||||||
|
opt.BoundaryGap = FalseFlag()
|
||||||
|
return opt
|
||||||
},
|
},
|
||||||
// 居中因此value多一个
|
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>",
|
||||||
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"}},
|
|
||||||
},
|
},
|
||||||
|
// 文本居中展示
|
||||||
|
// axis位于top
|
||||||
|
{
|
||||||
|
newOption: func() AxisOption {
|
||||||
|
opt := getDefaultOption()
|
||||||
|
opt.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>",
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
xAxis, values := GetXAxisAndValues(tt.xAxis, tt.tickPosition, tt.theme)
|
|
||||||
|
|
||||||
assert.Equal(tt.result.Ticks, xAxis.Ticks)
|
|
||||||
assert.Equal(tt.tickPosition, xAxis.TickPosition)
|
|
||||||
assert.Equal(tt.values, values)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultFloatFormater(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,
|
// axis位于left
|
||||||
}
|
{
|
||||||
yAxis := &chart.YAxis{
|
newOption: func() AxisOption {
|
||||||
Range: newYContinuousRange(opt),
|
opt := getDefaultOption()
|
||||||
}
|
opt.Position = PositionLeft
|
||||||
setYAxisOption(yAxis, opt)
|
opt.BoundaryGap = FalseFlag()
|
||||||
|
return opt
|
||||||
assert.NotEmpty(yAxis.ValueFormatter)
|
},
|
||||||
assert.Equal(max, yAxis.Range.GetMax())
|
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 44 5\nL 395 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 54\nL 395 54\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 103\nL 395 103\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 151\nL 395 151\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 199\nL 395 199\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 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>",
|
||||||
assert.Equal(min, yAxis.Range.GetMin())
|
},
|
||||||
}
|
// 文本居中展示
|
||||||
|
// axis位于left
|
||||||
func TestGetYAxis(t *testing.T) {
|
{
|
||||||
assert := assert.New(t)
|
newOption: func() AxisOption {
|
||||||
|
opt := getDefaultOption()
|
||||||
yAxis := GetYAxis(ThemeDark, nil)
|
opt.Position = PositionLeft
|
||||||
|
return opt
|
||||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
},
|
||||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
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 44 5\nL 395 5\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 47\nL 395 47\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 89\nL 395 89\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 131\nL 395 131\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 172\nL 395 172\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 213\nL 395 213\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 44 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>",
|
||||||
assert.False(yAxis.Style.Hidden)
|
},
|
||||||
|
// 文本按起始位置展示
|
||||||
yAxis = GetYAxis(ThemeDark, &YAxisOption{
|
// axis位于right
|
||||||
Disabled: true,
|
{
|
||||||
|
newOption: func() AxisOption {
|
||||||
|
opt := getDefaultOption()
|
||||||
|
opt.Position = PositionRight
|
||||||
|
opt.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.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>",
|
||||||
|
},
|
||||||
|
// text较多,仅展示部分
|
||||||
|
{
|
||||||
|
newOption: func() AxisOption {
|
||||||
|
opt := getDefaultOption()
|
||||||
|
opt.Position = PositionBottom
|
||||||
|
return opt
|
||||||
|
},
|
||||||
|
newData: func() AxisDataList {
|
||||||
|
return NewAxisDataListFromStringList([]string{
|
||||||
|
"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",
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
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 62 270\nL 62 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 119 270\nL 119 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 176 270\nL 176 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 233 270\nL 233 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 287 270\nL 287 275\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:none\"/><path d=\"M 341 270\nL 341 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 62 5\nL 62 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 119 5\nL 119 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 176 5\nL 176 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 233 5\nL 233 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 287 5\nL 287 270\" style=\"stroke-width:1;stroke:rgba(0,0,0,0.2);fill:none\"/><path d=\"M 341 5\nL 341 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=\"16\" 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\">01-02</text><text x=\"73\" 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\">01-05</text><text x=\"130\" 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\">01-08</text><text x=\"187\" 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\">01-11</text><text x=\"243\" 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\">01-14</text><text x=\"297\" 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\">01-17</text><text x=\"351\" 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\">01-20</text></svg>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
}, PaddingOption(chart.Box{
|
||||||
|
Left: 5,
|
||||||
|
Top: 5,
|
||||||
|
Right: 5,
|
||||||
|
Bottom: 5,
|
||||||
|
}))
|
||||||
|
assert.Nil(err)
|
||||||
|
style := tt.newOption()
|
||||||
|
data := axisData
|
||||||
|
if tt.newData != nil {
|
||||||
|
data = tt.newData()
|
||||||
|
}
|
||||||
|
NewAxis(d, data, style).Render()
|
||||||
|
|
||||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
result, err := d.Bytes()
|
||||||
assert.True(yAxis.GridMajorStyle.Hidden)
|
assert.Nil(err)
|
||||||
assert.True(yAxis.Style.Hidden)
|
assert.Equal(tt.result, string(result))
|
||||||
|
}
|
||||||
// secondary yAxis
|
}
|
||||||
yAxis = GetSecondaryYAxis(ThemeDark, nil)
|
|
||||||
assert.False(yAxis.GridMajorStyle.Hidden)
|
func TestMeasureAxis(t *testing.T) {
|
||||||
assert.False(yAxis.GridMajorStyle.Hidden)
|
assert := assert.New(t)
|
||||||
assert.True(yAxis.Style.StrokeColor.IsTransparent())
|
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
})
|
||||||
|
assert.Nil(err)
|
||||||
|
data := NewAxisDataListFromStringList([]string{
|
||||||
|
"Mon",
|
||||||
|
"Sun",
|
||||||
|
})
|
||||||
|
f, _ := chart.GetDefaultFont()
|
||||||
|
width := NewAxis(d, data, AxisOption{
|
||||||
|
FontSize: 12,
|
||||||
|
Font: f,
|
||||||
|
Position: PositionLeft,
|
||||||
|
}).measure().Width
|
||||||
|
assert.Equal(44, width)
|
||||||
|
|
||||||
|
height := NewAxis(d, data, AxisOption{
|
||||||
|
FontSize: 12,
|
||||||
|
Font: f,
|
||||||
|
Position: PositionTop,
|
||||||
|
}).measure().Height
|
||||||
|
assert.Equal(28, height)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -24,26 +24,35 @@ package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LineSeries struct {
|
type BarStyle struct {
|
||||||
BaseSeries
|
ClassName string
|
||||||
|
StrokeDashArray []float64
|
||||||
|
FillColor drawing.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ls LineSeries) getXRange(xrange chart.Range) chart.Range {
|
func (bs *BarStyle) Style() chart.Style {
|
||||||
if ls.TickPosition != chart.TickPositionBetweenTicks {
|
return chart.Style{
|
||||||
return xrange
|
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) {
|
// Bar renders bar for chart
|
||||||
style := ls.Style.InheritFrom(defaults)
|
func (d *Draw) Bar(b chart.Box, style BarStyle) {
|
||||||
xrange = ls.getXRange(xrange)
|
s := style.Style()
|
||||||
chart.Draw.LineSeries(r, canvasBox, xrange, yrange, style, ls)
|
|
||||||
lr := LabelRenderer{
|
r := d.Render
|
||||||
Options: ls.Label,
|
s.GetFillAndStrokeOptions().WriteToRenderer(r)
|
||||||
}
|
d.moveTo(b.Left, b.Top)
|
||||||
lr.Render(r, canvasBox, xrange, yrange, style, ls)
|
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()
|
||||||
}
|
}
|
||||||
163
bar_chart.go
Normal file
163
bar_chart.go
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type barChartOption struct {
|
||||||
|
// The series list fo bar chart
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The theme
|
||||||
|
Theme string
|
||||||
|
// The font
|
||||||
|
Font *truetype.Font
|
||||||
|
}
|
||||||
|
|
||||||
|
func barChartRender(opt barChartOption, result *basicRenderResult) ([]markPointRenderOption, error) {
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Parent: result.d,
|
||||||
|
}, PaddingOption(chart.Box{
|
||||||
|
Top: result.titleBox.Height(),
|
||||||
|
// TODO 后续考虑是否需要根据左侧是否展示y轴再生成对应的left
|
||||||
|
Left: YAxisWidth,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
xRange := result.xRange
|
||||||
|
x0, x1 := xRange.GetRange(0)
|
||||||
|
width := int(x1 - x0)
|
||||||
|
// 每一块之间的margin
|
||||||
|
margin := 10
|
||||||
|
// 每一个bar之间的margin
|
||||||
|
barMargin := 5
|
||||||
|
if width < 20 {
|
||||||
|
margin = 2
|
||||||
|
barMargin = 2
|
||||||
|
} else if width < 50 {
|
||||||
|
margin = 5
|
||||||
|
barMargin = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesCount := len(opt.SeriesList)
|
||||||
|
// 总的宽度-两个margin-(总数-1)的barMargin
|
||||||
|
barWidth := (width - 2*margin - barMargin*(seriesCount-1)) / len(opt.SeriesList)
|
||||||
|
|
||||||
|
barMaxHeight := result.getYRange(0).Size
|
||||||
|
theme := NewTheme(opt.Theme)
|
||||||
|
|
||||||
|
seriesNames := opt.SeriesList.Names()
|
||||||
|
|
||||||
|
r := d.Render
|
||||||
|
|
||||||
|
markPointRenderOptions := make([]markPointRenderOption, 0)
|
||||||
|
|
||||||
|
for i, s := range opt.SeriesList {
|
||||||
|
// 由于series是for range,为同一个数据,因此需要clone
|
||||||
|
// 后续需要使用,如mark point
|
||||||
|
series := s
|
||||||
|
yRange := result.getYRange(series.YAxisIndex)
|
||||||
|
points := make([]Point, len(series.Data))
|
||||||
|
index := series.index
|
||||||
|
if index == 0 {
|
||||||
|
index = i
|
||||||
|
}
|
||||||
|
seriesColor := theme.GetSeriesColor(index)
|
||||||
|
// mark line
|
||||||
|
markLineRender(markLineRenderOption{
|
||||||
|
Draw: d,
|
||||||
|
FillColor: seriesColor,
|
||||||
|
FontColor: theme.GetTextColor(),
|
||||||
|
StrokeColor: seriesColor,
|
||||||
|
Font: opt.Font,
|
||||||
|
Series: &series,
|
||||||
|
Range: yRange,
|
||||||
|
})
|
||||||
|
divideValues := xRange.AutoDivide()
|
||||||
|
for j, item := range series.Data {
|
||||||
|
if j >= len(divideValues) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x := divideValues[j]
|
||||||
|
x += margin
|
||||||
|
if i != 0 {
|
||||||
|
x += i * (barWidth + barMargin)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := int(yRange.getHeight(item.Value))
|
||||||
|
fillColor := seriesColor
|
||||||
|
if !item.Style.FillColor.IsZero() {
|
||||||
|
fillColor = item.Style.FillColor
|
||||||
|
}
|
||||||
|
top := barMaxHeight - h
|
||||||
|
d.Bar(chart.Box{
|
||||||
|
Top: top,
|
||||||
|
Left: x,
|
||||||
|
Right: x + barWidth,
|
||||||
|
Bottom: barMaxHeight - 1,
|
||||||
|
}, BarStyle{
|
||||||
|
FillColor: fillColor,
|
||||||
|
})
|
||||||
|
// 用于生成marker point
|
||||||
|
points[j] = Point{
|
||||||
|
// 居中的位置
|
||||||
|
X: x + barWidth>>1,
|
||||||
|
Y: top,
|
||||||
|
}
|
||||||
|
// 如果label不需要展示,则返回
|
||||||
|
if !series.Label.Show {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
distance := series.Label.Distance
|
||||||
|
if distance == 0 {
|
||||||
|
distance = 5
|
||||||
|
}
|
||||||
|
text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1)
|
||||||
|
labelStyle := chart.Style{
|
||||||
|
FontColor: theme.GetTextColor(),
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
Font: opt.Font,
|
||||||
|
}
|
||||||
|
if !series.Label.Color.IsZero() {
|
||||||
|
labelStyle.FontColor = series.Label.Color
|
||||||
|
}
|
||||||
|
labelStyle.GetTextOptions().WriteToRenderer(r)
|
||||||
|
textBox := r.MeasureText(text)
|
||||||
|
d.text(text, x+(barWidth-textBox.Width())>>1, barMaxHeight-h-distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成mark point的参数
|
||||||
|
markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{
|
||||||
|
Draw: d,
|
||||||
|
FillColor: seriesColor,
|
||||||
|
Font: opt.Font,
|
||||||
|
Points: points,
|
||||||
|
Series: &series,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return markPointRenderOptions, nil
|
||||||
|
}
|
||||||
131
bar_chart_test.go
Normal file
131
bar_chart_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
// 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 TestBarChartRender(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
width := 400
|
||||||
|
height := 300
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
})
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
result := basicRenderResult{
|
||||||
|
xRange: &Range{
|
||||||
|
Min: 0,
|
||||||
|
Max: 4,
|
||||||
|
divideCount: 4,
|
||||||
|
Size: width,
|
||||||
|
Boundary: true,
|
||||||
|
},
|
||||||
|
yRangeList: []*Range{
|
||||||
|
{
|
||||||
|
divideCount: 6,
|
||||||
|
Max: 100,
|
||||||
|
Min: 0,
|
||||||
|
Size: height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
d: d,
|
||||||
|
}
|
||||||
|
f, _ := chart.GetDefaultFont()
|
||||||
|
|
||||||
|
markPointOptions, err := barChartRender(barChartOption{
|
||||||
|
Font: f,
|
||||||
|
SeriesList: SeriesList{
|
||||||
|
{
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
Color: drawing.ColorBlue,
|
||||||
|
},
|
||||||
|
MarkLine: NewMarkLine(
|
||||||
|
SeriesMarkDataTypeMin,
|
||||||
|
),
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 60,
|
||||||
|
Style: chart.Style{
|
||||||
|
FillColor: drawing.ColorRed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NewSeriesFromValues([]float64{
|
||||||
|
80,
|
||||||
|
30,
|
||||||
|
70,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}, &result)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(2, len(markPointOptions))
|
||||||
|
assert.Equal([]Point{
|
||||||
|
{
|
||||||
|
X: 28,
|
||||||
|
Y: 240,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 128,
|
||||||
|
Y: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 228,
|
||||||
|
Y: 30,
|
||||||
|
},
|
||||||
|
}, markPointOptions[0].Points)
|
||||||
|
assert.Equal([]Point{
|
||||||
|
{
|
||||||
|
X: 70,
|
||||||
|
Y: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 170,
|
||||||
|
Y: 210,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 270,
|
||||||
|
Y: 90,
|
||||||
|
},
|
||||||
|
}, markPointOptions[1].Points)
|
||||||
|
|
||||||
|
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=\"40\" cy=\"240\" r=\"3\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 45 240\nL 382 240\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 382 235\nL 398 240\nL 382 245\nL 387 240\nL 382 235\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"400\" y=\"244\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text><path d=\"M 50 240\nL 87 240\nL 87 299\nL 50 299\nL 50 240\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"61\" y=\"235\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text><path d=\"M 150 120\nL 187 120\nL 187 299\nL 150 299\nL 150 120\" style=\"stroke-width:1;stroke:rgba(255,0,0,1.0);fill:rgba(255,0,0,1.0)\"/><text x=\"161\" y=\"115\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">60</text><path d=\"M 250 30\nL 287 30\nL 287 299\nL 250 299\nL 250 30\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"261\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">90</text><path d=\"M 92 60\nL 129 60\nL 129 299\nL 92 299\nL 92 60\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 192 210\nL 229 210\nL 229 299\nL 192 299\nL 192 210\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 292 90\nL 329 90\nL 329 299\nL 292 299\nL 292 90\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/></svg>", string(data))
|
||||||
|
}
|
||||||
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())
|
|
||||||
}
|
|
||||||
78
bar_test.go
Normal file
78
bar_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// 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 TestBarStyle(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
bs := BarStyle{
|
||||||
|
ClassName: "test",
|
||||||
|
StrokeDashArray: []float64{
|
||||||
|
1.0,
|
||||||
|
},
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(chart.Style{
|
||||||
|
ClassName: "test",
|
||||||
|
StrokeDashArray: []float64{
|
||||||
|
1.0,
|
||||||
|
},
|
||||||
|
StrokeWidth: 1,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
}, bs.Style())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrawBar(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
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())
|
|
||||||
}
|
|
||||||
450
chart.go
Normal file
450
chart.go
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
// 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 (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ChartTypeLine = "line"
|
||||||
|
ChartTypeBar = "bar"
|
||||||
|
ChartTypePie = "pie"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ChartOutputSVG = "svg"
|
||||||
|
ChartOutputPNG = "png"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Point struct {
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelFontSize = 10
|
||||||
|
|
||||||
|
var defaultChartWidth = 600
|
||||||
|
var defaultChartHeight = 400
|
||||||
|
|
||||||
|
type ChartOption struct {
|
||||||
|
// The output type of chart, "svg" or "png", default value is "svg"
|
||||||
|
Type string
|
||||||
|
// The font family, which should be installed first
|
||||||
|
FontFamily string
|
||||||
|
// The font of chart, the default font is "roboto"
|
||||||
|
Font *truetype.Font
|
||||||
|
// The theme of chart, "light" and "dark".
|
||||||
|
// The default theme is "light"
|
||||||
|
Theme string
|
||||||
|
// The title option
|
||||||
|
Title TitleOption
|
||||||
|
// The legend option
|
||||||
|
Legend LegendOption
|
||||||
|
// The x axis option
|
||||||
|
XAxis XAxisOption
|
||||||
|
// The y axis option list
|
||||||
|
YAxisList []YAxisOption
|
||||||
|
// The width of chart, default width is 600
|
||||||
|
Width int
|
||||||
|
// The height of chart, default height is 400
|
||||||
|
Height int
|
||||||
|
Parent *Draw
|
||||||
|
// The padding for chart, default padding is [20, 10, 10, 10]
|
||||||
|
Padding chart.Box
|
||||||
|
// The canvas box for chart
|
||||||
|
Box chart.Box
|
||||||
|
// The series list
|
||||||
|
SeriesList SeriesList
|
||||||
|
// The background color of chart
|
||||||
|
BackgroundColor drawing.Color
|
||||||
|
// The child charts
|
||||||
|
Children []ChartOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// FillDefault fills the default value for chart option
|
||||||
|
func (o *ChartOption) FillDefault(theme string) {
|
||||||
|
t := NewTheme(theme)
|
||||||
|
// 如果为空,初始化
|
||||||
|
yAxisCount := 1
|
||||||
|
for _, series := range o.SeriesList {
|
||||||
|
if series.YAxisIndex >= yAxisCount {
|
||||||
|
yAxisCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yAxisList := make([]YAxisOption, yAxisCount)
|
||||||
|
copy(yAxisList, o.YAxisList)
|
||||||
|
o.YAxisList = yAxisList
|
||||||
|
|
||||||
|
if o.Font == nil {
|
||||||
|
o.Font, _ = chart.GetDefaultFont()
|
||||||
|
}
|
||||||
|
if o.BackgroundColor.IsZero() {
|
||||||
|
o.BackgroundColor = t.GetBackgroundColor()
|
||||||
|
}
|
||||||
|
if o.Padding.IsZero() {
|
||||||
|
o.Padding = chart.Box{
|
||||||
|
Top: 10,
|
||||||
|
Right: 10,
|
||||||
|
Bottom: 10,
|
||||||
|
Left: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题的默认值
|
||||||
|
if o.Title.Style.FontColor.IsZero() {
|
||||||
|
o.Title.Style.FontColor = t.GetTextColor()
|
||||||
|
}
|
||||||
|
if o.Title.Style.FontSize == 0 {
|
||||||
|
o.Title.Style.FontSize = 14
|
||||||
|
}
|
||||||
|
if o.Title.Style.Font == nil {
|
||||||
|
o.Title.Style.Font = o.Font
|
||||||
|
}
|
||||||
|
if o.Title.Style.Padding.IsZero() {
|
||||||
|
o.Title.Style.Padding = chart.Box{
|
||||||
|
Bottom: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 副标题
|
||||||
|
if o.Title.SubtextStyle.FontColor.IsZero() {
|
||||||
|
o.Title.SubtextStyle.FontColor = o.Title.Style.FontColor.WithAlpha(180)
|
||||||
|
}
|
||||||
|
if o.Title.SubtextStyle.FontSize == 0 {
|
||||||
|
o.Title.SubtextStyle.FontSize = labelFontSize
|
||||||
|
}
|
||||||
|
if o.Title.SubtextStyle.Font == nil {
|
||||||
|
o.Title.SubtextStyle.Font = o.Font
|
||||||
|
}
|
||||||
|
|
||||||
|
o.Legend.theme = theme
|
||||||
|
if o.Legend.Style.FontSize == 0 {
|
||||||
|
o.Legend.Style.FontSize = labelFontSize
|
||||||
|
}
|
||||||
|
if o.Legend.Left == "" {
|
||||||
|
o.Legend.Left = PositionCenter
|
||||||
|
}
|
||||||
|
// legend与series name的关联
|
||||||
|
if len(o.Legend.Data) == 0 {
|
||||||
|
o.Legend.Data = o.SeriesList.Names()
|
||||||
|
} else {
|
||||||
|
seriesCount := len(o.SeriesList)
|
||||||
|
for index, name := range o.Legend.Data {
|
||||||
|
if index < seriesCount {
|
||||||
|
o.SeriesList[index].Name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果无legend数据,则隐藏
|
||||||
|
if len(strings.Join(o.Legend.Data, "")) == 0 {
|
||||||
|
o.Legend.Show = FalseFlag()
|
||||||
|
}
|
||||||
|
if o.Legend.Style.Font == nil {
|
||||||
|
o.Legend.Style.Font = o.Font
|
||||||
|
}
|
||||||
|
if o.Legend.Style.FontColor.IsZero() {
|
||||||
|
o.Legend.Style.FontColor = t.GetTextColor()
|
||||||
|
}
|
||||||
|
if o.XAxis.Theme == "" {
|
||||||
|
o.XAxis.Theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *ChartOption) getWidth() int {
|
||||||
|
if o.Width != 0 {
|
||||||
|
return o.Width
|
||||||
|
}
|
||||||
|
if o.Parent != nil {
|
||||||
|
return o.Parent.Box.Width()
|
||||||
|
}
|
||||||
|
return defaultChartWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetDefaultWidth(width int) {
|
||||||
|
if width > 0 {
|
||||||
|
defaultChartWidth = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func SetDefaultHeight(height int) {
|
||||||
|
if height > 0 {
|
||||||
|
defaultChartHeight = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *ChartOption) getHeight() int {
|
||||||
|
|
||||||
|
if o.Height != 0 {
|
||||||
|
return o.Height
|
||||||
|
}
|
||||||
|
if o.Parent != nil {
|
||||||
|
return o.Parent.Box.Height()
|
||||||
|
}
|
||||||
|
return defaultChartHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *ChartOption) newYRange(axisIndex int) Range {
|
||||||
|
min := math.MaxFloat64
|
||||||
|
max := -math.MaxFloat64
|
||||||
|
if axisIndex >= len(o.YAxisList) {
|
||||||
|
axisIndex = 0
|
||||||
|
}
|
||||||
|
yAxis := o.YAxisList[axisIndex]
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
min = min * 0.9
|
||||||
|
max = max * 1.1
|
||||||
|
if yAxis.Min != nil {
|
||||||
|
min = *yAxis.Min
|
||||||
|
}
|
||||||
|
if yAxis.Max != nil {
|
||||||
|
max = *yAxis.Max
|
||||||
|
}
|
||||||
|
divideCount := 6
|
||||||
|
// y轴分设置默认划分为6块
|
||||||
|
r := NewRange(min, max, divideCount)
|
||||||
|
|
||||||
|
// 由于NewRange会重新计算min max
|
||||||
|
if yAxis.Min != nil {
|
||||||
|
r.Min = min
|
||||||
|
}
|
||||||
|
if yAxis.Max != nil {
|
||||||
|
r.Max = max
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
type basicRenderResult struct {
|
||||||
|
xRange *Range
|
||||||
|
yRangeList []*Range
|
||||||
|
d *Draw
|
||||||
|
titleBox chart.Box
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *basicRenderResult) getYRange(index int) *Range {
|
||||||
|
if index >= len(r.yRangeList) {
|
||||||
|
index = 0
|
||||||
|
}
|
||||||
|
return r.yRangeList[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render renders the chart by option
|
||||||
|
func Render(opt ChartOption) (*Draw, error) {
|
||||||
|
if len(opt.SeriesList) == 0 {
|
||||||
|
return nil, errors.New("series can not be nil")
|
||||||
|
}
|
||||||
|
if len(opt.FontFamily) != 0 {
|
||||||
|
f, err := GetFont(opt.FontFamily)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opt.Font = f
|
||||||
|
}
|
||||||
|
opt.FillDefault(opt.Theme)
|
||||||
|
|
||||||
|
lineSeries := make([]Series, 0)
|
||||||
|
barSeries := make([]Series, 0)
|
||||||
|
isPieChart := false
|
||||||
|
for index, item := range opt.SeriesList {
|
||||||
|
item.index = index
|
||||||
|
switch item.Type {
|
||||||
|
case ChartTypePie:
|
||||||
|
isPieChart = true
|
||||||
|
case ChartTypeBar:
|
||||||
|
barSeries = append(barSeries, item)
|
||||||
|
default:
|
||||||
|
lineSeries = append(lineSeries, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果指定了pie,则以pie的形式处理,pie不支持多类型图表
|
||||||
|
// pie不需要axis
|
||||||
|
if isPieChart {
|
||||||
|
opt.XAxis.Hidden = true
|
||||||
|
for index := range opt.YAxisList {
|
||||||
|
opt.YAxisList[index].Hidden = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := chartBasicRender(&opt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
markPointRenderOptions := make([]markPointRenderOption, 0)
|
||||||
|
fns := []func() error{
|
||||||
|
// pie render
|
||||||
|
func() error {
|
||||||
|
if !isPieChart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := pieChartRender(pieChartOption{
|
||||||
|
SeriesList: opt.SeriesList,
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Font: opt.Font,
|
||||||
|
}, result)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
// bar render
|
||||||
|
func() error {
|
||||||
|
// 如果是pie或者无bar类型的series
|
||||||
|
if isPieChart || len(barSeries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
options, err := barChartRender(barChartOption{
|
||||||
|
SeriesList: barSeries,
|
||||||
|
Theme: opt.Theme,
|
||||||
|
Font: opt.Font,
|
||||||
|
}, result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
markPointRenderOptions = append(markPointRenderOptions, options...)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// line render
|
||||||
|
func() error {
|
||||||
|
// 如果是pie或者无line类型的series
|
||||||
|
if isPieChart || len(lineSeries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
options, err := lineChartRender(lineChartOption{
|
||||||
|
Theme: opt.Theme,
|
||||||
|
SeriesList: lineSeries,
|
||||||
|
Font: opt.Font,
|
||||||
|
}, result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
markPointRenderOptions = append(markPointRenderOptions, options...)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// legend需要在顶层,因此此处render
|
||||||
|
func() error {
|
||||||
|
_, err := NewLegend(result.d, opt.Legend).Render()
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
// mark point最后render
|
||||||
|
func() error {
|
||||||
|
// mark point render不会出错
|
||||||
|
for _, opt := range markPointRenderOptions {
|
||||||
|
markPointRender(opt)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fn := range fns {
|
||||||
|
err = fn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, child := range opt.Children {
|
||||||
|
child.Parent = result.d
|
||||||
|
if len(child.Theme) == 0 {
|
||||||
|
child.Theme = opt.Theme
|
||||||
|
}
|
||||||
|
_, err = Render(child)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func chartBasicRender(opt *ChartOption) (*basicRenderResult, error) {
|
||||||
|
d, err := NewDraw(
|
||||||
|
DrawOption{
|
||||||
|
Type: opt.Type,
|
||||||
|
Parent: opt.Parent,
|
||||||
|
Width: opt.getWidth(),
|
||||||
|
Height: opt.getHeight(),
|
||||||
|
},
|
||||||
|
PaddingOption(opt.Padding),
|
||||||
|
BoxOption(opt.Box),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(opt.YAxisList) > 2 {
|
||||||
|
return nil, errors.New("y axis should not be gt 2")
|
||||||
|
}
|
||||||
|
if opt.Parent == nil {
|
||||||
|
d.setBackground(opt.getWidth(), opt.getHeight(), opt.BackgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
titleBox, err := drawTitle(d, &opt.Title)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
xAxisHeight := 0
|
||||||
|
var xRange *Range
|
||||||
|
|
||||||
|
if !opt.XAxis.Hidden {
|
||||||
|
// xAxis
|
||||||
|
xAxisHeight, xRange, err = drawXAxis(d, &opt.XAxis, len(opt.YAxisList))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
yRangeList := make([]*Range, len(opt.YAxisList))
|
||||||
|
|
||||||
|
for index, yAxis := range opt.YAxisList {
|
||||||
|
var yRange *Range
|
||||||
|
if !yAxis.Hidden {
|
||||||
|
yRange, err = drawYAxis(d, opt, index, xAxisHeight, chart.Box{
|
||||||
|
Top: titleBox.Height(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
yRangeList[index] = yRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &basicRenderResult{
|
||||||
|
xRange: xRange,
|
||||||
|
yRangeList: yRangeList,
|
||||||
|
d: d,
|
||||||
|
titleBox: titleBox,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
504
chart_test.go
Normal file
504
chart_test.go
Normal file
File diff suppressed because one or more lines are too long
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))
|
|
||||||
}
|
|
||||||
311
draw.go
Normal file
311
draw.go
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
// 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 (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
PositionLeft = "left"
|
||||||
|
PositionRight = "right"
|
||||||
|
PositionCenter = "center"
|
||||||
|
PositionTop = "top"
|
||||||
|
PositionBottom = "bottom"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
OrientHorizontal = "horizontal"
|
||||||
|
OrientVertical = "vertical"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Draw struct {
|
||||||
|
// Render
|
||||||
|
Render chart.Renderer
|
||||||
|
// The canvas box
|
||||||
|
Box chart.Box
|
||||||
|
// The font for draw
|
||||||
|
Font *truetype.Font
|
||||||
|
// The parent of draw
|
||||||
|
parent *Draw
|
||||||
|
}
|
||||||
|
|
||||||
|
type DrawOption struct {
|
||||||
|
// Draw type, "svg" or "png", default type is "svg"
|
||||||
|
Type string
|
||||||
|
// Parent of draw
|
||||||
|
Parent *Draw
|
||||||
|
// The width of draw canvas
|
||||||
|
Width int
|
||||||
|
// The height of draw canvas
|
||||||
|
Height int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Option func(*Draw) error
|
||||||
|
|
||||||
|
// PaddingOption sets the padding of draw canvas
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoxOption set the box of draw canvas
|
||||||
|
func BoxOption(box chart.Box) Option {
|
||||||
|
return func(d *Draw) error {
|
||||||
|
if box.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d.Box = box
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDraw returns a new draw canvas
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
font, _ := chart.GetDefaultFont()
|
||||||
|
d := &Draw{
|
||||||
|
Font: font,
|
||||||
|
}
|
||||||
|
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 == ChartOutputPNG {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent returns the parent of draw
|
||||||
|
func (d *Draw) Parent() *Draw {
|
||||||
|
return d.parent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top returns the top parent of draw
|
||||||
|
func (d *Draw) Top() *Draw {
|
||||||
|
if d.parent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t := d.parent
|
||||||
|
// 限制最多查询次数,避免嵌套引用
|
||||||
|
for i := 50; i > 0; i-- {
|
||||||
|
if t.parent == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t = t.parent
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bytes returns the data of draw canvas
|
||||||
|
func (d *Draw) Bytes() ([]byte, error) {
|
||||||
|
buffer := bytes.Buffer{}
|
||||||
|
err := d.Render.Save(&buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buffer.Bytes(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) moveTo(x, y int) {
|
||||||
|
d.Render.MoveTo(x+d.Box.Left, y+d.Box.Top)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) arcTo(cx, cy int, rx, ry, startAngle, delta float64) {
|
||||||
|
d.Render.ArcTo(cx+d.Box.Left, cy+d.Box.Top, rx, ry, startAngle, delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) lineTo(x, y int) {
|
||||||
|
d.Render.LineTo(x+d.Box.Left, y+d.Box.Top)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) pin(x, y, width int) {
|
||||||
|
r := float64(width) / 2
|
||||||
|
y -= width / 4
|
||||||
|
angle := chart.DegreesToRadians(15)
|
||||||
|
|
||||||
|
startAngle := math.Pi/2 + angle
|
||||||
|
delta := 2*math.Pi - 2*angle
|
||||||
|
d.arcTo(x, y, r, r, startAngle, delta)
|
||||||
|
d.lineTo(x, y)
|
||||||
|
d.Render.Close()
|
||||||
|
d.Render.FillStroke()
|
||||||
|
|
||||||
|
startX := x - int(r)
|
||||||
|
startY := y
|
||||||
|
endX := x + int(r)
|
||||||
|
endY := y
|
||||||
|
d.moveTo(startX, startY)
|
||||||
|
|
||||||
|
left := d.Box.Left
|
||||||
|
top := d.Box.Top
|
||||||
|
cx := x
|
||||||
|
cy := y + int(r*2.5)
|
||||||
|
d.Render.QuadCurveTo(cx+left, cy+top, endX+left, endY+top)
|
||||||
|
d.Render.Close()
|
||||||
|
d.Render.Fill()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) arrowLeft(x, y, width, height int) {
|
||||||
|
d.arrow(x, y, width, height, PositionLeft)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) arrowRight(x, y, width, height int) {
|
||||||
|
d.arrow(x, y, width, height, PositionRight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) arrowTop(x, y, width, height int) {
|
||||||
|
d.arrow(x, y, width, height, PositionTop)
|
||||||
|
}
|
||||||
|
func (d *Draw) arrowBottom(x, y, width, height int) {
|
||||||
|
d.arrow(x, y, width, height, PositionBottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) arrow(x, y, width, height int, direction string) {
|
||||||
|
halfWidth := width >> 1
|
||||||
|
halfHeight := height >> 1
|
||||||
|
if direction == PositionTop || direction == PositionBottom {
|
||||||
|
x0 := x - halfWidth
|
||||||
|
x1 := x0 + width
|
||||||
|
dy := -height / 3
|
||||||
|
y0 := y
|
||||||
|
y1 := y0 - height
|
||||||
|
if direction == PositionBottom {
|
||||||
|
y0 = y - height
|
||||||
|
y1 = y
|
||||||
|
dy = 2 * dy
|
||||||
|
}
|
||||||
|
d.moveTo(x0, y0)
|
||||||
|
d.lineTo(x0+halfWidth, y1)
|
||||||
|
d.lineTo(x1, y0)
|
||||||
|
d.lineTo(x0+halfWidth, y+dy)
|
||||||
|
d.lineTo(x0, y0)
|
||||||
|
} else {
|
||||||
|
x0 := x + width
|
||||||
|
x1 := x0 - width
|
||||||
|
y0 := y - halfHeight
|
||||||
|
dx := -width / 3
|
||||||
|
if direction == PositionRight {
|
||||||
|
x0 = x - width
|
||||||
|
dx = -dx
|
||||||
|
x1 = x0 + width
|
||||||
|
}
|
||||||
|
d.moveTo(x0, y0)
|
||||||
|
d.lineTo(x1, y0+halfHeight)
|
||||||
|
d.lineTo(x0, y0+height)
|
||||||
|
d.lineTo(x0+dx, y0+halfHeight)
|
||||||
|
d.lineTo(x0, y0)
|
||||||
|
}
|
||||||
|
d.Render.FillStroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) makeLine(x, y, width int) {
|
||||||
|
arrowWidth := 16
|
||||||
|
arrowHeight := 10
|
||||||
|
endX := x + width
|
||||||
|
d.circle(3, x, y)
|
||||||
|
d.Render.Fill()
|
||||||
|
d.moveTo(x+5, y)
|
||||||
|
d.lineTo(endX-arrowWidth, y)
|
||||||
|
d.Render.Stroke()
|
||||||
|
d.Render.SetStrokeDashArray([]float64{})
|
||||||
|
d.arrowRight(endX, y, arrowWidth, arrowHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
d.Render.Text(body, x+d.Box.Left, y+d.Box.Top)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) lineStroke(points []Point, style LineStyle) {
|
||||||
|
s := style.Style()
|
||||||
|
if !s.ShouldDrawStroke() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := d.Render
|
||||||
|
s.GetStrokeOptions().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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Draw) setBackground(width, height int, color drawing.Color) {
|
||||||
|
r := d.Render
|
||||||
|
s := chart.Style{
|
||||||
|
FillColor: color,
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
427
draw_test.go
Normal file
427
draw_test.go
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"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 TestBoxOption(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
})
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
err = BoxOption(chart.Box{
|
||||||
|
Left: 10,
|
||||||
|
Top: 20,
|
||||||
|
Right: 50,
|
||||||
|
Bottom: 100,
|
||||||
|
})(d)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(chart.Box{
|
||||||
|
Left: 10,
|
||||||
|
Top: 20,
|
||||||
|
Right: 50,
|
||||||
|
Bottom: 100,
|
||||||
|
}, d.Box)
|
||||||
|
|
||||||
|
// zero box will be ignored
|
||||||
|
err = BoxOption(chart.Box{})(d)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(chart.Box{
|
||||||
|
Left: 10,
|
||||||
|
Top: 20,
|
||||||
|
Right: 50,
|
||||||
|
Bottom: 100,
|
||||||
|
}, 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>",
|
||||||
|
},
|
||||||
|
// arcTo
|
||||||
|
{
|
||||||
|
fn: func(d *Draw) {
|
||||||
|
chart.Style{
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
FillColor: drawing.ColorBlue,
|
||||||
|
}.WriteToRenderer(d.Render)
|
||||||
|
d.arcTo(100, 100, 100, 100, 0, math.Pi/2)
|
||||||
|
d.Render.Close()
|
||||||
|
d.Render.FillStroke()
|
||||||
|
},
|
||||||
|
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 205 110\nA 100 100 90.00 0 1 105 210\nZ\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,255,1.0)\"/></svg>",
|
||||||
|
},
|
||||||
|
// pin
|
||||||
|
{
|
||||||
|
fn: func(d *Draw) {
|
||||||
|
chart.Style{
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FillColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
}.WriteToRenderer(d.Render)
|
||||||
|
d.pin(30, 30, 30)
|
||||||
|
},
|
||||||
|
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 32 47\nA 15 15 330.00 1 1 38 47\nL 35 33\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 20 33\nQ35,70 50,33\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
|
||||||
|
},
|
||||||
|
// arrow left
|
||||||
|
{
|
||||||
|
fn: func(d *Draw) {
|
||||||
|
chart.Style{
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FillColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
}.WriteToRenderer(d.Render)
|
||||||
|
d.arrowLeft(30, 30, 16, 10)
|
||||||
|
},
|
||||||
|
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 51 35\nL 35 40\nL 51 45\nL 46 40\nL 51 35\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
|
||||||
|
},
|
||||||
|
// arrow right
|
||||||
|
{
|
||||||
|
fn: func(d *Draw) {
|
||||||
|
chart.Style{
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FillColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
}.WriteToRenderer(d.Render)
|
||||||
|
d.arrowRight(30, 30, 16, 10)
|
||||||
|
},
|
||||||
|
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 19 35\nL 35 40\nL 19 45\nL 24 40\nL 19 35\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
|
||||||
|
},
|
||||||
|
// arrow top
|
||||||
|
{
|
||||||
|
fn: func(d *Draw) {
|
||||||
|
chart.Style{
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FillColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
}.WriteToRenderer(d.Render)
|
||||||
|
d.arrowTop(30, 30, 10, 16)
|
||||||
|
},
|
||||||
|
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 30 40\nL 35 24\nL 40 40\nL 35 35\nL 30 40\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
|
||||||
|
},
|
||||||
|
// arrow bottom
|
||||||
|
{
|
||||||
|
fn: func(d *Draw) {
|
||||||
|
chart.Style{
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FillColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
}.WriteToRenderer(d.Render)
|
||||||
|
d.arrowBottom(30, 30, 10, 16)
|
||||||
|
},
|
||||||
|
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 30 24\nL 35 40\nL 40 24\nL 35 30\nL 30 24\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/></svg>",
|
||||||
|
},
|
||||||
|
// mark line
|
||||||
|
{
|
||||||
|
fn: func(d *Draw) {
|
||||||
|
chart.Style{
|
||||||
|
StrokeWidth: 1,
|
||||||
|
StrokeColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FillColor: drawing.Color{
|
||||||
|
R: 84,
|
||||||
|
G: 112,
|
||||||
|
B: 198,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
StrokeDashArray: []float64{
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
},
|
||||||
|
}.WriteToRenderer(d.Render)
|
||||||
|
d.makeLine(0, 20, 300)
|
||||||
|
},
|
||||||
|
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<circle cx=\"5\" cy=\"30\" r=\"3\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 10 30\nL 289 30\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 289 25\nL 305 30\nL 289 35\nL 294 30\nL 289 25\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,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))
|
||||||
|
}
|
||||||
|
}
|
||||||
510
echarts.go
510
echarts.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -28,21 +28,10 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"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 {
|
func convertToArray(data []byte) []byte {
|
||||||
data = bytes.TrimSpace(data)
|
data = bytes.TrimSpace(data)
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
|
|
@ -54,12 +43,47 @@ func convertToArray(data []byte) []byte {
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
|
type EChartsPosition string
|
||||||
data = bytes.TrimSpace(data)
|
|
||||||
|
func (p *EChartsPosition) UnmarshalJSON(data []byte) error {
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if regexp.MustCompile(`^\d+`).Match(data) {
|
if regexp.MustCompile(`^\d+`).Match(data) {
|
||||||
|
data = []byte(fmt.Sprintf(`"%s"`, string(data)))
|
||||||
|
}
|
||||||
|
s := (*string)(p)
|
||||||
|
return json.Unmarshal(data, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartStyle struct {
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (es *EChartStyle) ToStyle() chart.Style {
|
||||||
|
color := parseColor(es.Color)
|
||||||
|
return chart.Style{
|
||||||
|
FillColor: color,
|
||||||
|
FontColor: color,
|
||||||
|
StrokeColor: color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsSeriesData struct {
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ItemStyle EChartStyle `json:"itemStyle"`
|
||||||
|
}
|
||||||
|
type _EChartsSeriesData EChartsSeriesData
|
||||||
|
|
||||||
|
var numericRep = regexp.MustCompile("^[-+]?[0-9]+(?:\\.[0-9]+)?$")
|
||||||
|
|
||||||
|
func (es *EChartsSeriesData) UnmarshalJSON(data []byte) error {
|
||||||
|
data = bytes.TrimSpace(data)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if numericRep.Match(data) {
|
||||||
v, err := strconv.ParseFloat(string(data), 64)
|
v, err := strconv.ParseFloat(string(data), 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -67,7 +91,7 @@ func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
|
||||||
es.Value = v
|
es.Value = v
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
v := _ECharsSeriesData{}
|
v := _EChartsSeriesData{}
|
||||||
err := json.Unmarshal(data, &v)
|
err := json.Unmarshal(data, &v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -78,24 +102,53 @@ func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type EChartsPadding struct {
|
type EChartsXAxisData struct {
|
||||||
box chart.Box
|
BoundaryGap *bool `json:"boundaryGap"`
|
||||||
|
SplitNumber int `json:"splitNumber"`
|
||||||
|
Data []string `json:"data"`
|
||||||
|
}
|
||||||
|
type EChartsXAxis struct {
|
||||||
|
Data []EChartsXAxisData
|
||||||
}
|
}
|
||||||
|
|
||||||
type Position string
|
func (ex *EChartsXAxis) UnmarshalJSON(data []byte) error {
|
||||||
|
data = convertToArray(data)
|
||||||
func (lp *Position) UnmarshalJSON(data []byte) error {
|
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if regexp.MustCompile(`^\d+`).Match(data) {
|
return json.Unmarshal(data, &ex.Data)
|
||||||
data = []byte(fmt.Sprintf(`"%s"`, string(data)))
|
|
||||||
}
|
|
||||||
s := (*string)(lp)
|
|
||||||
return json.Unmarshal(data, s)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
type EChartsAxisLabel struct {
|
||||||
|
Formatter string `json:"formatter"`
|
||||||
|
}
|
||||||
|
type EChartsYAxisData struct {
|
||||||
|
Min *float64 `json:"min"`
|
||||||
|
Max *float64 `json:"max"`
|
||||||
|
AxisLabel EChartsAxisLabel `json:"axisLabel"`
|
||||||
|
AxisLine struct {
|
||||||
|
LineStyle struct {
|
||||||
|
Color string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type EChartsYAxis struct {
|
||||||
|
Data []EChartsYAxisData `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 EChartsPadding struct {
|
||||||
|
Box chart.Box
|
||||||
|
}
|
||||||
|
|
||||||
|
func (eb *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||||
data = convertToArray(data)
|
data = convertToArray(data)
|
||||||
if len(data) == 0 {
|
if len(data) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -110,14 +163,14 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||||
}
|
}
|
||||||
switch len(arr) {
|
switch len(arr) {
|
||||||
case 1:
|
case 1:
|
||||||
ep.box = chart.Box{
|
eb.Box = chart.Box{
|
||||||
Left: arr[0],
|
Left: arr[0],
|
||||||
Top: arr[0],
|
Top: arr[0],
|
||||||
Bottom: arr[0],
|
Bottom: arr[0],
|
||||||
Right: arr[0],
|
Right: arr[0],
|
||||||
}
|
}
|
||||||
case 2:
|
case 2:
|
||||||
ep.box = chart.Box{
|
eb.Box = chart.Box{
|
||||||
Top: arr[0],
|
Top: arr[0],
|
||||||
Bottom: arr[0],
|
Bottom: arr[0],
|
||||||
Left: arr[1],
|
Left: arr[1],
|
||||||
|
|
@ -130,7 +183,7 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||||
result[3] = result[1]
|
result[3] = result[1]
|
||||||
}
|
}
|
||||||
// 上右下左
|
// 上右下左
|
||||||
ep.box = chart.Box{
|
eb.Box = chart.Box{
|
||||||
Top: result[0],
|
Top: result[0],
|
||||||
Right: result[1],
|
Right: result[1],
|
||||||
Bottom: result[2],
|
Bottom: result[2],
|
||||||
|
|
@ -140,264 +193,247 @@ func (ep *EChartsPadding) UnmarshalJSON(data []byte) error {
|
||||||
return nil
|
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 {
|
type EChartsLabelOption struct {
|
||||||
Show bool `json:"show"`
|
Show bool `json:"show"`
|
||||||
Distance int `json:"distance"`
|
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"`
|
Color string `json:"color"`
|
||||||
FontFamily string `json:"fontFamily"`
|
}
|
||||||
FontSize float64 `json:"fontSize"`
|
type EChartsLegend struct {
|
||||||
Height float64 `json:"height"`
|
Show *bool `json:"show"`
|
||||||
} `json:"textStyle"`
|
|
||||||
} `json:"title"`
|
|
||||||
XAxis EChartsXAxis `json:"xAxis"`
|
|
||||||
YAxis EChartsYAxis `json:"yAxis"`
|
|
||||||
Legend struct {
|
|
||||||
Data []string `json:"data"`
|
Data []string `json:"data"`
|
||||||
Align string `json:"align"`
|
Align string `json:"align"`
|
||||||
|
Orient string `json:"orient"`
|
||||||
Padding EChartsPadding `json:"padding"`
|
Padding EChartsPadding `json:"padding"`
|
||||||
Left Position `json:"left"`
|
Left EChartsPosition `json:"left"`
|
||||||
Right Position `json:"right"`
|
Top EChartsPosition `json:"top"`
|
||||||
// Top string `json:"top"`
|
TextStyle EChartsTextStyle `json:"textStyle"`
|
||||||
// Bottom string `json:"bottom"`
|
}
|
||||||
} `json:"legend"`
|
|
||||||
Series []struct {
|
type EChartsMarkPoint struct {
|
||||||
Data []ECharsSeriesData `json:"data"`
|
SymbolSize int `json:"symbolSize"`
|
||||||
|
Data []struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (emp *EChartsMarkPoint) ToSeriesMarkPoint() SeriesMarkPoint {
|
||||||
|
sp := SeriesMarkPoint{
|
||||||
|
SymbolSize: emp.SymbolSize,
|
||||||
|
}
|
||||||
|
if len(emp.Data) == 0 {
|
||||||
|
return sp
|
||||||
|
}
|
||||||
|
data := make([]SeriesMarkData, len(emp.Data))
|
||||||
|
for index, item := range emp.Data {
|
||||||
|
data[index] = SeriesMarkData{
|
||||||
|
Type: item.Type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sp.Data = data
|
||||||
|
return sp
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsMarkLine struct {
|
||||||
|
Data []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (eml *EChartsMarkLine) ToSeriesMarkLine() SeriesMarkLine {
|
||||||
|
sl := SeriesMarkLine{}
|
||||||
|
if len(eml.Data) == 0 {
|
||||||
|
return sl
|
||||||
|
}
|
||||||
|
data := make([]SeriesMarkData, len(eml.Data))
|
||||||
|
for index, item := range eml.Data {
|
||||||
|
data[index] = SeriesMarkData{
|
||||||
|
Type: item.Type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sl.Data = data
|
||||||
|
return sl
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsSeries struct {
|
||||||
|
Data []EChartsSeriesData `json:"data"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Radius string `json:"radius"`
|
||||||
YAxisIndex int `json:"yAxisIndex"`
|
YAxisIndex int `json:"yAxisIndex"`
|
||||||
ItemStyle EChartStyle `json:"itemStyle"`
|
ItemStyle EChartStyle `json:"itemStyle"`
|
||||||
// label的配置
|
// label的配置
|
||||||
Label EChartsLabelOption `json:"label"`
|
Label EChartsLabelOption `json:"label"`
|
||||||
} `json:"series"`
|
MarkPoint EChartsMarkPoint `json:"markPoint"`
|
||||||
|
MarkLine EChartsMarkLine `json:"markLine"`
|
||||||
}
|
}
|
||||||
|
type EChartsSeriesList []EChartsSeries
|
||||||
|
|
||||||
func convertEChartsSeries(e *ECharsOptions) ([]Series, chart.TickPosition) {
|
func (esList EChartsSeriesList) ToSeriesList() SeriesList {
|
||||||
tickPosition := chart.TickPositionUnset
|
seriesList := make(SeriesList, 0, len(esList))
|
||||||
|
for _, item := range esList {
|
||||||
if len(e.Series) == 0 {
|
// 如果是pie,则每个子荐生成一个series
|
||||||
return nil, tickPosition
|
if item.Type == ChartTypePie {
|
||||||
}
|
for _, dataItem := range item.Data {
|
||||||
seriesType := e.Series[0].Type
|
seriesList = append(seriesList, Series{
|
||||||
if seriesType == SeriesPie {
|
Type: ChartTypePie,
|
||||||
series := make([]Series, len(e.Series[0].Data))
|
Name: dataItem.Name,
|
||||||
label := e.Series[0].Label.ToLabel()
|
Label: SeriesLabel{
|
||||||
for index, item := range e.Series[0].Data {
|
Show: true,
|
||||||
style := chart.Style{}
|
},
|
||||||
if item.ItemStyle.Color != "" {
|
Radius: item.Radius,
|
||||||
c := parseColor(item.ItemStyle.Color)
|
|
||||||
style.FillColor = c
|
|
||||||
style.StrokeColor = c
|
|
||||||
}
|
|
||||||
|
|
||||||
series[index] = Series{
|
|
||||||
Style: style,
|
|
||||||
Data: []SeriesData{
|
Data: []SeriesData{
|
||||||
{
|
{
|
||||||
Value: item.Value,
|
Value: dataItem.Value,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Type: seriesType,
|
})
|
||||||
Name: item.Name,
|
|
||||||
Label: label,
|
|
||||||
}
|
}
|
||||||
}
|
continue
|
||||||
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))
|
data := make([]SeriesData, len(item.Data))
|
||||||
for j, itemData := range item.Data {
|
for j, dataItem := range item.Data {
|
||||||
sd := SeriesData{
|
data[j] = SeriesData{
|
||||||
Value: itemData.Value,
|
Value: dataItem.Value,
|
||||||
|
Style: dataItem.ItemStyle.ToStyle(),
|
||||||
}
|
}
|
||||||
if itemData.ItemStyle.Color != "" {
|
|
||||||
c := parseColor(itemData.ItemStyle.Color)
|
|
||||||
sd.Style.FillColor = c
|
|
||||||
sd.Style.StrokeColor = c
|
|
||||||
}
|
}
|
||||||
data[j] = sd
|
seriesList = append(seriesList, Series{
|
||||||
}
|
|
||||||
series[index] = Series{
|
|
||||||
Style: style,
|
|
||||||
YAxisIndex: item.YAxisIndex,
|
|
||||||
Data: data,
|
|
||||||
Type: item.Type,
|
Type: item.Type,
|
||||||
Label: item.Label.ToLabel(),
|
Data: data,
|
||||||
|
YAxisIndex: item.YAxisIndex,
|
||||||
|
Style: item.ItemStyle.ToStyle(),
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Color: parseColor(item.Label.Color),
|
||||||
|
Show: item.Label.Show,
|
||||||
|
Distance: item.Label.Distance,
|
||||||
|
},
|
||||||
|
Name: item.Name,
|
||||||
|
MarkPoint: item.MarkPoint.ToSeriesMarkPoint(),
|
||||||
|
MarkLine: item.MarkLine.ToSeriesMarkLine(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
return seriesList
|
||||||
return series, tickPosition
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ECharsOptions) ToOptions() Options {
|
type EChartsTextStyle struct {
|
||||||
o := Options{
|
Color string `json:"color"`
|
||||||
Theme: e.Theme,
|
FontFamily string `json:"fontFamily"`
|
||||||
Padding: e.Padding.box,
|
FontSize float64 `json:"fontSize"`
|
||||||
}
|
}
|
||||||
|
|
||||||
titleTextStyle := e.Title.TextStyle
|
func (et *EChartsTextStyle) ToStyle() chart.Style {
|
||||||
o.Title = Title{
|
s := chart.Style{
|
||||||
Text: e.Title.Text,
|
FontSize: et.FontSize,
|
||||||
Left: string(e.Title.Left),
|
FontColor: parseColor(et.Color),
|
||||||
Top: string(e.Title.Top),
|
}
|
||||||
Style: chart.Style{
|
if et.FontFamily != "" {
|
||||||
FontColor: parseColor(titleTextStyle.Color),
|
s.Font, _ = GetFont(et.FontFamily)
|
||||||
FontSize: titleTextStyle.FontSize,
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsOption struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
FontFamily string `json:"fontFamily"`
|
||||||
|
Padding EChartsPadding `json:"padding"`
|
||||||
|
Box chart.Box `json:"box"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Title struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Subtext string `json:"subtext"`
|
||||||
|
Left EChartsPosition `json:"left"`
|
||||||
|
Top EChartsPosition `json:"top"`
|
||||||
|
TextStyle EChartsTextStyle `json:"textStyle"`
|
||||||
|
SubtextStyle EChartsTextStyle `json:"subtextStyle"`
|
||||||
|
} `json:"title"`
|
||||||
|
XAxis EChartsXAxis `json:"xAxis"`
|
||||||
|
YAxis EChartsYAxis `json:"yAxis"`
|
||||||
|
Legend EChartsLegend `json:"legend"`
|
||||||
|
Series EChartsSeriesList `json:"series"`
|
||||||
|
Children []EChartsOption `json:"children"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (eo *EChartsOption) ToOption() ChartOption {
|
||||||
|
fontFamily := eo.FontFamily
|
||||||
|
if len(fontFamily) == 0 {
|
||||||
|
fontFamily = eo.Title.TextStyle.FontFamily
|
||||||
|
}
|
||||||
|
o := ChartOption{
|
||||||
|
Type: eo.Type,
|
||||||
|
FontFamily: fontFamily,
|
||||||
|
Theme: eo.Theme,
|
||||||
|
Title: TitleOption{
|
||||||
|
Text: eo.Title.Text,
|
||||||
|
Subtext: eo.Title.Subtext,
|
||||||
|
Style: eo.Title.TextStyle.ToStyle(),
|
||||||
|
SubtextStyle: eo.Title.SubtextStyle.ToStyle(),
|
||||||
|
Left: string(eo.Title.Left),
|
||||||
|
Top: string(eo.Title.Top),
|
||||||
},
|
},
|
||||||
|
Legend: LegendOption{
|
||||||
|
Show: eo.Legend.Show,
|
||||||
|
Style: eo.Legend.TextStyle.ToStyle(),
|
||||||
|
Data: eo.Legend.Data,
|
||||||
|
Left: string(eo.Legend.Left),
|
||||||
|
Top: string(eo.Legend.Top),
|
||||||
|
Align: eo.Legend.Align,
|
||||||
|
Orient: eo.Legend.Orient,
|
||||||
|
},
|
||||||
|
Width: eo.Width,
|
||||||
|
Height: eo.Height,
|
||||||
|
Padding: eo.Padding.Box,
|
||||||
|
Box: eo.Box,
|
||||||
|
SeriesList: eo.Series.ToSeriesList(),
|
||||||
}
|
}
|
||||||
if e.Title.TextStyle.FontFamily != "" {
|
if len(eo.XAxis.Data) != 0 {
|
||||||
// 如果获取字体失败忽略
|
xAxisData := eo.XAxis.Data[0]
|
||||||
o.Font, _ = GetFont(e.Title.TextStyle.FontFamily)
|
o.XAxis = XAxisOption{
|
||||||
}
|
BoundaryGap: xAxisData.BoundaryGap,
|
||||||
|
Data: xAxisData.Data,
|
||||||
if titleTextStyle.FontSize != 0 && titleTextStyle.Height > titleTextStyle.FontSize {
|
SplitNumber: xAxisData.SplitNumber,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
yAxisOptions := make([]YAxisOption, len(eo.YAxis.Data))
|
||||||
o.Legend = Legend{
|
for index, item := range eo.YAxis.Data {
|
||||||
Data: e.Legend.Data,
|
yAxisOptions[index] = YAxisOption{
|
||||||
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,
|
Min: item.Min,
|
||||||
}
|
Max: item.Max,
|
||||||
template := item.AxisLabel.Formatter
|
Formatter: item.AxisLabel.Formatter,
|
||||||
if template != "" {
|
Color: parseColor(item.AxisLine.LineStyle.Color),
|
||||||
opt.Formater = func(v interface{}) string {
|
|
||||||
str := defaultFloatFormater(v)
|
|
||||||
return strings.ReplaceAll(template, "{value}", str)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
yAxisOptions[index] = opt
|
o.YAxisList = yAxisOptions
|
||||||
}
|
|
||||||
o.YAxisOptions = yAxisOptions
|
|
||||||
}
|
|
||||||
|
|
||||||
series, tickPosition := convertEChartsSeries(e)
|
if len(eo.Children) != 0 {
|
||||||
|
o.Children = make([]ChartOption, len(eo.Children))
|
||||||
o.Series = series
|
for index, item := range eo.Children {
|
||||||
|
o.Children[index] = item.ToOption()
|
||||||
if boundaryGap {
|
}
|
||||||
tickPosition = chart.TickPositionBetweenTicks
|
|
||||||
}
|
}
|
||||||
o.TickPosition = tickPosition
|
|
||||||
return o
|
return o
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseECharsOptions(options string) (Options, error) {
|
func renderEcharts(options, outputType string) ([]byte, error) {
|
||||||
e := ECharsOptions{}
|
o := EChartsOption{}
|
||||||
err := json.Unmarshal([]byte(options), &e)
|
err := json.Unmarshal([]byte(options), &o)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
g, err := New(o)
|
opt := o.ToOption()
|
||||||
|
opt.Type = outputType
|
||||||
|
d, err := Render(opt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return render(g, rp)
|
return d.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderEChartsToPNG(options string) ([]byte, error) {
|
func RenderEChartsToPNG(options string) ([]byte, error) {
|
||||||
return echartsRender(options, chart.PNG)
|
return renderEcharts(options, "png")
|
||||||
}
|
}
|
||||||
|
|
||||||
func RenderEChartsToSVG(options string) ([]byte, error) {
|
func RenderEChartsToSVG(options string) ([]byte, error) {
|
||||||
return echartsRender(options, chart.SVG)
|
return renderEcharts(options, "svg")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
784
echarts_test.go
784
echarts_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -31,425 +31,529 @@ import (
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConvertToArray(t *testing.T) {
|
func TestEChartsPosition(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
assert.Nil(convertToArray([]byte(" ")))
|
var p EChartsPosition
|
||||||
|
err := p.UnmarshalJSON([]byte("12"))
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal("12", string(p))
|
||||||
|
|
||||||
assert.Equal([]byte("[{}]"), convertToArray([]byte("{}")))
|
err = p.UnmarshalJSON([]byte(`"12%"`))
|
||||||
assert.Equal([]byte("[{}]"), convertToArray([]byte("[{}]")))
|
assert.Nil(err)
|
||||||
|
assert.Equal("12%", string(p))
|
||||||
|
}
|
||||||
|
func TestEChartStyle(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
s := EChartStyle{
|
||||||
|
Color: "#aaa",
|
||||||
|
}
|
||||||
|
r := drawing.Color{
|
||||||
|
R: 170,
|
||||||
|
G: 170,
|
||||||
|
B: 170,
|
||||||
|
A: 255,
|
||||||
|
}
|
||||||
|
assert.Equal(chart.Style{
|
||||||
|
FillColor: r,
|
||||||
|
FontColor: r,
|
||||||
|
StrokeColor: r,
|
||||||
|
}, s.ToStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestECharsSeriesData(t *testing.T) {
|
func TestEChartsXAxis(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
ex := EChartsXAxis{}
|
||||||
es := ECharsSeriesData{}
|
err := ex.UnmarshalJSON([]byte(`{
|
||||||
err := es.UnmarshalJSON([]byte(" "))
|
"boundaryGap": false,
|
||||||
|
"splitNumber": 5,
|
||||||
|
"data": [
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun"
|
||||||
|
]
|
||||||
|
}`))
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
assert.Equal(ECharsSeriesData{}, es)
|
assert.Equal(EChartsXAxis{
|
||||||
|
Data: []EChartsXAxisData{
|
||||||
|
{
|
||||||
|
BoundaryGap: FalseFlag(),
|
||||||
|
SplitNumber: 5,
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
"Thu",
|
||||||
|
"Fri",
|
||||||
|
"Sat",
|
||||||
|
"Sun",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, ex)
|
||||||
|
}
|
||||||
|
|
||||||
es = ECharsSeriesData{}
|
func TestEChartsYAxis(t *testing.T) {
|
||||||
err = es.UnmarshalJSON([]byte("12.1"))
|
assert := assert.New(t)
|
||||||
assert.Nil(err)
|
ey := EChartsYAxis{}
|
||||||
assert.Equal(ECharsSeriesData{
|
|
||||||
Value: 12.1,
|
|
||||||
}, es)
|
|
||||||
|
|
||||||
es = ECharsSeriesData{}
|
err := ey.UnmarshalJSON([]byte(`{
|
||||||
err = es.UnmarshalJSON([]byte(`{
|
"min": 1,
|
||||||
"value": 12.1,
|
"max": 10,
|
||||||
"name": "test",
|
"axisLabel": {
|
||||||
"itemStyle": {
|
"formatter": "ab"
|
||||||
"color": "#333"
|
|
||||||
}
|
}
|
||||||
}`))
|
}`))
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
assert.Equal(ECharsSeriesData{
|
assert.Equal(EChartsYAxis{
|
||||||
Value: 12.1,
|
Data: []EChartsYAxisData{
|
||||||
Name: "test",
|
{
|
||||||
ItemStyle: EChartStyle{
|
Min: NewFloatPoint(1),
|
||||||
Color: "#333",
|
Max: NewFloatPoint(10),
|
||||||
|
AxisLabel: EChartsAxisLabel{
|
||||||
|
Formatter: "ab",
|
||||||
},
|
},
|
||||||
}, es)
|
},
|
||||||
|
},
|
||||||
|
}, ey)
|
||||||
|
|
||||||
|
ey = EChartsYAxis{}
|
||||||
|
err = ey.UnmarshalJSON([]byte(`[
|
||||||
|
{
|
||||||
|
"min": 1,
|
||||||
|
"max": 10,
|
||||||
|
"axisLabel": {
|
||||||
|
"formatter": "ab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"min": 2,
|
||||||
|
"max": 20,
|
||||||
|
"axisLabel": {
|
||||||
|
"formatter": "cd"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]`))
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(EChartsYAxis{
|
||||||
|
Data: []EChartsYAxisData{
|
||||||
|
{
|
||||||
|
Min: NewFloatPoint(1),
|
||||||
|
Max: NewFloatPoint(10),
|
||||||
|
AxisLabel: EChartsAxisLabel{
|
||||||
|
Formatter: "ab",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Min: NewFloatPoint(2),
|
||||||
|
Max: NewFloatPoint(20),
|
||||||
|
AxisLabel: EChartsAxisLabel{
|
||||||
|
Formatter: "cd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, ey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEChartsPadding(t *testing.T) {
|
func TestEChartsPadding(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ep := EChartsPadding{}
|
ep := EChartsPadding{}
|
||||||
err := ep.UnmarshalJSON([]byte(" "))
|
|
||||||
assert.Nil(err)
|
|
||||||
assert.Equal(EChartsPadding{}, ep)
|
|
||||||
|
|
||||||
ep = EChartsPadding{}
|
ep.UnmarshalJSON([]byte(`10`))
|
||||||
err = ep.UnmarshalJSON([]byte("1"))
|
|
||||||
assert.Nil(err)
|
|
||||||
assert.Equal(EChartsPadding{
|
assert.Equal(EChartsPadding{
|
||||||
box: chart.Box{
|
Box: chart.Box{
|
||||||
Top: 1,
|
Top: 10,
|
||||||
Left: 1,
|
Right: 10,
|
||||||
Right: 1,
|
Bottom: 10,
|
||||||
Bottom: 1,
|
Left: 10,
|
||||||
},
|
},
|
||||||
}, ep)
|
}, ep)
|
||||||
|
|
||||||
ep = EChartsPadding{}
|
ep = EChartsPadding{}
|
||||||
err = ep.UnmarshalJSON([]byte("[1, 2]"))
|
ep.UnmarshalJSON([]byte(`[10, 20]`))
|
||||||
assert.Nil(err)
|
|
||||||
assert.Equal(EChartsPadding{
|
assert.Equal(EChartsPadding{
|
||||||
box: chart.Box{
|
Box: chart.Box{
|
||||||
Top: 1,
|
Top: 10,
|
||||||
Left: 2,
|
Right: 20,
|
||||||
Right: 2,
|
Bottom: 10,
|
||||||
Bottom: 1,
|
Left: 20,
|
||||||
},
|
},
|
||||||
}, ep)
|
}, ep)
|
||||||
|
|
||||||
ep = EChartsPadding{}
|
ep = EChartsPadding{}
|
||||||
err = ep.UnmarshalJSON([]byte("[1, 2, 3]"))
|
ep.UnmarshalJSON([]byte(`[10, 20, 30]`))
|
||||||
assert.Nil(err)
|
|
||||||
assert.Equal(EChartsPadding{
|
assert.Equal(EChartsPadding{
|
||||||
box: chart.Box{
|
Box: chart.Box{
|
||||||
Top: 1,
|
Top: 10,
|
||||||
Right: 2,
|
Right: 20,
|
||||||
Bottom: 3,
|
Bottom: 30,
|
||||||
Left: 2,
|
Left: 20,
|
||||||
},
|
},
|
||||||
}, ep)
|
}, ep)
|
||||||
|
|
||||||
ep = EChartsPadding{}
|
ep = EChartsPadding{}
|
||||||
err = ep.UnmarshalJSON([]byte("[1, 2, 3, 4]"))
|
ep.UnmarshalJSON([]byte(`[10, 20, 30, 40]`))
|
||||||
assert.Nil(err)
|
|
||||||
assert.Equal(EChartsPadding{
|
assert.Equal(EChartsPadding{
|
||||||
box: chart.Box{
|
Box: chart.Box{
|
||||||
Top: 1,
|
Top: 10,
|
||||||
Right: 2,
|
Right: 20,
|
||||||
Bottom: 3,
|
Bottom: 30,
|
||||||
Left: 4,
|
Left: 40,
|
||||||
},
|
},
|
||||||
}, ep)
|
}, ep)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
func TestEChartsLegend(t *testing.T) {
|
||||||
func TestConvertEChartsSeries(t *testing.T) {
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
seriesList, tickPosition := convertEChartsSeries(&ECharsOptions{})
|
el := EChartsLegend{}
|
||||||
assert.Empty(seriesList)
|
|
||||||
assert.Equal(chart.TickPositionUnset, tickPosition)
|
|
||||||
|
|
||||||
e := ECharsOptions{}
|
|
||||||
err := json.Unmarshal([]byte(`{
|
err := json.Unmarshal([]byte(`{
|
||||||
"title": {
|
"data": ["a", "b", "c"],
|
||||||
"text": "Referer of a Website"
|
"align": "right",
|
||||||
|
"padding": [10],
|
||||||
|
"left": "20%",
|
||||||
|
"top": 10
|
||||||
|
}`), &el)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(EChartsLegend{
|
||||||
|
Data: []string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
"c",
|
||||||
},
|
},
|
||||||
"series": [
|
Align: "right",
|
||||||
|
Padding: EChartsPadding{
|
||||||
|
Box: chart.Box{
|
||||||
|
Left: 10,
|
||||||
|
Top: 10,
|
||||||
|
Right: 10,
|
||||||
|
Bottom: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Left: EChartsPosition("20%"),
|
||||||
|
Top: EChartsPosition("10"),
|
||||||
|
}, el)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEChartsSeriesData(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
esd := EChartsSeriesData{}
|
||||||
|
err := esd.UnmarshalJSON([]byte(`123`))
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(EChartsSeriesData{
|
||||||
|
Value: 123,
|
||||||
|
}, esd)
|
||||||
|
|
||||||
|
esd = EChartsSeriesData{}
|
||||||
|
err = esd.UnmarshalJSON([]byte(`{
|
||||||
|
"value": 123.12,
|
||||||
|
"name": "test",
|
||||||
|
"itemStyle": {
|
||||||
|
"color": "#aaa"
|
||||||
|
}
|
||||||
|
}`))
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(EChartsSeriesData{
|
||||||
|
Value: 123.12,
|
||||||
|
Name: "test",
|
||||||
|
ItemStyle: EChartStyle{
|
||||||
|
Color: "#aaa",
|
||||||
|
},
|
||||||
|
}, esd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEChartsSeries(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
esList := make([]EChartsSeries, 0)
|
||||||
|
err := json.Unmarshal([]byte(`[
|
||||||
{
|
{
|
||||||
"name": "Access From",
|
"name": "Email",
|
||||||
"type": "pie",
|
"data": [
|
||||||
"radius": "50%",
|
120,
|
||||||
|
132
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Union Ads",
|
||||||
|
"type": "bar",
|
||||||
|
"data": [
|
||||||
|
220,
|
||||||
|
182
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]`), &esList)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal([]EChartsSeries{
|
||||||
|
{
|
||||||
|
Name: "Email",
|
||||||
|
Data: []EChartsSeriesData{
|
||||||
|
{
|
||||||
|
Value: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 132,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Union Ads",
|
||||||
|
Type: "bar",
|
||||||
|
Data: []EChartsSeriesData{
|
||||||
|
{
|
||||||
|
Value: 220,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 182,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, esList)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEChartsMarkPoint(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
p := EChartsMarkPoint{}
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(`{
|
||||||
|
"symbolSize": 30,
|
||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"value": 1048,
|
"type": "max"
|
||||||
"name": "Search Engine"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": 735,
|
"type": "min"
|
||||||
"name": "Direct"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}`), &p)
|
||||||
]
|
|
||||||
}`), &e)
|
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
seriesList, tickPosition = convertEChartsSeries(&e)
|
assert.Equal(SeriesMarkPoint{
|
||||||
assert.Equal(chart.TickPositionUnset, tickPosition)
|
SymbolSize: 30,
|
||||||
assert.Equal([]Series{
|
Data: []SeriesMarkData{
|
||||||
{
|
{
|
||||||
Data: []SeriesData{
|
Type: "max",
|
||||||
{
|
|
||||||
Value: 1048,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Type: SeriesPie,
|
|
||||||
Name: "Search Engine",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Data: []SeriesData{
|
Type: "min",
|
||||||
{
|
|
||||||
Value: 735,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Type: SeriesPie,
|
}, p.ToSeriesMarkPoint())
|
||||||
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) {
|
func TestEChartsMarkLine(t *testing.T) {
|
||||||
|
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
options, err := ParseECharsOptions(`{
|
l := EChartsMarkLine{}
|
||||||
"theme": "dark",
|
|
||||||
"padding": [5, 10],
|
err := json.Unmarshal([]byte(`{
|
||||||
"title": {
|
"data": [
|
||||||
"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,
|
"type": "max"
|
||||||
"max": 250
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"min": 0,
|
"type": "min"
|
||||||
"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]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}`)
|
}`), &l)
|
||||||
|
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
|
assert.Equal(SeriesMarkLine{
|
||||||
min1 := float64(0)
|
Data: []SeriesMarkData{
|
||||||
max1 := float64(250)
|
{
|
||||||
min2 := float64(0)
|
Type: "max",
|
||||||
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",
|
Type: "min",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, l.ToSeriesMarkLine())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEChartsTextStyle(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
s := EChartsTextStyle{
|
||||||
|
Color: "#aaa",
|
||||||
|
FontFamily: "test",
|
||||||
|
FontSize: 14,
|
||||||
|
}
|
||||||
|
assert.Equal(chart.Style{
|
||||||
|
FontColor: drawing.Color{
|
||||||
|
R: 170,
|
||||||
|
G: 170,
|
||||||
|
B: 170,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FontSize: 14,
|
||||||
|
}, s.ToStyle())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEChartsSeriesList(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// pie
|
||||||
|
es := EChartsSeriesList{
|
||||||
|
{
|
||||||
|
Type: ChartTypePie,
|
||||||
|
Radius: "30%",
|
||||||
|
Data: []EChartsSeriesData{
|
||||||
|
{
|
||||||
|
Name: "1",
|
||||||
|
Value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "2",
|
||||||
|
Value: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Equal(SeriesList{
|
||||||
|
{
|
||||||
|
Type: ChartTypePie,
|
||||||
|
Name: "1",
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
},
|
||||||
|
Radius: "30%",
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: ChartTypePie,
|
||||||
|
Name: "2",
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
},
|
||||||
|
Radius: "30%",
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, es.ToSeriesList())
|
||||||
|
|
||||||
|
es = EChartsSeriesList{
|
||||||
|
{
|
||||||
|
Type: ChartTypeBar,
|
||||||
|
Data: []EChartsSeriesData{
|
||||||
|
{
|
||||||
|
Value: 1,
|
||||||
|
ItemStyle: EChartStyle{
|
||||||
|
Color: "#aaa",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
YAxisIndex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Data: []EChartsSeriesData{
|
||||||
|
{
|
||||||
|
Value: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ItemStyle: EChartStyle{
|
||||||
|
Color: "#ccc",
|
||||||
|
},
|
||||||
|
Label: EChartsLabelOption{
|
||||||
|
Color: "#ddd",
|
||||||
|
Show: true,
|
||||||
|
Distance: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Equal(SeriesList{
|
||||||
|
{
|
||||||
|
Type: ChartTypeBar,
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 1,
|
||||||
Style: chart.Style{
|
Style: chart.Style{
|
||||||
FontColor: parseColor("#333"),
|
FontColor: drawing.Color{
|
||||||
FontSize: 24,
|
R: 170,
|
||||||
Padding: chart.Box{
|
G: 170,
|
||||||
Top: 8,
|
B: 170,
|
||||||
Bottom: 8,
|
A: 255,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
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{
|
StrokeColor: drawing.Color{
|
||||||
R: 0,
|
R: 170,
|
||||||
G: 82,
|
G: 170,
|
||||||
B: 217,
|
B: 170,
|
||||||
A: 255,
|
A: 255,
|
||||||
},
|
},
|
||||||
FillColor: drawing.Color{
|
FillColor: drawing.Color{
|
||||||
R: 0,
|
R: 170,
|
||||||
G: 82,
|
G: 170,
|
||||||
B: 217,
|
B: 170,
|
||||||
A: 255,
|
A: 255,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Data: NewSeriesDataListFromFloat([]float64{
|
Value: 2,
|
||||||
2, 2.2, 3.3, 4.5, 6.3, 10.2, 20.3,
|
},
|
||||||
}),
|
},
|
||||||
Type: SeriesLine,
|
|
||||||
YAxisIndex: 1,
|
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]
|
Data: []SeriesData{
|
||||||
}
|
|
||||||
]
|
|
||||||
}`)
|
|
||||||
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]
|
Value: 3,
|
||||||
}
|
},
|
||||||
]
|
{
|
||||||
}`)
|
Value: 4,
|
||||||
if err != nil {
|
},
|
||||||
panic(err)
|
},
|
||||||
}
|
Style: chart.Style{
|
||||||
}
|
FontColor: drawing.Color{
|
||||||
|
R: 204,
|
||||||
|
G: 204,
|
||||||
|
B: 204,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
StrokeColor: drawing.Color{
|
||||||
|
R: 204,
|
||||||
|
G: 204,
|
||||||
|
B: 204,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
FillColor: drawing.Color{
|
||||||
|
R: 204,
|
||||||
|
G: 204,
|
||||||
|
B: 204,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Color: drawing.Color{
|
||||||
|
R: 221,
|
||||||
|
G: 221,
|
||||||
|
B: 221,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
Show: true,
|
||||||
|
Distance: 5,
|
||||||
|
},
|
||||||
|
MarkPoint: SeriesMarkPoint{},
|
||||||
|
MarkLine: SeriesMarkLine{},
|
||||||
|
},
|
||||||
|
}, es.ToSeriesList())
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
61
font.go
Normal file
61
font.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// 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 (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/roboto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fonts = sync.Map{}
|
||||||
|
var ErrFontNotExists = errors.New("font is not exists")
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_ = InstallFont("roboto", roboto.Roboto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 get the font by 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
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -26,21 +26,17 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2/roboto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLineSeries(t *testing.T) {
|
func TestInstallFont(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
ls := LineSeries{}
|
fontFamily := "test"
|
||||||
|
err := InstallFont(fontFamily, roboto.Roboto)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
originalRange := &chart.ContinuousRange{}
|
font, err := GetFont(fontFamily)
|
||||||
xrange := ls.getXRange(originalRange)
|
assert.Nil(err)
|
||||||
assert.Equal(originalRange, xrange)
|
assert.NotNil(font)
|
||||||
|
|
||||||
ls.TickPosition = chart.TickPositionBetweenTicks
|
|
||||||
xrange = ls.getXRange(originalRange)
|
|
||||||
value, ok := xrange.(*Range)
|
|
||||||
assert.True(ok)
|
|
||||||
assert.Equal(originalRange, &value.ContinuousRange)
|
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
339
legend.go
339
legend.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -30,220 +30,179 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type LegendOption struct {
|
type LegendOption struct {
|
||||||
|
theme string
|
||||||
|
// Legend show flag, if nil or true, the legend will be shown
|
||||||
|
Show *bool
|
||||||
|
// Legend text style
|
||||||
Style chart.Style
|
Style chart.Style
|
||||||
Padding chart.Box
|
// Text array of legend
|
||||||
|
Data []string
|
||||||
|
// Distance between legend component and the left side of the container.
|
||||||
|
// It can be pixel value: 20, percentage value: 20%,
|
||||||
|
// or position value: right, center.
|
||||||
Left string
|
Left string
|
||||||
Right string
|
// Distance between legend component and the top side of the container.
|
||||||
|
// It can be pixel value: 20.
|
||||||
Top string
|
Top string
|
||||||
Bottom string
|
// Legend marker and text aligning, it can be left or right, default is left.
|
||||||
Align string
|
Align string
|
||||||
Theme string
|
// The layout orientation of legend, it can be horizontal or vertical, default is horizontal.
|
||||||
IconDraw LegendIconDraw
|
Orient string
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegendIconDrawOption struct {
|
// NewLegendOption creates a new legend option by legend text list
|
||||||
Box chart.Box
|
func NewLegendOption(data []string, position ...string) LegendOption {
|
||||||
Style chart.Style
|
opt := LegendOption{
|
||||||
Index int
|
Data: data,
|
||||||
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())
|
if len(position) != 0 {
|
||||||
strokeWidth := opt.Style.GetStrokeWidth()
|
opt.Left = position[0]
|
||||||
r.SetStrokeWidth(strokeWidth)
|
}
|
||||||
height := opt.Box.Bottom - opt.Box.Top
|
return opt
|
||||||
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 {
|
type legend struct {
|
||||||
if !strings.HasSuffix(value, "%") {
|
d *Draw
|
||||||
return -1
|
opt *LegendOption
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLegend(d *Draw, opt LegendOption) *legend {
|
||||||
|
return &legend{
|
||||||
|
d: d,
|
||||||
|
opt: &opt,
|
||||||
}
|
}
|
||||||
v, err := strconv.Atoi(strings.ReplaceAll(value, "%", ""))
|
}
|
||||||
|
|
||||||
|
func (l *legend) Render() (chart.Box, error) {
|
||||||
|
d := l.d
|
||||||
|
opt := l.opt
|
||||||
|
if len(opt.Data) == 0 || isFalse(opt.Show) {
|
||||||
|
return chart.BoxZero, nil
|
||||||
|
}
|
||||||
|
theme := NewTheme(opt.theme)
|
||||||
|
padding := opt.Style.Padding
|
||||||
|
legendDraw, err := NewDraw(DrawOption{
|
||||||
|
Parent: d,
|
||||||
|
}, PaddingOption(padding))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1
|
return chart.BoxZero, err
|
||||||
}
|
}
|
||||||
return float64(v) / 100
|
r := legendDraw.Render
|
||||||
}
|
opt.Style.GetTextOptions().WriteToRenderer(r)
|
||||||
|
|
||||||
func getLegendLeft(canvasWidth, legendBoxWidth int, opt LegendOption) int {
|
x := 0
|
||||||
left := (canvasWidth - legendBoxWidth) / 2
|
y := 0
|
||||||
leftValue := opt.Left
|
top := 0
|
||||||
if leftValue == "auto" || leftValue == "center" {
|
// TODO TOP 暂只支持数值
|
||||||
leftValue = ""
|
if opt.Top != "" {
|
||||||
|
top, _ = strconv.Atoi(opt.Top)
|
||||||
|
y += top
|
||||||
}
|
}
|
||||||
if leftValue == "left" {
|
legendWidth := 30
|
||||||
leftValue = "0"
|
legendDotHeight := 5
|
||||||
|
textPadding := 5
|
||||||
|
legendMargin := 10
|
||||||
|
// 往下移2倍dot的高度
|
||||||
|
y += 2 * legendDotHeight
|
||||||
|
|
||||||
|
widthCount := 0
|
||||||
|
maxTextWidth := 0
|
||||||
|
// 文本宽度
|
||||||
|
for _, text := range opt.Data {
|
||||||
|
b := r.MeasureText(text)
|
||||||
|
if b.Width() > maxTextWidth {
|
||||||
|
maxTextWidth = b.Width()
|
||||||
|
}
|
||||||
|
widthCount += b.Width()
|
||||||
|
}
|
||||||
|
if opt.Orient == OrientVertical {
|
||||||
|
widthCount = maxTextWidth + legendWidth + textPadding
|
||||||
|
} else {
|
||||||
|
// 加上标记
|
||||||
|
widthCount += legendWidth * len(opt.Data)
|
||||||
|
// 文本的padding
|
||||||
|
widthCount += 2 * textPadding * len(opt.Data)
|
||||||
|
// margin的宽度
|
||||||
|
widthCount += legendMargin * (len(opt.Data) - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
rightValue := opt.Right
|
left := 0
|
||||||
if rightValue == "auto" || leftValue == "center" {
|
switch opt.Left {
|
||||||
rightValue = ""
|
case PositionRight:
|
||||||
}
|
left = legendDraw.Box.Width() - widthCount
|
||||||
if rightValue == "right" {
|
case PositionCenter:
|
||||||
rightValue = "0"
|
left = (legendDraw.Box.Width() - widthCount) >> 1
|
||||||
}
|
default:
|
||||||
if leftValue == "" && rightValue == "" {
|
if strings.HasSuffix(opt.Left, "%") {
|
||||||
return left
|
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
|
||||||
}
|
left = legendDraw.Box.Width() * value / 100
|
||||||
if leftValue != "" {
|
} else {
|
||||||
percent := convertPercent(leftValue)
|
value, _ := strconv.Atoi(opt.Left)
|
||||||
if percent >= 0 {
|
left = value
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
x = left
|
||||||
|
for index, text := range opt.Data {
|
||||||
|
seriesColor := theme.GetSeriesColor(index)
|
||||||
|
fillColor := seriesColor
|
||||||
|
if !theme.IsDark() {
|
||||||
|
fillColor = theme.GetBackgroundColor()
|
||||||
}
|
}
|
||||||
|
style := chart.Style{
|
||||||
var textHeight int
|
StrokeColor: seriesColor,
|
||||||
var textBox chart.Box
|
FillColor: fillColor,
|
||||||
labelWidth := 0
|
StrokeWidth: 3,
|
||||||
// 计算文本宽度与高度(取最大值)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
style.GetFillAndStrokeOptions().WriteDrawingOptionsToRenderer(r)
|
||||||
|
|
||||||
legendBoxHeight := textHeight + legendStyle.Padding.Top + legendStyle.Padding.Bottom
|
textBox := r.MeasureText(text)
|
||||||
chartPadding := cb.Top
|
var renderText func()
|
||||||
legendYMargin := (chartPadding - legendBoxHeight) >> 1
|
if opt.Orient == OrientVertical {
|
||||||
|
// 垂直
|
||||||
iconWidth := 25
|
// 重置x的位置
|
||||||
lineTextGap := 5
|
x = left
|
||||||
|
renderText = func() {
|
||||||
iconAllWidth := iconWidth * len(labels)
|
x += textPadding
|
||||||
spaceAllWidth := (chart.DefaultMinimumTickHorizontalSpacing + lineTextGap) * (len(labels) - 1)
|
legendDraw.text(text, x, y+legendDotHeight)
|
||||||
|
|
||||||
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()
|
x += textBox.Width()
|
||||||
|
y += (2*legendDotHeight + legendMargin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算下一个legend的位置
|
} else {
|
||||||
startX = x + chart.DefaultMinimumTickHorizontalSpacing
|
// 水平
|
||||||
|
if index != 0 {
|
||||||
|
x += legendMargin
|
||||||
|
}
|
||||||
|
renderText = func() {
|
||||||
|
x += textPadding
|
||||||
|
legendDraw.text(text, x, y+legendDotHeight)
|
||||||
|
x += textBox.Width()
|
||||||
|
x += textPadding
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if opt.Align == PositionRight {
|
||||||
|
renderText()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
legendDraw.moveTo(x, y)
|
||||||
|
legendDraw.lineTo(x+legendWidth, y)
|
||||||
|
r.Stroke()
|
||||||
|
legendDraw.circle(float64(legendDotHeight), x+legendWidth>>1, y)
|
||||||
|
r.FillStroke()
|
||||||
|
x += legendWidth
|
||||||
|
|
||||||
|
if opt.Align != PositionRight {
|
||||||
|
renderText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
legendBox := padding.Clone()
|
||||||
|
// 计算展示区域
|
||||||
|
if opt.Orient == OrientVertical {
|
||||||
|
legendBox.Right = legendBox.Left + left + maxTextWidth + legendWidth + textPadding
|
||||||
|
legendBox.Bottom = legendBox.Top + y
|
||||||
|
} else {
|
||||||
|
legendBox.Right = legendBox.Left + x
|
||||||
|
legendBox.Bottom = legendBox.Top + 2*legendDotHeight + top + textPadding
|
||||||
|
}
|
||||||
|
return legendBox, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
204
legend_test.go
204
legend_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,91 +23,163 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewLegendCustomize(t *testing.T) {
|
func TestNewLegendOption(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
series := GetSeries([]Series{
|
opt := NewLegendOption([]string{
|
||||||
{
|
"a",
|
||||||
Name: "chrome",
|
"b",
|
||||||
|
}, PositionRight)
|
||||||
|
assert.Equal(LegendOption{
|
||||||
|
Data: []string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
},
|
},
|
||||||
{
|
Left: PositionRight,
|
||||||
Name: "edge",
|
}, opt)
|
||||||
},
|
}
|
||||||
}, chart.TickPositionBetweenTicks, "")
|
|
||||||
|
func TestLegendRender(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
newDraw := func() *Draw {
|
||||||
|
d, _ := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
style := chart.Style{
|
||||||
|
FontSize: 10,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
}
|
||||||
|
style.Font, _ = chart.GetDefaultFont()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
align string
|
newDraw func() *Draw
|
||||||
svg string
|
newLegend func(*Draw) *legend
|
||||||
|
box chart.Box
|
||||||
|
result string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
align: LegendAlignLeft,
|
newDraw: newDraw,
|
||||||
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>",
|
newLegend: func(d *Draw) *legend {
|
||||||
|
return NewLegend(d, LegendOption{
|
||||||
|
Top: "10",
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
},
|
||||||
|
Style: style,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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 20\nL 30 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"15\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"35\" y=\"25\" 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><path d=\"M 76 20\nL 106 20\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"91\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"111\" y=\"25\" 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><path d=\"M 148 20\nL 178 20\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"163\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"183\" y=\"25\" 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></svg>",
|
||||||
|
box: chart.Box{
|
||||||
|
Right: 214,
|
||||||
|
Bottom: 25,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
align: LegendAlignRight,
|
newDraw: newDraw,
|
||||||
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>",
|
newLegend: func(d *Draw) *legend {
|
||||||
|
return NewLegend(d, LegendOption{
|
||||||
|
Top: "10",
|
||||||
|
Left: PositionRight,
|
||||||
|
Align: PositionRight,
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
},
|
||||||
|
Style: style,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"191\" y=\"25\" 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><path d=\"M 222 20\nL 252 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"237\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"267\" y=\"25\" 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><path d=\"M 294 20\nL 324 20\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"309\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"339\" y=\"25\" 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><path d=\"M 370 20\nL 400 20\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"385\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/></svg>",
|
||||||
|
box: chart.Box{
|
||||||
|
Right: 400,
|
||||||
|
Bottom: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
newDraw: newDraw,
|
||||||
|
newLegend: func(d *Draw) *legend {
|
||||||
|
return NewLegend(d, LegendOption{
|
||||||
|
Top: "10",
|
||||||
|
Left: PositionCenter,
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
},
|
||||||
|
Style: style,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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 93 20\nL 123 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"108\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"128\" y=\"25\" 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><path d=\"M 169 20\nL 199 20\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"184\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"204\" y=\"25\" 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><path d=\"M 241 20\nL 271 20\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"256\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"276\" y=\"25\" 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></svg>",
|
||||||
|
box: chart.Box{
|
||||||
|
Right: 307,
|
||||||
|
Bottom: 25,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
newDraw: newDraw,
|
||||||
|
newLegend: func(d *Draw) *legend {
|
||||||
|
return NewLegend(d, LegendOption{
|
||||||
|
Top: "10",
|
||||||
|
Left: PositionLeft,
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
},
|
||||||
|
Style: style,
|
||||||
|
Orient: OrientVertical,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
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 20\nL 30 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"15\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"35\" y=\"25\" 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><path d=\"M 0 40\nL 30 40\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"15\" cy=\"40\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"35\" y=\"45\" 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><path d=\"M 0 60\nL 30 60\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"15\" cy=\"60\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"35\" y=\"65\" 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></svg>",
|
||||||
|
box: chart.Box{
|
||||||
|
Right: 61,
|
||||||
|
Bottom: 80,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
newDraw: newDraw,
|
||||||
|
newLegend: func(d *Draw) *legend {
|
||||||
|
return NewLegend(d, LegendOption{
|
||||||
|
Top: "10",
|
||||||
|
Left: "10%",
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
"Wed",
|
||||||
|
},
|
||||||
|
Style: style,
|
||||||
|
Orient: OrientVertical,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
box: chart.Box{
|
||||||
|
Right: 101,
|
||||||
|
Bottom: 80,
|
||||||
|
},
|
||||||
|
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 40 20\nL 70 20\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"55\" cy=\"20\" r=\"5\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"75\" y=\"25\" 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><path d=\"M 40 40\nL 70 40\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"55\" cy=\"40\" r=\"5\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"75\" y=\"45\" 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><path d=\"M 40 60\nL 70 60\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"55\" cy=\"60\" r=\"5\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:3;stroke:rgba(250,200,88,1.0);fill:rgba(255,255,255,1.0)\"/><text x=\"75\" y=\"65\" 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></svg>",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
r, err := chart.SVG(800, 600)
|
d := tt.newDraw()
|
||||||
|
b, err := tt.newLegend(d).Render()
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
fn := NewLegendCustomize(series, LegendOption{
|
assert.Equal(tt.box, b)
|
||||||
Align: tt.align,
|
data, err := d.Bytes()
|
||||||
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.Nil(err)
|
||||||
assert.Equal(tt.svg, buf.String())
|
assert.NotEmpty(data)
|
||||||
|
assert.Equal(tt.result, string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
|
}
|
||||||
128
line_chart.go
Normal file
128
line_chart.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type lineChartOption struct {
|
||||||
|
Theme string
|
||||||
|
SeriesList SeriesList
|
||||||
|
Font *truetype.Font
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPointRenderOption, error) {
|
||||||
|
|
||||||
|
theme := NewTheme(opt.Theme)
|
||||||
|
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Parent: result.d,
|
||||||
|
}, PaddingOption(chart.Box{
|
||||||
|
Top: result.titleBox.Height(),
|
||||||
|
Left: YAxisWidth,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
seriesNames := opt.SeriesList.Names()
|
||||||
|
|
||||||
|
r := d.Render
|
||||||
|
xRange := result.xRange
|
||||||
|
markPointRenderOptions := make([]markPointRenderOption, 0)
|
||||||
|
for i, s := range opt.SeriesList {
|
||||||
|
// 由于series是for range,为同一个数据,因此需要clone
|
||||||
|
// 后续需要使用,如mark point
|
||||||
|
series := s
|
||||||
|
index := series.index
|
||||||
|
if index == 0 {
|
||||||
|
index = i
|
||||||
|
}
|
||||||
|
seriesColor := theme.GetSeriesColor(index)
|
||||||
|
|
||||||
|
yRange := result.getYRange(series.YAxisIndex)
|
||||||
|
points := make([]Point, len(series.Data))
|
||||||
|
// mark line
|
||||||
|
markLineRender(markLineRenderOption{
|
||||||
|
Draw: d,
|
||||||
|
FillColor: seriesColor,
|
||||||
|
FontColor: theme.GetTextColor(),
|
||||||
|
StrokeColor: seriesColor,
|
||||||
|
Font: opt.Font,
|
||||||
|
Series: &series,
|
||||||
|
Range: yRange,
|
||||||
|
})
|
||||||
|
|
||||||
|
for j, item := range series.Data {
|
||||||
|
y := yRange.getRestHeight(item.Value)
|
||||||
|
x := xRange.getWidth(float64(j))
|
||||||
|
points[j] = Point{
|
||||||
|
Y: y,
|
||||||
|
X: x,
|
||||||
|
}
|
||||||
|
if !series.Label.Show {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
distance := series.Label.Distance
|
||||||
|
if distance == 0 {
|
||||||
|
distance = 5
|
||||||
|
}
|
||||||
|
text := NewValueLabelFormater(seriesNames, series.Label.Formatter)(i, item.Value, -1)
|
||||||
|
labelStyle := chart.Style{
|
||||||
|
FontColor: theme.GetTextColor(),
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
Font: opt.Font,
|
||||||
|
}
|
||||||
|
if !series.Label.Color.IsZero() {
|
||||||
|
labelStyle.FontColor = series.Label.Color
|
||||||
|
}
|
||||||
|
labelStyle.GetTextOptions().WriteToRenderer(r)
|
||||||
|
textBox := r.MeasureText(text)
|
||||||
|
d.text(text, x-textBox.Width()>>1, y-distance)
|
||||||
|
}
|
||||||
|
|
||||||
|
dotFillColor := drawing.ColorWhite
|
||||||
|
if theme.IsDark() {
|
||||||
|
dotFillColor = seriesColor
|
||||||
|
}
|
||||||
|
d.Line(points, LineStyle{
|
||||||
|
StrokeColor: seriesColor,
|
||||||
|
StrokeWidth: 2,
|
||||||
|
DotColor: seriesColor,
|
||||||
|
DotWidth: 2,
|
||||||
|
DotFillColor: dotFillColor,
|
||||||
|
})
|
||||||
|
// draw mark point
|
||||||
|
markPointRenderOptions = append(markPointRenderOptions, markPointRenderOption{
|
||||||
|
Draw: d,
|
||||||
|
FillColor: seriesColor,
|
||||||
|
Font: opt.Font,
|
||||||
|
Points: points,
|
||||||
|
Series: &series,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return markPointRenderOptions, nil
|
||||||
|
}
|
||||||
96
line_chart_test.go
Normal file
96
line_chart_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// 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 TestLineChartRender(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
width := 400
|
||||||
|
height := 300
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
})
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
result := basicRenderResult{
|
||||||
|
xRange: &Range{
|
||||||
|
Min: 0,
|
||||||
|
Max: 4,
|
||||||
|
divideCount: 4,
|
||||||
|
Size: width,
|
||||||
|
Boundary: true,
|
||||||
|
},
|
||||||
|
yRangeList: []*Range{
|
||||||
|
{
|
||||||
|
divideCount: 6,
|
||||||
|
Max: 100,
|
||||||
|
Min: 0,
|
||||||
|
Size: height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
d: d,
|
||||||
|
}
|
||||||
|
f, _ := chart.GetDefaultFont()
|
||||||
|
lineChartRender(lineChartOption{
|
||||||
|
Font: f,
|
||||||
|
SeriesList: SeriesList{
|
||||||
|
{
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
Color: drawing.ColorBlue,
|
||||||
|
},
|
||||||
|
MarkLine: NewMarkLine(
|
||||||
|
SeriesMarkDataTypeAverage,
|
||||||
|
),
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
NewSeriesFromValues([]float64{
|
||||||
|
40,
|
||||||
|
60,
|
||||||
|
70,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}, &result)
|
||||||
|
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=\"40\" cy=\"130\" r=\"3\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 45 130\nL 382 130\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 382 125\nL 398 130\nL 382 135\nL 387 130\nL 382 125\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"400\" y=\"134\" style=\"stroke-width:0;stroke:none;fill:rgba(70,70,70,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">56.66</text><text x=\"83\" y=\"235\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text><text x=\"183\" y=\"115\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">60</text><text x=\"283\" y=\"25\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">90</text><path d=\"M 90 240\nL 190 120\nL 290 30\" style=\"stroke-width:2;stroke:rgba(84,112,198,1.0);fill:none\"/><circle cx=\"90\" cy=\"240\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"190\" cy=\"120\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"290\" cy=\"30\" r=\"2\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"M 90 180\nL 190 120\nL 290 90\" style=\"stroke-width:2;stroke:rgba(145,204,117,1.0);fill:none\"/><circle cx=\"90\" cy=\"180\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"190\" cy=\"120\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><circle cx=\"290\" cy=\"90\" r=\"2\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/><path d=\"\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(255,255,255,1.0)\"/></svg>", string(data))
|
||||||
|
}
|
||||||
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))
|
||||||
|
}
|
||||||
92
mark_line.go
Normal file
92
mark_line.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewMarkLine(markLineTypes ...string) SeriesMarkLine {
|
||||||
|
data := make([]SeriesMarkData, len(markLineTypes))
|
||||||
|
for index, t := range markLineTypes {
|
||||||
|
data[index] = SeriesMarkData{
|
||||||
|
Type: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SeriesMarkLine{
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type markLineRenderOption struct {
|
||||||
|
Draw *Draw
|
||||||
|
FillColor drawing.Color
|
||||||
|
FontColor drawing.Color
|
||||||
|
StrokeColor drawing.Color
|
||||||
|
Font *truetype.Font
|
||||||
|
Series *Series
|
||||||
|
Range *Range
|
||||||
|
}
|
||||||
|
|
||||||
|
func markLineRender(opt markLineRenderOption) {
|
||||||
|
d := opt.Draw
|
||||||
|
s := opt.Series
|
||||||
|
if len(s.MarkLine.Data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := d.Render
|
||||||
|
summary := s.Summary()
|
||||||
|
for _, markLine := range s.MarkLine.Data {
|
||||||
|
// 由于mark line会修改style,因此每次重新设置
|
||||||
|
chart.Style{
|
||||||
|
FillColor: opt.FillColor,
|
||||||
|
FontColor: opt.FontColor,
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
StrokeColor: opt.StrokeColor,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
Font: opt.Font,
|
||||||
|
StrokeDashArray: []float64{
|
||||||
|
4,
|
||||||
|
2,
|
||||||
|
},
|
||||||
|
}.WriteToRenderer(r)
|
||||||
|
value := float64(0)
|
||||||
|
switch markLine.Type {
|
||||||
|
case SeriesMarkDataTypeMax:
|
||||||
|
value = summary.MaxValue
|
||||||
|
case SeriesMarkDataTypeMin:
|
||||||
|
value = summary.MinValue
|
||||||
|
default:
|
||||||
|
value = summary.AverageValue
|
||||||
|
}
|
||||||
|
y := opt.Range.getRestHeight(value)
|
||||||
|
width := d.Box.Width()
|
||||||
|
text := commafWithDigits(value)
|
||||||
|
textBox := r.MeasureText(text)
|
||||||
|
d.makeLine(0, y, width-2)
|
||||||
|
d.text(text, width, y+textBox.Height()>>1-2)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
99
mark_line_test.go
Normal file
99
mark_line_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// 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 TestNewMarkLine(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
markLine := NewMarkLine(
|
||||||
|
SeriesMarkDataTypeMax,
|
||||||
|
SeriesMarkDataTypeMin,
|
||||||
|
SeriesMarkDataTypeAverage,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(SeriesMarkLine{
|
||||||
|
Data: []SeriesMarkData{
|
||||||
|
{
|
||||||
|
Type: SeriesMarkDataTypeMax,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: SeriesMarkDataTypeMin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: SeriesMarkDataTypeAverage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, markLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkLineRender(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
}, PaddingOption(chart.Box{
|
||||||
|
Left: 20,
|
||||||
|
Right: 20,
|
||||||
|
}))
|
||||||
|
assert.Nil(err)
|
||||||
|
f, _ := chart.GetDefaultFont()
|
||||||
|
|
||||||
|
markLineRender(markLineRenderOption{
|
||||||
|
Draw: d,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
StrokeColor: drawing.ColorBlack,
|
||||||
|
Font: f,
|
||||||
|
Series: &Series{
|
||||||
|
MarkLine: NewMarkLine(
|
||||||
|
SeriesMarkDataTypeMax,
|
||||||
|
SeriesMarkDataTypeMin,
|
||||||
|
SeriesMarkDataTypeAverage,
|
||||||
|
),
|
||||||
|
Data: NewSeriesDataFromValues([]float64{
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
7,
|
||||||
|
9,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Range: &Range{
|
||||||
|
Min: 0,
|
||||||
|
Max: 10,
|
||||||
|
Size: 200,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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=\"20\" cy=\"20\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 25 20\nL 362 20\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path d=\"M 362 15\nL 378 20\nL 362 25\nL 367 20\nL 362 15\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"380\" y=\"24\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">9</text><circle cx=\"20\" cy=\"180\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 25 180\nL 362 180\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path d=\"M 362 175\nL 378 180\nL 362 185\nL 367 180\nL 362 175\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"380\" y=\"184\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">1</text><circle cx=\"20\" cy=\"100\" r=\"3\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path stroke-dasharray=\"4.0, 2.0\" d=\"M 25 100\nL 362 100\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><path d=\"M 362 95\nL 378 100\nL 362 105\nL 367 100\nL 362 95\" style=\"stroke-width:1;stroke:rgba(0,0,0,1.0);fill:rgba(0,0,0,1.0)\"/><text x=\"380\" y=\"104\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">5</text></svg>", string(data))
|
||||||
|
}
|
||||||
89
mark_point.go
Normal file
89
mark_point.go
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewMarkPoint(markPointTypes ...string) SeriesMarkPoint {
|
||||||
|
data := make([]SeriesMarkData, len(markPointTypes))
|
||||||
|
for index, t := range markPointTypes {
|
||||||
|
data[index] = SeriesMarkData{
|
||||||
|
Type: t,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SeriesMarkPoint{
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type markPointRenderOption struct {
|
||||||
|
Draw *Draw
|
||||||
|
FillColor drawing.Color
|
||||||
|
Font *truetype.Font
|
||||||
|
Series *Series
|
||||||
|
Points []Point
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPointRender(opt markPointRenderOption) {
|
||||||
|
d := opt.Draw
|
||||||
|
s := opt.Series
|
||||||
|
if len(s.MarkPoint.Data) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
points := opt.Points
|
||||||
|
summary := s.Summary()
|
||||||
|
symbolSize := s.MarkPoint.SymbolSize
|
||||||
|
if symbolSize == 0 {
|
||||||
|
symbolSize = 30
|
||||||
|
}
|
||||||
|
r := d.Render
|
||||||
|
// 设置填充样式
|
||||||
|
chart.Style{
|
||||||
|
FillColor: opt.FillColor,
|
||||||
|
}.WriteToRenderer(r)
|
||||||
|
// 设置文本样式
|
||||||
|
chart.Style{
|
||||||
|
FontColor: NewTheme(ThemeDark).GetTextColor(),
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
Font: opt.Font,
|
||||||
|
}.WriteTextOptionsToRenderer(r)
|
||||||
|
for _, markPointData := range s.MarkPoint.Data {
|
||||||
|
p := points[summary.MinIndex]
|
||||||
|
value := summary.MinValue
|
||||||
|
switch markPointData.Type {
|
||||||
|
case SeriesMarkDataTypeMax:
|
||||||
|
p = points[summary.MaxIndex]
|
||||||
|
value = summary.MaxValue
|
||||||
|
}
|
||||||
|
|
||||||
|
d.pin(p.X, p.Y-symbolSize>>1, symbolSize)
|
||||||
|
text := commafWithDigits(value)
|
||||||
|
textBox := r.MeasureText(text)
|
||||||
|
d.text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2)
|
||||||
|
}
|
||||||
|
}
|
||||||
103
mark_point_test.go
Normal file
103
mark_point_test.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// 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 TestNewMarkPoint(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
markPoint := NewMarkPoint(
|
||||||
|
SeriesMarkDataTypeMax,
|
||||||
|
SeriesMarkDataTypeMin,
|
||||||
|
SeriesMarkDataTypeAverage,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(SeriesMarkPoint{
|
||||||
|
Data: []SeriesMarkData{
|
||||||
|
{
|
||||||
|
Type: SeriesMarkDataTypeMax,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: SeriesMarkDataTypeMin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: SeriesMarkDataTypeAverage,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, markPoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMarkPointRender(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
}, PaddingOption(chart.Box{
|
||||||
|
Left: 20,
|
||||||
|
Right: 20,
|
||||||
|
}))
|
||||||
|
assert.Nil(err)
|
||||||
|
f, _ := chart.GetDefaultFont()
|
||||||
|
|
||||||
|
markPointRender(markPointRenderOption{
|
||||||
|
Draw: d,
|
||||||
|
FillColor: drawing.ColorBlack,
|
||||||
|
Font: f,
|
||||||
|
Series: &Series{
|
||||||
|
MarkPoint: NewMarkPoint(
|
||||||
|
SeriesMarkDataTypeMax,
|
||||||
|
SeriesMarkDataTypeMin,
|
||||||
|
),
|
||||||
|
Data: NewSeriesDataFromValues([]float64{
|
||||||
|
1,
|
||||||
|
3,
|
||||||
|
5,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Points: []Point{
|
||||||
|
{
|
||||||
|
X: 1,
|
||||||
|
Y: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 100,
|
||||||
|
Y: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
X: 200,
|
||||||
|
Y: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
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 217 192\nA 15 15 330.00 1 1 223 192\nL 220 178\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><path d=\"M 205 178\nQ220,215 235,178\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><text x=\"216\" y=\"183\" style=\"stroke-width:0;stroke:none;fill:rgba(238,238,238,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">5</text><path d=\"M 18 42\nA 15 15 330.00 1 1 24 42\nL 21 28\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><path d=\"M 6 28\nQ21,65 36,28\nZ\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0)\"/><text x=\"17\" y=\"33\" style=\"stroke-width:0;stroke:none;fill:rgba(238,238,238,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">1</text></svg>", string(data))
|
||||||
|
}
|
||||||
174
pie_chart.go
Normal file
174
pie_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 (
|
||||||
|
"math"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultRadiusPercent = 0.4
|
||||||
|
|
||||||
|
func getPieStyle(theme *Theme, index int) chart.Style {
|
||||||
|
seriesColor := theme.GetSeriesColor(index)
|
||||||
|
return chart.Style{
|
||||||
|
StrokeColor: seriesColor,
|
||||||
|
StrokeWidth: 1,
|
||||||
|
FillColor: seriesColor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type pieChartOption struct {
|
||||||
|
Theme string
|
||||||
|
Font *truetype.Font
|
||||||
|
SeriesList SeriesList
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPieRadius(diameter float64, radiusValue string) float64 {
|
||||||
|
var radius float64
|
||||||
|
if len(radiusValue) != 0 {
|
||||||
|
v := convertPercent(radiusValue)
|
||||||
|
if v != -1 {
|
||||||
|
radius = float64(diameter) * v
|
||||||
|
} else {
|
||||||
|
radius, _ = strconv.ParseFloat(radiusValue, 64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if radius <= 0 {
|
||||||
|
radius = float64(diameter) * defaultRadiusPercent
|
||||||
|
}
|
||||||
|
return radius
|
||||||
|
}
|
||||||
|
|
||||||
|
func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Parent: result.d,
|
||||||
|
}, PaddingOption(chart.Box{
|
||||||
|
Top: result.titleBox.Height(),
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
values := make([]float64, len(opt.SeriesList))
|
||||||
|
total := float64(0)
|
||||||
|
radiusValue := ""
|
||||||
|
for index, series := range opt.SeriesList {
|
||||||
|
if len(series.Radius) != 0 {
|
||||||
|
radiusValue = series.Radius
|
||||||
|
}
|
||||||
|
value := float64(0)
|
||||||
|
for _, item := range series.Data {
|
||||||
|
value += item.Value
|
||||||
|
}
|
||||||
|
values[index] = value
|
||||||
|
total += value
|
||||||
|
}
|
||||||
|
r := d.Render
|
||||||
|
theme := NewTheme(opt.Theme)
|
||||||
|
|
||||||
|
box := d.Box
|
||||||
|
cx := box.Width() >> 1
|
||||||
|
cy := box.Height() >> 1
|
||||||
|
|
||||||
|
diameter := chart.MinInt(box.Width(), box.Height())
|
||||||
|
radius := getPieRadius(float64(diameter), radiusValue)
|
||||||
|
|
||||||
|
labelLineWidth := 15
|
||||||
|
if radius < 50 {
|
||||||
|
labelLineWidth = 10
|
||||||
|
}
|
||||||
|
labelRadius := radius + float64(labelLineWidth)
|
||||||
|
|
||||||
|
seriesNames := opt.SeriesList.Names()
|
||||||
|
|
||||||
|
if len(values) == 1 {
|
||||||
|
getPieStyle(theme, 0).WriteToRenderer(r)
|
||||||
|
d.moveTo(cx, cy)
|
||||||
|
d.circle(radius, cx, cy)
|
||||||
|
} else {
|
||||||
|
currentValue := float64(0)
|
||||||
|
for index, v := range values {
|
||||||
|
|
||||||
|
pieStyle := getPieStyle(theme, index)
|
||||||
|
pieStyle.WriteToRenderer(r)
|
||||||
|
d.moveTo(cx, cy)
|
||||||
|
start := chart.PercentToRadians(currentValue/total) - math.Pi/2
|
||||||
|
currentValue += v
|
||||||
|
percent := (v / total)
|
||||||
|
delta := chart.PercentToRadians(percent)
|
||||||
|
d.arcTo(cx, cy, radius, radius, start, delta)
|
||||||
|
d.lineTo(cx, cy)
|
||||||
|
r.Close()
|
||||||
|
r.FillStroke()
|
||||||
|
|
||||||
|
series := opt.SeriesList[index]
|
||||||
|
// 是否显示label
|
||||||
|
showLabel := series.Label.Show
|
||||||
|
if !showLabel {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// label的角度为饼块中间
|
||||||
|
angle := start + delta/2
|
||||||
|
startx := cx + int(radius*math.Cos(angle))
|
||||||
|
starty := cy + int(radius*math.Sin(angle))
|
||||||
|
|
||||||
|
endx := cx + int(labelRadius*math.Cos(angle))
|
||||||
|
endy := cy + int(labelRadius*math.Sin(angle))
|
||||||
|
d.moveTo(startx, starty)
|
||||||
|
d.lineTo(endx, endy)
|
||||||
|
offset := labelLineWidth
|
||||||
|
if endx < cx {
|
||||||
|
offset *= -1
|
||||||
|
}
|
||||||
|
d.moveTo(endx, endy)
|
||||||
|
endx += offset
|
||||||
|
d.lineTo(endx, endy)
|
||||||
|
r.Stroke()
|
||||||
|
textStyle := chart.Style{
|
||||||
|
FontColor: theme.GetTextColor(),
|
||||||
|
FontSize: labelFontSize,
|
||||||
|
Font: opt.Font,
|
||||||
|
}
|
||||||
|
if !series.Label.Color.IsZero() {
|
||||||
|
textStyle.FontColor = series.Label.Color
|
||||||
|
}
|
||||||
|
textStyle.GetTextOptions().WriteToRenderer(r)
|
||||||
|
text := NewPieLabelFormatter(seriesNames, series.Label.Formatter)(index, v, percent)
|
||||||
|
textBox := r.MeasureText(text)
|
||||||
|
textMargin := 3
|
||||||
|
x := endx + textMargin
|
||||||
|
y := endy + textBox.Height()>>1 - 1
|
||||||
|
if offset < 0 {
|
||||||
|
textWidth := textBox.Width()
|
||||||
|
x = endx - textWidth - textMargin
|
||||||
|
}
|
||||||
|
d.text(text, x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
75
pie_chart_test.go
Normal file
75
pie_chart_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// 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 TestGetPieRadius(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(50.0, getPieRadius(100, "50%"))
|
||||||
|
assert.Equal(30.0, getPieRadius(100, "30"))
|
||||||
|
assert.Equal(40.0, getPieRadius(100, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPieChartRender(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Width: 250,
|
||||||
|
Height: 150,
|
||||||
|
})
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
f, _ := chart.GetDefaultFont()
|
||||||
|
|
||||||
|
err = pieChartRender(pieChartOption{
|
||||||
|
Font: f,
|
||||||
|
SeriesList: NewPieSeriesList([]float64{
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
}, PieSeriesOption{
|
||||||
|
Names: []string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
},
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
Color: drawing.ColorRed,
|
||||||
|
},
|
||||||
|
Radius: "20%",
|
||||||
|
}),
|
||||||
|
}, &basicRenderResult{
|
||||||
|
d: d,
|
||||||
|
})
|
||||||
|
assert.Nil(err)
|
||||||
|
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=\"250\" height=\"150\">\\n<path d=\"M 125 75\nL 125 45\nA 30 30 120.00 0 1 150 89\nL 125 75\nZ\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><path d=\"M 150 60\nL 159 55\nM 159 55\nL 169 55\" style=\"stroke-width:1;stroke:rgba(84,112,198,1.0);fill:rgba(84,112,198,1.0)\"/><text x=\"172\" y=\"60\" style=\"stroke-width:0;stroke:none;fill:rgba(255,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">a: 33.33%</text><path d=\"M 125 75\nL 150 89\nA 30 30 240.00 1 1 125 45\nL 125 75\nZ\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><path d=\"M 100 90\nL 91 95\nM 91 95\nL 81 95\" style=\"stroke-width:1;stroke:rgba(145,204,117,1.0);fill:rgba(145,204,117,1.0)\"/><text x=\"22\" y=\"100\" style=\"stroke-width:0;stroke:none;fill:rgba(255,0,0,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">b: 66.66%</text></svg>", string(data))
|
||||||
|
}
|
||||||
112
range.go
112
range.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -24,70 +24,86 @@ package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Range struct {
|
type Range struct {
|
||||||
TickPosition chart.TickPosition
|
divideCount int
|
||||||
chart.ContinuousRange
|
Min float64
|
||||||
|
Max float64
|
||||||
|
Size int
|
||||||
|
Boundary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapRange(r chart.Range, tickPosition chart.TickPosition) chart.Range {
|
func NewRange(min, max float64, divideCount int) Range {
|
||||||
xr, ok := r.(*chart.ContinuousRange)
|
r := math.Abs(max - min)
|
||||||
if !ok {
|
|
||||||
return r
|
// 最小单位计算
|
||||||
|
unit := 2
|
||||||
|
if r > 10 {
|
||||||
|
unit = 4
|
||||||
}
|
}
|
||||||
return &Range{
|
if r > 30 {
|
||||||
TickPosition: tickPosition,
|
unit = 5
|
||||||
ContinuousRange: *xr,
|
}
|
||||||
|
if r > 100 {
|
||||||
|
unit = 10
|
||||||
|
}
|
||||||
|
if r > 200 {
|
||||||
|
unit = 20
|
||||||
|
}
|
||||||
|
unit = int((r/float64(divideCount))/float64(unit))*unit + unit
|
||||||
|
|
||||||
|
if min != 0 {
|
||||||
|
isLessThanZero := min < 0
|
||||||
|
min = float64(int(min/float64(unit)) * unit)
|
||||||
|
// 如果是小于0,int的时候向上取整了,因此调整
|
||||||
|
if min < 0 ||
|
||||||
|
(isLessThanZero && min == 0) {
|
||||||
|
min -= float64(unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
max = min + float64(unit*divideCount)
|
||||||
|
return Range{
|
||||||
|
Min: min,
|
||||||
|
Max: max,
|
||||||
|
divideCount: divideCount,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Translate maps a given value into the ContinuousRange space.
|
func (r Range) Values() []string {
|
||||||
func (r Range) Translate(value float64) int {
|
offset := (r.Max - r.Min) / float64(r.divideCount)
|
||||||
v := r.ContinuousRange.Translate(value)
|
values := make([]string, 0)
|
||||||
if r.TickPosition == chart.TickPositionBetweenTicks {
|
for i := 0; i <= r.divideCount; i++ {
|
||||||
v -= int(float64(r.Domain) / (r.GetDelta() * 2))
|
v := r.Min + float64(i)*offset
|
||||||
|
value := commafWithDigits(v)
|
||||||
|
values = append(values, value)
|
||||||
}
|
}
|
||||||
return v
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
type HiddenRange struct {
|
func (r *Range) getHeight(value float64) int {
|
||||||
chart.ContinuousRange
|
v := (value - r.Min) / (r.Max - r.Min)
|
||||||
|
return int(v * float64(r.Size))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r HiddenRange) GetDelta() float64 {
|
func (r *Range) getRestHeight(value float64) int {
|
||||||
return 0
|
return r.Size - r.getHeight(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Y轴使用的continuous range
|
func (r *Range) GetRange(index int) (float64, float64) {
|
||||||
// min 与max只允许设置一次
|
unit := float64(r.Size) / float64(r.divideCount)
|
||||||
// 如果是计算得出的max,增加20%的值并取整
|
return unit * float64(index), unit * float64(index+1)
|
||||||
type YContinuousRange struct {
|
}
|
||||||
chart.ContinuousRange
|
func (r *Range) AutoDivide() []int {
|
||||||
|
return autoDivide(r.Size, r.divideCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m YContinuousRange) IsZero() bool {
|
func (r *Range) getWidth(value float64) int {
|
||||||
// 默认返回true,允许修改
|
v := value / (r.Max - r.Min)
|
||||||
return true
|
// 移至居中
|
||||||
}
|
if r.Boundary &&
|
||||||
|
r.divideCount != 0 {
|
||||||
func (m *YContinuousRange) SetMin(min float64) {
|
v += 1 / float64(r.divideCount*2)
|
||||||
// 如果已修改,则忽略
|
|
||||||
if m.Min != -math.MaxFloat64 {
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
m.Min = min
|
return int(v * float64(r.Size))
|
||||||
}
|
|
||||||
|
|
||||||
func (m *YContinuousRange) SetMax(max float64) {
|
|
||||||
// 如果已修改,则忽略
|
|
||||||
if m.Max != math.MaxFloat64 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 此处为计算得来的最大值,放大20%
|
|
||||||
v := int(max * 1.2)
|
|
||||||
// TODO 是否要取整十整百
|
|
||||||
m.Max = float64(v)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,55 +23,72 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRange(t *testing.T) {
|
func TestRange(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
r := NewRange(0, 8, 6)
|
||||||
|
assert.Equal(0.0, r.Min)
|
||||||
|
assert.Equal(12.0, r.Max)
|
||||||
|
|
||||||
|
r = NewRange(0, 12, 6)
|
||||||
|
assert.Equal(0.0, r.Min)
|
||||||
|
assert.Equal(24.0, r.Max)
|
||||||
|
|
||||||
|
r = NewRange(-13, 18, 6)
|
||||||
|
assert.Equal(-20.0, r.Min)
|
||||||
|
assert.Equal(40.0, r.Max)
|
||||||
|
|
||||||
|
r = NewRange(0, 150, 6)
|
||||||
|
assert.Equal(0.0, r.Min)
|
||||||
|
assert.Equal(180.0, r.Max)
|
||||||
|
|
||||||
|
r = NewRange(0, 400, 6)
|
||||||
|
assert.Equal(0.0, r.Min)
|
||||||
|
assert.Equal(480.0, r.Max)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRangeHeightWidth(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
r := NewRange(0, 8, 6)
|
||||||
|
r.Size = 100
|
||||||
|
|
||||||
|
assert.Equal(33, r.getHeight(4))
|
||||||
|
assert.Equal(67, r.getRestHeight(4))
|
||||||
|
|
||||||
|
assert.Equal(33, r.getWidth(4))
|
||||||
|
r.Boundary = true
|
||||||
|
assert.Equal(41, r.getWidth(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRangeGetRange(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
r := NewRange(0, 8, 6)
|
||||||
|
r.Size = 120
|
||||||
|
|
||||||
|
f1, f2 := r.GetRange(0)
|
||||||
|
assert.Equal(0.0, f1)
|
||||||
|
assert.Equal(20.0, f2)
|
||||||
|
|
||||||
|
f1, f2 = r.GetRange(2)
|
||||||
|
assert.Equal(40.0, f1)
|
||||||
|
assert.Equal(60.0, f2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRangeAutoDivide(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
r := Range{
|
r := Range{
|
||||||
ContinuousRange: chart.ContinuousRange{
|
Size: 120,
|
||||||
Min: 0,
|
divideCount: 6,
|
||||||
Max: 5,
|
|
||||||
Domain: 500,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(100, r.Translate(1))
|
assert.Equal([]int{0, 20, 40, 60, 80, 100, 120}, r.AutoDivide())
|
||||||
|
|
||||||
r.TickPosition = chart.TickPositionBetweenTicks
|
r.Size = 130
|
||||||
assert.Equal(50, r.Translate(1))
|
assert.Equal([]int{0, 22, 44, 66, 88, 109, 130}, r.AutoDivide())
|
||||||
}
|
|
||||||
|
|
||||||
func TestHiddenRange(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())
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
282
series.go
282
series.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,113 +23,207 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SeriesData struct {
|
type SeriesData struct {
|
||||||
|
// The value of series data
|
||||||
Value float64
|
Value float64
|
||||||
|
// The style of series data
|
||||||
Style chart.Style
|
Style chart.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
type Series struct {
|
func NewSeriesFromValues(values []float64, chartType ...string) Series {
|
||||||
Type string
|
s := Series{
|
||||||
Name string
|
Data: NewSeriesDataFromValues(values),
|
||||||
Data []SeriesData
|
}
|
||||||
XValues []float64
|
if len(chartType) != 0 {
|
||||||
YAxisIndex int
|
s.Type = chartType[0]
|
||||||
Style chart.Style
|
}
|
||||||
Label SeriesLabel
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
const lineStrokeWidth = 2
|
func NewSeriesDataFromValues(values []float64) []SeriesData {
|
||||||
const dotWith = 2
|
data := make([]SeriesData, len(values))
|
||||||
|
|
||||||
const (
|
|
||||||
SeriesBar = "bar"
|
|
||||||
SeriesLine = "line"
|
|
||||||
SeriesPie = "pie"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewSeriesDataListFromFloat(values []float64) []SeriesData {
|
|
||||||
dataList := make([]SeriesData, len(values))
|
|
||||||
for index, value := range values {
|
for index, value := range values {
|
||||||
dataList[index] = SeriesData{
|
data[index] = SeriesData{
|
||||||
Value: value,
|
Value: value,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dataList
|
return data
|
||||||
}
|
}
|
||||||
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 {
|
type SeriesLabel struct {
|
||||||
style := chart.Style{
|
// Data label formatter, which supports string template.
|
||||||
StrokeWidth: lineStrokeWidth,
|
// {b}: the name of a data item.
|
||||||
StrokeColor: getSeriesColor(theme, index),
|
// {c}: the value of a data item.
|
||||||
// TODO 调整为通过dot with color 生成
|
// {d}: the percent of a data item(pie chart).
|
||||||
DotColor: getSeriesColor(theme, index),
|
Formatter string
|
||||||
DotWidth: dotWith,
|
// The color for label
|
||||||
FontColor: getAxisColor(theme),
|
Color drawing.Color
|
||||||
}
|
// Show flag for label
|
||||||
if !item.Style.StrokeColor.IsZero() {
|
Show bool
|
||||||
style.StrokeColor = item.Style.StrokeColor
|
// Distance to the host graphic element.
|
||||||
style.DotColor = item.Style.StrokeColor
|
Distance int
|
||||||
}
|
}
|
||||||
pointIndexOffset := 0
|
|
||||||
// 如果居中,需要多增加一个点
|
const (
|
||||||
if tickPosition == chart.TickPositionBetweenTicks {
|
SeriesMarkDataTypeMax = "max"
|
||||||
item.Data = append([]SeriesData{
|
SeriesMarkDataTypeMin = "min"
|
||||||
{
|
SeriesMarkDataTypeAverage = "average"
|
||||||
Value: 0.0,
|
)
|
||||||
},
|
|
||||||
}, item.Data...)
|
type SeriesMarkData struct {
|
||||||
pointIndexOffset = -1
|
// The mark data type, it can be "max", "min", "average".
|
||||||
}
|
// The "average" is only for mark line
|
||||||
yValues := make([]float64, len(item.Data))
|
Type string
|
||||||
barCustomStyles := make([]BarSeriesCustomStyle, 0)
|
}
|
||||||
for i, item := range item.Data {
|
type SeriesMarkPoint struct {
|
||||||
yValues[i] = item.Value
|
// The width of symbol, default value is 30
|
||||||
if !item.Style.IsZero() {
|
SymbolSize int
|
||||||
barCustomStyles = append(barCustomStyles, BarSeriesCustomStyle{
|
// The mark data of series mark point
|
||||||
PointIndex: i + pointIndexOffset,
|
Data []SeriesMarkData
|
||||||
Index: barIndex,
|
}
|
||||||
Style: item.Style,
|
type SeriesMarkLine struct {
|
||||||
})
|
// The mark data of series mark line
|
||||||
}
|
Data []SeriesMarkData
|
||||||
}
|
}
|
||||||
baseSeries := BaseSeries{
|
type Series struct {
|
||||||
Name: item.Name,
|
index int
|
||||||
XValues: item.XValues,
|
// The type of series, it can be "line", "bar" or "pie".
|
||||||
Style: style,
|
// Default value is "line"
|
||||||
YValues: yValues,
|
Type string
|
||||||
TickPosition: tickPosition,
|
// The data list of series
|
||||||
YAxis: chart.YAxisSecondary,
|
Data []SeriesData
|
||||||
Label: item.Label,
|
// The Y axis index, it should be 0 or 1.
|
||||||
}
|
// Default value is 1
|
||||||
if item.YAxisIndex != 0 {
|
YAxisIndex int
|
||||||
baseSeries.YAxis = chart.YAxisPrimary
|
// The style for series
|
||||||
}
|
Style chart.Style
|
||||||
switch item.Type {
|
// The label for series
|
||||||
case SeriesBar:
|
Label SeriesLabel
|
||||||
arr[index] = BarSeries{
|
// The name of series
|
||||||
Count: barCount,
|
Name string
|
||||||
Index: barIndex,
|
// Radius for Pie chart, e.g.: 40%, default is "40%"
|
||||||
BaseSeries: baseSeries,
|
Radius string
|
||||||
CustomStyles: barCustomStyles,
|
// Mark point for series
|
||||||
}
|
MarkPoint SeriesMarkPoint
|
||||||
barIndex++
|
// Make line for series
|
||||||
default:
|
MarkLine SeriesMarkLine
|
||||||
arr[index] = LineSeries{
|
}
|
||||||
BaseSeries: baseSeries,
|
type SeriesList []Series
|
||||||
}
|
|
||||||
}
|
type PieSeriesOption struct {
|
||||||
}
|
Radius string
|
||||||
return arr
|
Label SeriesLabel
|
||||||
|
Names []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPieSeriesList(values []float64, opts ...PieSeriesOption) []Series {
|
||||||
|
result := make([]Series, len(values))
|
||||||
|
var opt PieSeriesOption
|
||||||
|
if len(opts) != 0 {
|
||||||
|
opt = opts[0]
|
||||||
|
}
|
||||||
|
for index, v := range values {
|
||||||
|
name := ""
|
||||||
|
if index < len(opt.Names) {
|
||||||
|
name = opt.Names[index]
|
||||||
|
}
|
||||||
|
s := Series{
|
||||||
|
Type: ChartTypePie,
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: v,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Radius: opt.Radius,
|
||||||
|
Label: opt.Label,
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
result[index] = s
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
type seriesSummary struct {
|
||||||
|
MaxIndex int
|
||||||
|
MaxValue float64
|
||||||
|
MinIndex int
|
||||||
|
MinValue float64
|
||||||
|
AverageValue float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Series) Summary() seriesSummary {
|
||||||
|
minIndex := -1
|
||||||
|
maxIndex := -1
|
||||||
|
minValue := math.MaxFloat64
|
||||||
|
maxValue := -math.MaxFloat64
|
||||||
|
sum := float64(0)
|
||||||
|
for j, item := range s.Data {
|
||||||
|
if item.Value < minValue {
|
||||||
|
minIndex = j
|
||||||
|
minValue = item.Value
|
||||||
|
}
|
||||||
|
if item.Value > maxValue {
|
||||||
|
maxIndex = j
|
||||||
|
maxValue = item.Value
|
||||||
|
}
|
||||||
|
sum += item.Value
|
||||||
|
}
|
||||||
|
return seriesSummary{
|
||||||
|
MaxIndex: maxIndex,
|
||||||
|
MaxValue: maxValue,
|
||||||
|
MinIndex: minIndex,
|
||||||
|
MinValue: minValue,
|
||||||
|
AverageValue: sum / float64(len(s.Data)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sl SeriesList) Names() []string {
|
||||||
|
names := make([]string, len(sl))
|
||||||
|
for index, s := range sl {
|
||||||
|
names[index] = s.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
type LabelFormatter func(index int, value float64, percent float64) string
|
||||||
|
|
||||||
|
func NewPieLabelFormatter(seriesNames []string, layout string) LabelFormatter {
|
||||||
|
if len(layout) == 0 {
|
||||||
|
layout = "{b}: {d}"
|
||||||
|
}
|
||||||
|
return NewLabelFormatter(seriesNames, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewValueLabelFormater(seriesNames []string, layout string) LabelFormatter {
|
||||||
|
if len(layout) == 0 {
|
||||||
|
layout = "{c}"
|
||||||
|
}
|
||||||
|
return NewLabelFormatter(seriesNames, layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLabelFormatter(seriesNames []string, layout string) LabelFormatter {
|
||||||
|
return func(index int, value, percent float64) string {
|
||||||
|
// 如果无percent的则设置为<0
|
||||||
|
percentText := ""
|
||||||
|
if percent >= 0 {
|
||||||
|
percentText = humanize.FtoaWithDigits(percent*100, 2) + "%"
|
||||||
|
}
|
||||||
|
valueText := humanize.FtoaWithDigits(value, 2)
|
||||||
|
name := ""
|
||||||
|
if len(seriesNames) > index {
|
||||||
|
name = seriesNames[index]
|
||||||
|
}
|
||||||
|
text := strings.ReplaceAll(layout, "{c}", valueText)
|
||||||
|
text = strings.ReplaceAll(text, "{d}", percentText)
|
||||||
|
text = strings.ReplaceAll(text, "{b}", name)
|
||||||
|
return text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
191
series_test.go
191
series_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -26,10 +26,28 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSeriesDataListFromFloat(t *testing.T) {
|
func TestNewSeriesFromValues(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(Series{
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Type: ChartTypeBar,
|
||||||
|
}, NewSeriesFromValues([]float64{
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
}, ChartTypeBar))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSeriesDataFromValues(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
assert.Equal([]SeriesData{
|
assert.Equal([]SeriesData{
|
||||||
|
|
@ -39,87 +57,110 @@ func TestNewSeriesDataListFromFloat(t *testing.T) {
|
||||||
{
|
{
|
||||||
Value: 2,
|
Value: 2,
|
||||||
},
|
},
|
||||||
}, NewSeriesDataListFromFloat([]float64{
|
}, NewSeriesDataFromValues([]float64{
|
||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSeries(t *testing.T) {
|
func TestNewPieSeriesList(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
xValues := []float64{
|
assert.Equal([]Series{
|
||||||
|
{
|
||||||
|
Type: ChartTypePie,
|
||||||
|
Name: "a",
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
},
|
||||||
|
Radius: "30%",
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: ChartTypePie,
|
||||||
|
Name: "b",
|
||||||
|
Label: SeriesLabel{
|
||||||
|
Show: true,
|
||||||
|
},
|
||||||
|
Radius: "30%",
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, NewPieSeriesList([]float64{
|
||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
3,
|
}, PieSeriesOption{
|
||||||
4,
|
Radius: "30%",
|
||||||
5,
|
Label: SeriesLabel{
|
||||||
}
|
Show: true,
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
{
|
Names: []string{
|
||||||
Data: NewSeriesDataListFromFloat([]float64{
|
"a",
|
||||||
11,
|
"b",
|
||||||
21,
|
|
||||||
31,
|
|
||||||
41,
|
|
||||||
51,
|
|
||||||
}),
|
|
||||||
XValues: xValues,
|
|
||||||
},
|
},
|
||||||
}, chart.TickPositionBetweenTicks, "")
|
}))
|
||||||
|
}
|
||||||
assert.Equal(seriesList[0].GetYAxis(), chart.YAxisPrimary)
|
|
||||||
assert.Equal(seriesList[1].GetYAxis(), chart.YAxisSecondary)
|
func TestSeriesSummary(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
barSeries, ok := seriesList[0].(BarSeries)
|
|
||||||
assert.True(ok)
|
s := Series{
|
||||||
// 居中前置多插入一个点
|
Data: NewSeriesDataFromValues([]float64{
|
||||||
assert.Equal([]float64{
|
1,
|
||||||
0,
|
3,
|
||||||
10,
|
5,
|
||||||
20,
|
7,
|
||||||
30,
|
9,
|
||||||
40,
|
}),
|
||||||
50,
|
}
|
||||||
}, barSeries.YValues)
|
assert.Equal(seriesSummary{
|
||||||
assert.Equal(xValues, barSeries.XValues)
|
MaxIndex: 4,
|
||||||
assert.Equal(1, barSeries.Count)
|
MaxValue: 9,
|
||||||
assert.Equal(0, barSeries.Index)
|
MinIndex: 0,
|
||||||
assert.Equal([]BarSeriesCustomStyle{
|
MinValue: 1,
|
||||||
{
|
AverageValue: 5,
|
||||||
PointIndex: 1,
|
}, s.Summary())
|
||||||
Index: 0,
|
}
|
||||||
Style: barData[1].Style,
|
|
||||||
},
|
func TestGetSeriesNames(t *testing.T) {
|
||||||
}, barSeries.CustomStyles)
|
assert := assert.New(t)
|
||||||
|
|
||||||
lineSeries, ok := seriesList[1].(LineSeries)
|
sl := SeriesList{
|
||||||
assert.True(ok)
|
{
|
||||||
// 居中前置多插入一个点
|
Name: "a",
|
||||||
assert.Equal([]float64{
|
},
|
||||||
0,
|
{
|
||||||
11,
|
Name: "b",
|
||||||
21,
|
},
|
||||||
31,
|
}
|
||||||
41,
|
assert.Equal([]string{
|
||||||
51,
|
"a",
|
||||||
}, lineSeries.YValues)
|
"b",
|
||||||
assert.Equal(xValues, lineSeries.XValues)
|
}, sl.Names())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewPieLabelFormatter(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
fn := NewPieLabelFormatter([]string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
}, "")
|
||||||
|
assert.Equal("a: 35%", fn(0, 1.2, 0.35))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewValueLabelFormater(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
fn := NewValueLabelFormater([]string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
}, "")
|
||||||
|
assert.Equal("1.2", fn(0, 1.2, 0.35))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
287
theme.go
287
theme.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,200 +23,177 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
var hiddenColor = drawing.Color{R: 255, G: 255, B: 255, A: 0}
|
const ThemeDark = "dark"
|
||||||
|
const ThemeLight = "light"
|
||||||
|
const ThemeGrafana = "grafana"
|
||||||
|
|
||||||
var AxisColorLight = drawing.Color{
|
type Theme struct {
|
||||||
R: 110,
|
palette *themeColorPalette
|
||||||
G: 112,
|
|
||||||
B: 121,
|
|
||||||
A: 255,
|
|
||||||
}
|
}
|
||||||
var AxisColorDark = drawing.Color{
|
|
||||||
|
type themeColorPalette struct {
|
||||||
|
isDarkMode bool
|
||||||
|
axisStrokeColor drawing.Color
|
||||||
|
axisSplitLineColor drawing.Color
|
||||||
|
backgroundColor drawing.Color
|
||||||
|
textColor drawing.Color
|
||||||
|
seriesColors []drawing.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
var palettes = map[string]*themeColorPalette{}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
echartSeriesColors := []drawing.Color{
|
||||||
|
parseColor("#5470c6"),
|
||||||
|
parseColor("#91cc75"),
|
||||||
|
parseColor("#fac858"),
|
||||||
|
parseColor("#ee6666"),
|
||||||
|
parseColor("#73c0de"),
|
||||||
|
parseColor("#3ba272"),
|
||||||
|
parseColor("#fc8452"),
|
||||||
|
parseColor("#9a60b4"),
|
||||||
|
parseColor("#ea7ccc"),
|
||||||
|
}
|
||||||
|
grafanaSeriesColors := []drawing.Color{
|
||||||
|
parseColor("#7EB26D"),
|
||||||
|
parseColor("#EAB839"),
|
||||||
|
parseColor("#6ED0E0"),
|
||||||
|
parseColor("#EF843C"),
|
||||||
|
parseColor("#E24D42"),
|
||||||
|
parseColor("#1F78C1"),
|
||||||
|
parseColor("#705DA0"),
|
||||||
|
parseColor("#508642"),
|
||||||
|
}
|
||||||
|
AddTheme(
|
||||||
|
ThemeDark,
|
||||||
|
true,
|
||||||
|
drawing.Color{
|
||||||
R: 185,
|
R: 185,
|
||||||
G: 184,
|
G: 184,
|
||||||
B: 206,
|
B: 206,
|
||||||
A: 255,
|
A: 255,
|
||||||
}
|
},
|
||||||
|
drawing.Color{
|
||||||
var GridColorDark = drawing.Color{
|
|
||||||
R: 72,
|
R: 72,
|
||||||
G: 71,
|
G: 71,
|
||||||
B: 83,
|
B: 83,
|
||||||
A: 255,
|
A: 255,
|
||||||
}
|
},
|
||||||
|
drawing.Color{
|
||||||
var GridColorLight = drawing.Color{
|
|
||||||
R: 224,
|
|
||||||
G: 230,
|
|
||||||
B: 241,
|
|
||||||
A: 255,
|
|
||||||
}
|
|
||||||
|
|
||||||
var BackgroundColorDark = drawing.Color{
|
|
||||||
R: 16,
|
R: 16,
|
||||||
G: 12,
|
G: 12,
|
||||||
B: 42,
|
B: 42,
|
||||||
A: 255,
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
var SeriesColorsLight = []drawing.Color{
|
|
||||||
{
|
|
||||||
R: 84,
|
|
||||||
G: 112,
|
|
||||||
B: 198,
|
|
||||||
A: 255,
|
|
||||||
},
|
},
|
||||||
{
|
drawing.Color{
|
||||||
R: 145,
|
|
||||||
G: 204,
|
|
||||||
B: 117,
|
|
||||||
A: 255,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
R: 250,
|
|
||||||
G: 200,
|
|
||||||
B: 88,
|
|
||||||
A: 255,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
R: 238,
|
R: 238,
|
||||||
G: 102,
|
G: 238,
|
||||||
B: 102,
|
B: 238,
|
||||||
A: 255,
|
A: 255,
|
||||||
},
|
},
|
||||||
{
|
echartSeriesColors,
|
||||||
R: 115,
|
)
|
||||||
G: 192,
|
|
||||||
B: 222,
|
AddTheme(
|
||||||
|
ThemeLight,
|
||||||
|
false,
|
||||||
|
drawing.Color{
|
||||||
|
R: 110,
|
||||||
|
G: 112,
|
||||||
|
B: 121,
|
||||||
A: 255,
|
A: 255,
|
||||||
},
|
},
|
||||||
|
drawing.Color{
|
||||||
|
R: 224,
|
||||||
|
G: 230,
|
||||||
|
B: 242,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
drawing.ColorWhite,
|
||||||
|
drawing.Color{
|
||||||
|
R: 70,
|
||||||
|
G: 70,
|
||||||
|
B: 70,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
echartSeriesColors,
|
||||||
|
)
|
||||||
|
AddTheme(
|
||||||
|
ThemeGrafana,
|
||||||
|
true,
|
||||||
|
drawing.Color{
|
||||||
|
R: 185,
|
||||||
|
G: 184,
|
||||||
|
B: 206,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
drawing.Color{
|
||||||
|
R: 68,
|
||||||
|
G: 67,
|
||||||
|
B: 67,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
drawing.Color{
|
||||||
|
R: 31,
|
||||||
|
G: 29,
|
||||||
|
B: 29,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
drawing.Color{
|
||||||
|
R: 216,
|
||||||
|
G: 217,
|
||||||
|
B: 218,
|
||||||
|
A: 255,
|
||||||
|
},
|
||||||
|
grafanaSeriesColors,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBackgroundColor(theme string) drawing.Color {
|
func AddTheme(name string, isDarkMode bool, axisStrokeColor, axisSplitLineColor, backgroundColor, textColor drawing.Color, seriesColors []drawing.Color) {
|
||||||
if theme == ThemeDark {
|
palettes[name] = &themeColorPalette{
|
||||||
return BackgroundColorDark
|
isDarkMode: isDarkMode,
|
||||||
|
axisStrokeColor: axisStrokeColor,
|
||||||
|
axisSplitLineColor: axisSplitLineColor,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
textColor: textColor,
|
||||||
|
seriesColors: seriesColors,
|
||||||
}
|
}
|
||||||
return chart.DefaultBackgroundColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getTextColor(theme string) drawing.Color {
|
func NewTheme(name string) *Theme {
|
||||||
if theme == ThemeDark {
|
p, ok := palettes[name]
|
||||||
return TextColorDark
|
if !ok {
|
||||||
|
p = palettes[ThemeLight]
|
||||||
}
|
}
|
||||||
return chart.DefaultTextColor
|
return &Theme{
|
||||||
}
|
palette: p,
|
||||||
|
|
||||||
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 {
|
func (t *Theme) IsDark() bool {
|
||||||
return chart.DefaultCanvasStrokeColor
|
return t.palette.isDarkMode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tp ThemeColorPalette) AxisStrokeColor() drawing.Color {
|
func (t *Theme) GetAxisStrokeColor() drawing.Color {
|
||||||
if tp.Theme == ThemeDark {
|
return t.palette.axisStrokeColor
|
||||||
return BackgroundColorDark
|
|
||||||
}
|
|
||||||
return chart.DefaultAxisColor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tp ThemeColorPalette) TextColor() drawing.Color {
|
func (t *Theme) GetAxisSplitLineColor() drawing.Color {
|
||||||
return getTextColor(tp.Theme)
|
return t.palette.axisSplitLineColor
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tp ThemeColorPalette) GetSeriesColor(index int) drawing.Color {
|
func (t *Theme) GetSeriesColor(index int) drawing.Color {
|
||||||
return getSeriesColor(tp.Theme, index)
|
colors := t.palette.seriesColors
|
||||||
|
return colors[index%len(colors)]
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSeriesColor(theme string, index int) drawing.Color {
|
func (t *Theme) GetBackgroundColor() drawing.Color {
|
||||||
return SeriesColorsLight[index%len(SeriesColorsLight)]
|
return t.palette.backgroundColor
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseColor(color string) drawing.Color {
|
func (t *Theme) GetTextColor() drawing.Color {
|
||||||
c := drawing.Color{}
|
return t.palette.textColor
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
theme_test.go
123
theme_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -26,97 +26,62 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestThemeColors(t *testing.T) {
|
func TestTheme(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
assert.Equal(AxisColorDark, getAxisColor(ThemeDark))
|
darkTheme := NewTheme(ThemeDark)
|
||||||
assert.Equal(AxisColorLight, getAxisColor(""))
|
lightTheme := NewTheme(ThemeLight)
|
||||||
|
|
||||||
assert.Equal(GridColorDark, getGridColor(ThemeDark))
|
assert.True(darkTheme.IsDark())
|
||||||
assert.Equal(GridColorLight, getGridColor(""))
|
assert.False(lightTheme.IsDark())
|
||||||
|
|
||||||
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{
|
assert.Equal(drawing.Color{
|
||||||
R: 51,
|
R: 185,
|
||||||
G: 51,
|
G: 184,
|
||||||
B: 51,
|
B: 206,
|
||||||
A: 255,
|
A: 255,
|
||||||
}, c)
|
}, darkTheme.GetAxisStrokeColor())
|
||||||
|
|
||||||
c = parseColor("#313233")
|
|
||||||
assert.Equal(drawing.Color{
|
assert.Equal(drawing.Color{
|
||||||
R: 49,
|
R: 110,
|
||||||
G: 50,
|
G: 112,
|
||||||
B: 51,
|
B: 121,
|
||||||
A: 255,
|
A: 255,
|
||||||
}, c)
|
}, lightTheme.GetAxisStrokeColor())
|
||||||
|
|
||||||
c = parseColor("rgb(31,32,33)")
|
|
||||||
assert.Equal(drawing.Color{
|
assert.Equal(drawing.Color{
|
||||||
R: 31,
|
R: 72,
|
||||||
G: 32,
|
G: 71,
|
||||||
B: 33,
|
B: 83,
|
||||||
A: 255,
|
A: 255,
|
||||||
}, c)
|
}, darkTheme.GetAxisSplitLineColor())
|
||||||
|
|
||||||
c = parseColor("rgba(50,51,52,250)")
|
|
||||||
assert.Equal(drawing.Color{
|
assert.Equal(drawing.Color{
|
||||||
R: 50,
|
R: 224,
|
||||||
G: 51,
|
G: 230,
|
||||||
B: 52,
|
B: 242,
|
||||||
A: 250,
|
A: 255,
|
||||||
}, c)
|
}, lightTheme.GetAxisSplitLineColor())
|
||||||
|
|
||||||
|
assert.Equal(drawing.Color{
|
||||||
|
R: 16,
|
||||||
|
G: 12,
|
||||||
|
B: 42,
|
||||||
|
A: 255,
|
||||||
|
}, darkTheme.GetBackgroundColor())
|
||||||
|
assert.Equal(drawing.ColorWhite, lightTheme.GetBackgroundColor())
|
||||||
|
|
||||||
|
assert.Equal(drawing.Color{
|
||||||
|
R: 238,
|
||||||
|
G: 238,
|
||||||
|
B: 238,
|
||||||
|
A: 255,
|
||||||
|
}, darkTheme.GetTextColor())
|
||||||
|
assert.Equal(drawing.Color{
|
||||||
|
R: 70,
|
||||||
|
G: 70,
|
||||||
|
B: 70,
|
||||||
|
A: 255,
|
||||||
|
}, lightTheme.GetTextColor())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
148
title.go
148
title.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -29,75 +29,127 @@ import (
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type TitleOption struct {
|
||||||
|
// Title text, support \n for new line
|
||||||
|
Text string
|
||||||
|
// Subtitle text, support \n for new line
|
||||||
|
Subtext string
|
||||||
|
// Title style
|
||||||
|
Style chart.Style
|
||||||
|
// Subtitle style
|
||||||
|
SubtextStyle chart.Style
|
||||||
|
// Distance between title component and the left side of the container.
|
||||||
|
// It can be pixel value: 20, percentage value: 20%,
|
||||||
|
// or position value: right, center.
|
||||||
|
Left string
|
||||||
|
// Distance between title component and the top side of the container.
|
||||||
|
// It can be pixel value: 20.
|
||||||
|
Top string
|
||||||
|
}
|
||||||
type titleMeasureOption struct {
|
type titleMeasureOption struct {
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
text string
|
text string
|
||||||
|
style chart.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTitleCustomize(title Title) chart.Renderable {
|
func splitTitleText(text string) []string {
|
||||||
return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) {
|
arr := strings.Split(text, "\n")
|
||||||
if len(title.Text) == 0 || title.Style.Hidden {
|
result := make([]string, 0)
|
||||||
return
|
for _, v := range arr {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
font := title.Font
|
result = append(result, v)
|
||||||
if font == nil {
|
|
||||||
font, _ = chart.GetDefaultFont()
|
|
||||||
}
|
}
|
||||||
r.SetFont(font)
|
return result
|
||||||
r.SetFontColor(title.Style.FontColor)
|
}
|
||||||
titleFontSize := title.Style.GetFontSize(chart.DefaultTitleFontSize)
|
|
||||||
r.SetFontSize(titleFontSize)
|
|
||||||
|
|
||||||
arr := strings.Split(title.Text, "\n")
|
func drawTitle(p *Draw, opt *TitleOption) (chart.Box, error) {
|
||||||
textWidth := 0
|
if len(opt.Text) == 0 {
|
||||||
textHeight := 0
|
return chart.BoxZero, nil
|
||||||
measureOptions := make([]titleMeasureOption, len(arr))
|
}
|
||||||
for index, str := range arr {
|
|
||||||
textBox := r.MeasureText(str)
|
padding := opt.Style.Padding
|
||||||
|
d, err := NewDraw(DrawOption{
|
||||||
|
Parent: p,
|
||||||
|
}, PaddingOption(padding))
|
||||||
|
if err != nil {
|
||||||
|
return chart.BoxZero, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := d.Render
|
||||||
|
|
||||||
|
measureOptions := make([]titleMeasureOption, 0)
|
||||||
|
|
||||||
|
// 主标题
|
||||||
|
for _, v := range splitTitleText(opt.Text) {
|
||||||
|
measureOptions = append(measureOptions, titleMeasureOption{
|
||||||
|
text: v,
|
||||||
|
style: opt.Style.GetTextOptions(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 副标题
|
||||||
|
for _, v := range splitTitleText(opt.Subtext) {
|
||||||
|
measureOptions = append(measureOptions, titleMeasureOption{
|
||||||
|
text: v,
|
||||||
|
style: opt.SubtextStyle.GetTextOptions(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
textMaxWidth := 0
|
||||||
|
textMaxHeight := 0
|
||||||
|
width := 0
|
||||||
|
for index, item := range measureOptions {
|
||||||
|
item.style.WriteTextOptionsToRenderer(r)
|
||||||
|
textBox := r.MeasureText(item.text)
|
||||||
|
|
||||||
w := textBox.Width()
|
w := textBox.Width()
|
||||||
h := textBox.Height()
|
h := textBox.Height()
|
||||||
if w > textWidth {
|
if w > textMaxWidth {
|
||||||
textWidth = w
|
textMaxWidth = w
|
||||||
}
|
}
|
||||||
if h > textHeight {
|
if h > textMaxHeight {
|
||||||
textHeight = h
|
textMaxHeight = h
|
||||||
}
|
}
|
||||||
measureOptions[index] = titleMeasureOption{
|
measureOptions[index].height = h
|
||||||
text: str,
|
measureOptions[index].width = w
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
}
|
}
|
||||||
}
|
width = textMaxWidth
|
||||||
|
|
||||||
titleX := 0
|
titleX := 0
|
||||||
switch title.Left {
|
b := d.Box
|
||||||
case "right":
|
switch opt.Left {
|
||||||
titleX = cb.Left + cb.Width() - textWidth
|
case PositionRight:
|
||||||
case "center":
|
titleX = b.Width() - textMaxWidth
|
||||||
titleX = cb.Left + cb.Width()>>1 - (textWidth >> 1)
|
case PositionCenter:
|
||||||
|
titleX = b.Width()>>1 - (textMaxWidth >> 1)
|
||||||
default:
|
default:
|
||||||
if strings.HasSuffix(title.Left, "%") {
|
if strings.HasSuffix(opt.Left, "%") {
|
||||||
value, _ := strconv.Atoi(strings.ReplaceAll(title.Left, "%", ""))
|
value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
|
||||||
titleX = cb.Left + cb.Width()*value/100
|
titleX = b.Width() * value / 100
|
||||||
} else {
|
} else {
|
||||||
value, _ := strconv.Atoi(title.Left)
|
value, _ := strconv.Atoi(opt.Left)
|
||||||
titleX = cb.Left + value
|
titleX = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
titleY := 0
|
||||||
titleY := cb.Top + title.Style.Padding.GetTop(chart.DefaultTitleTop) + (textHeight >> 1)
|
// TODO TOP 暂只支持数值
|
||||||
// TOP 暂只支持数值
|
if opt.Top != "" {
|
||||||
if title.Top != "" {
|
value, _ := strconv.Atoi(opt.Top)
|
||||||
value, _ := strconv.Atoi(title.Top)
|
|
||||||
titleY += value
|
titleY += value
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range measureOptions {
|
for _, item := range measureOptions {
|
||||||
x := titleX + (textWidth-item.width)>>1
|
item.style.WriteTextOptionsToRenderer(r)
|
||||||
r.Text(item.text, x, titleY)
|
x := titleX + (textMaxWidth-item.width)>>1
|
||||||
titleY += textHeight
|
y := titleY + item.height
|
||||||
}
|
d.text(item.text, x, y)
|
||||||
|
titleY += item.height
|
||||||
}
|
}
|
||||||
|
height := titleY + padding.Top + padding.Bottom
|
||||||
|
box := padding.Clone()
|
||||||
|
box.Right = box.Left + titleX + width
|
||||||
|
box.Bottom = box.Top + height
|
||||||
|
|
||||||
|
return box, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
title_test.go
125
title_test.go
|
|
@ -1,6 +1,6 @@
|
||||||
// MIT License
|
// 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
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
// of this software and associated documentation files (the "Software"), to deal
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|
@ -23,7 +23,6 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -31,55 +30,113 @@ import (
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTitleCustomize(t *testing.T) {
|
func TestSplitTitleText(t *testing.T) {
|
||||||
assert := assert.New(t)
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal([]string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
}, splitTitleText("a\nb"))
|
||||||
|
assert.Equal([]string{
|
||||||
|
"a",
|
||||||
|
}, splitTitleText("a\n "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDrawTitle(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
newOption := func() *TitleOption {
|
||||||
|
f, _ := chart.GetDefaultFont()
|
||||||
|
return &TitleOption{
|
||||||
|
Text: "title\nHello",
|
||||||
|
Subtext: "subtitle\nWorld!",
|
||||||
|
Style: chart.Style{
|
||||||
|
FontSize: 14,
|
||||||
|
Font: f,
|
||||||
|
FontColor: drawing.ColorBlack,
|
||||||
|
},
|
||||||
|
SubtextStyle: chart.Style{
|
||||||
|
FontSize: 10,
|
||||||
|
Font: f,
|
||||||
|
FontColor: drawing.ColorBlue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newDraw := func() *Draw {
|
||||||
|
d, _ := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
title Title
|
newDraw func() *Draw
|
||||||
svg string
|
newOption func() *TitleOption
|
||||||
|
result string
|
||||||
|
box chart.Box
|
||||||
}{
|
}{
|
||||||
// 单行标题
|
|
||||||
{
|
{
|
||||||
title: Title{
|
newDraw: newDraw,
|
||||||
Text: "Hello World!",
|
newOption: newOption,
|
||||||
Style: chart.Style{
|
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"6\" y=\"17\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"0\" y=\"34\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">Hello</text><text x=\"0\" y=\"46\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">subtitle</text><text x=\"3\" y=\"58\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">World!</text></svg>",
|
||||||
FontColor: drawing.ColorBlack,
|
box: chart.Box{
|
||||||
|
Right: 43,
|
||||||
|
Bottom: 58,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
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{
|
newDraw: newDraw,
|
||||||
Text: "Hello World!\nHello World",
|
newOption: func() *TitleOption {
|
||||||
Style: chart.Style{
|
opt := newOption()
|
||||||
FontColor: drawing.ColorBlack,
|
opt.Left = PositionRight
|
||||||
|
opt.Top = "50"
|
||||||
|
return opt
|
||||||
},
|
},
|
||||||
Left: "right",
|
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"363\" y=\"67\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"357\" y=\"84\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">Hello</text><text x=\"357\" y=\"96\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">subtitle</text><text x=\"360\" y=\"108\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">World!</text></svg>",
|
||||||
|
box: chart.Box{
|
||||||
|
Right: 400,
|
||||||
|
Bottom: 108,
|
||||||
},
|
},
|
||||||
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{
|
newDraw: newDraw,
|
||||||
Text: "Hello World!",
|
newOption: func() *TitleOption {
|
||||||
Style: chart.Style{
|
opt := newOption()
|
||||||
FontColor: drawing.ColorBlack,
|
opt.Left = PositionCenter
|
||||||
|
opt.Top = "10"
|
||||||
|
return opt
|
||||||
},
|
},
|
||||||
Left: "center",
|
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"185\" y=\"27\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"179\" y=\"44\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">Hello</text><text x=\"179\" y=\"56\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">subtitle</text><text x=\"182\" y=\"68\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">World!</text></svg>",
|
||||||
|
box: chart.Box{
|
||||||
|
Right: 222,
|
||||||
|
Bottom: 68,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
newDraw: newDraw,
|
||||||
|
newOption: func() *TitleOption {
|
||||||
|
opt := newOption()
|
||||||
|
opt.Left = "10%"
|
||||||
|
opt.Top = "10"
|
||||||
|
return opt
|
||||||
|
},
|
||||||
|
result: "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" width=\"400\" height=\"300\">\\n<text x=\"46\" y=\"27\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">title</text><text x=\"40\" y=\"44\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,0,1.0);font-size:17.9px;font-family:'Roboto Medium',sans-serif\">Hello</text><text x=\"40\" y=\"56\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">subtitle</text><text x=\"43\" y=\"68\" style=\"stroke-width:0;stroke:none;fill:rgba(0,0,255,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">World!</text></svg>",
|
||||||
|
box: chart.Box{
|
||||||
|
Right: 83,
|
||||||
|
Bottom: 68,
|
||||||
},
|
},
|
||||||
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 {
|
for _, tt := range tests {
|
||||||
r, err := chart.SVG(800, 600)
|
d := tt.newDraw()
|
||||||
|
o := tt.newOption()
|
||||||
|
b, err := drawTitle(d, o)
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
fn := NewTitleCustomize(tt.title)
|
assert.Equal(tt.box, b)
|
||||||
fn(r, chart.NewBox(50, 50, 600, 400), chart.Style{
|
data, err := d.Bytes()
|
||||||
Font: chart.StyleTextDefaults().Font,
|
|
||||||
})
|
|
||||||
buf := bytes.Buffer{}
|
|
||||||
err = r.Save(&buf)
|
|
||||||
assert.Nil(err)
|
assert.Nil(err)
|
||||||
assert.Equal(tt.svg, buf.String())
|
assert.NotEmpty(data)
|
||||||
|
assert.Equal(tt.result, string(data))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
170
util.go
Normal file
170
util.go
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// 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 (
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TrueFlag() *bool {
|
||||||
|
t := true
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func FalseFlag() *bool {
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ceilFloatToInt(value float64) int {
|
||||||
|
i := int(value)
|
||||||
|
if value == float64(i) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
return i + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDefaultInt(value, defaultValue int) int {
|
||||||
|
if value == 0 {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoDivide(max, size int) []int {
|
||||||
|
unit := max / size
|
||||||
|
|
||||||
|
rest := max - unit*size
|
||||||
|
values := make([]int, size+1)
|
||||||
|
value := 0
|
||||||
|
for i := 0; i < size; i++ {
|
||||||
|
values[i] = value
|
||||||
|
if i < rest {
|
||||||
|
value++
|
||||||
|
}
|
||||||
|
value += unit
|
||||||
|
}
|
||||||
|
values[size] = max
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// measureTextMaxWidthHeight returns maxWidth and maxHeight of text list
|
||||||
|
func measureTextMaxWidthHeight(textList []string, r chart.Renderer) (int, int) {
|
||||||
|
maxWidth := 0
|
||||||
|
maxHeight := 0
|
||||||
|
for _, text := range textList {
|
||||||
|
box := r.MeasureText(text)
|
||||||
|
maxWidth = chart.MaxInt(maxWidth, box.Width())
|
||||||
|
maxHeight = chart.MaxInt(maxHeight, box.Height())
|
||||||
|
}
|
||||||
|
return maxWidth, maxHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseStringSlice(stringList []string) {
|
||||||
|
for i, j := 0, len(stringList)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
stringList[i], stringList[j] = stringList[j], stringList[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reverseIntSlice(intList []int) {
|
||||||
|
for i, j := 0, len(intList)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
intList[i], intList[j] = intList[j], intList[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 isFalse(flag *bool) bool {
|
||||||
|
if flag != nil && !*flag {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFloatPoint(f float64) *float64 {
|
||||||
|
v := f
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
func commafWithDigits(value float64) string {
|
||||||
|
decimals := 2
|
||||||
|
m := float64(1000 * 1000)
|
||||||
|
if value >= m {
|
||||||
|
return humanize.CommafWithDigits(value/m, decimals) + "M"
|
||||||
|
}
|
||||||
|
k := float64(1000)
|
||||||
|
if value >= k {
|
||||||
|
return humanize.CommafWithDigits(value/k, decimals) + "k"
|
||||||
|
}
|
||||||
|
return humanize.CommafWithDigits(value, decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
181
util_test.go
Normal file
181
util_test.go
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
// 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 TestGetDefaultInt(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(1, getDefaultInt(0, 1))
|
||||||
|
assert.Equal(10, getDefaultInt(10, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCeilFloatToInt(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(1, ceilFloatToInt(0.8))
|
||||||
|
assert.Equal(1, ceilFloatToInt(1.0))
|
||||||
|
assert.Equal(2, ceilFloatToInt(1.2))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommafWithDigits(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal("1.2", commafWithDigits(1.2))
|
||||||
|
assert.Equal("1.21", commafWithDigits(1.21231))
|
||||||
|
|
||||||
|
assert.Equal("1.20k", commafWithDigits(1200.121))
|
||||||
|
assert.Equal("1.20M", commafWithDigits(1200000.121))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutoDivide(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal([]int{
|
||||||
|
0,
|
||||||
|
86,
|
||||||
|
172,
|
||||||
|
258,
|
||||||
|
344,
|
||||||
|
430,
|
||||||
|
515,
|
||||||
|
600,
|
||||||
|
}, autoDivide(600, 7))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertPercent(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
assert.Equal(-1.0, convertPercent("1"))
|
||||||
|
assert.Equal(-1.0, convertPercent("a%"))
|
||||||
|
assert.Equal(0.1, convertPercent("10%"))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
97
xaxis.go
Normal file
97
xaxis.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2022 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type XAxisOption struct {
|
||||||
|
// The boundary gap on both sides of a coordinate axis.
|
||||||
|
// Nil or *true means the center part of two axis ticks
|
||||||
|
BoundaryGap *bool
|
||||||
|
// The data value of x axis
|
||||||
|
Data []string
|
||||||
|
// The theme of chart
|
||||||
|
Theme string
|
||||||
|
// Hidden x axis
|
||||||
|
Hidden bool
|
||||||
|
// Number of segments that the axis is split into. Note that this number serves only as a recommendation.
|
||||||
|
SplitNumber int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewXAxisOption(data []string, boundaryGap ...*bool) XAxisOption {
|
||||||
|
opt := XAxisOption{
|
||||||
|
Data: data,
|
||||||
|
}
|
||||||
|
if len(boundaryGap) != 0 {
|
||||||
|
opt.BoundaryGap = boundaryGap[0]
|
||||||
|
}
|
||||||
|
return opt
|
||||||
|
}
|
||||||
|
|
||||||
|
// drawXAxis draws x axis, and returns the height, range of if.
|
||||||
|
func drawXAxis(p *Draw, opt *XAxisOption, yAxisCount int) (int, *Range, error) {
|
||||||
|
if opt.Hidden {
|
||||||
|
return 0, nil, nil
|
||||||
|
}
|
||||||
|
left := YAxisWidth
|
||||||
|
right := (yAxisCount - 1) * YAxisWidth
|
||||||
|
dXAxis, err := NewDraw(
|
||||||
|
DrawOption{
|
||||||
|
Parent: p,
|
||||||
|
},
|
||||||
|
PaddingOption(chart.Box{
|
||||||
|
Left: left,
|
||||||
|
Right: right,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
theme := NewTheme(opt.Theme)
|
||||||
|
data := NewAxisDataListFromStringList(opt.Data)
|
||||||
|
style := AxisOption{
|
||||||
|
BoundaryGap: opt.BoundaryGap,
|
||||||
|
StrokeColor: theme.GetAxisStrokeColor(),
|
||||||
|
FontColor: theme.GetAxisStrokeColor(),
|
||||||
|
StrokeWidth: 1,
|
||||||
|
SplitNumber: opt.SplitNumber,
|
||||||
|
}
|
||||||
|
|
||||||
|
boundary := true
|
||||||
|
max := float64(len(opt.Data))
|
||||||
|
if isFalse(opt.BoundaryGap) {
|
||||||
|
boundary = false
|
||||||
|
max--
|
||||||
|
}
|
||||||
|
axis := NewAxis(dXAxis, data, style)
|
||||||
|
axis.Render()
|
||||||
|
return axis.measure().Height, &Range{
|
||||||
|
divideCount: len(opt.Data),
|
||||||
|
Min: 0,
|
||||||
|
Max: max,
|
||||||
|
Size: dXAxis.Box.Width(),
|
||||||
|
Boundary: boundary,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
108
xaxis_test.go
Normal file
108
xaxis_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewXAxisOption(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
opt := NewXAxisOption([]string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
}, FalseFlag())
|
||||||
|
|
||||||
|
assert.Equal(XAxisOption{
|
||||||
|
Data: []string{
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
},
|
||||||
|
BoundaryGap: FalseFlag(),
|
||||||
|
}, opt)
|
||||||
|
|
||||||
|
}
|
||||||
|
func TestDrawXAxis(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
newDraw := func() *Draw {
|
||||||
|
d, _ := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
newDraw func() *Draw
|
||||||
|
newOption func() *XAxisOption
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
newDraw: newDraw,
|
||||||
|
newOption: func() *XAxisOption {
|
||||||
|
return &XAxisOption{
|
||||||
|
BoundaryGap: FalseFlag(),
|
||||||
|
Data: []string{
|
||||||
|
"Mon",
|
||||||
|
"Tue",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 40 275\nL 400 275\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 40 275\nL 40 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 400 275\nL 400 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"27\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Mon</text><text x=\"389\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">Tue</text></svg>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
newDraw: newDraw,
|
||||||
|
newOption: func() *XAxisOption {
|
||||||
|
return &XAxisOption{
|
||||||
|
Data: []string{
|
||||||
|
"01-01",
|
||||||
|
"01-02",
|
||||||
|
"01-03",
|
||||||
|
"01-04",
|
||||||
|
"01-05",
|
||||||
|
"01-06",
|
||||||
|
"01-07",
|
||||||
|
"01-08",
|
||||||
|
"01-09",
|
||||||
|
},
|
||||||
|
SplitNumber: 3,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 40 275\nL 400 275\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 40 275\nL 40 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 160 275\nL 160 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 280 275\nL 280 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><path d=\"M 400 275\nL 400 280\" style=\"stroke-width:1;stroke:rgba(110,112,121,1.0);fill:none\"/><text x=\"83\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-02</text><text x=\"203\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-05</text><text x=\"323\" y=\"292\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">01-08</text></svg>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
d := tt.newDraw()
|
||||||
|
height, _, err := drawXAxis(d, tt.newOption(), 1)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(25, height)
|
||||||
|
data, err := d.Bytes()
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(tt.result, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
102
yaxis.go
Normal file
102
yaxis.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
// 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 (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type YAxisOption struct {
|
||||||
|
// The minimun value of axis.
|
||||||
|
Min *float64
|
||||||
|
// The maximum value of axis.
|
||||||
|
Max *float64
|
||||||
|
// Hidden y axis
|
||||||
|
Hidden bool
|
||||||
|
// Formatter for y axis text value
|
||||||
|
Formatter string
|
||||||
|
// Color for y axis
|
||||||
|
Color drawing.Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO 长度是否可以变化
|
||||||
|
const YAxisWidth = 40
|
||||||
|
|
||||||
|
func drawYAxis(p *Draw, opt *ChartOption, axisIndex, xAxisHeight int, padding chart.Box) (*Range, error) {
|
||||||
|
theme := NewTheme(opt.Theme)
|
||||||
|
yRange := opt.newYRange(axisIndex)
|
||||||
|
values := yRange.Values()
|
||||||
|
yAxis := opt.YAxisList[axisIndex]
|
||||||
|
formatter := yAxis.Formatter
|
||||||
|
if len(formatter) != 0 {
|
||||||
|
for index, text := range values {
|
||||||
|
values[index] = strings.ReplaceAll(formatter, "{value}", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data := NewAxisDataListFromStringList(values)
|
||||||
|
style := AxisOption{
|
||||||
|
Position: PositionLeft,
|
||||||
|
BoundaryGap: FalseFlag(),
|
||||||
|
FontColor: theme.GetAxisStrokeColor(),
|
||||||
|
TickShow: FalseFlag(),
|
||||||
|
StrokeWidth: 1,
|
||||||
|
SplitLineColor: theme.GetAxisSplitLineColor(),
|
||||||
|
SplitLineShow: true,
|
||||||
|
}
|
||||||
|
if !yAxis.Color.IsZero() {
|
||||||
|
style.FontColor = yAxis.Color
|
||||||
|
style.StrokeColor = yAxis.Color
|
||||||
|
}
|
||||||
|
width := NewAxis(p, data, style).measure().Width
|
||||||
|
|
||||||
|
yAxisCount := len(opt.YAxisList)
|
||||||
|
boxWidth := p.Box.Width()
|
||||||
|
if axisIndex > 0 {
|
||||||
|
style.SplitLineShow = false
|
||||||
|
style.Position = PositionRight
|
||||||
|
padding.Right += (axisIndex - 1) * YAxisWidth
|
||||||
|
} else {
|
||||||
|
boxWidth = p.Box.Width() - (yAxisCount-1)*YAxisWidth
|
||||||
|
padding.Left += (YAxisWidth - width)
|
||||||
|
}
|
||||||
|
|
||||||
|
dYAxis, err := NewDraw(
|
||||||
|
DrawOption{
|
||||||
|
Parent: p,
|
||||||
|
Width: boxWidth,
|
||||||
|
// 减去x轴的高
|
||||||
|
Height: p.Box.Height() - xAxisHeight,
|
||||||
|
},
|
||||||
|
PaddingOption(padding),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
NewAxis(dYAxis, data, style).Render()
|
||||||
|
yRange.Size = dYAxis.Box.Height()
|
||||||
|
return &yRange, nil
|
||||||
|
}
|
||||||
119
yaxis_test.go
Normal file
119
yaxis_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
// 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 TestDrawYAxis(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
newDraw := func() *Draw {
|
||||||
|
d, _ := NewDraw(DrawOption{
|
||||||
|
Width: 400,
|
||||||
|
Height: 300,
|
||||||
|
})
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
newDraw func() *Draw
|
||||||
|
newOption func() *ChartOption
|
||||||
|
axisIndex int
|
||||||
|
xAxisHeight int
|
||||||
|
result string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
newDraw: newDraw,
|
||||||
|
newOption: func() *ChartOption {
|
||||||
|
return &ChartOption{
|
||||||
|
YAxisList: []YAxisOption{
|
||||||
|
{
|
||||||
|
Max: NewFloatPoint(20),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SeriesList: []Series{
|
||||||
|
{
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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 50 10\nL 50 290\" style=\"stroke-width:1;stroke:none;fill:none\"/><path d=\"M 50 10\nL 390 10\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 57\nL 390 57\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 104\nL 390 104\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 151\nL 390 151\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 198\nL 390 198\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><path d=\"M 50 244\nL 390 244\" style=\"stroke-width:1;stroke:rgba(224,230,242,1.0);fill:none\"/><text x=\"36\" y=\"294\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">0</text><text x=\"18\" y=\"248\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">3.33</text><text x=\"18\" y=\"202\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">6.66</text><text x=\"29\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">10</text><text x=\"11\" y=\"108\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">13.33</text><text x=\"11\" y=\"61\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">16.66</text><text x=\"29\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20</text></svg>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
newDraw: newDraw,
|
||||||
|
newOption: func() *ChartOption {
|
||||||
|
return &ChartOption{
|
||||||
|
YAxisList: []YAxisOption{
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
Max: NewFloatPoint(20),
|
||||||
|
Formatter: "{value} C",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SeriesList: []Series{
|
||||||
|
{
|
||||||
|
YAxisIndex: 1,
|
||||||
|
Data: []SeriesData{
|
||||||
|
{
|
||||||
|
Value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Value: 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisIndex: 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 337 10\nL 337 290\" style=\"stroke-width:1;stroke:none;fill:none\"/><text x=\"345\" y=\"294\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">0 C</text><text x=\"345\" y=\"248\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">3.33 C</text><text x=\"345\" y=\"202\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">6.66 C</text><text x=\"345\" y=\"155\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">10 C</text><text x=\"345\" y=\"108\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">13.33 C</text><text x=\"345\" y=\"61\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">16.66 C</text><text x=\"345\" y=\"14\" style=\"stroke-width:0;stroke:none;fill:rgba(110,112,121,1.0);font-size:12.8px;font-family:'Roboto Medium',sans-serif\">20 C</text></svg>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
d := tt.newDraw()
|
||||||
|
r, err := drawYAxis(d, tt.newOption(), tt.axisIndex, tt.xAxisHeight, chart.NewBox(10, 10, 10, 10))
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(&Range{
|
||||||
|
divideCount: 6,
|
||||||
|
Max: 20,
|
||||||
|
Size: 280,
|
||||||
|
}, r)
|
||||||
|
|
||||||
|
data, err := d.Bytes()
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal(tt.result, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue