refactor: support echarts options
This commit is contained in:
parent
4d8086a283
commit
8f7587561f
7 changed files with 294 additions and 51 deletions
26
axis.go
26
axis.go
|
|
@ -102,6 +102,32 @@ func GetXAxisAndValues(xAxis XAxis, tickPosition chart.TickPosition, theme strin
|
||||||
}, xValues
|
}, xValues
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GetSecondaryYAxis(theme string) chart.YAxis {
|
||||||
|
// TODO
|
||||||
|
if theme == ThemeDark {
|
||||||
|
return chart.YAxis{}
|
||||||
|
}
|
||||||
|
// strokeColor := drawing.Color{
|
||||||
|
// R: 224,
|
||||||
|
// G: 230,
|
||||||
|
// B: 241,
|
||||||
|
// A: 255,
|
||||||
|
// }
|
||||||
|
return chart.YAxis{
|
||||||
|
ValueFormatter: func(v interface{}) string {
|
||||||
|
value, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return humanize.Commaf(value)
|
||||||
|
},
|
||||||
|
AxisType: chart.YAxisPrimary,
|
||||||
|
GridMajorStyle: chart.Hidden(),
|
||||||
|
GridMinorStyle: chart.Hidden(),
|
||||||
|
Style: chart.Hidden(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func GetYAxis(theme string) chart.YAxis {
|
func GetYAxis(theme string) chart.YAxis {
|
||||||
// TODO
|
// TODO
|
||||||
if theme == ThemeDark {
|
if theme == ThemeDark {
|
||||||
|
|
|
||||||
76
charts.go
76
charts.go
|
|
@ -35,19 +35,25 @@ const (
|
||||||
ThemeDark = "dark"
|
ThemeDark = "dark"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultChartWidth = 800
|
||||||
|
DefaultChartHeight = 400
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Title struct {
|
Title struct {
|
||||||
Text string
|
Text string
|
||||||
|
Style chart.Style
|
||||||
}
|
}
|
||||||
Legend struct {
|
Legend struct {
|
||||||
Data []string
|
Data []string
|
||||||
}
|
}
|
||||||
Option struct {
|
Options struct {
|
||||||
Width int
|
Width int
|
||||||
Height int
|
Height int
|
||||||
Theme string
|
Theme string
|
||||||
XAxis XAxis
|
XAxis XAxis
|
||||||
// YAxis Axis
|
YAxisList []chart.YAxis
|
||||||
Series []Series
|
Series []Series
|
||||||
Title Title
|
Title Title
|
||||||
Legend Legend
|
Legend Legend
|
||||||
|
|
@ -55,27 +61,11 @@ type (
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
type Chart interface {
|
type Graph interface {
|
||||||
Render(rp chart.RendererProvider, w io.Writer) error
|
Render(rp chart.RendererProvider, w io.Writer) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type ECharOption struct {
|
func (o *Options) validate() error {
|
||||||
Title struct {
|
|
||||||
Text string
|
|
||||||
TextStyle struct {
|
|
||||||
Color string
|
|
||||||
FontFamily string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
XAxis struct {
|
|
||||||
Type string
|
|
||||||
BoundaryGap *bool
|
|
||||||
SplitNumber int
|
|
||||||
Data []string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *Option) validate() error {
|
|
||||||
xAxisCount := len(o.XAxis.Data)
|
xAxisCount := len(o.XAxis.Data)
|
||||||
if len(o.Series) == 0 {
|
if len(o.Series) == 0 {
|
||||||
return errors.New("series can not be empty")
|
return errors.New("series can not be empty")
|
||||||
|
|
@ -89,28 +79,36 @@ func (o *Option) validate() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func render(c Chart, rp chart.RendererProvider) ([]byte, error) {
|
func render(g Graph, rp chart.RendererProvider) ([]byte, error) {
|
||||||
buf := bytes.Buffer{}
|
buf := bytes.Buffer{}
|
||||||
err := c.Render(rp, &buf)
|
err := g.Render(rp, &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToPNG(c Chart) ([]byte, error) {
|
func ToPNG(g Graph) ([]byte, error) {
|
||||||
return render(c, chart.PNG)
|
return render(g, chart.PNG)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ToSVG(c Chart) ([]byte, error) {
|
func ToSVG(g Graph) ([]byte, error) {
|
||||||
return render(c, chart.SVG)
|
return render(g, chart.SVG)
|
||||||
}
|
}
|
||||||
func New(opt Option) (Chart, error) {
|
func New(opt Options) (Graph, error) {
|
||||||
err := opt.validate()
|
err := opt.validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tickPosition := opt.TickPosition
|
tickPosition := opt.TickPosition
|
||||||
|
width := opt.Width
|
||||||
|
if width <= 0 {
|
||||||
|
width = DefaultChartWidth
|
||||||
|
}
|
||||||
|
height := opt.Height
|
||||||
|
if height <= 0 {
|
||||||
|
height = DefaultChartHeight
|
||||||
|
}
|
||||||
|
|
||||||
xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme)
|
xAxis, xValues := GetXAxisAndValues(opt.XAxis, tickPosition, opt.Theme)
|
||||||
|
|
||||||
|
|
@ -131,33 +129,35 @@ func New(opt Option) (Chart, error) {
|
||||||
Label: item.Name,
|
Label: item.Name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c := &chart.PieChart{
|
g := &chart.PieChart{
|
||||||
Title: opt.Title.Text,
|
Title: opt.Title.Text,
|
||||||
Width: opt.Width,
|
TitleStyle: opt.Title.Style,
|
||||||
Height: opt.Height,
|
Width: width,
|
||||||
|
Height: height,
|
||||||
Values: values,
|
Values: values,
|
||||||
ColorPalette: &ThemeColorPalette{
|
ColorPalette: &ThemeColorPalette{
|
||||||
Theme: opt.Theme,
|
Theme: opt.Theme,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
return c, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c := &chart.Chart{
|
g := &chart.Chart{
|
||||||
Title: opt.Title.Text,
|
Title: opt.Title.Text,
|
||||||
Width: opt.Width,
|
TitleStyle: opt.Title.Style,
|
||||||
Height: opt.Height,
|
Width: width,
|
||||||
|
Height: height,
|
||||||
XAxis: xAxis,
|
XAxis: xAxis,
|
||||||
YAxis: GetYAxis(opt.Theme),
|
YAxis: GetYAxis(opt.Theme),
|
||||||
|
YAxisSecondary: GetSecondaryYAxis(opt.Theme),
|
||||||
Series: GetSeries(opt.Series, tickPosition, opt.Theme),
|
Series: GetSeries(opt.Series, tickPosition, opt.Theme),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置secondary的样式
|
// 设置secondary的样式
|
||||||
c.YAxisSecondary.Style = c.YAxis.Style
|
|
||||||
if legendSize != 0 {
|
if legendSize != 0 {
|
||||||
c.Elements = []chart.Renderable{
|
g.Elements = []chart.Renderable{
|
||||||
DefaultLegend(c),
|
DefaultLegend(g),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return c, nil
|
return g, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
171
echarts.go
Normal file
171
echarts.go
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
// MIT License
|
||||||
|
|
||||||
|
// Copyright (c) 2021 Tree Xie
|
||||||
|
|
||||||
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
// of this software and associated documentation files (the "Software"), to deal
|
||||||
|
// in the Software without restriction, including without limitation the rights
|
||||||
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
// copies of the Software, and to permit persons to whom the Software is
|
||||||
|
// furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
// The above copyright notice and this permission notice shall be included in all
|
||||||
|
// copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
// SOFTWARE.
|
||||||
|
|
||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EChartStyle struct {
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
type ECharsSeriesData struct {
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
ItemStyle EChartStyle `json:"itemStyle"`
|
||||||
|
}
|
||||||
|
type _ECharsSeriesData ECharsSeriesData
|
||||||
|
|
||||||
|
func (es *ECharsSeriesData) UnmarshalJSON(data []byte) error {
|
||||||
|
data = bytes.TrimSpace(data)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if regexp.MustCompile(`^\d+`).Match(data) {
|
||||||
|
v, err := strconv.ParseFloat(string(data), 64)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
es.Value = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := _ECharsSeriesData{}
|
||||||
|
err := json.Unmarshal(data, &v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
es.Value = v.Value
|
||||||
|
es.ItemStyle = v.ItemStyle
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type EChartsYAxis struct {
|
||||||
|
Data []struct {
|
||||||
|
Min int `json:"min"`
|
||||||
|
Max int `json:"max"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
AxisLabel struct {
|
||||||
|
Formatter string `json:"formatter"`
|
||||||
|
} `json:"axisLabel"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ey *EChartsYAxis) UnmarshalJSON(data []byte) error {
|
||||||
|
data = bytes.TrimSpace(data)
|
||||||
|
if len(data) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if data[0] != '[' {
|
||||||
|
data = []byte("[" + string(data) + "]")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(data, &ey.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ECharsOptions struct {
|
||||||
|
Title struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
TextStyle struct {
|
||||||
|
Color string `json:"color"`
|
||||||
|
FontFamily string `json:"fontFamily"`
|
||||||
|
} `json:"textStyle"`
|
||||||
|
} `json:"title"`
|
||||||
|
XAxis struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
BoundaryGap *bool `json:"boundaryGap"`
|
||||||
|
SplitNumber int `json:"splitNumber"`
|
||||||
|
Data []string `json:"data"`
|
||||||
|
} `json:"xAxis"`
|
||||||
|
YAxis EChartsYAxis `json:"yAxis"`
|
||||||
|
Legend struct {
|
||||||
|
Data []string `json:"data"`
|
||||||
|
} `json:"legend"`
|
||||||
|
Series []struct {
|
||||||
|
Data []ECharsSeriesData `json:"data"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ECharsOptions) ToOptions() Options {
|
||||||
|
o := Options{}
|
||||||
|
o.Title = Title{
|
||||||
|
Text: e.Title.Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
o.XAxis = XAxis{
|
||||||
|
Type: e.XAxis.Type,
|
||||||
|
Data: e.XAxis.Data,
|
||||||
|
SplitNumber: e.XAxis.SplitNumber,
|
||||||
|
}
|
||||||
|
|
||||||
|
o.Legend = Legend{
|
||||||
|
Data: e.Legend.Data,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO 生成yAxis
|
||||||
|
|
||||||
|
tickPosition := chart.TickPositionUnset
|
||||||
|
series := make([]Series, len(e.Series))
|
||||||
|
for index, item := range e.Series {
|
||||||
|
// bar默认tick居中
|
||||||
|
if item.Type == SeriesBar {
|
||||||
|
tickPosition = chart.TickPositionBetweenTicks
|
||||||
|
}
|
||||||
|
data := make([]SeriesData, len(item.Data))
|
||||||
|
for j, itemData := range item.Data {
|
||||||
|
sd := SeriesData{
|
||||||
|
Value: itemData.Value,
|
||||||
|
}
|
||||||
|
if itemData.ItemStyle.Color != "" {
|
||||||
|
c := parseColor(itemData.ItemStyle.Color)
|
||||||
|
sd.Style.FillColor = c
|
||||||
|
sd.Style.StrokeColor = c
|
||||||
|
}
|
||||||
|
data[j] = sd
|
||||||
|
}
|
||||||
|
series[index] = Series{
|
||||||
|
Data: data,
|
||||||
|
Type: item.Type,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.Series = series
|
||||||
|
|
||||||
|
if e.XAxis.BoundaryGap == nil || *e.XAxis.BoundaryGap {
|
||||||
|
tickPosition = chart.TickPositionBetweenTicks
|
||||||
|
}
|
||||||
|
o.TickPosition = tickPosition
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseECharsOptions(options string) (Options, error) {
|
||||||
|
e := ECharsOptions{}
|
||||||
|
err := json.Unmarshal([]byte(options), &e)
|
||||||
|
if err != nil {
|
||||||
|
return Options{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.ToOptions(), nil
|
||||||
|
}
|
||||||
13
go.mod
Normal file
13
go.mod
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
module github.com/vicanso/echarts
|
||||||
|
|
||||||
|
go 1.17
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.0
|
||||||
|
github.com/wcharczuk/go-chart/v2 v2.1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
|
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 // indirect
|
||||||
|
)
|
||||||
9
go.sum
Normal file
9
go.sum
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
|
||||||
|
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
|
github.com/wcharczuk/go-chart/v2 v2.1.0 h1:tY2slqVQ6bN+yHSnDYwZebLQFkphK4WNrVwnt7CJZ2I=
|
||||||
|
github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA=
|
||||||
|
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5 h1:QelT11PB4FXiDEXucrfNckHoFxwt8USGY1ajP1ZF5lM=
|
||||||
|
golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
15
series.go
15
series.go
|
|
@ -30,6 +30,7 @@ type SeriesData struct {
|
||||||
Value float64
|
Value float64
|
||||||
Style chart.Style
|
Style chart.Style
|
||||||
}
|
}
|
||||||
|
|
||||||
type Series struct {
|
type Series struct {
|
||||||
Type string
|
Type string
|
||||||
Name string
|
Name string
|
||||||
|
|
@ -46,6 +47,15 @@ const (
|
||||||
SeriesPie = "pie"
|
SeriesPie = "pie"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func NewSeriesDataListFromFloat(values []float64) []SeriesData {
|
||||||
|
dataList := make([]SeriesData, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
dataList[index] = SeriesData{
|
||||||
|
Value: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dataList
|
||||||
|
}
|
||||||
func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) []chart.Series {
|
func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) []chart.Series {
|
||||||
arr := make([]chart.Series, len(series))
|
arr := make([]chart.Series, len(series))
|
||||||
barCount := 0
|
barCount := 0
|
||||||
|
|
@ -60,11 +70,11 @@ func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) [
|
||||||
style := chart.Style{
|
style := chart.Style{
|
||||||
StrokeWidth: lineStrokeWidth,
|
StrokeWidth: lineStrokeWidth,
|
||||||
StrokeColor: getSeriesColor(theme, index),
|
StrokeColor: getSeriesColor(theme, index),
|
||||||
// FillColor: getSeriesColor(theme, index),
|
|
||||||
// TODO 调整为通过dot with color 生成
|
// TODO 调整为通过dot with color 生成
|
||||||
DotColor: getSeriesColor(theme, index),
|
DotColor: getSeriesColor(theme, index),
|
||||||
DotWidth: dotWith,
|
DotWidth: dotWith,
|
||||||
}
|
}
|
||||||
|
pointIndexOffset := 0
|
||||||
// 如果居中,需要多增加一个点
|
// 如果居中,需要多增加一个点
|
||||||
if tickPosition == chart.TickPositionBetweenTicks {
|
if tickPosition == chart.TickPositionBetweenTicks {
|
||||||
item.Data = append([]SeriesData{
|
item.Data = append([]SeriesData{
|
||||||
|
|
@ -72,6 +82,7 @@ func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) [
|
||||||
Value: 0.0,
|
Value: 0.0,
|
||||||
},
|
},
|
||||||
}, item.Data...)
|
}, item.Data...)
|
||||||
|
pointIndexOffset = -1
|
||||||
}
|
}
|
||||||
yValues := make([]float64, len(item.Data))
|
yValues := make([]float64, len(item.Data))
|
||||||
barCustomStyles := make([]BarSeriesCustomStyle, 0)
|
barCustomStyles := make([]BarSeriesCustomStyle, 0)
|
||||||
|
|
@ -79,7 +90,7 @@ func GetSeries(series []Series, tickPosition chart.TickPosition, theme string) [
|
||||||
yValues[i] = item.Value
|
yValues[i] = item.Value
|
||||||
if !item.Style.IsZero() {
|
if !item.Style.IsZero() {
|
||||||
barCustomStyles = append(barCustomStyles, BarSeriesCustomStyle{
|
barCustomStyles = append(barCustomStyles, BarSeriesCustomStyle{
|
||||||
PointIndex: i,
|
PointIndex: i + pointIndexOffset,
|
||||||
Index: barIndex,
|
Index: barIndex,
|
||||||
Style: item.Style,
|
Style: item.Style,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
13
theme.go
13
theme.go
|
|
@ -23,6 +23,8 @@
|
||||||
package charts
|
package charts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/wcharczuk/go-chart/v2"
|
"github.com/wcharczuk/go-chart/v2"
|
||||||
"github.com/wcharczuk/go-chart/v2/drawing"
|
"github.com/wcharczuk/go-chart/v2/drawing"
|
||||||
)
|
)
|
||||||
|
|
@ -107,3 +109,14 @@ func getSeriesColor(theme string, index int) drawing.Color {
|
||||||
}
|
}
|
||||||
return SeriesColorsLight[index%len(SeriesColorsLight)]
|
return SeriesColorsLight[index%len(SeriesColorsLight)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseColor(color string) drawing.Color {
|
||||||
|
if color == "" {
|
||||||
|
return drawing.Color{}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(color, "#") {
|
||||||
|
return drawing.ColorFromHex(color[1:])
|
||||||
|
}
|
||||||
|
// TODO
|
||||||
|
return drawing.Color{}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue