├── .gitignore ├── doc ├── all.png ├── demo.gif ├── dual.png ├── rows.gif ├── single.png ├── vegeta.gif ├── memstats.png └── producer.go ├── term ├── not_go110.go ├── go110.go ├── iterm2.go ├── std.go ├── sixel.go ├── common.go └── kitty.go ├── data ├── stdin.go ├── http.go ├── spec.go └── point.go ├── go.mod ├── graph ├── dash.go ├── legend.go └── graph.go ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── .goreleaser.yml ├── go.sum ├── main.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | jplot 2 | dist/ 3 | -------------------------------------------------------------------------------- /doc/all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/jplot/HEAD/doc/all.png -------------------------------------------------------------------------------- /doc/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/jplot/HEAD/doc/demo.gif -------------------------------------------------------------------------------- /doc/dual.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/jplot/HEAD/doc/dual.png -------------------------------------------------------------------------------- /doc/rows.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/jplot/HEAD/doc/rows.gif -------------------------------------------------------------------------------- /doc/single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/jplot/HEAD/doc/single.png -------------------------------------------------------------------------------- /doc/vegeta.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/jplot/HEAD/doc/vegeta.gif -------------------------------------------------------------------------------- /doc/memstats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/jplot/HEAD/doc/memstats.png -------------------------------------------------------------------------------- /term/not_go110.go: -------------------------------------------------------------------------------- 1 | // +build !go1.10 2 | 3 | package term 4 | 5 | import ( 6 | "os" 7 | "time" 8 | ) 9 | 10 | func fileSetReadDeadline(f *os.File, t time.Time) error { 11 | return nil 12 | } 13 | -------------------------------------------------------------------------------- /term/go110.go: -------------------------------------------------------------------------------- 1 | // +build go1.10 2 | 3 | package term 4 | 5 | import ( 6 | "os" 7 | "time" 8 | ) 9 | 10 | func fileSetReadDeadline(f *os.File, t time.Time) error { 11 | return f.SetReadDeadline(t) 12 | } 13 | -------------------------------------------------------------------------------- /data/stdin.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "bufio" 5 | "os" 6 | 7 | "github.com/elgs/gojq" 8 | ) 9 | 10 | type stdin struct { 11 | scan *bufio.Scanner 12 | } 13 | 14 | // FromStdin reads data from stdin as one JSON object per line. 15 | func FromStdin(size int) *Points { 16 | return &Points{ 17 | Size: size, 18 | Source: stdin{bufio.NewScanner(os.Stdin)}, 19 | } 20 | } 21 | 22 | func (s stdin) Get() (*gojq.JQ, error) { 23 | if s.scan.Scan() { 24 | return gojq.NewStringQuery(s.scan.Text()) 25 | } 26 | return nil, s.scan.Err() 27 | } 28 | 29 | func (s stdin) Close() error { 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rs/jplot 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/dustin/go-humanize v1.0.1 7 | github.com/elgs/gojq v0.0.0-20201120033525-b5293fef2759 8 | github.com/go-analyze/charts v0.5.21 9 | github.com/mattn/go-isatty v0.0.14 10 | github.com/mattn/go-sixel v0.0.1 11 | github.com/monochromegane/terminal v0.0.0-20161222050454-9bc47e2707d9 12 | golang.org/x/crypto v0.17.0 13 | ) 14 | 15 | require ( 16 | github.com/elgs/gosplitargs v0.0.0-20161028071935-a491c5eeb3c8 // indirect 17 | github.com/go-analyze/bulk v0.1.0 // indirect 18 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect 19 | github.com/soniakeys/quant v1.0.0 // indirect 20 | golang.org/x/image v0.24.0 // indirect 21 | golang.org/x/sys v0.15.0 // indirect 22 | golang.org/x/term v0.15.0 // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /doc/producer.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "math/rand" 7 | "time" 8 | ) 9 | 10 | func add(i *int, d, min int) int { 11 | *i += d 12 | if *i < min { 13 | *i = min 14 | } 15 | return *i 16 | } 17 | 18 | func main() { 19 | t := time.NewTicker(time.Millisecond) 20 | var utime, stime, cpu int 21 | for range t.C { 22 | b, _ := json.Marshal(map[string]interface{}{ 23 | "mem": map[string]interface{}{ 24 | "Heap": 10000 + rand.Intn(2000), 25 | "Sys": 20000 + rand.Intn(1000), 26 | "Stack": 3000 + rand.Intn(500), 27 | }, 28 | "cpu": map[string]interface{}{ 29 | "UTime": add(&utime, 100+rand.Intn(100), 0), 30 | "STime": add(&stime, 100+rand.Intn(200), 0), 31 | }, 32 | "Threads": add(&cpu, rand.Intn(10)-4, 1), 33 | }) 34 | fmt.Printf("%s\n", b) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /graph/dash.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "image" 5 | "image/draw" 6 | "image/png" 7 | "io" 8 | 9 | chart "github.com/go-analyze/charts/chartdraw" 10 | "github.com/rs/jplot/data" 11 | ) 12 | 13 | type Dash struct { 14 | Specs []data.Spec 15 | Data *data.Points 16 | } 17 | 18 | // Render generates a PNG with all graphs stacked. 19 | func (d Dash) Render(w io.Writer, width, height int) error { 20 | graphs := make([]chart.Chart, 0, len(d.Specs)) 21 | for _, spec := range d.Specs { 22 | graphs = append(graphs, New(spec, d.Data, width, height/len(d.Specs))) 23 | } 24 | canvas := image.NewRGBA(image.Rectangle{image.Point{0, 0}, image.Point{width, height}}) 25 | var top int 26 | for _, graph := range graphs { 27 | iw := &chart.ImageWriter{} 28 | if err := graph.Render(chart.PNG, iw); err != nil { 29 | return err 30 | } 31 | img, _ := iw.Image() 32 | r := image.Rectangle{image.Point{0, top}, image.Point{width, top + graph.Height}} 33 | top += graph.Height 34 | draw.Draw(canvas, r, img, image.Point{0, 0}, draw.Src) 35 | } 36 | return png.Encode(w, canvas) 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - 21 | name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: stable 25 | - 26 | name: Login to GitHub Container Registry 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | - 33 | name: Run GoReleaser 34 | uses: goreleaser/goreleaser-action@v6 35 | with: 36 | distribution: goreleaser 37 | version: "~> v2" 38 | args: release --clean 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Olivier Poitrey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /term/iterm2.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "io" 8 | "os" 9 | "sync" 10 | ) 11 | 12 | func init() { 13 | if os.Getenv("TERM") == "screen" { 14 | ecsi = "\033Ptmux;\033" + ecsi 15 | st += "\033\\" 16 | } 17 | } 18 | 19 | // imageWriter is a writer that write into iTerm2 terminal the PNG data written 20 | type imageWriter struct { 21 | Name string 22 | Width int 23 | Height int 24 | 25 | once sync.Once 26 | b64enc io.WriteCloser 27 | buf *bytes.Buffer 28 | } 29 | 30 | func (w *imageWriter) init() { 31 | w.buf = &bytes.Buffer{} 32 | w.b64enc = base64.NewEncoder(base64.StdEncoding, w.buf) 33 | } 34 | 35 | // Write writes the PNG image data into the imageWriter buffer. 36 | func (w *imageWriter) Write(p []byte) (n int, err error) { 37 | w.once.Do(w.init) 38 | return w.b64enc.Write(p) 39 | } 40 | 41 | // Close flushes the image to the terminal and close the writer. 42 | func (w *imageWriter) Close() error { 43 | w.once.Do(w.init) 44 | fmt.Printf("%s1337;File=preserveAspectRatio=1;width=%dpx;height=%dpx;inline=1:%s%s", ecsi, w.Width, w.Height, w.buf.Bytes(), st) 45 | return w.b64enc.Close() 46 | } 47 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | project_name: jplot 4 | before: 5 | hooks: 6 | - go mod tidy 7 | - go generate ./... 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - darwin 13 | goarch: 14 | - amd64 15 | - arm 16 | - arm64 17 | goamd64: 18 | - v1 19 | - v2 20 | - v3 21 | universal_binaries: 22 | - 23 | name_template: '{{.ProjectName}}' 24 | checksum: 25 | name_template: 'checksums.txt' 26 | snapshot: 27 | version_template: "{{ incpatch .Version }}-next" 28 | changelog: 29 | sort: asc 30 | filters: 31 | exclude: 32 | - '^docs:' 33 | - '^test:' 34 | brews: 35 | - repository: 36 | owner: rs 37 | name: homebrew-tap 38 | branch: master 39 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 40 | commit_msg_template: "Brew formula update for {{ .ProjectName }} version {{ .Tag }}" 41 | commit_author: 42 | name: Olivier Poitrey 43 | email: rs@rhapsodyk.net 44 | homepage: https://github.com/rs/jplot 45 | description: iTerm2 expvar/JSON monitoring tool 46 | 47 | 48 | release: 49 | name_template: "{{.ProjectName}}-v{{.Version}}" 50 | -------------------------------------------------------------------------------- /data/http.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/elgs/gojq" 9 | ) 10 | 11 | type httpSource struct { 12 | c chan res 13 | done chan struct{} 14 | } 15 | 16 | type res struct { 17 | jq *gojq.JQ 18 | err error 19 | } 20 | 21 | // FromHTTP fetch data points from url every interval and keep size points. 22 | func FromHTTP(url string, interval time.Duration, size int) *Points { 23 | h := httpSource{ 24 | c: make(chan res), 25 | done: make(chan struct{}), 26 | } 27 | go h.run(url, interval) 28 | return &Points{ 29 | Size: size, 30 | Source: h, 31 | } 32 | } 33 | 34 | func (h httpSource) run(url string, interval time.Duration) { 35 | t := time.NewTicker(interval) 36 | defer t.Stop() 37 | h.fetch(url) 38 | for { 39 | select { 40 | case <-t.C: 41 | h.fetch(url) 42 | case <-h.done: 43 | close(h.c) 44 | return 45 | } 46 | } 47 | } 48 | 49 | func (h httpSource) fetch(url string) { 50 | resp, err := http.Get(url) 51 | if err != nil { 52 | h.c <- res{err: err} 53 | return 54 | } 55 | defer resp.Body.Close() 56 | b, err := io.ReadAll(resp.Body) 57 | if err != nil { 58 | h.c <- res{err: err} 59 | return 60 | } 61 | jq, err := gojq.NewStringQuery(string(b)) 62 | h.c <- res{jq: jq, err: err} 63 | } 64 | 65 | func (h httpSource) Get() (*gojq.JQ, error) { 66 | res := <-h.c 67 | return res.jq, res.err 68 | } 69 | 70 | func (h httpSource) Close() error { 71 | close(h.done) 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /term/std.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import "fmt" 4 | 5 | // Direction constants are to be used with CursorMove. 6 | type Direction string 7 | 8 | const ( 9 | // Up moves the cursor up. 10 | Up Direction = "A" 11 | // Down moves the cursor down. 12 | Down Direction = "B" 13 | // Forward moves the cursor forward. 14 | Forward Direction = "C" 15 | // Backward moves the cursor backward. 16 | Backward Direction = "D" 17 | // NextLine cursor to beginning of the line next line. 18 | NextLine Direction = "E" 19 | // PreviousLine cursor to beginning of the line previous line. 20 | PreviousLine Direction = "F" 21 | // HorizontalAbsolute the cursor to the specified column. 22 | HorizontalAbsolute Direction = "G" 23 | ) 24 | 25 | const csi = "\033[" 26 | 27 | // HideCursor hides cursor. 28 | func HideCursor() { 29 | print(csi + "?25l") 30 | } 31 | 32 | // ShowCursor shows cursor. 33 | func ShowCursor() { 34 | print(csi + "?25h") 35 | } 36 | 37 | // Clear clears the screen. 38 | func Clear() { 39 | print(csi + "H\033[2J") 40 | } 41 | 42 | // CursorPosition moves cursor to row, col. 43 | func CursorPosition(row, col int) { 44 | fmt.Printf("%s%d;%dH", csi, row, col) 45 | } 46 | 47 | // CursorMove moves the cursor n times in the direction d. 48 | func CursorMove(d Direction, n int) { 49 | fmt.Printf("%s%d%s", csi, n, d) 50 | } 51 | 52 | // CursorSavePosition saves the cursor position/state. 53 | func CursorSavePosition() { 54 | print(csi + "s") 55 | } 56 | 57 | // CursorRestorePosition restores the cursor position/state. 58 | func CursorRestorePosition() { 59 | print(csi + "u") 60 | } 61 | -------------------------------------------------------------------------------- /data/spec.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // Spec specify a list of field for a single graph. 9 | type Spec struct { 10 | Fields []Field 11 | } 12 | 13 | // Field describe a field in a graph. 14 | type Field struct { 15 | ID string 16 | Name string 17 | IsCounter bool 18 | IsMarker bool 19 | } 20 | 21 | // ParseSpec parses a graph specification. Each spec is a string with one or 22 | // more JSON path separated by + with fields options prefixed with colon and 23 | // separated by commas. 24 | func ParseSpec(args []string) ([]Spec, error) { 25 | specs := make([]Spec, 0, len(args)) 26 | for i, v := range args { 27 | spec := Spec{} 28 | for j, name := range strings.Split(v, "+") { 29 | var isCounter bool 30 | var isMarker bool 31 | if strings.HasPrefix(name, "marker:counter:") { 32 | // Backward compat. 33 | name = strings.Replace(name, "marker:counter:", "marker,counter:", 1) 34 | } 35 | if idx := strings.IndexByte(name, ':'); idx != -1 { 36 | options := strings.Split(name[:idx], ",") 37 | name = name[idx+1:] 38 | for _, o := range options { 39 | switch o { 40 | case "counter": 41 | isCounter = true 42 | case "marker": 43 | isMarker = true 44 | default: 45 | return nil, fmt.Errorf("invalid field option: %s", o) 46 | } 47 | } 48 | } 49 | spec.Fields = append(spec.Fields, Field{ 50 | ID: fmt.Sprintf("%d.%d.%s", i, j, name), 51 | Name: name, 52 | IsCounter: isCounter, 53 | IsMarker: isMarker, 54 | }) 55 | } 56 | specs = append(specs, spec) 57 | } 58 | return specs, nil 59 | } 60 | -------------------------------------------------------------------------------- /data/point.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "sync" 7 | 8 | "github.com/elgs/gojq" 9 | ) 10 | 11 | // Getter is the interface used by Points to get next points. 12 | type Getter interface { 13 | io.Closer 14 | Get() (*gojq.JQ, error) 15 | } 16 | 17 | // Points is a series of Size data points gathered from Source. 18 | type Points struct { 19 | // Size is the number of data point to store per metric. 20 | Size int 21 | Source Getter 22 | 23 | points map[string][]float64 24 | last map[string]float64 25 | mu sync.Mutex 26 | } 27 | 28 | // Run get data from the source and capture metrics following specs. 29 | func (p *Points) Run(specs []Spec) error { 30 | for { 31 | jq, err := p.Source.Get() 32 | if err != nil { 33 | return fmt.Errorf("input error: %v", err) 34 | } 35 | if jq == nil { 36 | break 37 | } 38 | for _, spec := range specs { 39 | for _, f := range spec.Fields { 40 | v, err := jq.Query(f.Name) 41 | if err != nil { 42 | return fmt.Errorf("cannot get %s: %v", f.Name, err) 43 | } 44 | n, ok := v.(float64) 45 | if !ok { 46 | return fmt.Errorf("invalid type %s: %T", f.Name, v) 47 | } 48 | p.push(f.ID, n, f.IsCounter) 49 | } 50 | } 51 | } 52 | return nil 53 | } 54 | 55 | func (p *Points) push(name string, value float64, counter bool) { 56 | p.mu.Lock() 57 | defer p.mu.Unlock() 58 | d := p.getLocked(name) 59 | if counter { 60 | var diff float64 61 | if last := p.last[name]; last > 0 && last < value { 62 | diff = value - last 63 | } 64 | p.last[name] = value 65 | value = diff 66 | } 67 | d = append(append(make([]float64, 0, p.Size), d[1:]...), value) 68 | p.points[name] = d 69 | } 70 | 71 | // Get gets the points vector for name. 72 | func (p *Points) Get(name string) []float64 { 73 | p.mu.Lock() 74 | defer p.mu.Unlock() 75 | return p.getLocked(name) 76 | } 77 | 78 | func (p *Points) getLocked(name string) []float64 { 79 | if p.points == nil { 80 | p.points = make(map[string][]float64, 1) 81 | p.last = make(map[string]float64) 82 | } 83 | d, found := p.points[name] 84 | if !found { 85 | d = make([]float64, p.Size) 86 | p.points[name] = d 87 | } 88 | return d 89 | } 90 | 91 | // Close calls Close on Source. 92 | func (p *Points) Close() error { 93 | return p.Source.Close() 94 | } 95 | -------------------------------------------------------------------------------- /term/sixel.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "bytes" 5 | "image/png" 6 | "os" 7 | "sync" 8 | "time" 9 | 10 | "github.com/mattn/go-isatty" 11 | "github.com/mattn/go-sixel" 12 | "golang.org/x/crypto/ssh/terminal" 13 | ) 14 | 15 | var sixelEnabled = false 16 | 17 | func init() { 18 | if os.Getenv("TERM_PROGRAM") != "iTerm.app" { 19 | sixelEnabled = checkSixel() 20 | } 21 | } 22 | 23 | func checkSixel() bool { 24 | if isatty.IsCygwinTerminal(os.Stdout.Fd()) { 25 | return true 26 | } 27 | s, err := terminal.MakeRaw(1) 28 | if err == nil { 29 | defer terminal.Restore(1, s) 30 | } 31 | _, err = os.Stdout.Write([]byte("\x1b[c")) 32 | if err != nil { 33 | return false 34 | } 35 | defer fileSetReadDeadline(os.Stdout, time.Time{}) 36 | 37 | var b [100]byte 38 | n, err := os.Stdout.Read(b[:]) 39 | if err != nil { 40 | return false 41 | } 42 | // Check tmux (sixel enabled) 43 | // See: CSI Ps c, Ps = 4 (Sixel graphics) 44 | // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html 45 | // NOTE: tmux does not return VT name. 46 | if bytes.HasPrefix(b[:n], []byte("\x1b[?1;2;4c")) { 47 | return true 48 | } 49 | var supportedTerminals = []string{ 50 | "\x1b[?62;", // VT240 51 | "\x1b[?63;", // wsltty 52 | "\x1b[?64;", // mintty 53 | "\x1b[?65;", // RLogin 54 | } 55 | supported := false 56 | for _, supportedTerminal := range supportedTerminals { 57 | if bytes.HasPrefix(b[:n], []byte(supportedTerminal)) { 58 | supported = true 59 | break 60 | } 61 | } 62 | if !supported { 63 | return false 64 | } 65 | for _, t := range bytes.Split(b[6:n], []byte(";")) { 66 | if len(t) == 1 && t[0] == '4' { 67 | return true 68 | } 69 | } 70 | return false 71 | } 72 | 73 | type sixelWriter struct { 74 | Name string 75 | Width int 76 | Height int 77 | 78 | once sync.Once 79 | enc *sixel.Encoder 80 | buf *bytes.Buffer 81 | } 82 | 83 | func (w *sixelWriter) init() { 84 | w.buf = &bytes.Buffer{} 85 | w.enc = sixel.NewEncoder(os.Stdout) 86 | } 87 | 88 | // Write writes the PNG image data into the imageWriter buffer. 89 | func (w *sixelWriter) Write(p []byte) (n int, err error) { 90 | w.once.Do(w.init) 91 | return w.buf.Write(p) 92 | } 93 | 94 | // Close flushes the image to the terminal and close the writer. 95 | func (w *sixelWriter) Close() error { 96 | w.once.Do(w.init) 97 | img, err := png.Decode(w.buf) 98 | if err != nil { 99 | return err 100 | } 101 | return w.enc.Encode(img) 102 | } 103 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 3 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 4 | github.com/elgs/gojq v0.0.0-20201120033525-b5293fef2759 h1:WtAKk9RULee8RXnR3to5hg7TrsPVCdxYVrOZRjR54LU= 5 | github.com/elgs/gojq v0.0.0-20201120033525-b5293fef2759/go.mod h1:rQELVIqRXpraeUryHOBadz99ePvEVQmTVpGr8M9QQ4Q= 6 | github.com/elgs/gosplitargs v0.0.0-20161028071935-a491c5eeb3c8 h1:bD2/rCXwgXJm2vgoSSSCM9IPjVFfEoQFFblzg7HHABI= 7 | github.com/elgs/gosplitargs v0.0.0-20161028071935-a491c5eeb3c8/go.mod h1:o4DgpccPNAQAlPSxo7I4L/LWNh2oyr/BBGSynrLTmZM= 8 | github.com/go-analyze/bulk v0.1.0 h1:GJb6jMJfQZR5oTp/VgUT5cc0Gl4WZI33Imin37Ry4FM= 9 | github.com/go-analyze/bulk v0.1.0/go.mod h1:COn1t9AN7JJukKJSJ4YAx1fjTGZET9nbnogIIDxR3lk= 10 | github.com/go-analyze/charts v0.5.21 h1:kzsfvPiVvAPcNLtxUN4Rj8vXqz8sl3TiZrTOa1hcQfk= 11 | github.com/go-analyze/charts v0.5.21/go.mod h1:zukR151ymTK87fxZbnW0KquNGtS0QlRkV+LCDEbbHgE= 12 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 13 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 14 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 15 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 16 | github.com/mattn/go-sixel v0.0.1 h1:rhJSpux2xjsmXdXqY694uiEC0Rwxt6jYoq7Bahqo2xs= 17 | github.com/mattn/go-sixel v0.0.1/go.mod h1:zlzhYSuMbLdRdrxfutExxGpC+Pf2uUTJ6GpVQ4LB5dc= 18 | github.com/monochromegane/terminal v0.0.0-20161222050454-9bc47e2707d9 h1:YhsMshmD0JlqM4ss3JBY6Ul4QLigGvBtShFYHkb3JGg= 19 | github.com/monochromegane/terminal v0.0.0-20161222050454-9bc47e2707d9/go.mod h1:9N3QHEQ4Ov/dAnHmOIJ8ffm8O1iQfCPfso+PpakXPsY= 20 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 21 | github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= 22 | github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= 23 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 24 | golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= 25 | golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= 26 | golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= 27 | golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= 28 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= 30 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 31 | golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= 32 | golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= 33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 34 | -------------------------------------------------------------------------------- /term/common.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | "golang.org/x/crypto/ssh/terminal" 12 | ) 13 | 14 | var ecsi = "\033]" 15 | var st = "\007" 16 | 17 | var cellSizeOnce sync.Once 18 | var cellWidth, cellHeight float64 19 | var termWidth, termHeight int 20 | 21 | func HasGraphicsSupport() bool { 22 | return os.Getenv("TERM_PROGRAM") == "iTerm.app" || 23 | kittyEnabled || 24 | sixelEnabled 25 | } 26 | 27 | // ClearScrollback clears iTerm2 scrollback. 28 | func ClearScrollback() { 29 | if !sixelEnabled { 30 | print(ecsi + "1337;ClearScrollback" + st) 31 | } 32 | } 33 | 34 | // TermSize contains sizing information of the terminal. 35 | type TermSize struct { 36 | Row int 37 | Col int 38 | Width int 39 | Height int 40 | } 41 | 42 | func initCellSize() { 43 | s, err := terminal.MakeRaw(1) 44 | if err != nil { 45 | return 46 | } 47 | defer terminal.Restore(1, s) 48 | if sixelEnabled { 49 | fmt.Fprint(os.Stdout, "\033[14t") 50 | fileSetReadDeadline(os.Stdout, time.Now().Add(time.Second)) 51 | defer fileSetReadDeadline(os.Stdout, time.Time{}) 52 | fmt.Fscanf(os.Stdout, "\033[4;%d;%dt", &termHeight, &termWidth) 53 | return 54 | } 55 | if kittyEnabled { 56 | // For Kitty terminals, use the standard terminal size query 57 | fmt.Fprint(os.Stdout, "\033[14t") 58 | fileSetReadDeadline(os.Stdout, time.Now().Add(time.Second)) 59 | defer fileSetReadDeadline(os.Stdout, time.Time{}) 60 | fmt.Fscanf(os.Stdout, "\033[4;%d;%dt", &termHeight, &termWidth) 61 | return 62 | } 63 | fmt.Fprint(os.Stdout, ecsi+"1337;ReportCellSize"+st) 64 | fileSetReadDeadline(os.Stdout, time.Now().Add(time.Second)) 65 | defer fileSetReadDeadline(os.Stdout, time.Time{}) 66 | fmt.Fscanf(os.Stdout, "\033]1337;ReportCellSize=%f;%f\033\\", &cellHeight, &cellWidth) 67 | } 68 | 69 | // Size gathers sizing information of the current session's controling terminal. 70 | func Size() (size TermSize, err error) { 71 | size.Col, size.Row, err = terminal.GetSize(1) 72 | if err != nil { 73 | return 74 | } 75 | cellSizeOnce.Do(initCellSize) 76 | if termWidth > 0 && termHeight > 0 { 77 | size.Width = int(termWidth/(size.Col-1)) * (size.Col - 1) 78 | size.Height = int(termHeight/(size.Row-1)) * (size.Row - 1) 79 | return 80 | } 81 | if cellWidth+cellHeight == 0 { 82 | err = errors.New("cannot get terminal cell size") 83 | } 84 | size.Width, size.Height = size.Col*int(cellWidth), size.Row*int(cellHeight) 85 | return 86 | } 87 | 88 | // Rows returns the number of rows for the controling terminal. 89 | func Rows() (rows int, err error) { 90 | _, rows, err = terminal.GetSize(1) 91 | return 92 | } 93 | 94 | func NewImageWriter(width, height int) io.WriteCloser { 95 | if sixelEnabled { 96 | return &sixelWriter{ 97 | Width: width, 98 | Height: height, 99 | } 100 | } 101 | if kittyEnabled { 102 | return &kittyWriter{ 103 | Width: width, 104 | Height: height, 105 | } 106 | } 107 | return &imageWriter{ 108 | Width: width, 109 | Height: height, 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /term/kitty.go: -------------------------------------------------------------------------------- 1 | package term 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "fmt" 7 | "os" 8 | "sync" 9 | "time" 10 | 11 | "golang.org/x/crypto/ssh/terminal" 12 | ) 13 | 14 | var kittyEnabled = false 15 | var kittyCurrentID = 1 // Track current image ID globally 16 | var kittyFirstImage = true // Track if this is the first image 17 | 18 | func init() { 19 | if os.Getenv("TERM_PROGRAM") != "iTerm.app" { 20 | kittyEnabled = checkKitty() 21 | } 22 | } 23 | 24 | // checkKitty detects if the terminal supports Kitty graphics protocol 25 | func checkKitty() bool { 26 | s, err := terminal.MakeRaw(1) 27 | if err != nil { 28 | return false 29 | } 30 | defer terminal.Restore(1, s) 31 | 32 | // Send Kitty graphics query to check if graphics are supported 33 | // Use a simple query that Kitty will respond to if it supports graphics 34 | _, err = os.Stdout.Write([]byte("\033_Gi=1,a=q,t=d,f=24\033\\")) 35 | if err != nil { 36 | return false 37 | } 38 | 39 | fileSetReadDeadline(os.Stdout, time.Now().Add(500*time.Millisecond)) 40 | defer fileSetReadDeadline(os.Stdout, time.Time{}) 41 | 42 | var b [200]byte 43 | n, err := os.Stdout.Read(b[:]) 44 | if err != nil { 45 | return false 46 | } 47 | 48 | // Look for Kitty's graphics response 49 | // Kitty will respond with \033_Gi=1;...\033\ if it supports graphics 50 | response := b[:n] 51 | return bytes.Contains(response, []byte("_G")) && bytes.Contains(response, []byte("i=1")) 52 | } 53 | 54 | // kittyWriter is a writer that displays PNG images in Kitty terminal using the Kitty graphics protocol 55 | type kittyWriter struct { 56 | Name string 57 | Width int 58 | Height int 59 | 60 | once sync.Once 61 | buf *bytes.Buffer 62 | } 63 | 64 | func (w *kittyWriter) init() { 65 | w.buf = &bytes.Buffer{} 66 | } 67 | 68 | // Write writes the PNG image data into the kittyWriter buffer. 69 | func (w *kittyWriter) Write(p []byte) (n int, err error) { 70 | w.once.Do(w.init) 71 | return w.buf.Write(p) 72 | } 73 | 74 | // Close flushes the image to the terminal using Kitty's graphics protocol and closes the writer. 75 | func (w *kittyWriter) Close() error { 76 | w.once.Do(w.init) 77 | 78 | // Encode the image data as base64 79 | b64data := base64.StdEncoding.EncodeToString(w.buf.Bytes()) 80 | 81 | // Calculate next image ID (alternate between 1 and 2) 82 | nextID := 3 - kittyCurrentID 83 | 84 | // Step 1: Display the new image with absolute positioning 85 | // a=T (transmit and display), f=100 (PNG), t=d (direct), i=nextID (image ID), 86 | // X=0,Y=0 (absolute position), q=2 (suppress responses) 87 | fmt.Printf("\033_Ga=T,f=100,t=d,i=%d,X=0,Y=0,q=2;%s\033\\", nextID, b64data) 88 | 89 | // Step 2: Delete the previously displayed image (skip deletion only on very first image) 90 | // Remove q=2 from delete to ensure it executes properly 91 | if !kittyFirstImage { 92 | fmt.Printf("\033_Ga=d,d=i,i=%d,q=2\033\\", kittyCurrentID) 93 | } 94 | kittyFirstImage = false // After first image, we always delete 95 | 96 | // Update current ID for next time 97 | kittyCurrentID = nextID 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /graph/legend.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "math" 5 | 6 | chart "github.com/go-analyze/charts/chartdraw" 7 | ) 8 | 9 | // custom version of chart.Legend 10 | func legend(c *chart.Chart, userDefaults ...chart.Style) chart.Renderable { 11 | return func(r chart.Renderer, cb chart.Box, chartDefaults chart.Style) { 12 | legendDefaults := chart.Style{ 13 | FillColor: chart.ColorWhite, 14 | FontStyle: chart.FontStyle{ 15 | FontColor: chart.ColorBlack, 16 | FontSize: 8.0, 17 | }, 18 | StrokeColor: chart.ColorBlack, 19 | StrokeWidth: 1.0, 20 | } 21 | 22 | legendPadding := chart.Box{ 23 | Top: 5, 24 | Left: 5, 25 | Right: 5, 26 | Bottom: 5, 27 | } 28 | lineTextGap := 5 29 | lineLengthMinimum := 25 30 | 31 | var legendStyle chart.Style 32 | if len(userDefaults) > 0 { 33 | legendStyle = userDefaults[0].InheritFrom(chartDefaults.InheritFrom(legendDefaults)) 34 | } else { 35 | legendStyle = chartDefaults.InheritFrom(legendDefaults) 36 | } 37 | 38 | var labels []string 39 | var lines []chart.Style 40 | for _, s := range c.Series { 41 | if s.GetStyle().IsZero() || !s.GetStyle().Hidden { 42 | if _, isAnnotationSeries := s.(chart.AnnotationSeries); !isAnnotationSeries { 43 | labels = append(labels, s.GetName()) 44 | lines = append(lines, s.GetStyle()) 45 | } 46 | } 47 | } 48 | 49 | legend := chart.Box{ 50 | Top: cb.Top, 51 | Left: cb.Left, 52 | // bottom and right will be sized by the legend content 53 | } 54 | 55 | legendContent := chart.Box{ 56 | Top: legend.Top + legendPadding.Top, 57 | Left: legend.Left + legendPadding.Left, 58 | Right: legend.Left + legendPadding.Left, 59 | Bottom: legend.Top + legendPadding.Top, 60 | } 61 | 62 | legendStyle.GetTextOptions().WriteToRenderer(r) 63 | 64 | // measure 65 | labelCount := 0 66 | for x := 0; x < len(labels); x++ { 67 | if len(labels[x]) > 0 { 68 | tb := r.MeasureText(labels[x]) 69 | legendContent.Bottom += tb.Height() + 5 70 | right := legendContent.Left + tb.Width() + lineTextGap + lineLengthMinimum 71 | legendContent.Right = int(math.Max(float64(legendContent.Right), float64(right))) 72 | labelCount++ 73 | } 74 | } 75 | 76 | legend = legend.Grow(legendContent) 77 | legend.Right = legendContent.Right + legendPadding.Right 78 | legend.Bottom = legendContent.Bottom + legendPadding.Bottom 79 | 80 | chart.Draw.Box(r, legend, legendStyle) 81 | 82 | legendStyle.GetTextOptions().WriteToRenderer(r) 83 | 84 | ycursor := legendContent.Top 85 | tx := legendContent.Left 86 | legendCount := 0 87 | var label string 88 | for x := 0; x < len(labels); x++ { 89 | label = labels[x] 90 | if len(label) > 0 { 91 | if legendCount > 0 { 92 | ycursor += 5 93 | } 94 | 95 | tb := r.MeasureText(label) 96 | 97 | ty := ycursor + tb.Height() 98 | r.Text(label, tx, ty) 99 | 100 | th2 := tb.Height() >> 1 101 | 102 | lx := tx + tb.Width() + lineTextGap 103 | ly := ty - th2 104 | lx2 := legendContent.Right - legendPadding.Right 105 | 106 | r.SetStrokeColor(lines[x].GetStrokeColor()) 107 | r.SetStrokeWidth(lines[x].GetStrokeWidth()) 108 | r.SetStrokeDashArray(lines[x].GetStrokeDashArray()) 109 | 110 | r.MoveTo(lx, ly) 111 | r.LineTo(lx2, ly) 112 | r.Stroke() 113 | 114 | ycursor += tb.Height() 115 | legendCount++ 116 | } 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main // import "github.com/rs/jplot" 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "os/signal" 8 | "strings" 9 | "sync" 10 | "syscall" 11 | "time" 12 | 13 | "github.com/monochromegane/terminal" 14 | "github.com/rs/jplot/data" 15 | "github.com/rs/jplot/graph" 16 | "github.com/rs/jplot/term" 17 | ) 18 | 19 | func main() { 20 | flag.Usage = func() { 21 | out := os.Stderr 22 | fmt.Fprintln(out, "Usage: jplot [OPTIONS] FIELD_SPEC [FIELD_SPEC...]:") 23 | fmt.Fprintln(out, "") 24 | fmt.Fprintln(out, "OPTIONS:") 25 | flag.PrintDefaults() 26 | fmt.Fprintln(out, "") 27 | fmt.Fprintln(out, "FIELD_SPEC: [