Skip to content
NimbleMarketsPublic

About

Nimble Terminal Charts for the Golang BubbleTea framework and your TUIs

Topics

Resources

Code of conduct

Stars

805 stars

Watchers

9 watching

Forks

Repository files navigation

ntcharts - Nimble Terminal Charts

Build Status Latest Release GoDoc Code Of Conduct

ntcharts is a Golang Terminal Charting library for the Bubble Tea Framework and other TUIs.

We supply many chart types within the glory of your terminal!

Explore our NTCharts Live Demos. They are our example code compiled to WASM and embedded in HTML using NimbleMarkets/go-booba.

Type Description
Canvas A 2D grid to plot arbitrary runes, with LipGloss for styling and BubbleZone for mousing. It is the foundation for all the following charts.
Bar Chart Displays values as either horizontal rows or vertical columns.
Heat Map Displays (x,y) values on a color-mapped heatmap.
Line Chart Displays (X,Y) data points onto a 2D grid in various types of charts.
OHLC/Candle Chart Displays Open, High, Low, Close values as candlesticks.
Picture Displays images with picture and via http with pictureurl
Chart Picture Renders go-analyze/charts chart images via an embedded picture.Model — Kitty graphics with glyph fallback.
Heat Picture High-resolution continuous-field heatmap via an embedded picture.Model. Sampler-driven; Kitty-mode samples at full pixel resolution for smooth gradients.
Scatter Chart Plots abitrary runes onto (X,Y) coordinates.
Streamline Chart Displays a continuous a line moving across the Canvas from the right side to the left side.
Time Series Chart Displays lines with values on the Y axis and time values on the X axis.
Waveline Chart A line chart that connects points in a wave pattern.
Sparkline A small, simple visual of data chart for quick understanding.

For Kitty graphics in games, boards, tile maps, or other coordinate-aligned UIs, see KITTY.md (which includes instructions for running inside tmux).

Companion Widgets

These sibling Bubble Tea widgets build on ntcharts/v2/picture — half-block glyphs anywhere, full-resolution Kitty graphics on terminals that support them:

Widget Description
ntcharts-pdf Terminal PDF viewer — pure-Go text extraction plus PDFium-via-WASM page rasterization. Live WASM demo.
ntcharts-svg Terminal SVG viewer and vector canvas — pure-Go rasterization, immediate-mode drawing, and SVG/PNG export. Live WASM demo.
ntcharts-osm Terminal OpenStreetMap widget — renders map tiles with markers and paths.
ntcharts3d 3D charts — scatter, surface, bar, line, and vector-field series with an orbit/pan/zoom camera, picking, and legends. Live WASM demo.

Quickstart Tutorial

This tutorial creates a simple Time Series Chart with two data sets utilizing the Bubble Tea framework, Lip Gloss for styling and BubbleZone for mouse support.

quickstart gif

Demo Apps

Standalone CLI demos. Run task to build them all into the bin/ directory.

Command Source What it shows
ntcharts-quickstart examples/quickstart The tutorial above — time-series chart with two data sets, mouse support.
ntcharts-ohlc cmd/ntcharts-ohlc Renders OHLC candles + a sparkline from a CSV (example.csv) using the time-series line chart with braille runes.
ntcharts-lorem-picsum cmd/ntcharts-lorem-picsum Sortable/filterable Lorem Picsum catalog browser; previews the selected image side-by-side in Glyph and Kitty graphics modes via pictureurl. Requires a Kitty-graphics-capable terminal for the right pane.
ntcharts-picture examples/picture Two-pane image demo: embedded PNG via picture on the left, fetched URL via pictureurl on the right.
ntcharts-chartpicture examples/chartpicture Live-updating chart rendered through chartpicture (go-analyze/charts → image → Kitty/Glyph). Press r to swap line/bar, t to cycle themes, g to toggle modes.
ntcharts-heatpicture-perlin examples/heatpicture/perlin Animated 2D Perlin-noise field rendered through heatpicture at full Kitty-graphics resolution (smooth sub-cell gradients) with Glyph fallback. <space> start/stop, t toggle modes, F cycle sampling factor.

ntcharts-lorem-picsum gif

ntcharts-ohlc gif

Multi-surface rendering (spec package)

