diff --git a/chart_option.go b/chart_option.go
new file mode 100644
index 0000000..6ca7cd7
--- /dev/null
+++ b/chart_option.go
@@ -0,0 +1,120 @@
+// 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 (
+ "sort"
+
+ "github.com/golang/freetype/truetype"
+ "github.com/wcharczuk/go-chart/v2"
+)
+
+type ChartOption struct {
+ theme ColorPalette
+ font *truetype.Font
+ // 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 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
+ YAxisOptions []YAxisOption
+ // The width of chart, default width is 600
+ Width int
+ // The height of chart, default height is 400
+ Height int
+ Parent *Painter
+ // The padding for chart, default padding is [20, 10, 10, 10]
+ Padding Box
+ // The canvas box for chart
+ Box Box
+ // The series list
+ SeriesList SeriesList
+ // The radar indicator list
+ // RadarIndicators []RadarIndicator
+ // The background color of chart
+ BackgroundColor Color
+ // The child charts
+ Children []ChartOption
+}
+
+func (o *ChartOption) fillDefault() {
+ t := NewTheme(o.Theme)
+ o.theme = t
+ // 如果为空,初始化
+ axisCount := 1
+ for _, series := range o.SeriesList {
+ if series.AxisIndex >= axisCount {
+ axisCount++
+ }
+ }
+ o.Width = getDefaultInt(o.Width, defaultChartWidth)
+ o.Height = getDefaultInt(o.Height, defaultChartHeight)
+ yAxisOptions := make([]YAxisOption, axisCount)
+ copy(yAxisOptions, o.YAxisOptions)
+ o.YAxisOptions = yAxisOptions
+ o.font, _ = GetFont(o.FontFamily)
+
+ 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,
+ }
+ }
+ // 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 &&
+ len(o.SeriesList[index].Name) == 0 {
+ o.SeriesList[index].Name = name
+ }
+ }
+ nameIndexDict := map[string]int{}
+ for index, name := range o.Legend.Data {
+ nameIndexDict[name] = index
+ }
+ // 保证series的顺序与legend一致
+ sort.Slice(o.SeriesList, func(i, j int) bool {
+ return nameIndexDict[o.SeriesList[i].Name] < nameIndexDict[o.SeriesList[j].Name]
+ })
+ }
+}
diff --git a/charts.go b/charts.go
index 6a2f5f2..947fa8d 100644
--- a/charts.go
+++ b/charts.go
@@ -22,6 +22,24 @@
package charts
+const labelFontSize = 10
+const defaultDotWidth = 2.0
+const defaultStrokeWidth = 2.0
+
+var defaultChartWidth = 600
+var defaultChartHeight = 400
+
+func SetDefaultWidth(width int) {
+ if width > 0 {
+ defaultChartWidth = width
+ }
+}
+func SetDefaultHeight(height int) {
+ if height > 0 {
+ defaultChartHeight = height
+ }
+}
+
type Renderer interface {
Render() (Box, error)
}
@@ -34,6 +52,10 @@ type defaultRenderOption struct {
YAxisOptions []YAxisOption
// The x axis option
XAxis XAxisOption
+ // The title option
+ TitleOption TitleOption
+ // The legend option
+ LegendOption LegendOption
}
type defaultRenderResult struct {
@@ -42,10 +64,37 @@ type defaultRenderResult struct {
}
func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, error) {
- p.SetBackground(p.Width(), p.Height(), opt.Theme.GetBackgroundColor())
if !opt.Padding.IsZero() {
p = p.Child(PainterPaddingOption(opt.Padding))
}
+
+ if len(opt.LegendOption.Data) != 0 {
+ if opt.LegendOption.Theme == nil {
+ opt.LegendOption.Theme = opt.Theme
+ }
+ _, err := NewLegendPainter(p, opt.LegendOption).Render()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ // 如果有标题
+ if opt.TitleOption.Text != "" {
+ if opt.TitleOption.Theme == nil {
+ opt.TitleOption.Theme = opt.Theme
+ }
+ titlePainter := NewTitlePainter(p, opt.TitleOption)
+
+ titleBox, err := titlePainter.Render()
+ if err != nil {
+ return nil, err
+ }
+ p = p.Child(PainterPaddingOption(Box{
+ // 标题下留白
+ Top: titleBox.Height() + 20,
+ }))
+ }
+
result := defaultRenderResult{
axisRanges: make(map[int]axisRange),
}
@@ -60,7 +109,8 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e
}
// 高度需要减去x轴的高度
rangeHeight := p.Height() - defaultXAxisHeight
- rangeWidth := 0
+ rangeWidthLeft := 0
+ rangeWidthRight := 0
// 计算对应的axis range
for _, index := range axisIndexList {
@@ -89,28 +139,28 @@ func defaultRender(p *Painter, opt defaultRenderOption) (*defaultRenderResult, e
if err != nil {
return nil, err
}
- rangeWidth += yAxisBox.Width()
+ if index == 0 {
+ rangeWidthLeft += yAxisBox.Width()
+ } else {
+ rangeWidthRight += yAxisBox.Width()
+ }
}
if opt.XAxis.Theme == nil {
opt.XAxis.Theme = opt.Theme
}
xAxis := NewBottomXAxis(p.Child(PainterPaddingOption(Box{
- Left: rangeWidth,
+ Left: rangeWidthLeft,
})), opt.XAxis)
_, err := xAxis.Render()
if err != nil {
return nil, err
}
- // // 生成Y轴
- // for _, yAxisOption := range opt.YAxisOptions {
-
- // }
-
result.p = p.Child(PainterPaddingOption(Box{
Bottom: rangeHeight,
- Left: rangeWidth,
+ Left: rangeWidthLeft,
+ Right: rangeWidthRight,
}))
return &result, nil
}
@@ -124,3 +174,50 @@ func doRender(renderers ...Renderer) error {
}
return nil
}
+
+func Render(opt ChartOption) (*Painter, error) {
+ opt.fillDefault()
+
+ if opt.Parent == nil {
+ p, err := NewPainter(PainterOptions{
+ Type: opt.Type,
+ Width: opt.Width,
+ Height: opt.Height,
+ })
+ if err != nil {
+ return nil, err
+ }
+ opt.Parent = p
+ }
+ p := opt.Parent
+ p.SetBackground(p.Width(), p.Height(), opt.BackgroundColor)
+ seriesList := opt.SeriesList
+ seriesList.init()
+
+ rendererList := make([]Renderer, 0)
+
+ // line chart
+ lineChartSeriesList := seriesList.Filter(ChartTypeLine)
+ if len(lineChartSeriesList) != 0 {
+ renderer := NewLineChart(p, LineChartOption{
+ Theme: opt.theme,
+ Font: opt.font,
+ SeriesList: lineChartSeriesList,
+ XAxis: opt.XAxis,
+ Padding: opt.Padding,
+ YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.Title,
+ LegendOption: opt.Legend,
+ })
+ rendererList = append(rendererList, renderer)
+ }
+
+ for _, renderer := range rendererList {
+ _, err := renderer.Render()
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return p, nil
+}
diff --git a/examples/charts/main.go b/examples/charts/main.go
new file mode 100644
index 0000000..18f5a95
--- /dev/null
+++ b/examples/charts/main.go
@@ -0,0 +1,1321 @@
+package main
+
+import (
+ "bytes"
+ "net/http"
+ "strconv"
+
+ charts "github.com/vicanso/go-charts"
+)
+
+var html = `
+
+
+
+
+
+
+ go-charts
+
+
+ {{body}}
+
+
+`
+
+func handler(w http.ResponseWriter, req *http.Request, chartOptions []charts.ChartOption, echartsOptions []string) {
+ if req.URL.Path != "/" &&
+ req.URL.Path != "/echarts" {
+ return
+ }
+ query := req.URL.Query()
+ theme := query.Get("theme")
+ width, _ := strconv.Atoi(query.Get("width"))
+ height, _ := strconv.Atoi(query.Get("height"))
+ charts.SetDefaultWidth(width)
+ charts.SetDefaultWidth(height)
+ bytesList := make([][]byte, 0)
+ for _, opt := range chartOptions {
+ opt.Theme = theme
+ d, err := charts.Render(opt)
+ if err != nil {
+ panic(err)
+ }
+ buf, err := d.Bytes()
+ if err != nil {
+ panic(err)
+ }
+ bytesList = append(bytesList, buf)
+ }
+ // for _, opt := range echartsOptions {
+ // buf, err := charts.RenderEChartsToSVG(opt)
+ // if err != nil {
+ // panic(err)
+ // }
+ // bytesList = append(bytesList, buf)
+ // }
+
+ data := bytes.ReplaceAll([]byte(html), []byte("{{body}}"), bytes.Join(bytesList, []byte("")))
+ w.Header().Set("Content-Type", "text/html")
+ w.Write(data)
+}
+
+func indexHandler(w http.ResponseWriter, req *http.Request) {
+ chartOptions := []charts.ChartOption{
+ {
+ Title: charts.TitleOption{
+ Text: "Line",
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ }),
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }),
+ SeriesList: []charts.Series{
+ charts.NewSeriesFromValues([]float64{
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320,
+ }),
+ charts.NewSeriesFromValues([]float64{
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320,
+ }),
+ },
+ },
+ // 温度折线图
+ {
+ Title: charts.TitleOption{
+ Text: "Temperature Change in the Coming Week",
+ },
+ Padding: charts.Box{
+ Top: 20,
+ Left: 20,
+ Right: 30,
+ Bottom: 20,
+ },
+ Legend: charts.NewLegendOption([]string{
+ "Highest",
+ "Lowest",
+ }, charts.PositionRight),
+ XAxis: charts.NewXAxisOption([]string{
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun",
+ }, charts.FalseFlag()),
+ SeriesList: []charts.Series{
+ {
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 14,
+ 11,
+ 13,
+ 11,
+ 12,
+ 12,
+ 7,
+ }),
+ MarkPoint: charts.NewMarkPoint(charts.SeriesMarkDataTypeMax, charts.SeriesMarkDataTypeMin),
+ MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage),
+ },
+ {
+ Data: charts.NewSeriesDataFromValues([]float64{
+ 1,
+ -2,
+ 2,
+ 5,
+ 3,
+ 2,
+ 0,
+ }),
+ MarkLine: charts.NewMarkLine(charts.SeriesMarkDataTypeAverage),
+ },
+ },
+ },
+ }
+ handler(w, req, chartOptions, nil)
+}
+
+func echartsHandler(w http.ResponseWriter, req *http.Request) {
+ echartsOptions := []string{
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 150,
+ 230,
+ 224,
+ 218,
+ 135,
+ 147,
+ 260
+ ],
+ "type": "line"
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Multiple Line"
+ },
+ "tooltip": {
+ "trigger": "axis"
+ },
+ "legend": {
+ "left": "right",
+ "data": [
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine"
+ ]
+ },
+ "grid": {
+ "left": "3%",
+ "right": "4%",
+ "bottom": "3%",
+ "containLabel": true
+ },
+ "toolbox": {
+ "feature": {
+ "saveAsImage": {}
+ }
+ },
+ "xAxis": {
+ "type": "category",
+ "boundaryGap": false,
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "name": "Email",
+ "type": "line",
+ "data": [
+ 120,
+ 132,
+ 101,
+ 134,
+ 90,
+ 230,
+ 210
+ ]
+ },
+ {
+ "name": "Union Ads",
+ "type": "line",
+ "data": [
+ 220,
+ 182,
+ 191,
+ 234,
+ 290,
+ 330,
+ 310
+ ]
+ },
+ {
+ "name": "Video Ads",
+ "type": "line",
+ "data": [
+ 150,
+ 232,
+ 201,
+ 154,
+ 190,
+ 330,
+ 410
+ ]
+ },
+ {
+ "name": "Direct",
+ "type": "line",
+ "data": [
+ 320,
+ 332,
+ 301,
+ 334,
+ 390,
+ 330,
+ 320
+ ]
+ },
+ {
+ "name": "Search Engine",
+ "type": "line",
+ "data": [
+ 820,
+ 932,
+ 901,
+ 934,
+ 1290,
+ 1330,
+ 1320
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Temperature Change in the Coming Week"
+ },
+ "legend": {
+ "left": "right"
+ },
+ "padding": [10, 30, 10, 10],
+ "xAxis": {
+ "type": "category",
+ "boundaryGap": false,
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ },
+ "series": [
+ {
+ "name": "Highest",
+ "type": "line",
+ "data": [
+ 10,
+ 11,
+ 13,
+ 11,
+ 12,
+ 12,
+ 9
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ },
+ {
+ "name": "Lowest",
+ "type": "line",
+ "data": [
+ 1,
+ -2,
+ 2,
+ 5,
+ 3,
+ 2,
+ 0
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ },
+ {
+ "type": "max"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 120,
+ 200,
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
+ }
+ ]
+ }`,
+ `{
+ "xAxis": {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ]
+ },
+ "yAxis": {
+ "type": "value"
+ },
+ "series": [
+ {
+ "data": [
+ 120,
+ {
+ "value": 200,
+ "itemStyle": {
+ "color": "#a90000"
+ }
+ },
+ 150,
+ 80,
+ 70,
+ 110,
+ 130
+ ],
+ "type": "bar"
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Rainfall vs Evaporation",
+ "subtext": "Fake Data"
+ },
+ "legend": {
+ "data": [
+ "Rainfall",
+ "Evaporation"
+ ]
+ },
+ "padding": [10, 30, 10, 10],
+ "xAxis": [
+ {
+ "type": "category",
+ "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
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ },
+ {
+ "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
+ ],
+ "markPoint": {
+ "data": [
+ {
+ "type": "max"
+ },
+ {
+ "type": "min"
+ }
+ ]
+ },
+ "markLine": {
+ "data": [
+ {
+ "type": "average"
+ }
+ ]
+ }
+ }
+ ]
+ }`,
+ `{
+ "legend": {
+ "data": [
+ "Evaporation",
+ "Precipitation",
+ "Temperature"
+ ]
+ },
+ "xAxis": [
+ {
+ "type": "category",
+ "data": [
+ "Mon",
+ "Tue",
+ "Wed",
+ "Thu",
+ "Fri",
+ "Sat",
+ "Sun"
+ ],
+ "axisPointer": {
+ "type": "shadow"
+ }
+ }
+ ],
+ "yAxis": [
+ {
+ "type": "value",
+ "name": "Precipitation",
+ "min": 0,
+ "max": 240,
+ "axisLabel": {
+ "formatter": "{value} ml"
+ }
+ },
+ {
+ "type": "value",
+ "name": "Temperature",
+ "min": 0,
+ "max": 24,
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ }
+ ],
+ "series": [
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "tooltip": {},
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ]
+ },
+ {
+ "name": "Precipitation",
+ "type": "bar",
+ "tooltip": {},
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ]
+ },
+ {
+ "name": "Temperature",
+ "type": "line",
+ "yAxisIndex": 1,
+ "tooltip": {},
+ "data": [
+ 2,
+ 2.2,
+ 3.3,
+ 4.5,
+ 6.3,
+ 10.2,
+ 20.3,
+ 23.4,
+ 23,
+ 16.5,
+ 12,
+ 6.2
+ ]
+ }
+ ]
+ }`,
+ `{
+ "tooltip": {
+ "trigger": "axis",
+ "axisPointer": {
+ "type": "cross"
+ }
+ },
+ "grid": {
+ "right": "20%"
+ },
+ "toolbox": {
+ "feature": {
+ "dataView": {
+ "show": true,
+ "readOnly": false
+ },
+ "restore": {
+ "show": true
+ },
+ "saveAsImage": {
+ "show": true
+ }
+ }
+ },
+ "legend": {
+ "data": [
+ "Evaporation",
+ "Precipitation",
+ "Temperature"
+ ]
+ },
+ "xAxis": [
+ {
+ "type": "category",
+ "axisTick": {
+ "alignWithLabel": true
+ },
+ "data": [
+ "Jan",
+ "Feb",
+ "Mar",
+ "Apr",
+ "May",
+ "Jun",
+ "Jul",
+ "Aug",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dec"
+ ]
+ }
+ ],
+ "yAxis": [
+ {
+ "type": "value",
+ "name": "温度",
+ "position": "left",
+ "alignTicks": true,
+ "axisLine": {
+ "show": true,
+ "lineStyle": {
+ "color": "#EE6666"
+ }
+ },
+ "axisLabel": {
+ "formatter": "{value} °C"
+ }
+ },
+ {
+ "type": "value",
+ "name": "Evaporation",
+ "position": "right",
+ "alignTicks": true,
+ "axisLine": {
+ "show": true,
+ "lineStyle": {
+ "color": "#5470C6"
+ }
+ },
+ "axisLabel": {
+ "formatter": "{value} ml"
+ }
+ }
+ ],
+ "series": [
+ {
+ "name": "Evaporation",
+ "type": "bar",
+ "yAxisIndex": 1,
+ "data": [
+ 2,
+ 4.9,
+ 7,
+ 23.2,
+ 25.6,
+ 76.7,
+ 135.6,
+ 162.2,
+ 32.6,
+ 20,
+ 6.4,
+ 3.3
+ ]
+ },
+ {
+ "name": "Precipitation",
+ "type": "bar",
+ "yAxisIndex": 1,
+ "data": [
+ 2.6,
+ 5.9,
+ 9,
+ 26.4,
+ 28.7,
+ 70.7,
+ 175.6,
+ 182.2,
+ 48.7,
+ 18.8,
+ 6,
+ 2.3
+ ]
+ },
+ {
+ "name": "Temperature",
+ "type": "line",
+ "data": [
+ 2,
+ 2.2,
+ 3.3,
+ 4.5,
+ 6.3,
+ 10.2,
+ 20.3,
+ 23.4,
+ 23,
+ 16.5,
+ 12,
+ 6.2
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Referer of a Website",
+ "subtext": "Fake Data",
+ "left": "center"
+ },
+ "tooltip": {
+ "trigger": "item"
+ },
+ "legend": {
+ "orient": "vertical",
+ "left": "left"
+ },
+ "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"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Rainfall"
+ },
+ "padding": [10, 10, 10, 30],
+ "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": {
+ "text": "Basic Radar Chart"
+ },
+ "legend": {
+ "data": [
+ "Allocated Budget",
+ "Actual Spending"
+ ]
+ },
+ "radar": {
+ "indicator": [
+ {
+ "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
+ }
+ ]
+ },
+ "series": [
+ {
+ "name": "Budget vs spending",
+ "type": "radar",
+ "data": [
+ {
+ "value": [
+ 4200,
+ 3000,
+ 20000,
+ 35000,
+ 50000,
+ 18000
+ ],
+ "name": "Allocated Budget"
+ },
+ {
+ "value": [
+ 5000,
+ 14000,
+ 28000,
+ 26000,
+ 42000,
+ 21000
+ ],
+ "name": "Actual Spending"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "title": {
+ "text": "Funnel"
+ },
+ "tooltip": {
+ "trigger": "item",
+ "formatter": "{a}
{b} : {c}%"
+ },
+ "toolbox": {
+ "feature": {
+ "dataView": {
+ "readOnly": false
+ },
+ "restore": {},
+ "saveAsImage": {}
+ }
+ },
+ "legend": {
+ "data": [
+ "Show",
+ "Click",
+ "Visit",
+ "Inquiry",
+ "Order"
+ ]
+ },
+ "series": [
+ {
+ "name": "Funnel",
+ "type": "funnel",
+ "left": "10%",
+ "top": 60,
+ "bottom": 60,
+ "width": "80%",
+ "min": 0,
+ "max": 100,
+ "minSize": "0%",
+ "maxSize": "100%",
+ "sort": "descending",
+ "gap": 2,
+ "label": {
+ "show": true,
+ "position": "inside"
+ },
+ "labelLine": {
+ "length": 10,
+ "lineStyle": {
+ "width": 1,
+ "type": "solid"
+ }
+ },
+ "itemStyle": {
+ "borderColor": "#fff",
+ "borderWidth": 1
+ },
+ "emphasis": {
+ "label": {
+ "fontSize": 20
+ }
+ },
+ "data": [
+ {
+ "value": 60,
+ "name": "Visit"
+ },
+ {
+ "value": 40,
+ "name": "Inquiry"
+ },
+ {
+ "value": 20,
+ "name": "Order"
+ },
+ {
+ "value": 80,
+ "name": "Click"
+ },
+ {
+ "value": 100,
+ "name": "Show"
+ }
+ ]
+ }
+ ]
+ }`,
+ `{
+ "legend": {
+ "top": "-140",
+ "data": [
+ "Milk Tea",
+ "Matcha Latte",
+ "Cheese Cocoa",
+ "Walnut Brownie"
+ ]
+ },
+ "padding": [
+ 150,
+ 10,
+ 10,
+ 10
+ ],
+ "xAxis": [
+ {
+ "data": [
+ "2012",
+ "2013",
+ "2014",
+ "2015",
+ "2016",
+ "2017"
+ ]
+ }
+ ],
+ "series": [
+ {
+ "data": [
+ 56.5,
+ 82.1,
+ 88.7,
+ 70.1,
+ 53.4,
+ 85.1
+ ]
+ },
+ {
+ "data": [
+ 51.1,
+ 51.4,
+ 55.1,
+ 53.3,
+ 73.8,
+ 68.7
+ ]
+ },
+ {
+ "data": [
+ 40.1,
+ 62.2,
+ 69.5,
+ 36.4,
+ 45.2,
+ 32.5
+ ]
+ },
+ {
+ "data": [
+ 25.2,
+ 37.1,
+ 41.2,
+ 18,
+ 33.9,
+ 49.1
+ ]
+ }
+ ],
+ "children": [
+ {
+ "box": {
+ "left": 0,
+ "top": 30,
+ "right": 600,
+ "bottom": 150
+ },
+ "legend": {
+ "show": false
+ },
+ "series": [
+ {
+ "type": "pie",
+ "radius": "50%",
+ "data": [
+ {
+ "value": 435.9,
+ "name": "Milk Tea"
+ },
+ {
+ "value": 354.3,
+ "name": "Matcha Latte"
+ },
+ {
+ "value": 285.9,
+ "name": "Cheese Cocoa"
+ },
+ {
+ "value": 204.5,
+ "name": "Walnut Brownie"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }`,
+ }
+ handler(w, req, nil, echartsOptions)
+}
+
+func main() {
+ http.HandleFunc("/", indexHandler)
+ http.HandleFunc("/echarts", echartsHandler)
+ http.ListenAndServe(":3012", nil)
+}
diff --git a/examples/line_chart/main.go b/examples/line_chart/main.go
index e15500c..c168f08 100644
--- a/examples/line_chart/main.go
+++ b/examples/line_chart/main.go
@@ -39,6 +39,19 @@ func main() {
Right: 10,
Bottom: 10,
},
+ TitleOption: charts.TitleOption{
+ Text: "Line",
+ },
+ LegendOption: charts.LegendOption{
+ Data: []string{
+ "Email",
+ "Union Ads",
+ "Video Ads",
+ "Direct",
+ "Search Engine",
+ },
+ Left: charts.PositionCenter,
+ },
XAxis: charts.NewXAxisOption([]string{
"Mon",
"Tue",
diff --git a/legend.go b/legend.go
index 16341e4..b8a6fdc 100644
--- a/legend.go
+++ b/legend.go
@@ -29,13 +29,13 @@ import (
type legendPainter struct {
p *Painter
- opt *LegendPainterOption
+ opt *LegendOption
}
const IconRect = "rect"
const IconLineDot = "lineDot"
-type LegendPainterOption struct {
+type LegendOption struct {
Theme ColorPalette
// Text array of legend
Data []string
@@ -58,7 +58,17 @@ type LegendPainterOption struct {
FontColor Color
}
-func NewLegendPainter(p *Painter, opt LegendPainterOption) *legendPainter {
+func NewLegendOption(labels []string, left ...string) LegendOption {
+ opt := LegendOption{
+ Data: labels,
+ }
+ if len(left) != 0 {
+ opt.Left = left[0]
+ }
+ return opt
+}
+
+func NewLegendPainter(p *Painter, opt LegendOption) *legendPainter {
return &legendPainter{
p: p,
opt: &opt,
@@ -71,6 +81,12 @@ func (l *legendPainter) Render() (Box, error) {
if theme == nil {
theme = l.p.theme
}
+ if opt.FontSize == 0 {
+ opt.FontSize = theme.GetFontSize()
+ }
+ if opt.FontColor.IsZero() {
+ opt.FontColor = theme.GetTextColor()
+ }
p := l.p
p.SetTextStyle(Style{
FontSize: opt.FontSize,
@@ -129,7 +145,7 @@ func (l *legendPainter) Render() (Box, error) {
top, _ := strconv.Atoi(opt.Top)
x := int(left)
- y := int(top)
+ y := int(top) + 10
x0 := x
y0 := y
diff --git a/line_chart.go b/line_chart.go
index 3d93341..5f4ea4f 100644
--- a/line_chart.go
+++ b/line_chart.go
@@ -23,7 +23,9 @@
package charts
import (
+ "github.com/golang/freetype/truetype"
"github.com/wcharczuk/go-chart/v2"
+ "github.com/wcharczuk/go-chart/v2/drawing"
)
type lineChart struct {
@@ -43,6 +45,8 @@ func NewLineChart(p *Painter, opt LineChartOption) *lineChart {
type LineChartOption struct {
Theme ColorPalette
+ // The font size
+ Font *truetype.Font
// The data series list
SeriesList SeriesList
// The x axis option
@@ -51,6 +55,10 @@ type LineChartOption struct {
Padding Box
// The y axis option
YAxisOptions []YAxisOption
+ // The option of title
+ TitleOption TitleOption
+ // The legend option
+ LegendOption LegendOption
}
func (l *lineChart) Render() (Box, error) {
@@ -64,6 +72,8 @@ func (l *lineChart) Render() (Box, error) {
SeriesList: seriesList,
XAxis: opt.XAxis,
YAxisOptions: opt.YAxisOptions,
+ TitleOption: opt.TitleOption,
+ LegendOption: opt.LegendOption,
})
if err != nil {
return chart.BoxZero, err
@@ -78,13 +88,20 @@ func (l *lineChart) Render() (Box, error) {
for i := 0; i < len(xDivideValues)-1; i++ {
xValues[i] = (xDivideValues[i] + xDivideValues[i+1]) >> 1
}
+ markPointPainter := NewMarkPointPainter(seriesPainter)
+ markLinePainter := NewMarkLinePainter(seriesPainter)
+ rendererList := []Renderer{
+ markPointPainter,
+ markLinePainter,
+ }
for index, series := range seriesList {
seriesColor := opt.Theme.GetSeriesColor(index)
- seriesPainter.SetDrawingStyle(Style{
+ drawingStyle := Style{
StrokeColor: seriesColor,
- StrokeWidth: 2,
- FillColor: seriesColor,
- })
+ StrokeWidth: defaultStrokeWidth,
+ }
+
+ seriesPainter.SetDrawingStyle(drawingStyle)
yr := renderResult.axisRanges[series.AxisIndex]
points := make([]Point, 0)
for i, item := range series.Data {
@@ -95,8 +112,39 @@ func (l *lineChart) Render() (Box, error) {
}
points = append(points, p)
}
+ // 画线
seriesPainter.LineStroke(points)
+
+ // 画点
+ if opt.Theme.IsDark() {
+ drawingStyle.FillColor = drawingStyle.StrokeColor
+ } else {
+ drawingStyle.FillColor = drawing.ColorWhite
+ }
+ drawingStyle.StrokeWidth = 1
+ seriesPainter.SetDrawingStyle(drawingStyle)
seriesPainter.Dots(points)
+ markPointPainter.Add(markPointRenderOption{
+ FillColor: seriesColor,
+ Font: opt.Font,
+ Points: points,
+ Series: series,
+ })
+ markLinePainter.Add(markLineRenderOption{
+ FillColor: seriesColor,
+ FontColor: opt.Theme.GetTextColor(),
+ StrokeColor: seriesColor,
+ Font: opt.Font,
+ Series: series,
+ Range: yr,
+ })
+ }
+ // 最大、最小的mark point
+ for _, renderer := range rendererList {
+ _, err = renderer.Render()
+ if err != nil {
+ return chart.BoxZero, err
+ }
}
return p.box, nil
diff --git a/mark_line.go b/mark_line.go
new file mode 100644
index 0000000..9a9d568
--- /dev/null
+++ b/mark_line.go
@@ -0,0 +1,118 @@
+// 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"
+)
+
+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 markLinePainter struct {
+ p *Painter
+ options []markLineRenderOption
+}
+
+func (m *markLinePainter) Add(opt markLineRenderOption) {
+ m.options = append(m.options, opt)
+}
+
+func NewMarkLinePainter(p *Painter) *markLinePainter {
+ return &markLinePainter{
+ p: p,
+ options: make([]markLineRenderOption, 0),
+ }
+}
+
+type markLineRenderOption struct {
+ FillColor Color
+ FontColor Color
+ StrokeColor Color
+ Font *truetype.Font
+ Series Series
+ Range axisRange
+}
+
+func (m *markLinePainter) Render() (Box, error) {
+ painter := m.p
+ for _, opt := range m.options {
+ s := opt.Series
+ if len(s.MarkLine.Data) == 0 {
+ continue
+ }
+ summary := s.Summary()
+ for _, markLine := range s.MarkLine.Data {
+ // 由于mark line会修改style,因此每次重新设置
+ painter.OverrideDrawingStyle(Style{
+ FillColor: opt.FillColor,
+ StrokeColor: opt.StrokeColor,
+ StrokeWidth: 1,
+ StrokeDashArray: []float64{
+ 4,
+ 2,
+ },
+ }).OverrideTextStyle(Style{
+ Font: opt.Font,
+ FontColor: opt.FontColor,
+ FontSize: labelFontSize,
+ })
+ 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 := painter.Width()
+ text := commafWithDigits(value)
+ textBox := painter.MeasureText(text)
+ painter.MarkLine(0, y, width-2)
+ painter.Text(text, width, y+textBox.Height()>>1-2)
+ }
+ }
+ return chart.BoxZero, nil
+}
+
+func markLineRender(opt markLineRenderOption) {
+ // d := opt.Draw
+ // s := opt.Series
+ // if len(s.MarkLine.Data) == 0 {
+ // return
+ // }
+ // r := d.Render
+
+}
diff --git a/mark_point.go b/mark_point.go
new file mode 100644
index 0000000..ce3cb0f
--- /dev/null
+++ b/mark_point.go
@@ -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 (
+ "github.com/golang/freetype/truetype"
+ "github.com/wcharczuk/go-chart/v2"
+)
+
+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 markPointPainter struct {
+ p *Painter
+ options []markPointRenderOption
+}
+
+func (m *markPointPainter) Add(opt markPointRenderOption) {
+ m.options = append(m.options, opt)
+}
+
+type markPointRenderOption struct {
+ FillColor Color
+ Font *truetype.Font
+ Series Series
+ Points []Point
+}
+
+func NewMarkPointPainter(p *Painter) *markPointPainter {
+ return &markPointPainter{
+ p: p,
+ options: make([]markPointRenderOption, 0),
+ }
+}
+
+func (m *markPointPainter) Render() (Box, error) {
+ painter := m.p
+ for _, opt := range m.options {
+ s := opt.Series
+ if len(s.MarkPoint.Data) == 0 {
+ continue
+ }
+ points := opt.Points
+ summary := s.Summary()
+ symbolSize := s.MarkPoint.SymbolSize
+ if symbolSize == 0 {
+ symbolSize = 30
+ }
+ painter.OverrideDrawingStyle(Style{
+ FillColor: opt.FillColor,
+ }).OverrideTextStyle(Style{
+ FontColor: NewTheme(ThemeDark).GetTextColor(),
+ FontSize: labelFontSize,
+ StrokeWidth: 1,
+ Font: opt.Font,
+ })
+ 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
+ }
+
+ painter.Pin(p.X, p.Y-symbolSize>>1, symbolSize)
+ text := commafWithDigits(value)
+ textBox := painter.MeasureText(text)
+ painter.Text(text, p.X-textBox.Width()>>1, p.Y-symbolSize>>1-2)
+ }
+ }
+ return chart.BoxZero, nil
+}
diff --git a/painter.go b/painter.go
index 75d4a38..fff6ca7 100644
--- a/painter.go
+++ b/painter.go
@@ -700,7 +700,7 @@ func (p *Painter) Grid(opt GridOption) *Painter {
func (p *Painter) Dots(points []Point) *Painter {
for _, item := range points {
- p.Circle(3, item.X, item.Y)
+ p.Circle(2, item.X, item.Y)
}
p.FillStroke()
return p
diff --git a/title.go b/title.go
new file mode 100644
index 0000000..30831ac
--- /dev/null
+++ b/title.go
@@ -0,0 +1,192 @@
+// 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 (
+ "strconv"
+ "strings"
+
+ "github.com/golang/freetype/truetype"
+)
+
+type TitleOption struct {
+ // The theme of chart
+ Theme ColorPalette
+ // Title text, support \n for new line
+ Text string
+ // Subtitle text, support \n for new line
+ Subtext string
+ // // Title style
+ // Style Style
+ // // Subtitle style
+ // SubtextStyle 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
+ // The font of label
+ Font *truetype.Font
+ // The font size of label
+ FontSize float64
+ // The color of label
+ FontColor Color
+ // The subtext font size of label
+ SubtextFontSize float64
+ // The subtext font color of label
+ SubtextFontColor Color
+}
+
+type titleMeasureOption struct {
+ width int
+ height int
+ text string
+ style Style
+}
+
+func splitTitleText(text string) []string {
+ arr := strings.Split(text, "\n")
+ result := make([]string, 0)
+ for _, v := range arr {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ continue
+ }
+ result = append(result, v)
+ }
+ return result
+}
+
+type titlePainter struct {
+ p *Painter
+ opt *TitleOption
+}
+
+func NewTitlePainter(p *Painter, opt TitleOption) *titlePainter {
+ return &titlePainter{
+ p: p,
+ opt: &opt,
+ }
+}
+
+func (t *titlePainter) Render() (Box, error) {
+ opt := t.opt
+ p := t.p
+ theme := opt.Theme
+ measureOptions := make([]titleMeasureOption, 0)
+
+ if opt.Font == nil {
+ opt.Font = theme.GetFont()
+ }
+ if opt.FontColor.IsZero() {
+ opt.FontColor = theme.GetTextColor()
+ }
+ if opt.FontSize == 0 {
+ opt.FontSize = theme.GetFontSize()
+ }
+ if opt.SubtextFontColor.IsZero() {
+ opt.SubtextFontColor = opt.FontColor
+ }
+ if opt.SubtextFontSize == 0 {
+ opt.SubtextFontSize = opt.FontSize
+ }
+
+ titleTextStyle := Style{
+ Font: opt.Font,
+ FontSize: opt.FontSize,
+ FontColor: opt.FontColor,
+ }
+ // 主标题
+ for _, v := range splitTitleText(opt.Text) {
+ measureOptions = append(measureOptions, titleMeasureOption{
+ text: v,
+ style: titleTextStyle,
+ })
+ }
+ subtextStyle := Style{
+ Font: opt.Font,
+ FontSize: opt.SubtextFontSize,
+ FontColor: opt.SubtextFontColor,
+ }
+ // 副标题
+ for _, v := range splitTitleText(opt.Subtext) {
+ measureOptions = append(measureOptions, titleMeasureOption{
+ text: v,
+ style: subtextStyle,
+ })
+ }
+ textMaxWidth := 0
+ textMaxHeight := 0
+ for index, item := range measureOptions {
+ p.OverrideTextStyle(item.style)
+ textBox := p.MeasureText(item.text)
+
+ w := textBox.Width()
+ h := textBox.Height()
+ if w > textMaxWidth {
+ textMaxWidth = w
+ }
+ if h > textMaxHeight {
+ textMaxHeight = h
+ }
+ measureOptions[index].height = h
+ measureOptions[index].width = w
+ }
+ width := textMaxWidth
+
+ titleX := 0
+ switch opt.Left {
+ case PositionRight:
+ titleX = p.Width() - textMaxWidth
+ case PositionCenter:
+ titleX = p.Width()>>1 - (textMaxWidth >> 1)
+ default:
+ if strings.HasSuffix(opt.Left, "%") {
+ value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", ""))
+ titleX = p.Width() * value / 100
+ } else {
+ value, _ := strconv.Atoi(opt.Left)
+ titleX = value
+ }
+ }
+ titleY := 0
+ // TODO TOP 暂只支持数值
+ if opt.Top != "" {
+ value, _ := strconv.Atoi(opt.Top)
+ titleY += value
+ }
+ for _, item := range measureOptions {
+ p.OverrideTextStyle(item.style)
+ x := titleX + (textMaxWidth-item.width)>>1
+ y := titleY + item.height
+ p.Text(item.text, x, y)
+ titleY += item.height
+ }
+
+ return Box{
+ Bottom: titleY,
+ Right: titleX + width,
+ }, nil
+}