Selkie.git | lib/Selkie/Widget/ | Histogram.rakumod


=begin pod

=head1 NAME

Selkie::Widget::Histogram - Bin a numeric series and render it as a BarChart

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Widget::Histogram;
use Selkie::Sizing;

# Bin 1000 random samples into 10 bins
my @samples = (1..1000).map: { rand * 100 };
my $h = Selkie::Widget::Histogram.new(
    values => @samples,
    bins   => 10,
    sizing => Sizing.flex,
);

# Custom bin edges instead of equal-width bins
my $edges = Selkie::Widget::Histogram.new(
    values    => @latencies-ms,
    bin-edges => [0, 10, 50, 100, 500, 1000, 5000],
    sizing    => Sizing.flex,
);

=end code

=head1 DESCRIPTION

A histogram is a categorical view of a numeric distribution. This
widget bins a list of numeric values into intervals and delegates
rendering to L<Selkie::Widget::BarChart>. Each bin becomes a bar
labelled by its lower edge.

=head2 Bin convention

Intervals are B<left-closed, right-open>, with the final bin
B<closed-closed> so the maximum sample is always counted. For bin
edges C<[0, 10, 20, 30]>:

=item Bin 1: C<[0, 10)> — values 0 ≤ v < 10
=item Bin 2: C<[10, 20)>
=item Bin 3: C<[20, 30]> — values 20 ≤ v ≤ 30 (inclusive)

This matches numpy / R / matplotlib defaults.

=head2 Modes

Two ways to specify the bins:

=item B<Equal-width> — pass C<:bins(N)>. The widget computes N equal-width bins spanning C<[min, max]> of the data.
=item B<Explicit edges> — pass C<:bin-edges([...])>. The widget uses your edges directly. C<edges.elems> = bin count + 1.

The two are mutually exclusive. C<:bins> is convenient; C<:bin-edges>
is for non-uniform binning (log-scale latency, age brackets, etc.).

=head1 EXAMPLES

=head2 Distribution of request latencies

=begin code :lang<raku>

my @latencies = $request-log.map: *.<duration-ms>;
my $h = Selkie::Widget::Histogram.new(
    values => @latencies,
    bins   => 20,
    sizing => Sizing.flex,
);

=end code

=head2 Non-uniform bins for skewed data

Latencies cluster near zero with a long tail. Equal bins waste most
of the chart on near-zero values. Custom edges let you focus on the
distribution where it matters:

=begin code :lang<raku>

my $h = Selkie::Widget::Histogram.new(
    values    => @latencies,
    bin-edges => [0, 5, 10, 25, 50, 100, 250, 500, 1000, 5000],
    sizing    => Sizing.flex,
);

=end code

=head2 Reactive — auto-rebin when the source data changes

=begin code :lang<raku>

# Histogram doesn't bind to a store directly; instead, subscribe in
# app code and call set-values when the source updates.
$store.subscribe-with-callback(
    'latencies-hist',
    -> $s { $s.get-in('metrics', 'latency-samples') // [] },
    -> @samples { $hist.set-values(@samples) },
    $hist,
);

=end code

=head1 SEE ALSO

=item L<Selkie::Widget::BarChart> — the bar renderer this delegates to
=item L<Selkie::Plot::Ticks> — for picking nice bin edges manually

=end pod

use Selkie::Widget::BarChart;

unit class Selkie::Widget::Histogram is Selkie::Widget::BarChart;

#| Numeric values to bin.
has Real @.values;

#| Equal-width bin count. Mutually exclusive with C<bin-edges>.
has UInt $.bins = 0;

#| Explicit bin edges, ascending. C<bin-edges.elems> = bin count + 1.
#| Mutually exclusive with C<bins>.
has Real @.bin-edges;

submethod TWEAK {
    die "Selkie::Widget::Histogram: pass exactly one of :bins or :bin-edges"
        if ($!bins > 0 && @!bin-edges.elems > 0)
        || ($!bins == 0 && @!bin-edges.elems == 0);
    die "Selkie::Widget::Histogram: bin-edges must be ascending"
        if @!bin-edges.elems > 0
            && @!bin-edges.list ne @!bin-edges.sort.list;

    self!rebin;
}

#|( Replace the value list and re-bin. The chart re-renders
    automatically. )
method set-values(@new) {
    @!values = @new;
    self!rebin;
    self.mark-dirty;
}

method !rebin() {
    my @data;
    if $!bins > 0 {
        @data = self!bin-equal-width;
    } else {
        @data = self!bin-edges;
    }
    # Cast through the parent's data attribute. We use direct attribute
    # access because BarChart's set-data has a guard against
    # store-path mode that doesn't apply here.
    nextwith(:@data) if False;          # silence "no proto" — never runs
    # The right way: assign to the inherited @.data attribute.
    self.set-data(@data);
}

method !bin-equal-width(--> List) {
    return () if @!values.elems == 0;
    my @clean = @!values.grep({ .defined && $_ !=== NaN });
    return () if @clean.elems == 0;

    my $lo = @clean.min;
    my $hi = @clean.max;
    if $lo == $hi {
        # All samples identical — single bin
        return (
            { label => $lo.fmt('%g'), value => @clean.elems },
        ).list;
    }

    my $width = ($hi - $lo) / $!bins;
    my @counts = 0 xx $!bins;
    for @clean -> $v {
        my $idx = (($v - $lo) / $width).Int;
        $idx = $!bins - 1 if $idx >= $!bins;     # max value falls into last bin
        $idx = 0          if $idx < 0;
        @counts[$idx]++;
    }

    my @data;
    for ^$!bins -> $i {
        my $edge-lo = $lo + $i * $width;
        @data.push: {
            label => $edge-lo.fmt('%g'),
            value => @counts[$i],
        };
    }
    @data;
}

method !bin-edges(--> List) {
    return () if @!values.elems == 0 || @!bin-edges.elems < 2;
    my @clean = @!values.grep({ .defined && $_ !=== NaN });
    return () if @clean.elems == 0;

    my $bins = @!bin-edges.elems - 1;
    my @counts = 0 xx $bins;

    for @clean -> $v {
        # Left-closed / right-open, except the last bin which is
        # closed-closed (so the max value is always counted).
        my $found = False;
        for ^$bins -> $i {
            my $edge-lo = @!bin-edges[$i];
            my $edge-hi = @!bin-edges[$i + 1];
            my $in-bin = $i == $bins - 1
                ?? ($v >= $edge-lo && $v <= $edge-hi)
                !! ($v >= $edge-lo && $v <  $edge-hi);
            if $in-bin {
                @counts[$i]++;
                $found = True;
                last;
            }
        }
        # Values outside [first-edge, last-edge] are dropped silently
        # — same as numpy.histogram default behaviour.
    }

    my @data;
    for ^$bins -> $i {
        @data.push: {
            label => @!bin-edges[$i].fmt('%g'),
            value => @counts[$i],
        };
    }
    @data;
}