Mo' Wiki
/hunter/charts/weather/temperature forecast

Charting Weather with Chart::Clicker

The following code will scrape and chart the high and low temperature forecast using Chart::Clicker and friends.

Table of Contents

Load the Prerequisite Modules

#!/usr/bin/env perl
use strict;
use warnings;
use Chart::Clicker;
use Chart::Clicker::Data::Range;
use Chart::Clicker::Data::Series;
use Chart::Clicker::Data::DataSet;
use Chart::Clicker::Drawing::ColorAllocator;
use Geometry::Primitive::Circle;
use Graphics::Primitive::Font;
use Graphics::Color::RGB;
use Number::Format;
use WWW::Mechanize;
use HTML::TreeBuilder;
use List::Util qw/ max min /;

Get Chart Data

Input and Output

Here we define the URL from which we scrape the temperature forecast data, and the file we will write the chart to.

my $data_url   = 'http://www.wunderground.com/US/MA/Boston.html';
my $chart_file = 'temperature-forecast.png';

Data and Axis Values

Here we scrape the forecast data from the source URL, and then use it to compute some of the chart parameters such as range and tick location.

my ( $lows$highs )            = get_high_low_data($data_url);
my ( $min_range$max_range )   = compute_range( $lows$highs );
my $range_ticks                 = range_ticks( $min_range$max_range );
( $min_range$max_range )      = pad_range( $min_range$max_range );

# Set the domain and range
my $domain = Chart::Clicker::Data::Range->new(
    {
        lower => 0.75,
        upper => 5.25
    }
);
my $range = Chart::Clicker::Data::Range->new(
    {
        lower => $min_range,
        upper => $max_range,
    }
);

Initialize the Chart Object

Create the chart canvas with png format

my $report_format = 'png';
my $chart         = Chart::Clicker->new(
    width  => 240,
    height => 160,
    format => $report_format
);

Set Some Chart Parameters

Title

Set title text and font size

$chart->title->text('Temperature Forecast');
$chart->title->font->size(12);

Maximize Data to Ink

Do what Tufte would do and get rid of chart phluff1.

$chart->grid_over(1);
$chart->plot->grid->show_range(0);
$chart->plot->grid->show_domain(0);
$chart->legend->visible(0);
$chart->border->width(0);

Customize Font and Number Format

Tick Font

Set the font of the tick numbers

my $tick_font = Graphics::Primitive::Font->new(
    {
        family          => 'Trebuchet',
        size            => 11,
        antialias_mode  => 'subpixel',
        hint_style      => 'medium',

    }
);

Number Format

Control the format of numbers.

my $number_formatter = Number::Format->new;

Customize Context

Chart objects have a context the we can get at and manipulate. Notice that we use some of the previously obtained values here such as:

  • $domain - a Chart::Clicker::Data::Range object
  • $range - a Chart::Clicker::Data::Range object
  • $range_ticks - an ArrayRef of Ints
  • $tick_font - a Graphics::Primitive::Font object
my $default_ctx = $chart->get_context('default');
$default_ctx->domain_axis->tick_values( [qw(1 2 3 4 5)] );
$default_ctx->range_axis->tick_values($range_ticks);
$default_ctx->domain_axis->range($domain);
$default_ctx->range_axis->range($range);
$default_ctx->domain_axis->formatsub return $number_formatter->format_number(shift); } );
$default_ctx->domain_axis->tick_font($tick_font);
$default_ctx->range_axis->tick_font($tick_font);
$default_ctx->range_axis->formatsub return $number_formatter->format_number(shift); } );
$default_ctx->rendererChart::Clicker::Renderer::Line->new );
$default_ctx->renderer->shapeGeometry::Primitive::Circle->new( { radius => 3, } ) );
$default_ctx->renderer->brush->width(1);

Build the Data Series

Ultimately we want to plot some data. The data comes in the form of a series which takes an ArrayRef for both the keys and values which correspond to x and y values.

High and Low Temperatures

my $high_series = Chart::Clicker::Data::Series->new(
    keys   => [ 123, 4, 5 ],
    values => $highs,
);
my $low_series = Chart::Clicker::Data::Series->new(
    keys   => [ 123, 4, 5 ],
    values => $lows,
);

Reference Lines

I like to see where freezing and zero wrt the temperature forecast so I create a series for those two reference lines.

my $freezing_line = Chart::Clicker::Data::Series->new(
    keys => [ 123, 4, 5 ],
    values => [ (32) x 5 ],
);
my $zero_line = Chart::Clicker::Data::Series->new(
    keys => [ 123, 4, 5 ],
    values => [ (0) x 5 ],
);

