Selkie.git | examples/ | charts.raku


#!/usr/bin/env raku
#
# charts.raku — Showcase of all seven Selkie chart widgets.
#
# Demonstrates, from top to bottom:
#   - Sparkline bound reactively to a store path; the widget
#     re-renders automatically when the path's value changes.
#   - LineChart updated from a store subscription (the widget
#     accepts static :series, so we call set-series from the
#     subscription callback).
#   - BarChart + Histogram + Heatmap: three kinds of categorical
#     / distribution views side-by-side. Static for this demo.
#   - Plot + ScatterPlot: streaming native plot next to a braille
#     scatter.
#
# The reactive pattern: every few frames we dispatch `metrics/tick`,
# which shifts the sample buffers in the store. Subscribers
# (Sparkline, LineChart) re-render off the new values — no
# imperative .set-anything calls from the frame callback.
#
# Run with:  raku -I lib examples/charts.raku
# Quit with: Ctrl+Q

use Selkie::App;
use Selkie::Layout::VBox;
use Selkie::Layout::HBox;
use Selkie::Widget::Text;
use Selkie::Widget::Border;
use Selkie::Sizing;
use Selkie::Style;

use Selkie::Widget::Sparkline;
use Selkie::Widget::Plot;
use Selkie::Widget::BarChart;
use Selkie::Widget::Histogram;
use Selkie::Widget::Heatmap;
use Selkie::Widget::ScatterPlot;
use Selkie::Widget::LineChart;

my $app = Selkie::App.new;

# --- Store handlers --------------------------------------------------------
#
# Three rolling sample buffers (60 cells each) — live load %, p50
# latency, p99 latency. Each tick shifts the oldest sample off and
# appends a new one. This is exactly the pattern a monitoring app
# uses: the store owns the data, the widgets just render what's
# there.

constant WINDOW-SIZE = 60;

sub seed-samples(&fn --> List) {
    (^WINDOW-SIZE).map({ fn($_) }).list;
}

$app.store.register-handler('metrics/init', -> $st, %ev {
    # Same p50/p99 shape as the tick handler so the visual
    # invariant (p99 >= p50) holds from frame 0.
    my @p50 = (^WINDOW-SIZE).map({ (sin($_ * 0.2 + 1) * 20 + 30).Int });
    my @p99 = (^WINDOW-SIZE).map({
        my $gap = (cos($_ * 0.11) * 15 + 25).Int.abs;
        (@p50[$_] + $gap) min 95;
    });
    (db => {
        tick => 0,
        load => seed-samples(-> $i { (sin($i * 0.3) * 30 + 50).Int }),
        p50  => @p50,
        p99  => @p99,
    },);
});

