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_style: ?vaxis.Style = null,
alt_start: usize = 0, alt_start: usize = 0,
alt_end: 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 }; const OptionsRowKind = enum { expiration, calls_header, puts_header, call, put };
@ -1168,13 +1173,22 @@ const App = struct {
for (0..width) |ci| { for (0..width) |ci| {
buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style }; buf[row * width + ci] = .{ .char = .{ .grapheme = " " }, .style = line.style };
} }
for (0..@min(line.text.len, width)) |ci| { // Grapheme-based rendering (for braille / multi-byte Unicode lines)
var s = line.style; if (line.graphemes) |graphemes| {
// Apply alt_style for the gain/loss column range const cell_styles = line.cell_styles;
if (line.alt_style) |alt| { for (0..@min(graphemes.len, width)) |ci| {
if (ci >= line.alt_start and ci < line.alt_end) s = alt; 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
if (line.alt_style) |alt| {
if (ci >= line.alt_start and ci < line.alt_end) s = alt;
}
buf[row * width + ci] = .{ .char = .{ .grapheme = glyph(line.text[ci]) }, .style = s };
} }
buf[row * width + ci] = .{ .char = .{ .grapheme = glyph(line.text[ci]) }, .style = s };
} }
} }
} }
@ -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() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() });
const chart_days: usize = @min(c.len, 60); const chart_days: usize = @min(c.len, 60);
const chart_data = c[c.len - chart_days ..]; 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. /// 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 { fn buildBrailleChart(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme_mod.Theme) !void {
if (data.len < 2) return; if (data.len < 2) return;
const chart_width: usize = 60; 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 // Find min/max close prices
var min_price: f64 = data[0].close; 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 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 ""; const min_label = std.fmt.bufPrint(&min_buf, "${d:.0}", .{min_price}) catch "";
// ASCII block chart (compatible with glyph() which is ASCII-only) // Map each data column to a dot-row position (0 = top, dot_rows-1 = bottom)
const chart_height: usize = 10; const n_cols = @min(data.len, chart_width);
for (0..chart_height) |row| { const dot_y = try arena.alloc(usize, n_cols);
var line_chars: [72]u8 = undefined; const col_color = try arena.alloc([3]u8, n_cols);
var lpos: usize = 0;
line_chars[0] = ' ';
line_chars[1] = ' ';
lpos = 2;
for (0..chart_width) |col| { for (0..n_cols) |col| {
// Map column to data index const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(n_cols - 1));
const data_idx_f: f64 = @as(f64, @floatFromInt(col)) * @as(f64, @floatFromInt(data.len - 1)) / @as(f64, @floatFromInt(chart_width - 1)); const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1);
const data_idx: usize = @min(@as(usize, @intFromFloat(data_idx_f)), data.len - 1); const close = data[data_idx].close;
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) // Color: gradient from negative (bottom) to positive (top)
const norm = (close - min_price) / price_range; col_color[col] = lerpColor(th.negative, th.positive, norm);
const fill_row: usize = @intFromFloat((1.0 - norm) * @as(f64, @floatFromInt(chart_height - 1))); }
if (row == fill_row) { // Build the braille grid: each cell is a pattern byte
if (lpos < line_chars.len) { // Grid is [chart_height][padded_width] where padded_width includes 2 leading spaces
line_chars[lpos] = '#'; const padded_width: usize = n_cols + 2; // 2 leading spaces
lpos += 1;
} // Allocate pattern grid
} else if (row > fill_row) { const patterns = try arena.alloc(u8, chart_height * padded_width);
if (lpos < line_chars.len) { @memset(patterns, 0);
line_chars[lpos] = ':';
lpos += 1; // 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.
} else { for (0..n_cols) |col| {
if (lpos < line_chars.len) { const target_y = dot_y[col];
line_chars[lpos] = ' '; // Fill from target_y to bottom
lpos += 1; 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];
} }
// Right label // 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 {
styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(bg), .bg = theme_mod.Theme.vcolor(bg) };
}
gpos += 1;
}
// Right-side price labels
if (row == 0) { if (row == 0) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{max_label}); const lbl = try std.fmt.allocPrint(arena, " {s}", .{max_label});
for (lbl) |ch| { for (lbl) |ch| {
if (lpos < line_chars.len) { if (gpos < graphemes.len) {
line_chars[lpos] = ch; graphemes[gpos] = glyph(ch);
lpos += 1; styles[gpos] = .{ .fg = theme_mod.Theme.vcolor(th.text_muted), .bg = theme_mod.Theme.vcolor(bg) };
gpos += 1;
} }
} }
} else if (row == chart_height - 1) { } else if (row == chart_height - 1) {
const lbl = try std.fmt.allocPrint(arena, " {s}", .{min_label}); const lbl = try std.fmt.allocPrint(arena, " {s}", .{min_label});
for (lbl) |ch| { for (lbl) |ch| {
if (lpos < line_chars.len) { if (gpos < graphemes.len) {
line_chars[lpos] = ch; graphemes[gpos] = glyph(ch);
lpos += 1; 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, .{
try lines.append(arena, .{ .text = try arena.dupe(u8, line_chars[0..lpos]), .style = chart_style }); .text = "",
.style = .{ .fg = theme_mod.Theme.vcolor(th.text), .bg = theme_mod.Theme.vcolor(bg) },
.graphemes = graphemes[0..gpos],
.cell_styles = styles[0..gpos],
});
} }
} }