Day 11 – Networks Roasting on an Open Fire, Part 2: Axes to Grind

by Geoffrey Broadwell

In part 1 of these blog posts, I roughed out a simple ping chart program that produced output like this as it ran:

                                                            o
                      o                                o  o     o
  o o      o o       o o o        o   oo                o  o o o  o o  o       o
 o o oooooo o ooooooo   o o oooooo ooo  ooooooooooooooo  o    o  o o oo ooooooo
o                          o

         ^

This is over-minimalist; a user couldn’t even tell the scale of the latency measurements. It’s not at all obvious whether this is a fast or slow connection, and whether the jitter in the results is something to worry about.

A Sense of Scale

One change that would help considerably is to show a Y axis with labels for various latency thresholds; as long as it is not overdrawn, it’s only necessary to draw this once.

Here’s a routine to do that:

#| Draw a Y axis with latency labels; returns width
#| of axis marks (to be used as a left offset for
#| the actual chart content)
sub draw-y-axis(Terminal::Print::Grid:D :$grid!,
                UInt:D :$ms-per-line!,
                UInt:D :$tick-every = 5
                --> UInt:D) {
    # Compute dimensions
    my $bottom      = $grid.h - 1;
    my $last-label  = $bottom - $bottom % $tick-every;
    my $label-width =
      2 max ($last-label * $ms-per-line).chars;
    my $x-offset    = $label-width + 1;

    # Add labels and axis line
    for ^$grid.h .reverse -> $y {
        my $is-tick = $y %% $tick-every;
        my $value   = $y * $ms-per-line;
        my $label   = $is-tick
          ?? $value.fmt("%{$label-width}d")
          !! ' ' x $label-width;
        $grid.print-string(0, $bottom - $y, "$label│");
    }

    # Show that the Y-axis scale is in milliseconds
    $grid.print-string(
      0, 0, 'ms'.fmt("%{$label-width}s") ~ '│'
    );

    # Return computed x-offset
    $x-offset
}

This draw-y-axis sub starts off by determining the required label size (which must be at least 2 because of the ms unit name printed at the top), and then prints the axis labels starting at the bottom (with a  character on each line for the axis itself). Finally it prints the ms on the first line and returns the computed x-offset for the actual chart content.

With default settings on a default size terminal window, it would look like this:

ms│
  │
  │
80│
  │
  │
  │
  │
60│
  │
  │
  │
  │
40│
  │
  │
  │
  │
20│
  │
  │
  │
  │
 0│

DRY: Good for Kindling and for Code

The above version of draw-y-axis is certainly functional, and could be used as-is in a pinch. But it also has a rather verbose interface, demonstrating a problem that would quickly spread throughout the program:

sub draw-y-axis(Terminal::Print::Grid:D :$grid!,
                UInt:D :$ms-per-line!,
                UInt:D :$tick-every = 5
                --> UInt:D) {

Here draw-y-axis is taking separate arguments for several different values, as well as returning the x-offset to be used elsewhere in the program. Each of these values would need to be plumbed through the interfaces of MAINping-chart, and update-chart too, just to make sure those values are available everywhere they are needed.

This violates the DRY principle: Don’t Repeat Yourself. It shouldn’t be necessary to copy all this information everywhere, but instead to just define it once. In fact I can head this problem off by defining a simple structure to hold any relevant configuration for the chart and passing that around rather than the individual values; then if future features require more config values, I can just add them to the structure.

If you’re thinking “Sounds like objects!” … well yes, but that’s actually a bigger hammer than I intend to use here. For this task I don’t need full object-oriented generality but rather a simple Data Record that can be treated as a unit (also known as a Passive Data Structure or Plain Old Data). Essentially, it’s a class without any methods:

#| All of the configuration and state for a ping chart
class Chart {
    has Terminal::Print::Grid:D $.grid is required;

    has UInt:D $.ms-per-line    = 4;
    has UInt:D $.tick-every     = 5;
    has UInt:D $.x-offset is rw = 0;
}

Packaging these details in a data record simplifies the interface to draw-y-axis a bit, and requires a couple changes in its code to reference those details via the record. Here’s a full rewrite for clarity:

sub draw-y-axis(Chart:D $chart) {
    my $grid        = $chart.grid;
    my $bottom      = $grid.h - 1;
    my $last-label  = $bottom - $bottom % $chart.tick-every;
    my $label-width =
      2 max ($last-label * $chart.ms-per-line).chars;
    $chart.x-offset = $label-width + 1;

    # Add labels and axis line
    for ^$grid.h .reverse -> $y {
        my $is-tick = $y %% $chart.tick-every;
        my $value   = $y * $chart.ms-per-line;
        my $label   = $is-tick
          ?? $value.fmt("%{$label-width}d")
          !! ' ' x $label-width;
        $grid.print-string(0, $bottom - $y, $label ~ '│');
    }

    # Show that the Y-axis scale is in milliseconds (ms)
    $grid.print-string(
      0, 0, 'ms'.fmt("%{$label-width}s") ~ '│'
    );
}

One-time interface changes to ping-chart and update-chart allow them to accept a Chart record rather than raw values:

sub ping-chart(Chart:D :$chart!, Str:D :$target!) {
    # unchanged until ...
              update-chart(:$chart, :$id, :$time);
    # likewise unchanged ...
}

sub update-chart(
  Chart:D :$chart!, UInt:D :$id!, Real:D :$time!
) {
    my $grid   = $chart.grid;
    # unchanged beyond this point ...
}

The Chart record of course has to be created in MAIN and passed in to draw-y-axis and ping-chart:

# Draw a ping chart on the current screen grid
my $grid  = T.current-grid;
my $chart = Chart.new(:$grid, :$ms-per-line);
draw-y-axis($chart);
ping-chart(:$chart, :$target);

Back On Track

With that refactoring done, a few simple changes in update-chart can now accommodate the x-offset computed by draw-y-axis so that the chart content doesn’t try to overwrite the Y axis and its labels:

    my $grid     = $chart.grid;
    my $x-offset = $chart.x-offset;
    my $bottom   = $grid.h - 1;
    my $right    = $grid.w - 1;
    my $width    = $grid.w - $x-offset;
    my $x        = $id % $width + $x-offset;

    # ...
    state $prev-x  = $x-offset - 1;
    # ...
        $grid.print-cell($prev-x, $bottom, ' ')
          if $prev-x >= $x-offset
          && $grid.grid[$bottom][$prev-x] eq '^';
    # ...
        $prev-x = $x-offset if ++$prev-x > $right;

Here’s what the full program’s output looks like now:

ms│
  │                               o
  │
80│
  │
  │
  │                         o
  │
60│
  │
  │
  │
  │
40│           o                                      o
  │
  │                                    o                    o         o
  │                                                                        o
  │               o                            o  o    ooo         o     o
20│o ooo         o   oooo oo        o   ooo ooo     o o   o  o   o             o
  │ o   oooo o oo  oo    o   o ooo   oo         o  o       o  ooo o oo oo o ooo
  │         o
  │
  │
 0│                      ^

Scale Adjustments

When I’m connected via direct cabling to my home ISP and it’s having a good day, most ping times are short enough to display reasonably in the chart with its default scaling. But when my ISP is having a bad day or I’m tethered via my cell phone, latency increases several times over and the chart marks no longer stay below the top of the terminal.

One quick fix for this is to add a command-line option to set the vertical scale in milliseconds per screen line, and respect that setting in update-chart. Here’s what the MAIN changes look like:

sub MAIN($target = '8.8.8.8',    #= Hostname/IP address to ping
         UInt :$ms-per-line = 4, #= Milliseconds per screen line
        ) {
    # ...
    my $chart = Chart.new(:$grid, :$ms-per-line);
    # ...
}

The change in update-chart is even simpler, just part of one line:

    my $y = $bottom - floor($time / $chart.ms-per-line);

Here’s the new USAGE:

$ ./ping-chart -?
Usage:
  ./ping-chart [--ms-per-line[=UInt]] [<target>] -- Slowly draw a chart of ping times

    [<target>]              Hostname/IP address to ping [default: '8.8.8.8']
    --ms-per-line[=UInt]    Milliseconds per screen line [default: 4]

And here it is in action:

$ ./ping-chart --ms-per-line=20


 ms│
   │
   │
400│
   │
   │
   │
   │
300│
   │
   │
   │
   │
200│
   │
   │
   │      o o
   │o                                                                          o
100│       o                             o   o                                o
   │                                       o   o o                           o
   │ o o o                                                                  o
   │  o o    o   o
   │          ooo                    o oo o         o  ooooooooooooooooooooo
  0│             ^ooooooooooooooooooo o     o o o oo oo

Note how the vertical scale has changed, and the Y axis labels and x-offset have automatically compensated. The low ping times are when connected via the local ISP (it’s doing OK tonight). The much higher and spread out values are when I switched to cell phone tethering; many would have been invisible without the rescaling.

For Next Time

The changes in this part have improved both functionality and maintainability, but haven’t addressed the chart’s low information density (or its overall lack of “cool”). I’ll pick that up in part 3.

Appendix: (Probably) Frequently Asked Question

Why not switch entirely to OO design, using proper methods?

I wanted to demonstrate how the (really old school) Data Record concept can be used to do just enough refactoring to permit some improved maintainability and DRY compliance without forcing a full code rewrite. This complexity threshold is very commonly reached when developing something that started as a small proof of concept, but need not cause a daunting — and thus probably never actually accomplished — full OO rewrite.

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.