$app.store.register-handler('metrics/tick', -> $st, %ev {
    my $t = ($st.get-in('tick') // 0) + 1;

    # Synthetic live samples. p99 must stay at or above p50 by
    # definition (99th percentile can never be below the median),
    # so we derive p99 as p50 + a non-negative gap rather than
    # generating them independently.
    my $load = (sin($t * 0.3) * 30 + 50).Int;
    my $p50  = (sin($t * 0.2 + 1) * 20 + 30).Int;        # 10..50
    my $gap  = (cos($t * 0.11) * 15 + 25).Int.abs;       # 10..40
    my $p99  = ($p50 + $gap) min 95;                     # clamp to y-axis

    sub shift-append(@buf, Int $v --> List) {
        (|@buf.tail(WINDOW-SIZE - 1), $v).list;
    }

    (db => {
        tick => $t,
        load => shift-append($st.get-in('load') // [], $load),
        p50  => shift-append($st.get-in('p50')  // [], $p50),
        p99  => shift-append($st.get-in('p99')  // [], $p99),
    },);
});

# --- Layout scaffolding ----------------------------------------------------

my $root = Selkie::Layout::VBox.new(sizing => Sizing.flex);
$app.add-screen('main', $root);
$app.switch-screen('main');

$root.add: Selkie::Widget::Text.new(
    text   => '  Selkie charts showcase  —  Ctrl+Q quits',
    sizing => Sizing.fixed(1),
    style  => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
);

# Row 1 — Sparkline bound to the 'load' store path. The widget
# subscribes to the path itself via on-store-attached, so we just
# hand it the path and it re-renders when the value changes.
#
# Note: .map: { ... } colon-call form swallows trailing named args
# (Raku parses `bins => 12` as a .map argument, not a constructor
# one). We use explicit parens on every .map in constructor arg
# lists below for that reason.
my $sparkline = Selkie::Widget::Sparkline.new(
    store-path => <load>,
    min        => 0,
    max        => 100,
    sizing     => Sizing.flex,
);
my $spark-row = Selkie::Layout::HBox.new(sizing => Sizing.fixed(1));
$spark-row.add: Selkie::Widget::Text.new(
    text   => 'load: ',
    sizing => Sizing.fixed(6),
    style  => Selkie::Style.new(fg => 0x808080),
);
$spark-row.add: $sparkline;
$root.add($spark-row);

# Row 2 — LineChart. Static :series mode; we update via set-series
# from a store subscription callback. This matches the idiom used
# in examples/dashboard.raku — works without LineChart needing to
# know what store paths to subscribe to.
my $line-chart = Selkie::Widget::LineChart.new(
    series => [
        { label => 'p50', values => [], color => 0x4477AA },
        { label => 'p99', values => [], color => 0xEE6677 },
    ],
    y-min      => 0,
    y-max      => 100,
    fill-below => False,
    sizing     => Sizing.flex,
);
my $line-border = Selkie::Widget::Border.new(
    title  => 'LineChart — latency percentiles (live)',
    sizing => Sizing.flex,
);
$line-border.set-content($line-chart);
$root.add($line-border);

# Row 3 — BarChart | Histogram | Heatmap (all static)
my $mid-row = Selkie::Layout::HBox.new(sizing => Sizing.fixed(14));

my $bar-chart = Selkie::Widget::BarChart.new(
    data => [
        { label => 'Q1', value =>  50 },
        { label => 'Q2', value =>  80 },
        { label => 'Q3', value =>  65 },
        { label => 'Q4', value => 100 },
    ],
    sizing => Sizing.flex,
);
my $bar-border = Selkie::Widget::Border.new(
    title  => 'BarChart — quarterly sales',
    sizing => Sizing.flex,
);
$bar-border.set-content($bar-chart);
$mid-row.add($bar-border);

my $histogram = Selkie::Widget::Histogram.new(
    values => (^1000).map({ 5 + 5 * sqrt(-2 * log(rand)) * cos(2 * pi * rand) }),
    bins   => 12,
    sizing => Sizing.flex,
);
my $hist-border = Selkie::Widget::Border.new(
    title  => 'Histogram — gaussian samples',
    sizing => Sizing.flex,
);
$hist-border.set-content($histogram);
$mid-row.add($hist-border);

# 8x16 heatmap of sin(r)+cos(c)
my @heatmap-data = (^8).map(-> $r {
    (^16).map(-> $c {
        sin($r * 0.5) + cos($c * 0.4);
    });
});
my $heatmap = Selkie::Widget::Heatmap.new(
    data   => @heatmap-data,
    ramp   => 'viridis',
    sizing => Sizing.flex,
);
my $heat-border = Selkie::Widget::Border.new(
    title  => 'Heatmap — sin(r) + cos(c)',
    sizing => Sizing.flex,
);
$heat-border.set-content($heatmap);
$mid-row.add($heat-border);

$root.add($mid-row);

# Row 4 — Plot | ScatterPlot
my $bot-row = Selkie::Layout::HBox.new(sizing => Sizing.flex);

my $plot = Selkie::Widget::Plot.new(
    type   => 'uint',
    min-y  => 0,
    max-y  => 100,
    title  => 'cpu%',
    sizing => Sizing.flex,
);
my $plot-border = Selkie::Widget::Border.new(
    title  => 'Plot — streaming ncuplot',
    sizing => Sizing.flex,
);
$plot-border.set-content($plot);
$bot-row.add($plot-border);

my @scatter-points;
for ^200 {
    # Two clusters: one around (25, 25), one around (75, 75)
    my $cluster = rand < 0.5;
    my $cx = $cluster ?? 25 !! 75;
    my $cy = $cluster ?? 25 !! 75;
    my $x = $cx + (rand * 20 - 10);
    my $y = $cy + (rand * 20 - 10);
    @scatter-points.push: $x => $y;
}
my $scatter = Selkie::Widget::ScatterPlot.new(
    series => [{ label => 'clusters', points => @scatter-points }],
    x-min  => 0, x-max => 100,
    y-min  => 0, y-max => 100,
    sizing => Sizing.flex,
);
my $scatter-border = Selkie::Widget::Border.new(
    title  => 'ScatterPlot — two clusters',
    sizing => Sizing.flex,
);
$scatter-border.set-content($scatter);
$bot-row.add($scatter-border);

$root.add($bot-row);

# --- Subscriptions ---------------------------------------------------------

# LineChart: rebuild its series from the two latency paths on every
# store change. The callback fires whenever either path's value
# differs from last time, which causes the widget to mark-dirty and
# re-render on the next frame.
$app.store.subscribe-with-callback(
    'latency-series',
    -> $s {
        [
            $s.get-in('p50') // [],
            $s.get-in('p99') // [],
        ]
    },
    -> @paths {
        $line-chart.set-series([
            { label => 'p50', values => @paths[0], color => 0x4477AA },
            { label => 'p99', values => @paths[1], color => 0xEE6677 },
        ]);
    },
    $line-chart,
);

# --- Frame loop -----------------------------------------------------------
# Every 4 frames (~15Hz at 60fps):
#   - dispatch metrics/tick, which the store handler uses to shift
#     the load/p50/p99 buffers. Sparkline auto-subscribes to the
#     load path and re-renders; LineChart is rewired by the
#     subscribe-with-callback above.
#   - push one sample onto the native Plot (independent of the
#     store — Plot has its own native buffer).

my UInt $frame = 0;
$app.on-frame: {
    $frame++;
    if $frame %% 4 {
        $app.store.dispatch('metrics/tick');

        my $x = $frame div 4;
        my $y = (sin($x * 0.15) * 40 + 50).Int;
        $plot.push-sample($x, $y);
    }
};

# --- Keybinds --------------------------------------------------------------

$app.on-key('ctrl+q', -> $ { $app.quit });

# --- Boot ------------------------------------------------------------------

$app.store.dispatch('metrics/init');
$app.store.tick;

$app.run;