Drafter.Widget.Chart (drafter v0.5.6)

Copy Markdown View Source

Renders time-series and financial data as interactive charts with multiple styles.

Supported chart types: :line, :braille, :step, :area, :braille_area, :bar, :clustered_bar, :stacked_bar, :range_bar, :scatter, :bubble, :histogram, :heatmap, and :candlestick. Any unrecognised value renders as :line.

Every chart is drawn once as shapes and then painted at the resolution of the renderer in use (see :renderer): a terminal image at the terminal's own pixels per cell, braille at two by four dots per cell, or block characters of a FrenchCurve.Backend.Cells glyph set. Axis, bar and value labels are drawn as text around and over the plot.

Each chart type reads :data in its own shape:

  • :line, :braille, :step, :area, :braille_area, :bar — a list of numbers, or a list of such lists for multiple series
  • :clustered_bar, :stacked_bar — a list of series, each a list of numbers
  • :range_bar — a list of [low, high] pairs, one per bar
  • :scatter — [x, y] lists, {x, y} tuples, or weighted variants [x, y, weight] / {x, y, weight} where weight is a float between 0.0 and 1.0. Higher weights produce denser braille dot clusters and brighter colors. A list of point-lists renders multiple series
  • :bubble — points carrying a magnitude, rendered as sized dots; a list of point-lists renders multiple series
  • :histogram — a flat list of raw values, binned automatically
  • :heatmap — a list of rows, each a list of numbers, forming a matrix
  • :candlestick — [open, high, low, close] lists or maps with :open, :high, :low, :close keys

Component tag

Tag :chart, built by Drafter.App as {:chart, data, opts}:

chart(data, opts)

The positional argument becomes :data when it is a list; otherwise :data is read from opts. Because a bare keyword list in the first position is treated as opts, both chart(values, chart_type: :line) and chart(data: values, chart_type: :line) are valid. :width and :height default to the rect the parent allocated.

Negative Values

All chart types (except candlestick) support negative values natively. The Y-axis range is derived from the data including any negative values. For charts that span both positive and negative territory a zero-line is drawn automatically when show_axes: true. Set min_value and max_value explicitly to pin a symmetric range:

chart(io_data, chart_type: :line, min_value: -150, max_value: 150, show_axes: true)

Multi-Series Charts

Pass a list of series (each a list of values) to :data for :line, :clustered_bar, :stacked_bar, and :scatter. Each series is rendered in its own colour cycling through :colors. If :colors is empty a built-in palette of six hues is used.

chart([series_a, series_b, series_c],
  chart_type: :line,
  height: 8,
  colors: [{100, 200, 255}, {255, 150, 80}, {80, 255, 150}]
)

For :scatter, multi-series data is a list of point-lists where each point-list contains [x, y] pairs:

chart([series_a_points, series_b_points], chart_type: :scatter, height: 8)

On :line, :braille, :step, :area and :braille_area charts a nil in a series is a gap: nothing is drawn at that position and the series resumes after it, so series sampled on a shared time grid stay aligned. Bar charts draw a gap as zero.

chart([[nil, nil, 3, 4, 6], [1, 2, 2, 3, 2]], chart_type: :line, height: 8)

For :range_bar, data is a list of [low, high] pairs — one pair per bar:

chart([[10, 40], [25, 65], [5, 55]], chart_type: :range_bar, height: 8)

Bar Chart Types

  • :bar — one bar per data point; a list of series draws as :clustered_bar
  • :clustered_bar — grouped bars; each group shows one bar per series side by side
  • :stacked_bar — multi-row stacked bars; series accumulate from the baseline (supports negatives — positive series stack upward, negative series stack downward)
  • :range_bar — one bar per data point spanning a low→high range

Keyboard Controls (when focused)

  • ← / → — scroll the X-axis by 5 data points, clamped at 0 on the left
  • ↑ / ↓ — pan the Y-axis up/down by a tenth of its span per press
  • ?c — reset the Y-axis pan offset to 0
  • Click and drag — pan both axes simultaneously

Every other key bubbles.