The spec package defines a neutral, surface-agnostic chart specification. A single spec.Spec value can be rendered to the terminal (spec.Build(s) → an ntcharts model) or to the web (echarts.ToECharts(s) → a go-echarts/v2 chart, from the optional github.com/NimbleMarkets/ntcharts/spec/echarts/v2 module). See spec/README.md for details, the current chart-type support matrix, and how to run the tests. A spec can carry a second, right-hand Y axis (y2_axis, series[].y_axis: "right") that both surfaces honour — see the spec README's "Dual Y axes".

BubbleTea Version Compatibility

We have migrated to Bubble Tea v2. It exists on the v2 branch. You should import as so:

import "github.com/NimbleMarkets/ntcharts/v2"

Our Bubble Tea v1 compatible library exists on the main branch. You should import it as so:

import "github.com/NimbleMarkets/ntcharts"

We will continue to backport relevant fixes to both branches.

Please note that the v2 designation is for BubbleTea API compatibility. Despite the version number, the ntcharts API is still subject to change. v2 is the primary development branch branch now.

Usage

See the examples folder for code samples and visuals of each type.

The optional chartpicture integration is a separate module, so core users do not inherit its chart-rendering dependencies. Starting with v2.4.0, use:

go get github.com/NimbleMarkets/ntcharts/picture/chartpicture/v2@latest
import "github.com/NimbleMarkets/ntcharts/picture/chartpicture/v2"

If you used chartpicture from v2.2.0 or earlier, update its old github.com/NimbleMarkets/ntcharts/v2/picture/chartpicture import to the path above. Other library imports are unchanged. Examples and GPU shaders also have separate modules to keep their dependencies out of the core library.

Canvas

package main

import (
    "fmt"
    "github.com/NimbleMarkets/ntcharts/v2/canvas"
    "charm.land/lipgloss/v2"
)

func main() {
    c := canvas.New(5, 2)
    c.SetLinesWithStyle(
        []string{"hello", "world"},
        lipgloss.NewStyle().Foreground(lipgloss.Color("6"))) // cyan

    fmt.Println(c.View())
}

This example produces the following canvas with Lip Gloss foreground color:

canvas png

Bar Chart

package main

import (
    "fmt"
    "github.com/NimbleMarkets/ntcharts/v2/barchart"
    "charm.land/lipgloss/v2"
)

func main() {
    d1 := barchart.BarData{
        Label: "A",
        Values: []barchart.BarValue{
            {"Item1", 21.2, lipgloss.NewStyle().Foreground(lipgloss.Color("10"))}}, // green
    }
    d2 := barchart.BarData{
        Label: "B",
        Values: []barchart.BarValue{
            {"Item1", 15.2, lipgloss.NewStyle().Foreground(lipgloss.Color("9"))}}, // red
    }

    bc := barchart.New(11, 10)
    bc.PushAll([]barchart.BarData{d1, d2})
    bc.Draw()

    fmt.Println(bc.View())
}

This example produces the following bar chart with green and red bars:

barchart png

Streamline Chart

package main

import (
    "fmt"
    "github.com/NimbleMarkets/ntcharts/v2/linechart/streamlinechart"
)

func main() {
    slc := streamlinechart.New(13, 10)
    for _, v := range []float64{4, 6, 8, 10, 8, 6, 4, 2, 0, 2, 4} {
        slc.Push(v)
    }
    slc.Draw()

    fmt.Println(slc.View())
}

This example produces the following streamline chart:

  │  ╭╮      
 8│  ││      
  │ ╭╯╰╮     
 6│ │  │     
  │╭╯  ╰╮    
 4├╯    ╰╮  ╭
  │      │  │
 2│      ╰╮╭╯
  │       ││ 
 0│       ╰╯ 

Time Series Chart

package main

import (
    "fmt"
    "time"
    "github.com/NimbleMarkets/ntcharts/v2/linechart/timeserieslinechart"
)

func main() {
    tslc := timeserieslinechart.New(41, 10)
    for i, v := range []float64{0, 4, 8, 10, 8, 4, 0, -4, -8, -10, -8, -4, 0} {
        date := time.Now().Add(time.Hour * time.Duration(24*i))
        tslc.Push(timeserieslinechart.TimePoint{date, v})
    }
    tslc.DrawBraille()

    fmt.Println(tslc.View())
}

