mirror of
https://github.com/K-Dense-AI/claude-scientific-skills.git
synced 2026-03-27 07:09:27 +08:00
feat(example): add interactive forecast animation with slider
Create an all-out demonstration showing how TimesFM forecasts evolve as more historical data is added: - generate_animation_data.py: Runs 25 incremental forecasts (12→36 points) - interactive_forecast.html: Single-file HTML with Chart.js slider - Play/Pause animation control - Shows historical data, forecast, 80%/90% CIs, and actual future data - Live stats: forecast mean, max, min, CI width - generate_gif.py: Creates animated GIF for embedding in markdown - forecast_animation.gif: 25-frame animation (896 KB) Interactive features: - Slider to manually step through forecast evolution - Auto-play with 500ms per frame - Shows how each additional data point changes the forecast - Confidence intervals narrow as more data is added
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 896 KiB |
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate animation data for interactive forecast visualization.
|
||||||
|
|
||||||
|
This script runs TimesFM forecasts incrementally, starting with minimal data
|
||||||
|
and adding one point at a time, saving all forecasts for an interactive slider.
|
||||||
|
|
||||||
|
Output: animation_data.json with all forecast steps
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import timesfm
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
MIN_CONTEXT = 12 # Minimum points to start forecasting
|
||||||
|
HORIZON = 12 # Always forecast 12 months ahead
|
||||||
|
INPUT_FILE = Path(__file__).parent / "temperature_anomaly.csv"
|
||||||
|
OUTPUT_FILE = Path(__file__).parent / "animation_data.json"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print("=" * 60)
|
||||||
|
print(" TIMESFM ANIMATION DATA GENERATOR")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
df = pd.read_csv(INPUT_FILE, parse_dates=["date"])
|
||||||
|
df = df.sort_values("date").reset_index(drop=True)
|
||||||
|
|
||||||
|
all_dates = df["date"].tolist()
|
||||||
|
all_values = df["anomaly_c"].values.astype(np.float32)
|
||||||
|
|
||||||
|
print(f"\n📊 Total data: {len(all_values)} months")
|
||||||
|
print(
|
||||||
|
f" Date range: {all_dates[0].strftime('%Y-%m')} to {all_dates[-1].strftime('%Y-%m')}"
|
||||||
|
)
|
||||||
|
print(f" Animation steps: {len(all_values) - MIN_CONTEXT + 1}")
|
||||||
|
|
||||||
|
# Load TimesFM
|
||||||
|
print("\n🤖 Loading TimesFM 1.0 (200M) PyTorch...")
|
||||||
|
hparams = timesfm.TimesFmHparams(horizon_len=HORIZON)
|
||||||
|
checkpoint = timesfm.TimesFmCheckpoint(
|
||||||
|
huggingface_repo_id="google/timesfm-1.0-200m-pytorch"
|
||||||
|
)
|
||||||
|
model = timesfm.TimesFm(hparams=hparams, checkpoint=checkpoint)
|
||||||
|
|
||||||
|
# Generate forecasts for each step
|
||||||
|
animation_steps = []
|
||||||
|
|
||||||
|
for n_points in range(MIN_CONTEXT, len(all_values) + 1):
|
||||||
|
step_num = n_points - MIN_CONTEXT + 1
|
||||||
|
total_steps = len(all_values) - MIN_CONTEXT + 1
|
||||||
|
|
||||||
|
print(f"\n📈 Step {step_num}/{total_steps}: Using {n_points} points...")
|
||||||
|
|
||||||
|
# Get historical data up to this point
|
||||||
|
historical_values = all_values[:n_points]
|
||||||
|
historical_dates = all_dates[:n_points]
|
||||||
|
|
||||||
|
# Run forecast
|
||||||
|
point, quantiles = model.forecast(
|
||||||
|
[historical_values],
|
||||||
|
freq=[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine forecast dates
|
||||||
|
last_date = historical_dates[-1]
|
||||||
|
forecast_dates = pd.date_range(
|
||||||
|
start=last_date + pd.DateOffset(months=1),
|
||||||
|
periods=HORIZON,
|
||||||
|
freq="MS",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store step data
|
||||||
|
step_data = {
|
||||||
|
"step": step_num,
|
||||||
|
"n_points": n_points,
|
||||||
|
"last_historical_date": historical_dates[-1].strftime("%Y-%m"),
|
||||||
|
"historical_dates": [d.strftime("%Y-%m") for d in historical_dates],
|
||||||
|
"historical_values": historical_values.tolist(),
|
||||||
|
"forecast_dates": [d.strftime("%Y-%m") for d in forecast_dates],
|
||||||
|
"point_forecast": point[0].tolist(),
|
||||||
|
"q10": quantiles[0, :, 0].tolist(),
|
||||||
|
"q20": quantiles[0, :, 1].tolist(),
|
||||||
|
"q80": quantiles[0, :, 7].tolist(),
|
||||||
|
"q90": quantiles[0, :, 8].tolist(),
|
||||||
|
}
|
||||||
|
|
||||||
|
animation_steps.append(step_data)
|
||||||
|
|
||||||
|
# Show summary
|
||||||
|
print(f" Last date: {historical_dates[-1].strftime('%Y-%m')}")
|
||||||
|
print(f" Forecast mean: {point[0].mean():.3f}°C")
|
||||||
|
|
||||||
|
# Create output
|
||||||
|
output = {
|
||||||
|
"metadata": {
|
||||||
|
"model": "TimesFM 1.0 (200M) PyTorch",
|
||||||
|
"total_steps": len(animation_steps),
|
||||||
|
"min_context": MIN_CONTEXT,
|
||||||
|
"horizon": HORIZON,
|
||||||
|
"data_source": "NOAA GISTEMP Global Temperature Anomaly",
|
||||||
|
"full_date_range": f"{all_dates[0].strftime('%Y-%m')} to {all_dates[-1].strftime('%Y-%m')}",
|
||||||
|
},
|
||||||
|
"actual_data": {
|
||||||
|
"dates": [d.strftime("%Y-%m") for d in all_dates],
|
||||||
|
"values": all_values.tolist(),
|
||||||
|
},
|
||||||
|
"animation_steps": animation_steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save
|
||||||
|
with open(OUTPUT_FILE, "w") as f:
|
||||||
|
json.dump(output, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\n" + "=" * 60)
|
||||||
|
print(" ✅ ANIMATION DATA COMPLETE")
|
||||||
|
print("=" * 60)
|
||||||
|
print(f"\n📁 Output: {OUTPUT_FILE}")
|
||||||
|
print(f" Total steps: {len(animation_steps)}")
|
||||||
|
print(f" Each step shows forecast as one more data point is added")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate animated GIF showing forecast evolution.
|
||||||
|
|
||||||
|
Creates a GIF animation showing how the TimesFM forecast changes
|
||||||
|
as more historical data points are added.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.dates as mdates
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
EXAMPLE_DIR = Path(__file__).parent
|
||||||
|
DATA_FILE = EXAMPLE_DIR / "animation_data.json"
|
||||||
|
OUTPUT_FILE = EXAMPLE_DIR / "forecast_animation.gif"
|
||||||
|
DURATION_MS = 500 # Time per frame in milliseconds
|
||||||
|
|
||||||
|
|
||||||
|
def create_frame(ax, step_data: dict, actual_data: dict, total_steps: int) -> None:
|
||||||
|
"""Create a single frame of the animation."""
|
||||||
|
ax.clear()
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
historical_dates = pd.to_datetime(step_data["historical_dates"])
|
||||||
|
forecast_dates = pd.to_datetime(step_data["forecast_dates"])
|
||||||
|
|
||||||
|
# Plot historical data
|
||||||
|
ax.plot(
|
||||||
|
historical_dates,
|
||||||
|
step_data["historical_values"],
|
||||||
|
color="#3b82f6",
|
||||||
|
linewidth=2,
|
||||||
|
marker="o",
|
||||||
|
markersize=4,
|
||||||
|
label="Historical",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plot 90% CI (outer)
|
||||||
|
ax.fill_between(
|
||||||
|
forecast_dates,
|
||||||
|
step_data["q10"],
|
||||||
|
step_data["q90"],
|
||||||
|
alpha=0.1,
|
||||||
|
color="#ef4444",
|
||||||
|
label="90% CI",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plot 80% CI (inner)
|
||||||
|
ax.fill_between(
|
||||||
|
forecast_dates,
|
||||||
|
step_data["q20"],
|
||||||
|
step_data["q80"],
|
||||||
|
alpha=0.2,
|
||||||
|
color="#ef4444",
|
||||||
|
label="80% CI",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plot forecast
|
||||||
|
ax.plot(
|
||||||
|
forecast_dates,
|
||||||
|
step_data["point_forecast"],
|
||||||
|
color="#ef4444",
|
||||||
|
linewidth=2,
|
||||||
|
marker="s",
|
||||||
|
markersize=4,
|
||||||
|
label="Forecast",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Plot actual future data if available
|
||||||
|
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(
|
||||||
|
x=historical_dates[-1],
|
||||||
|
color="#6b7280",
|
||||||
|
linestyle="--",
|
||||||
|
linewidth=1,
|
||||||
|
alpha=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Formatting
|
||||||
|
ax.set_xlabel("Date", fontsize=11)
|
||||||
|
ax.set_ylabel("Temperature Anomaly (°C)", fontsize=11)
|
||||||
|
ax.set_title(
|
||||||
|
f"TimesFM Forecast Evolution\n"
|
||||||
|
f"Step {step_data['step']}/{total_steps}: {step_data['n_points']} points → {step_data['last_historical_date']}",
|
||||||
|
fontsize=13,
|
||||||
|
fontweight="bold",
|
||||||
|
)
|
||||||
|
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.legend(loc="upper left", fontsize=9)
|
||||||
|
ax.set_ylim(0.5, 1.6)
|
||||||
|
|
||||||
|
# Format x-axis
|
||||||
|
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
|
||||||
|
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=6))
|
||||||
|
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
print("=" * 60)
|
||||||
|
print(" GENERATING ANIMATED GIF")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
with open(DATA_FILE) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
total_steps = len(data["animation_steps"])
|
||||||
|
print(f"\n📊 Total frames: {total_steps}")
|
||||||
|
|
||||||
|
# Create figure
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 6))
|
||||||
|
|
||||||
|
# Generate frames
|
||||||
|
frames = []
|
||||||
|
|
||||||
|
for i, step in enumerate(data["animation_steps"]):
|
||||||
|
print(f" Frame {i + 1}/{total_steps}...")
|
||||||
|
|
||||||
|
create_frame(ax, step, data["actual_data"], total_steps)
|
||||||
|
|
||||||
|
# Save frame to buffer
|
||||||
|
fig.canvas.draw()
|
||||||
|
|
||||||
|
# Convert to PIL Image
|
||||||
|
buf = fig.canvas.buffer_rgba()
|
||||||
|
width, height = fig.canvas.get_width_height()
|
||||||
|
img = Image.frombytes("RGBA", (width, height), buf)
|
||||||
|
frames.append(img.convert("RGB"))
|
||||||
|
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
# Save as GIF
|
||||||
|
print(f"\n💾 Saving GIF: {OUTPUT_FILE}")
|
||||||
|
frames[0].save(
|
||||||
|
OUTPUT_FILE,
|
||||||
|
save_all=True,
|
||||||
|
append_images=frames[1:],
|
||||||
|
duration=DURATION_MS,
|
||||||
|
loop=0, # Loop forever
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
size_kb = OUTPUT_FILE.stat().st_size / 1024
|
||||||
|
print(f" File size: {size_kb:.1f} KB")
|
||||||
|
print(f"\n✅ Done!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,569 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TimesFM Interactive Forecast Animation</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation"></script>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: linear-gradient(90deg, #60a5fa, #a78bfa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#chart {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 400px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
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"] {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #374151;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 10px rgba(96, 165, 250, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
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 {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 100px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #a78bfa);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(96, 165, 250, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #374151;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding-top: 15px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 30px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer a {
|
||||||
|
color: #60a5fa;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>TimesFM Zero-Shot Forecast Animation</h1>
|
||||||
|
<p class="subtitle">Watch the forecast evolve as more data is added</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="chart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="slider-container">
|
||||||
|
<div class="slider-label">
|
||||||
|
<span>Historical Data Points</span>
|
||||||
|
<span class="value" id="points-value">12 / 36</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="slider" min="0" max="24" value="0" step="1">
|
||||||
|
<div class="slider-label">
|
||||||
|
<span id="date-start">2022-01</span>
|
||||||
|
<span id="date-end">2022-12 → Forecast to 2023-12</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn-primary" id="play-btn">▶ Play</button>
|
||||||
|
<button class="btn-secondary" id="reset-btn">↺ Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Forecast Mean</div>
|
||||||
|
<div class="value" id="stat-mean">0.86°C</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Forecast Max</div>
|
||||||
|
<div class="value" id="stat-max">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">Forecast Min</div>
|
||||||
|
<div class="value" id="stat-min">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="label">80% CI Width</div>
|
||||||
|
<div class="value" id="stat-ci">--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #3b82f6;"></div>
|
||||||
|
<span>Historical Data</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #ef4444;"></div>
|
||||||
|
<span>Forecast</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: rgba(239, 68, 68, 0.2);"></div>
|
||||||
|
<span>80% Confidence Interval</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>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Load animation data
|
||||||
|
let animationData = null;
|
||||||
|
let chart = null;
|
||||||
|
let isPlaying = false;
|
||||||
|
let playInterval = null;
|
||||||
|
let currentStep = 0;
|
||||||
|
|
||||||
|
// Fetch the JSON data
|
||||||
|
fetch('animation_data.json')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
animationData = data;
|
||||||
|
initChart();
|
||||||
|
updateChart(0);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Error loading animation data:', err);
|
||||||
|
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>';
|
||||||
|
});
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
const ctx = document.getElementById('chart').getContext('2d');
|
||||||
|
|
||||||
|
chart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Historical',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointBackgroundColor: '#3b82f6',
|
||||||
|
fill: false,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'transparent',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||||
|
fill: '+1',
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '90% CI Upper',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'transparent',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '80% CI Lower',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'transparent',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
fill: '+1',
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '80% CI Upper',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'transparent',
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
fill: false,
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Actual (Future)',
|
||||||
|
data: [],
|
||||||
|
borderColor: '#10b981',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 2,
|
||||||
|
pointBackgroundColor: '#10b981',
|
||||||
|
fill: false,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index',
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
titleColor: '#fff',
|
||||||
|
bodyColor: '#fff',
|
||||||
|
padding: 12,
|
||||||
|
displayColors: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
maxRotation: 45,
|
||||||
|
minRotation: 45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: '#9ca3af',
|
||||||
|
callback: function(value) {
|
||||||
|
return value.toFixed(2) + '°C';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
min: 0.5,
|
||||||
|
max: 1.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
duration: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChart(stepIndex) {
|
||||||
|
if (!animationData || !chart) return;
|
||||||
|
|
||||||
|
const step = animationData.animation_steps[stepIndex];
|
||||||
|
const actual = animationData.actual_data;
|
||||||
|
|
||||||
|
// Build all dates
|
||||||
|
const allDates = [...step.historical_dates, ...step.forecast_dates];
|
||||||
|
|
||||||
|
// Historical data (with nulls for forecast period)
|
||||||
|
const historicalData = [...step.historical_values, ...Array(step.forecast_dates.length).fill(null)];
|
||||||
|
|
||||||
|
// Forecast data (with nulls for historical period)
|
||||||
|
const forecastData = [...Array(step.historical_dates.length).fill(null), ...step.point_forecast];
|
||||||
|
|
||||||
|
// Confidence intervals
|
||||||
|
const q90Lower = [...Array(step.historical_dates.length).fill(null), ...step.q10];
|
||||||
|
const q90Upper = [...Array(step.historical_dates.length).fill(null), ...step.q90];
|
||||||
|
const q80Lower = [...Array(step.historical_dates.length).fill(null), ...step.q20];
|
||||||
|
const q80Upper = [...Array(step.historical_dates.length).fill(null), ...step.q80];
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
actualFuture.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
|
||||||
|
// Update UI
|
||||||
|
document.getElementById('slider').value = stepIndex;
|
||||||
|
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]}`;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
const mean = (step.point_forecast.reduce((a, b) => a + b, 0) / step.point_forecast.length).toFixed(3);
|
||||||
|
const max = Math.max(...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-max').textContent = max + '°C';
|
||||||
|
document.getElementById('stat-min').textContent = min + '°C';
|
||||||
|
document.getElementById('stat-ci').textContent = '±' + (ciWidth / 2).toFixed(2) + '°C';
|
||||||
|
|
||||||
|
currentStep = stepIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slider control
|
||||||
|
document.getElementById('slider').addEventListener('input', (e) => {
|
||||||
|
updateChart(parseInt(e.target.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Play button
|
||||||
|
document.getElementById('play-btn').addEventListener('click', () => {
|
||||||
|
const btn = document.getElementById('play-btn');
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
clearInterval(playInterval);
|
||||||
|
btn.textContent = '▶ Play';
|
||||||
|
isPlaying = false;
|
||||||
|
} else {
|
||||||
|
btn.textContent = '⏸ Pause';
|
||||||
|
isPlaying = true;
|
||||||
|
|
||||||
|
if (currentStep >= animationData.animation_steps.length - 1) {
|
||||||
|
currentStep = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
playInterval = setInterval(() => {
|
||||||
|
if (currentStep >= animationData.animation_steps.length - 1) {
|
||||||
|
clearInterval(playInterval);
|
||||||
|
document.getElementById('play-btn').textContent = '▶ Play';
|
||||||
|
isPlaying = false;
|
||||||
|
} else {
|
||||||
|
currentStep++;
|
||||||
|
updateChart(currentStep);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
document.getElementById('reset-btn').addEventListener('click', () => {
|
||||||
|
if (isPlaying) {
|
||||||
|
clearInterval(playInterval);
|
||||||
|
document.getElementById('play-btn').textContent = '▶ Play';
|
||||||
|
isPlaying = false;
|
||||||
|
}
|
||||||
|
updateChart(0);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user