Options

  • :data — numeric list; list of series for multi-series types; [low, high] pairs for :range_bar. Default []
  • :chart_type — :line (default), :step, :area, :braille, :braille_area, :bar, :clustered_bar, :stacked_bar, :range_bar, :scatter, :bubble, :histogram, :heatmap, :candlestick. Any other value renders as :line
  • :pixel_style — how a chart whose renderer resolves to :text draws: :braille (default) or :quadrant, the :quad block characters
  • :min_value — explicit Y minimum. Default nil, auto-detected from the data with 5% padding on each side
  • :max_value — explicit Y maximum. Default nil, auto-detected from the data with 5% padding on each side. When the resolved minimum equals the maximum the range is widened by 0.001 either way
  • :color — {r, g, b} primary colour for single-series charts. Default nil, falling back to the theme and then to {100, 200, 255}
  • :colors — list of {r, g, b} tuples, one per series for multi-series types; the first entry overrides :color for single-series bar/scatter/area. Default []
  • :show_axes — boolean/0, draw axis lines and the zero-line when the range spans zero. Default false. Reserves two rows and a labelled left column
  • :show_labels — boolean/0, draw axis tick labels. Default false
  • :title — String.t/0 displayed on a row above the chart. Default nil; an empty string is treated the same as nil
  • :x_labels — list of strings for X-axis tick labels. Default []
  • :y_labels — list of strings for Y-axis tick labels. Default []
  • :orientation — :vertical (default) or :horizontal; applies to :bar, :clustered_bar, :stacked_bar and :range_bar only, and every other chart type falls back to the vertical renderer
  • :bar_labels — list of strings labelling each bar or group, shown when show_labels: true. Default []
  • :show_values — boolean/0, show the numeric value after each bar of a horizontal bar chart. Default false
  • :fill_opacity — how strongly an area's fill shows its series colour over the chart background, 0.0 (background) to 1.0 (the series colour). Default 0.6
  • :animated — boolean/0. A :line, :area or :step chart of one series rotates its points by one every :animation_speed milliseconds, in every renderer. Default false
  • :animation_speed — milliseconds per animation frame. Default 100
  • :width — chart width in columns. The element defaults it to the rect the parent allocated, but rendering always uses rect.width, so this only reaches the state
  • :height — chart height in rows. The element defaults it to the rect the parent allocated; mount/1 on its own defaults it to 1
  • :max_data_points — cap on retained points; older points are dropped when exceeded. Default nil, no cap
  • :bar_gap — blank columns between bars. Default 0
  • :area_fill — which side of the line the area body fills: :below (default) or :inverted to fill from the line up to the top edge
  • :show_baseline — boolean/0, draw the zero baseline row on stacked braille areas. Default false
  • :zero_center — how a stacked range spanning zero is scaled: :symmetric (default, equal extent either side of zero), :independent to let the positive and negative extents differ, each half scaled on its own, or :linear, one scale from the lowest to the highest value with zero where it falls. Ignored when the data does not cross zero
  • :smooth — boolean/0, interpolate between points on line charts. Default false
  • :series_names — list of names, one per series, shown in the value readout. Default [], which names them series 1, series 2, …
  • :readout_format — one-arity function turning a value into the text the readout shows. Default nil, two decimals
  • :readout_sort — :none (default), :asc or :desc, the order of series in the readout
  • :stacked — boolean/0, on :braille_area charts stack each series on the ones before it; false draws every series from the baseline, later series over earlier ones. Default true
  • :line_thickness — line width for line and area charts, in braille dots; an image scales it to the terminal's cell height. Default 1
  • :connect_lines — boolean/0, join consecutive points of a :line or :braille chart with line segments; false draws the points alone. Default true
  • :precision — decimal places in the Y-axis tick labels. Default 3. Read by mount/1 and update/2 only; the chart/2 element does not forward it
  • :style — map/0 of style properties. Default %{}
  • :class — theme class atom or list of them, reaching mount/1 as :classes. Default []
  • :renderer — rendering backend for this chart: :text (braille, or :quad blocks with pixel_style: :quadrant), :braille, :half / :quad / :sextant / :octant (block characters of that FrenchCurve.Backend.Cells set, two colours per cell), :pixel / :auto (best terminal graphics available — kitty/iTerm2/sixel image, falling back to braille), or :iterm2 / :kitty / :sixel to force a protocol. Overrides the runtime mode: config, but the DRAFTER_MODE env var still forces over it. When unset, the global mode applies: DRAFTER_MODE, then the mode: run option (Drafter.run(App, mode: :pixel) / Drafter.render_mode/1), then :text.
  • :image_scale — for :pixel charts on a terminal that has not reported its cell size, pixels drawn per cell column (rows use 2×); default 4. When the terminal reports its cell size (see :cell_size in Drafter.App), images are drawn at that size and this is unused

