move braille charts to its own space

This commit is contained in:
Emil Lerch 2026-06-26 11:53:29 -07:00
parent 8ca673c8e3
commit acf5f723f8
Signed by: lobo
GPG key ID: A7B62D657EF764F8
8 changed files with 543 additions and 549 deletions

14
TODO.md
View file

@ -103,18 +103,18 @@ still don't have PNG export and were deferred:
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
`src/format.zig` is still a ~1700-line grab-bag, but the money- and
`src/format.zig` is still a ~1600-line grab-bag, but the money- and
date-shaped helpers that used to live there have been moved out:
money formatting now lives in `src/Money.zig` (with `{f}` /
`whole()` / `trim()` / `signed()` / `padRight(N)` / `padLeft(N)`),
and date formatting lives in `src/Date.zig` (with `{f}` /
`padRight(N)` / `padLeft(N)`). What's left in `format.zig` is the
genuinely-format-domain stuff: braille charts, return formatters,
allocation notes, signed-percent rendering.
date formatting lives in `src/Date.zig` (with `{f}` /
`padRight(N)` / `padLeft(N)`), and the braille sparkline chart now
lives in `src/charts/braille.zig`. What's left in `format.zig` is
the genuinely-format-domain stuff: return formatters, allocation
notes, signed-percent rendering.
If the file ever grows enough to be annoying again, consider
renaming to `src/render.zig` to better describe what's left, or
splitting the braille chart out (it's ~600 lines on its own).
renaming to `src/render.zig` to better describe what's left.
Not blocking - file it as cleanup if and when it bites.
## Investigate: detailed 401(k) contributions data source

513
src/charts/braille.zig Normal file
View file

@ -0,0 +1,513 @@
//! Braille sparkline chart engine.
//!
//! Renders compact price/value sparklines using Unicode braille
//! characters (U+2800..U+28FF) for a 2-wide x 4-tall dot matrix per
//! terminal cell. `computeBrailleChart` produces a renderer-agnostic
//! `BrailleChart` (pattern grid + per-column colors); `writeBrailleAnsi`
//! renders it to a writer with ANSI color for the CLI, while the TUI
//! consumes the `BrailleChart` directly to emit vaxis cells.
//!
//! Extracted from `format.zig`; CLI and TUI both reach for it via
//! `@import("charts/braille.zig")`.
const std = @import("std");
const Date = @import("../Date.zig");
const Money = @import("../Money.zig");
const Candle = @import("../models/candle.zig").Candle;
/// Interpolate color between two RGB values. t in [0.0, 1.0].
pub 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),
};
}
// Braille chart
/// 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
pub 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.
pub 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.
pub fn brailleGlyph(pattern: u8) []const u8 {
return &braille_utf8[pattern];
}
/// Maximum byte length for a `Money.from(v).{f}` rendering used as a
/// chart axis label. Sized to fit `$999,999,999,999.99` (19 chars,
/// up to a trillion-plus) with slack. Renderers that pre-allocate
/// buffer cells for these labels should use this constant rather
/// than hard-coding a smaller width and silently truncating
/// portfolios over $1M.
pub const money_label_max_bytes: usize = 24;
/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis).
pub const BrailleChart = struct {
/// Braille pattern bytes: patterns[row * n_cols + col]
patterns: []u8,
/// RGB color per data column
col_colors: [][3]u8,
n_cols: usize,
chart_height: usize,
/// Money labels formatted via `Money.from(v).{f}`. Sized to fit
/// up to `$999,999,999,999.99` (19 chars) with slack so we don't
/// silently drop the label when portfolios cross into ten figures.
/// Renderers that need to budget cells for the label should use
/// `money_label_max_bytes` rather than guessing.
max_label: [money_label_max_bytes]u8,
max_label_len: usize,
min_label: [money_label_max_bytes]u8,
min_label_len: usize,
/// Date of first candle in the chart data
start_date: Date,
/// Date of last candle in the chart data
end_date: Date,
pub fn maxLabel(self: *const BrailleChart) []const u8 {
return self.max_label[0..self.max_label_len];
}
pub fn minLabel(self: *const BrailleChart) []const u8 {
return self.min_label[0..self.min_label_len];
}
pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 {
return self.patterns[row * self.n_cols + col];
}
/// Format a date for the chart's x-axis at a granularity
/// appropriate for that individual date's recency. Two tiers,
/// per-date (driven by how far back the date is from the
/// chart's `end_date` reference, not by the chart's overall
/// span):
/// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May")
/// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014")
///
/// On a 12-year chart, this typically yields a long-format
/// start label (`Jul 2014`) paired with a short-format end
/// label (`08 May`) - the start is far enough back that
/// year context is what matters; the end is recent enough
/// that day-of-month resolution is useful.
///
/// The day-first ordering for the short form is intentional:
/// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
/// character of each label cleanly disambiguates the format
/// at a glance - digit-first is a recent date, letter-first is
/// a distant date. Saves the eye from re-parsing every label.
///
/// `buf` must be at least 8 bytes; the returned slice borrows
/// from it.
pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 {
const age_days = self.end_date.days - date.days;
const mon = Date.monthShort(date.month());
if (age_days <= 720) {
// "DD MMM" - day-first so the leading character is a
// digit (visually distinct from the letter-first
// long form below).
return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0];
}
// "MMM YYYY" - for dates more than ~2 years before
// `end_date`. Day-of-month resolution stops being useful
// at this scale; full 4-digit year keeps the label
// unambiguous regardless of how far back the chart goes.
return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0];
}
pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
alloc.free(self.patterns);
alloc.free(self.col_colors);
}
};
/// Compute braille sparkline chart data from candle close prices.
/// 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.
///
/// Returns a BrailleChart with the pattern grid and per-column colors.
/// Caller must call deinit() when done (unless using an arena allocator).
pub fn computeBrailleChart(
alloc: std.mem.Allocator,
data: []const Candle,
chart_width: usize,
chart_height: usize,
positive_color: [3]u8,
negative_color: [3]u8,
) !BrailleChart {
if (data.len < 2) return error.InsufficientData;
const dot_rows: usize = chart_height * 4; // vertical dot resolution
// Find min/max chart-close prices (split-adjusted when available).
// See `Candle.chartClose` - using raw `close` here would render
// false cliffs at split dates.
var min_price: f64 = data[0].chartClose();
var max_price: f64 = data[0].chartClose();
for (data) |d| {
const cc = d.chartClose();
if (cc < min_price) min_price = cc;
if (cc > max_price) max_price = cc;
}
if (max_price == min_price) max_price = min_price + 1.0;
const price_range = max_price - min_price;
// Price labels
// SAFETY: every field of `result` is initialized below before
// it is read or returned. Treating it as `undefined` here is
// a deliberate "stack-allocate, then write each field"
// pattern - Zig requires the variable to exist before
// bufPrint can take a slice of one of its fields.
var result: BrailleChart = undefined;
const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch "";
result.max_label_len = max_str.len;
const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch "";
result.min_label_len = min_str.len;
const n_cols = @min(data.len, chart_width);
result.n_cols = n_cols;
result.chart_height = chart_height;
result.start_date = data[0].date;
result.end_date = data[data.len - 1].date;
// Map each data column to a dot-row position and color
const dot_y = try alloc.alloc(usize, n_cols);
defer alloc.free(dot_y);
result.col_colors = try alloc.alloc([3]u8, n_cols);
errdefer alloc.free(result.col_colors);
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].chartClose();
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);
// Color: gradient from negative (bottom) to positive (top)
result.col_colors[col] = lerpColor(negative_color, positive_color, norm);
}
// Build the braille pattern grid
result.patterns = try alloc.alloc(u8, chart_height * n_cols);
@memset(result.patterns, 0);
for (0..n_cols) |col| {
const target_y = dot_y[col];
// Fill from target_y down to the bottom
for (target_y..dot_rows) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
// Interpolate between this point and the next for smooth 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);
for (min_y..max_y + 1) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
}
}
return result;
}
/// Write a braille chart to a writer with ANSI color escapes.
/// Used by the CLI for terminal output. Set `skip_date_axis` to
/// provide a custom x-axis (e.g. year labels instead of dates).
pub fn writeBrailleAnsi(
out: *std.Io.Writer,
chart: *const BrailleChart,
use_color: bool,
muted_color: [3]u8,
skip_date_axis: bool,
) !void {
var last_r: u8 = 0;
var last_g: u8 = 0;
var last_b: u8 = 0;
var color_active = false;
for (0..chart.chart_height) |row| {
try out.writeAll(" "); // 2 leading spaces
for (0..chart.n_cols) |col| {
const pat = chart.pattern(row, col);
if (use_color and pat != 0) {
const c = chart.col_colors[col];
// Only emit color escape if color changed
if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) {
try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] });
last_r = c[0];
last_g = c[1];
last_b = c[2];
color_active = true;
}
} else if (color_active and pat == 0) {
try out.writeAll("\x1b[0m");
color_active = false;
}
try out.writeAll(brailleGlyph(pat));
}
if (color_active) {
try out.writeAll("\x1b[0m");
color_active = false;
}
// Price label on first/last row
if (row == 0) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.maxLabel()});
if (use_color) try out.writeAll("\x1b[0m");
} else if (row == chart.chart_height - 1) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.minLabel()});
if (use_color) try out.writeAll("\x1b[0m");
}
try out.writeAll("\n");
}
// Date axis below chart
if (!skip_date_axis) {
var start_buf: [8]u8 = undefined;
var end_buf: [8]u8 = undefined;
const start_label = chart.fmtAxisDate(chart.start_date, &start_buf);
const end_label = chart.fmtAxisDate(chart.end_date, &end_buf);
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.writeAll(" "); // match leading indent
try out.writeAll(start_label);
const total_width = chart.n_cols;
if (total_width > start_label.len + end_label.len) {
const gap = total_width - start_label.len - end_label.len;
for (0..gap) |_| try out.writeAll(" ");
}
try out.writeAll(end_label);
if (use_color) try out.writeAll("\x1b[0m");
try out.writeAll("\n");
}
}
// Tests
test "lerpColor" {
// t=0 returns first color
const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
try std.testing.expectEqual(@as(u8, 0), c0[0]);
try std.testing.expectEqual(@as(u8, 0), c0[1]);
// t=1 returns second color
const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0);
try std.testing.expectEqual(@as(u8, 255), c1[0]);
// t=0.5 returns midpoint
const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5);
try std.testing.expectEqual(@as(u8, 100), c_mid[0]);
try std.testing.expectEqual(@as(u8, 50), c_mid[1]);
try std.testing.expectEqual(@as(u8, 25), c_mid[2]);
}
test "brailleGlyph" {
// Pattern 0 = U+2800 (blank braille)
const blank = brailleGlyph(0);
try std.testing.expectEqual(@as(usize, 3), blank.len);
try std.testing.expectEqual(@as(u8, 0xE2), blank[0]);
try std.testing.expectEqual(@as(u8, 0xA0), blank[1]);
try std.testing.expectEqual(@as(u8, 0x80), blank[2]);
// Pattern 0xFF = U+28FF (full braille)
const full = brailleGlyph(0xFF);
try std.testing.expectEqual(@as(usize, 3), full.len);
try std.testing.expectEqual(@as(u8, 0xE2), full[0]);
try std.testing.expectEqual(@as(u8, 0xA3), full[1]);
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
}
test "computeBrailleChart" {
const alloc = std.testing.allocator;
// Build synthetic candle data: 20 candles, prices rising from 100 to 119
var candles: [20]Candle = undefined;
for (0..20) |i| {
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
try std.testing.expectEqual(@as(usize, 20), chart.n_cols);
try std.testing.expectEqual(@as(usize, 4), chart.chart_height);
try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20
try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len);
// Max/min labels should contain price info
try std.testing.expect(chart.maxLabel().len > 0);
try std.testing.expect(chart.minLabel().len > 0);
}
test "computeBrailleChart insufficient data" {
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 });
try std.testing.expectError(error.InsufficientData, result);
}
test "computeBrailleChart preserves full label for prices over $1M" {
// Regression test for a bug where the max/min label buffers were
// sized at 16 bytes - too small for `Money.from(v).{f}` of values
// with 13+ chars (anything $1,000,000+). Result was a silently
// empty label string in the BrailleChart, then the TUI renderer
// truncated even further to 10 cells, dropping the label entirely
// for portfolios over $1M. See `money_label_max_bytes`.
const alloc = std.testing.allocator;
var candles: [20]Candle = undefined;
for (0..20) |i| {
// Arbitrary placeholder range starting at $1,234,567.89 with
// a $500,000 step. The point of this test is the rendered
// shape - 13+ char `$X,XXX,XXX.XX` strings that would have
// overflowed the old 16-byte label buffer or the renderer's
// 10-cell budget. The exact numbers are irrelevant.
const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Both labels must contain the dollar sign and a comma (the
// thousands separator) - that confirms `Money.from` produced
// a multi-million-dollar string and didn't fall through to the
// empty-string fallback when bufPrint hit NoSpaceLeft.
const max_lbl = chart.maxLabel();
const min_lbl = chart.minLabel();
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, ',') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, ',') != null);
// Both labels should match the `$X,XXX,XXX.XX` shape (at
// least 13 chars). Any silent-truncation bug would leave them
// empty or much shorter.
try std.testing.expect(max_lbl.len >= 13);
try std.testing.expect(min_lbl.len >= 13);
}
test "computeBrailleChart uses adj_close to avoid split cliff" {
// Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
// because the chart consumed raw `close` instead of `adj_close`.
// Build a synthetic 4-candle slice that mimics a 3:1 split: raw
// close drops 300 -> 100, but adj_close is constant at 100. The
// chart should see a flat line, not a cliff.
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 3, 5), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 6), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 7), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Min and max labels should reflect the adjusted price (~$100),
// not the raw close range (300 -> 100). The exact values vary
// because computeBrailleChart bumps max by $1 internally when
// min == max, but neither label should mention $300.
try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null);
try std.testing.expect(std.mem.indexOf(u8, chart.minLabel(), "300") == null);
}
test "fmtAxisDate: span <=720d produces DD MMM" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2026, 1, 1);
br.end_date = Date.fromYmd(2026, 5, 11);
var buf: [8]u8 = undefined;
const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf);
try std.testing.expectEqualStrings("27 Apr", lbl);
}
test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" {
// 700 days from 2024-01-01 - still inside the threshold.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 1, 1);
br.end_date = Date.fromYmd(2025, 12, 1); // 700 days
var buf: [8]u8 = undefined;
const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" {
// 12-year chart: start is way more than 720 days from end,
// so the start gets MMM YYYY. End is `end_date` itself
// (age 0), so it gets DD MMM.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2014, 7, 3);
br.end_date = Date.fromYmd(2026, 5, 11);
var buf: [8]u8 = undefined;
const start_lbl = br.fmtAxisDate(br.start_date, &buf);
try std.testing.expectEqualStrings("Jul 2014", start_lbl);
var buf2: [8]u8 = undefined;
const end_lbl = br.fmtAxisDate(br.end_date, &buf2);
try std.testing.expectEqualStrings("11 May", end_lbl);
}
test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2025, 1, 1);
// 720 days later = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Date 720 days before end_date: still boundary-inclusive -> DD MMM.
const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
test "fmtAxisDate: 721 days before end flips to MMM YYYY" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 12, 31);
// 721 days after 2024-12-31 = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Format the start (which is 721 days before end_date).
const lbl = br.fmtAxisDate(br.start_date, &buf);
try std.testing.expectEqualStrings("Dec 2024", lbl);
}

