feat(web): add Weather dashboard page with Leaflet map, location cards, and stress charts

- routes.py: add weather() route (range/location params, asyncio.gather, HTMX support)
- weather.html: page shell loading Leaflet + Chart.js, HTMX canvas scaffold
- weather_canvas.html: HTMX partial with overview (map, metric cards, global stress chart,
  alert table, location card grid) and detail view (stress+precip chart, temp+water chart)
- dashboard_base.html: add Weather to sidebar (after Warehouse) and mobile bottom nav
  (replaces Origins; Origins remains in desktop sidebar)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-26 02:39:19 +01:00
parent 127881f7d8
commit a8cfd68eda
4 changed files with 760 additions and 10 deletions

View File

@@ -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

View File

@@ -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>

View 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 &amp; 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 %}

View 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 &amp; 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 &amp; 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 &gt; 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">0100</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 0100', 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: '&copy; <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>