This example produces the following time series chart using braille runes starting with today's date:

 10│      ⣀⠤⠒⠉⠒⠤⡀                        
   │    ⡠⠊      ⠈⠢⡀                      
  5│  ⡠⠊          ⠈⠢⡀                    
   │⡠⠊              ⠈⠑⢄                 ⢀
  0│                   ⠑⡄              ⡔⠁
   │                    ⠈⠢⡀          ⡠⠊  
 -5│                      ⠈⠢⡀      ⡠⠊    
   │                        ⠈⠑⠢⢄⡠⠔⠊      
-10└─────────────────────────────────────
   '24 03/27   03/31   04/03   04/05     

For a rolling time window, trim old samples and fit Y after adding each batch:

start := now.Add(-60 * time.Second)
tslc.TrimBefore(start) // removes older samples from every data set
tslc.SetViewTimeRange(start, now)
tslc.FitYToViewWithOpts(timeserieslinechart.FitYOpts{IncludeZero: true})
tslc.DrawBrailleAll()

FitYToView() fits all data sets to the stored values inside the time viewport. FitYToViewWithOpts can select DataSets and include a zero baseline on linear axes. Both can shrink the displayed Y range when a spike leaves the window; neither changes the existing auto-range flags. Empty windows leave Y unchanged. TrimBefore releases discarded storage and leaves ranges unchanged. Keep a sample before the viewport if you need interpolation at its left edge. See the rolling throughput example.

Waveline Chart

package main

import (
    "fmt"
    "github.com/NimbleMarkets/ntcharts/v2/canvas"
    "github.com/NimbleMarkets/ntcharts/v2/linechart/wavelinechart"
)

func main() {
    wlc := wavelinechart.New(12, 10, wavelinechart.WithYRange(-3, 3))
    wlc.Plot(canvas.Float64Point{1.0, 2.0})
    wlc.Plot(canvas.Float64Point{3.0, -2.0})
    wlc.Plot(canvas.Float64Point{5.0, 2.0})
    wlc.Plot(canvas.Float64Point{7.0, -2.0})
    wlc.Plot(canvas.Float64Point{9.0, 2.0})
    wlc.Draw()

    fmt.Println(wlc.View())
}

This example produces the following waveline chart:

 3│         
  │╭╮  ╭╮  ╭
 2│││  ││  │
  │││  ││  │
 0├╯╰╮╭╯╰╮╭╯
  │  ││  ││ 
-2│  ││  ││ 
  │  ╰╯  ╰╯ 
-3└─────────
  0 2 4 6    

Log scales

The logscale example shows it live.

The line charts can space either axis logarithmically (base 10), so each decade takes the same length of axis. Set linechart.ScaleLog with WithXScale / WithYScale (or SetXScale / SetYScale) on linechart and wavelinechart, and with WithYScale on timeserieslinechart and streamlinechart:

package main

import (
    "fmt"
    "github.com/NimbleMarkets/ntcharts/v2/canvas"
    "github.com/NimbleMarkets/ntcharts/v2/linechart"
    "github.com/NimbleMarkets/ntcharts/v2/linechart/wavelinechart"
)

func main() {
    wlc := wavelinechart.New(24, 10,
        wavelinechart.WithYScale(linechart.ScaleLog),
        wavelinechart.WithXYRange(0, 5, 1, 10000))
    for i, v := range []float64{3, 40, 500, 6000} {
        wlc.Plot(canvas.Float64Point{X: float64(i + 1), Y: v})
    }
    wlc.Draw()

    fmt.Println(wlc.View())
}

This example produces the following chart:

10000│             ╭╮   
     │             ││   
 1000│             ││   
     │          ╭╮ ││   
  100│          ││ ││   
     │      ╭╮  ││ ││   
   10│      ││  ││ ││   
     │   ╭╮ ││  ││ ││   
    1└───┴┴─┴┴──┴┴─┴┴───
     0 1   2   3   4  5 
  • Ranges, data points, and the values passed to label formatters stay in data units; only the mapping to rows and columns changes.
  • Labels sit on powers of ten, on the row or column where each one falls, whatever the chart's size. When a range holds fewer than three of them, 2× and 5× are added where they fit; a range too narrow for two round values falls back to evenly spaced labels. The X and Y steps still hide an axis at 0, and otherwise set the minimum spacing between labels.
  • Only values greater than zero have a place on a log axis. A point at or below zero is not drawn (a line breaks around it), auto-ranging ignores it, and a non-positive range bound is replaced: the minimum becomes a tenth of the maximum, or the range becomes 1..10 if neither is positive.
  • Zoom and pan steps are measured in decades, so a zoom multiplies the bounds and a pan shifts them by a constant ratio.
  • The default label formatter prints whole numbers. Set your own with WithYLabelFormatter / WithXLabelFormatter for ranges below 1.

Dual Y axes

The dualaxis example shows it live.

A line chart can show a second Y axis on the right, with its own range, scale (linear or log), label formatter, and label style, so two series on very different scales can share one X axis. Describe an axis with linechart.Axis and add it with WithY2Axis / SetY2Axis. YAxis / SetYAxis read and set the left axis the same way. The right axis has no view of its own: it follows the left axis when you zoom or pan.

timeserieslinechart, wavelinechart, and streamlinechart put a data set on the right axis with WithDataSetYAxis / SetDataSetYAxis. With a plain linechart, map right-axis values with Y2ToY before drawing them, and auto-range them with AutoAdjustY2Range.

package main

import (
    "fmt"
    "time"

    "github.com/NimbleMarkets/ntcharts/v2/linechart"
    tslc "github.com/NimbleMarkets/ntcharts/v2/linechart/timeserieslinechart"
)

func main() {
    start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
    chart := tslc.New(40, 10,
        tslc.WithTimeRange(start, start.Add(6*time.Hour)),
        tslc.WithXLabelFormatter(tslc.HourTimeLabelFormatter()),
        tslc.WithXYSteps(12, 2),
        tslc.WithYRange(0, 1),
        tslc.WithYLabelFormatter(func(_ int, v float64) string { return fmt.Sprintf("%.1f", v) }),
        tslc.WithY2Axis(linechart.Axis{Min: 5, Max: 20}),
        tslc.WithDataSetYAxis("temp", linechart.YAxisRight),
    )
    load := []float64{0.2, 0.5, 0.4, 0.9, 0.7, 0.3, 0.6}
    temp := []float64{8, 9, 12, 15, 19, 17, 14}
    for i := range load {
        t := start.Add(time.Duration(i) * time.Hour)
        chart.PushDataSet("load", tslc.TimePoint{Time: t, Value: load[i]})
        chart.PushDataSet("temp", tslc.TimePoint{Time: t, Value: temp[i]})
    }
    chart.DrawAll()
    fmt.Println(chart.View())
}

This example produces the following chart:

1.0│                                 │20
   │                ╭──╮╭────╮       │  
0.8│              ╭─╯ ╭┴┴─╮  ╰────╮  │16
   │             ╭┼───╯   ╰─╮     ╰─┬│  
0.5│    ╭───╮╭─┬─┴╯         ╰╮    ╭─╯│12
   │ ╭──╯  ╭┴┴─╯             ╰─╮╭─╯  │  
0.2├─┴─────╯                   ╰╯    │9 
   │                                 │  
0.0└─────────────────────────────────┘5 
   00:00:00    02:10:54    04:21:49

With a plain linechart, draw right-axis values like this:

package main

import (
    "fmt"
    "github.com/NimbleMarkets/ntcharts/v2/canvas"
    "github.com/NimbleMarkets/ntcharts/v2/linechart"
)

func main() {
    lc := linechart.New(30, 8, 0, 5, 0, 1,
        linechart.WithXYSteps(2, 2),
        linechart.WithY2Axis(linechart.Axis{Min: 5, Max: 20, AutoMin: true, AutoMax: true}))
    temps := []float64{8, 12, 25, 14}

    // widen the right axis for every point first, then draw
    for i, v := range temps {
        lc.AutoAdjustY2Range(canvas.Float64Point{X: float64(i + 1), Y: v})
    }
    lc.DrawXYAxisAndLabel()
    for i, v := range temps {
        if y := lc.Y2ToY(v); y == y { // skip a NaN
            lc.DrawRune(canvas.Float64Point{X: float64(i + 1), Y: y}, '*')
        }
    }
    fmt.Println(lc.View())
}

