1
0
mirror of https://github.com/nikolaydubina/calendarheatmap.git synced 2025-02-07 19:40:06 +02:00
This commit is contained in:
Nikolay 2020-07-02 02:48:34 +08:00
parent a8e93fa03e
commit 908358b76d
12 changed files with 436 additions and 2 deletions

View File

@ -1,2 +1,12 @@
# calendarheatmap
Calendar heamap in plain Go
Calendar heatmap in plain Go inspired by Github contribution activity visualization
Colorscales
![PuBu9](example/chart_PuBu9.png)
![GnBu9](example/chart_GnBu9.png)
![YlGn9](example/chart_YlGn9.png)
Month separator
![PuBu9_separator](example/chart_PuBu.png)
![PuBu9_noseparator](example/chart_PuBu9_noseparator.png)
For example usage check `example/main.go` and `input.txt`.

121
charts/charts.go Normal file
View File

@ -0,0 +1,121 @@
package charts
import (
"image"
"image/color"
"image/draw"
"time"
"github.com/nikolaydubina/plotstats/colorscales"
)
var weekdaysPos = map[time.Weekday]int{
time.Monday: 0,
time.Tuesday: 1,
time.Wednesday: 2,
time.Thursday: 3,
time.Friday: 4,
time.Saturday: 5,
time.Sunday: 6,
}
const (
numWeeksYear = 52
numDaysYear = 366 // always account for leap day
numWeekCols = numWeeksYear + 1 // 53 * 7 = 371 > 366
margin = 7 // should be odd number for best result if using month separator
boxSize = 25
)
var borderColor = color.RGBA{200, 200, 200, 255}
// MakeYearDayHeatmapHoriz draw every day of a year as square
// filled with color proportional to counter from the max.
func MakeYearDayHeatmapHoriz(year int, countByDay map[int]int, colorScale colorscales.ColorScale, drawMonthSeparator bool) image.Image {
maxCount := 0
for _, q := range countByDay {
if q > maxCount {
maxCount = q
}
}
width := numWeekCols * (boxSize + margin)
height := 7 * (boxSize + margin)
img := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(img, img.Bounds(), &image.Uniform{color.White}, image.ZP, draw.Src)
x, y := 0, 0
yearStartDate := time.Date(year, 1, 1, 1, 1, 1, 1, time.UTC)
vIdx := weekdaysPos[yearStartDate.Weekday()]
// start from 1 since time.DayOfYear is expected to return from 1 ~ 366
for currDay := yearStartDate; currDay.Year() == year; currDay = currDay.Add(time.Hour * 24) {
y = (boxSize + margin) * vIdx
r := image.Rect(x, y, x+boxSize, y+boxSize)
val := float64(countByDay[currDay.YearDay()]) / float64(maxCount)
color := colorScale.GetColor(val)
draw.Draw(img, r, &image.Uniform{color}, image.ZP, draw.Src)
if drawMonthSeparator {
if currDay.Day() == 1 && currDay.Month() != time.January {
marginSep := margin / 2
closeLeft := image.Point{X: x - marginSep - 1, Y: y - marginSep - 1}
closeRight := image.Point{X: x + boxSize + marginSep, Y: y - marginSep - 1}
farLeft := image.Point{X: x - marginSep - 1, Y: height - margin - 1}
farRight := image.Point{X: x + boxSize + marginSep, Y: 0}
drawLineAxis(img, farLeft, closeLeft, borderColor) // left line
if vIdx != 0 {
drawLineAxis(img, closeRight, farRight, borderColor) // right line
drawLineAxis(img, closeLeft, closeRight, borderColor) // top line
}
}
}
vIdx += 1
if vIdx == 7 {
vIdx = 0
x += boxSize + margin
}
}
return img
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a < b {
return b
}
return a
}
// drawLineAxis draws line parallel to X or Y axis
func drawLineAxis(img draw.Image, a image.Point, b image.Point, col color.Color) {
switch {
// do not attempt to draw dot
case a == b:
return
// vertical
case a.X == b.X:
for q := min(a.Y, b.Y); q <= max(a.Y, b.Y); q += 1 {
img.Set(a.X, q, col)
}
// horizontal
case a.Y == b.Y:
for q := min(a.X, b.X); q <= max(a.X, b.X); q += 1 {
img.Set(q, a.Y, col)
}
default:
panic("input line is not parallel to axis. not implemented")
}
}

View File

