Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions internal/chart/chart.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Package chart renders float series as terminal-cell charts. It is pure
// computation: no screen, no styles — callers color cells by Kind.
package chart

import "fmt"

// Style selects the drawing technique for line charts.
type Style int

const (
StyleBraille Style = iota // 2×4 pixels per cell (Unicode braille)
StyleBox // cell-resolution solid line (╭─╮ box drawing)
StyleASCII // plain-ASCII fallback (marker per column)
)

// Interp selects how samples are interpolated onto pixel columns.
type Interp int

const (
InterpLinear Interp = iota
InterpSmooth // monotone cubic (Fritsch–Carlson); no overshoot
)

// Kind tags what a cell is part of, so callers can style layers separately.
type Kind int

const (
KindNone Kind = iota
KindLine
KindAxis
KindLabel
)

// Cell is one terminal cell of a rendered chart.
type Cell struct {
Rune rune
Kind Kind
}

// Options configures Render. Thickness (1..3) only affects StyleBraille;
// zero means 1.
type Options struct {
Width, Height int
Style Style
Interp Interp
Thickness int
}

func bounds(series []float64) (lo, hi float64) {
lo, hi = series[0], series[0]
for _, v := range series[1:] {
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
return lo, hi
}

// brailleBits holds the braille dot bit for the pixel at (px, py) inside one
// cell: 2 pixel columns × 4 pixel rows per character cell.
var brailleBits = [4][2]int{{0x01, 0x08}, {0x02, 0x10}, {0x04, 0x20}, {0x40, 0x80}}

// labelW is the y-axis label gutter: "999 " right-aligned, then the tick.
const labelW = 4

// sample maps series onto n points. InterpLinear draws straight segments
// between samples; InterpSmooth is added with the style work (PR-2) and
// falls back to linear until then.
func sample(series []float64, n int, ip Interp) []float64 {
out := make([]float64, n)
for c := 0; c < n; c++ {
pos := float64(c) * float64(len(series)-1) / float64(n-1)
i := int(pos)
v := series[i]
if frac := pos - float64(i); i+1 < len(series) {
v = series[i]*(1-frac) + series[i+1]*frac
}
out[c] = v
}
return out
}

// pixelRows maps sampled values onto pixel rows using the given lo/hi
// bounds (the TRUE series bounds, not the sampled array's own bounds — the
// caller must scale consistently with whatever bounds the axis labels
// use). Row 0 is the top (max). A flat series (hi == lo) sits on the
// middle row.
func pixelRows(values []float64, pxRows int, lo, hi float64) []int {
out := make([]int, len(values))
for i, v := range values {
if hi == lo {
out[i] = pxRows / 2
continue
}
out[i] = int((hi - v) / (hi - lo) * float64(pxRows-1))
}
return out
}

// Render draws series as a line chart with a labeled y-axis into a
// Height×Width cell grid. Fewer than two samples, or a rect too small to
// hold the axis, returns nil.
func Render(series []float64, o Options) [][]Cell {
cols := o.Width - labelW - 1
if len(series) < 2 || cols < 2 || o.Height < 2 {
return nil
}
grid := make([][]Cell, o.Height)
for i := range grid {
grid[i] = make([]Cell, o.Width)
for j := range grid[i] {
grid[i][j] = Cell{Rune: ' '}
}
}

lo, hi := bounds(series)
tick := '┤'
if o.Style == StyleASCII {
tick = '|'
}
for row := 0; row < o.Height; row++ {
v := hi
if hi != lo {
v = hi - (hi-lo)*float64(row)/float64(o.Height-1)
}
if row == 0 || row == o.Height-1 || row == (o.Height-1)/2 {
label := fmt.Sprintf("%*.0f", labelW-1, v)
for i, r := range []rune(label) {
grid[row][i] = Cell{Rune: r, Kind: KindLabel}
}
}
grid[row][labelW] = Cell{Rune: tick, Kind: KindAxis}
}

switch o.Style {
case StyleASCII:
renderASCII(grid, series, cols, o.Height)
default:
renderBraille(grid, series, cols, o, lo, hi)
}
return grid
}

// renderBraille plots into a 2×4-per-cell pixel grid, joining neighbor
// pixel columns vertically so steep segments stay connected. lo, hi are
// the TRUE series bounds (matching the axis labels), NOT the bounds of the
// downsampled array — a narrow peak can fall between sample points and be
// smoothed away, so scaling against the sample's own bounds would diverge
// from what the axis reports.
func renderBraille(grid [][]Cell, series []float64, cols int, o Options, lo, hi float64) {
pxRows := pixelRows(sample(series, cols*2, o.Interp), o.Height*4, lo, hi)
masks := make([][]int, o.Height)
for i := range masks {
masks[i] = make([]int, cols)
}
prev := pxRows[0]
for c, row := range pxRows {
lo, hi := row, row
if c > 0 {
if prev < row {
lo = prev + 1
} else if prev > row {
hi = prev - 1
}
}
for py := lo; py <= hi; py++ {
masks[py/4][c/2] |= brailleBits[py%4][c%2]
}
prev = row
}
for cy := range masks {
for cx, mask := range masks[cy] {
if mask != 0 {
grid[cy][labelW+1+cx] = Cell{Rune: rune(0x2800 + mask), Kind: KindLine}
}
}
}
}

// renderASCII is the --ascii fallback: a marker per cell column with
// vertical fill between steep neighbors. Samples are picked per column
// without interpolation, matching the pre-refactor behavior.
func renderASCII(grid [][]Cell, series []float64, cols, height int) {
rows := make([]int, cols)
lo, hi := bounds(series)
for c := 0; c < cols; c++ {
i := c * (len(series) - 1) / (cols - 1)
if hi == lo {
rows[c] = height / 2
continue
}
rows[c] = int((hi - series[i]) / (hi - lo) * float64(height-1))
}
for c, row := range rows {
grid[row][labelW+1+c] = Cell{Rune: '*', Kind: KindLine}
if c > 0 {
a, b := rows[c-1], row
if a > b {
a, b = b, a
}
for between := a + 1; between < b; between++ {
grid[between][labelW+1+c] = Cell{Rune: '|', Kind: KindLine}
}
}
}
}
180 changes: 180 additions & 0 deletions internal/chart/chart_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package chart

import "testing"

func TestRenderNilOnDegenerate(t *testing.T) {
o := Options{Width: 20, Height: 5}
if Render([]float64{42}, o) != nil {
t.Fatal("single sample should render nil")
}
if Render([]float64{1, 2}, Options{Width: 6, Height: 5}) != nil {
t.Fatal("too-narrow rect should render nil")
}
if Render([]float64{1, 2}, Options{Width: 20, Height: 1}) != nil {
t.Fatal("too-short rect should render nil")
}
}

func TestRenderDimensions(t *testing.T) {
o := Options{Width: 20, Height: 5}
g := Render([]float64{10, 20, 30}, o)
if len(g) != 5 || len(g[0]) != 20 {
t.Fatalf("grid is %dx%d, want 5x20", len(g), len(g[0]))
}
}

func TestRenderAxisAndLabels(t *testing.T) {
o := Options{Width: 20, Height: 5}
g := Render([]float64{0, 100}, o)
const labelW = 4
for row := 0; row < 5; row++ {
if g[row][labelW].Kind != KindAxis || g[row][labelW].Rune != '┤' {
t.Fatalf("row %d col %d should be axis tick, got %q/%v",
row, labelW, g[row][labelW].Rune, g[row][labelW].Kind)
}
}
// top row label reads "100", bottom " 0" (right-aligned width 3)
top := string([]rune{g[0][0].Rune, g[0][1].Rune, g[0][2].Rune})
if top != "100" {
t.Fatalf("top label %q, want 100", top)
}
if g[0][0].Kind != KindLabel {
t.Fatalf("label cell kind = %v, want KindLabel", g[0][0].Kind)
}
}

func TestRenderLineCellsTagged(t *testing.T) {
o := Options{Width: 20, Height: 5}
g := Render([]float64{0, 50, 100}, o)
found := false
for _, row := range g {
for cx, c := range row {
if c.Kind == KindLine {
if cx <= 4 {
t.Fatalf("line cell inside axis area at col %d", cx)
}
found = true
}
}
}
if !found {
t.Fatal("no line cells rendered")
}
}

func TestRenderFlatSeriesMidRow(t *testing.T) {
o := Options{Width: 20, Height: 5}
g := Render([]float64{50, 50, 50, 50}, o)
for row := 0; row < 5; row++ {
hasLine := false
for _, c := range g[row] {
if c.Kind == KindLine {
hasLine = true
}
}
// flat series → all line pixels land in the middle pixel row band
if hasLine && row != 2 {
t.Fatalf("flat series drew on cell row %d, want only row 2", row)
}
}
}

func TestRenderASCIIStyle(t *testing.T) {
o := Options{Width: 20, Height: 5, Style: StyleASCII}
g := Render([]float64{0, 100, 0}, o)
stars := 0
for _, row := range g {
for _, c := range row {
if c.Kind == KindLine && c.Rune == '*' {
stars++
}
if c.Rune >= 0x2800 && c.Rune <= 0x28FF {
t.Fatal("ASCII style must not emit braille runes")
}
}
}
if stars == 0 {
t.Fatal("ASCII style should draw * markers")
}
if g[0][4].Rune != '|' {
t.Fatalf("ASCII tick should be '|', got %q", g[0][4].Rune)
}
}

func TestSampleLinearInterpolates(t *testing.T) {
got := sample([]float64{0, 10}, 3, InterpLinear)
want := []float64{0, 5, 10}
for i := range want {
if got[i] != want[i] {
t.Fatalf("sample[%d] = %v, want %v", i, got[i], want[i])
}
}
}

func TestPixelRowsFlat(t *testing.T) {
rows := pixelRows([]float64{5, 5, 5, 5}, 8, 5, 5)
for _, r := range rows {
if r != 4 {
t.Fatalf("flat series pixel row = %d, want 4", r)
}
}
}

// TestRenderBrailleScalesAgainstTrueSeriesBounds is a regression test for a
// bug where the braille line was scaled against the bounds of the
// downsampled array (sample(series, cols*2, ...)) instead of the true
// series bounds used by the axis labels. When cols*2 < len(series), a
// narrow peak can be smoothed away by interpolation before scaling, so the
// sampled max is well below the true max — the line then reports as
// touching the axis top even though it is nowhere near the labeled max.
//
// series has a single spike (100) at index 3 out of 17 points, everything
// else 0. With Width=9 the plot area is 4 cols, so cols*2 = 8 sample
// points — well under 17, so the spike lands between two sample points and
// is interpolated down to ~28.6 instead of surviving as 100.
func TestRenderBrailleScalesAgainstTrueSeriesBounds(t *testing.T) {
series := make([]float64, 17)
series[3] = 100

o := Options{Width: 9, Height: 10}
cols := o.Width - labelW - 1
if cols*2 >= len(series) {
t.Fatalf("test setup invalid: cols*2=%d must be < len(series)=%d", cols*2, len(series))
}

// Expected top pixel row, computed against the TRUE series bounds
// (what the axis labels use), not the sampled array's own bounds.
lo, hi := bounds(series)
values := sample(series, cols*2, InterpLinear)
peak := values[0]
for _, v := range values {
if v > peak {
peak = v
}
}
pxRows := o.Height * 4
expectedPixelRow := int((hi - peak) / (hi - lo) * float64(pxRows-1))
expectedCellRow := expectedPixelRow / 4

g := Render(series, o)
topRow := -1
for row := range g {
for _, c := range g[row] {
if c.Kind == KindLine {
topRow = row
break
}
}
if topRow != -1 {
break
}
}
if topRow == -1 {
t.Fatal("no line cells rendered")
}
if topRow != expectedCellRow {
t.Fatalf("topmost line cell row = %d, want %d (scaled against true series bounds; "+
"got row 0 would indicate the bug — scaling against the sampled array's own bounds)",
topRow, expectedCellRow)
}
}
Loading
Loading