feat: support radar chart

This commit is contained in:
vicanso 2022-03-02 23:20:41 +08:00
parent 6209a9ce63
commit 570828d35f
9 changed files with 337 additions and 35 deletions

View file

@ -36,6 +36,7 @@ const (
ChartTypeLine = "line"
ChartTypeBar = "bar"
ChartTypePie = "pie"
ChartTypeRadar = "radar"
)
const (
@ -49,6 +50,8 @@ type Point struct {
}
const labelFontSize = 10
const defaultDotWidth = 2.0
const defaultStrokeWidth = 2.0
var defaultChartWidth = 600
var defaultChartHeight = 400
@ -82,6 +85,8 @@ type ChartOption struct {
Box chart.Box
// The series list
SeriesList SeriesList
// The radar indicator list
RadarIndicators []RadarIndicator
// The background color of chart
BackgroundColor drawing.Color
// The child charts
@ -283,11 +288,14 @@ func Render(opt ChartOption) (*Draw, error) {
lineSeries := make([]Series, 0)
barSeries := make([]Series, 0)
isPieChart := false
isRadarChart := false
for index, item := range opt.SeriesList {
item.index = index
switch item.Type {
case ChartTypePie:
isPieChart = true
case ChartTypeRadar:
isRadarChart = true
case ChartTypeBar:
barSeries = append(barSeries, item)
default:
@ -296,7 +304,8 @@ func Render(opt ChartOption) (*Draw, error) {
}
// 如果指定了pie则以pie的形式处理pie不支持多类型图表
// pie不需要axis
if isPieChart {
// radar 同样处理
if isPieChart || isRadarChart {
opt.XAxis.Hidden = true
for index := range opt.YAxisList {
opt.YAxisList[index].Hidden = true
@ -313,12 +322,23 @@ func Render(opt ChartOption) (*Draw, error) {
if !isPieChart {
return nil
}
err := pieChartRender(pieChartOption{
return pieChartRender(pieChartOption{
SeriesList: opt.SeriesList,
Theme: opt.Theme,
Font: opt.Font,
}, result)
return err
},
// radar render
func() error {
if !isRadarChart {
return nil
}
return radarChartRender(radarChartOption{
SeriesList: opt.SeriesList,
Theme: opt.Theme,
Font: opt.Font,
Indicators: opt.RadarIndicators,
}, result)
},
// bar render
func() error {

13
draw.go
View file

@ -309,3 +309,16 @@ func (d *Draw) setBackground(width, height int, color drawing.Color) {
r.LineTo(0, 0)
r.FillStroke()
}
func (d *Draw) polygon(center Point, radius float64, sides int) {
points := getPolygonPoints(center, radius, sides)
for i, p := range points {
if i == 0 {
d.moveTo(p.X, p.Y)
} else {
d.lineTo(p.X, p.Y)
}
}
d.lineTo(points[0].X, points[0].Y)
d.Render.Stroke()
}

View file

@ -496,6 +496,66 @@ func indexHandler(w http.ResponseWriter, req *http.Request) {
Radius: "35%",
}),
},
// 雷达图
{
Title: charts.TitleOption{
Text: "Basic Radar Chart",
},
Legend: charts.NewLegendOption([]string{
"Allocated Budget",
"Actual Spending",
}),
RadarIndicators: []charts.RadarIndicator{
{
Name: "Sales",
Max: 6500,
},
{
Name: "Administration",
Max: 16000,
},
{
Name: "Information Technology",
Max: 30000,
},
{
Name: "Customer Support",
Max: 38000,
},
{
Name: "Development",
Max: 52000,
},
{
Name: "Marketing",
Max: 25000,
},
},
SeriesList: charts.SeriesList{
{
Type: charts.ChartTypeRadar,
Data: charts.NewSeriesDataFromValues([]float64{
4200,
3000,
20000,
35000,
50000,
18000,
}),
},
{
Type: charts.ChartTypeRadar,
Data: charts.NewSeriesDataFromValues([]float64{
5000,
14000,
28000,
26000,
42000,
21000,
}),
},
},
},
// 多图展示
{
Legend: charts.LegendOption{

View file

@ -114,7 +114,7 @@ func lineChartRender(opt lineChartOption, result *basicRenderResult) ([]markPoin
StrokeColor: seriesColor,
StrokeWidth: 2,
DotColor: seriesColor,
DotWidth: 2,
DotWidth: defaultDotWidth,
DotFillColor: dotFillColor,
})
// draw mark point

View file

@ -24,14 +24,11 @@ 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{
@ -47,22 +44,6 @@ type pieChartOption struct {
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,
@ -95,7 +76,7 @@ func pieChartRender(opt pieChartOption, result *basicRenderResult) error {
cy := box.Height() >> 1
diameter := chart.MinInt(box.Width(), box.Height())
radius := getPieRadius(float64(diameter), radiusValue)
radius := getRadius(float64(diameter), radiusValue)
labelLineWidth := 15
if radius < 50 {

View file

@ -30,14 +30,6 @@ import (
"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)

183
radar_chart.go Normal file
View file

@ -0,0 +1,183 @@
// 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"
"github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing"
)
// 线 E0E6F1
// 填充 rgb(210,219,238) fill-opacity="0.2"
type RadarIndicator struct {
Name string
Max float64
}
type radarChartOption struct {
Theme string
Font *truetype.Font
SeriesList SeriesList
Indicators []RadarIndicator
}
func radarChartRender(opt radarChartOption, result *basicRenderResult) error {
sides := len(opt.Indicators)
if sides < 3 {
return errors.New("The count of indicator should be >= 3")
}
for _, indicator := range opt.Indicators {
if indicator.Max <= 0 {
return errors.New("The max of indicator should be > 0")
}
}
d, err := NewDraw(DrawOption{
Parent: result.d,
}, PaddingOption(chart.Box{
Top: result.titleBox.Height(),
}))
if err != nil {
return err
}
radiusValue := ""
for _, series := range opt.SeriesList {
if len(series.Radius) != 0 {
radiusValue = series.Radius
}
}
box := d.Box
cx := box.Width() >> 1
cy := box.Height() >> 1
diameter := chart.MinInt(box.Width(), box.Height())
radius := getRadius(float64(diameter), radiusValue)
theme := NewTheme(opt.Theme)
divideCount := 5
divideRadius := float64(int(radius / float64(divideCount)))
radius = divideRadius * float64(divideCount)
style := chart.Style{
StrokeColor: theme.GetAxisSplitLineColor(),
StrokeWidth: 1,
}
r := d.Render
style.WriteToRenderer(r)
center := Point{
X: cx,
Y: cy,
}
for i := 0; i < divideCount; i++ {
d.polygon(center, divideRadius*float64(i+1), sides)
}
points := getPolygonPoints(center, radius, sides)
for _, p := range points {
d.moveTo(center.X, center.Y)
d.lineTo(p.X, p.Y)
d.Render.Stroke()
}
// 文本
textStyle := chart.Style{
FontColor: theme.GetTextColor(),
FontSize: labelFontSize,
Font: opt.Font,
}
textStyle.GetTextOptions().WriteToRenderer(r)
offset := 5
// 文本生成
for index, p := range points {
name := opt.Indicators[index].Name
b := r.MeasureText(name)
isXCenter := p.X == center.X
isYCenter := p.Y == center.Y
isRight := p.X > center.X
isLeft := p.X < center.X
isTop := p.Y < center.Y
isBottom := p.Y > center.Y
x := p.X
y := p.Y
if isXCenter {
x -= b.Width() >> 1
if isTop {
y -= b.Height()
} else {
y += b.Height()
}
}
if isYCenter {
y += b.Height() >> 1
}
if isTop {
y += offset
}
if isBottom {
y += offset
}
if isRight {
x += offset
}
if isLeft {
x -= (b.Width() + offset)
}
d.text(name, x, y)
}
// 雷达图
angles := getPolygonPointAngles(sides)
maxCount := len(opt.Indicators)
for i, series := range opt.SeriesList {
linePoints := make([]Point, 0, maxCount)
for j, item := range series.Data {
if j >= maxCount {
continue
}
percent := item.Value / opt.Indicators[j].Max
r := percent * radius
p := getPolygonPoint(center, r, angles[j])
linePoints = append(linePoints, p)
}
color := theme.GetSeriesColor(i)
dotFillColor := drawing.ColorWhite
if theme.IsDark() {
dotFillColor = color
}
linePoints = append(linePoints, linePoints[0])
s := LineStyle{
StrokeColor: color,
StrokeWidth: defaultStrokeWidth,
DotWidth: defaultDotWidth,
DotColor: color,
DotFillColor: dotFillColor,
FillColor: color.WithAlpha(20),
}
d.lineStroke(linePoints, s)
d.lineFill(linePoints, s)
d.lineDot(linePoints[0:len(linePoints)-1], s)
}
return nil
}

45
util.go
View file

@ -23,6 +23,7 @@
package charts
import (
"math"
"regexp"
"strconv"
"strings"
@ -168,3 +169,47 @@ func parseColor(color string) drawing.Color {
}
return c
}
const defaultRadiusPercent = 0.4
func getRadius(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 getPolygonPointAngles(sides int) []float64 {
angles := make([]float64, sides)
for i := 0; i < sides; i++ {
angle := 2*math.Pi/float64(sides)*float64(i) - (math.Pi / 2)
angles[i] = angle
}
return angles
}
func getPolygonPoint(center Point, radius, angle float64) Point {
x := center.X + int(radius*math.Cos(angle))
y := center.Y + int(radius*math.Sin(angle))
return Point{
X: x,
Y: y,
}
}
func getPolygonPoints(center Point, radius float64, sides int) []Point {
points := make([]Point, sides)
for i, angle := range getPolygonPointAngles(sides) {
points[i] = getPolygonPoint(center, radius, angle)
}
return points
}

View file

@ -70,6 +70,14 @@ func TestAutoDivide(t *testing.T) {
}, autoDivide(600, 7))
}
func TestGetRadius(t *testing.T) {
assert := assert.New(t)
assert.Equal(50.0, getRadius(100, "50%"))
assert.Equal(30.0, getRadius(100, "30"))
assert.Equal(40.0, getRadius(100, ""))
}
func TestMeasureTextMaxWidthHeight(t *testing.T) {
assert := assert.New(t)
r, err := chart.SVG(400, 300)