View file

@ -45,6 +45,7 @@ const view = @import("../views/history.zig");
const chart_export = @import("../chart_export.zig");
const line_chart = @import("../charts/line_chart.zig");
const chart = @import("../charts/chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
@ -655,9 +656,9 @@ fn renderBraille(
}
const candles = candles_list.items;
var braille_chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
var braille_chart = braille.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer braille_chart.deinit(allocator);
try fmt.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
try braille.writeBrailleAnsi(out, &braille_chart, color, cli.CLR_MUTED, false);
}
fn renderTable(

View file

@ -23,6 +23,7 @@ const milestones = @import("../analytics/milestones.zig");
const shiller = @import("../data/shiller.zig");
const chart_export = @import("../chart_export.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
@ -895,9 +896,9 @@ pub fn runBands(
};
}
var br = fmt.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
var br = braille.computeBrailleChart(va, candles, 80, 12, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch null;
if (br) |*chart| {
try fmt.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
try braille.writeBrailleAnsi(out, chart, color, cli.CLR_MUTED, true);
// Year axis instead of date axis
try cli.setFg(out, color, cli.CLR_MUTED);
try out.print(" Now", .{});

View file

@ -6,6 +6,7 @@ const fmt = cli.fmt;
const Money = @import("../Money.zig");
const chart_export = @import("../chart_export.zig");
const tui_chart = @import("../charts/chart.zig");
const braille = @import("../charts/braille.zig");
const term_graphics = @import("../term_graphics.zig");
const term_query = @import("../term_query.zig");
const theme = @import("../tui/theme.zig");
@ -288,9 +289,9 @@ const KittyChart = struct {
fn renderBrailleCandles(allocator: std.mem.Allocator, out: *std.Io.Writer, color: bool, candles: []const zfin.Candle, display_count: usize) !void {
const n = @min(candles.len, display_count);
const data = candles[candles.len - n ..];
var ch = fmt.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
var ch = braille.computeBrailleChart(allocator, data, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return;
defer ch.deinit(allocator);
try fmt.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
try braille.writeBrailleAnsi(out, &ch, color, cli.CLR_MUTED, false);
}
/// Render the price+Bollinger+volume+RSI chart for the most recent

View file

@ -1,7 +1,8 @@
//! Shared formatting utilities used by both CLI and TUI.
//!
//! Number formatting (fmtIntCommas, etc.), financial helpers
//! (capitalGainsIndicator, filterNearMoney), and braille chart computation.
//! Number formatting (fmtIntCommas, etc.) and financial helpers
//! (capitalGainsIndicator, filterNearMoney). The braille sparkline
//! chart lives in `charts/braille.zig`.
const std = @import("std");
const Date = @import("Date.zig");
@ -888,329 +889,6 @@ pub fn fmtPriceChange(buf: []u8, change: f64, pct: f64) []const u8 {
}
}
/// Interpolate color between two RGB values. t in [0.0, 1.0].
pub 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),
};
}
// Braille chart
/// 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
pub 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.
pub 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.
pub fn brailleGlyph(pattern: u8) []const u8 {
return &braille_utf8[pattern];
}
/// Maximum byte length for a `Money.from(v).{f}` rendering used as a
/// chart axis label. Sized to fit `$999,999,999,999.99` (19 chars,
/// up to a trillion-plus) with slack. Renderers that pre-allocate
/// buffer cells for these labels should use this constant rather
/// than hard-coding a smaller width and silently truncating
/// portfolios over $1M.
pub const money_label_max_bytes: usize = 24;
/// Computed braille chart data, ready for rendering by CLI (ANSI) or TUI (vaxis).
pub const BrailleChart = struct {
/// Braille pattern bytes: patterns[row * n_cols + col]
patterns: []u8,
/// RGB color per data column
col_colors: [][3]u8,
n_cols: usize,
chart_height: usize,
/// Money labels formatted via `Money.from(v).{f}`. Sized to fit
/// up to `$999,999,999,999.99` (19 chars) with slack so we don't
/// silently drop the label when portfolios cross into ten figures.
/// Renderers that need to budget cells for the label should use
/// `money_label_max_bytes` rather than guessing.
max_label: [money_label_max_bytes]u8,
max_label_len: usize,
min_label: [money_label_max_bytes]u8,
min_label_len: usize,
/// Date of first candle in the chart data
start_date: Date,
/// Date of last candle in the chart data
end_date: Date,
pub fn maxLabel(self: *const BrailleChart) []const u8 {
return self.max_label[0..self.max_label_len];
}
pub fn minLabel(self: *const BrailleChart) []const u8 {
return self.min_label[0..self.min_label_len];
}
pub fn pattern(self: *const BrailleChart, row: usize, col: usize) u8 {
return self.patterns[row * self.n_cols + col];
}
/// Format a date as "MMM DD" for the braille chart x-axis.
/// The year context is already visible in the surrounding CLI/TUI interface.
/// Returns the number of bytes written.
pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 {
const mon = Date.monthShort(date.month());
const d = date.day();
buf[0] = mon[0];
buf[1] = mon[1];
buf[2] = mon[2];
buf[3] = ' ';
if (d >= 10) {
buf[4] = '0' + d / 10;
} else {
buf[4] = '0';
}
buf[5] = '0' + d % 10;
return buf[0..6];
}
/// Format a date for the chart's x-axis at a granularity
/// appropriate for that individual date's recency. Two tiers,
/// per-date (driven by how far back the date is from the
/// chart's `end_date` reference, not by the chart's overall
/// span):
/// - within 720 days of `end_date` -> "DD MMM" (e.g., "08 May")
/// - older than 720 days -> "MMM YYYY" (e.g., "Jul 2014")
///
/// On a 12-year chart, this typically yields a long-format
/// start label (`Jul 2014`) paired with a short-format end
/// label (`08 May`) - the start is far enough back that
/// year context is what matters; the end is recent enough
/// that day-of-month resolution is useful.
///
/// The day-first ordering for the short form is intentional:
/// when a chart pairs `"08 May"` with `"Jul 2014"`, the first
/// character of each label cleanly disambiguates the format
/// at a glance - digit-first is a recent date, letter-first is
/// a distant date. Saves the eye from re-parsing every label.
///
/// `buf` must be at least 8 bytes; the returned slice borrows
/// from it.
pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 {
const age_days = self.end_date.days - date.days;
const mon = Date.monthShort(date.month());
if (age_days <= 720) {
// "DD MMM" - day-first so the leading character is a
// digit (visually distinct from the letter-first
// long form below).
return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0];
}
// "MMM YYYY" - for dates more than ~2 years before
// `end_date`. Day-of-month resolution stops being useful
// at this scale; full 4-digit year keeps the label
// unambiguous regardless of how far back the chart goes.
return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0];
}
pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void {
alloc.free(self.patterns);
alloc.free(self.col_colors);
}
};
/// Compute braille sparkline chart data from candle close prices.
/// 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.
///
/// Returns a BrailleChart with the pattern grid and per-column colors.
/// Caller must call deinit() when done (unless using an arena allocator).
pub fn computeBrailleChart(
alloc: std.mem.Allocator,
data: []const Candle,
chart_width: usize,
chart_height: usize,
positive_color: [3]u8,
negative_color: [3]u8,
) !BrailleChart {
if (data.len < 2) return error.InsufficientData;
const dot_rows: usize = chart_height * 4; // vertical dot resolution
// Find min/max chart-close prices (split-adjusted when available).
// See `Candle.chartClose` - using raw `close` here would render
// false cliffs at split dates.
var min_price: f64 = data[0].chartClose();
var max_price: f64 = data[0].chartClose();
for (data) |d| {
const cc = d.chartClose();
if (cc < min_price) min_price = cc;
if (cc > max_price) max_price = cc;
}
if (max_price == min_price) max_price = min_price + 1.0;
const price_range = max_price - min_price;
// Price labels
// SAFETY: every field of `result` is initialized below before
// it is read or returned. Treating it as `undefined` here is
// a deliberate "stack-allocate, then write each field"
// pattern - Zig requires the variable to exist before
// bufPrint can take a slice of one of its fields.
var result: BrailleChart = undefined;
const max_str = std.fmt.bufPrint(&result.max_label, "{f}", .{Money.from(max_price)}) catch "";
result.max_label_len = max_str.len;
const min_str = std.fmt.bufPrint(&result.min_label, "{f}", .{Money.from(min_price)}) catch "";
result.min_label_len = min_str.len;
const n_cols = @min(data.len, chart_width);
result.n_cols = n_cols;
result.chart_height = chart_height;
result.start_date = data[0].date;
result.end_date = data[data.len - 1].date;
// Map each data column to a dot-row position and color
const dot_y = try alloc.alloc(usize, n_cols);
defer alloc.free(dot_y);
result.col_colors = try alloc.alloc([3]u8, n_cols);
errdefer alloc.free(result.col_colors);
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].chartClose();
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);
// Color: gradient from negative (bottom) to positive (top)
result.col_colors[col] = lerpColor(negative_color, positive_color, norm);
}
// Build the braille pattern grid
result.patterns = try alloc.alloc(u8, chart_height * n_cols);
@memset(result.patterns, 0);
for (0..n_cols) |col| {
const target_y = dot_y[col];
// Fill from target_y down to the bottom
for (target_y..dot_rows) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
// Interpolate between this point and the next for smooth 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);
for (min_y..max_y + 1) |dy| {
const term_row = dy / 4;
const sub_row = dy % 4;
result.patterns[term_row * n_cols + col] |= braille_dots[sub_row][0];
}
}
}
return result;
}
/// Write a braille chart to a writer with ANSI color escapes.
/// Used by the CLI for terminal output. Set `skip_date_axis` to
/// provide a custom x-axis (e.g. year labels instead of dates).
pub fn writeBrailleAnsi(
out: *std.Io.Writer,
chart: *const BrailleChart,
use_color: bool,
muted_color: [3]u8,
skip_date_axis: bool,
) !void {
var last_r: u8 = 0;
var last_g: u8 = 0;
var last_b: u8 = 0;
var color_active = false;
for (0..chart.chart_height) |row| {
try out.writeAll(" "); // 2 leading spaces
for (0..chart.n_cols) |col| {
const pat = chart.pattern(row, col);
if (use_color and pat != 0) {
const c = chart.col_colors[col];
// Only emit color escape if color changed
if (!color_active or c[0] != last_r or c[1] != last_g or c[2] != last_b) {
try out.print("\x1b[38;2;{d};{d};{d}m", .{ c[0], c[1], c[2] });
last_r = c[0];
last_g = c[1];
last_b = c[2];
color_active = true;
}
} else if (color_active and pat == 0) {
try out.writeAll("\x1b[0m");
color_active = false;
}
try out.writeAll(brailleGlyph(pat));
}
if (color_active) {
try out.writeAll("\x1b[0m");
color_active = false;
}
// Price label on first/last row
if (row == 0) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.maxLabel()});
if (use_color) try out.writeAll("\x1b[0m");
} else if (row == chart.chart_height - 1) {
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.print(" {s}", .{chart.minLabel()});
if (use_color) try out.writeAll("\x1b[0m");
}
try out.writeAll("\n");
}
// Date axis below chart
if (!skip_date_axis) {
var start_buf: [8]u8 = undefined;
var end_buf: [8]u8 = undefined;
const start_label = chart.fmtAxisDate(chart.start_date, &start_buf);
const end_label = chart.fmtAxisDate(chart.end_date, &end_buf);
if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] });
try out.writeAll(" "); // match leading indent
try out.writeAll(start_label);
const total_width = chart.n_cols;
if (total_width > start_label.len + end_label.len) {
const gap = total_width - start_label.len - end_label.len;
for (0..gap) |_| try out.writeAll(" ");
}
try out.writeAll(end_label);
if (use_color) try out.writeAll("\x1b[0m");
try out.writeAll("\n");
}
}
// ANSI color helpers (for CLI)
/// Determine whether to use ANSI color output.
@ -1531,151 +1209,6 @@ test "aggregateDripLots empty" {
try std.testing.expect(agg.lt.isEmpty());
}
test "lerpColor" {
// t=0 returns first color
const c0 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 0.0);
try std.testing.expectEqual(@as(u8, 0), c0[0]);
try std.testing.expectEqual(@as(u8, 0), c0[1]);
// t=1 returns second color
const c1 = lerpColor(.{ 0, 0, 0 }, .{ 255, 255, 255 }, 1.0);
try std.testing.expectEqual(@as(u8, 255), c1[0]);
// t=0.5 returns midpoint
const c_mid = lerpColor(.{ 0, 0, 0 }, .{ 200, 100, 50 }, 0.5);
try std.testing.expectEqual(@as(u8, 100), c_mid[0]);
try std.testing.expectEqual(@as(u8, 50), c_mid[1]);
try std.testing.expectEqual(@as(u8, 25), c_mid[2]);
}
test "brailleGlyph" {
// Pattern 0 = U+2800 (blank braille)
const blank = brailleGlyph(0);
try std.testing.expectEqual(@as(usize, 3), blank.len);
try std.testing.expectEqual(@as(u8, 0xE2), blank[0]);
try std.testing.expectEqual(@as(u8, 0xA0), blank[1]);
try std.testing.expectEqual(@as(u8, 0x80), blank[2]);
// Pattern 0xFF = U+28FF (full braille)
const full = brailleGlyph(0xFF);
try std.testing.expectEqual(@as(usize, 3), full.len);
try std.testing.expectEqual(@as(u8, 0xE2), full[0]);
try std.testing.expectEqual(@as(u8, 0xA3), full[1]);
try std.testing.expectEqual(@as(u8, 0xBF), full[2]);
}
test "fmtShortDate" {
var buf: [7]u8 = undefined;
const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf);
try std.testing.expectEqualStrings("Jan 15", jan15);
const dec01 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 12, 1), &buf);
try std.testing.expectEqualStrings("Dec 01", dec01);
const jun09 = BrailleChart.fmtShortDate(Date.fromYmd(2026, 6, 9), &buf);
try std.testing.expectEqualStrings("Jun 09", jun09);
}
test "computeBrailleChart" {
const alloc = std.testing.allocator;
// Build synthetic candle data: 20 candles, prices rising from 100 to 119
var candles: [20]Candle = undefined;
for (0..20) |i| {
const price: f64 = 100.0 + @as(f64, @floatFromInt(i));
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
try std.testing.expectEqual(@as(usize, 20), chart.n_cols);
try std.testing.expectEqual(@as(usize, 4), chart.chart_height);
try std.testing.expectEqual(@as(usize, 80), chart.patterns.len); // 4 * 20
try std.testing.expectEqual(@as(usize, 20), chart.col_colors.len);
// Max/min labels should contain price info
try std.testing.expect(chart.maxLabel().len > 0);
try std.testing.expect(chart.minLabel().len > 0);
}
test "computeBrailleChart insufficient data" {
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 1, 2), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
const result = computeBrailleChart(alloc, &candles, 10, 4, .{ 0, 0, 0 }, .{ 255, 255, 255 });
try std.testing.expectError(error.InsufficientData, result);
}
test "computeBrailleChart preserves full label for prices over $1M" {
// Regression test for a bug where the max/min label buffers were
// sized at 16 bytes - too small for `Money.from(v).{f}` of values
// with 13+ chars (anything $1,000,000+). Result was a silently
// empty label string in the BrailleChart, then the TUI renderer
// truncated even further to 10 cells, dropping the label entirely
// for portfolios over $1M. See `money_label_max_bytes`.
const alloc = std.testing.allocator;
var candles: [20]Candle = undefined;
for (0..20) |i| {
// Arbitrary placeholder range starting at $1,234,567.89 with
// a $500,000 step. The point of this test is the rendered
// shape - 13+ char `$X,XXX,XXX.XX` strings that would have
// overflowed the old 16-byte label buffer or the renderer's
// 10-cell budget. The exact numbers are irrelevant.
const price: f64 = 1_234_567.89 + @as(f64, @floatFromInt(i)) * 500_000.0;
candles[i] = .{
.date = Date.fromYmd(2024, 1, 2).addDays(@intCast(i)),
.open = price,
.high = price,
.low = price,
.close = price,
.adj_close = price,
.volume = 1000,
};
}
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Both labels must contain the dollar sign and a comma (the
// thousands separator) - that confirms `Money.from` produced
// a multi-million-dollar string and didn't fall through to the
// empty-string fallback when bufPrint hit NoSpaceLeft.
const max_lbl = chart.maxLabel();
const min_lbl = chart.minLabel();
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, max_lbl, ',') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, '$') != null);
try std.testing.expect(std.mem.indexOfScalar(u8, min_lbl, ',') != null);
// Both labels should match the `$X,XXX,XXX.XX` shape (at
// least 13 chars). Any silent-truncation bug would leave them
// empty or much shorter.
try std.testing.expect(max_lbl.len >= 13);
try std.testing.expect(min_lbl.len >= 13);
}
test "computeBrailleChart uses adj_close to avoid split cliff" {
// Regression: SOXX 3:1 on 2024-03-07 used to render a sharp drop
// because the chart consumed raw `close` instead of `adj_close`.
// Build a synthetic 4-candle slice that mimics a 3:1 split: raw
// close drops 300 -> 100, but adj_close is constant at 100. The
// chart should see a flat line, not a cliff.
const alloc = std.testing.allocator;
const candles = [_]Candle{
.{ .date = Date.fromYmd(2024, 3, 5), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 6), .open = 300, .high = 300, .low = 300, .close = 300, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 7), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
.{ .date = Date.fromYmd(2024, 3, 8), .open = 100, .high = 100, .low = 100, .close = 100, .adj_close = 100, .volume = 1000 },
};
var chart = try computeBrailleChart(alloc, &candles, 20, 4, .{ 0x7f, 0xd8, 0x8f }, .{ 0xe0, 0x6c, 0x75 });
defer chart.deinit(alloc);
// Min and max labels should reflect the adjusted price (~$100),
// not the raw close range (300 -> 100). The exact values vary
// because computeBrailleChart bumps max by $1 internally when
// min == max, but neither label should mention $300.
try std.testing.expect(std.mem.indexOf(u8, chart.maxLabel(), "300") == null);
try std.testing.expect(std.mem.indexOf(u8, chart.minLabel(), "300") == null);
}
test "fmtContractLine" {
var buf: [128]u8 = undefined;
const contract = OptionContract{
@ -1868,62 +1401,6 @@ test "fmtTimeAgo: days" {
try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400));
}
test "fmtAxisDate: span <=720d produces DD MMM" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2026, 1, 1);
br.end_date = Date.fromYmd(2026, 5, 11);
var buf: [8]u8 = undefined;
const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf);
try std.testing.expectEqualStrings("27 Apr", lbl);
}
test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" {
// 700 days from 2024-01-01 - still inside the threshold.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 1, 1);
br.end_date = Date.fromYmd(2025, 12, 1); // 700 days
var buf: [8]u8 = undefined;
const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" {
// 12-year chart: start is way more than 720 days from end,
// so the start gets MMM YYYY. End is `end_date` itself
// (age 0), so it gets DD MMM.
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2014, 7, 3);
br.end_date = Date.fromYmd(2026, 5, 11);
var buf: [8]u8 = undefined;
const start_lbl = br.fmtAxisDate(br.start_date, &buf);
try std.testing.expectEqualStrings("Jul 2014", start_lbl);
var buf2: [8]u8 = undefined;
const end_lbl = br.fmtAxisDate(br.end_date, &buf2);
try std.testing.expectEqualStrings("11 May", end_lbl);
}
test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2025, 1, 1);
// 720 days later = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Date 720 days before end_date: still boundary-inclusive -> DD MMM.
const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf);
try std.testing.expectEqualStrings("01 Jan", lbl);
}
test "fmtAxisDate: 721 days before end flips to MMM YYYY" {
var br: BrailleChart = undefined;
br.start_date = Date.fromYmd(2024, 12, 31);
// 721 days after 2024-12-31 = 2026-12-22.
br.end_date = Date.fromYmd(2026, 12, 22);
var buf: [8]u8 = undefined;
// Format the start (which is 721 days before end_date).
const lbl = br.fmtAxisDate(br.start_date, &buf);
try std.testing.expectEqualStrings("Dec 2024", lbl);
}
test "displayCols: ASCII bytes count as 1 col each" {
try std.testing.expectEqual(@as(usize, 0), displayCols(""));
try std.testing.expectEqual(@as(usize, 5), displayCols("hello"));

View file

@ -1,7 +1,6 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("root.zig");
const fmt = @import("format.zig");
const Money = @import("Money.zig");
const cli = @import("commands/common.zig");
const stderr = @import("stderr.zig");
@ -10,6 +9,7 @@ const tab_framework = @import("tui/tab_framework.zig");
const framework = @import("commands/framework.zig");
const theme = @import("tui/theme.zig");
const chart = @import("charts/chart.zig");
const braille = @import("charts/braille.zig");
const input_buffer = @import("tui/input_buffer.zig");
pub const PortfolioData = @import("PortfolioData.zig");
@ -2143,14 +2143,14 @@ pub const App = struct {
pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayList(StyledLine), data: []const zfin.Candle, th: theme.Theme) !void {
// Local shadows the `chart` module import; use a shorter name for
// the local BrailleChart handle.
var br = fmt.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
var br = braille.computeBrailleChart(arena, data, 60, 10, th.positive, th.negative) catch return;
// No deinit needed: arena handles cleanup
// Cell budget per row: 2 leading spaces + n_cols chart cells +
// 1 separator space + up to `money_label_max_bytes` for the
// price label. Sizing this too small silently truncates labels
// for portfolios over $1M; see `fmt.money_label_max_bytes`.
const label_cells: usize = 1 + fmt.money_label_max_bytes;
// for portfolios over $1M; see `braille.money_label_max_bytes`.
const label_cells: usize = 1 + braille.money_label_max_bytes;
const row_cells: usize = 2 + br.n_cols + label_cells;
const bg = th.bg;
@ -2170,7 +2170,7 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis
// Chart columns
for (0..br.n_cols) |col| {
const pattern = br.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pattern);
graphemes[gpos] = braille.brailleGlyph(pattern);
if (pattern != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(br.col_colors[col]), .bg = theme.Theme.vcolor(bg) };
} else {
@ -3088,7 +3088,7 @@ test "renderBrailleToStyledLines: full price label renders for portfolios over $
// and `n_cols` chart cells). For 13+ char money strings like
// `$X,XXX,XXX.XX` (14 chars including the leading separator
// space), the label got silently truncated to the 10 cell
// budget. Buffer is now sized via `fmt.money_label_max_bytes`.
// budget. Buffer is now sized via `braille.money_label_max_bytes`.
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();

View file

@ -28,6 +28,7 @@ const theme = @import("theme.zig");
const tui = @import("../tui.zig");
const projection_chart = @import("../charts/projection_chart.zig");
const forecast_chart = @import("../charts/forecast_chart.zig");
const braille = @import("../charts/braille.zig");
const forecast = @import("../analytics/forecast_evaluation.zig");
const imported = @import("../data/imported_values.zig");
const milestones = @import("../analytics/milestones.zig");
@ -1981,7 +1982,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
// Compute braille chart with wider dimensions
const chart_width: usize = 80;
const chart_height: usize = 12;
var br = fmt.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null;
var br = braille.computeBrailleChart(arena, candles, chart_width, chart_height, th.positive, th.negative) catch null;
if (br) |*br_chart| {
const bg = th.bg;
@ -1994,8 +1995,8 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
// Sized via a named constant so the projection
// chart doesn't silently truncate labels for
// portfolios that grow past $1M; see
// `fmt.money_label_max_bytes`.
const proj_label_cells: usize = 1 + fmt.money_label_max_bytes;
// `braille.money_label_max_bytes`.
const proj_label_cells: usize = 1 + braille.money_label_max_bytes;
const proj_row_cells: usize = 2 + br_chart.n_cols + proj_label_cells;
for (0..br_chart.chart_height) |row| {
@ -2014,7 +2015,7 @@ fn buildLines(state: *State, app: *App, arena: std.mem.Allocator) ![]const Style
// Chart columns
for (0..br_chart.n_cols) |col| {
const pat = br_chart.pattern(row, col);
graphemes[gpos] = fmt.brailleGlyph(pat);
graphemes[gpos] = braille.brailleGlyph(pat);
if (pat != 0) {
styles[gpos] = .{ .fg = theme.Theme.vcolor(br_chart.col_colors[col]), .bg = bg_v };
} else {