fix(animation): use fixed axes showing full observed data in background

- X-axis fixed to 2022-01 to 2025-12 (full data range)
- Y-axis fixed to 0.72°C to 1.52°C (full value range)
- Background shows all observed data (faded gray) + final forecast reference (faded red dashed)
- Foreground shows current step data (bright blue) + current forecast (bright red)
- GIF size reduced from 918KB to 659KB
This commit is contained in:
Clayton Young
2026-02-21 16:45:55 -05:00
parent 1506a60993
commit 7b7110eebb
3 changed files with 320 additions and 315 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 896 KiB

After

Width:  |  Height:  |  Size: 644 KiB

View File

@@ -3,9 +3,8 @@
Generate animated GIF showing forecast evolution. Generate animated GIF showing forecast evolution.
Creates a GIF animation showing how the TimesFM forecast changes Creates a GIF animation showing how the TimesFM forecast changes
as more historical data points are added. as more historical data points are added. Shows the full actual data as a background layer.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@@ -24,107 +23,134 @@ OUTPUT_FILE = EXAMPLE_DIR / "forecast_animation.gif"
DURATION_MS = 500 # Time per frame in milliseconds DURATION_MS = 500 # Time per frame in milliseconds
def create_frame(ax, step_data: dict, actual_data: dict, total_steps: int) -> None: def create_frame(
"""Create a single frame of the animation.""" ax,
step_data: dict,
actual_data: dict,
final_forecast: dict,
total_steps: int,
x_min,
x_max,
y_min,
y_max,
) -> None:
"""Create a single frame of the animation with fixed axes."""
ax.clear() ax.clear()
# Parse dates # Parse dates
historical_dates = pd.to_datetime(step_data["historical_dates"]) historical_dates = pd.to_datetime(step_data["historical_dates"])
forecast_dates = pd.to_datetime(step_data["forecast_dates"]) forecast_dates = pd.to_datetime(step_data["forecast_dates"])
# Plot historical data # Get final forecast dates for full extent
final_forecast_dates = pd.to_datetime(final_forecast["forecast_dates"])
# All actual dates for full background
all_actual_dates = pd.to_datetime(actual_data["dates"])
all_actual_values = np.array(actual_data["values"])
# ========== BACKGROUND LAYER: Full actual data (faded) ==========
ax.plot(
all_actual_dates,
all_actual_values,
color="#9ca3af",
linewidth=1,
marker="o",
markersize=2,
alpha=0.3,
label="All observed data",
zorder=1,
)
# ========== BACKGROUND LAYER: Final forecast (faded) ==========
ax.plot(
final_forecast_dates,
final_forecast["point_forecast"],
color="#fca5a5",
linewidth=1,
linestyle="--",
marker="s",
markersize=2,
alpha=0.3,
label="Final forecast",
zorder=2,
)
# ========== FOREGROUND LAYER: Historical data used (bright) ==========
ax.plot( ax.plot(
historical_dates, historical_dates,
step_data["historical_values"], step_data["historical_values"],
color="#3b82f6", color="#3b82f6",
linewidth=2, linewidth=2.5,
marker="o", marker="o",
markersize=4, markersize=5,
label="Historical", label="Data used",
zorder=10,
) )
# Plot 90% CI (outer) # ========== FOREGROUND LAYER: Current forecast (bright) ==========
# 90% CI (outer)
ax.fill_between( ax.fill_between(
forecast_dates, forecast_dates,
step_data["q10"], step_data["q10"],
step_data["q90"], step_data["q90"],
alpha=0.1, alpha=0.15,
color="#ef4444", color="#ef4444",
label="90% CI", zorder=5,
) )
# Plot 80% CI (inner) # 80% CI (inner)
ax.fill_between( ax.fill_between(
forecast_dates, forecast_dates,
step_data["q20"], step_data["q20"],
step_data["q80"], step_data["q80"],
alpha=0.2, alpha=0.25,
color="#ef4444", color="#ef4444",
label="80% CI", zorder=6,
) )
# Plot forecast # Forecast line
ax.plot( ax.plot(
forecast_dates, forecast_dates,
step_data["point_forecast"], step_data["point_forecast"],
color="#ef4444", color="#ef4444",
linewidth=2, linewidth=2.5,
marker="s", marker="s",
markersize=4, markersize=5,
label="Forecast", label="Forecast",
zorder=7,
) )
# Plot actual future data if available # ========== Vertical line at forecast boundary ==========
actual_dates = pd.to_datetime(actual_data["dates"])
actual_values = actual_data["values"]
# Find which actual points fall in forecast period
forecast_start = forecast_dates[0]
forecast_end = forecast_dates[-1]
future_mask = (actual_dates >= forecast_start) & (actual_dates <= forecast_end)
future_dates = actual_dates[future_mask]
future_values = np.array(actual_values)[future_mask]
if len(future_dates) > 0:
ax.plot(
future_dates,
future_values,
color="#10b981",
linewidth=1,
linestyle="--",
marker="o",
markersize=3,
alpha=0.7,
label="Actual (future)",
)
# Add vertical line at forecast boundary
ax.axvline( ax.axvline(
x=historical_dates[-1], x=historical_dates[-1],
color="#6b7280", color="#6b7280",
linestyle="--", linestyle="--",
linewidth=1, linewidth=1.5,
alpha=0.5, alpha=0.7,
zorder=8,
) )
# Formatting # ========== Formatting ==========
ax.set_xlabel("Date", fontsize=11) ax.set_xlabel("Date", fontsize=11)
ax.set_ylabel("Temperature Anomaly (°C)", fontsize=11) ax.set_ylabel("Temperature Anomaly (°C)", fontsize=11)
ax.set_title( ax.set_title(
f"TimesFM Forecast Evolution\n" f"TimesFM Forecast Evolution\n"
f"Step {step_data['step']}/{total_steps}: {step_data['n_points']} points → {step_data['last_historical_date']}", f"Step {step_data['step']}/{total_steps}: {step_data['n_points']} points → "
f"forecast from {step_data['last_historical_date']}",
fontsize=13, fontsize=13,
fontweight="bold", fontweight="bold",
) )
ax.grid(True, alpha=0.3) ax.grid(True, alpha=0.3, zorder=0)
ax.legend(loc="upper left", fontsize=9) ax.legend(loc="upper left", fontsize=8)
ax.set_ylim(0.5, 1.6)
# FIXED AXES - same for all frames
ax.set_xlim(x_min, x_max)
ax.set_ylim(y_min, y_max)
# Format x-axis # Format x-axis
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m")) ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=6)) ax.xaxis.set_major_locator(mdates.MonthLocator(interval=4))
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right") plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
@@ -140,6 +166,36 @@ def main() -> None:
total_steps = len(data["animation_steps"]) total_steps = len(data["animation_steps"])
print(f"\n📊 Total frames: {total_steps}") print(f"\n📊 Total frames: {total_steps}")
# Get the final forecast step for reference
final_forecast = data["animation_steps"][-1]
# Calculate fixed axis extents from ALL data
all_actual_dates = pd.to_datetime(data["actual_data"]["dates"])
all_actual_values = np.array(data["actual_data"]["values"])
final_forecast_dates = pd.to_datetime(final_forecast["forecast_dates"])
final_forecast_values = np.array(final_forecast["point_forecast"])
# X-axis: from first actual date to last forecast date
x_min = all_actual_dates[0]
x_max = final_forecast_dates[-1]
# Y-axis: min/max across all actual + all forecasts with CIs
all_forecast_q10 = np.array(final_forecast["q10"])
all_forecast_q90 = np.array(final_forecast["q90"])
all_values = np.concatenate([
all_actual_values,
final_forecast_values,
all_forecast_q10,
all_forecast_q90,
])
y_min = all_values.min() - 0.05
y_max = all_values.max() + 0.05
print(f" X-axis: {x_min.strftime('%Y-%m')} to {x_max.strftime('%Y-%m')}")
print(f" Y-axis: {y_min:.2f}°C to {y_max:.2f}°C")
# Create figure # Create figure
fig, ax = plt.subplots(figsize=(12, 6)) fig, ax = plt.subplots(figsize=(12, 6))
@@ -149,7 +205,17 @@ def main() -> None:
for i, step in enumerate(data["animation_steps"]): for i, step in enumerate(data["animation_steps"]):
print(f" Frame {i + 1}/{total_steps}...") print(f" Frame {i + 1}/{total_steps}...")
create_frame(ax, step, data["actual_data"], total_steps) create_frame(
ax,
step,
data["actual_data"],
final_forecast,
total_steps,
x_min,
x_max,
y_min,
y_max,
)
# Save frame to buffer # Save frame to buffer
fig.canvas.draw() fig.canvas.draw()