This example produces the following chart:

 │              *          │25
 │                         │  
1│                         │18
 │          *        *     │  
 │     *                   │12
 │                         │  
0└─────────────────────────┘5 
 0   1   2     3   4     5    
  • Call AutoAdjustY2Range on the raw right-axis point before Y2ToY. The Draw* methods auto-range the left axis with the value they are given, so a right value outside the right range would otherwise widen the left axis. AutoAdjustY2Range widens the right axis instead, if its AutoMin or AutoMax is set, and the mapped value is then in range.
  • Y2ToY returns NaN for a value with no place on a log right axis (zero or below). Skip it: on a linear left axis the Draw* methods accept NaN and draw a stray stroke at the baseline instead of a break.
  • After any range change, redraw. Earlier Y2ToY mappings were made against the old view.
  • With a Y step of 0 (WithXYSteps(x, 0)) the right axis is not drawn and reserves no space, but right-axis data still maps through Y2ToY, and right-assigned data sets still draw against its range.
  • In the data-set charts, give WithDataSetYAxis and WithY2Axis before the data, or the data auto-ranges the left axis instead.

Sparkline

package main

import (
    "fmt"
    "github.com/NimbleMarkets/ntcharts/v2/sparkline"
)

func main() {
    sl := sparkline.New(10, 5)
    sl.PushAll([]float64{7.81, 3.82, 8.39, 2.06, 4.19, 4.34, 6.83, 2.51, 9.21, 1.3})
    sl.Draw()

    fmt.Println(sl.View())
}

This example produces the following sparkline:

sparkline png

DrawQuadrants() fits two values in each column using quadrant block elements, doubling the horizontal resolution at half-row vertical resolution. The same 10 values in a sparkline half as wide:

sl := sparkline.New(5, 5)
sl.PushAll([]float64{7.81, 3.82, 8.39, 2.06, 4.19, 4.34, 6.83, 2.51, 9.21, 1.3})
sl.DrawQuadrants()
 ▖  ▌
▌▌ ▖▌
▌▌▄▌▌
█▌█▙▌
████▙

Heat Map

Heat Maps map values to colors on a 2D grid. The following example creates a heatmap of the function sin(sqrt(x^2 + y^2)). There are more examples in the examples README.

package main

import (
	"fmt"
	"math"

	"github.com/NimbleMarkets/ntcharts/v2/heatmap"
)

func main() {
	hm := heatmap.New(20, 20, heatmap.WithValueRange(0, 1))
	hm.SetXYRange(-1, 1, -1, 1)
	for x := float64(-1); x < 1.0; x += 1.0 / float64(hm.GraphWidth()) {
		for y := float64(-1); y < 1.0; y += 1.0 / float64(hm.GraphHeight()) {
			val := math.Sin(math.Sqrt(x*x + y*y))
			hm.Push(heatmap.NewHeatPoint(x, y, val))
		}
	}
	hm.Draw()
	fmt.Println(hm.View())
}

This example (source) produces the following heatmap:

simple heatmap png

Open Collaboration

Run task ci before submitting changes. It checks Go formatting and module consistency, runs vet and race-enabled tests across all six modules, builds commands and examples, and verifies release packaging in temporary clones. It requires Go, Task, and a C compiler for the race detector. GitHub CI runs the same task. Use task test for the regular test suite.

We welcome contributions and feedback. Please adhere to our Code of Conduct when engaging our community.

Acknowledgements

Thanks to Charm.sh for making the command line glamorous and sharing Bubble Tea and Lip Gloss and more. Thanks to BubbleZone for bringing the mouse support 🐭.

Thanks also to asciigraph, ratatui, and termdash for inspiration.

License

This project is released under the MIT License, see LICENSE, except for the following files (also listed in NOTICE.md):

Copyright (c) 2024-2026 Neomantra Corp.


Made with ❤️ and 🔥 by the team behind Nimble.Markets.

About

Nimble Terminal Charts for the Golang BubbleTea framework and your TUIs

Topics

Resources

Code of conduct

Stars

805 stars

Watchers

9 watching

Forks

Releases

Used by

Contributors

Languages