From 29c432247abf6e74885ed7f1e15588c4277f6afb Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 12:49:55 +0900 Subject: [PATCH 1/4] feat: Smooth the wpm curve with monotone cubic interpolation --- internal/chart/chart.go | 6 ++-- internal/chart/interp.go | 52 +++++++++++++++++++++++++++++++++++ internal/chart/interp_test.go | 48 ++++++++++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 internal/chart/interp.go create mode 100644 internal/chart/interp_test.go diff --git a/internal/chart/chart.go b/internal/chart/chart.go index edcf87c..c2e702a 100644 --- a/internal/chart/chart.go +++ b/internal/chart/chart.go @@ -67,9 +67,11 @@ var brailleBits = [4][2]int{{0x01, 0x08}, {0x02, 0x10}, {0x04, 0x20}, {0x40, 0x8 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. +// between samples; InterpSmooth uses monotone cubic interpolation. func sample(series []float64, n int, ip Interp) []float64 { + if ip == InterpSmooth && len(series) >= 3 { + return monotoneCubic(series, n) + } out := make([]float64, n) for c := 0; c < n; c++ { pos := float64(c) * float64(len(series)-1) / float64(n-1) diff --git a/internal/chart/interp.go b/internal/chart/interp.go new file mode 100644 index 0000000..75c17c1 --- /dev/null +++ b/internal/chart/interp.go @@ -0,0 +1,52 @@ +package chart + +import "math" + +// monotoneCubic interpolates series onto n points with the Fritsch–Carlson +// monotone cubic scheme: the curve is smooth but never overshoots its +// samples, so a WPM graph never shows a peak the player didn't hit. +func monotoneCubic(series []float64, n int) []float64 { + m := len(series) + // secant slopes and tangents + d := make([]float64, m-1) + for i := range d { + d[i] = series[i+1] - series[i] + } + t := make([]float64, m) + t[0], t[m-1] = d[0], d[m-2] + for i := 1; i < m-1; i++ { + if d[i-1]*d[i] <= 0 { + t[i] = 0 + } else { + t[i] = (d[i-1] + d[i]) / 2 + } + } + // Fritsch–Carlson limiter keeps each segment monotone. + for i := 0; i < m-1; i++ { + if d[i] == 0 { + t[i], t[i+1] = 0, 0 + continue + } + a, b := t[i]/d[i], t[i+1]/d[i] + if s := a*a + b*b; s > 9 { + tau := 3 / math.Sqrt(s) + t[i] = tau * a * d[i] + t[i+1] = tau * b * d[i] + } + } + out := make([]float64, n) + for c := 0; c < n; c++ { + pos := float64(c) * float64(m-1) / float64(n-1) + i := int(pos) + if i >= m-1 { + i = m - 2 + } + x := pos - float64(i) + h00 := (1 + 2*x) * (1 - x) * (1 - x) + h10 := x * (1 - x) * (1 - x) + h01 := x * x * (3 - 2*x) + h11 := x * x * (x - 1) + out[c] = h00*series[i] + h10*t[i] + h01*series[i+1] + h11*t[i+1] + } + return out +} diff --git a/internal/chart/interp_test.go b/internal/chart/interp_test.go new file mode 100644 index 0000000..518407a --- /dev/null +++ b/internal/chart/interp_test.go @@ -0,0 +1,48 @@ +package chart + +import "testing" + +// 단조 구간에서 보간값도 단조 — 오버슈트 없음이 스펙 요구사항이다. +func TestMonotoneCubicNoOvershoot(t *testing.T) { + series := []float64{0, 10, 12, 60, 62, 100} + got := monotoneCubic(series, 101) + lo, hi := bounds(series) + prev := got[0] + for i, v := range got { + if v < lo-1e-9 || v > hi+1e-9 { + t.Fatalf("overshoot at %d: %v outside [%v,%v]", i, v, lo, hi) + } + if v < prev-1e-9 { + t.Fatalf("monotone increasing series produced a dip at %d: %v < %v", i, v, prev) + } + prev = v + } +} + +func TestMonotoneCubicHitsSamples(t *testing.T) { + series := []float64{20, 50, 30, 80} + got := monotoneCubic(series, 7) // n = 2*(len-1)+1 → 짝수 인덱스가 원본 샘플 + for i, want := range series { + if diff := got[i*2] - want; diff > 1e-9 || diff < -1e-9 { + t.Fatalf("sample %d: got %v, want %v", i, got[i*2], want) + } + } +} + +func TestMonotoneCubicFlat(t *testing.T) { + for _, v := range monotoneCubic([]float64{5, 5, 5}, 10) { + if v != 5 { + t.Fatalf("flat series interpolated to %v", v) + } + } +} + +func TestSampleSmoothFallsBackBelowThree(t *testing.T) { + got := sample([]float64{0, 10}, 3, InterpSmooth) + want := []float64{0, 5, 10} + for i := range want { + if got[i] != want[i] { + t.Fatalf("2-sample smooth should be linear, got %v", got) + } + } +} From 1a048ddf211207c6c1a0567be6944a614576144a Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 12:53:30 +0900 Subject: [PATCH 2/4] feat: Add line thickness to the braille chart --- internal/chart/chart.go | 19 ++++++++++++++-- internal/chart/chart_test.go | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/internal/chart/chart.go b/internal/chart/chart.go index c2e702a..5ae06b3 100644 --- a/internal/chart/chart.go +++ b/internal/chart/chart.go @@ -153,11 +153,24 @@ func Render(series []float64, o Options) [][]Cell { // 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) + thick := o.Thickness + if thick < 1 { + thick = 1 + } + if thick > 3 { + thick = 3 + } + pxTotal := o.Height * 4 + pxRows := pixelRows(sample(series, cols*2, o.Interp), pxTotal, lo, hi) masks := make([][]int, o.Height) for i := range masks { masks[i] = make([]int, cols) } + set := func(py, c int) { + if py >= 0 && py < pxTotal { + masks[py/4][c/2] |= brailleBits[py%4][c%2] + } + } prev := pxRows[0] for c, row := range pxRows { lo, hi := row, row @@ -169,7 +182,9 @@ func renderBraille(grid [][]Cell, series []float64, cols int, o Options, lo, hi } } for py := lo; py <= hi; py++ { - masks[py/4][c/2] |= brailleBits[py%4][c%2] + for t := 0; t < thick; t++ { + set(py+t, c) + } } prev = row } diff --git a/internal/chart/chart_test.go b/internal/chart/chart_test.go index 9bbc27a..9bcf599 100644 --- a/internal/chart/chart_test.go +++ b/internal/chart/chart_test.go @@ -178,3 +178,45 @@ func TestRenderBrailleScalesAgainstTrueSeriesBounds(t *testing.T) { topRow, expectedCellRow) } } + +func brailleDotCount(g [][]Cell) int { + n := 0 + for _, row := range g { + for _, c := range row { + if c.Rune >= 0x2800 && c.Rune <= 0x28FF { + for mask := int(c.Rune - 0x2800); mask != 0; mask &= mask - 1 { + n++ + } + } + } + } + return n +} + +func TestThicknessAddsPixels(t *testing.T) { + series := []float64{0, 30, 60, 100} + thin := Render(series, Options{Width: 30, Height: 6, Thickness: 1}) + thick := Render(series, Options{Width: 30, Height: 6, Thickness: 2}) + if brailleDotCount(thick) <= brailleDotCount(thin) { + t.Fatalf("thickness 2 (%d dots) should light more pixels than 1 (%d)", + brailleDotCount(thick), brailleDotCount(thin)) + } +} + +func TestThicknessClampsAtBottom(t *testing.T) { + // a series hugging the minimum: thick pixels must not run past the grid + g := Render([]float64{0, 0, 100}, Options{Width: 20, Height: 3, Thickness: 3}) + if g == nil { + t.Fatal("render returned nil") + } + // reaching here without an index-out-of-range panic is the real assertion +} + +func TestThicknessZeroMeansOne(t *testing.T) { + series := []float64{0, 50, 100} + zero := Render(series, Options{Width: 30, Height: 6, Thickness: 0}) + one := Render(series, Options{Width: 30, Height: 6, Thickness: 1}) + if brailleDotCount(zero) != brailleDotCount(one) { + t.Fatal("thickness 0 should behave as 1") + } +} From 0f695759aa25687a064162d7329cfdb65415163f Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 12:56:12 +0900 Subject: [PATCH 3/4] feat: Add a box-drawing solid line chart style --- internal/chart/chart.go | 34 ++++++++++++++++++++++++++++++++++ internal/chart/chart_test.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/internal/chart/chart.go b/internal/chart/chart.go index 5ae06b3..a502b70 100644 --- a/internal/chart/chart.go +++ b/internal/chart/chart.go @@ -140,6 +140,8 @@ func Render(series []float64, o Options) [][]Cell { switch o.Style { case StyleASCII: renderASCII(grid, series, cols, o.Height) + case StyleBox: + renderBox(grid, series, cols, o, lo, hi) default: renderBraille(grid, series, cols, o, lo, hi) } @@ -197,6 +199,38 @@ func renderBraille(grid [][]Cell, series []float64, cols int, o Options, lo, hi } } +// renderBox draws a cell-resolution solid line with rounded corners: +// ─ for level runs, ╮╰ going down, ╯╭ going up, │ filling steep drops. +// lo, hi are the TRUE series bounds (matching the axis labels), NOT the +// bounds of the downsampled array — see renderBraille for why. +func renderBox(grid [][]Cell, series []float64, cols int, o Options, lo, hi float64) { + vals := sample(series, cols, o.Interp) + rows := pixelRows(vals, o.Height, lo, hi) + put := func(y, c int, r rune) { + grid[y][labelW+1+c] = Cell{Rune: r, Kind: KindLine} + } + prev := rows[0] + for c, row := range rows { + switch { + case c == 0 || row == prev: + put(row, c, '─') + case row < prev: // going up + put(prev, c, '╯') + put(row, c, '╭') + for r := row + 1; r < prev; r++ { + put(r, c, '│') + } + default: // going down + put(prev, c, '╮') + put(row, c, '╰') + for r := prev + 1; r < row; r++ { + put(r, c, '│') + } + } + prev = row + } +} + // 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. diff --git a/internal/chart/chart_test.go b/internal/chart/chart_test.go index 9bcf599..4f33d34 100644 --- a/internal/chart/chart_test.go +++ b/internal/chart/chart_test.go @@ -220,3 +220,35 @@ func TestThicknessZeroMeansOne(t *testing.T) { t.Fatal("thickness 0 should behave as 1") } } + +func TestBoxStyleCorners(t *testing.T) { + // 3-sample V shape: down then up → expect ╮ ╰ on the way down, ╯ ╭ up + g := Render([]float64{100, 0, 100}, Options{Width: 20, Height: 5, Style: StyleBox}) + counts := map[rune]int{} + for _, row := range g { + for _, c := range row { + if c.Kind == KindLine { + counts[c.Rune]++ + } + } + } + for _, r := range []rune{'╮', '╰', '╯', '╭'} { + if counts[r] == 0 { + t.Fatalf("box line missing corner %q; got %v", r, counts) + } + } + if counts['─'] == 0 { + t.Fatalf("box line missing horizontal runs; got %v", counts) + } +} + +func TestBoxStyleFlat(t *testing.T) { + g := Render([]float64{50, 50, 50}, Options{Width: 20, Height: 5, Style: StyleBox}) + for y, row := range g { + for _, c := range row { + if c.Kind == KindLine && (c.Rune != '─' || y != 2) { + t.Fatalf("flat box line should be ─ on middle row, got %q on row %d", c.Rune, y) + } + } + } +} From cfd93d51093217587dcb3777d370a07e793b3ad4 Mon Sep 17 00:00:00 2001 From: stlim Date: Thu, 20 Aug 2026 12:59:30 +0900 Subject: [PATCH 4/4] feat: Make the smooth 2px braille curve the default graph --- cmd/termtype/main.go | 17 +++++++++++++++++ cmd/termtype/main_test.go | 27 +++++++++++++++++++++++++++ internal/store/store.go | 10 ++++++++++ internal/store/store_test.go | 9 +++++++++ 4 files changed, 63 insertions(+) create mode 100644 cmd/termtype/main_test.go diff --git a/cmd/termtype/main.go b/cmd/termtype/main.go index ef58a89..96b4b23 100644 --- a/cmd/termtype/main.go +++ b/cmd/termtype/main.go @@ -12,6 +12,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/mattn/go-runewidth" "github.com/namest504/termtype/internal/app" + "github.com/namest504/termtype/internal/chart" "github.com/namest504/termtype/internal/domain" "github.com/namest504/termtype/internal/store" "github.com/namest504/termtype/internal/themes" @@ -99,6 +100,21 @@ func drawText(s tcell.Screen, x, y int, style tcell.Style, text string) { } } +// chartOptionsFor maps a config style code onto chart options. Unknown +// codes fall back to the default so an edited config never breaks startup. +func chartOptionsFor(code string) chart.Options { + o := chart.Options{Style: chart.StyleBraille, Interp: chart.InterpSmooth, Thickness: 2} + switch code { + case "braille1": + o.Thickness = 1 + case "braille3": + o.Thickness = 3 + case "box": + o.Style, o.Thickness = chart.StyleBox, 1 + } + return o +} + // selection is everything the menu picks: the theme (and its registry name, // recorded in history), the mode limit, the text source, and the language. type selection struct { @@ -289,6 +305,7 @@ func main() { // Ctrl-C anywhere) leaves the program. st := store.Default() cfg := st.LoadConfig() + ui.SetChartOptions(chartOptionsFor(cfg.ChartStyle())) for { sel, err := selectTheme(s, events, cfg, st) if err != nil { diff --git a/cmd/termtype/main_test.go b/cmd/termtype/main_test.go new file mode 100644 index 0000000..441b43a --- /dev/null +++ b/cmd/termtype/main_test.go @@ -0,0 +1,27 @@ +package main + +import ( + "testing" + + "github.com/namest504/termtype/internal/chart" +) + +func TestChartOptionsFor(t *testing.T) { + cases := []struct { + code string + style chart.Style + thick int + }{ + {"braille1", chart.StyleBraille, 1}, + {"braille2", chart.StyleBraille, 2}, + {"braille3", chart.StyleBraille, 3}, + {"box", chart.StyleBox, 1}, + {"unknown", chart.StyleBraille, 2}, // 알 수 없는 값은 기본값 + } + for _, c := range cases { + o := chartOptionsFor(c.code) + if o.Style != c.style || o.Thickness != c.thick || o.Interp != chart.InterpSmooth { + t.Fatalf("%s → %+v", c.code, o) + } + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 855324d..0c3c6fc 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -19,6 +19,7 @@ type Config struct { Lang string `json:"lang"` Source string `json:"source"` // "builtin" | "words" Graph string `json:"graph,omitempty"` // "" or "on" = auto result graph; "off" = manual (g key) + Style string `json:"style,omitempty"` // result-graph style; see Config.ChartStyle Ghost bool `json:"ghost"` } @@ -26,6 +27,15 @@ type Config struct { // after a round. It defaults to on. func (c Config) GraphAuto() bool { return c.Graph != "off" } +// ChartStyle returns the configured result-graph style, defaulting to the +// 2px braille spline. Values: "braille1" | "braille2" | "braille3" | "box". +func (c Config) ChartStyle() string { + if c.Style == "" { + return "braille2" + } + return c.Style +} + // Round is one finished typing round — one line in history.jsonl. The // RawWPM/CPM/WPMSeries fields were added later and are absent from older // lines, which decode with zero values. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 1284c6f..cc830a5 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -69,3 +69,12 @@ func TestNoopStore(t *testing.T) { } } } + +func TestChartStyleDefault(t *testing.T) { + if got := (Config{}).ChartStyle(); got != "braille2" { + t.Fatalf("empty style should default to braille2, got %q", got) + } + if got := (Config{Style: "box"}).ChartStyle(); got != "box" { + t.Fatalf("explicit style should pass through, got %q", got) + } +}