# /lib/MyApp/Controller/Chelsea.pm
package MyApp::Controller::Chelsea;
use Mojo::Base 'Mojolicious::Controller';
use Mojo::UserAgent;
use Mojo::JSON qw(decode_json);
use Time::Piece;
# Chelsea Weather Controller
# Handles scraping, interpolation, and formatting of Chelsea forecast data.
# Features:
# - Real-time scraping of Windfinder GFS data
# - Row-based data synchronization (Temp, Rain, Icons)
# - Linear interpolation for 2-hour granular viewing
# - Color-coded wind and temperature thresholds
# Main entry point for the Chelsea weather dashboard.
# Fetches external data and prepares a 96-hour interpolated forecast.
# Parameters: None
# Returns:
# Renders 'chelsea' template with an array ref of daily-grouped forecast rows.
sub index {
my $self = shift;
my $url = 'https://www.windfinder.com/forecast/chelsea';
my $ua = Mojo::UserAgent->new;
$ua->transactor->name('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
# 1. Fetch Remote Content
my $tx = $ua->get($url);
return $self->render(template => 'chelsea', forecast => []) if $tx->result->is_error;
my $html = $tx->result->body;
my $weather_data = [];
# 2. Extract Base Wind/Wave JSON (fcData)
if ($html =~ /fcData\s*:\s*(\[\{.*?\}\])\s*,/s) {
$weather_data = eval { decode_json($1) } // [];
}
# 3. Synchronize HTML-specific data (Row-by-Row parsing)
# Extracts individual table rows to prevent array offset errors for Rain and Temp.
my @html_rows = ($html =~ /
<\/span>/gs);
for my $i (0 .. $#$weather_data) {
my $row_content = $html_rows[$i] // '';
# Extract Temperature
my ($temp) = ($row_content =~ /(-?\d+)<\/span>/);
$weather_data->[$i]{temp} = $temp // 0;
# Extract Rainfall (Defaults to 0.0 if tag is missing in current row)
my ($rain) = ($row_content =~ /([\d\.]+)<\/span>/);
$weather_data->[$i]{rain} = $rain // '0.0';
# Extract Condition Icon class
my ($icon) = ($row_content =~ /class="data-cover__symbol\s+(icon-[nd]-[a-z0-9]+)/);
$weather_data->[$i]{icon} = $icon // 'skc';
# Sanitize ISO8601 offset for Time::Piece compatibility (+11:00 -> +1100)
if (my $dt = $weather_data->[$i]{dtl}) {
$dt =~ s/(\+\d{2}):(\d{2})$/$1$2/;
$weather_data->[$i]{dtl_fixed} = $dt;
}
}
# 4. Prepare Interpolated Dataset
my $interpolated = _process_forecast($weather_data);
$self->render(
template => 'chelsea',
forecast => $interpolated
);
}
# Helper: Performs linear interpolation and grouping for the forecast data.
# Parameters:
# raw_data : ArrayRef of hashes containing the 3-hour source points.
# Returns:
# ArrayRef of days, each containing a list of 2-hour interpolated points.
sub _process_forecast {
my $raw = shift;
return [] unless @$raw;
my %days;
my @day_order;
my %icon_map = (
'skc'=>'âï¸','clr'=>'âï¸','few'=>'ð¤ï¸','sct'=>'â
','bkn'=>'ð¥ï¸',
'ovc'=>'âï¸','ra'=>'ð§ï¸','sh'=>'ð¦ï¸','ts'=>'âï¸'
);
my $start_time = Time::Piece->strptime($raw->[0]{dtl_fixed}, "%Y-%m-%dT%H:%M:%S%z");
for (my $h = 0; $h < 96; $h += 2) {
my $target_epoch = $start_time->epoch + ($h * 3600);
my ($p0, $p1);
for (my $i = 0; $i < $#$raw; $i++) {
my $t0 = Time::Piece->strptime($raw->[$i]{dtl_fixed}, "%Y-%m-%dT%H:%M:%S%z")->epoch;
my $t1 = Time::Piece->strptime($raw->[$i+1]{dtl_fixed}, "%Y-%m-%dT%H:%M:%S%z")->epoch;
if ($target_epoch >= $t0 && $target_epoch <= $t1) {
$p0 = { %{$raw->[$i]}, epoch => $t0 };
$p1 = { %{$raw->[$i+1]}, epoch => $t1 };
last;
}
}
$p0 //= { %{$raw->[0]}, epoch => $start_time->epoch };
$p1 //= $p0;
my $target_time = localtime($target_epoch);
my $day_label = $target_time->strftime("%A, %d %B %Y");
if (!$days{$day_label}) {
push @day_order, $day_label;
$days{$day_label} = [];
}
my $cur_ws = _lerp($target_epoch, $p0->{epoch}, $p1->{epoch}, $p0->{ws}, $p1->{ws});
my $cur_temp = _lerp($target_epoch, $p0->{epoch}, $p1->{epoch}, $p0->{temp}, $p1->{temp});
my $icon_code = ($p0->{icon} =~ /icon-[nd]-([a-z0-9]+)/) ? $1 : 'skc';
push @{$days{$day_label}}, {
time => $target_time->strftime("%l:%M %p"),
temp => sprintf("%.0f", $cur_temp),
ws_kmh => sprintf("%.1f", $cur_ws * 1.852),
wg_kmh => sprintf("%.1f", $p0->{wg} * 1.852),
wh => sprintf("%.1f", $p0->{wh}),
rain => $p0->{rain},
icon => $icon_map{$icon_code} // 'âï¸',
w_class => _get_wind_style($cur_ws * 1.852),
t_class => _get_temp_style($cur_temp)
};
}
return [ map { { label => $_, rows => $days{$_} } } @day_order ];
}
# Helper: Standard Linear Interpolation
sub _lerp {
my ($t, $t0, $t1, $v0, $v1) = @_;
return $v0 if $t0 == $t1;
return $v0 + ($v1 - $v0) * (($t - $t0) / ($t1 - $t0));
}
# Helper: Assigns CSS classes based on wind intensity (KM/H)
sub _get_wind_style {
my $v = shift;
return 'wind-vlow' if $v < 15;
return 'wind-low' if $v < 25;
return 'wind-med' if $v < 35;
return 'wind-high' if $v < 45;
return 'wind-extreme';
}
# Helper: Assigns CSS classes based on temperature levels (Celsius)
sub _get_temp_style {
my $t = shift;
return 'temp-cool' if $t < 18;
return 'temp-mild' if $t < 23;
return 'temp-warm' if $t < 28;
return 'temp-hot' if $t < 33;
return 'temp-vhot';
}
1;