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
|
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",
|
||||||
|
|||||||
@@ -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 & consuming nations</p>
|
<p class="cc-page-subtitle">Compare coffee metrics across {{ all_countries|length }} producing & 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 country = all_countries | selectattr("country_code", "equalto", code) | first %}
|
{% set c = all_countries | selectattr("country_code", "equalto", code) | first %}
|
||||||
{% if country %}
|
{% if c %}
|
||||||
<span class="cc-chip" data-code="{{ code }}"
|
<span class="cc-chip" data-code="{{ code }}" onclick="toggleCountry('{{ code }}')">
|
||||||
onclick="toggleCountry('{{ code }}')">
|
{{ c.country_name }}
|
||||||
{{ country.country_name }}
|
|
||||||
<span class="cc-chip-x">×</span>
|
<span class="cc-chip-x">×</span>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% if not selected_codes %}
|
||||||
<span class="cc-chips-empty">No countries selected</span>
|
<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,21 +665,19 @@ 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,
|
|
||||||
useBorderRadius: true,
|
|
||||||
padding: 16,
|
padding: 16,
|
||||||
font: { family: "'DM Sans', sans-serif", size: 11.5 },
|
font: { family: "'DM Sans', sans-serif", size: 11.5 },
|
||||||
color: '#57534E',
|
color: '#57534E',
|
||||||
@@ -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 %}
|
||||||
|
|||||||
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