update/2 re-reads every option above except :width and :app_module, which are fixed once the widget is mounted.

A :pixel chart's image is drawn for the frame that shows it and written to the terminal with that frame's text, so the chart and the cells around it always change together.

Widget value

Drafter.get_widget_value/1 is not implemented for this widget and returns nil.

Usage

chart(data: [1, 4, 2, 8, 5, 9, 3], chart_type: :line)
chart(data: candles, chart_type: :candlestick, show_axes: true)
chart(data: points, chart_type: :scatter)
chart(data: [series_a, series_b], chart_type: :clustered_bar, height: 8)
chart(data: [series_a, series_b], chart_type: :stacked_bar, height: 8)
chart(data: [[lo, hi] | ...], chart_type: :range_bar, height: 8)
chart(data: io_data, chart_type: :line, min_value: -150, max_value: 150)

Summary

Functions

Replaces :data with everything the widget's data channel has buffered.

The registry tag for this widget.

Turns the {:chart, data, opts} element into a props map for mount/1.

Pans both axes as the pointer moves with a button held.

Moves the value readout cursor to the cell x, y as the pointer passes over the chart.

Pans the chart.

Ends the current drag gesture, clearing :drag_last_x and setting :dragging_scrollbar. A release that ends no drag is a click, and moves the value readout cursor to column x. Always returns {:ok, state} and consumes the event.

Whether this chart is drawing a transmitted image rather than cells.

The chart as a Drafter.Render.Picture filling the plot area of rect, or nil when the chart does not resolve to the pixel renderer or cannot be drawn.

Builds the chart state from props.

opts[:height], or 5 when it is absent.

Draws the chart into rect, padded or truncated to exactly rect.height strips.

Callback implementation for Drafter.Widget.unmount/1.

Folds fresh props into state.

Types

chart_type()

@type chart_type() ::
  :line
  | :step
  | :bar
  | :clustered_bar
  | :stacked_bar
  | :range_bar
  | :candlestick
  | :area
  | :scatter
  | :histogram
  | :heatmap
  | :bubble
  | :braille
  | :braille_area

t()

@type t() :: %Drafter.Widget.Chart{
  _internal: map(),
  animated: boolean(),
  animation_speed: pos_integer(),
  app_module: module() | nil,
  area_fill: :below | :inverted,
  bar_gap: non_neg_integer(),
  bar_labels: [String.t()],
  chart_type: chart_type(),
  classes: [atom()],
  color: {0..255, 0..255, 0..255} | nil,
  colors: [{0..255, 0..255, 0..255}],
  data: list(),
  fill_opacity: float(),
  focused: boolean(),
  height: pos_integer(),
  max_data_points: pos_integer() | nil,
  max_value: number(),
  min_value: number(),
  orientation: :vertical | :horizontal,
  pixel_style: :braille | :quadrant,
  show_axes: boolean(),
  show_baseline: boolean(),
  show_labels: boolean(),
  show_values: boolean(),
  style: map(),
  title: String.t() | nil,
  width: pos_integer() | nil,
  x_labels: [String.t()],
  y_labels: [String.t()],
  zero_center: :symmetric | :independent | :linear
}

Functions

apply_data_buffer(state, buffer, rect)

@spec apply_data_buffer(t(), Drafter.RingBuffer.t(), Drafter.Widget.rect()) :: t()

Replaces :data with everything the widget's data channel has buffered.

The buffer contents become the whole data set, they are not appended. An empty buffer leaves the state alone. The Y range is not recomputed here, so it keeps the bounds from the last mount/1 or update/2.

component_tag()

