ai generated braille-based chart

This commit is contained in:
Emil Lerch 2026-02-25 14:23:03 -08:00
parent 18827be200
commit 3b66e38152
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

@ -68,6 +68,11 @@ const StyledLine = struct {
alt_style: ?vaxis.Style = null,
alt_start: usize = 0,
alt_end: usize = 0,
// Optional pre-encoded grapheme array for multi-byte Unicode (e.g. braille charts).
// When set, each element is a grapheme string for one column position.
graphemes: ?[]const []const u8 = null,
// Optional per-cell style array (same length as graphemes). Enables color gradients.
cell_styles: ?[]const vaxis.Style = null,
};
const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put };
@ -1168,6 +1173,14 @@ const App = struct {
for (0..width) |ci| {
buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style };
}
// Grapheme-based rendering (for braille / multi-byte Unicode lines)
if (line.graphemes) |graphemes| {
const cell_styles = line.cell_styles;
for (0..@min(graphemes.len, width)) |ci| {
const s = if (cell_styles) |cs| cs[ci] else line.style;
buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
}
} else {
for (0..@min(line.text.len, width)) |ci| {
var s = line.style;
// Apply alt_style for the gain/loss column range
@ -1178,6 +1191,7 @@ const App = struct {
}
}
}
}
fn drawStatusBar(self: *App, ctx: vaxis.vxfw.DrawContext, width: u16) !vaxis.vxfw.Surface {
const t = self.theme;
@ -1512,7 +1526,7 @@ const App = struct {
}
}
// Braille chart of recent 60 trading days
// Braille sparkline chart of recent 60 trading days
try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const chart_days: usize = @min(c.len, 60);
const chart_data = c[c.len - chart_days ..];
@ -2088,12 +2102,56 @@ fn fmtContractLine(arena: std.mem.Allocator, prefix: []const u8, c: zfin.OptionC
});
}
/// Braille dot patterns for the 2x4 matrix within each character cell.
/// Layout: [0][3] Bit mapping: dot0=0x01, dot3=0x08
/// [1][4] dot1=0x02, dot4=0x10
/// [2][5] dot2=0x04, dot5=0x20
/// [6][7] dot6=0x40, dot7=0x80
const braille_dots = [4][2]u8{
.{ 0x01, 0x08 }, // row 0 (top)
.{ 0x02, 0x10 }, // row 1
.{ 0x04, 0x20 }, // row 2
.{ 0x40, 0x80 }, // row 3 (bottom)
};
/// Comptime table of braille character UTF-8 encodings (U+2800..U+28FF).
/// Each braille codepoint is 3 bytes in UTF-8: 0xE2 0xA0+hi 0x80+lo.
const braille_utf8 = blk: {
var table: [256][3]u8 = undefined;
for (0..256) |i| {
const cp: u21 = 0x2800 + @as(u21, @intCast(i));
table[i] = .{
@as(u8, 0xE0 | @as(u8, @truncate(cp >> 12))),
@as(u8, 0x80 | @as(u8, @truncate((cp >> 6) & 0x3F))),
@as(u8, 0x80 | @as(u8, @truncate(cp & 0x3F))),
};
}
break :blk table;
};
/// Return a static-lifetime grapheme slice for a braille pattern byte.
fn brailleGlyph(pattern: u8) []const u8 {
return &braille_utf8[pattern];
}
/// Interpolate color between two RGB values. t in [0.0, 1.0].
fn lerpColor(a: [3]u8, b: [3]u8, t: f64) [3]u8 {
return .{
@intFromFloat(@as(f64, @floatFromInt(a[0])) * (1.0 - t) + @as(f64, @floatFromInt(b[0])) * t),
@intFromFloat(@as(f64, @floatFromInt(a[1])) * (1.0 - t) + @as(f64, @floatFromInt(b[1])) * t),
@intFromFloat(@as(f64, @floatFromInt(a[2])) * (1.0 - t) + @as(f64, @floatFromInt(b[2])) * t),
};
}
/// Build a braille sparkline chart from candle close prices.
/// Uses Unicode braille characters (U+2800..U+28FF) for 2x4 dot matrix per character.
/// Uses Unicode braille characters (U+2800..U+28FF) for 2-wide x 4-tall dot matrix per cell.
/// Each terminal row provides 4 sub-rows of resolution; each column maps to one data point.
fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void {
if (data.len < 2) return;
const chart_width: usize = 60;
const chart_height: usize = 10; // terminal rows
const dot_rows: usize = chart_height * 4; // vertical dot resolution
// Find min/max close prices
var min_price: f64 = data[0].close;
@ -2111,64 +2169,116 @@ fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine)
const max_label = std.fmt.bufPrint(&max_buf, "${d:.0}", .{max_price}) catch "";
const min_label = std.fmt.bufPrint(&min_buf, "${d:.0}", .{min_price}) catch "";
// ASCII block chart (compatible with glyph() which is ASCII-only)
const chart_height: usize = 10;
for (0..chart_height) |row| {
var line_chars: [72]u8 = undefined;
var lpos: usize = 0;
line_chars[0] = ' ';
line_chars[1] = ' ';
lpos = 2;
// Map each data column to a dot-row position (0 = top, dot_rows-1 = bottom)
const n_cols = @min(data.len, chart_width);
const dot_y = try arena.alloc(usize, n_cols);
const col_color = try arena.alloc([3]u8, n_cols);
for (0..chart_width) |col| {
// Map column to data index
const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(chart_width - 1));
for (0..n_cols) |col| {
const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1));
const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1);
const close = data[data_idx].close;
const norm = (close - min_price) / price_range; // 0 = min, 1 = max
// Inverted: 0 = top dot row, dot_rows-1 = bottom
const y_f = (1.0 - norm) * @as(f64, @floatFromInt(dot_rows - 1));
dot_y[col] = @min(@as(usize, @intFromFloat(y_f)), dot_rows - 1);
// Map price to row (inverted)
const norm = (close - min_price) / price_range;
const fill_row: usize = @intFromFloat((1.0 - norm) * @as(f64, @floatFromInt(chart_height - 1)));
// Color: gradient from negative (bottom) to positive (top)
col_color[col] = lerpColor(th.negative, th.positive, norm);
}
if (row == fill_row) {
if (lpos < line_chars.len) {
line_chars[lpos] = '#';
lpos += 1;
// Build the braille grid: each cell is a pattern byte
// Grid is [chart_height][padded_width] where padded_width includes 2 leading spaces
const padded_width: usize = n_cols + 2; // 2 leading spaces
// Allocate pattern grid
const patterns = try arena.alloc(u8, chart_height * padded_width);
@memset(patterns, 0);
// For each column, draw a filled area from dot_y[col] down to the bottom,
// plus interpolate a line between adjacent points for smooth contour.
for (0..n_cols) |col| {
const target_y = dot_y[col];
// Fill from target_y to bottom
for (target_y..dot_rows) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
// Left column of the braille cell (we use col 0 of each 2-wide cell)
const grid_col = col + 2; // offset for 2 leading spaces
patterns[term_row * padded_width + grid_col] |= braille_dots[sub_row][0];
}
} else if (row > fill_row) {
if (lpos < line_chars.len) {
line_chars[lpos] = ':';
lpos += 1;
// Interpolate between this point and the next for the line contour
if (col + 1 < n_cols) {
const y0 = dot_y[col];
const y1 = dot_y[col + 1];
const min_y = @min(y0, y1);
const max_y = @max(y0, y1);
// Fill intermediate dots on this column for vertical segments
for (min_y..max_y + 1) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
const grid_col = col + 2;
patterns[term_row * padded_width + grid_col] |= braille_dots[sub_row][0];
}
}
}
// Render each terminal row as a StyledLine with graphemes
const bg = th.bg;
for (0..chart_height) |row| {
const graphemes = try arena.alloc([]const u8, padded_width + 10); // extra for label
const styles = try arena.alloc(vaxis.Style, padded_width + 10);
var gpos: usize = 0;
// 2 leading spaces
graphemes[gpos] = " ";
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
gpos += 1;
graphemes[gpos] = " ";
styles[gpos] = styles[0];
gpos += 1;
// Chart columns
for (0..n_cols) |col| {
const pattern = patterns[row * padded_width + col + 2];
graphemes[gpos] = brailleGlyph(pattern);
// Use the column's color for non-empty cells, dim for empty
if (pattern != 0) {
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(col_color[col]), .bg = theme_mod.Theme.vcolor(bg) };
} else {
if (lpos < line_chars.len) {
line_chars[lpos] = ' ';
lpos += 1;
}
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(bg), .bg = theme_mod.Theme.vcolor(bg) };
}
gpos += 1;
}
// Right label
// Right-side price labels
if (row == 0) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{max_label});
for (lbl) |ch| {
if (lpos < line_chars.len) {
line_chars[lpos] = ch;
lpos += 1;
if (gpos < graphemes.len) {
graphemes[gpos] = glyph(ch);
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
gpos += 1;
}
}
} else if (row == chart_height - 1) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{min_label});
for (lbl) |ch| {
if (lpos < line_chars.len) {
line_chars[lpos] = ch;
lpos += 1;
if (gpos < graphemes.len) {
graphemes[gpos] = glyph(ch);
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
gpos += 1;
}
}
}
const chart_style: vaxis.Style = .{ .fg = theme_mod.Theme.vcolor(th.accent), .bg = theme_mod.Theme.vcolor(th.bg) };
try lines.append(arena, .{ .text = try arena.dupe(u8, line_chars[0..lpos]), .style = chart_style });
try lines.append(arena, .{
.text = "",
.style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) },
.graphemes = graphemes[0..gpos],
.cell_styles = styles[0..gpos],
});
}
}