├── .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: [