From 908358b76d7ce8e5a1670aa8ef891779e0677e6e Mon Sep 17 00:00:00 2001 From: Nikolay Date: Thu, 2 Jul 2020 02:48:34 +0800 Subject: [PATCH] init --- README.md | 14 ++- charts/charts.go | 121 +++++++++++++++++++++++ colorscales/colorscales.go | 22 +++++ colorscales/colorscales9.go | 50 ++++++++++ example/chart_GnBu9.png | Bin 0 -> 2692 bytes example/chart_PuBu9.png | Bin 0 -> 2682 bytes example/chart_PuBu9_noseparator.png | Bin 0 -> 2288 bytes example/chart_YlGn9.png | Bin 0 -> 2679 bytes example/input.txt | 144 ++++++++++++++++++++++++++++ example/main.go | 84 ++++++++++++++++ go.mod | 3 + go.sum | 0 12 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 charts/charts.go create mode 100644 colorscales/colorscales.go create mode 100644 colorscales/colorscales9.go create mode 100644 example/chart_GnBu9.png create mode 100644 example/chart_PuBu9.png create mode 100644 example/chart_PuBu9_noseparator.png create mode 100644 example/chart_YlGn9.png create mode 100644 example/input.txt create mode 100644 example/main.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/README.md b/README.md index 3cbe529..ad0b299 100644 --- a/README.md +++ b/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`. \ No newline at end of file diff --git a/charts/charts.go b/charts/charts.go new file mode 100644 index 0000000..73a42ce --- /dev/null +++ b/charts/charts.go @@ -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") + } +} diff --git a/colorscales/colorscales.go b/colorscales/colorscales.go new file mode 100644 index 0000000..6c549f2 --- /dev/null +++ b/colorscales/colorscales.go @@ -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) + } +} diff --git a/colorscales/colorscales9.go b/colorscales/colorscales9.go new file mode 100644 index 0000000..22b271c --- /dev/null +++ b/colorscales/colorscales9.go @@ -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}, +} diff --git a/example/chart_GnBu9.png b/example/chart_GnBu9.png new file mode 100644 index 0000000000000000000000000000000000000000..00cd65768b8800c31f259cb718cfcce9ebae7a07 GIT binary patch literal 2692 zcmdUxZA?>F9L5h6kRiz2mN7;qGsY3gG9;+D1St+o2MaIjsEoOKnFwwaL=(zz&|cs} z#jva&W^@A-#UZ0Q3OYK&pch8aOnEC^+W=v0By$!h1xjDIm)o;@`-YNi`wIULJ@+&@ z_w;%Gzw?}4$Vg)a1-u>r00>HcKZy;1-!(@?04YsfGyEiHYNnm!WT%znU%z+tnN&z+Q+aqXDVItBaZ%hB_O z@G+8!^}Map4r<8SN#Yip{Hw%lMJvjoaYu2A`P8>Z;0Xg^Ttx(j;7(Gw+u~mME9`eY zBkAxmn@>prIWVw-9$y^n-9nFj8+v-XmGq{4ah7&`P{Sb}=_Li|f$U6!Mh=IeaSX+l z&CYtDw)%wAqI_Z&yr?0eeI{Jt3qaXWS6dkF4>&gBzz)B7%7nQI*&D7eUW#wpO0RJn zaT{lqG+qr)X)YDsXqVF^JOQU#X4z!zU8Q}%H2H{44pmHH_j<5M6_S=k)_y6DZN~5Z z-h_5oh-1X6-pWE;g%xUF-b8M@k*J%9I#W^7By|+LLg|)JlDVB&Lz~1x;ieKAtiM=R zlt!_`AKIeqt2zc{Vr33(F97~W$z5H5_=7(+$^t+Bri}%_exWM(;J%{CZ33Ul$W;LY zdKJPl-Q~sE?!k>9A!k}Jc;7L_iQ=J zkx)IxLzG%%4PvxWknTKAdmVdtPS?hs^1czLN#vDIK7`tQdD}!|>Z{K9V3H&sb*F~e zI=((O@}DxmnS;*I0N_Thj&bb=S<(4;>N4oPXRCRK_STazPxLF-GkJf<+Gl1RWsN#* zl%|+3D^+&FM`&YDhZYsds*q6^PRG)+ z6y*5H4?9uPUZfJ@M7`bQj1Q@lDH9g0O9yqJpfY-<>okQbSYWKeFtAV;Y7;4B1}ZLq zZi@}Z0-b8#aXwHPw zOR4bjiXpGno-h9sU2BHxzsi7}&|`=@)ix{rOsu;*P~b=BfDT=H-q`<+#deGe5ePr8 W>0Q}Ys@YG!A^AvJQrmku)&Bt7!^kQC literal 0 HcmV?d00001 diff --git a/example/chart_PuBu9.png b/example/chart_PuBu9.png new file mode 100644 index 0000000000000000000000000000000000000000..0d799ee537baed9d3d61312aa1b87d825ab79418 GIT binary patch literal 2682 zcmdUxeM}Q~7{`Bz0wS!M%`lu<=SW5}*dQ9lKNE1^3TVVIpb(2C4l%byFj128+X$D6hMiFR&ttJDf#qx-E&* zTW<S;O38TH~=#FfM>(CRn^@< zUNv7sw$>_$XFn!X|9vdiu5hTXH1w`Qujdlbt49j)nW9@kil(FYt*|NqUv~aSJVR8L zt($=6buHJCRiKz;ZF8{ALK{hmVKsxUPiXlRb+c(BaqCK)K3~3upVZko3em(JU&=ED4`oMxju_7jJ$Hm z=4YTb#+&MR4=;BPe0)V`HLfPuc+j;_H0D6KWZtNgdaMz(X*xBx{r^rvyphdd#JZO$I1$J-! zw=zhri#HLyg3tU0VJSj<7DWQ+_h9%%6USL^7Rp2DPq#ZQCu?o@Z0zlbM%ZTacA

4n@KuGPsIKzMWai@9N1#ucaqvAImF~R-k6;O|}4vSiw7Gkcnlb=#D{3_WpVCOJ7 z4PI7sV{fmHC4XYs{lY|syN&Qeg<~K}SlH~TlrDUJL_~tVSDk>P<1TN6df2Ded2=ml z-)AoqBNrnG`yJp-!L+iE?lLy2dJ{BYXD5Lx@o9Vv} literal 0 HcmV?d00001 diff --git a/example/chart_PuBu9_noseparator.png b/example/chart_PuBu9_noseparator.png new file mode 100644 index 0000000000000000000000000000000000000000..75ed0d4608b7edbcb0c884a2606cf088b4feec4b GIT binary patch literal 2288 zcmeAS@N?(olHy`uVBq!ia0y~yU|RrWKj2^jl8+q>LKzr1wtKobhE&XXb8DgBoJ{%l z#2@=kvNa!SaCeT~yoytO!#Ym)i)&s4hU8R`(ll+;%(J`m&RVCb zpEuaK{krP^N7u~*&dg0W^u2sBZms#@WqxAc-rnAxfB#>7^8a`LGOzr3d-my{A0K|$ zWc~m3Dyc4J-x{~K^XJab+xhP2^Y_317fyH&RJ^bDH%OO#+5h^(*BF70YzSBX0HPgc z9AF1h42ewgAgYD421E&{4H`*V|H~yW@XUP-gEO*e|RK$Fe+JHW7- z1rDqGb^olg{@-pd|NQt^`?0$W{*FA0SG>=K(X2YA|Tz z2XPOv3^vKW<(XDr%t3Bru>RCUyd$G!fVuIQpICGsBzf)oJ9GE%<$<5Kn@@*_PDx!& zMUC9n`r6WsPo@6<{q^$(JfP~&*6I(90D{Jt^}J;-FWd)*)0-0{L~%~xmU}$X){!}u zx1gR+mjh`FH=t-}3YI;_LrE{8)MA-`A^0|LmxV?7d}g pZD0HM@2~sQM`Gf;53DWz|G#4`dv{X%Oczk-d%F6$taD0e0swxYvI_tJ literal 0 HcmV?d00001 diff --git a/example/chart_YlGn9.png b/example/chart_YlGn9.png new file mode 100644 index 0000000000000000000000000000000000000000..01db87e79592f32fbbc6c4625d6217299d4b6cfa GIT binary patch literal 2679 zcmds(ZA?>V6vrQg0WuxvmY{P`5G+5Y00NlF2}e04-!Dx3l(VlG z++9j3s)#m*CO-Hxn9+Hu$uB50aq?*Px5|4@iz7lSXz@OZDPCj6_R!u7D<6+v4f?3z zz4~RsfYvajvhhW&v_?=z3xCLDv0{oQBq&1xx8E7a^pOhkdLS2Jy$APPM9 z^cEsrd4wzjocqj|NoD`jdT%n7Wo_!^$cU&a3UUP&(&}i=5k+PmH>QOT%CS)!>>%E^ znk8y?;4({GzaLdv|Bg~B1uX#DgBJiOQ99K$#2;kXkuQ!=U-iihs$%9{ZzsT=>8{TVkFGrp9f@Xy)G}E(aXAW5~$4J&H$L2mc*~Le&8xX#V zTyt+xDEL>x?UaIHwHE`aTpps|uL70BHtbOn`0-ab1^~S3Vjv@$tNRA>DLaTl-XM$a z>dZgx8;Kr*l%tdRsMaOA4laUso2*6(?&njc~X( zl2`he&4)()vQN~a7n$NCytlxzd4<)BpHj41*C4lkR((ofj=|^Mk29;(w+>|R zhhlD5i_^x~WJ_b$nPTJK7m6lm?s0=4?3neLf!f^0^-O)6pzi12=vO@s&ZW%j;>AE7 zw@ZrSDwcCCDiXdrv1xzLK#<11@ZBl-$cy}c1vTT$J} z%p_Y&&8J$tJCHB84#V)OROImwHf+#6$GMC|kG#S%LgWp2hrMBjs(l~HXMh5N@C7|5 zm`yK|7GB3V*-)ggs~0}d<`Pe;6RkJ|GUt$EX7Pz-B2k9tsQ2>PAbhN{m5mML#r4=t z284~JDOfMjnM^^7rKM(O3YpXJ*ZSU~Qw(lNGB?MzqPyKye2LYskcS|J4L*WaKUU=s z!v=8%GJNl_N{S=bJwAJm&Duf`@=w5;$AkMdVm)JzossCmGwFKQC;s-!Z%{p0Ln=ut z)1cNWyw>3YmXJqimnCe=UqGgKRmJ-$}Ifp!}1OHzg;Ki|6V#t?L kMlV|aFQ-lii)08v9NQLq$Uh`En|eZWVroMBJ6Yv_0|58PvH$=8 literal 0 HcmV?d00001 diff --git a/example/input.txt b/example/input.txt new file mode 100644 index 0000000..abe1833 --- /dev/null +++ b/example/input.txt @@ -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 diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..518a30c --- /dev/null +++ b/example/main.go @@ -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)) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b113056 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/nikolaydubina/plotstats + +go 1.14 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e69de29