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).
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. |
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.
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. |
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".
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.
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@latestimport "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.
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:
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:
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│ ╰╯
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.
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
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/WithXLabelFormatterfor ranges below 1.
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
AutoAdjustY2Rangeon the raw right-axis point beforeY2ToY. TheDraw*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.AutoAdjustY2Rangewidens the right axis instead, if itsAutoMinorAutoMaxis set, and the mapped value is then in range. Y2ToYreturns NaN for a value with no place on a log right axis (zero or below). Skip it: on a linear left axis theDraw*methods accept NaN and draw a stray stroke at the baseline instead of a break.- After any range change, redraw. Earlier
Y2ToYmappings 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 throughY2ToY, and right-assigned data sets still draw against its range. - In the data-set charts, give
WithDataSetYAxisandWithY2Axisbefore the data, or the data auto-ranges the left axis instead.
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:
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 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:
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.
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.
This project is released under the MIT License, see LICENSE, except for the following files (also listed in NOTICE.md):
-
The Nimby Flame image,
./web/_assets/NimbyFlame.svgremains All Rights Reserved by Neomantra Corp. You may use it only in unmodified form and only as part of this project (e.g., in forks or distributions of the project). You may not extract it for unrelated use, modify it, or redistribute it separately without explicit permission. -
The 1-bit Hokusai Wave image
./examples/picture/Fuji-01.pngis from the artist' blog and licensed under Creative Commons Attribution-NonCommercial-NoDerivatives 4.0 International License.
Copyright (c) 2024-2026 Neomantra Corp.
Made with ❤️ and 🔥 by the team behind Nimble.Markets.






