diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..d454b00 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: go +go: +- 1.12 +env: + global: + - GO111MODULE=on + - secure: jsI88hoE5j+ffbxf6hvbA7mtkizkHrmd/RmqFncATUAgE7ny8W0P1pN9wXqncMD/9YaG7cic1H/+NuYuH+cU/GHKkAR2AY8Rbf+4uGiATMfG7tuySVYDdwdYEQKcKXJ6rHSU8oNLBVckZJNv1bqk+LEHhzEXUI7sFEwxvcmTb/zH8TXf5TyTpquW9CWwzsudbl6RRYuzU2DIFBh99/4umY/94FHOHnxm/AENMnsiIjRCZtdqpVdvtaY43rQphotmzR+5IwvMaT/7jxwtksR5HAuEZ2eFQd1+mpOeqddZJ3eigx/DvTkH6N22NmAZL3b2oJKzBDAJCjz/f6rBiEpZBlO8fSHKKMR3XZySbJgNEL7TYr6jEKwNsFo5Bkcj2qKtrPwswxTCihPVBb2QhimN7zvCFi1KgZRcUr4d+FP6YGXsFoBRggDpeNt1x2EXjusfHEnuJY/uoypwJSi2vlGqXuNb45nBVUXS5CIz/kTr2KwYT9sPn/V59TiEuqefX3D3PASzQY/NVd4gcPSZCAXUMs0IA50qfo3/sBW/tFABdhcMhzjEOHc0fJeUAFn+w8O2xmigifUdqh6nPh/4TODSWwlFk+icwDDNdInAOCxThGHamuB1Jlr4P7UvG1itUnQT8hnAcRe8/VjD8b9QGIR9mLbCVFYpAc7PfbN3hrRgzgc= +before_script: +- go get github.com/mattn/goveralls +script: +- make test build +- "$GOPATH/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN" diff --git a/Makefile b/Makefile index a2e8196..13061a5 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ update_version: @echo "Replace version to \"${VERSION}\"" test: setup - $(GO) test -covermode=count -coverprofile=coverage.out $$(go list ./... | grep -v vendor) + $(GO) test -covermode=count -coverprofile=coverage.out $$(go list ./...) build: setup cd cmd/heatman; $(GO) build -o $(NAME) -v diff --git a/README.md b/README.md index 4f2fa76..3453419 100644 --- a/README.md +++ b/README.md @@ -1 +1,66 @@ +[![Build Status](https://travis-ci.com/tamada/goheatman.svg?branch=master)](https://travis-ci.com/tamada/goheatman) +[![Coverage Status](https://coveralls.io/repos/github/tamada/goheatman/badge.svg?branch=master)](https://coveralls.io/github/tamada/goheatman?branch=master) + # goheatman + +Create heat map image from given csv file. +Go language implementation of [tamada/heatman](https://github.com/tamada/heatman). + +## Usage + +```sh +$ heatman -h both testdata/sample.csv +# generate heatman.png. +$ heatman -H # print the following help message. +heatman [OPTIONS] [CSVFILE] +OPTIONS + -a, --additional-line-gap specifies the gap of assistant lines per cells. + if GAP is less equals than 0, no assistant lines are drawn. + -c, --color specifies heatmap color type (color or gray), default: color. + -d, --dest specifies the destination file. + -h, --headers
specifies header model (both, row, column, or no), default: no. + -p, --pixel specifies the pixel size per cell. + -s, --scaler generates scaler of heatmap. If this option was specified, + additional-line-gap, headers, pixel, and CSVFILE are ignored. + -H, --help print this message. +ARGUMENTS + CSVFILE input csv files with no headers. + if no csv files are specified, heatman read csv from stdin. + The value of each cell must be 0.0 to 1.0. +$ heatman -p 15 -a 4 -h both -d heatman2.png testdata/sample.csv +# generate heatmap2.png. +$ heatman -p 15 -h both --color gray -d heatman3.png testdata/sample.csv +# generate heatmap3.png in gray scale. +$ heatman --scaler -d heatman_scaler.png +# generate heatmap_scaler.png which shows scaler of colors. +``` + +### The Results + +Each image is generated by above command. The sources of below images are same. + +#### heatman.png + +![heatman.png](https://github.com/tamada/goheatman/raw/master/testdata/heatman.png) + +It was too small. +The one cell in csv is shown in one pixel square. + +#### heatman2.png + +The one cell in csv is shown in 15-pixel square, and the auxiliary lines are drawn by 4 cells. + +![heatman2.png](https://github.com/tamada/heatman/raw/master/testdata/heatman2.png) + +#### heatman_gray.png + +The image is gray-scaled heatmap. + +![heatman3.png](https://github.com/tamada/heatman/raw/master/testdata/heatman_gray.png) + +#### heatman_scaler.png + +This is generated scaler image. +The most right side shows 1.0, and the most left side represents 0.0. + +![heatman_scaler.png](https://github.com/tamada/heatman/raw/master/testdata/heatman_scaler.png) diff --git a/cmd/heatman/main.go b/cmd/heatman/main.go index f0aa141..6353fff 100644 --- a/cmd/heatman/main.go +++ b/cmd/heatman/main.go @@ -18,16 +18,18 @@ func printHelp(programName string) { fmt.Printf(`%s [OPTIONS] [CSVFILE] OPTIONS -a, --additional-line-gap specifies the gap of assistant lines per cells. - if the value is less equals than 0, no assistant lines are drawn. - -c, --color specifies heatmap color type (default or gray), default: default + if GAP is less equals than 0, no assistant lines are drawn. + -c, --color specifies heatmap color type (color or gray), default: color. -d, --dest specifies the destination file. -h, --headers
specifies header model (both, row, column, or no), default: no. - -H, --help print this message. -p, --pixel specifies the pixel size per cell. + -s, --scaler generates scaler of heatmap. If this option was specified, + additional-line-gap, headers, pixel, and CSVFILE are ignored. + -H, --help print this message. ARGUMENTS CSVFILE input csv files with no headers. if no csv files are specified, heatman read csv from stdin. - The value of each cell must be 0.0 to 1.0 + The value of each cell must be 0.0 to 1.0. `, programName) } @@ -36,6 +38,7 @@ type options struct { heatmapType string headerType string helpFlag bool + scalerFlag bool } func buildFlagSet() (*flag.FlagSet, *options) { @@ -43,19 +46,20 @@ func buildFlagSet() (*flag.FlagSet, *options) { var flags = flag.NewFlagSet(heatmanName, flag.ContinueOnError) flags.Usage = func() { printHelp(heatmanName) } flags.StringVarP(&options.context.Destination, "dest", "d", "heatman.png", "specifies the destination file") - flags.IntVarP(&options.context.SizeOfAPixel, "pixelSize", "p", 1, "pixel size per cell") + flags.IntVarP(&options.context.SizeOfAPixel, "pixel", "p", 1, "pixel size per cell") flags.IntVarP(&options.context.GapOfAdditionalLine, "additional-line-gap", "a", 0, "if greater than 0, draw assistant lines per specified number of cells") flags.StringVarP(&options.headerType, "headers", "h", "no", "header model (both, row, column, or no), default: no") - flags.StringVarP(&options.heatmapType, "color", "c", "default", "specifies heatmap color type (default or gray)") + flags.StringVarP(&options.heatmapType, "color", "c", "color", "specifies heatmap color type (default or gray)") + flags.BoolVarP(&options.scalerFlag, "scaler", "s", false, "generates scaler") flags.BoolVarP(&options.helpFlag, "help", "H", false, "print this message.") return flags, &options } -func findInput(flags *flag.FlagSet) (*os.File, error) { - if len(flags.Args()) == 1 { +func findInput(args []string) (*os.File, error) { + if len(args) == 1 { return os.Stdin, nil } - return os.Open(flags.Arg(1)) + return os.Open(args[1]) } func printError(err error) int { @@ -74,17 +78,23 @@ func writeImage(image image.Image, context *heatman.Context) error { return png.Encode(to, image) } -func perform(flags *flag.FlagSet, context *heatman.Context) int { - if len(flags.Args()) > 2 { +func perform(args []string, context *heatman.Context, scalerFlag bool) int { + if len(args) > 2 { printHelp(heatmanName) return 1 } - var from, err = findInput(flags) + var from, err = findInput(args) if err != nil { return printError(err) } defer from.Close() - var image = heatman.NewTable(csv.NewReader(from), context) + var image image.Image + if scalerFlag { + image = heatman.ScalerImage(context) + } else { + image = heatman.NewTable(csv.NewReader(from), context) + + } var err2 = writeImage(image, context) if err2 != nil { return printError(err2) @@ -105,6 +115,9 @@ func parseOptions(flags *flag.FlagSet, opts *options, args []string) error { if err != nil { return err } + if opts.context.GapOfAdditionalLine < 0 { + opts.context.GapOfAdditionalLine = 0 + } return nil } @@ -118,7 +131,7 @@ func goMain() int { printHelp(heatmanName) return 0 } - return perform(flags, opts.context) + return perform(flags.Args(), opts.context, opts.scalerFlag) } func main() { diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a6d4241 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module github.com/tamada/goheatman + +go 1.12 + +require github.com/ogier/pflag v0.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d273608 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= +github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= diff --git a/heatman.go b/heatman.go index d3861a4..9dc2f54 100644 --- a/heatman.go +++ b/heatman.go @@ -2,11 +2,11 @@ package heatman import ( "encoding/csv" - "image" "fmt" - "math" + "image" "image/color" "io" + "math" "strconv" ) @@ -14,10 +14,10 @@ import ( Table represents the data for heat map. */ type Table struct { - width int - height int - data [][]*number - context *Context + width int + height int + data [][]*number + context *Context } type number struct { @@ -27,7 +27,7 @@ type number struct { /* HeatmapConverter is an interface for converting the value to color. */ -type HeatmapConverter interface{ +type HeatmapConverter interface { Convert(value float64) color.Color } @@ -43,7 +43,7 @@ Convert converts the given value to color for heatmap. func (dc *DefaultHeatmapConverter) Convert(value float64) color.Color { var hsb = createHSB(value) var r, g, b, a = hsb.RGBA() - return color.RGBA{R: uint8(r&0xff), G: uint8(g&0xff), B: uint8(b&0xff), A: uint8(a&0xff)} + return color.RGBA{R: uint8(r & 0xff), G: uint8(g & 0xff), B: uint8(b & 0xff), A: uint8(a & 0xff)} } /* @@ -56,10 +56,24 @@ type GraymapConverter struct { Convert converts the given value to color for graymap. */ func (gc *GraymapConverter) Convert(value float64) color.Color { - var gray = uint8((1 - value) * 255 + 0.5) + var gray = uint8((1-value)*255 + 0.5) return color.RGBA{R: gray, G: gray, B: gray, A: 0xff} } +/* +ScalerImage generates the image of scaler for heat map. +*/ +func ScalerImage(context *Context) *Table { + var data = [][]*number{} + for i := 0; i < 10; i++ { + var subd = []*number{} + for j := 0; j < 255; j++ { + subd = append(subd, &number{float64(j) / 255.0}) + } + data = append(data, subd) + } + return &Table{width: 255, height: 10, context: context, data: data} +} /* ColorModel shows the color model of the heat map. @@ -76,10 +90,10 @@ func (table *Table) calculateGap() (int, int) { var gap = table.context.GapOfAdditionalLine xCount = table.width / gap yCount = table.height / gap - if table.width % gap == 0 { + if table.width%gap == 0 { xCount-- } - if table.height % gap == 0 { + if table.height%gap == 0 { yCount-- } } @@ -92,14 +106,14 @@ Bounds returns the size of the heatmap image. func (table *Table) Bounds() image.Rectangle { var scale = table.context.SizeOfAPixel var xCount, yCount = table.calculateGap() - var r = image.Rect(0, 0, table.width*scale + xCount, table.height*scale + yCount) + var r = image.Rect(0, 0, table.width*scale+xCount, table.height*scale+yCount) return r } func createHSB(value float64) *HSB { var hue = (1 - value) * 240 / 360 return &HSB{ - Hue: hue, + Hue: hue, Saturation: 1.0, Brightness: 1.0, } @@ -113,12 +127,12 @@ func (table *Table) At(x, y int) color.Color { var gap = table.context.GapOfAdditionalLine var xx = x / scale var yy = y / scale - var line = scale * gap + 1 + var line = scale*gap + 1 if gap > 0 { - xx = (x - x / line) / scale - yy = (y - y / line) / scale + xx = (x - x/line) / scale + yy = (y - y/line) / scale } - if x != 0 && x % line == (line - 1) || y != 0 && y % line == (line - 1) { + if gap > 0 && (x != 0 && x%line == (line-1) || y != 0 && y%line == (line-1)) { return color.RGBA{0xff, 0xff, 0xff, 0x00} } if len(table.data[yy]) < xx || table.data[yy][xx] == nil { @@ -141,42 +155,44 @@ func (hsb *HSB) String() string { /* RGBA converts HSB color to RGBA color. -This routine is refered from java.awt.Color#HSBtoRGB +This routine is refered from java.awt.Color#HSBtoRGB in amazon-corretto8 */ func (hsb *HSB) RGBA() (uint32, uint32, uint32, uint32) { var r, g, b uint32 = 0, 0, 0 - if hsb.Saturation > 0.0 { - var h = (hsb.Hue - math.Floor(hsb.Hue)) * 6.0 - var f = h - math.Floor(h) - var p = hsb.Brightness * (1.0 - hsb.Saturation) - var q = hsb.Brightness * (1.0 - hsb.Saturation * f) - var t = hsb.Brightness * (1.0 - (hsb.Saturation * (1.0 - f))) - switch(int(h)) { - case 0: - r = uint32(hsb.Brightness * 255 + 0.5) - g = uint32(t * 255.0 + 0.5) - b = uint32(p * 255.0 + 0.5) - case 1: - r = uint32(q * 255.0 + 0.5); - g = uint32(hsb.Brightness * 255.0 + 0.5); - b = uint32(p * 255.0 + 0.5); - case 2: - r = uint32(p * 255.0 + 0.5); - g = uint32 (hsb.Brightness * 255.0 + 0.5); - b = uint32 (t * 255.0 + 0.5); - case 3: - r = uint32 (p * 255.0 + 0.5); - g = uint32 (q * 255.0 + 0.5); - b = uint32(hsb.Brightness * 255.0 + 0.5); - case 4: - r = uint32(t * 255.0 + 0.5); - g = uint32(p * 255.0 + 0.5); - b = uint32(hsb.Brightness * 255.0 + 0.5); - case 5: - r = uint32(hsb.Brightness * 255.0 + 0.5); - g = uint32(p * 255.0 + 0.5); - b = uint32(q * 255.0 + 0.5); - } + if hsb.Saturation == 0.0 { + r = uint32(hsb.Brightness*255.0 + 0.5) + return r, r, r, 255 + } + var h = (hsb.Hue - math.Floor(hsb.Hue)) * 6.0 + var f = h - math.Floor(h) + var p = hsb.Brightness * (1.0 - hsb.Saturation) + var q = hsb.Brightness * (1.0 - hsb.Saturation*f) + var t = hsb.Brightness * (1.0 - (hsb.Saturation * (1.0 - f))) + switch int(h) { + case 0: + r = uint32(hsb.Brightness*255 + 0.5) + g = uint32(t*255.0 + 0.5) + b = uint32(p*255.0 + 0.5) + case 1: + r = uint32(q*255.0 + 0.5) + g = uint32(hsb.Brightness*255.0 + 0.5) + b = uint32(p*255.0 + 0.5) + case 2: + r = uint32(p*255.0 + 0.5) + g = uint32(hsb.Brightness*255.0 + 0.5) + b = uint32(t*255.0 + 0.5) + case 3: + r = uint32(p*255.0 + 0.5) + g = uint32(q*255.0 + 0.5) + b = uint32(hsb.Brightness*255.0 + 0.5) + case 4: + r = uint32(t*255.0 + 0.5) + g = uint32(p*255.0 + 0.5) + b = uint32(hsb.Brightness*255.0 + 0.5) + case 5: + r = uint32(hsb.Brightness*255.0 + 0.5) + g = uint32(p*255.0 + 0.5) + b = uint32(q*255.0 + 0.5) } return r, g, b, 0xff } @@ -222,7 +238,7 @@ func convert(values []string, context *Context) []*number { for _, value := range values { if first && context.WithHeader.hasRowHeader() { first = false - continue; + continue } var val, err = strconv.ParseFloat(value, 64) if err == nil { diff --git a/heatman_test.go b/heatman_test.go index 2baa28f..35ade78 100644 --- a/heatman_test.go +++ b/heatman_test.go @@ -8,13 +8,21 @@ import ( "testing" ) +func TestScaler(t *testing.T) { + var context = NewContext(1, 0) + var scaler = ScalerImage(context) + if scaler.width != 255 && scaler.height != 10 { + t.Errorf("size of scaler did not match") + } +} + func TestCsvToTable(t *testing.T) { var csvData = `,c1,c2,c3,c4 r1,0.0,0.1,0.2,0.3 -r2,,0.2,0.3,0.4 -r3,,,0.4,0.5 -r4,,,,0.6 -r5,,,,0.7` +r2,,0.4,0.5,0.6 +r3,,,0.7,0.8 +r4,,,,0.9 +r5,,,,1.0` var context = NewContext(3, 0) context.WithHeader = RowColumnHeader var table = NewTable(csv.NewReader(strings.NewReader(csvData)), context) @@ -37,9 +45,20 @@ func TestHSBtoRGB(t *testing.T) { wontG uint32 wontB uint32 }{ - {&HSB{0.0, 1.0, 1.0}, 255, 0, 0}, - {&HSB{0.333334, 1.0, 1.0}, 0, 255, 0}, - {&HSB{0.666667, 1.0, 1.0}, 0, 0, 255}, + {&HSB{0.000000, 0.0, 1.000}, 255, 255, 255}, + {&HSB{0.000000, 0.0, 0.502}, 128, 128, 128}, + {&HSB{0.000000, 1.0, 1.000}, 255, 0, 0}, + {&HSB{0.000000, 1.0, 0.502}, 128, 0, 0}, + {&HSB{0.166667, 1.0, 1.000}, 255, 255, 0}, + {&HSB{0.166667, 1.0, 0.502}, 128, 128, 0}, + {&HSB{0.333334, 1.0, 1.000}, 0, 255, 0}, + {&HSB{0.333334, 1.0, 0.502}, 0, 128, 0}, + {&HSB{0.500000, 1.0, 1.000}, 0, 255, 255}, + {&HSB{0.500000, 1.0, 0.502}, 0, 128, 128}, + {&HSB{0.666667, 1.0, 1.000}, 0, 0, 255}, + {&HSB{0.666667, 1.0, 0.502}, 0, 0, 128}, + {&HSB{0.833333, 1.0, 1.000}, 255, 0, 255}, + {&HSB{0.833333, 1.0, 0.502}, 128, 0, 128}, } for _, tc := range testcases { var r, g, b, a = tc.givesHSB.RGBA() @@ -55,7 +74,7 @@ func TestCreateHSB(t *testing.T) { givesValue float64 wontHSB *HSB }{ - {1.0, &HSB{0.0, 1.0, 1.0}}, + {1.0, &HSB{0.000000, 1.0, 1.0}}, {0.0, &HSB{0.666667, 1.0, 1.0}}, {0.5, &HSB{0.333334, 1.0, 1.0}}, } @@ -73,7 +92,7 @@ func TestHSBString(t *testing.T) { hsb *HSB str string }{ - {&HSB{0.0, 1.0, 1.0}, "hue: 0.000000, saturation: 1.000000, brightness: 1.000000"}, + {&HSB{0.000000, 1.0, 1.0}, "hue: 0.000000, saturation: 1.000000, brightness: 1.000000"}, {&HSB{0.666667, 1.0, 1.0}, "hue: 0.666667, saturation: 1.000000, brightness: 1.000000"}, {&HSB{0.333334, 1.0, 1.0}, "hue: 0.333334, saturation: 1.000000, brightness: 1.000000"}, } diff --git a/internal/convert.go b/internal/convert.go index 1571312..bb583eb 100644 --- a/internal/convert.go +++ b/internal/convert.go @@ -35,7 +35,7 @@ Available names are: default, and gray. */ func ParseColorType(colorType string) (heatman.HeatmapConverter, error) { switch strings.ToLower(colorType) { - case "default": + case "color": return &heatman.DefaultHeatmapConverter{}, nil case "gray": return &heatman.GraymapConverter{}, nil diff --git a/testdata/heatman.png b/testdata/heatman.png new file mode 100644 index 0000000..36d2ffc Binary files /dev/null and b/testdata/heatman.png differ diff --git a/testdata/heatman2.png b/testdata/heatman2.png new file mode 100644 index 0000000..2938917 Binary files /dev/null and b/testdata/heatman2.png differ diff --git a/testdata/heatman3.png b/testdata/heatman3.png new file mode 100644 index 0000000..1f96ba0 Binary files /dev/null and b/testdata/heatman3.png differ diff --git a/testdata/heatman_scaler.png b/testdata/heatman_scaler.png new file mode 100644 index 0000000..0ec78e5 Binary files /dev/null and b/testdata/heatman_scaler.png differ