mirror of
https://github.com/nikolaydubina/calendarheatmap.git
synced 2025-02-07 19:40:06 +02:00
init
This commit is contained in:
parent
a8e93fa03e
commit
908358b76d
14
README.md
14
README.md
@ -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
121
charts/charts.go
Normal 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")
|
||||
}
|
||||
}
|
22
colorscales/colorscales.go
Normal file
22
colorscales/colorscales.go
Normal 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)
|
||||
}
|
||||
}
|
50
colorscales/colorscales9.go
Normal file
50
colorscales/colorscales9.go
Normal 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
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
BIN
example/chart_PuBu9.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
BIN
example/chart_PuBu9_noseparator.png
Normal file
BIN
example/chart_PuBu9_noseparator.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
BIN
example/chart_YlGn9.png
Normal file
BIN
example/chart_YlGn9.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.6 KiB |
144
example/input.txt
Normal file
144
example/input.txt
Normal 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
84
example/main.go
Normal 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))
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user