Skip to content

BarChart — reusable vertical bar chart component #6

Description

@MohnDoe

Problem Statement

pi-tui-extras lacks a generic bar chart component. The existing BarChart in pi-atlas is domain-specific (coupled to DaySpend/HourSpend/cost formatting) and not reusable. Consumers who want to visualize numeric data as vertical bars must build their own rendering, reimplementing axis layout, label density, auto-downsampling, and bar drawing.

Solution

A reusable BarChart component implementing pi-tui's Component interface. Accepts a flat array of BarItem { label, value }, renders vertical bars with 8-block unicode precision (▁▂▃▄▅▆▇█), auto-downsamples when data exceeds available width, and exposes style callbacks for bar/axis/label/footer decoration. No ANSI defaults — plain output, composable via BorderBox or other containers.

User Stories

  1. As a pi-tui app developer, I want to render a bar chart from {label, value} items, so that I can visualize numeric data without writing layout code.
  2. As a developer, I want the chart to auto-downsample when data exceeds the terminal width, so that it always fits without manual pre-aggregation.
  3. As a developer, I want bars rendered with 8-block unicode characters (▁▂▃▄▅▆▇█), so that vertical precision exceeds row granularity.
  4. As a developer, I want a formatValue callback for y-axis ticks, so that I can render currency, bytes, percentages, etc.
  5. As a developer, I want minValue/maxValue options to configure the y-axis range, so that I can zoom or force zero-based scale.
  6. As a developer, I want barFn/axisFn/labelFn/footerFn style callbacks, so that I can apply ANSI styling (themes, chalk) to individual visual elements.
  7. As a developer, I want configurable horizontal grid lines, so that I can improve readability on large charts.
  8. As a developer, I want the y-axis separator character overridable, so that I can use plain ASCII (|) or custom delimiters.
  9. As a developer, I want x-axis labels auto-truncated with smart density, so that crowded labels don't overflow their columns.
  10. As a developer, I want the y-axis label density auto-calculated with an optional yAxisSpacing override, so that the chart adapts to any height.
  11. As a developer, I want the aggregation footer customizable via a callback, so that I can show domain-specific phrasing ("~5d avg" vs "~5 items grouped").
  12. As a developer, I want the chart to render a full axis shell with a centered "No data" message when data is empty, so that the user sees a consistent chart shape.
  13. As a developer, I want a centered fallback message when the terminal is too narrow, so that the user knows to widen their view.
  14. As a developer, I want setData() to update data and invalidate the cache, so that I can swap datasets without reconstructing the component.
  15. As a developer, I want height option to control chart vertical budget, so that I can size the chart appropriately for my layout.
  16. As a developer, I want the component to implement Component (not Box/Container), so that it's a leaf node composable inside any pi-tui container.
  17. As a developer, I want render output cached per-width and invalidated on invalidate(), so that repeated renders at the same width are cheap.

Implementation Decisions

  • Data primitive: BarItem { label: string; value: number }. No generic type parameters — users map their domain data to this shape.
  • Constructor: new BarChart(data: BarItem[], options?: BarChartOptions). Data is shallow-copied. setData(data) replaces and invalidates.
  • Options interface:
interface BarChartOptions {
  height?: number;           // default 10
  formatValue?: (v: number) => string;
  minValue?: number;
  maxValue?: number;
  barFn?: (text: string) => string;
  axisFn?: (text: string) => string;
  labelFn?: (text: string) => string;
  footerFn?: (text: string) => string;
  showGridLines?: boolean;   // default false
  gridChar?: string;         // default "·"
  yAxisSpacing?: number;
  yAxisSeparator?: string;   // default " │ "
  aggregationLabel?: (factor: number) => string;
}
  • Rendering algorithm (vertical orientation):

    1. Edge-case checks: empty data → shell + "No data". Width below minimum → centered fallback message.
    2. Compute barAreaH = max(3, height - 2). 2 rows for x-axis baseline + footer.
    3. Compute y-axis layout: step from auto-density heuristic (every 1st/2nd/3rd row) or override. Compute maxCost from data (clamped by minValue/maxValue).
    4. Compute y-axis label width: pad the widest formatted value.
    5. Compute available width for bars: width - yAxisWidth. Determine max bar count from column width + gap. Downsample if needed.
    6. For each row from top to bottom: render y-axis label (density-aware), separator, then bars using 8-block elements. Optionally draw grid lines.
    7. Render x-axis baseline: └─ corner, then interleaved labels and fill.
    8. Render granularity footer (right-aligned) via aggregationLabel callback.
    9. Cache output.
  • Bar character mapping: 8 equal-sized buckets per row. subRow = barHeight - floor(barHeight), blockIndex = floor(subRow * 8), mapped to [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]. Bars with value > 0 always get at least .

  • Auto-downsampling: When data.length * (MIN_COL_WIDTH + BAR_GAP) > availableWidth, aggregate adjacent items into buckets. Same algorithm as the original: aggregate<T>(data, target) groups floor(n/target) or ceil(n/target) items per bucket, summing value. Footer shows ratio via aggregationLabel.

  • X-axis label density: For N bars at column width W, labels are shown at calculated intervals to avoid overflow. Smartly shows first, last, and intermediately spaced labels.

  • No handleInput — static component. Only implements render, invalidate.

  • Style callbacks are optional — defaults pass through the string unchanged (plain text).

  • No BorderBox extension — implements Component directly. Users wrap in BorderBox for borders/titles.

Testing Decisions

What makes a good test: Test external behavior (render output given inputs), not internal implementation (cache internals, loop counters). Prefer simple string-output assertions for well-defined cases, and structural assertions (line count, character presence) for variable-width scenarios.

Seams (from highest to lowest):

  • render(width) — the primary seam. Test output lines for correctness given data + options.
  • setData(newData) followed by render(width) — verify output changes after mutation.
  • invalidate() — verify that cache is cleared (same width produces new array reference after invalidation).

Prior art: border-box.test.ts tests render(width) output with string equality for specific cases, mock-based tests for handleInput, and edge cases like empty children. BarChart tests follow the same pattern.

Core test cases:

  1. Basic 3-bar rendering at standard width
  2. Empty data → full axis shell + centered "No data"
  3. Tiny width fallback message
  4. Auto-downsampling (many items, narrow width)
  5. Grid lines enabled
  6. Custom formatValue (e.g., currency)
  7. Custom minValue/maxValue range override
  8. setData replaces output
  9. invalidate() clears cache
  10. All-zero data
  11. Single bar edge case
  12. yAxisSeparator override
  13. aggregationLabel callback

Out of Scope

  • Horizontal orientation
  • Grouped or stacked bars
  • Event handling / focus management
  • Real-time animation / smooth transitions
  • Interactive hover/tooltip selection
  • Theme preset objects (use style callbacks instead)
  • SVG or canvas output (terminal only)
  • Responsive height calculation from parent container (height is explicit)
  • Non-block bar styles (shading, patterns, gradients)
  • Logarithmic y-axis scale
  • Legend rendering

Further Notes

  • Default output is plain text with no ANSI codes. Users layer styling via callbacks. This keeps the component composable with any theme system (pi-tui theme, chalk, raw codes).
  • The 8-block unicode characters (▁▂▃▄▅▆▇█) are widely supported in modern terminals (xterm-256color, kitty, iTerm2, Windows Terminal).
  • Box-drawing characters (│ └ ─) have been supported in terminals since the 1980s.

Metadata

Metadata

Assignees

Labels

consideringProposal under consideration, not yet accepted

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions