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 +7 -0
- data/lib/termchart/bar.rb +110 -0
- data/lib/termchart/candle.rb +125 -0
- data/lib/termchart/canvas.rb +120 -0
- data/lib/termchart/line.rb +136 -0
- data/lib/termchart/spark.rb +40 -0
- data/lib/termchart/version.rb +3 -0
- data/lib/termchart.rb +13 -0
- metadata +51 -0
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
|
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: []
|