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:
Deeman
2026-02-21 22:30:37 +01:00
parent 493ce64fde
commit 91a9fb83be

View File

@@ -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 &amp; 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>