View File

@@ -5,31 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TimesFM Interactive Forecast Animation</title> <title>TimesFM Interactive Forecast Animation</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation"></script>
<style> <style>
* { * { margin: 0; padding: 0; box-sizing: border-box; }
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh; min-height: 100vh;
color: #e0e0e0; color: #e0e0e0;
padding: 20px; padding: 20px;
} }
.container { .container { max-width: 1200px; margin: 0 auto; }
max-width: 1200px;
margin: 0 auto;
}
header { header { text-align: center; margin-bottom: 30px; }
text-align: center;
margin-bottom: 30px;
}
h1 { h1 {
font-size: 2rem; font-size: 2rem;
@@ -37,13 +26,9 @@
background: linear-gradient(90deg, #60a5fa, #a78bfa); background: linear-gradient(90deg, #60a5fa, #a78bfa);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text;
} }
.subtitle { .subtitle { color: #9ca3af; font-size: 1.1rem; }
color: #9ca3af;
font-size: 1.1rem;
}
.chart-container { .chart-container {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
@@ -53,10 +38,7 @@
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
} }
#chart { #chart { width: 100% !important; height: 450px !important; }
width: 100% !important;
height: 400px !important;
}
.controls { .controls {
display: flex; display: flex;
@@ -67,94 +49,43 @@
padding: 20px; padding: 20px;
} }
.slider-container { .slider-container { display: flex; flex-direction: column; gap: 10px; }
display: flex;
flex-direction: column;
gap: 10px;
}
.slider-label { .slider-label { display: flex; justify-content: space-between; align-items: center; }
display: flex; .slider-label span { font-size: 0.9rem; color: #9ca3af; }
justify-content: space-between; .slider-label .value { font-weight: 600; color: #60a5fa; font-size: 1.1rem; }
align-items: center;
}
.slider-label span {
font-size: 0.9rem;
color: #9ca3af;
}
.slider-label .value {
font-weight: 600;
color: #60a5fa;
font-size: 1.1rem;
}
input[type="range"] { input[type="range"] {
width: 100%; width: 100%; height: 8px; border-radius: 4px;
height: 8px; background: #374151; outline: none; -webkit-appearance: none;
border-radius: 4px;
background: #374151;
outline: none;
-webkit-appearance: none;
} }
input[type="range"]::-webkit-slider-thumb { input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
width: 24px; width: 24px; height: 24px; border-radius: 50%;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #60a5fa, #a78bfa); background: linear-gradient(135deg, #60a5fa, #a78bfa);
cursor: pointer; cursor: pointer;
box-shadow: 0 2px 10px rgba(96, 165, 250, 0.5); box-shadow: 0 2px 10px rgba(96, 165, 250, 0.5);
} }
input[type="range"]::-moz-range-thumb { .buttons { display: flex; gap: 10px; flex-wrap: wrap; }
width: 24px;
height: 24px;
border-radius: 50%;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
cursor: pointer;
border: none;
box-shadow: 0 2px 10px rgba(96, 165, 250, 0.5);
}
.buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button { button {
flex: 1; flex: 1; min-width: 100px;
min-width: 100px;
padding: 12px 20px; padding: 12px 20px;
border: none; border: none; border-radius: 8px;
border-radius: 8px; font-size: 1rem; font-weight: 600;
font-size: 1rem; cursor: pointer; transition: all 0.2s ease;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
} }
.btn-primary { .btn-primary {
background: linear-gradient(135deg, #60a5fa, #a78bfa); background: linear-gradient(135deg, #60a5fa, #a78bfa);
color: white; color: white;
} }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(96, 165, 250, 0.4); }
.btn-primary:hover { .btn-secondary { background: #374151; color: #e0e0e0; }
transform: translateY(-2px); .btn-secondary:hover { background: #4b5563; }
box-shadow: 0 4px 15px rgba(96, 165, 250, 0.4);
}
.btn-secondary {
background: #374151;
color: #e0e0e0;
}
.btn-secondary:hover {
background: #4b5563;
}
.stats { .stats {
display: grid; display: grid;
@@ -169,18 +100,8 @@
padding: 15px; padding: 15px;
text-align: center; text-align: center;
} }
.stat-card .label { font-size: 0.8rem; color: #9ca3af; margin-bottom: 5px; }
.stat-card .label { .stat-card .value { font-size: 1.3rem; font-weight: 600; color: #60a5fa; }
font-size: 0.8rem;
color: #9ca3af;
margin-bottom: 5px;
}
.stat-card .value {
font-size: 1.3rem;
font-weight: 600;
color: #60a5fa;
}
.legend { .legend {
display: flex; display: flex;
@@ -192,18 +113,8 @@
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);
} }
.legend-item { .legend-item { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; }
display: flex; .legend-color { width: 16px; height: 16px; border-radius: 4px; }
align-items: center;
gap: 8px;
font-size: 0.9rem;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
}
footer { footer {
text-align: center; text-align: center;
@@ -211,32 +122,14 @@
color: #6b7280; color: #6b7280;
font-size: 0.9rem; font-size: 0.9rem;
} }
footer a { color: #60a5fa; text-decoration: none; }
footer a {
color: #60a5fa;
text-decoration: none;
}
@media (max-width: 768px) {
h1 {
font-size: 1.5rem;
}
.buttons {
flex-direction: column;
}
button {
width: 100%;
}
}
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>TimesFM Zero-Shot Forecast Animation</h1> <h1>TimesFM Forecast Evolution</h1>
<p class="subtitle">Watch the forecast evolve as more data is added</p> <p class="subtitle">Watch the forecast evolve as more data is added — with full context always visible</p>
</header> </header>
<div class="chart-container"> <div class="chart-container">
@@ -246,13 +139,13 @@
<div class="controls"> <div class="controls">
<div class="slider-container"> <div class="slider-container">
<div class="slider-label"> <div class="slider-label">
<span>Historical Data Points</span> <span>Data Points Used</span>
<span class="value" id="points-value">12 / 36</span> <span class="value" id="points-value">12 / 36</span>
</div> </div>
<input type="range" id="slider" min="0" max="24" value="0" step="1"> <input type="range" id="slider" min="0" max="24" value="0" step="1">
<div class="slider-label"> <div class="slider-label">
<span id="date-start">2022-01</span> <span>2022-01</span>
<span id="date-end">2022-12 → Forecast to 2023-12</span> <span id="date-end">Using data through 2022-12</span>
</div> </div>
</div> </div>
@@ -266,6 +159,10 @@
<div class="label">Forecast Mean</div> <div class="label">Forecast Mean</div>
<div class="value" id="stat-mean">0.86°C</div> <div class="value" id="stat-mean">0.86°C</div>
</div> </div>
<div class="stat-card">
<div class="label">vs Final Forecast</div>
<div class="value" id="stat-diff">--</div>
</div>
<div class="stat-card"> <div class="stat-card">
<div class="label">Forecast Max</div> <div class="label">Forecast Max</div>
<div class="value" id="stat-max">--</div> <div class="value" id="stat-max">--</div>
@@ -274,47 +171,49 @@
<div class="label">Forecast Min</div> <div class="label">Forecast Min</div>
<div class="value" id="stat-min">--</div> <div class="value" id="stat-min">--</div>
</div> </div>
<div class="stat-card">
<div class="label">80% CI Width</div>
<div class="value" id="stat-ci">--</div>
</div>
</div> </div>
<div class="legend"> <div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: #9ca3af;"></div>
<span>All Observed Data</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #fca5a5;"></div>
<span>Final Forecast (reference)</span>
</div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color" style="background: #3b82f6;"></div> <div class="legend-color" style="background: #3b82f6;"></div>
<span>Historical Data</span> <span>Data Used</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color" style="background: #ef4444;"></div> <div class="legend-color" style="background: #ef4444;"></div>
<span>Forecast</span> <span>Current Forecast</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color" style="background: rgba(239, 68, 68, 0.2);"></div> <div class="legend-color" style="background: rgba(239, 68, 68, 0.25);"></div>
<span>80% Confidence Interval</span> <span>80% CI</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: rgba(239, 68, 68, 0.1);"></div>
<span>90% Confidence Interval</span>
</div> </div>
</div> </div>
</div> </div>
<footer> <footer>
<p>TimesFM 1.0 (200M) PyTorch • <a href="https://github.com/google-research/timesfm">Google Research</a></p> <p>TimesFM 1.0 (200M) PyTorch • <a href="https://github.com/google-research/timesfm">Google Research</a></p>
<p style="margin-top: 5px;">Generated by <a href="https://github.com/K-Dense-AI/claude-scientific-skills">claude-scientific-skills</a></p>
</footer> </footer>
</div> </div>
<script> <script>
// Load animation data
let animationData = null; let animationData = null;
let chart = null; let chart = null;
let isPlaying = false; let isPlaying = false;
let playInterval = null; let playInterval = null;
let currentStep = 0; let currentStep = 0;
// Fetch the JSON data // Fixed axis extents (set from data)
let allDates = [];
let yMin = 0.7;
let yMax = 1.55;
fetch('animation_data.json') fetch('animation_data.json')
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
@@ -325,38 +224,68 @@
.catch(err => { .catch(err => {
console.error('Error loading animation data:', err); console.error('Error loading animation data:', err);
document.querySelector('.chart-container').innerHTML = document.querySelector('.chart-container').innerHTML =
'<p style="text-align: center; padding: 50px; color: #ef4444;">Error loading data. Make sure animation_data.json is in the same directory.</p>'; '<p style="text-align:center;padding:50px;color:#ef4444;">Error loading data. Make sure animation_data.json is in the same directory.</p>';
}); });
function initChart() { function initChart() {
const ctx = document.getElementById('chart').getContext('2d'); const ctx = document.getElementById('chart').getContext('2d');
// Calculate fixed extents
const finalStep = animationData.animation_steps[animationData.animation_steps.length - 1];
allDates = [
...animationData.actual_data.dates,
...finalStep.forecast_dates
];
// Y extent from all values
const allValues = [
...animationData.actual_data.values,
...finalStep.point_forecast,
...finalStep.q10,
...finalStep.q90
];
yMin = Math.min(...allValues) - 0.05;
yMax = Math.max(...allValues) + 0.05;
chart = new Chart(ctx, { chart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: [], labels: allDates,
datasets: [ datasets: [
{ {
label: 'Historical', label: 'All Observed',
data: animationData.actual_data.values.map((v, i) => ({x: animationData.actual_data.dates[i], y: v})),
borderColor: '#9ca3af',
borderWidth: 1,
pointRadius: 2,
pointBackgroundColor: '#9ca3af',
fill: false,
tension: 0.1,
order: 1,
},
{
label: 'Final Forecast',
data: [...Array(animationData.actual_data.dates.length).fill(null), ...finalStep.point_forecast],
borderColor: '#fca5a5',
borderWidth: 1,
borderDash: [4, 4],
pointRadius: 2,
pointBackgroundColor: '#fca5a5',
fill: false,
tension: 0.1,
order: 2,
},
{
label: 'Data Used',
data: [], data: [],
borderColor: '#3b82f6', borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)', backgroundColor: 'rgba(59, 130, 246, 0.1)',
borderWidth: 2, borderWidth: 2.5,
pointRadius: 3, pointRadius: 4,
pointBackgroundColor: '#3b82f6', pointBackgroundColor: '#3b82f6',
fill: false, fill: false,
tension: 0.1, tension: 0.1,
}, order: 10,
{
label: 'Forecast',
data: [],
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 2,
pointRadius: 3,
pointBackgroundColor: '#ef4444',
fill: false,
tension: 0.1,
}, },
{ {
label: '90% CI Lower', label: '90% CI Lower',
@@ -366,6 +295,7 @@
fill: '+1', fill: '+1',
pointRadius: 0, pointRadius: 0,
tension: 0.1, tension: 0.1,
order: 5,
}, },
{ {
label: '90% CI Upper', label: '90% CI Upper',
@@ -375,86 +305,71 @@
fill: false, fill: false,
pointRadius: 0, pointRadius: 0,
tension: 0.1, tension: 0.1,
order: 5,
}, },
{ {
label: '80% CI Lower', label: '80% CI Lower',
data: [], data: [],
borderColor: 'transparent', borderColor: 'transparent',
backgroundColor: 'rgba(239, 68, 68, 0.15)', backgroundColor: 'rgba(239, 68, 68, 0.2)',
fill: '+1', fill: '+1',
pointRadius: 0, pointRadius: 0,
tension: 0.1, tension: 0.1,
order: 6,
}, },
{ {
label: '80% CI Upper', label: '80% CI Upper',
data: [], data: [],
borderColor: 'transparent', borderColor: 'transparent',
backgroundColor: 'rgba(239, 68, 68, 0.15)', backgroundColor: 'rgba(239, 68, 68, 0.2)',
fill: false, fill: false,
pointRadius: 0, pointRadius: 0,
tension: 0.1, tension: 0.1,
order: 6,
}, },
{ {
label: 'Actual (Future)', label: 'Forecast',
data: [], data: [],
borderColor: '#10b981', borderColor: '#ef4444',
backgroundColor: 'transparent', backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 1, borderWidth: 2.5,
borderDash: [5, 5], pointRadius: 4,
pointRadius: 2, pointBackgroundColor: '#ef4444',
pointBackgroundColor: '#10b981',
fill: false, fill: false,
tension: 0.1, tension: 0.1,
order: 7,
}, },
] ]
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { interaction: { intersect: false, mode: 'index' },
intersect: false,
mode: 'index',
},
plugins: { plugins: {
legend: { legend: { display: false },
display: false,
},
tooltip: { tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)', backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff', titleColor: '#fff',
bodyColor: '#fff', bodyColor: '#fff',
padding: 12, padding: 12,
displayColors: true,
}, },
}, },
scales: { scales: {
x: { x: {
grid: { grid: { color: 'rgba(255, 255, 255, 0.05)' },
color: 'rgba(255, 255, 255, 0.05)', ticks: { color: '#9ca3af', maxRotation: 45, minRotation: 45 },
},
ticks: {
color: '#9ca3af',
maxRotation: 45,
minRotation: 45,
},
}, },
y: { y: {
grid: { grid: { color: 'rgba(255, 255, 255, 0.05)' },
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: { ticks: {
color: '#9ca3af', color: '#9ca3af',
callback: function(value) { callback: v => v.toFixed(2) + '°C'
return value.toFixed(2) + '°C';
}
}, },
min: 0.5, min: yMin,
max: 1.6, max: yMax,
}, },
}, },
animation: { animation: { duration: 150 },
duration: 200,
},
}, },
}); });
} }
@@ -463,73 +378,102 @@
if (!animationData || !chart) return; if (!animationData || !chart) return;
const step = animationData.animation_steps[stepIndex]; const step = animationData.animation_steps[stepIndex];
const finalStep = animationData.animation_steps[animationData.animation_steps.length - 1];
const actual = animationData.actual_data; const actual = animationData.actual_data;
// Build all dates // Build data arrays for each dataset
const allDates = [...step.historical_dates, ...step.forecast_dates]; const nHist = step.historical_dates.length;
const nForecast = step.forecast_dates.length;
const nActual = actual.dates.length;
const nFinalForecast = finalStep.forecast_dates.length;
const totalPoints = nActual + nFinalForecast;
// Historical data (with nulls for forecast period) // Dataset 0: All observed (always full)
const historicalData = [...step.historical_values, ...Array(step.forecast_dates.length).fill(null)]; chart.data.datasets[0].data = actual.values.map((v, i) => ({x: actual.dates[i], y: v}));
// Forecast data (with nulls for historical period) // Dataset 1: Final forecast reference (always full)
const forecastData = [...Array(step.historical_dates.length).fill(null), ...step.point_forecast]; chart.data.datasets[1].data = [
...Array(nActual).fill(null),
...finalStep.point_forecast
];
// Confidence intervals // Dataset 2: Data used (historical only)
const q90Lower = [...Array(step.historical_dates.length).fill(null), ...step.q10]; const dataUsed = [];
const q90Upper = [...Array(step.historical_dates.length).fill(null), ...step.q90]; for (let i = 0; i < totalPoints; i++) {
const q80Lower = [...Array(step.historical_dates.length).fill(null), ...step.q20]; if (i < nHist) {
const q80Upper = [...Array(step.historical_dates.length).fill(null), ...step.q80]; dataUsed.push(step.historical_values[i]);
// Actual future data (if available)
const actualFuture = [];
for (let i = 0; i < allDates.length; i++) {
const dateIdx = actual.dates.indexOf(allDates[i]);
if (dateIdx >= step.n_points) {
actualFuture.push(actual.values[dateIdx]);
} else { } else {
actualFuture.push(null); dataUsed.push(null);
} }
} }
chart.data.datasets[2].data = dataUsed;
// Datasets 3-6: CIs (forecast only)
const forecastOffset = nActual;
const q90Lower = [];
const q90Upper = [];
const q80Lower = [];
const q80Upper = [];
for (let i = 0; i < totalPoints; i++) {
const forecastIdx = i - forecastOffset;
if (forecastIdx >= 0 && forecastIdx < nForecast) {
q90Lower.push(step.q10[forecastIdx]);
q90Upper.push(step.q90[forecastIdx]);
q80Lower.push(step.q20[forecastIdx]);
q80Upper.push(step.q80[forecastIdx]);
} else {
q90Lower.push(null);
q90Upper.push(null);
q80Lower.push(null);
q80Upper.push(null);
}
}
chart.data.datasets[3].data = q90Lower;
chart.data.datasets[4].data = q90Upper;
chart.data.datasets[5].data = q80Lower;
chart.data.datasets[6].data = q80Upper;
// Dataset 7: Forecast line
const forecastData = [];
for (let i = 0; i < totalPoints; i++) {
const forecastIdx = i - forecastOffset;
if (forecastIdx >= 0 && forecastIdx < nForecast) {
forecastData.push(step.point_forecast[forecastIdx]);
} else {
forecastData.push(null);
}
}
chart.data.datasets[7].data = forecastData;
// Update chart data
chart.data.labels = allDates;
chart.data.datasets[0].data = historicalData;
chart.data.datasets[1].data = forecastData;
chart.data.datasets[2].data = q90Lower;
chart.data.datasets[3].data = q90Upper;
chart.data.datasets[4].data = q80Lower;
chart.data.datasets[5].data = q80Upper;
chart.data.datasets[6].data = actualFuture;
chart.update('none'); chart.update('none');
// Update UI // Update UI
document.getElementById('slider').value = stepIndex; document.getElementById('slider').value = stepIndex;
document.getElementById('points-value').textContent = `${step.n_points} / 36`; document.getElementById('points-value').textContent = `${step.n_points} / 36`;
document.getElementById('date-end').textContent = `${step.last_historical_date} → Forecast to ${step.forecast_dates[step.forecast_dates.length - 1]}`; document.getElementById('date-end').textContent = `Using data through ${step.last_historical_date}`;
// Update stats // Stats
const mean = (step.point_forecast.reduce((a, b) => a + b, 0) / step.point_forecast.length).toFixed(3); const mean = (step.point_forecast.reduce((a, b) => a + b, 0) / step.point_forecast.length).toFixed(3);
const finalMean = (finalStep.point_forecast.reduce((a, b) => a + b, 0) / finalStep.point_forecast.length).toFixed(3);
const diff = (mean - finalMean).toFixed(3);
const max = Math.max(...step.point_forecast).toFixed(3); const max = Math.max(...step.point_forecast).toFixed(3);
const min = Math.min(...step.point_forecast).toFixed(3); const min = Math.min(...step.point_forecast).toFixed(3);
const ciWidth = ((step.q80[0] - step.q20[0]).toFixed(3));
document.getElementById('stat-mean').textContent = mean + '°C'; document.getElementById('stat-mean').textContent = mean + '°C';
document.getElementById('stat-diff').textContent = (diff >= 0 ? '+' : '') + diff + '°C';
document.getElementById('stat-max').textContent = max + '°C'; document.getElementById('stat-max').textContent = max + '°C';
document.getElementById('stat-min').textContent = min + '°C'; document.getElementById('stat-min').textContent = min + '°C';
document.getElementById('stat-ci').textContent = '±' + (ciWidth / 2).toFixed(2) + '°C';
currentStep = stepIndex; currentStep = stepIndex;
} }
// Slider control document.getElementById('slider').addEventListener('input', e => {
document.getElementById('slider').addEventListener('input', (e) => {
updateChart(parseInt(e.target.value)); updateChart(parseInt(e.target.value));
}); });
// Play button
document.getElementById('play-btn').addEventListener('click', () => { document.getElementById('play-btn').addEventListener('click', () => {
const btn = document.getElementById('play-btn'); const btn = document.getElementById('play-btn');
if (isPlaying) { if (isPlaying) {
clearInterval(playInterval); clearInterval(playInterval);
btn.textContent = '▶ Play'; btn.textContent = '▶ Play';
@@ -537,11 +481,7 @@
} else { } else {
btn.textContent = '⏸ Pause'; btn.textContent = '⏸ Pause';
isPlaying = true; isPlaying = true;
if (currentStep >= animationData.animation_steps.length - 1) currentStep = 0;
if (currentStep >= animationData.animation_steps.length - 1) {
currentStep = 0;
}
playInterval = setInterval(() => { playInterval = setInterval(() => {
if (currentStep >= animationData.animation_steps.length - 1) { if (currentStep >= animationData.animation_steps.length - 1) {
clearInterval(playInterval); clearInterval(playInterval);
@@ -551,11 +491,10 @@
currentStep++; currentStep++;
updateChart(currentStep); updateChart(currentStep);
} }
}, 500); }, 400);
} }
}); });
// Reset button
document.getElementById('reset-btn').addEventListener('click', () => { document.getElementById('reset-btn').addEventListener('click', () => {
if (isPlaying) { if (isPlaying) {
clearInterval(playInterval); clearInterval(playInterval);