@ -0,0 +1,22 @@
package colorscales
import (
"image/color"
)
type ColorScale interface {
GetColor(val float64) color.RGBA
}
func LoadColorScale(name string) ColorScale {
switch name {
case "PuBu9":
return PuBu9
case "GnBu9":
return GnBu9
case "YlGn9":
return YlGn9
default:
panic("unknown colorscale " + name)
}
}

View File

@ -0,0 +1,50 @@
package colorscales
import (
"image/color"
"math"
)
type ColorScale9 [9]color.RGBA
func (c ColorScale9) GetColor(val float64) color.RGBA {
maxIdx := 8
idx := int(math.Round(float64(maxIdx) * val))
return c[idx]
}
var PuBu9 = ColorScale9{
color.RGBA{255, 247, 251, 255},
color.RGBA{236, 231, 242, 255},
color.RGBA{208, 209, 230, 255},
color.RGBA{166, 189, 219, 255},
color.RGBA{116, 169, 207, 255},
color.RGBA{54, 144, 192, 255},
color.RGBA{5, 112, 176, 255},
color.RGBA{4, 90, 141, 255},
color.RGBA{2, 56, 88, 255},
}
var GnBu9 = ColorScale9{
color.RGBA{247, 252, 240, 255},
color.RGBA{224, 243, 219, 255},
color.RGBA{204, 235, 197, 255},
color.RGBA{168, 221, 181, 255},
color.RGBA{123, 204, 196, 255},
color.RGBA{78, 179, 211, 255},
color.RGBA{43, 140, 190, 255},
color.RGBA{8, 104, 172, 255},
color.RGBA{8, 64, 129, 255},
}
var YlGn9 = ColorScale9{
color.RGBA{255, 255, 229, 255},
color.RGBA{247, 252, 185, 255},
color.RGBA{217, 240, 163, 255},
color.RGBA{173, 221, 142, 255},
color.RGBA{120, 198, 121, 255},
color.RGBA{65, 171, 93, 255},
color.RGBA{35, 132, 67, 255},
color.RGBA{0, 104, 55, 255},
color.RGBA{0, 69, 41, 255},
}

BIN
example/chart_GnBu9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
example/chart_PuBu9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
example/chart_YlGn9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

144
example/input.txt Normal file
View File

@ -0,0 +1,144 @@
2020-05-16 17:11 P
2020-05-16 17:11 P
2020-05-16 17:11 P
2020-05-16 17:11 P
2020-05-16 17:11 P
2020-05-16 17:11 P
2020-05-16 17:15 P
2020-05-16 20:43 P
2020-05-17 09:52 P
2020-05-17 09:56 P
2020-05-17 10:03 P
2020-05-17 10:06 P
2020-05-17 10:08 P
2020-05-17 10:11 P
2020-05-17 17:00 P
2020-05-17 17:02 P
2020-05-17 17:15 P
2020-05-17 18:30 P
2020-05-17 19:29 P
2020-05-17 19:31 P
2020-05-17 19:37 P
2020-05-18 09:06 P
2020-05-18 11:03 P
2020-05-18 11:51 P
2020-05-18 16:14 P
2020-05-18 16:55 P
2020-05-19 08:35 P
2020-05-19 08:42 P
2020-05-19 08:47 P
2020-05-19 10:46 P
2020-05-19 17:05 P
2020-05-19 20:18 P
2020-05-19 20:19 P
2020-05-19 20:22 P
2020-05-20 09:27 P
2020-05-20 09:30 P
2020-05-20 13:58 P
2020-05-20 16:26 P
2020-05-20 17:02 P
2020-05-21 09:40 P
2020-05-21 15:22 P
2020-05-21 17:41 P
2020-05-21 19:02 P
2020-05-21 20:03 P
2020-05-22 09:23 P
2020-05-22 09:28 P
2020-05-22 15:57 P
2020-05-23 12:53 P
2020-05-23 13:14 P
2020-05-23 13:14 P
2020-05-23 14:01 P
2020-05-23 18:31 P
2020-05-24 10:27 PPP
2020-05-24 10:37 P
2020-05-24 17:05 P
2020-05-24 21:04 P
2020-05-25 09:55 P
2020-05-25 11:26 P
2020-05-25 20:48 P
2020-05-26 16:00 PP
2020-05-26 20:03 P
2020-05-26 20:12 P
2020-05-26 21:12 P
2020-05-27 09:11 P
2020-05-27 09:28 P
2020-05-27 10:45 PP
2020-05-27 13:22 P
2020-05-27 13:44 P
2020-05-27 13:44 P
2020-05-27 19:02 P
2020-05-28 09:21 P
2020-05-28 20:28 P
2020-05-29 13:53 P
2020-05-29 13:56 PP
2020-05-30 12:42 P
2020-05-30 12:42 P
2020-05-30 13:06 P
2020-05-30 20:03 PP
2020-05-30 21:26 P
2020-05-30 22:02 P
2020-05-30 23:50 P
2020-05-31 09:59 P
2020-05-31 10:19 P
2020-05-31 14:25 PP
2020-05-31 20:26 P
2020-06-01 09:46 P
2020-06-02 09:22 P
2020-06-02 09:22 P
2020-06-02 16:51 P
2020-06-03 09:18 P
2020-06-04 09:07 P
2020-06-04 09:07 P
2020-06-04 13:20 P
2020-06-05 08:42 P
2020-06-06 09:40 PP
2020-06-06 13:21 P
2020-06-07 10:58 PP
2020-06-07 15:16 P
2020-06-07 16:31 P
2020-06-07 17:18 P
2020-06-09 09:09 P
2020-06-10 09:31 P
2020-06-10 12:51 P
2020-06-12 09:35 P
2020-06-12 09:35 P
2020-06-12 09:38 P
2020-06-12 20:29 P
2020-06-12 20:29 P
2020-06-12 20:46 P
2020-06-12 20:51 P
2020-06-12 20:51 P
2020-06-12 21:26 P
2020-06-13 10:08 P
2020-06-13 10:13 P
2020-06-13 10:18 P
2020-06-13 10:22 P
2020-06-13 10:50 P
2020-06-13 11:10 P
2020-06-13 11:10 P
2020-06-14 09:39 P
2020-06-14 09:39 P
2020-06-14 10:11 P
2020-06-14 17:33 P
2020-06-15 09:11 P
2020-06-17 10:02 P
2020-06-20 10:00 P
2020-06-20 10:00 P
2020-06-21 09:28 P
2020-06-23 09:22 P
2020-06-23 09:24 P
2020-06-24 09:44 P
2020-06-24 09:44 P
2020-06-25 09:20 P
2020-06-25 09:20 P
2020-06-25 09:29 P
2020-06-26 09:27 P
2020-06-26 09:27 P
2020-06-26 09:32 P
2020-06-27 08:44 P
2020-06-27 08:44 P
2020-06-28 01:29 P
2020-06-29 18:58 P
2020-06-30 13:49 P
2020-06-30 19:06 P

