1
0
mirror of https://github.com/nikolaydubina/calendarheatmap.git synced 2024-12-12 13:25:21 +02:00
calendarheatmap/charts/charts.go
2021-08-10 18:42:20 +08:00

255 lines
5.8 KiB
Go

package charts
import (
"fmt"
"image"
"image/color"
"image/draw"
"image/gif"
"image/jpeg"
"image/png"
"io"
"time"
"golang.org/x/image/font"
)
// ColorScale is interface for extracting color based on value from 0 to 1
type ColorScale interface {
GetColor(val float64) color.RGBA
}
var weekdayOrder = [7]time.Weekday{
time.Monday,
time.Tuesday,
time.Wednesday,
time.Thursday,
time.Friday,
time.Saturday,
time.Sunday,
}
const (
numWeeksYear = 52
numWeekCols = numWeeksYear + 1 // 53 * 7 = 371 > 366
)
// HeatmapConfig contains config of calendar heatmap image
type HeatmapConfig struct {
Counts map[string]int
ColorScale ColorScale
DrawMonthSeparator bool
DrawLabels bool
BoxSize int
Margin int
MonthSeparatorWidth int
MonthLabelYOffset int
TextWidthLeft int
TextHeightTop int
TextColor color.RGBA
BorderColor color.RGBA
Locale string
Format string
FontFace font.Face
ShowWeekdays map[time.Weekday]bool
}
// WriteHeatmap writes image with heatmap and additional elements
func WriteHeatmap(conf HeatmapConfig, w io.Writer) error {
if conf.Format == "svg" {
writeSVG(conf, w)
return nil
}
width := conf.TextWidthLeft + numWeekCols*(conf.BoxSize+conf.Margin)
height := conf.TextHeightTop + 7*(conf.BoxSize+conf.Margin)
offset := image.Point{X: conf.TextWidthLeft, Y: conf.TextHeightTop}
img := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(img, img.Bounds(), &image.Uniform{color.White}, image.ZP, draw.Src)
visitors := []DayVisitor{
&DayBoxVisitor{img, conf.ColorScale, conf.BoxSize},
}
if conf.DrawMonthSeparator {
visitors = append(
visitors,
&MonthSeparatorVisitor{
Img: img,
MinY: conf.TextHeightTop,
MaxY: height - conf.Margin,
Margin: conf.Margin,
BoxSize: conf.BoxSize,
Width: conf.MonthSeparatorWidth,
Color: conf.BorderColor,
},
)
}
labelsProvider := NewLabelsProvider(conf.Locale)
if conf.DrawLabels {
visitors = append(visitors, &MonthLabelsVisitor{
FontFace: conf.FontFace,
Img: img,
YOffset: conf.MonthLabelYOffset,
Color: conf.TextColor,
LabelsProvider: labelsProvider,
})
drawWeekdayLabels(
conf.FontFace,
img,
image.Point{X: 0, Y: conf.TextHeightTop},
conf.ShowWeekdays,
conf.BoxSize,
conf.Margin,
conf.TextColor,
labelsProvider,
)
}
for iter := NewDayIterator(conf.Counts, offset, conf.BoxSize, conf.Margin); !iter.Done(); iter.Next() {
for _, v := range visitors {
v.Visit(iter)
}
}
switch conf.Format {
case "png":
if err := png.Encode(w, img); err != nil {
return err
}
case "jpeg":
if err := jpeg.Encode(w, img, nil); err != nil {
return err
}
case "gif":
if err := gif.Encode(w, img, nil); err != nil {
return err
}
default:
return fmt.Errorf("unexpected format")
}
return nil
}
// DayVisitor is interface to update image based on current box
type DayVisitor interface {
Visit(iter *DayIterator)
}
// DayBoxVisitor draws signle heatbox
type DayBoxVisitor struct {
Img *image.RGBA
ColorScale ColorScale
BoxSize int
}
// Visit called on every iteration
func (d *DayBoxVisitor) Visit(iter *DayIterator) {
p := iter.Point()
r := image.Rect(p.X, p.Y, p.X+d.BoxSize, p.Y+d.BoxSize)
color := d.ColorScale.GetColor(iter.Value())
draw.Draw(d.Img, r, &image.Uniform{color}, image.ZP, draw.Src)
}
// MonthSeparatorVisitor draws month separator
type MonthSeparatorVisitor struct {
Img *image.RGBA
MinY int
MaxY int
Margin int
BoxSize int
Width int
Color color.RGBA
}
// Visit called on every iteration
func (d *MonthSeparatorVisitor) Visit(iter *DayIterator) {
day := iter.Time()
if day.Day() == 1 && day.Month() != time.January {
p := iter.Point()
marginSep := d.Margin / 2
xL := p.X - marginSep - d.Width/2
xR := p.X + d.BoxSize + marginSep
// left vertical line
draw.Draw(
d.Img,
image.Rect(xL, p.Y, xL+d.Width, d.MaxY),
&image.Uniform{d.Color},
image.ZP,
draw.Src,
)
if day.Weekday() != weekdayOrder[0] {
// right vertical line
draw.Draw(
d.Img,
image.Rect(xR, d.MinY, xR+d.Width, p.Y-marginSep),
&image.Uniform{d.Color},
image.ZP,
draw.Src,
)
// horizontal line
draw.Draw(
d.Img,
image.Rect(xL, p.Y-marginSep, xR+d.Width, p.Y-marginSep-d.Width),
&image.Uniform{d.Color},
image.ZP,
draw.Src,
)
// connect left vertical line and horizontal one
draw.Draw(
d.Img,
image.Rect(xL, p.Y-marginSep-d.Width, xL+d.Width, p.Y),
&image.Uniform{d.Color},
image.ZP,
draw.Src,
)
}
}
}
// MonthLabelsVisitor draws month label on top of first row 0 of month
type MonthLabelsVisitor struct {
Img *image.RGBA
YOffset int
Color color.RGBA
LabelsProvider LabelsProvider
FontFace font.Face
}
// Visit on every iteration
func (d *MonthLabelsVisitor) Visit(iter *DayIterator) {
day := iter.Time()
// Note, day is from 1~31
if iter.Row == 0 && day.Day() <= 7 {
p := iter.Point()
drawText(
d.FontFace,
d.Img,
image.Point{X: p.X, Y: p.Y - d.YOffset},
d.LabelsProvider.GetMonth(day.Month()),
d.Color,
)
}
}
// drawWeekdayLabel draws column of same width labels for weekdays
// All weekday labels assumed to have same width, which really depends on font.
// offset argument is top right corner of where to insert column of weekday labels.
func drawWeekdayLabels(fontFace font.Face, img *image.RGBA, offset image.Point, weekdays map[time.Weekday]bool, boxSize int, margin int, color color.RGBA, lp LabelsProvider) {
y := offset.Y + boxSize - margin
for _, w := range weekdayOrder {
if weekdays[w] {
drawText(fontFace, img, image.Point{X: offset.X, Y: y}, lp.GetWeekday(w), color)
}
y += boxSize + margin
}
}