Selkie.git | lib/Selkie/Widget/ | Axis.rakumod
=begin pod
=head1 NAME
Selkie::Widget::Axis - Labelled tick axis for chart widgets
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::Axis;
use Selkie::Sizing;
# A bottom axis covering [0, 100] with five ticks.
my $axis = Selkie::Widget::Axis.new(
edge => 'bottom',
min => 0,
max => 100,
tick-count => 5,
sizing => Sizing.fixed(2), # 1 row line + 1 row labels
);
# A left axis for a y-axis; reserves 5 columns by default for labels.
my $left = Selkie::Widget::Axis.new(
edge => 'left',
min => 0,
max => 1.0,
tick-count => 6,
sizing => Sizing.fixed(6),
);
=end code
=head1 DESCRIPTION
Renders a labelled axis along one of the four edges of its plane:
C<top>, C<bottom>, C<left>, or C<right>. The axis is the visual
companion to chart widgets — a horizontal axis sits below a chart
body, a vertical axis sits to its left or right.
Internally the axis builds its own L<Selkie::Plot::Scaler> and
L<Selkie::Plot::Ticks> matched to its current plane dimensions. So
when a chart widget composes an Axis, it just passes the axis's
data range (min, max) and tick count — the axis figures out its own
cell mapping.
Y-axes (C<left>, C<right>) automatically use C<:invert> so the
maximum value sits at the top of the plane (terminal row 0 is the
I<top> of the screen, which by chart convention should hold the
largest value).
=head2 Glyphs
The four edges use these box-drawing glyphs:
=table
Edge | Line | Tick | Label position
bottom | ─ | ┬ | row below the line, centred on the tick column
top | ─ | ┴ | row above the line, centred on the tick column
left | │ | ┤ | columns to the left of the line, right-aligned to the tick row
right | │ | ├ | columns to the right of the line, left-aligned to the tick row
All glyphs render in the C<graph-axis> theme slot; labels render in
C<graph-axis-label>. Override either per-theme or via custom slots
to restyle.
=head2 Sizing
=item B<Top / bottom> axes need 2 rows: one for the line and one for labels. C<reserved-rows> returns 2.
=item B<Left / right> axes need C<widest-label + 1> columns: the labels plus the line. Width depends on the data range — call C<reserved-cols> to get the actual budget.
Use these helpers when sizing parent containers so the axis gets
exactly the rows / columns it needs:
=begin code :lang<raku>
my $axis = Selkie::Widget::Axis.new(edge => 'left', min => 0, max => 1000);
$container.add: $axis, sizing => Sizing.fixed($axis.reserved-cols);
=end code
=head1 EXAMPLES
=head2 A standalone bottom axis
=begin code :lang<raku>
use Selkie::Widget::Axis;
use Selkie::Sizing;
my $axis = Selkie::Widget::Axis.new(
edge => 'bottom',
min => 0,
max => 1000,
tick-count => 5,
sizing => Sizing.fixed(2),
);
# Drop into a VBox above other content, or into a chart widget that
# delegates the bottom strip to it.
=end code
=head2 Composed inside a chart layout
A chart usually composes a bottom axis below the body and a left axis
to its left:
=begin code :lang<raku>
use Selkie::Widget::Axis;
use Selkie::Layout::VBox;
use Selkie::Layout::HBox;
my $left = Selkie::Widget::Axis.new(edge => 'left', min => 0, max => 100);
my $bottom = Selkie::Widget::Axis.new(edge => 'bottom', min => 0, max => 60);
my $body = my-chart-body(); # a LineChart, BarChart, etc.
my $row = Selkie::Layout::HBox.new;
$row.add: $left, sizing => Sizing.fixed($left.reserved-cols);
$row.add: $body, sizing => Sizing.flex;
my $stack = Selkie::Layout::VBox.new;
$stack.add: $row, sizing => Sizing.flex;
$stack.add: $bottom, sizing => Sizing.fixed($bottom.reserved-rows);
=end code
=head1 SEE ALSO
=item L<Selkie::Plot::Scaler> — the value→cell mapping the axis uses
=item L<Selkie::Plot::Ticks> — the nice-number tick generation
=item L<Selkie::Widget::LineChart>, L<Selkie::Widget::BarChart>, L<Selkie::Widget::ScatterPlot>, L<Selkie::Widget::Histogram>, L<Selkie::Widget::Heatmap> — chart widgets that compose this axis
=end pod
use Notcurses::Native;
use Notcurses::Native::Plane;
use Selkie::Widget;
use Selkie::Plot::Scaler;
use Selkie::Plot::Ticks;
unit class Selkie::Widget::Axis does Selkie::Widget;
#| Which edge to render on: C<top>, C<bottom>, C<left>, or C<right>.
has Str $.edge = 'bottom';
#| Lower bound of the axis range.
has Real $.min is required;
#| Upper bound of the axis range.
has Real $.max is required;
#| Approximate tick count; the actual count depends on Heckbert's
#| nice-number choice (see L<Selkie::Plot::Ticks>).
has UInt $.tick-count = 5;
#| Whether to draw the connecting axis line. Disable when stacking
#| multiple axes on the same edge or when the chart body provides its
#| own border.
has Bool $.show-line = True;
submethod TWEAK {
die "Selkie::Widget::Axis: edge must be one of top|bottom|left|right (got '$!edge')"
unless $!edge eq 'top'|'bottom'|'left'|'right';
die "Selkie::Widget::Axis: min ($!min) must be <= max ($!max)"
if $!min > $!max;
}
#|( Number of rows this axis needs to render properly. Returns 2 for
horizontal axes (line + labels), 0 for vertical (caller decides
height). Use to size the axis's container correctly. )
method reserved-rows(--> UInt) {
$!edge eq 'top'|'bottom' ?? 2 !! 0;
}
#|( Number of columns this axis needs to render properly. For vertical
axes, returns the widest tick label's width plus one (for the
axis line). For horizontal axes, returns 0 (caller decides width). )
method reserved-cols(--> UInt) {
return 0 unless $!edge eq 'left'|'right';
my $ticks = self!build-ticks;
my $widest = $ticks.labels.map(*.chars).max // 0;
$widest + 1;
}
method render() {
return without self.plane;
ncplane_erase(self.plane);
my $axis-style = self.theme.graph-axis;
my $label-style = self.theme.graph-axis-label;
given $!edge {
when 'bottom' { self!render-bottom($axis-style, $label-style) }
when 'top' { self!render-top($axis-style, $label-style) }
when 'left' { self!render-left($axis-style, $label-style) }
when 'right' { self!render-right($axis-style, $label-style) }
}
self.clear-dirty;
}
method !build-ticks(--> Selkie::Plot::Ticks) {
Selkie::Plot::Ticks.nice(
min => $!min, max => $!max, count => $!tick-count,
);
}
method !build-scaler(UInt $cells, Bool :$invert = False --> Selkie::Plot::Scaler) {
# Degenerate case — caller's responsibility to avoid 0-cell planes,
# but we don't crash if it happens. Return an arbitrary 1-cell
# scaler since nothing will render anyway.
my $safe-cells = max($cells, 1);
Selkie::Plot::Scaler.linear(
min => $!min, max => $!max, cells => $safe-cells, :$invert,
);
}
method !render-bottom($axis-style, $label-style) {
return if self.cols == 0 || self.rows == 0;
my $scaler = self!build-scaler(self.cols);
my $ticks = self!build-ticks;
self.apply-style($axis-style);
if $!show-line {
ncplane_putstr_yx(self.plane, 0, 0, '─' x self.cols);
}
for $ticks.values -> $v {
my $col = $scaler.value-to-cell($v);
next without $col.defined;
ncplane_putstr_yx(self.plane, 0, $col, '┬') if $!show-line;
}
return if self.rows < 2;
self.apply-style($label-style);
for $ticks.values.kv -> $i, $v {
my $col = $scaler.value-to-cell($v);
next without $col.defined;
self!draw-horizontal-label(1, $col, $ticks.labels[$i]);
}
}
method !render-top($axis-style, $label-style) {
return if self.cols == 0 || self.rows == 0;
my $scaler = self!build-scaler(self.cols);
my $ticks = self!build-ticks;
# Line is on the LAST row; labels are above (row 0 ... row last-1).
my $line-row = self.rows - 1;
self.apply-style($axis-style);
if $!show-line {
ncplane_putstr_yx(self.plane, $line-row, 0, '─' x self.cols);
}
for $ticks.values -> $v {
my $col = $scaler.value-to-cell($v);
next without $col.defined;
ncplane_putstr_yx(self.plane, $line-row, $col, '┴') if $!show-line;
}
return if self.rows < 2;
self.apply-style($label-style);
for $ticks.values.kv -> $i, $v {
my $col = $scaler.value-to-cell($v);
next without $col.defined;
self!draw-horizontal-label($line-row - 1, $col, $ticks.labels[$i]);
}
}
method !render-left($axis-style, $label-style) {
return if self.cols == 0 || self.rows == 0;
# Y-axes invert: the maximum value sits at row 0 (top of screen).
my $scaler = self!build-scaler(self.rows, :invert);
my $ticks = self!build-ticks;
my $line-col = self.cols - 1;
self.apply-style($axis-style);
if $!show-line {
for ^self.rows -> $r {
ncplane_putstr_yx(self.plane, $r, $line-col, '│');
}
}
for $ticks.values -> $v {
my $row = $scaler.value-to-cell($v);
next without $row.defined;
ncplane_putstr_yx(self.plane, $row, $line-col, '┤') if $!show-line;
}
# Labels right-aligned in the columns to the left of the line.
return if $line-col == 0;
self.apply-style($label-style);
for $ticks.values.kv -> $i, $v {
my $row = $scaler.value-to-cell($v);
next without $row.defined;
my $text = $ticks.labels[$i];
my $start = $line-col - $text.chars;
$start = 0 if $start < 0;
ncplane_putstr_yx(self.plane, $row, $start, $text.substr(0, $line-col));
}
}
method !render-right($axis-style, $label-style) {
return if self.cols == 0 || self.rows == 0;
my $scaler = self!build-scaler(self.rows, :invert);
my $ticks = self!build-ticks;
my $line-col = 0;
self.apply-style($axis-style);
if $!show-line {
for ^self.rows -> $r {
ncplane_putstr_yx(self.plane, $r, $line-col, '│');
}
}
for $ticks.values -> $v {
my $row = $scaler.value-to-cell($v);
next without $row.defined;
ncplane_putstr_yx(self.plane, $row, $line-col, '├') if $!show-line;
}
# Labels left-aligned in the columns to the right of the line.
return if self.cols < 2;
self.apply-style($label-style);
for $ticks.values.kv -> $i, $v {
my $row = $scaler.value-to-cell($v);
next without $row.defined;
my $text = $ticks.labels[$i];
my $available = self.cols - 1;
ncplane_putstr_yx(self.plane, $row, 1, $text.substr(0, $available));
}
}
# Center a label under/over a tick column, clamping to the plane width
# and avoiding negative starts when the label is wider than the
# remaining cells.
method !draw-horizontal-label(UInt $row, UInt $tick-col, Str $text) {
my $half = $text.chars div 2;
my Int $start = $tick-col.Int - $half;
$start = 0 if $start < 0;
my $max-start = self.cols.Int - $text.chars.Int;
$start = $max-start if $start > $max-start;
$start = 0 if $start < 0;
my $clipped = $text.substr(0, self.cols - $start);
ncplane_putstr_yx(self.plane, $row, $start, $clipped);
}