termchart 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8e5399b740269dde9e659e037b6b54d158e71c4828778f6077686e59d0409a7e
4
+ data.tar.gz: a4bb881eac513473fd08d2a9314dd9bfd18954b5931c546ee35955473614d490
5
+ SHA512:
6
+ metadata.gz: '08a3561dca9546457c27d6515ade0a83c59587dbc5dd2deb39617980e4b5e9c201e4ea5700d97267ed0e58f0f7e8c44829e3e1c95b8de504b8cd2aa5c3f0a155'
7
+ data.tar.gz: 39bb6081e3eee076630fe3806e670fac5bb15440e542f29ca3be06632e043e06d7a806925318a76ee2cc05283b122866c7d0afb003c9e213785b33427cc1acd2
@@ -0,0 +1,110 @@
1
+ module Termchart
2
+ # Horizontal and vertical bar charts.
3
+ # Horizontal uses ▏▎▍▌▋▊▉█ eighths for sub-cell width.
4
+ # Vertical uses ▁▂▃▄▅▆▇█ eighths for sub-cell height.
5
+ class Bar
6
+ H_BLOCKS = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"].freeze
7
+ V_BLOCKS = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"].freeze
8
+
9
+ attr_accessor :width, :orientation
10
+
11
+ def initialize(width: 40, height: nil, orientation: :horizontal)
12
+ @width = width
13
+ @height = height # only used for vertical
14
+ @orientation = orientation
15
+ @items = []
16
+ end
17
+
18
+ def add(label, value, color: nil)
19
+ @items << { label: label.to_s, value: value.to_f, color: color }
20
+ end
21
+
22
+ def render
23
+ return "" if @items.empty?
24
+ @orientation == :horizontal ? render_horizontal : render_vertical
25
+ end
26
+
27
+ private
28
+
29
+ def render_horizontal
30
+ max_val = @items.map { |i| i[:value] }.max
31
+ max_val = 1.0 if max_val.zero?
32
+ label_w = @items.map { |i| i[:label].length }.max
33
+ val_w = @items.map { |i| format_val(i[:value]).length }.max
34
+ bar_w = @width - label_w - val_w - 4 # " │" + " " + value
35
+ bar_w = 10 if bar_w < 10
36
+
37
+ lines = @items.map do |item|
38
+ frac = item[:value] / max_val * bar_w
39
+ full = frac.floor
40
+ eighth = ((frac - full) * 8).round.clamp(0, 8)
41
+ bar = "█" * full
42
+ bar += H_BLOCKS[eighth] if eighth > 0 && full < bar_w
43
+ pad = bar_w - visible_len(bar)
44
+ pad = 0 if pad < 0
45
+ bar_str = bar + " " * pad
46
+ bar_str = colorize(bar_str, item[:color]) if item[:color]
47
+ lbl = item[:label].ljust(label_w)
48
+ val = format_val(item[:value]).rjust(val_w)
49
+ "#{lbl} │#{bar_str} #{val}"
50
+ end
51
+ lines.join("\n")
52
+ end
53
+
54
+ def render_vertical
55
+ max_val = @items.map { |i| i[:value] }.max
56
+ max_val = 1.0 if max_val.zero?
57
+ h = @height || 15
58
+ col_w = [@items.map { |i| i[:label].length }.max, 3].max
59
+ cols = @items.map do |item|
60
+ frac = item[:value] / max_val * h
61
+ full = frac.floor
62
+ eighth = ((frac - full) * 8).round.clamp(0, 8)
63
+ column = []
64
+ h.times do |row|
65
+ row_from_bottom = h - 1 - row
66
+ if row_from_bottom < full
67
+ column << "█" * col_w
68
+ elsif row_from_bottom == full && eighth > 0
69
+ column << (V_BLOCKS[eighth] * col_w)
70
+ else
71
+ column << " " * col_w
72
+ end
73
+ end
74
+ column
75
+ end
76
+
77
+ lines = []
78
+ h.times do |row|
79
+ parts = cols.each_with_index.map do |col, i|
80
+ cell = col[row]
81
+ @items[i][:color] ? colorize(cell, @items[i][:color]) : cell
82
+ end
83
+ lines << parts.join(" ")
84
+ end
85
+ # Labels below
86
+ labels = @items.map { |i| i[:label].center(col_w) }.join(" ")
87
+ lines << labels
88
+ lines.join("\n")
89
+ end
90
+
91
+ def format_val(v)
92
+ v == v.to_i.to_f ? v.to_i.to_s : ("%.2f" % v)
93
+ end
94
+
95
+ def visible_len(str)
96
+ str.gsub(/\e\[[0-9;]*m/, "").length
97
+ end
98
+
99
+ def colorize(str, color)
100
+ code = case color
101
+ when Integer then "38;5;#{color}"
102
+ when /\A#?([0-9a-fA-F]{6})\z/
103
+ r, g, b = [$1].pack("H*").unpack("CCC")
104
+ "38;2;#{r};#{g};#{b}"
105
+ else "38;5;#{color}"
106
+ end
107
+ "\e[#{code}m#{str}\e[0m"
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,125 @@
1
+ module Termchart
2
+ # OHLC candlestick chart.
3
+ # Uses │ for wicks, █/▌ for bodies. Green (82) up, red (196) down.
4
+ # Y-axis labels on left.
5
+ class Candle
6
+ UP_COLOR = 82 # green
7
+ DOWN_COLOR = 196 # red
8
+
9
+ attr_accessor :width, :height
10
+
11
+ def initialize(width: 60, height: 20)
12
+ @width = width
13
+ @height = height
14
+ @data = []
15
+ end
16
+
17
+ # Add OHLC data: array of hashes with keys :o, :h, :l, :c
18
+ def add(ohlc_data)
19
+ ohlc_data.each do |d|
20
+ @data << {
21
+ o: d[:o].to_f, h: d[:h].to_f,
22
+ l: d[:l].to_f, c: d[:c].to_f
23
+ }
24
+ end
25
+ end
26
+
27
+ def render
28
+ return "" if @data.empty?
29
+
30
+ data_min = @data.map { |d| d[:l] }.min
31
+ data_max = @data.map { |d| d[:h] }.max
32
+ data_range = data_max - data_min
33
+ data_range = 1.0 if data_range.zero?
34
+
35
+ # Y-axis label width
36
+ y_label_w = [format_num(data_max).length, format_num(data_min).length].max + 1
37
+ chart_w = @width - y_label_w
38
+ chart_w = 10 if chart_w < 10
39
+ chart_h = @height
40
+
41
+ # Decide how many candles to show (1 char width each, with gaps)
42
+ max_candles = chart_w / 2 # each candle takes 1 col + 1 gap
43
+ visible = @data.last(max_candles)
44
+
45
+ # Map price to row (0 = top = data_max, chart_h-1 = bottom = data_min)
46
+ price_to_row = ->(price) {
47
+ ((data_max - price) / data_range * (chart_h - 1)).round.clamp(0, chart_h - 1)
48
+ }
49
+
50
+ # Build grid
51
+ canvas = Canvas.new(chart_w, chart_h)
52
+
53
+ visible.each_with_index do |d, i|
54
+ col = i * 2 # 1 col candle, 1 col gap
55
+ next if col >= chart_w
56
+
57
+ up = d[:c] >= d[:o]
58
+ color = up ? UP_COLOR : DOWN_COLOR
59
+
60
+ high_row = price_to_row.call(d[:h])
61
+ low_row = price_to_row.call(d[:l])
62
+ body_top = price_to_row.call([d[:o], d[:c]].max)
63
+ body_bot = price_to_row.call([d[:o], d[:c]].min)
64
+
65
+ # Draw wick above body
66
+ (high_row...body_top).each do |row|
67
+ canvas.set(col, row, "│", fg: color)
68
+ end
69
+
70
+ # Draw body
71
+ if body_top == body_bot
72
+ # Doji: single line
73
+ canvas.set(col, body_top, "─", fg: color)
74
+ else
75
+ (body_top..body_bot).each do |row|
76
+ canvas.set(col, row, "█", fg: color)
77
+ end
78
+ end
79
+
80
+ # Draw wick below body
81
+ ((body_bot + 1)..low_row).each do |row|
82
+ canvas.set(col, row, "│", fg: color)
83
+ end
84
+ end
85
+
86
+ chart_str = canvas.render
87
+ lines = chart_str.split("\n")
88
+
89
+ # Add Y-axis labels
90
+ result = lines.each_with_index.map do |line, row|
91
+ if row == 0
92
+ label = format_num(data_max).rjust(y_label_w - 1) + "┤"
93
+ elsif row == chart_h - 1
94
+ label = format_num(data_min).rjust(y_label_w - 1) + "┤"
95
+ elsif row == chart_h / 2
96
+ mid = data_min + data_range / 2.0
97
+ label = format_num(mid).rjust(y_label_w - 1) + "┤"
98
+ elsif row == chart_h / 4
99
+ q1 = data_max - data_range / 4.0
100
+ label = format_num(q1).rjust(y_label_w - 1) + "┤"
101
+ elsif row == chart_h * 3 / 4
102
+ q3 = data_min + data_range / 4.0
103
+ label = format_num(q3).rjust(y_label_w - 1) + "┤"
104
+ else
105
+ label = " " * (y_label_w - 1) + "│"
106
+ end
107
+ label + line
108
+ end
109
+
110
+ result.join("\n")
111
+ end
112
+
113
+ private
114
+
115
+ def format_num(v)
116
+ if v.abs >= 1000
117
+ "%.0f" % v
118
+ elsif v.abs >= 100
119
+ "%.1f" % v
120
+ else
121
+ "%.2f" % v
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,120 @@
1
+ module Termchart
2
+ # Character grid where each cell has a character, fg color, and bg color.
3
+ # render() converts to an ANSI-colored string (rows joined by \n).
4
+ class Canvas
5
+ attr_reader :width, :height
6
+
7
+ def initialize(width, height)
8
+ @width = width
9
+ @height = height
10
+ @cells = Array.new(height) { Array.new(width) { [" ", nil, nil] } }
11
+ end
12
+
13
+ # Set a cell: char, optional fg/bg (256-color int or hex string)
14
+ def set(x, y, char, fg: nil, bg: nil)
15
+ return unless x >= 0 && x < @width && y >= 0 && y < @height
16
+ @cells[y][x] = [char, fg, bg]
17
+ end
18
+
19
+ def get(x, y)
20
+ return nil unless x >= 0 && x < @width && y >= 0 && y < @height
21
+ @cells[y][x]
22
+ end
23
+
24
+ def render
25
+ @cells.map { |row| render_row(row) }.join("\n")
26
+ end
27
+
28
+ private
29
+
30
+ def render_row(row)
31
+ out = +""
32
+ prev_fg = prev_bg = nil
33
+ row.each do |char, fg, bg|
34
+ if fg != prev_fg || bg != prev_bg
35
+ out << "\e[0m" if prev_fg || prev_bg
36
+ codes = []
37
+ codes << fg_code(fg) if fg
38
+ codes << bg_code(bg) if bg
39
+ out << "\e[#{codes.join(';')}m" unless codes.empty?
40
+ prev_fg = fg
41
+ prev_bg = bg
42
+ end
43
+ out << char
44
+ end
45
+ out << "\e[0m" if prev_fg || prev_bg
46
+ out
47
+ end
48
+
49
+ def fg_code(color)
50
+ case color
51
+ when Integer then "38;5;#{color}"
52
+ when /\A#?([0-9a-fA-F]{6})\z/
53
+ r, g, b = [$1].pack("H*").unpack("CCC")
54
+ "38;2;#{r};#{g};#{b}"
55
+ else "38;5;#{color}"
56
+ end
57
+ end
58
+
59
+ def bg_code(color)
60
+ case color
61
+ when Integer then "48;5;#{color}"
62
+ when /\A#?([0-9a-fA-F]{6})\z/
63
+ r, g, b = [$1].pack("H*").unpack("CCC")
64
+ "48;2;#{r};#{g};#{b}"
65
+ else "48;5;#{color}"
66
+ end
67
+ end
68
+ end
69
+
70
+ # Braille canvas: each terminal cell maps to a 2x4 dot grid.
71
+ # Pixel resolution = width*2 x height*4.
72
+ # Uses Unicode braille patterns U+2800..U+28FF.
73
+ class BrailleCanvas < Canvas
74
+ # Braille dot offsets: dot(px % 2, py % 4) maps to a bit
75
+ # col0 col1
76
+ # 0x01 0x08 row0
77
+ # 0x02 0x10 row1
78
+ # 0x04 0x20 row2
79
+ # 0x40 0x80 row3
80
+ DOT_MAP = [
81
+ [0x01, 0x08],
82
+ [0x02, 0x10],
83
+ [0x04, 0x20],
84
+ [0x40, 0x80],
85
+ ].freeze
86
+
87
+ def initialize(width, height)
88
+ super
89
+ @dots = Array.new(height) { Array.new(width, 0) }
90
+ end
91
+
92
+ # Set a sub-cell dot. px range: 0..width*2-1, py range: 0..height*4-1
93
+ def set_dot(px, py, fg: nil)
94
+ cx = px / 2
95
+ cy = py / 4
96
+ return unless cx >= 0 && cx < @width && cy >= 0 && cy < @height
97
+ dx = px % 2
98
+ dy = py % 4
99
+ @dots[cy][cx] |= DOT_MAP[dy][dx]
100
+ # Store fg color on the cell
101
+ cell = get(cx, cy)
102
+ if cell
103
+ set(cx, cy, cell[0], fg: fg || cell[1], bg: cell[2])
104
+ end
105
+ end
106
+
107
+ def render
108
+ # Convert dot patterns to braille characters, then render as Canvas
109
+ @height.times do |cy|
110
+ @width.times do |cx|
111
+ pattern = @dots[cy][cx]
112
+ char = (0x2800 + pattern).chr(Encoding::UTF_8)
113
+ cell = get(cx, cy)
114
+ set(cx, cy, char, fg: cell ? cell[1] : nil, bg: cell ? cell[2] : nil)
115
+ end
116
+ end
117
+ super
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,136 @@
1
+ module Termchart
2
+ # Braille line chart with Y-axis labels.
3
+ # Uses BrailleCanvas for 2x4 sub-cell dot resolution.
4
+ class Line
5
+ attr_accessor :width, :height
6
+
7
+ def initialize(width: 60, height: 20)
8
+ @width = width
9
+ @height = height
10
+ @series = []
11
+ end
12
+
13
+ # Add a data series (array of numeric values)
14
+ def add(values, color: 82, label: nil)
15
+ @series << { values: values.map(&:to_f), color: color, label: label }
16
+ end
17
+
18
+ def render
19
+ return "" if @series.empty?
20
+
21
+ # Compute global min/max across all series
22
+ all_vals = @series.flat_map { |s| s[:values] }
23
+ data_min = all_vals.min
24
+ data_max = all_vals.max
25
+ data_range = data_max - data_min
26
+ data_range = 1.0 if data_range.zero?
27
+
28
+ # Reserve space for Y-axis labels
29
+ y_label_w = format_num(data_max).length + 1
30
+ chart_w = @width - y_label_w
31
+ chart_w = 10 if chart_w < 10
32
+ chart_h = @height
33
+
34
+ # Pixel dimensions (braille: 2 dots wide, 4 dots tall per cell)
35
+ px_w = chart_w * 2
36
+ px_h = chart_h * 4
37
+
38
+ canvas = BrailleCanvas.new(chart_w, chart_h)
39
+
40
+ @series.each do |series|
41
+ vals = series[:values]
42
+ n = vals.length
43
+ next if n < 2
44
+
45
+ # Map each value to pixel coordinates
46
+ points = vals.each_with_index.map do |v, i|
47
+ px = n == 1 ? 0 : (i.to_f / (n - 1) * (px_w - 1)).round
48
+ py = ((data_max - v) / data_range * (px_h - 1)).round.clamp(0, px_h - 1)
49
+ [px, py]
50
+ end
51
+
52
+ # Draw lines between consecutive points using Bresenham
53
+ (0...points.length - 1).each do |i|
54
+ x0, y0 = points[i]
55
+ x1, y1 = points[i + 1]
56
+ bresenham(x0, y0, x1, y1) do |px, py|
57
+ canvas.set_dot(px, py, fg: series[:color])
58
+ end
59
+ end
60
+ end
61
+
62
+ chart_str = canvas.render
63
+
64
+ # Add Y-axis labels
65
+ lines = chart_str.split("\n")
66
+ result = lines.each_with_index.map do |line, row|
67
+ if row == 0
68
+ label = format_num(data_max).rjust(y_label_w - 1) + "┤"
69
+ elsif row == chart_h - 1
70
+ label = format_num(data_min).rjust(y_label_w - 1) + "┤"
71
+ elsif row == chart_h / 2
72
+ mid = data_min + data_range / 2.0
73
+ label = format_num(mid).rjust(y_label_w - 1) + "┤"
74
+ else
75
+ label = " " * (y_label_w - 1) + "│"
76
+ end
77
+ label + line
78
+ end
79
+
80
+ # Legend line
81
+ if @series.any? { |s| s[:label] }
82
+ legend = @series.map do |s|
83
+ name = s[:label] || "data"
84
+ colorize("━━", s[:color]) + " " + name
85
+ end.join(" ")
86
+ result << " " * y_label_w + legend
87
+ end
88
+
89
+ result.join("\n")
90
+ end
91
+
92
+ private
93
+
94
+ def bresenham(x0, y0, x1, y1)
95
+ dx = (x1 - x0).abs
96
+ dy = -(y1 - y0).abs
97
+ sx = x0 < x1 ? 1 : -1
98
+ sy = y0 < y1 ? 1 : -1
99
+ err = dx + dy
100
+ loop do
101
+ yield x0, y0
102
+ break if x0 == x1 && y0 == y1
103
+ e2 = 2 * err
104
+ if e2 >= dy
105
+ err += dy
106
+ x0 += sx
107
+ end
108
+ if e2 <= dx
109
+ err += dx
110
+ y0 += sy
111
+ end
112
+ end
113
+ end
114
+
115
+ def format_num(v)
116
+ if v.abs >= 1000
117
+ "%.0f" % v
118
+ elsif v.abs >= 1
119
+ "%.1f" % v
120
+ else
121
+ "%.2f" % v
122
+ end
123
+ end
124
+
125
+ def colorize(str, color)
126
+ code = case color
127
+ when Integer then "38;5;#{color}"
128
+ when /\A#?([0-9a-fA-F]{6})\z/
129
+ r, g, b = [$1].pack("H*").unpack("CCC")
130
+ "38;2;#{r};#{g};#{b}"
131
+ else "38;5;#{color}"
132
+ end
133
+ "\e[#{code}m#{str}\e[0m"
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,40 @@
1
+ module Termchart
2
+ # Sparkline: maps values to 8 eighth-block characters ▁▂▃▄▅▆▇█
3
+ # Returns a single-line string (with optional ANSI color).
4
+ module Spark
5
+ TICKS = %w[▁ ▂ ▃ ▄ ▅ ▆ ▇ █].freeze
6
+
7
+ def self.render(values, color: nil)
8
+ return "" if values.nil? || values.empty?
9
+ values = values.map(&:to_f)
10
+ min = values.min
11
+ max = values.max
12
+ range = max - min
13
+ chars = values.map do |v|
14
+ idx = range.zero? ? 3 : ((v - min) / range * 7).round.clamp(0, 7)
15
+ TICKS[idx]
16
+ end
17
+ line = chars.join
18
+ color ? colorize(line, color) : line
19
+ end
20
+
21
+ private
22
+
23
+ def self.colorize(str, color)
24
+ code = case color
25
+ when Integer then "38;5;#{color}"
26
+ when Symbol then "38;5;#{NAMED_COLORS[color] || 7}"
27
+ when /\A#?([0-9a-fA-F]{6})\z/
28
+ r, g, b = [$1].pack("H*").unpack("CCC")
29
+ "38;2;#{r};#{g};#{b}"
30
+ else "38;5;#{color}"
31
+ end
32
+ "\e[#{code}m#{str}\e[0m"
33
+ end
34
+
35
+ NAMED_COLORS = {
36
+ red: 196, green: 82, blue: 33, yellow: 226, cyan: 51,
37
+ magenta: 201, white: 255, gray: 245, orange: 208
38
+ }.freeze
39
+ end
40
+ end
@@ -0,0 +1,3 @@
1
+ module Termchart
2
+ VERSION = "0.1.0"
3
+ end
data/lib/termchart.rb ADDED
@@ -0,0 +1,13 @@
1
+ require_relative "termchart/version"
2
+ require_relative "termchart/canvas"
3
+ require_relative "termchart/spark"
4
+ require_relative "termchart/line"
5
+ require_relative "termchart/candle"
6
+ require_relative "termchart/bar"
7
+
8
+ module Termchart
9
+ # Convenience: Termchart.spark([1,3,5,2,8], color: :green)
10
+ def self.spark(values, color: nil)
11
+ Spark.render(values, color: color)
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: termchart
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Geir Isene
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-20 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Render sparklines, line charts (braille), candlestick charts, and bar
14
+ charts as plain strings with ANSI color codes. Zero dependencies, pure Ruby.
15
+ email: g@isene.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/termchart.rb
21
+ - lib/termchart/bar.rb
22
+ - lib/termchart/candle.rb
23
+ - lib/termchart/canvas.rb
24
+ - lib/termchart/line.rb
25
+ - lib/termchart/spark.rb
26
+ - lib/termchart/version.rb
27
+ homepage: https://github.com/isene/termchart
28
+ licenses:
29
+ - Unlicense
30
+ metadata:
31
+ source_code_uri: https://github.com/isene/termchart
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 2.7.0
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.4.20
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: Terminal charts with Unicode and ANSI colors
51
+ test_files: []