feat: support radar chart
This commit is contained in:
parent
6209a9ce63
commit
570828d35f
9 changed files with 337 additions and 35 deletions
26
chart.go
26
chart.go
|
|
@ -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
13
draw.go
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
21
pie_chart.go
21
pie_chart.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
183
radar_chart.go
Normal 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
45
util.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue