redesign Countries page: commodity intelligence terminal aesthetic
Replace generic multi-select + plain card with a two-panel layout: - Dark espresso selector panel (sticky, searchable, click-to-toggle) with country rows showing rank, name, production figure, checkbox - Right canvas: metric segment tabs, selected-country chips (colored), Chart.js line chart with dark espresso tooltip, and a JS-built rankings table with proportional colored bars (latest year) - Smooth fade-in animations, monospaced figures, copper accent palette Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,102 +4,804 @@
|
||||
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<style>
|
||||
/* ── Country Comparison: Commodity Intelligence Terminal ── */
|
||||
|
||||
/* Page header */
|
||||
.cc-masthead {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.75rem;
|
||||
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;
|
||||
font-weight: 800;
|
||||
color: var(--color-espresso);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0;
|
||||
}
|
||||
.cc-page-subtitle {
|
||||
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-stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-copper);
|
||||
line-height: 1;
|
||||
}
|
||||
.cc-stat-label {
|
||||
font-size: 0.6875rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-stone);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Metric segment */
|
||||
.cc-metric-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cc-metrics {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
background: var(--color-latte);
|
||||
border: 1px solid var(--color-parchment);
|
||||
border-radius: 0.875rem;
|
||||
padding: 3px;
|
||||
}
|
||||
.cc-metric-btn {
|
||||
padding: 0.375rem 1.125rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-stone-dark);
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Body layout: dark panel left, light canvas right */
|
||||
.cc-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.cc-body {
|
||||
grid-template-columns: 300px 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Dark selector panel ── */
|
||||
.cc-panel {
|
||||
background: var(--color-espresso);
|
||||
border-radius: 1.25rem;
|
||||
overflow: hidden;
|
||||
position: sticky;
|
||||
top: calc(56px + 1.25rem);
|
||||
}
|
||||
.cc-panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem 0.875rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.07);
|
||||
}
|
||||
.cc-panel-label {
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
font-weight: 700;
|
||||
color: rgba(255,255,255,0.35);
|
||||
}
|
||||
.cc-count-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-copper);
|
||||
background: rgba(180,83,9,0.15);
|
||||
padding: 2px 7px;
|
||||
border-radius: 100px;
|
||||
}
|
||||
.cc-search-wrap {
|
||||
padding: 0.625rem 1rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.cc-search {
|
||||
width: 100%;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.09);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
color: rgba(255,255,255,0.85);
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.cc-search::placeholder { color: rgba(255,255,255,0.25); }
|
||||
.cc-search:focus { border-color: var(--color-copper); }
|
||||
|
||||
.cc-list {
|
||||
padding: 0.375rem 0.5rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||
}
|
||||
.cc-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 0.4375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
user-select: none;
|
||||
}
|
||||
.cc-row:hover { background: rgba(255,255,255,0.05); }
|
||||
.cc-row.selected { background: rgba(180,83,9,0.18); }
|
||||
.cc-row-rank {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
color: rgba(255,255,255,0.2);
|
||||
width: 1.5rem;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.cc-row-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.7);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.cc-row.selected .cc-row-name { color: rgba(255,255,255,0.95); }
|
||||
.cc-row-val {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
color: rgba(255,255,255,0.25);
|
||||
flex-shrink: 0;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
.cc-row.selected .cc-row-val { color: var(--color-copper); }
|
||||
.cc-checkbox {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 3px;
|
||||
border: 1.5px solid rgba(255,255,255,0.15);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.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;
|
||||
background: rgba(255,255,255,0.05);
|
||||
color: rgba(255,255,255,0.4);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
.cc-clear:hover { background: rgba(255,255,255,0.09); color: rgba(255,255,255,0.7); }
|
||||
|
||||
/* ── Right canvas ── */
|
||||
.cc-canvas {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Chips row */
|
||||
.cc-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
}
|
||||
.cc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.2rem 0.5rem 0.2rem 0.625rem;
|
||||
border-radius: 100px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
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;
|
||||
}
|
||||
|
||||
/* Chart card */
|
||||
.cc-chart-card {
|
||||
background: white;
|
||||
border: 1px solid var(--color-parchment);
|
||||
border-radius: 1.25rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(44,24,16,0.05), 0 4px 20px rgba(44,24,16,0.03);
|
||||
}
|
||||
.cc-chart-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem 0;
|
||||
gap: 1rem;
|
||||
}
|
||||
.cc-chart-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-espresso);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.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;
|
||||
color: var(--color-stone);
|
||||
background: var(--color-latte);
|
||||
border: 1px solid var(--color-parchment);
|
||||
padding: 2px 8px;
|
||||
border-radius: 100px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.cc-chart-body {
|
||||
padding: 1rem 1.5rem 1.5rem;
|
||||
}
|
||||
.cc-chart-body canvas {
|
||||
max-height: 340px !important;
|
||||
}
|
||||
|
||||
/* Rankings table */
|
||||
.cc-table-card {
|
||||
background: white;
|
||||
border: 1px solid var(--color-parchment);
|
||||
border-radius: 1.25rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(44,24,16,0.05);
|
||||
}
|
||||
.cc-table-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-parchment);
|
||||
}
|
||||
.cc-table-label {
|
||||
font-size: 0.625rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--color-stone);
|
||||
font-weight: 700;
|
||||
}
|
||||
.cc-table-year {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-stone);
|
||||
background: var(--color-latte);
|
||||
border: 1px solid var(--color-parchment);
|
||||
padding: 2px 7px;
|
||||
border-radius: 100px;
|
||||
}
|
||||
.cc-trow {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr 72px 5.5rem;
|
||||
align-items: center;
|
||||
padding: 0.5625rem 1.5rem;
|
||||
border-bottom: 1px solid rgba(232,223,213,0.5);
|
||||
transition: background 0.1s;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.cc-empty {
|
||||
background: white;
|
||||
border: 1px solid var(--color-parchment);
|
||||
border-radius: 1.25rem;
|
||||
padding: 4.5rem 2rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.cc-empty-ring {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-latte);
|
||||
border: 2px solid var(--color-parchment);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
@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; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h1>Country Comparison</h1>
|
||||
<p>Compare coffee metrics across producing and consuming countries.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card mb-8">
|
||||
<form id="country-form" method="get" action="{{ url_for('dashboard.countries') }}">
|
||||
<div class="grid-2">
|
||||
<div>
|
||||
<label for="metric" class="form-label">Metric</label>
|
||||
<select name="metric" id="metric" class="form-input" onchange="this.form.submit()">
|
||||
<!-- 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">
|
||||
<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>
|
||||
<div class="cc-masthead-right">
|
||||
<div class="cc-stat-value">{{ all_countries|length }}</div>
|
||||
<div class="cc-stat-label">Countries tracked</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metric selector + status -->
|
||||
<div class="cc-metric-row">
|
||||
<div class="cc-metrics" id="metric-tabs">
|
||||
{% for m in ["production", "exports", "imports", "ending_stocks"] %}
|
||||
<option value="{{ m }}" {{ "selected" if metric == m }}>{{ m | replace("_", " ") }}</option>
|
||||
<button class="cc-metric-btn {{ 'active' if metric == m }}"
|
||||
onclick="setMetric('{{ m }}')" type="button">
|
||||
{{ m.replace("_", " ").title() }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="country" class="form-label">Countries (select up to 10)</label>
|
||||
<select name="country" id="country" class="form-input" multiple size="8" onchange="this.form.submit()">
|
||||
<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" }}
|
||||
{% else %}
|
||||
Select countries to compare
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="cc-body">
|
||||
|
||||
<!-- ── Dark selector panel ── -->
|
||||
<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"
|
||||
oninput="filterList(this.value)" autocomplete="off">
|
||||
</div>
|
||||
<div class="cc-list" id="cc-list">
|
||||
{% for c in all_countries %}
|
||||
<option value="{{ c.country_code }}" {{ "selected" if c.country_code in selected_codes }}>
|
||||
{{ c.country_name }}
|
||||
</option>
|
||||
<div class="cc-row {{ 'selected' if c.country_code in selected_codes }}"
|
||||
data-code="{{ c.country_code }}"
|
||||
data-name="{{ c.country_name }}"
|
||||
onclick="toggleCountry('{{ c.country_code }}')">
|
||||
<span class="cc-row-rank">{{ loop.index }}</span>
|
||||
<span class="cc-row-name">{{ c.country_name }}</span>
|
||||
<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"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<!-- ── Right canvas ── -->
|
||||
<div class="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 }}
|
||||
<span class="cc-chip-x">×</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="cc-chips-empty">No countries selected</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Chart or empty state -->
|
||||
{% if comparison_data %}
|
||||
<div class="chart-container mb-8">
|
||||
|
||||
<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>
|
||||
{% else %}
|
||||
<div class="plan-gate mb-8">
|
||||
Select countries above to see the comparison chart.
|
||||
</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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const CHART_COLORS = {
|
||||
copper: '#B45309',
|
||||
roast: '#4A2C1A',
|
||||
beanGreen: '#15803D',
|
||||
forest: '#064E3B',
|
||||
stone: '#78716C',
|
||||
espresso: '#2C1810',
|
||||
warning: '#D97706',
|
||||
danger: '#EF4444',
|
||||
parchment: '#E8DFD5',
|
||||
};
|
||||
const CHART_PALETTE = [
|
||||
CHART_COLORS.copper,
|
||||
CHART_COLORS.beanGreen,
|
||||
CHART_COLORS.roast,
|
||||
CHART_COLORS.forest,
|
||||
CHART_COLORS.stone,
|
||||
CHART_COLORS.warning,
|
||||
CHART_COLORS.danger,
|
||||
CHART_COLORS.espresso,
|
||||
// ── Palette ──────────────────────────────────────────────────────────────────
|
||||
const PALETTE = [
|
||||
'#B45309', '#15803D', '#7C3AED', '#0284C7', '#BE185D',
|
||||
'#0F766E', '#C2410C', '#1D4ED8', '#92400E', '#047857',
|
||||
];
|
||||
|
||||
Chart.defaults.font.family = "'DM Sans', sans-serif";
|
||||
Chart.defaults.color = CHART_COLORS.stone;
|
||||
Chart.defaults.borderColor = CHART_COLORS.parchment;
|
||||
// ── 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;
|
||||
|
||||
// Apply palette to server-rendered chips
|
||||
document.querySelectorAll('.cc-chip[data-code]').forEach((chip, i) => {
|
||||
chip.style.background = PALETTE[i % PALETTE.length];
|
||||
});
|
||||
|
||||
// ── 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 }};
|
||||
|
||||
// ── Interaction ──────────────────────────────────────────────────────────────
|
||||
function toggleCountry(code) {
|
||||
if (selectedSet.has(code)) {
|
||||
selectedSet.delete(code);
|
||||
orderedSel.splice(orderedSel.indexOf(code), 1);
|
||||
} else {
|
||||
if (selectedSet.size >= 10) { flashCount(); return; }
|
||||
selectedSet.add(code);
|
||||
orderedSel.push(code);
|
||||
}
|
||||
syncPanelRow(code);
|
||||
rebuildChips();
|
||||
updateCountLabel();
|
||||
updateStatus();
|
||||
}
|
||||
|
||||
function syncPanelRow(code) {
|
||||
const row = document.querySelector(`.cc-row[data-code="${code}"]`);
|
||||
if (row) row.classList.toggle('selected', selectedSet.has(code));
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
function flashCount() {
|
||||
const 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);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
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 }};
|
||||
|
||||
if (rawData.length > 0) {
|
||||
const byCountry = {};
|
||||
for (const row of rawData) {
|
||||
if (!byCountry[row.country_name]) byCountry[row.country_name] = [];
|
||||
byCountry[row.country_name].push(row);
|
||||
}
|
||||
function buildRankings(data, metric, orderedCodes) {
|
||||
if (!data.length) 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;
|
||||
|
||||
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>`;
|
||||
}).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}
|
||||
`;
|
||||
}
|
||||
|
||||
if (rawData.length > 0) {
|
||||
buildRankings(rawData, metric, orderedSel);
|
||||
|
||||
// ── 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();
|
||||
|
||||
const datasets = Object.entries(byCountry).map(([name, rows], i) => {
|
||||
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];
|
||||
return {
|
||||
label: name,
|
||||
data: allYears.map(y => yearMap[y] ?? null),
|
||||
borderColor: CHART_PALETTE[i % CHART_PALETTE.length],
|
||||
tension: 0.3,
|
||||
spanGaps: true
|
||||
borderColor: color,
|
||||
backgroundColor: color + '14',
|
||||
borderWidth: 2.5,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 14,
|
||||
tension: 0.35,
|
||||
spanGaps: true,
|
||||
fill: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -108,12 +810,54 @@ if (rawData.length > 0) {
|
||||
data: { labels: allYears, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
animation: { duration: 500, easing: 'easeInOutQuart' },
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'bottom' },
|
||||
title: { display: true, text: metric.replace(/_/g, ' ') + ' by Country' }
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
boxWidth: 10,
|
||||
boxHeight: 10,
|
||||
borderRadius: 5,
|
||||
useBorderRadius: true,
|
||||
padding: 16,
|
||||
font: { family: "'DM Sans', sans-serif", size: 11.5 },
|
||||
color: '#57534E',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#2C1810',
|
||||
titleColor: 'rgba(255,255,255,0.5)',
|
||||
bodyColor: 'rgba(255,255,255,0.9)',
|
||||
borderColor: 'rgba(255,255,255,0.07)',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
cornerRadius: 10,
|
||||
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()}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
|
||||
ticks: { font: { family: "'DM Sans', sans-serif", size: 11 }, color: '#78716C', maxTicksLimit: 8 },
|
||||
border: { display: false },
|
||||
},
|
||||
y: {
|
||||
grid: { color: 'rgba(232,223,213,0.45)', drawTicks: false },
|
||||
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,
|
||||
},
|
||||
border: { display: false },
|
||||
beginAtZero: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: { y: { beginAtZero: false } }
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user