Initialize some colors

We want a red line for high temps and a blue line for low temps. Light blue is used for the reference lines at 0 and 32.

my $red = Graphics::Color::RGB->new(
    {
        red   => .75,
        green => 0,
        blue  => 0,
        alpha => .8
    }
);
my $blue = Graphics::Color::RGB->new(
    {
        red   => 0,
        green => 0,
        blue  => .75,
        alpha => .8
    }
);
my $light_blue = Graphics::Color::RGB->new(
    {
        red   => 0,
        green => 0,
        blue  => .95,
        alpha => .16
    }
);

Construct the Dataset Object and Add to Chart

We want to use a dataset object to which we can add each data series and color one at a time. Then we add these to the chart object as well as the colors desired for each dataset.

# Build a new dataset object to which we add series of data.
my $dataset = Chart::Clicker::Data::DataSet->new;

# Build the color allocator
my $color_allocator = Chart::Clicker::Drawing::ColorAllocator->new;
$dataset->add_to_series($high_series);
$color_allocator->add_to_colors($red);
$dataset->add_to_series($low_series);
$color_allocator->add_to_colors($blue);

# Add freezing line when appropriate.
if ( $min_range <= 32 ) {
    $dataset->add_to_series($freezing_line);
    $color_allocator->add_to_colors($light_blue);
}

# Add zero line when appropriate.
if ( $min_range <= 0 ) {
    $dataset->add_to_series($zero_line);
    $color_allocator->add_to_colors($light_blue);
}

# add the dataset to the chart
$chart->add_to_datasets($dataset);

# assign the color allocator to the chart
$chart->color_allocator($color_allocator);

Output the Chart

Once we have added our datasets and colors then we can call the write_output method to get a chart in the png format written to the disk.

$chart->write_output$temperature_forecast_file );

Subroutines

The following subroutines are used to gather the data and customize the axis.

get_high_low_data

Get the high and low temperature forecast numbers

sub get_high_low_data {
    my $forecast_data_url = shift;

    my ( @lows@highs );

    my %numbers;
    for my $i ( -40 .. 120 ) {
        $numbers{$i} = $i;
    }
    my $mech = WWW::Mechanize->new();
    $mech->get($forecast_data_url);
    die $mech->response->status_line unless $mech->success;
    my $content = $mech->{content};

    my $page_tree = HTML::TreeBuilder->new_from_content( $mech->{content} );
    my @high_temps = $page_tree->look_down'_tag''span''style''color: #900;' );
    foreach my $high (@high_temps) {
        my $high_text = $high->as_trimmed_text;
        if ( my ($high_temp) = $high_text =~ m{(-?\d+).*F$} ) {
            push @highs$numbers{$high_temp};
        }
    }
    my @low_temps = $page_tree->look_down'_tag''span''style''color: #009;' );
    foreach my $low (@low_temps) {
        my $low_text = $low->as_trimmed_text;
        if ( my ($low_temp) = $low_text =~ m{(-?\d+).*F$} ) {
            push @lows$numbers{$low_temp};
        }
    }
    return ( \@lows, \@highs );
}

compute_range

Compute the max and min values for the y-axis (range).

sub compute_range {
    my ( $lows$highs ) = @_;
    
    my $min_temperature = min @{$lows};
    my $max_temperature = max @{$highs};
    
    # Find nearest factor of 10 above and below
    $max_temperature += 10 - ( $max_temperature % 10 );
    $min_temperature -= ( $min_temperature % 10 );

    return ( $min_temperature$max_temperature );
}

pad_range

Add just a touch of padding in case a value is right on the computed range.
This keeps data from being cropped off in the graph.

sub pad_range {
    my ( $min_range$max_range ) = @_;
    
    my $padding = 2;
    return ( ( $min_range - $padding ), ( $max_range + $padding ) );
}

range_ticks

Determine where the ticks for the y-axis will be based on the high and low temperatures

sub range_ticks {
    my ( $low$high ) = @_;
    my $delta = $high - $low;
    my $tens  = int$delta / 10 );
    my @ticks = ($low);
    for my $factor ( 1 .. $tens ) {
        push @ticks, ( $low + ( $factor * 10 ) );
    }
    return \@ticks;
}

Source Code

temperature-forecaster.pl

Footnotes

1 OK maybe I’m taking it to an extreme by removing axis labels and legends.