@spec component_tag() :: :chart

The registry tag for this widget.

iex> Drafter.Widget.Chart.component_tag()
:chart

focused(state)

from_component_opts(data, opts)

@spec from_component_opts(
  term(),
  keyword()
) :: Drafter.Widget.props()

Turns the {:chart, data, opts} element into a props map for mount/1.

data is used as :data when it is a list, and otherwise opts[:data] is used, which is what lets chart(chart_type: :line, data: values) work. :width and :height default to the parent rect passed as :__rect__, itself defaulting to %{width: 80, height: 20}. :class is normalised into :classes, :__app_module__ becomes :app_module, and :_render_timestamp is stamped from the monotonic clock for an :animated chart and is 0 otherwise, so a chart that is not animated keeps the same props while its data does not change.

:precision is not forwarded; pass it directly to mount/1 or update/2.

iex> props = Drafter.Widget.Chart.from_component_opts([1, 2, 3], chart_type: :bar)
iex> {props.data, props.chart_type, props.width, props.height, props.bar_gap}
{[1, 2, 3], :bar, 80, 20, 0}

iex> props = Drafter.Widget.Chart.from_component_opts(nil, data: [4, 5], height: 6)
iex> {props.data, props.height, props.fill_opacity, props.zero_center}
{[4, 5], 6, 0.6, :symmetric}

handle_drag(x, y, state)

@spec handle_drag(integer(), integer(), t()) :: {:ok, t()}

Pans both axes as the pointer moves with a button held.

The first drag event after a press only records the anchor point and leaves the offsets alone. Subsequent events move the X scroll offset by the leftward pointer delta, clamped as handle_key/2 clamps it, and the Y offset by the downward delta. Always returns {:ok, state}.

handle_event(event, state)

Callback implementation for Drafter.Widget.handle_event/2.

handle_hover(x, y, state)

@spec handle_hover(integer(), integer(), t()) :: {:ok, t()} | {:noreply, t()}

Moves the value readout cursor to the cell x, y as the pointer passes over the chart.

