merge: weather data integration — serving layer + web app + browser auto-open
This commit is contained in:
187
transform/sqlmesh_materia/models/serving/weather_daily.sql
Normal file
187
transform/sqlmesh_materia/models/serving/weather_daily.sql
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* Serving mart: daily weather analytics for 12 coffee-growing regions. */
|
||||||
|
/* Source: foundation.fct_weather_daily (already has seed join for location metadata). */
|
||||||
|
/* Adds rolling aggregates, water balance, gaps-and-islands streak counters, */
|
||||||
|
/* and a composite crop stress index (0–100) as a single severity gauge. */
|
||||||
|
/* Grain: (location_id, observation_date) */
|
||||||
|
/* Lookback 90: rolling windows reach up to 30 days, streak counters can extend */
|
||||||
|
/* up to ~90 days; without lookback a daily run sees only 1 row and all window */
|
||||||
|
/* functions degrade to single-row values. */
|
||||||
|
MODEL (
|
||||||
|
name serving.weather_daily,
|
||||||
|
kind INCREMENTAL_BY_TIME_RANGE (
|
||||||
|
time_column observation_date,
|
||||||
|
lookback 90
|
||||||
|
),
|
||||||
|
grain (location_id, observation_date),
|
||||||
|
start '2020-01-01',
|
||||||
|
cron '@daily'
|
||||||
|
);
|
||||||
|
|
||||||
|
WITH base AS (
|
||||||
|
SELECT
|
||||||
|
observation_date,
|
||||||
|
location_id,
|
||||||
|
location_name,
|
||||||
|
country,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
variety,
|
||||||
|
temp_min_c,
|
||||||
|
temp_max_c,
|
||||||
|
temp_mean_c,
|
||||||
|
precipitation_mm,
|
||||||
|
humidity_max_pct,
|
||||||
|
cloud_cover_mean_pct,
|
||||||
|
wind_max_speed_ms,
|
||||||
|
et0_mm,
|
||||||
|
vpd_max_kpa,
|
||||||
|
is_frost,
|
||||||
|
is_heat_stress,
|
||||||
|
is_drought,
|
||||||
|
is_high_vpd,
|
||||||
|
in_growing_season,
|
||||||
|
/* Rolling precipitation — w7 = trailing 7 days, w30 = trailing 30 days */
|
||||||
|
SUM(precipitation_mm) OVER w7 AS precip_sum_7d_mm,
|
||||||
|
SUM(precipitation_mm) OVER w30 AS precip_sum_30d_mm,
|
||||||
|
/* Rolling temperature baseline */
|
||||||
|
AVG(temp_mean_c) OVER w30 AS temp_mean_30d_c,
|
||||||
|
/* Temperature anomaly: today vs trailing 30-day mean */
|
||||||
|
temp_mean_c - AVG(temp_mean_c) OVER w30 AS temp_anomaly_c,
|
||||||
|
/* Water balance: net daily water gain/loss (precipitation minus evapotranspiration) */
|
||||||
|
precipitation_mm - et0_mm AS water_balance_mm,
|
||||||
|
SUM(precipitation_mm - et0_mm) OVER w7 AS water_balance_7d_mm,
|
||||||
|
/* Gaps-and-islands group markers for streak counting. */
|
||||||
|
/* Pattern: ROW_NUMBER() - running_count_of_true creates a stable group ID */
|
||||||
|
/* for each consecutive run of TRUE. Rows where flag=FALSE get a unique group ID */
|
||||||
|
/* (so their streak length stays 0 after the CASE in with_streaks). */
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY location_id
|
||||||
|
ORDER BY observation_date
|
||||||
|
) - SUM(
|
||||||
|
CASE WHEN is_drought THEN 1 ELSE 0 END
|
||||||
|
) OVER (
|
||||||
|
PARTITION BY location_id
|
||||||
|
ORDER BY observation_date
|
||||||
|
ROWS UNBOUNDED PRECEDING
|
||||||
|
) AS _drought_group,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY location_id
|
||||||
|
ORDER BY observation_date
|
||||||
|
) - SUM(
|
||||||
|
CASE WHEN is_heat_stress THEN 1 ELSE 0 END
|
||||||
|
) OVER (
|
||||||
|
PARTITION BY location_id
|
||||||
|
ORDER BY observation_date
|
||||||
|
ROWS UNBOUNDED PRECEDING
|
||||||
|
) AS _heat_group,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY location_id
|
||||||
|
ORDER BY observation_date
|
||||||
|
) - SUM(
|
||||||
|
CASE WHEN is_high_vpd THEN 1 ELSE 0 END
|
||||||
|
) OVER (
|
||||||
|
PARTITION BY location_id
|
||||||
|
ORDER BY observation_date
|
||||||
|
ROWS UNBOUNDED PRECEDING
|
||||||
|
) AS _vpd_group
|
||||||
|
FROM foundation.fct_weather_daily
|
||||||
|
WHERE
|
||||||
|
observation_date BETWEEN @start_ds AND @end_ds
|
||||||
|
WINDOW
|
||||||
|
w7 AS (
|
||||||
|
PARTITION BY location_id
|
||||||
|
ORDER BY observation_date
|
||||||
|
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
|
||||||
|
),
|
||||||
|
w30 AS (
|
||||||
|
PARTITION BY location_id
|
||||||
|
ORDER BY observation_date
|
||||||
|
ROWS BETWEEN 29 PRECEDING AND CURRENT ROW
|
||||||
|
)
|
||||||
|
), with_streaks AS (
|
||||||
|
SELECT
|
||||||
|
base.*,
|
||||||
|
/* Drought streak: number of consecutive dry days ending on observation_date. */
|
||||||
|
/* Returns 0 when flag is FALSE (not a drought day). */
|
||||||
|
CASE
|
||||||
|
WHEN NOT is_drought
|
||||||
|
THEN 0
|
||||||
|
ELSE ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY location_id, _drought_group
|
||||||
|
ORDER BY observation_date
|
||||||
|
)
|
||||||
|
END AS drought_streak_days,
|
||||||
|
/* Heat stress streak: consecutive days with temp_max > 35°C */
|
||||||
|
CASE
|
||||||
|
WHEN NOT is_heat_stress
|
||||||
|
THEN 0
|
||||||
|
ELSE ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY location_id, _heat_group
|
||||||
|
ORDER BY observation_date
|
||||||
|
)
|
||||||
|
END AS heat_streak_days,
|
||||||
|
/* VPD stress streak: consecutive days with vpd_max > 1.5 kPa */
|
||||||
|
CASE
|
||||||
|
WHEN NOT is_high_vpd
|
||||||
|
THEN 0
|
||||||
|
ELSE ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY location_id, _vpd_group
|
||||||
|
ORDER BY observation_date
|
||||||
|
)
|
||||||
|
END AS vpd_streak_days
|
||||||
|
FROM base
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
observation_date,
|
||||||
|
location_id,
|
||||||
|
location_name,
|
||||||
|
country,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
variety,
|
||||||
|
temp_min_c,
|
||||||
|
temp_max_c,
|
||||||
|
temp_mean_c,
|
||||||
|
precipitation_mm,
|
||||||
|
humidity_max_pct,
|
||||||
|
cloud_cover_mean_pct,
|
||||||
|
wind_max_speed_ms,
|
||||||
|
et0_mm,
|
||||||
|
vpd_max_kpa,
|
||||||
|
is_frost,
|
||||||
|
is_heat_stress,
|
||||||
|
is_drought,
|
||||||
|
is_high_vpd,
|
||||||
|
in_growing_season,
|
||||||
|
ROUND(precip_sum_7d_mm, 2) AS precip_sum_7d_mm,
|
||||||
|
ROUND(precip_sum_30d_mm, 2) AS precip_sum_30d_mm,
|
||||||
|
ROUND(temp_mean_30d_c, 2) AS temp_mean_30d_c,
|
||||||
|
ROUND(temp_anomaly_c, 2) AS temp_anomaly_c,
|
||||||
|
ROUND(water_balance_mm, 2) AS water_balance_mm,
|
||||||
|
ROUND(water_balance_7d_mm, 2) AS water_balance_7d_mm,
|
||||||
|
drought_streak_days,
|
||||||
|
heat_streak_days,
|
||||||
|
vpd_streak_days,
|
||||||
|
/* Composite crop stress index (0–100).
|
||||||
|
Weights: drought streak 30%, water deficit 25%, heat streak 20%,
|
||||||
|
VPD streak 15%, frost (binary) 10%.
|
||||||
|
Each component is normalized to [0,1] then capped before weighting:
|
||||||
|
drought: 14 days = fully stressed
|
||||||
|
water: 20mm 7d deficit = fully stressed
|
||||||
|
heat: 7 days = fully stressed
|
||||||
|
vpd: 7 days = fully stressed
|
||||||
|
frost: binary (Arabica highland catastrophic event) */
|
||||||
|
ROUND(
|
||||||
|
GREATEST(0.0, LEAST(100.0,
|
||||||
|
LEAST(1.0, drought_streak_days / 14.0) * 30.0
|
||||||
|
+ LEAST(1.0, GREATEST(0.0, -water_balance_7d_mm) / 20.0) * 25.0
|
||||||
|
+ LEAST(1.0, heat_streak_days / 7.0) * 20.0
|
||||||
|
+ LEAST(1.0, vpd_streak_days / 7.0) * 15.0
|
||||||
|
+ CASE WHEN is_frost THEN 10.0 ELSE 0.0 END
|
||||||
|
)),
|
||||||
|
1
|
||||||
|
) AS crop_stress_index
|
||||||
|
FROM with_streaks
|
||||||
|
ORDER BY
|
||||||
|
location_id,
|
||||||
|
observation_date
|
||||||
@@ -165,4 +165,27 @@ run_with_label "$COLOR_APP" "app " uv run --package beanflows python -m bea
|
|||||||
run_with_label "$COLOR_WORKER" "worker" uv run --package beanflows python -m beanflows.worker
|
run_with_label "$COLOR_WORKER" "worker" uv run --package beanflows python -m beanflows.worker
|
||||||
run_with_label "$COLOR_CSS" "css " make css-watch
|
run_with_label "$COLOR_CSS" "css " make css-watch
|
||||||
|
|
||||||
|
# Open a private/incognito browser window once the server is ready.
|
||||||
|
# Polls /auth/dev-login until it responds (up to 10 seconds), then launches.
|
||||||
|
(
|
||||||
|
DEV_URL="http://localhost:5001/auth/dev-login?email=pro@beanflows.coffee"
|
||||||
|
for i in $(seq 1 20); do
|
||||||
|
sleep 0.5
|
||||||
|
if curl -s -o /dev/null -w "%{http_code}" "$DEV_URL" 2>/dev/null | grep -qE "^[23]"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if flatpak info com.google.Chrome >/dev/null 2>&1; then
|
||||||
|
flatpak run com.google.Chrome --incognito "$DEV_URL" &>/dev/null &
|
||||||
|
elif flatpak info org.mozilla.firefox >/dev/null 2>&1; then
|
||||||
|
flatpak run org.mozilla.firefox --private-window "$DEV_URL" &>/dev/null &
|
||||||
|
elif command -v google-chrome >/dev/null 2>&1; then
|
||||||
|
google-chrome --incognito "$DEV_URL" &>/dev/null &
|
||||||
|
elif command -v chromium >/dev/null 2>&1; then
|
||||||
|
chromium --incognito "$DEV_URL" &>/dev/null &
|
||||||
|
elif command -v firefox >/dev/null 2>&1; then
|
||||||
|
firefox --private-window "$DEV_URL" &>/dev/null &
|
||||||
|
fi
|
||||||
|
) &
|
||||||
|
|
||||||
wait
|
wait
|
||||||
|
|||||||
@@ -531,3 +531,185 @@ async def get_country_comparison(
|
|||||||
""",
|
""",
|
||||||
[commodity_code, *country_codes],
|
[commodity_code, *country_codes],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Weather Queries
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Columns safe for user-facing weather series queries (prevents SQL injection)
|
||||||
|
ALLOWED_WEATHER_METRICS = frozenset({
|
||||||
|
"temp_min_c",
|
||||||
|
"temp_max_c",
|
||||||
|
"temp_mean_c",
|
||||||
|
"precipitation_mm",
|
||||||
|
"humidity_max_pct",
|
||||||
|
"et0_mm",
|
||||||
|
"vpd_max_kpa",
|
||||||
|
"precip_sum_7d_mm",
|
||||||
|
"precip_sum_30d_mm",
|
||||||
|
"temp_mean_30d_c",
|
||||||
|
"temp_anomaly_c",
|
||||||
|
"water_balance_mm",
|
||||||
|
"water_balance_7d_mm",
|
||||||
|
"drought_streak_days",
|
||||||
|
"heat_streak_days",
|
||||||
|
"vpd_streak_days",
|
||||||
|
"crop_stress_index",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_weather_metrics(metrics: list[str]) -> list[str]:
|
||||||
|
valid = [m for m in metrics if m in ALLOWED_WEATHER_METRICS]
|
||||||
|
assert valid, f"No valid weather metrics in {metrics}. Allowed: {sorted(ALLOWED_WEATHER_METRICS)}"
|
||||||
|
return valid
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather_locations() -> list[dict]:
|
||||||
|
"""12 locations with latest-day stress index and flags, sorted by severity.
|
||||||
|
|
||||||
|
Used by the map and location card grid (overview mode).
|
||||||
|
"""
|
||||||
|
return await fetch_analytics(
|
||||||
|
"""
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT MAX(observation_date) AS max_date
|
||||||
|
FROM serving.weather_daily
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
w.location_id,
|
||||||
|
w.location_name,
|
||||||
|
w.country,
|
||||||
|
w.lat,
|
||||||
|
w.lon,
|
||||||
|
w.variety,
|
||||||
|
w.observation_date,
|
||||||
|
w.crop_stress_index,
|
||||||
|
w.drought_streak_days,
|
||||||
|
w.heat_streak_days,
|
||||||
|
w.vpd_streak_days,
|
||||||
|
w.precip_sum_7d_mm,
|
||||||
|
w.precip_sum_30d_mm,
|
||||||
|
w.temp_anomaly_c,
|
||||||
|
w.is_frost,
|
||||||
|
w.is_heat_stress,
|
||||||
|
w.is_drought,
|
||||||
|
w.is_high_vpd
|
||||||
|
FROM serving.weather_daily AS w, latest
|
||||||
|
WHERE w.observation_date = latest.max_date
|
||||||
|
ORDER BY w.crop_stress_index DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather_location_series(
|
||||||
|
location_id: str,
|
||||||
|
metrics: list[str] | None = None,
|
||||||
|
days: int = 365,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Time series for one location. limit defaults to 1 year of daily data."""
|
||||||
|
assert 1 <= days <= 3650, "days must be between 1 and 3650"
|
||||||
|
if metrics is None:
|
||||||
|
metrics = [
|
||||||
|
"temp_mean_c", "precipitation_mm", "crop_stress_index",
|
||||||
|
"precip_sum_7d_mm", "water_balance_7d_mm", "temp_anomaly_c",
|
||||||
|
]
|
||||||
|
metrics = _validate_weather_metrics(metrics)
|
||||||
|
cols = ", ".join(metrics)
|
||||||
|
|
||||||
|
return await fetch_analytics(
|
||||||
|
f"""
|
||||||
|
SELECT observation_date, location_id, location_name, country,
|
||||||
|
is_frost, is_heat_stress, is_drought, is_high_vpd,
|
||||||
|
{cols}
|
||||||
|
FROM serving.weather_daily
|
||||||
|
WHERE location_id = ?
|
||||||
|
ORDER BY observation_date DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
[location_id, days],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather_stress_latest() -> dict | None:
|
||||||
|
"""Global stress snapshot for the latest day.
|
||||||
|
|
||||||
|
Returns avg/max stress index, count of locations under stress (index > 20),
|
||||||
|
worst location name, and observation date. Aggregates 12 rows in DuckDB.
|
||||||
|
"""
|
||||||
|
rows = await fetch_analytics(
|
||||||
|
"""
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT MAX(observation_date) AS max_date
|
||||||
|
FROM serving.weather_daily
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
latest.max_date AS observation_date,
|
||||||
|
ROUND(AVG(w.crop_stress_index), 1) AS avg_crop_stress_index,
|
||||||
|
MAX(w.crop_stress_index) AS max_crop_stress_index,
|
||||||
|
COUNT(*) FILTER (WHERE w.crop_stress_index > 20) AS locations_under_stress,
|
||||||
|
ARG_MAX(w.location_name, w.crop_stress_index) AS worst_location_name,
|
||||||
|
ARG_MAX(w.country, w.crop_stress_index) AS worst_location_country,
|
||||||
|
COUNT(*) FILTER (WHERE w.is_frost) AS frost_count,
|
||||||
|
COUNT(*) FILTER (WHERE w.is_drought) AS drought_count
|
||||||
|
FROM serving.weather_daily AS w, latest
|
||||||
|
WHERE w.observation_date = latest.max_date
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather_stress_trend(days: int = 365) -> list[dict]:
|
||||||
|
"""Daily global stress time series — avg and max across all 12 locations.
|
||||||
|
|
||||||
|
Used by the global stress chart and Pulse sparkline.
|
||||||
|
"""
|
||||||
|
assert 1 <= days <= 3650, "days must be between 1 and 3650"
|
||||||
|
return await fetch_analytics(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
observation_date,
|
||||||
|
ROUND(AVG(crop_stress_index), 1) AS avg_crop_stress_index,
|
||||||
|
MAX(crop_stress_index) AS max_crop_stress_index,
|
||||||
|
COUNT(*) FILTER (WHERE crop_stress_index > 20) AS locations_under_stress
|
||||||
|
FROM serving.weather_daily
|
||||||
|
GROUP BY observation_date
|
||||||
|
ORDER BY observation_date DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
[days],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_weather_active_alerts() -> list[dict]:
|
||||||
|
"""Locations with active stress flags on the latest observation date.
|
||||||
|
|
||||||
|
Sorted by crop_stress_index descending.
|
||||||
|
"""
|
||||||
|
return await fetch_analytics(
|
||||||
|
"""
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT MAX(observation_date) AS max_date
|
||||||
|
FROM serving.weather_daily
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
w.location_id,
|
||||||
|
w.location_name,
|
||||||
|
w.country,
|
||||||
|
w.variety,
|
||||||
|
w.observation_date,
|
||||||
|
w.crop_stress_index,
|
||||||
|
w.drought_streak_days,
|
||||||
|
w.heat_streak_days,
|
||||||
|
w.vpd_streak_days,
|
||||||
|
w.precip_sum_7d_mm,
|
||||||
|
w.is_frost,
|
||||||
|
w.is_heat_stress,
|
||||||
|
w.is_drought,
|
||||||
|
w.is_high_vpd
|
||||||
|
FROM serving.weather_daily AS w, latest
|
||||||
|
WHERE w.observation_date = latest.max_date
|
||||||
|
AND (w.is_frost OR w.is_heat_stress OR w.is_drought OR w.is_high_vpd)
|
||||||
|
ORDER BY w.crop_stress_index DESC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|||||||
@@ -298,6 +298,59 @@ async def commodity_ice_stocks_by_port(code: str):
|
|||||||
return jsonify({"commodity": code, "data": data})
|
return jsonify({"commodity": code, "data": data})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/weather/locations")
|
||||||
|
@api_key_required(scopes=["read"])
|
||||||
|
async def weather_locations():
|
||||||
|
"""12 coffee-growing locations with latest-day stress index, sorted by severity."""
|
||||||
|
data = await analytics.get_weather_locations()
|
||||||
|
return jsonify({"data": data})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/weather/locations/<location_id>")
|
||||||
|
@api_key_required(scopes=["read"])
|
||||||
|
async def weather_location_series(location_id: str):
|
||||||
|
"""Daily weather time series for one location.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
metrics — repeated param, e.g. ?metrics=crop_stress_index&metrics=precipitation_mm
|
||||||
|
days — trailing days (default 365, max 3650)
|
||||||
|
"""
|
||||||
|
raw_metrics = request.args.getlist("metrics") or None
|
||||||
|
if raw_metrics:
|
||||||
|
metrics = [m for m in raw_metrics if m in analytics.ALLOWED_WEATHER_METRICS]
|
||||||
|
if not metrics:
|
||||||
|
return jsonify({"error": f"No valid metrics. Allowed: {sorted(analytics.ALLOWED_WEATHER_METRICS)}"}), 400
|
||||||
|
else:
|
||||||
|
metrics = None
|
||||||
|
|
||||||
|
days = min(int(request.args.get("days", 365)), 3650)
|
||||||
|
data = await analytics.get_weather_location_series(location_id, metrics=metrics, days=days)
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": f"No data found for location_id {location_id!r}"}), 404
|
||||||
|
return jsonify({"location_id": location_id, "data": data})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/weather/stress")
|
||||||
|
@api_key_required(scopes=["read"])
|
||||||
|
async def weather_stress():
|
||||||
|
"""Daily global crop stress trend (avg + max across 12 origins).
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
days — trailing days (default 365, max 3650)
|
||||||
|
"""
|
||||||
|
days = min(int(request.args.get("days", 365)), 3650)
|
||||||
|
data = await analytics.get_weather_stress_trend(days=days)
|
||||||
|
return jsonify({"data": data})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/weather/alerts")
|
||||||
|
@api_key_required(scopes=["read"])
|
||||||
|
async def weather_alerts():
|
||||||
|
"""Locations with active crop stress flags on the latest observation date."""
|
||||||
|
data = await analytics.get_weather_active_alerts()
|
||||||
|
return jsonify({"data": data})
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/commodities/<int:code>/metrics.csv")
|
@bp.route("/commodities/<int:code>/metrics.csv")
|
||||||
@api_key_required(scopes=["read"])
|
@api_key_required(scopes=["read"])
|
||||||
async def commodity_metrics_csv(code: int):
|
async def commodity_metrics_csv(code: int):
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ import secrets
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, current_app, flash, g, jsonify, redirect, render_template, request, url_for
|
from quart import (
|
||||||
|
Blueprint,
|
||||||
|
current_app,
|
||||||
|
flash,
|
||||||
|
g,
|
||||||
|
redirect,
|
||||||
|
render_template,
|
||||||
|
request,
|
||||||
|
url_for,
|
||||||
|
)
|
||||||
|
|
||||||
from .. import analytics
|
from .. import analytics
|
||||||
from ..auth.routes import login_required, update_user
|
from ..auth.routes import login_required, update_user
|
||||||
@@ -135,16 +144,20 @@ async def index():
|
|||||||
["production", "total_distribution"],
|
["production", "total_distribution"],
|
||||||
start_year=(None if plan != "free" else None),
|
start_year=(None if plan != "free" else None),
|
||||||
),
|
),
|
||||||
|
analytics.get_weather_stress_latest(),
|
||||||
|
analytics.get_weather_stress_trend(days=90),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
)
|
)
|
||||||
defaults = [None, None, None, [], [], [], [], []]
|
defaults = [None, None, None, [], [], [], [], [], None, []]
|
||||||
(
|
(
|
||||||
price_latest, cot_latest, ice_stocks_latest,
|
price_latest, cot_latest, ice_stocks_latest,
|
||||||
stu_trend, price_series, ice_stocks_trend, cot_trend, time_series,
|
stu_trend, price_series, ice_stocks_trend, cot_trend, time_series,
|
||||||
|
weather_stress_latest, weather_stress_trend,
|
||||||
) = _safe(results, defaults)
|
) = _safe(results, defaults)
|
||||||
else:
|
else:
|
||||||
price_latest, cot_latest, ice_stocks_latest = None, None, None
|
price_latest, cot_latest, ice_stocks_latest = None, None, None
|
||||||
stu_trend, price_series, ice_stocks_trend, cot_trend, time_series = [], [], [], [], []
|
stu_trend, price_series, ice_stocks_trend, cot_trend, time_series = [], [], [], [], []
|
||||||
|
weather_stress_latest, weather_stress_trend = None, []
|
||||||
|
|
||||||
# Latest stock-to-use for the metric card
|
# Latest stock-to-use for the metric card
|
||||||
stu_latest = stu_trend[-1] if stu_trend else None
|
stu_latest = stu_trend[-1] if stu_trend else None
|
||||||
@@ -166,6 +179,8 @@ async def index():
|
|||||||
ice_stocks_trend=ice_stocks_trend,
|
ice_stocks_trend=ice_stocks_trend,
|
||||||
cot_trend=cot_trend,
|
cot_trend=cot_trend,
|
||||||
time_series=time_series,
|
time_series=time_series,
|
||||||
|
weather_stress_latest=weather_stress_latest,
|
||||||
|
weather_stress_trend=weather_stress_trend,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -372,6 +387,72 @@ async def countries():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/weather")
|
||||||
|
@login_required
|
||||||
|
async def weather():
|
||||||
|
"""Weather & crop stress deep-dive — 12 coffee-growing origins."""
|
||||||
|
plan = (g.get("subscription") or {}).get("plan", "free")
|
||||||
|
|
||||||
|
range_key = request.args.get("range", "1y")
|
||||||
|
if range_key not in RANGE_MAP:
|
||||||
|
range_key = "1y"
|
||||||
|
location_id = request.args.get("location", "").strip()
|
||||||
|
|
||||||
|
rng = RANGE_MAP[range_key]
|
||||||
|
days = rng["days"]
|
||||||
|
|
||||||
|
if analytics._db_path:
|
||||||
|
if location_id:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
analytics.get_weather_stress_latest(),
|
||||||
|
analytics.get_weather_locations(),
|
||||||
|
analytics.get_weather_stress_trend(days=days),
|
||||||
|
analytics.get_weather_active_alerts(),
|
||||||
|
analytics.get_weather_location_series(location_id, days=days),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
defaults = [None, [], [], [], []]
|
||||||
|
stress_latest, locations, stress_trend, active_alerts, location_series = _safe(
|
||||||
|
results, defaults
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
analytics.get_weather_stress_latest(),
|
||||||
|
analytics.get_weather_locations(),
|
||||||
|
analytics.get_weather_stress_trend(days=days),
|
||||||
|
analytics.get_weather_active_alerts(),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
defaults = [None, [], [], []]
|
||||||
|
stress_latest, locations, stress_trend, active_alerts = _safe(results, defaults)
|
||||||
|
location_series = []
|
||||||
|
else:
|
||||||
|
stress_latest = None
|
||||||
|
locations = stress_trend = active_alerts = location_series = []
|
||||||
|
|
||||||
|
# Resolve the selected location's metadata for the detail header
|
||||||
|
location_detail = None
|
||||||
|
if location_id and locations:
|
||||||
|
matches = [loc for loc in locations if loc["location_id"] == location_id]
|
||||||
|
location_detail = matches[0] if matches else None
|
||||||
|
|
||||||
|
ctx = dict(
|
||||||
|
plan=plan,
|
||||||
|
range_key=range_key,
|
||||||
|
location_id=location_id,
|
||||||
|
location_detail=location_detail,
|
||||||
|
stress_latest=stress_latest,
|
||||||
|
locations=locations,
|
||||||
|
stress_trend=stress_trend,
|
||||||
|
active_alerts=active_alerts,
|
||||||
|
location_series=location_series,
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return await render_template("weather_canvas.html", **ctx)
|
||||||
|
return await render_template("weather.html", user=g.user, **ctx)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/settings", methods=["GET", "POST"])
|
@bp.route("/settings", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@csrf_protect
|
@csrf_protect
|
||||||
|
|||||||
@@ -74,6 +74,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Warehouse
|
Warehouse
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('dashboard.weather') }}"
|
||||||
|
class="sidebar-item{% if request.path.startswith('/dashboard/weather') %} active{% endif %}">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
||||||
|
<circle cx="12" cy="12" r="4"/>
|
||||||
|
</svg>
|
||||||
|
Weather
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('dashboard.countries') }}"
|
<a href="{{ url_for('dashboard.countries') }}"
|
||||||
class="sidebar-item{% if request.path.startswith('/dashboard/countries') %} active{% endif %}">
|
class="sidebar-item{% if request.path.startswith('/dashboard/countries') %} active{% endif %}">
|
||||||
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24" aria-hidden="true">
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
@@ -146,15 +154,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Warehouse</span>
|
<span>Warehouse</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('dashboard.countries') }}"
|
<a href="{{ url_for('dashboard.weather') }}"
|
||||||
class="mobile-nav-item{% if request.path.startswith('/dashboard/countries') %} active{% endif %}">
|
class="mobile-nav-item{% if request.path.startswith('/dashboard/weather') %} active{% endif %}">
|
||||||
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24">
|
<svg width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
|
||||||
<circle cx="12" cy="12" r="9"/>
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
|
||||||
<path d="M3 12h18"/>
|
<circle cx="12" cy="12" r="4"/>
|
||||||
<path d="M12 3c-3.5 4.5-3.5 13.5 0 18"/>
|
|
||||||
<path d="M12 3c3.5 4.5 3.5 13.5 0 18"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span>Origins</span>
|
<span>Weather</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
@@ -25,16 +25,13 @@
|
|||||||
color: var(--color-stone);
|
color: var(--color-stone);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2×2 sparkline grid */
|
/* Sparkline grid — auto-fit for 4 or 5 cards */
|
||||||
.spark-grid {
|
.spark-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
@media (max-width: 600px) {
|
|
||||||
.spark-grid { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.spark-card {
|
.spark-card {
|
||||||
background: white;
|
background: white;
|
||||||
@@ -94,7 +91,7 @@
|
|||||||
<p>Global coffee market — full picture in 10 seconds{% if user.name %} · Welcome back, {{ user.name }}{% endif %}</p>
|
<p>Global coffee market — full picture in 10 seconds{% if user.name %} · Welcome back, {{ user.name }}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 4 metric cards -->
|
<!-- Metric cards -->
|
||||||
<div class="grid-4 mb-6 pulse-in">
|
<div class="grid-4 mb-6 pulse-in">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<div class="metric-label">KC=F Close</div>
|
<div class="metric-label">KC=F Close</div>
|
||||||
@@ -139,6 +136,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="metric-sub">ending stocks / consumption{% if stu_latest %} · {{ stu_latest.market_year }}{% endif %}</div>
|
<div class="metric-sub">ending stocks / consumption{% if stu_latest %} · {{ stu_latest.market_year }}{% endif %}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Crop Stress Index</div>
|
||||||
|
<div class="metric-value {% if weather_stress_latest and weather_stress_latest.avg_crop_stress_index > 40 %}text-danger{% elif weather_stress_latest and weather_stress_latest.avg_crop_stress_index > 20 %}text-copper{% else %}text-bean-green{% endif %}">
|
||||||
|
{% if weather_stress_latest %}{{ "{:.0f}".format(weather_stress_latest.avg_crop_stress_index) }}/100{% else %}--{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">
|
||||||
|
avg · {% if weather_stress_latest %}{{ weather_stress_latest.locations_under_stress }} stressed{% endif %}
|
||||||
|
{% if weather_stress_latest %} · {{ weather_stress_latest.observation_date }}{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Freshness bar -->
|
<!-- Freshness bar -->
|
||||||
@@ -156,6 +164,9 @@
|
|||||||
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
|
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
|
||||||
<strong>ICE Stocks</strong> {% if ice_stocks_latest %}{{ ice_stocks_latest.report_date }}{% else %}—{% endif %}
|
<strong>ICE Stocks</strong> {% if ice_stocks_latest %}{{ ice_stocks_latest.report_date }}{% else %}—{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="{{ url_for('dashboard.weather') }}" class="freshness-badge">
|
||||||
|
<strong>Weather</strong> {% if weather_stress_latest %}{{ weather_stress_latest.observation_date }}{% else %}—{% endif %}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2×2 sparkline grid -->
|
<!-- 2×2 sparkline grid -->
|
||||||
@@ -209,6 +220,18 @@
|
|||||||
<div class="spark-meta">USDA WASDE · 1,000 60-kg bags · production vs distribution</div>
|
<div class="spark-meta">USDA WASDE · 1,000 60-kg bags · production vs distribution</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Weather Crop Stress 90d -->
|
||||||
|
<div class="spark-card">
|
||||||
|
<div class="spark-card-head">
|
||||||
|
<div class="spark-title">Crop Stress Index — 90 days</div>
|
||||||
|
<a href="{{ url_for('dashboard.weather') }}" class="spark-detail-link">View details →</a>
|
||||||
|
</div>
|
||||||
|
<div class="spark-chart-wrap">
|
||||||
|
<canvas id="sparkWeather"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="spark-meta">Open-Meteo ERA5 · avg across 12 origins · 0 = no stress · 100 = severe</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if plan == "free" %}
|
{% if plan == "free" %}
|
||||||
@@ -338,6 +361,27 @@
|
|||||||
options: sparkOpts(C.copper, false),
|
options: sparkOpts(C.copper, false),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weather crop stress sparkline (90d)
|
||||||
|
var weatherRaw = {{ weather_stress_trend | tojson }};
|
||||||
|
if (weatherRaw && weatherRaw.length > 0) {
|
||||||
|
var weatherAsc = weatherRaw.slice().reverse();
|
||||||
|
new Chart(document.getElementById('sparkWeather'), {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: weatherAsc.map(function (r) { return r.observation_date; }),
|
||||||
|
datasets: [{
|
||||||
|
data: weatherAsc.map(function (r) { return r.avg_crop_stress_index; }),
|
||||||
|
borderColor: C.copper,
|
||||||
|
backgroundColor: C.copper + '22',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: sparkOpts(C.copper, true),
|
||||||
|
});
|
||||||
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
204
web/src/beanflows/dashboard/templates/weather.html
Normal file
204
web/src/beanflows/dashboard/templates/weather.html
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
{% extends "dashboard_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Weather — {{ config.APP_NAME }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||||
|
<!-- Leaflet for the overview map (only loaded on this page) -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<style>
|
||||||
|
/* ── Weather page ── */
|
||||||
|
.weather-map {
|
||||||
|
height: 340px;
|
||||||
|
border-radius: 1.25rem;
|
||||||
|
border: 1px solid var(--color-parchment);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.weather-map { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Location card grid */
|
||||||
|
.location-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.location-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.location-grid { grid-template-columns: repeat(4, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loc-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid var(--color-parchment);
|
||||||
|
border-radius: 1rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: block;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
box-shadow: 0 1px 3px rgba(44,24,16,0.04);
|
||||||
|
}
|
||||||
|
.loc-card:hover {
|
||||||
|
border-color: var(--color-copper);
|
||||||
|
box-shadow: 0 2px 8px rgba(180,83,9,0.12);
|
||||||
|
}
|
||||||
|
.loc-card-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-espresso);
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.loc-card-meta {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--color-stone);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.loc-card-stress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stress-bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 5px;
|
||||||
|
background: var(--color-parchment);
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.stress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.stress-bar-fill.low { background: #15803D; }
|
||||||
|
.stress-bar-fill.medium { background: #B45309; }
|
||||||
|
.stress-bar-fill.high { background: #DC2626; }
|
||||||
|
.stress-value {
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.stress-value.low { color: #15803D; }
|
||||||
|
.stress-value.medium { color: #B45309; }
|
||||||
|
.stress-value.high { color: #DC2626; }
|
||||||
|
|
||||||
|
/* Alert table */
|
||||||
|
.cc-trow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.5fr 0.8fr 0.7fr 0.7fr 0.7fr 0.5fr 0.7fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--color-parchment);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.cc-trow:last-child { border-bottom: none; }
|
||||||
|
.cc-trow:hover { background: rgba(232,223,213,0.25); }
|
||||||
|
.cc-thead {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--color-stone);
|
||||||
|
background: rgba(232,223,213,0.35);
|
||||||
|
border-radius: 0.625rem 0.625rem 0 0;
|
||||||
|
}
|
||||||
|
.flag-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.flag-dot.on { background: #DC2626; }
|
||||||
|
.flag-dot.off { background: var(--color-parchment); }
|
||||||
|
|
||||||
|
@keyframes cc-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||||||
|
.cc-in { animation: cc-in 0.25s ease both; }
|
||||||
|
.cc-in-2 { animation: cc-in 0.25s 0.07s ease both; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<!-- Masthead -->
|
||||||
|
<div class="page-masthead cc-in">
|
||||||
|
<div>
|
||||||
|
<h1>Weather & Crop Stress</h1>
|
||||||
|
<p class="page-masthead-sub">12 coffee-growing origins · Open-Meteo ERA5 reanalysis</p>
|
||||||
|
</div>
|
||||||
|
{% if stress_latest %}
|
||||||
|
<a href="{{ url_for('public.methodology') }}" class="freshness-badge">
|
||||||
|
<strong>Weather</strong> {{ stress_latest.observation_date }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="filter-bar" id="wx-filter-bar">
|
||||||
|
<div class="filter-pills" id="range-pills">
|
||||||
|
{% for r, label in [("3m","3M"),("6m","6M"),("1y","1Y"),("2y","2Y"),("5y","5Y")] %}
|
||||||
|
<button type="button"
|
||||||
|
class="filter-pill {{ 'active' if range_key == r }}"
|
||||||
|
onclick="setFilter('range','{{ r }}')">{{ label }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if location_id %}
|
||||||
|
<a href="{{ url_for('dashboard.weather') }}?range={{ range_key }}"
|
||||||
|
class="filter-pill"
|
||||||
|
style="text-decoration:none;">← All Locations</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTMX canvas -->
|
||||||
|
<div id="weather-canvas">
|
||||||
|
{% include "weather_canvas.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
var WEATHER_URL = '{{ url_for("dashboard.weather") }}';
|
||||||
|
var currentRange = {{ range_key | tojson }};
|
||||||
|
var currentLocation = {{ location_id | tojson }};
|
||||||
|
|
||||||
|
function setFilter(key, val) {
|
||||||
|
if (key === 'range') currentRange = val;
|
||||||
|
else if (key === 'location') currentLocation = val;
|
||||||
|
var url = WEATHER_URL + '?range=' + currentRange;
|
||||||
|
if (currentLocation) url += '&location=' + encodeURIComponent(currentLocation);
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
document.getElementById('weather-canvas').classList.add('canvas-loading');
|
||||||
|
htmx.ajax('GET', url, { target: '#weather-canvas', swap: 'innerHTML' });
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('htmx:afterSwap', function (e) {
|
||||||
|
if (e.detail.target.id === 'weather-canvas') {
|
||||||
|
document.getElementById('weather-canvas').classList.remove('canvas-loading');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('popstate', function () {
|
||||||
|
var p = new URLSearchParams(window.location.search);
|
||||||
|
currentRange = p.get('range') || '1y';
|
||||||
|
currentLocation = p.get('location') || '';
|
||||||
|
var url = WEATHER_URL + '?range=' + currentRange;
|
||||||
|
if (currentLocation) url += '&location=' + encodeURIComponent(currentLocation);
|
||||||
|
document.getElementById('weather-canvas').classList.add('canvas-loading');
|
||||||
|
htmx.ajax('GET', url, { target: '#weather-canvas', swap: 'innerHTML' });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
459
web/src/beanflows/dashboard/templates/weather_canvas.html
Normal file
459
web/src/beanflows/dashboard/templates/weather_canvas.html
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
{#
|
||||||
|
weather_canvas.html — HTMX partial for /dashboard/weather
|
||||||
|
Renders overview (no location) or location detail view.
|
||||||
|
#}
|
||||||
|
|
||||||
|
<!-- State sync -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var range = {{ range_key | tojson }};
|
||||||
|
var location = {{ location_id | tojson }};
|
||||||
|
document.querySelectorAll('#range-pills .filter-pill').forEach(function (btn) {
|
||||||
|
var t = btn.textContent.trim().toLowerCase();
|
||||||
|
btn.classList.toggle('active', t === range);
|
||||||
|
});
|
||||||
|
if (typeof currentRange !== 'undefined') currentRange = range;
|
||||||
|
if (typeof currentLocation !== 'undefined') currentLocation = location;
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% if location_id and location_detail %}
|
||||||
|
{# ════════════════════════════════════════════════════════════ #}
|
||||||
|
{# DETAIL VIEW — single location #}
|
||||||
|
{# ════════════════════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
{% set loc = location_detail %}
|
||||||
|
{% set latest = location_series[0] if location_series else none %}
|
||||||
|
|
||||||
|
<!-- Detail header -->
|
||||||
|
<div class="cc-in mb-5" style="padding:1rem 0 0.75rem;border-bottom:1.5px solid var(--color-parchment);">
|
||||||
|
<div style="display:flex;align-items:baseline;gap:0.75rem;flex-wrap:wrap;">
|
||||||
|
<span style="font-family:var(--font-display);font-size:1.375rem;font-weight:800;color:var(--color-espresso);">{{ loc.location_name }}</span>
|
||||||
|
<span class="freshness-badge" style="font-size:0.75rem;">{{ loc.country }}</span>
|
||||||
|
<span class="freshness-badge" style="font-size:0.75rem;">{{ loc.variety }}</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:0.8125rem;color:var(--color-stone);margin-top:0.25rem;">{{ loc.lat | round(2) }}°, {{ loc.lon | round(2) }}°</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location metric cards -->
|
||||||
|
<div class="grid-4 mb-6 cc-in">
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Stress Index</div>
|
||||||
|
<div class="metric-value {% if latest and latest.crop_stress_index > 40 %}text-danger{% elif latest and latest.crop_stress_index > 20 %}text-copper{% else %}text-bean-green{% endif %}">
|
||||||
|
{% if latest %}{{ "{:.0f}".format(latest.crop_stress_index) }}/100{% else %}--{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">crop stress composite{% if latest %} · {{ latest.observation_date }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">7-Day Precipitation</div>
|
||||||
|
<div class="metric-value">
|
||||||
|
{% if latest %}{{ "{:.1f}".format(latest.precip_sum_7d_mm) }} mm{% else %}--{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">rolling 7-day total</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">30-Day Precipitation</div>
|
||||||
|
<div class="metric-value">
|
||||||
|
{% if latest %}{{ "{:.0f}".format(latest.precip_sum_30d_mm) }} mm{% else %}--{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">rolling 30-day total</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Temp Anomaly</div>
|
||||||
|
<div class="metric-value {% if latest and latest.temp_anomaly_c and latest.temp_anomaly_c > 2 %}text-danger{% elif latest and latest.temp_anomaly_c and latest.temp_anomaly_c < -2 %}text-bean-green{% endif %}">
|
||||||
|
{% if latest and latest.temp_anomaly_c is not none %}{{ "{:+.1f}°C".format(latest.temp_anomaly_c) }}{% else %}--{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">vs trailing 30-day mean</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if location_series %}
|
||||||
|
|
||||||
|
<!-- Chart 1: Stress Index + Precipitation -->
|
||||||
|
<div class="cc-chart-card cc-in mb-5">
|
||||||
|
<div class="cc-chart-top">
|
||||||
|
<div>
|
||||||
|
<div class="cc-chart-title">Crop Stress Index & Precipitation</div>
|
||||||
|
<div class="cc-chart-meta">Daily · stress index (left) · precipitation mm (right)</div>
|
||||||
|
</div>
|
||||||
|
<span class="cc-chart-unit">composite</span>
|
||||||
|
</div>
|
||||||
|
<div class="cc-chart-body">
|
||||||
|
<canvas id="locStressChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart 2: Temperature + Water Balance -->
|
||||||
|
<div class="cc-chart-card cc-in-2">
|
||||||
|
<div class="cc-chart-top">
|
||||||
|
<div>
|
||||||
|
<div class="cc-chart-title">Temperature & Water Balance</div>
|
||||||
|
<div class="cc-chart-meta">Daily mean °C (left) · 7-day water balance mm (right)</div>
|
||||||
|
</div>
|
||||||
|
<span class="cc-chart-unit">°C / mm</span>
|
||||||
|
</div>
|
||||||
|
<div class="cc-chart-body">
|
||||||
|
<canvas id="locTempChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="cc-empty">
|
||||||
|
<div class="cc-empty-title">No Data Available</div>
|
||||||
|
<p class="cc-empty-body">Weather data for this location is still loading.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ════════════════════════════════════════════════════════════ #}
|
||||||
|
{# OVERVIEW — all 12 locations #}
|
||||||
|
{# ════════════════════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
<!-- Global metric cards -->
|
||||||
|
<div class="grid-4 mb-6 cc-in">
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Avg Crop Stress Index</div>
|
||||||
|
<div class="metric-value {% if stress_latest and stress_latest.avg_crop_stress_index > 40 %}text-danger{% elif stress_latest and stress_latest.avg_crop_stress_index > 20 %}text-copper{% else %}text-bean-green{% endif %}">
|
||||||
|
{% if stress_latest %}{{ "{:.0f}".format(stress_latest.avg_crop_stress_index) }}/100{% else %}--{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">across 12 origins{% if stress_latest %} · {{ stress_latest.observation_date }}{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Locations Under Stress</div>
|
||||||
|
<div class="metric-value {% if stress_latest and stress_latest.locations_under_stress >= 6 %}text-danger{% elif stress_latest and stress_latest.locations_under_stress >= 3 %}text-copper{% else %}text-bean-green{% endif %}">
|
||||||
|
{% if stress_latest %}{{ stress_latest.locations_under_stress }}/12{% else %}--{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">stress index > 20</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Worst Origin</div>
|
||||||
|
<div class="metric-value" style="font-size:1.1rem;">
|
||||||
|
{% if stress_latest and stress_latest.worst_location_name %}{{ stress_latest.worst_location_name }}{% else %}--{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">{% if stress_latest and stress_latest.max_crop_stress_index %}peak stress {{ "{:.0f}".format(stress_latest.max_crop_stress_index) }}/100{% endif %}</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card">
|
||||||
|
<div class="metric-label">Active Alerts</div>
|
||||||
|
<div class="metric-value {% if active_alerts | length >= 4 %}text-danger{% elif active_alerts | length >= 2 %}text-copper{% else %}text-bean-green{% endif %}">
|
||||||
|
{{ active_alerts | length }}
|
||||||
|
</div>
|
||||||
|
<div class="metric-sub">drought / heat / frost / VPD</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaflet map (desktop only via CSS) -->
|
||||||
|
<div id="weather-map" class="weather-map cc-in"></div>
|
||||||
|
|
||||||
|
<!-- Global stress chart -->
|
||||||
|
{% if stress_trend %}
|
||||||
|
<div class="cc-chart-card cc-in mb-5">
|
||||||
|
<div class="cc-chart-top">
|
||||||
|
<div>
|
||||||
|
<div class="cc-chart-title">Global Crop Stress Index</div>
|
||||||
|
<div class="cc-chart-meta">Daily average across all 12 origins</div>
|
||||||
|
</div>
|
||||||
|
<span class="cc-chart-unit">0–100</span>
|
||||||
|
</div>
|
||||||
|
<div class="cc-chart-body">
|
||||||
|
<canvas id="globalStressChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Active alerts table -->
|
||||||
|
{% if active_alerts %}
|
||||||
|
<div class="cc-table-card cc-in-2 mb-5">
|
||||||
|
<div class="cc-chart-top" style="padding:1rem 1rem 0.75rem;">
|
||||||
|
<div class="cc-chart-title">Active Weather Alerts</div>
|
||||||
|
<div class="cc-chart-meta">Locations with at least one active stress flag today</div>
|
||||||
|
</div>
|
||||||
|
<div class="cc-trow cc-thead">
|
||||||
|
<span>Location</span>
|
||||||
|
<span>Country</span>
|
||||||
|
<span>Stress</span>
|
||||||
|
<span>Drought</span>
|
||||||
|
<span>Heat</span>
|
||||||
|
<span>Frost</span>
|
||||||
|
<span>7d Rain</span>
|
||||||
|
</div>
|
||||||
|
{% for a in active_alerts %}
|
||||||
|
<a href="{{ url_for('dashboard.weather') }}?range={{ range_key }}&location={{ a.location_id }}"
|
||||||
|
class="cc-trow" style="text-decoration:none;color:inherit;">
|
||||||
|
<span style="font-weight:600;color:var(--color-espresso);">{{ a.location_name }}</span>
|
||||||
|
<span style="color:var(--color-stone);">{{ a.country }}</span>
|
||||||
|
<span class="stress-value {% if a.crop_stress_index > 40 %}high{% elif a.crop_stress_index > 20 %}medium{% else %}low{% endif %}">
|
||||||
|
{{ "{:.0f}".format(a.crop_stress_index) }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{% if a.is_drought %}<span class="flag-dot on" title="Drought"></span> {{ a.drought_streak_days }}d
|
||||||
|
{% else %}<span class="flag-dot off"></span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{% if a.is_heat_stress %}<span class="flag-dot on" title="Heat stress"></span> {{ a.heat_streak_days }}d
|
||||||
|
{% else %}<span class="flag-dot off"></span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{% if a.is_frost %}<span class="flag-dot on" title="Frost"></span>
|
||||||
|
{% else %}<span class="flag-dot off"></span>{% endif %}
|
||||||
|
</span>
|
||||||
|
<span style="font-family:ui-monospace,'Cascadia Code',monospace;">{{ "{:.1f}".format(a.precip_sum_7d_mm) }} mm</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Location card grid -->
|
||||||
|
{% if locations %}
|
||||||
|
<div class="location-grid cc-in-2">
|
||||||
|
{% for loc in locations %}
|
||||||
|
{% set stress = loc.crop_stress_index | float %}
|
||||||
|
{% set stress_class = 'high' if stress > 40 else ('medium' if stress > 20 else 'low') %}
|
||||||
|
<a href="{{ url_for('dashboard.weather') }}?range={{ range_key }}&location={{ loc.location_id }}"
|
||||||
|
class="loc-card">
|
||||||
|
<div class="loc-card-name">{{ loc.location_name }}</div>
|
||||||
|
<div class="loc-card-meta">{{ loc.country }} · {{ loc.variety }}</div>
|
||||||
|
<div class="loc-card-stress">
|
||||||
|
<div class="stress-bar-track">
|
||||||
|
<div class="stress-bar-fill {{ stress_class }}" style="width:{{ [stress, 100] | min }}%;"></div>
|
||||||
|
</div>
|
||||||
|
<span class="stress-value {{ stress_class }}">{{ "{:.0f}".format(stress) }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}{# end overview/detail #}
|
||||||
|
|
||||||
|
<!-- Chart init -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var C = {
|
||||||
|
copper: '#B45309', green: '#15803D', roast: '#4A2C1A', stone: '#78716C',
|
||||||
|
danger: '#DC2626', parchment: '#E8DFD5',
|
||||||
|
};
|
||||||
|
|
||||||
|
var AXES = {
|
||||||
|
x: {
|
||||||
|
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
|
||||||
|
border: { display: false },
|
||||||
|
ticks: { font: { family: "'DM Sans', sans-serif", size: 11 }, color: '#78716C', maxTicksLimit: 10 },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
|
||||||
|
border: { display: false },
|
||||||
|
beginAtZero: false,
|
||||||
|
ticks: {
|
||||||
|
font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 }, color: '#78716C',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
var TOOLTIP = {
|
||||||
|
backgroundColor: '#2C1810', titleColor: 'rgba(255,255,255,0.5)',
|
||||||
|
bodyColor: 'rgba(255,255,255,0.9)', borderColor: 'rgba(255,255,255,0.07)',
|
||||||
|
borderWidth: 1, padding: 12, cornerRadius: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
var LEGEND = {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { boxWidth: 10, boxHeight: 10, borderRadius: 5, useBorderRadius: true, padding: 16,
|
||||||
|
font: { family: "'DM Sans', sans-serif", size: 11.5 }, color: '#57534E' },
|
||||||
|
};
|
||||||
|
|
||||||
|
{% if location_id and location_series %}
|
||||||
|
// ── Location detail charts ──────────────────────────────────
|
||||||
|
var seriesRaw = {{ location_series | tojson }};
|
||||||
|
var seriesAsc = seriesRaw.slice().reverse();
|
||||||
|
|
||||||
|
// Chart 1: Stress Index (left y) + Precipitation bars (right y)
|
||||||
|
var stressCanvas = document.getElementById('locStressChart');
|
||||||
|
if (stressCanvas && seriesAsc.length > 0) {
|
||||||
|
new Chart(stressCanvas, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: seriesAsc.map(function (r) { return r.observation_date; }),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
label: 'Stress Index',
|
||||||
|
data: seriesAsc.map(function (r) { return r.crop_stress_index; }),
|
||||||
|
borderColor: C.copper, backgroundColor: C.copper + '20',
|
||||||
|
fill: true, tension: 0.25, pointRadius: 0, borderWidth: 2,
|
||||||
|
yAxisID: 'yLeft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
label: 'Precipitation',
|
||||||
|
data: seriesAsc.map(function (r) { return r.precipitation_mm; }),
|
||||||
|
backgroundColor: C.green + 'AA',
|
||||||
|
borderRadius: 3, borderWidth: 0,
|
||||||
|
yAxisID: 'yRight',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, aspectRatio: 2.5,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
animation: { duration: 350 },
|
||||||
|
plugins: { legend: LEGEND, tooltip: TOOLTIP },
|
||||||
|
scales: {
|
||||||
|
x: AXES.x,
|
||||||
|
yLeft: Object.assign({}, AXES.y, {
|
||||||
|
position: 'left',
|
||||||
|
min: 0, max: 100,
|
||||||
|
title: { display: true, text: 'Stress Index', font: { size: 10 }, color: '#78716C' },
|
||||||
|
}),
|
||||||
|
yRight: Object.assign({}, AXES.y, {
|
||||||
|
position: 'right',
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
title: { display: true, text: 'Precip mm', font: { size: 10 }, color: '#78716C' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart 2: Temperature (left y) + Water Balance 7d (right y)
|
||||||
|
var tempCanvas = document.getElementById('locTempChart');
|
||||||
|
if (tempCanvas && seriesAsc.length > 0) {
|
||||||
|
new Chart(tempCanvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: seriesAsc.map(function (r) { return r.observation_date; }),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Mean Temp',
|
||||||
|
data: seriesAsc.map(function (r) { return r.temp_mean_c; }),
|
||||||
|
borderColor: C.copper, tension: 0.25, pointRadius: 0, borderWidth: 2,
|
||||||
|
yAxisID: 'yLeft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '30d Baseline',
|
||||||
|
data: seriesAsc.map(function (r) { return r.temp_mean_30d_c; }),
|
||||||
|
borderColor: C.stone, borderDash: [5, 4], tension: 0.25, pointRadius: 0, borderWidth: 1.5,
|
||||||
|
yAxisID: 'yLeft',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '7d Water Balance',
|
||||||
|
data: seriesAsc.map(function (r) { return r.water_balance_7d_mm; }),
|
||||||
|
borderColor: C.green, backgroundColor: function (ctx) {
|
||||||
|
var v = ctx.dataset.data[ctx.dataIndex];
|
||||||
|
return v >= 0 ? C.green + '44' : C.danger + '44';
|
||||||
|
},
|
||||||
|
fill: 'origin', tension: 0.25, pointRadius: 0, borderWidth: 1.75,
|
||||||
|
yAxisID: 'yRight',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, aspectRatio: 2.5,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
animation: { duration: 350 },
|
||||||
|
plugins: { legend: LEGEND, tooltip: TOOLTIP },
|
||||||
|
scales: {
|
||||||
|
x: AXES.x,
|
||||||
|
yLeft: Object.assign({}, AXES.y, {
|
||||||
|
position: 'left',
|
||||||
|
title: { display: true, text: '°C', font: { size: 10 }, color: '#78716C' },
|
||||||
|
}),
|
||||||
|
yRight: Object.assign({}, AXES.y, {
|
||||||
|
position: 'right',
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
title: { display: true, text: 'mm', font: { size: 10 }, color: '#78716C' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
// ── Overview charts ──────────────────────────────────────────
|
||||||
|
|
||||||
|
// Global stress chart
|
||||||
|
var stressCanvas = document.getElementById('globalStressChart');
|
||||||
|
var stressRaw = {{ stress_trend | tojson }};
|
||||||
|
if (stressCanvas && stressRaw.length > 0) {
|
||||||
|
var stressAsc = stressRaw.slice().reverse();
|
||||||
|
new Chart(stressCanvas, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: stressAsc.map(function (r) { return r.observation_date; }),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Avg Stress Index',
|
||||||
|
data: stressAsc.map(function (r) { return r.avg_crop_stress_index; }),
|
||||||
|
borderColor: C.copper, backgroundColor: C.copper + '20',
|
||||||
|
fill: true, tension: 0.25, pointRadius: 0, borderWidth: 2.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Max Stress Index',
|
||||||
|
data: stressAsc.map(function (r) { return r.max_crop_stress_index; }),
|
||||||
|
borderColor: C.stone, borderDash: [5, 4], tension: 0.25, pointRadius: 0, borderWidth: 1.75,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true, aspectRatio: 2.5,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
animation: { duration: 350 },
|
||||||
|
plugins: { legend: LEGEND, tooltip: TOOLTIP },
|
||||||
|
scales: Object.assign({}, AXES, {
|
||||||
|
y: Object.assign({}, AXES.y, {
|
||||||
|
min: 0, max: 100,
|
||||||
|
title: { display: true, text: 'Stress Index 0–100', font: { size: 10 }, color: '#78716C' },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaflet map
|
||||||
|
var mapEl = document.getElementById('weather-map');
|
||||||
|
var locData = {{ locations | tojson }};
|
||||||
|
if (mapEl && locData.length > 0 && typeof L !== 'undefined') {
|
||||||
|
var map = L.map(mapEl, { zoomControl: true, scrollWheelZoom: false }).setView([5, 20], 2);
|
||||||
|
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
|
||||||
|
attribution: '© <a href="https://carto.com/">CARTO</a>',
|
||||||
|
subdomains: 'abcd',
|
||||||
|
maxZoom: 10,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
locData.forEach(function (loc) {
|
||||||
|
var idx = loc.crop_stress_index || 0;
|
||||||
|
var color = idx > 40 ? '#DC2626' : (idx > 20 ? '#B45309' : '#15803D');
|
||||||
|
var radius = 6 + Math.max(0, idx / 10);
|
||||||
|
|
||||||
|
var circle = L.circleMarker([loc.lat, loc.lon], {
|
||||||
|
radius: radius,
|
||||||
|
color: color,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: 0.65,
|
||||||
|
weight: 2,
|
||||||
|
opacity: 0.85,
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
var flags = [];
|
||||||
|
if (loc.is_frost) flags.push('❄ Frost');
|
||||||
|
if (loc.is_heat_stress) flags.push('🌡 Heat');
|
||||||
|
if (loc.is_drought) flags.push('💧 Drought');
|
||||||
|
if (loc.is_high_vpd) flags.push('🌬 High VPD');
|
||||||
|
|
||||||
|
circle.bindTooltip(
|
||||||
|
'<strong>' + loc.location_name + '</strong><br>' +
|
||||||
|
loc.country + ' · ' + loc.variety + '<br>' +
|
||||||
|
'Stress: <strong>' + idx.toFixed(0) + '/100</strong>' +
|
||||||
|
(flags.length ? '<br>' + flags.join(' · ') : ''),
|
||||||
|
{ direction: 'top', offset: [0, -6] }
|
||||||
|
);
|
||||||
|
|
||||||
|
circle.on('click', function () {
|
||||||
|
if (typeof setFilter === 'function') setFilter('location', loc.location_id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user