countries: HATEOAS + HTMX — click origin to update chart instantly

Replace Apply button flow with immediate HTMX partial fetches:
- toggleCountry() does an optimistic UI update (row + badge) then
  calls htmx.ajax() targeting #cc-canvas with swap=innerHTML
- URL is pushed to history on every selection change (bookmarkable)
- HX-Request now returns countries_canvas.html fragment (chips +
  chart/empty + inline IIFE that re-syncs globals + re-inits Chart.js)
- Panel (dark) is never swapped; canvas fades during in-flight request
- PALETTE, buildRankings(), initChart() defined once on page load,
  called by both initial render and partial IIFE after each swap
- Apply button removed; Clear triggers fetchCanvas() with empty codes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-21 22:40:59 +01:00
parent 91a9fb83be
commit 32e54f0381
3 changed files with 313 additions and 327 deletions

View File

@@ -195,9 +195,15 @@ async def countries():
analytics.COFFEE_COMMODITY_CODE, selected_codes, metric analytics.COFFEE_COMMODITY_CODE, selected_codes, metric
) )
# HTMX partial: return just the chart data as JSON # HTMX partial: return just the canvas fragment (chips + chart)
if request.headers.get("HX-Request"): if request.headers.get("HX-Request"):
return jsonify({"data": comparison_data, "metric": metric}) return await render_template(
"countries_canvas.html",
all_countries=all_countries,
selected_codes=selected_codes,
metric=metric,
comparison_data=comparison_data,
)
return await render_template( return await render_template(
"countries.html", "countries.html",

View File

@@ -7,7 +7,6 @@
<style> <style>
/* ── Country Comparison: Commodity Intelligence Terminal ── */ /* ── Country Comparison: Commodity Intelligence Terminal ── */
/* Page header */
.cc-masthead { .cc-masthead {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -16,7 +15,6 @@
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
border-bottom: 1.5px solid var(--color-parchment); border-bottom: 1.5px solid var(--color-parchment);
} }
.cc-masthead-left {}
.cc-page-title { .cc-page-title {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 2.25rem; font-size: 2.25rem;
@@ -30,12 +28,8 @@
font-size: 0.875rem; font-size: 0.875rem;
color: var(--color-stone); color: var(--color-stone);
margin-top: 0.375rem; margin-top: 0.375rem;
font-weight: 400;
}
.cc-masthead-right {
text-align: right;
flex-shrink: 0;
} }
.cc-masthead-right { text-align: right; flex-shrink: 0; }
.cc-stat-value { .cc-stat-value {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 2rem; font-size: 2rem;
@@ -61,7 +55,6 @@
} }
.cc-metrics { .cc-metrics {
display: flex; display: flex;
gap: 0;
background: var(--color-latte); background: var(--color-latte);
border: 1px solid var(--color-parchment); border: 1px solid var(--color-parchment);
border-radius: 0.875rem; border-radius: 0.875rem;
@@ -79,25 +72,17 @@
transition: all 0.15s; transition: all 0.15s;
white-space: nowrap; white-space: nowrap;
} }
.cc-metric-btn:hover:not(.active) { .cc-metric-btn:hover:not(.active) { background: white; color: var(--color-espresso); }
background: white;
color: var(--color-espresso);
}
.cc-metric-btn.active { .cc-metric-btn.active {
background: var(--color-copper); background: var(--color-copper);
color: white; color: white;
font-weight: 600; font-weight: 600;
box-shadow: 0 2px 8px rgba(180,83,9,0.3); box-shadow: 0 2px 8px rgba(180,83,9,0.3);
} }
.cc-selection-status { .cc-selection-status { font-size: 0.75rem; color: var(--color-stone); }
font-size: 0.75rem; .cc-selection-status strong { color: var(--color-espresso); }
color: var(--color-stone);
}
.cc-selection-status strong {
color: var(--color-espresso);
}
/* Body layout: dark panel left, light canvas right */ /* Layout */
.cc-body { .cc-body {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -105,9 +90,7 @@
align-items: start; align-items: start;
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.cc-body { .cc-body { grid-template-columns: 300px 1fr; }
grid-template-columns: 300px 1fr;
}
} }
/* ── Dark selector panel ── */ /* ── Dark selector panel ── */
@@ -140,6 +123,7 @@
background: rgba(180,83,9,0.15); background: rgba(180,83,9,0.15);
padding: 2px 7px; padding: 2px 7px;
border-radius: 100px; border-radius: 100px;
transition: color 0.2s, background 0.2s;
} }
.cc-search-wrap { .cc-search-wrap {
padding: 0.625rem 1rem; padding: 0.625rem 1rem;
@@ -170,7 +154,6 @@
.cc-row { .cc-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0;
padding: 0.4375rem 0.75rem; padding: 0.4375rem 0.75rem;
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
@@ -218,35 +201,17 @@
justify-content: center; justify-content: center;
transition: all 0.12s; transition: all 0.12s;
} }
.cc-row.selected .cc-checkbox { .cc-row.selected .cc-checkbox { background: var(--color-copper); border-color: var(--color-copper); }
background: var(--color-copper);
border-color: var(--color-copper);
}
.cc-check { display: none; } .cc-check { display: none; }
.cc-row.selected .cc-check { display: block; } .cc-row.selected .cc-check { display: block; }
.cc-panel-foot { .cc-panel-foot {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-top: 1px solid rgba(255,255,255,0.07); border-top: 1px solid rgba(255,255,255,0.07);
display: flex;
gap: 0.5rem;
} }
.cc-apply {
flex: 1;
padding: 0.5rem;
background: var(--color-copper);
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
font-family: var(--font-sans);
}
.cc-apply:hover { background: var(--color-copper-hover); }
.cc-clear { .cc-clear {
padding: 0.5rem 0.875rem; width: 100%;
padding: 0.5rem;
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
color: rgba(255,255,255,0.4); color: rgba(255,255,255,0.4);
border: 1px solid rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.08);
@@ -258,15 +223,17 @@
} }
.cc-clear:hover { background: rgba(255,255,255,0.09); color: rgba(255,255,255,0.7); } .cc-clear:hover { background: rgba(255,255,255,0.09); color: rgba(255,255,255,0.7); }
/* ── Right canvas ── */ /* ── Canvas ── */
.cc-canvas { .cc-canvas {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 1.25rem;
min-width: 0; min-width: 0;
transition: opacity 0.15s;
} }
.cc-canvas.cc-loading { opacity: 0.45; pointer-events: none; }
/* Chips row */ /* Chips */
.cc-chips { .cc-chips {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -288,16 +255,8 @@
line-height: 1.4; line-height: 1.4;
} }
.cc-chip:hover { opacity: 0.75; } .cc-chip:hover { opacity: 0.75; }
.cc-chip-x { .cc-chip-x { font-size: 0.875rem; opacity: 0.65; line-height: 1; }
font-size: 0.875rem; .cc-chips-empty { font-size: 0.75rem; color: var(--color-stone); font-style: italic; }
opacity: 0.65;
line-height: 1;
}
.cc-chips-empty {
font-size: 0.75rem;
color: var(--color-stone);
font-style: italic;
}
/* Chart card */ /* Chart card */
.cc-chart-card { .cc-chart-card {
@@ -321,11 +280,7 @@
color: var(--color-espresso); color: var(--color-espresso);
line-height: 1.2; line-height: 1.2;
} }
.cc-chart-meta { .cc-chart-meta { font-size: 0.6875rem; color: var(--color-stone); margin-top: 3px; }
font-size: 0.6875rem;
color: var(--color-stone);
margin-top: 3px;
}
.cc-chart-unit { .cc-chart-unit {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.6875rem; font-size: 0.6875rem;
@@ -339,12 +294,8 @@
align-self: flex-start; align-self: flex-start;
margin-top: 4px; margin-top: 4px;
} }
.cc-chart-body { .cc-chart-body { padding: 1rem 1.5rem 1.5rem; }
padding: 1rem 1.5rem 1.5rem; .cc-chart-body canvas { max-height: 340px !important; }
}
.cc-chart-body canvas {
max-height: 340px !important;
}
/* Rankings table */ /* Rankings table */
.cc-table-card { .cc-table-card {
@@ -388,50 +339,13 @@
} }
.cc-trow:last-child { border-bottom: none; } .cc-trow:last-child { border-bottom: none; }
.cc-trow:hover { background: var(--color-latte); } .cc-trow:hover { background: var(--color-latte); }
.cc-t-rank { .cc-t-rank { font-family: var(--font-mono); font-size: 0.6875rem; color: var(--color-stone); text-align: right; }
font-family: var(--font-mono); .cc-t-country { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
font-size: 0.6875rem; .cc-t-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
color: var(--color-stone); .cc-t-name { font-size: 0.8125rem; font-weight: 500; color: var(--color-espresso); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
text-align: right; .cc-t-bar-wrap { height: 4px; background: var(--color-parchment); border-radius: 2px; overflow: hidden; }
} .cc-t-bar { height: 100%; border-radius: 2px; transition: width 0.5s ease; }
.cc-t-country { .cc-t-value { font-family: var(--font-mono); font-size: 0.8125rem; font-weight: 600; color: var(--color-espresso); text-align: right; }
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.cc-t-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.cc-t-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-espresso);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cc-t-bar-wrap {
height: 4px;
background: var(--color-parchment);
border-radius: 2px;
overflow: hidden;
}
.cc-t-bar {
height: 100%;
border-radius: 2px;
transition: width 0.5s ease;
}
.cc-t-value {
font-family: var(--font-mono);
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-espresso);
text-align: right;
}
/* Empty state */ /* Empty state */
.cc-empty { .cc-empty {
@@ -455,40 +369,20 @@
justify-content: center; justify-content: center;
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.cc-empty-title { .cc-empty-title { font-family: var(--font-display); font-size: 1.125rem; font-weight: 700; color: var(--color-espresso); margin-bottom: 0.5rem; }
font-family: var(--font-display); .cc-empty-body { font-size: 0.875rem; color: var(--color-stone); line-height: 1.65; max-width: 300px; }
font-size: 1.125rem;
font-weight: 700;
color: var(--color-espresso);
margin-bottom: 0.5rem;
}
.cc-empty-body {
font-size: 0.875rem;
color: var(--color-stone);
line-height: 1.65;
max-width: 300px;
}
@keyframes cc-in { @keyframes cc-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
from { opacity: 0; transform: translateY(6px); } .cc-in { animation: cc-in 0.25s ease both; }
to { opacity: 1; transform: translateY(0); } .cc-in-2 { animation: cc-in 0.25s 0.07s ease both; }
}
.cc-in { animation: cc-in 0.3s ease both; }
.cc-in-2 { animation: cc-in 0.3s 0.07s ease both; }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<!-- Hidden form -->
<form id="cc-form" method="get" action="{{ url_for('dashboard.countries') }}" style="display:none">
<input type="hidden" name="metric" id="form-metric" value="{{ metric }}">
<div id="form-countries"></div>
</form>
<!-- Masthead --> <!-- Masthead -->
<div class="cc-masthead"> <div class="cc-masthead">
<div class="cc-masthead-left"> <div>
<h1 class="cc-page-title">Origin Intelligence</h1> <h1 class="cc-page-title">Origin Intelligence</h1>
<p class="cc-page-subtitle">Compare coffee metrics across {{ all_countries|length }} producing &amp; consuming nations</p> <p class="cc-page-subtitle">Compare coffee metrics across {{ all_countries|length }} producing &amp; consuming nations</p>
</div> </div>
@@ -510,7 +404,8 @@
</div> </div>
<span class="cc-selection-status" id="selection-status"> <span class="cc-selection-status" id="selection-status">
{% if selected_codes %} {% if selected_codes %}
Showing <strong>{{ selected_codes|length }}</strong> {{ "country" if selected_codes|length == 1 else "countries" }} Showing <strong>{{ selected_codes|length }}</strong>
{{ "country" if selected_codes|length == 1 else "countries" }}
{% else %} {% else %}
Select countries to compare Select countries to compare
{% endif %} {% endif %}
@@ -520,14 +415,14 @@
<!-- Body --> <!-- Body -->
<div class="cc-body"> <div class="cc-body">
<!-- ── Dark selector panel ── --> <!-- ── Dark selector panel (static, never swapped) ── -->
<div class="cc-panel"> <div class="cc-panel">
<div class="cc-panel-head"> <div class="cc-panel-head">
<span class="cc-panel-label">Origins</span> <span class="cc-panel-label">Origins</span>
<span class="cc-count-badge" id="sel-count">{{ selected_codes|length }}/10</span> <span class="cc-count-badge" id="sel-count">{{ selected_codes|length }}/10</span>
</div> </div>
<div class="cc-search-wrap"> <div class="cc-search-wrap">
<input class="cc-search" type="text" placeholder="Search…" id="cc-search" <input class="cc-search" type="text" placeholder="Search…"
oninput="filterList(this.value)" autocomplete="off"> oninput="filterList(this.value)" autocomplete="off">
</div> </div>
<div class="cc-list" id="cc-list"> <div class="cc-list" id="cc-list">
@@ -541,43 +436,39 @@
<span class="cc-row-val">{{ "{:,.0f}".format(c.production) if c.production else "" }}</span> <span class="cc-row-val">{{ "{:,.0f}".format(c.production) if c.production else "" }}</span>
<div class="cc-checkbox"> <div class="cc-checkbox">
<svg class="cc-check" width="8" height="6" viewBox="0 0 8 6" fill="none"> <svg class="cc-check" width="8" height="6" viewBox="0 0 8 6" fill="none">
<path d="M1 3L3 5L7 1" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> <path d="M1 3L3 5L7 1" stroke="white" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="cc-panel-foot"> <div class="cc-panel-foot">
<button class="cc-apply" onclick="applySelection()" type="button">Apply</button> <button class="cc-clear" onclick="clearAll()" type="button">Clear selection</button>
<button class="cc-clear" onclick="clearAll()" type="button">Clear</button>
</div> </div>
</div> </div>
<!-- ── Right canvas ── --> <!-- ── Canvas: HTMX target ── -->
<div class="cc-canvas"> <div class="cc-canvas" id="cc-canvas">
<!-- Chips --> <!-- Chips -->
<div class="cc-chips" id="cc-chips"> <div class="cc-chips" id="cc-chips">
{% if selected_codes %} {% for code in selected_codes %}
{% for code in selected_codes %} {% set c = all_countries | selectattr("country_code", "equalto", code) | first %}
{% set country = all_countries | selectattr("country_code", "equalto", code) | first %} {% if c %}
{% if country %} <span class="cc-chip" data-code="{{ code }}" onclick="toggleCountry('{{ code }}')">
<span class="cc-chip" data-code="{{ code }}" {{ c.country_name }}
onclick="toggleCountry('{{ code }}')"> <span class="cc-chip-x">×</span>
{{ country.country_name }} </span>
<span class="cc-chip-x">×</span> {% endif %}
</span> {% endfor %}
{% endif %} {% if not selected_codes %}
{% endfor %} <span class="cc-chips-empty">No countries selected</span>
{% else %}
<span class="cc-chips-empty">No countries selected</span>
{% endif %} {% endif %}
</div> </div>
<!-- Chart or empty state --> <!-- Initial chart or empty state -->
{% if comparison_data %} {% if comparison_data %}
<div class="cc-chart-card">
<div class="cc-chart-card cc-in">
<div class="cc-chart-top"> <div class="cc-chart-top">
<div> <div>
<div class="cc-chart-title">{{ metric.replace("_"," ").title() }} Over Time</div> <div class="cc-chart-title">{{ metric.replace("_"," ").title() }} Over Time</div>
@@ -589,12 +480,9 @@
<canvas id="comparisonChart"></canvas> <canvas id="comparisonChart"></canvas>
</div> </div>
</div> </div>
<div class="cc-table-card" id="cc-rankings"></div>
<div class="cc-table-card cc-in cc-in-2" id="cc-rankings"></div>
{% else %} {% else %}
<div class="cc-empty">
<div class="cc-empty cc-in">
<div class="cc-empty-ring"> <div class="cc-empty-ring">
<svg width="22" height="22" fill="none" stroke="var(--color-copper)" <svg width="22" height="22" fill="none" stroke="var(--color-copper)"
stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24"> stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24">
@@ -607,193 +495,165 @@
<div class="cc-empty-title">Select Origins to Compare</div> <div class="cc-empty-title">Select Origins to Compare</div>
<p class="cc-empty-body">Choose up to 10 coffee-producing countries from the panel on the left to visualize production, trade flows, and inventory trends.</p> <p class="cc-empty-body">Choose up to 10 coffee-producing countries from the panel on the left to visualize production, trade flows, and inventory trends.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
// ── Palette ────────────────────────────────────────────────────────────────── // ── Palette (shared with partial's inline script) ──────────────────────────
const PALETTE = [ const PALETTE = [
'#B45309', '#15803D', '#7C3AED', '#0284C7', '#BE185D', '#B45309', '#15803D', '#7C3AED', '#0284C7', '#BE185D',
'#0F766E', '#C2410C', '#1D4ED8', '#92400E', '#047857', '#0F766E', '#C2410C', '#1D4ED8', '#92400E', '#047857',
]; ];
// ── Seed CSS vars so Jinja chips (server-rendered) get correct colors too ── // ── Mutable state (updated by partial after every swap) ───────────────────
const cssVars = PALETTE.map((c, i) => `--cc-${i}: ${c}`).join(';'); window.orderedSel = {{ selected_codes | tojson }}.slice();
document.documentElement.style.cssText += ';' + cssVars; window.selectedSet = new Set(window.orderedSel);
window.currentMetric = {{ metric | tojson }};
// Apply palette to server-rendered chips const COUNTRIES_URL = '{{ url_for("dashboard.countries") }}';
document.querySelectorAll('.cc-chip[data-code]').forEach((chip, i) => {
chip.style.background = PALETTE[i % PALETTE.length];
});
// ── State ──────────────────────────────────────────────────────────────────── // ── URL builder ──────────────────────────────────────────────────────────
const allCountries = {{ all_countries | tojson }}; function buildUrl() {
const countryMap = Object.fromEntries(allCountries.map(c => [c.country_code, c])); var p = new URLSearchParams();
const orderedSel = {{ selected_codes | tojson }}.slice(); // preserves palette order p.set('metric', window.currentMetric);
const selectedSet = new Set(orderedSel); window.orderedSel.forEach(function (c) {
let currentMetric = {{ metric | tojson }}; if (window.selectedSet.has(c)) p.append('country', c);
});
return COUNTRIES_URL + '?' + p.toString();
}
// ── Interaction ────────────────────────────────────────────────────────────── // ── Fetch canvas partial ─────────────────────────────────────────────────
function fetchCanvas() {
var url = buildUrl();
window.history.pushState({}, '', url);
document.getElementById('cc-canvas').classList.add('cc-loading');
htmx.ajax('GET', url, { target: '#cc-canvas', swap: 'innerHTML' });
}
// ── Toggle country: optimistic UI then fetch ─────────────────────────────
function toggleCountry(code) { function toggleCountry(code) {
if (selectedSet.has(code)) { if (window.selectedSet.has(code)) {
selectedSet.delete(code); window.selectedSet.delete(code);
orderedSel.splice(orderedSel.indexOf(code), 1); var idx = window.orderedSel.indexOf(code);
if (idx > -1) window.orderedSel.splice(idx, 1);
} else { } else {
if (selectedSet.size >= 10) { flashCount(); return; } if (window.selectedSet.size >= 10) { flashCount(); return; }
selectedSet.add(code); window.selectedSet.add(code);
orderedSel.push(code); window.orderedSel.push(code);
} }
syncPanelRow(code); // Optimistic: update row + badge immediately
rebuildChips(); var row = document.querySelector('.cc-row[data-code="' + code + '"]');
updateCountLabel(); if (row) row.classList.toggle('selected', window.selectedSet.has(code));
updateStatus(); document.getElementById('sel-count').textContent = window.selectedSet.size + '/10';
fetchCanvas();
} }
function syncPanelRow(code) { // ── Metric change ────────────────────────────────────────────────────────
const row = document.querySelector(`.cc-row[data-code="${code}"]`); function setMetric(m) {
if (row) row.classList.toggle('selected', selectedSet.has(code)); window.currentMetric = m;
document.querySelectorAll('.cc-metric-btn').forEach(function (btn) {
btn.classList.toggle('active',
btn.textContent.trim().toLowerCase().replace(/ /g, '_') === m);
});
fetchCanvas();
} }
function rebuildChips() { // ── Clear all ────────────────────────────────────────────────────────────
const el = document.getElementById('cc-chips'); function clearAll() {
if (orderedSel.length === 0) { window.selectedSet.clear();
el.innerHTML = '<span class="cc-chips-empty">No countries selected</span>'; window.orderedSel.length = 0;
return; document.querySelectorAll('.cc-row.selected').forEach(function (r) {
} r.classList.remove('selected');
el.innerHTML = orderedSel });
.filter(c => selectedSet.has(c)) document.getElementById('sel-count').textContent = '0/10';
.map((code, i) => { fetchCanvas();
const name = (countryMap[code] || {}).country_name || code;
return `<span class="cc-chip" style="background:${PALETTE[i % PALETTE.length]}"
onclick="toggleCountry('${code}')">
${name} <span class="cc-chip-x">×</span>
</span>`;
}).join('');
} }
function updateCountLabel() { // ── Search / filter panel list ───────────────────────────────────────────
document.getElementById('sel-count').textContent = selectedSet.size + '/10'; function filterList(query) {
} var q = query.toLowerCase().trim();
document.querySelectorAll('.cc-row').forEach(function (row) {
function updateStatus() { row.style.display = (!q || row.dataset.name.toLowerCase().includes(q)) ? '' : 'none';
const el = document.getElementById('selection-status'); });
const n = selectedSet.size;
el.innerHTML = n
? `Showing <strong>${n}</strong> ${n === 1 ? 'country' : 'countries'}`
: 'Select countries to compare';
} }
// ── Flash badge on 10-country limit ─────────────────────────────────────
function flashCount() { function flashCount() {
const el = document.getElementById('sel-count'); var el = document.getElementById('sel-count');
el.style.color = '#EF4444'; el.style.color = '#EF4444';
el.style.background = 'rgba(239,68,68,0.12)'; el.style.background = 'rgba(239,68,68,0.12)';
setTimeout(() => { setTimeout(function () { el.style.color = ''; el.style.background = ''; }, 700);
el.style.color = '';
el.style.background = '';
}, 700);
} }
function setMetric(m) { // ── HTMX lifecycle ──────────────────────────────────────────────────────
currentMetric = m; document.addEventListener('htmx:afterSwap', function (e) {
document.querySelectorAll('.cc-metric-btn').forEach(btn => { if (e.detail.target.id === 'cc-canvas') {
const label = btn.textContent.trim().toLowerCase().replace(/ /g, '_'); document.getElementById('cc-canvas').classList.remove('cc-loading');
btn.classList.toggle('active', label === m); }
}); });
applySelection();
}
function applySelection() {
document.getElementById('form-metric').value = currentMetric;
const container = document.getElementById('form-countries');
container.innerHTML = orderedSel
.filter(c => selectedSet.has(c))
.map(c => `<input type="hidden" name="country" value="${c}">`)
.join('');
document.getElementById('cc-form').submit();
}
function clearAll() {
selectedSet.clear();
orderedSel.length = 0;
document.querySelectorAll('.cc-row.selected').forEach(r => r.classList.remove('selected'));
rebuildChips();
updateCountLabel();
updateStatus();
applySelection();
}
function filterList(query) {
const q = query.toLowerCase().trim();
document.querySelectorAll('.cc-row').forEach(row => {
const match = !q || row.dataset.name.toLowerCase().includes(q);
row.style.display = match ? '' : 'none';
});
}
// ── Rankings table (built from rawData JS-side) ──────────────────────────────
const rawData = {{ comparison_data | tojson }};
const metric = {{ metric | tojson }};
// ── Rankings table (called from page load + partial script) ─────────────
function buildRankings(data, metric, orderedCodes) { function buildRankings(data, metric, orderedCodes) {
if (!data.length) return; var rankEl = document.getElementById('cc-rankings');
if (!rankEl) return;
const maxYear = Math.max(...data.map(r => r.market_year)); var maxYear = Math.max.apply(null, data.map(function (r) { return r.market_year; }));
const latest = data.filter(r => r.market_year === maxYear); var latest = data.filter(function (r) { return r.market_year === maxYear; });
latest.sort((a, b) => (b[metric] || 0) - (a[metric] || 0)); latest.sort(function (a, b) { return (b[metric] || 0) - (a[metric] || 0); });
const maxVal = latest[0]?.[metric] || 1; var maxVal = latest[0] ? (latest[0][metric] || 1) : 1;
const rows = latest.map((row, i) => { var rows = latest.map(function (row, i) {
const pct = maxVal > 0 ? Math.round((row[metric] || 0) / maxVal * 100) : 0; var pct = maxVal > 0 ? Math.round((row[metric] || 0) / maxVal * 100) : 0;
const paletteIdx = orderedCodes.indexOf(row.country_code); var pi = orderedCodes.indexOf(row.country_code);
const color = PALETTE[paletteIdx >= 0 ? paletteIdx : i] || PALETTE[0]; var color = PALETTE[(pi >= 0 ? pi : i) % PALETTE.length];
const val = row[metric] != null ? Number(row[metric]).toLocaleString() : '—'; var val = row[metric] != null ? Number(row[metric]).toLocaleString() : '—';
return ` return '<div class="cc-trow">'
<div class="cc-trow"> + '<span class="cc-t-rank">' + (i + 1) + '</span>'
<span class="cc-t-rank">${i + 1}</span> + '<div class="cc-t-country">'
<div class="cc-t-country"> + '<div class="cc-t-dot" style="background:' + color + '"></div>'
<div class="cc-t-dot" style="background:${color}"></div> + '<span class="cc-t-name">' + row.country_name + '</span>'
<span class="cc-t-name">${row.country_name}</span> + '</div>'
</div> + '<div class="cc-t-bar-wrap"><div class="cc-t-bar" style="width:' + pct + '%;background:' + color + '"></div></div>'
<div class="cc-t-bar-wrap"> + '<span class="cc-t-value">' + val + '</span>'
<div class="cc-t-bar" style="width:${pct}%;background:${color}"></div> + '</div>';
</div>
<span class="cc-t-value">${val}</span>
</div>`;
}).join(''); }).join('');
document.getElementById('cc-rankings').innerHTML = ` rankEl.innerHTML = '<div class="cc-table-top">'
<div class="cc-table-top"> + '<span class="cc-table-label">Latest Ranking · ' + metric.replace(/_/g, ' ') + '</span>'
<span class="cc-table-label">Latest Ranking · ${metric.replace(/_/g,' ')}</span> + '<span class="cc-table-year">' + maxYear + '</span>'
<span class="cc-table-year">${maxYear}</span> + '</div>' + rows;
</div>
${rows}
`;
} }
if (rawData.length > 0) { // ── Chart.js (called from page load + partial script) ───────────────────
buildRankings(rawData, metric, orderedSel); function initChart(data, metric, orderedCodes) {
var existing = Chart.getChart('comparisonChart');
if (existing) existing.destroy();
// ── Chart ────────────────────────────────────────────────────────────────── var canvas = document.getElementById('comparisonChart');
const byCode = {}; if (!canvas) return;
for (const row of rawData) {
(byCode[row.country_code] ??= { name: row.country_name, rows: [] }).rows.push(row);
}
const allYears = [...new Set(rawData.map(r => r.market_year))].sort();
const datasets = orderedSel var byCode = {};
.filter(code => byCode[code]) data.forEach(function (row) {
.map((code, i) => { if (!byCode[row.country_code]) byCode[row.country_code] = { name: row.country_name, rows: [] };
const { name, rows } = byCode[code]; byCode[row.country_code].rows.push(row);
const yearMap = Object.fromEntries(rows.map(r => [r.market_year, r[metric]])); });
const color = PALETTE[i % PALETTE.length];
var allYears = Array.from(new Set(data.map(function (r) { return r.market_year; }))).sort();
var datasets = orderedCodes
.filter(function (code) { return byCode[code]; })
.map(function (code, i) {
var entry = byCode[code];
var yearMap = {};
entry.rows.forEach(function (r) { yearMap[r.market_year] = r[metric]; });
var color = PALETTE[i % PALETTE.length];
return { return {
label: name, label: entry.name,
data: allYears.map(y => yearMap[y] ?? null), data: allYears.map(function (y) { return yearMap[y] != null ? yearMap[y] : null; }),
borderColor: color, borderColor: color,
backgroundColor: color + '14', backgroundColor: color + '14',
borderWidth: 2.5, borderWidth: 2.5,
@@ -805,24 +665,22 @@ if (rawData.length > 0) {
}; };
}); });
new Chart(document.getElementById('comparisonChart'), { new Chart(canvas, {
type: 'line', type: 'line',
data: { labels: allYears, datasets }, data: { labels: allYears, datasets: datasets },
options: { options: {
responsive: true, responsive: true,
animation: { duration: 500, easing: 'easeInOutQuart' }, animation: { duration: 400, easing: 'easeInOutQuart' },
interaction: { mode: 'index', intersect: false }, interaction: { mode: 'index', intersect: false },
plugins: { plugins: {
legend: { legend: {
position: 'bottom', position: 'bottom',
labels: { labels: {
boxWidth: 10, boxWidth: 10, boxHeight: 10,
boxHeight: 10, borderRadius: 5, useBorderRadius: true,
borderRadius: 5, padding: 16,
useBorderRadius: true, font: { family: "'DM Sans', sans-serif", size: 11.5 },
padding: 16, color: '#57534E',
font: { family: "'DM Sans', sans-serif", size: 11.5 },
color: '#57534E',
}, },
}, },
tooltip: { tooltip: {
@@ -836,7 +694,9 @@ if (rawData.length > 0) {
titleFont: { family: "'DM Sans', sans-serif", size: 11, weight: '600' }, titleFont: { family: "'DM Sans', sans-serif", size: 11, weight: '600' },
bodyFont: { family: "'DM Sans', sans-serif", size: 12 }, bodyFont: { family: "'DM Sans', sans-serif", size: 12 },
callbacks: { callbacks: {
label: ctx => ` ${ctx.dataset.label}: ${Number(ctx.raw || 0).toLocaleString()}`, label: function (ctx) {
return ' ' + ctx.dataset.label + ': ' + Number(ctx.raw || 0).toLocaleString();
},
}, },
}, },
}, },
@@ -851,7 +711,11 @@ if (rawData.length > 0) {
ticks: { ticks: {
font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 }, font: { family: "ui-monospace, 'Cascadia Code', monospace", size: 11 },
color: '#78716C', color: '#78716C',
callback: v => v >= 1000000 ? (v/1000000).toFixed(1)+'M' : v >= 1000 ? (v/1000).toFixed(0)+'k' : v, callback: function (v) {
return v >= 1000000 ? (v / 1000000).toFixed(1) + 'M'
: v >= 1000 ? (v / 1000).toFixed(0) + 'k'
: v;
},
}, },
border: { display: false }, border: { display: false },
beginAtZero: false, beginAtZero: false,
@@ -860,5 +724,17 @@ if (rawData.length > 0) {
}, },
}); });
} }
// ── Initial page-load: apply palette + init if data present ──────────────
document.querySelectorAll('#cc-chips .cc-chip[data-code]').forEach(function (chip, i) {
chip.style.background = PALETTE[i % PALETTE.length];
});
var _initData = {{ comparison_data | tojson }};
var _initMetric = {{ metric | tojson }};
if (_initData.length > 0) {
buildRankings(_initData, _initMetric, window.orderedSel);
initChart(_initData, _initMetric, window.orderedSel);
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,104 @@
{#
countries_canvas.html — HTMX partial for the canvas area.
Returned on every HX-Request to /dashboard/countries.
Renders chips + chart (or empty state), then runs an IIFE that:
1. Syncs globals (orderedSel, selectedSet, currentMetric) from server state
2. Syncs panel row visual state
3. Applies palette to chips
4. Initialises Chart.js + rankings table
#}
<!-- ── Chips ── -->
<div class="cc-chips" id="cc-chips">
{% for code in selected_codes %}
{% set c = all_countries | selectattr("country_code", "equalto", code) | first %}
{% if c %}
<span class="cc-chip" data-code="{{ code }}" onclick="toggleCountry('{{ code }}')">
{{ c.country_name }}
<span class="cc-chip-x">×</span>
</span>
{% endif %}
{% endfor %}
{% if not selected_codes %}
<span class="cc-chips-empty">No countries selected</span>
{% endif %}
</div>
<!-- ── Chart card or empty state ── -->
{% if comparison_data %}
<div class="cc-chart-card cc-in">
<div class="cc-chart-top">
<div>
<div class="cc-chart-title">{{ metric.replace("_"," ").title() }} Over Time</div>
<div class="cc-chart-meta">USDA WASDE · 1000 60-kg bags · click legend to toggle</div>
</div>
<span class="cc-chart-unit">1k bags</span>
</div>
<div class="cc-chart-body">
<canvas id="comparisonChart"></canvas>
</div>
</div>
<div class="cc-table-card cc-in cc-in-2" id="cc-rankings"></div>
{% else %}
<div class="cc-empty cc-in">
<div class="cc-empty-ring">
<svg width="22" height="22" fill="none" stroke="var(--color-copper)"
stroke-width="1.75" stroke-linecap="round" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9"/>
<path d="M3 12h18"/>
<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>
</div>
<div class="cc-empty-title">Select Origins to Compare</div>
<p class="cc-empty-body">Choose up to 10 coffee-producing countries from the panel on the left to visualize production, trade flows, and inventory trends.</p>
</div>
{% endif %}
<!-- ── State sync + chart init (runs after HTMX swap) ── -->
<script>
(function () {
const selected = {{ selected_codes | tojson }};
const metric = {{ metric | tojson }};
const rawData = {{ comparison_data | tojson }};
// Sync globals so next click uses correct state
window.orderedSel = selected.slice();
window.selectedSet = new Set(selected);
window.currentMetric = metric;
// Sync panel rows
document.querySelectorAll('.cc-row').forEach(function (row) {
row.classList.toggle('selected', window.selectedSet.has(row.dataset.code));
});
// Update count badge
var countEl = document.getElementById('sel-count');
if (countEl) countEl.textContent = selected.length + '/10';
// Update status line
var statusEl = document.getElementById('selection-status');
if (statusEl) {
var n = selected.length;
statusEl.innerHTML = n
? 'Showing <strong>' + n + '</strong> ' + (n === 1 ? 'country' : 'countries')
: 'Select countries to compare';
}
// Apply palette to chips
document.querySelectorAll('#cc-chips .cc-chip').forEach(function (chip, i) {
chip.style.background = PALETTE[i % PALETTE.length];
});
// Chart + rankings
if (rawData.length > 0) {
buildRankings(rawData, metric, selected);
initChart(rawData, metric, selected);
}
}());
</script>