diff --git a/axis.go b/axis.go new file mode 100644 index 0000000..d2b559b --- /dev/null +++ b/axis.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 ( + "github.com/golang/freetype/truetype" +) + +type axisPainter struct { + p *Painter + opt *AxisPainterOption +} + +func NewAxisPainter(p *Painter, opt AxisPainterOption) *axisPainter { + return &axisPainter{ + p: p, + opt: &opt, + } +} + +type AxisPainterOption struct { + // The label of axis + Data []string + // The boundary gap on both sides of a coordinate axis. + // Nil or *true means the center part of two axis ticks + BoundaryGap *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 + // The line color of axis + StrokeColor Color + // The line width + StrokeWidth float64 + // The length of the axis tick + TickLength int + // The margin value of label + LabelMargin int + // The font size of label + FontSize float64 + // The font of label + Font *truetype.Font + // The color of label + FontColor 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 Color +} + +func (a *axisPainter) Render() (Box, error) { + opt := a.opt + p := a.p + + strokeWidth := opt.StrokeWidth + if strokeWidth == 0 { + strokeWidth = 1 + } + + tickCount := opt.SplitNumber + if tickCount == 0 { + tickCount = len(opt.Data) + } + + boundaryGap := true + if opt.BoundaryGap != nil && !*opt.BoundaryGap { + boundaryGap = false + } + + labelPosition := "" + if !boundaryGap { + tickCount-- + labelPosition = PositionLeft + } + + // TODO 计算unit + unit := 1 + // 如果小于0,则表示不处理 + tickLength := getDefaultInt(opt.TickLength, 5) + labelMargin := getDefaultInt(opt.LabelMargin, 5) + + textMaxWidth, textMaxHeight := p.MeasureTextMaxWidthHeight(opt.Data) + + width := 0 + height := 0 + // 垂直 + if opt.Position == PositionLeft || + opt.Position == PositionRight { + width = textMaxWidth + tickLength<<1 + height = p.Height() + } else { + width = p.Width() + height = tickLength<<1 + textMaxHeight + } + padding := Box{} + switch opt.Position { + case PositionTop: + padding.Top = p.Height() - height + case PositionLeft: + padding.Right = p.Width() - width + } + p = p.Child(PainterPaddingOption(padding)) + p.SetDrawingStyle(Style{ + StrokeColor: opt.StrokeColor, + StrokeWidth: strokeWidth, + }).OverrideTextStyle(Style{ + Font: opt.Font, + FontColor: opt.FontColor, + FontSize: opt.FontSize, + }) + + x0 := 0 + y0 := 0 + x1 := 0 + y1 := 0 + ticksPadding := 0 + labelPadding := 0 + orient := "" + textAlign := "" + + switch opt.Position { + case PositionTop: + labelPadding = labelMargin + x1 = p.Width() + y0 = labelMargin + int(opt.FontSize) + ticksPadding = int(opt.FontSize) + y1 = y0 + orient = OrientHorizontal + case PositionLeft: + orient = OrientVertical + textAlign = AlignRight + default: + labelPadding = height + x1 = p.Width() + orient = OrientHorizontal + } + + p.Child(PainterPaddingOption(Box{ + Top: ticksPadding, + })).Ticks(TicksOption{ + Count: tickCount, + Length: tickLength, + Unit: unit, + Orient: orient, + }) + + p.LineStroke([]Point{ + { + X: x0, + Y: y0, + }, + { + X: x1, + Y: y1, + }, + }) + + p.Child(PainterPaddingOption(Box{ + Top: labelPadding, + })).MultiText(MultiTextOption{ + Align: textAlign, + TextList: opt.Data, + Orient: orient, + Unit: unit, + Position: labelPosition, + }) + + return Box{ + Bottom: height, + Right: width, + }, nil +} diff --git a/examples/painter/main.go b/examples/painter/main.go index 094b98e..acbb3ef 100644 --- a/examples/painter/main.go +++ b/examples/painter/main.go @@ -27,7 +27,7 @@ func writeFile(buf []byte) error { func main() { p, err := charts.NewPainter(charts.PainterOptions{ Width: 600, - Height: 1200, + Height: 2000, Type: charts.ChartOutputPNG, }) if err != nil { @@ -429,6 +429,7 @@ func main() { Right: p.Width() - 1, Bottom: top + 30, })), charts.LegendPainterOption{ + Left: "10", Data: []string{ "Email", "Union Ads", @@ -446,6 +447,7 @@ func main() { Right: p.Width() - 1, Bottom: top + 30, })), charts.LegendPainterOption{ + Left: charts.PositionRight, Data: []string{ "Email", "Union Ads", @@ -465,6 +467,7 @@ func main() { Right: p.Width() - 1, Bottom: top + 100, })), charts.LegendPainterOption{ + Top: "10", Data: []string{ "Email", "Union Ads", @@ -476,6 +479,72 @@ func main() { FontColor: drawing.ColorBlack, }).Render() + top += 100 + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 50, + })), charts.AxisPainterOption{ + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + StrokeColor: drawing.ColorBlack, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + + top += 50 + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 1, + Right: p.Width() - 1, + Bottom: top + 50, + })), charts.AxisPainterOption{ + Position: charts.PositionTop, + BoundaryGap: charts.FalseFlag(), + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + StrokeColor: drawing.ColorBlack, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + + top += 50 + charts.NewAxisPainter(p.Child(charts.PainterBoxOption(charts.Box{ + Top: top, + Left: 10, + Right: p.Width() - 1, + Bottom: top + 200, + })), charts.AxisPainterOption{ + Position: charts.PositionLeft, + Data: []string{ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + }, + StrokeColor: drawing.ColorBlack, + FontSize: 12, + FontColor: drawing.ColorBlack, + }).Render() + buf, err := p.Bytes() if err != nil { panic(err) diff --git a/grid.go b/grid.go index 1a00381..252fe2e 100644 --- a/grid.go +++ b/grid.go @@ -22,7 +22,7 @@ package charts -type GridPainter struct { +type gridPainter struct { p *Painter opt *GridPainterOption } @@ -38,14 +38,14 @@ type GridPainterOption struct { IgnoreLastColumn bool } -func NewGridPainter(p *Painter, opt GridPainterOption) *GridPainter { - return &GridPainter{ +func NewGridPainter(p *Painter, opt GridPainterOption) *gridPainter { + return &gridPainter{ p: p, opt: &opt, } } -func (g *GridPainter) Render() (Box, error) { +func (g *gridPainter) Render() (Box, error) { opt := g.opt ignoreColumnLines := make([]int, 0) if opt.IgnoreFirstColumn { diff --git a/legend.go b/legend.go index d128272..16341e4 100644 --- a/legend.go +++ b/legend.go @@ -22,7 +22,12 @@ package charts -type LegendPainter struct { +import ( + "strconv" + "strings" +) + +type legendPainter struct { p *Painter opt *LegendPainterOption } @@ -53,14 +58,14 @@ type LegendPainterOption struct { FontColor Color } -func NewLegendPainter(p *Painter, opt LegendPainterOption) *LegendPainter { - return &LegendPainter{ +func NewLegendPainter(p *Painter, opt LegendPainterOption) *legendPainter { + return &legendPainter{ p: p, opt: &opt, } } -func (l *LegendPainter) Render() (Box, error) { +func (l *legendPainter) Render() (Box, error) { opt := l.opt theme := opt.Theme if theme == nil { @@ -80,12 +85,54 @@ func (l *LegendPainter) Render() (Box, error) { } measureList[index] = b } - x := 0 - y := 0 + + // 计算展示的宽高 + width := 0 + height := 0 offset := 20 textOffset := 2 legendWidth := 30 legendHeight := 20 + for _, item := range measureList { + if opt.Orient == OrientVertical { + height += item.Height() + } else { + width += item.Width() + } + } + if opt.Orient == OrientVertical { + width = maxTextWidth + textOffset + legendWidth + height = offset * len(opt.Data) + } else { + height = legendHeight + offsetValue := (len(opt.Data) - 1) * (offset + textOffset) + allLegendWidth := len(opt.Data) * legendWidth + width += (offsetValue + allLegendWidth) + } + + // 计算开始的位置 + left := 0 + switch opt.Left { + case PositionRight: + left = p.Width() - width + case PositionCenter: + left = (p.Width() - width) >> 1 + default: + if strings.HasSuffix(opt.Left, "%") { + value, _ := strconv.Atoi(strings.ReplaceAll(opt.Left, "%", "")) + left = p.Width() * value / 100 + } else { + value, _ := strconv.Atoi(opt.Left) + left = value + } + } + top, _ := strconv.Atoi(opt.Top) + + x := int(left) + y := int(top) + x0 := x + y0 := y + drawIcon := func(top, left int) int { if opt.Icon == IconRect { p.Rect(Box{ @@ -111,41 +158,23 @@ func (l *LegendPainter) Render() (Box, error) { StrokeColor: color, }) if opt.Align != AlignRight { - x = drawIcon(y, x) - x += textOffset + x0 = drawIcon(y0, x0) + x0 += textOffset } - p.Text(text, x, y) - x += measureList[index].Width() + p.Text(text, x0, y0) + x0 += measureList[index].Width() if opt.Align == AlignRight { - x += textOffset - x = drawIcon(0, x) + x0 += textOffset + x0 = drawIcon(0, x0) } if opt.Orient == OrientVertical { - y += offset - x = 0 + y0 += offset + x0 = x } else { - x += offset - y = 0 + x0 += offset + y0 = y } } - width := 0 - height := 0 - for _, item := range measureList { - if opt.Orient == OrientVertical { - height += item.Height() - } else { - width += item.Width() - } - } - if opt.Orient == OrientVertical { - width = maxTextWidth + textOffset + legendWidth - height = offset * len(opt.Data) - } else { - height = legendHeight - offsetValue := (len(opt.Data) - 1) * (offset + textOffset) - allLegendWidth := len(opt.Data) * legendWidth - width += (offsetValue + allLegendWidth) - } return Box{ Right: width, diff --git a/painter.go b/painter.go index 61a8e95..fb18510 100644 --- a/painter.go +++ b/painter.go @@ -417,6 +417,21 @@ func (p *Painter) MeasureText(text string) Box { return p.render.MeasureText(text) } +func (p *Painter) MeasureTextMaxWidthHeight(textList []string) (int, int) { + maxWidth := 0 + maxHeight := 0 + for _, text := range textList { + box := p.MeasureText(text) + if maxWidth < box.Width() { + maxWidth = box.Width() + } + if maxHeight < box.Height() { + maxHeight = box.Height() + } + } + return maxWidth, maxHeight +} + func (p *Painter) LineStroke(points []Point) *Painter { for index, point := range points { x := point.X