On :line, :braille, :step, :area, :braille_area and :stacked_area charts the point under the cursor column is read; on :bar, :clustered_bar, :stacked_bar and :range_bar charts the bar or group under it, or under the cursor row for horizontal bars. The cursor column is highlighted and a panel beside it, to the right when it fits and otherwise to the left, lists the label (the :bar_labels entry for a bar chart, else the :x_labels entry, else #index) and each series' colour, name (:series_names) and value (:readout_format; a range bar shows low – high), ordered by :readout_sort. A chart drawn as an image has the panel drawn into the image. Returns {:noreply, state} when the cursor cell is unchanged.

handle_key(arg1, state)

@spec handle_key(Drafter.Widget.key() | integer(), t()) :: {:ok, t()} | {:bubble, t()}

Pans the chart.

:left/:ArrowLeft and :right/:ArrowRight move the X scroll offset by 5 data points, clamped at 0 on the left and at one less than the longest series on the right. :up/:ArrowUp and :down/:ArrowDown move the Y offset by one step, unclamped; each step shifts the drawn value range by a tenth of its span. The codepoint ?c resets the Y offset to 0. All of those return {:ok, state}. :escape hides the value readout and bubbles; every other key returns {:bubble, state}.

iex> c = Drafter.Widget.Chart.mount(%{data: Enum.to_list(1..20)})
iex> {:ok, scrolled} = Drafter.Widget.Chart.handle_key(:right, c)
iex> scrolled._internal.scroll_offset
5

iex> c = Drafter.Widget.Chart.mount(%{data: [1, 2, 3]})
iex> {:ok, clamped} = Drafter.Widget.Chart.handle_key(:right, c)
iex> clamped._internal.scroll_offset
2

iex> c = Drafter.Widget.Chart.mount(%{data: [1, 2, 3]})
iex> {:ok, clamped} = Drafter.Widget.Chart.handle_key(:left, c)
iex> clamped._internal.scroll_offset
0

iex> c = Drafter.Widget.Chart.mount(%{data: [1, 2, 3]})
iex> {:ok, panned} = Drafter.Widget.Chart.handle_key(:up, c)
iex> {:ok, reset} = Drafter.Widget.Chart.handle_key(?c, panned)
iex> {panned._internal.y_offset, reset._internal.y_offset}
{1, 0}

iex> c = Drafter.Widget.Chart.mount(%{data: [1, 2, 3]})
iex> Drafter.Widget.Chart.handle_key(:enter, c) |> elem(0)
:bubble

handle_mouse_up(x, y, state)

@spec handle_mouse_up(integer(), integer(), t()) :: {:ok, t()}

Ends the current drag gesture, clearing :drag_last_x and setting :dragging_scrollbar. A release that ends no drag is a click, and moves the value readout cursor to column x. Always returns {:ok, state} and consumes the event.

image_active?(state)

@spec image_active?(t()) :: boolean()

Whether this chart is drawing a transmitted image rather than cells.

image_raster(state, rect)

@spec image_raster(t() | Drafter.Widget.props(), Drafter.Widget.rect()) ::
  Drafter.Render.Picture.t() | nil

The chart as a Drafter.Render.Picture filling the plot area of rect, or nil when the chart does not resolve to the pixel renderer or cannot be drawn.

The plot area is rect less the y axis column, the x axis row and the title row that render/2 draws as text. The image has the terminal's pixel size per cell when the terminal reported it (see :cell_size in Drafter.App), and carries the value readout panel while the pointer is over the chart.

mount(props)

@spec mount(Drafter.Widget.props()) :: t()

Builds the chart state from props.

Resolves the Y range from :min_value/:max_value and the data, seeds the candlestick live candle, and stores the scroll and drag state under :_internal. :height defaults to 1 here; the chart/2 element supplies the parent rect's height instead.

iex> c = Drafter.Widget.Chart.mount(%{data: [1, 2, 3], chart_type: :bar})
iex> {c.chart_type, c.data, c.height, c.bar_gap, c.show_axes}
{:bar, [1, 2, 3], 1, 0, false}

iex> c = Drafter.Widget.Chart.mount(%{data: [0, 100]})
iex> {c.min_value, c.max_value}
{-5.0, 105.0}

iex> c = Drafter.Widget.Chart.mount(%{})
iex> {c.min_value, c.max_value, c.chart_type, c.pixel_style}
{-5.0, 105.0, :line, :braille}

preferred_height(args, opts)

@spec preferred_height(
  term(),
  keyword()
) :: pos_integer()

opts[:height], or 5 when it is absent.

iex> Drafter.Widget.Chart.preferred_height([1, 2], [])
5

iex> Drafter.Widget.Chart.preferred_height([1, 2], height: 12)
12

render(state, rect)

Draws the chart into rect, padded or truncated to exactly rect.height strips.

Returns [] when rect.width is 0 or less. Accepts either a t/0 or a raw props map, which is mounted first. Every renderer draws the same picture of the chart at its own resolution: a :pixel chart emits blank strips for the plot area, because the image itself is emitted separately by the widget server; a :text or :braille chart draws braille, two by four dots per cell (:text with pixel_style: :quadrant draws :quad blocks); a cell-set chart draws that set's blocks. The y axis, the x axis line and the title row are drawn around the plot area when :show_axes and :title ask for them, labelled with the value range the chart was drawn against.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@spec update(Drafter.Widget.props(), t()) :: t()

Folds fresh props into state.

The Y range is recomputed only when the data hash changes or when props carries a numeric :min_value or :max_value; otherwise the current bounds are kept. :width and :app_module are not re-read and keep their mounted values, and :area_fill falls back to the current value on any falsy prop rather than only on a missing key. Scroll offset, drag anchors and Y pan offset survive untouched.

iex> c = Drafter.Widget.Chart.mount(%{data: [1, 2, 3]})
iex> updated = Drafter.Widget.Chart.update(%{data: [10, 20], chart_type: :bar}, c)
iex> {updated.data, updated.chart_type}
{[10, 20], :bar}

iex> c = Drafter.Widget.Chart.mount(%{data: [1, 2, 3], width: 40})
iex> Drafter.Widget.Chart.update(%{width: 99}, c).width
40

update_props_from_mount(mount_props, existing_state, opts)