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