84
example/main.go Normal file
View File

@ -0,0 +1,84 @@
package main
import (
"bufio"
"flag"
"fmt"
"image/png"
"log"
"os"
"strings"
"time"
"github.com/nikolaydubina/plotstats/charts"
"github.com/nikolaydubina/plotstats/colorscales"
)
type Row struct {
Date time.Time
Count int
}
func loadRows(filename string) ([]Row, error) {
file, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("cant not open file: %w", err)
}
defer file.Close()
rows := make([]Row, 0)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
items := strings.Split(scanner.Text(), " ")
if len(items) != 3 {
return nil, fmt.Errorf("number of items in row is not 3")
}
timeString, countString := items[0]+" "+items[1], items[2]
date, err := time.Parse("2006-01-02 15:04", timeString)
if err != nil {
return nil, fmt.Errorf("can not parse time: %w", err)
}
count := strings.Count(countString, "P")
rows = append(rows, Row{Date: date, Count: count})
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("scanner got error: %w", err)
}
return rows, nil
}
func main() {
filenameLogs := flag.String("input", "input.txt", "file should contain lines in format: 2020-05-16 20:43 PPPP")
filenameChart := flag.String("output", "chart.png", "output filename")
monthSep := flag.Bool("monthsep", true, "redner month separator")
colorScale := flag.String("colorscale", "PuBu9", "refer to colorscales for examples")
flag.Parse()
rows, err := loadRows(*filenameLogs)
if err != nil {
log.Fatal(err)
}
year := rows[0].Date.Year()
countByDay := make(map[int]int, 366)
for _, row := range rows {
countByDay[row.Date.YearDay()] += row.Count
}
img := charts.MakeYearDayHeatmapHoriz(
year,
countByDay,
colorscales.LoadColorScale(*colorScale),
*monthSep,
)
f, err := os.Create(*filenameChart)
if err != nil {
log.Fatal(fmt.Errorf("can not create file: %w", err))
}
defer f.Close()
if err := png.Encode(f, img); err != nil {
log.Fatal(fmt.Errorf("can not encode png: %w", err))
}
}

3
go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/nikolaydubina/plotstats
go 1.14

0
go.sum Normal file
View File