Spaces:
Sleeping
Sleeping
/** | |
* Advanced Chart Visualizations | |
* Provides different chart types for international trade data visualization | |
*/ | |
const TradeCharts = (function() { | |
// Store chart instances to destroy when needed | |
const chartInstances = {}; | |
// Color schemes for different chart types | |
const colorSchemes = { | |
default: [ | |
'rgba(25, 118, 210, 0.7)', // Primary blue | |
'rgba(229, 57, 53, 0.7)', // Red | |
'rgba(67, 160, 71, 0.7)', // Green | |
'rgba(251, 192, 45, 0.7)', // Yellow | |
'rgba(156, 39, 176, 0.7)', // Purple | |
'rgba(0, 188, 212, 0.7)', // Cyan | |
'rgba(255, 152, 0, 0.7)', // Orange | |
'rgba(121, 85, 72, 0.7)', // Brown | |
'rgba(96, 125, 139, 0.7)', // Blue Grey | |
'rgba(233, 30, 99, 0.7)' // Pink | |
], | |
borders: [ | |
'rgba(25, 118, 210, 1)', // Primary blue | |
'rgba(229, 57, 53, 1)', // Red | |
'rgba(67, 160, 71, 1)', // Green | |
'rgba(251, 192, 45, 1)', // Yellow | |
'rgba(156, 39, 176, 1)', // Purple | |
'rgba(0, 188, 212, 1)', // Cyan | |
'rgba(255, 152, 0, 1)', // Orange | |
'rgba(121, 85, 72, 1)', // Brown | |
'rgba(96, 125, 139, 1)', // Blue Grey | |
'rgba(233, 30, 99, 1)' // Pink | |
], | |
gradients: function(ctx) { | |
return [ | |
createGradient(ctx, [25, 118, 210]), | |
createGradient(ctx, [229, 57, 53]), | |
createGradient(ctx, [67, 160, 71]), | |
createGradient(ctx, [251, 192, 45]), | |
createGradient(ctx, [156, 39, 176]), | |
createGradient(ctx, [0, 188, 212]), | |
createGradient(ctx, [255, 152, 0]), | |
createGradient(ctx, [121, 85, 72]), | |
createGradient(ctx, [96, 125, 139]), | |
createGradient(ctx, [233, 30, 99]) | |
]; | |
} | |
}; | |
// Create a gradient color | |
function createGradient(ctx, rgbColor) { | |
const gradient = ctx.createLinearGradient(0, 0, 0, 400); | |
gradient.addColorStop(0, `rgba(${rgbColor[0]}, ${rgbColor[1]}, ${rgbColor[2]}, 0.8)`); | |
gradient.addColorStop(1, `rgba(${rgbColor[0]}, ${rgbColor[1]}, ${rgbColor[2]}, 0.2)`); | |
return gradient; | |
} | |
// Load Chart.js if not present | |
function ensureChartJsLoaded() { | |
return new Promise((resolve, reject) => { | |
if (window.Chart) { | |
resolve(window.Chart); | |
return; | |
} | |
const script = document.createElement('script'); | |
script.src = 'https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.min.js'; | |
script.onload = () => resolve(window.Chart); | |
script.onerror = () => reject(new Error('Failed to load Chart.js')); | |
document.body.appendChild(script); | |
}); | |
} | |
// Helper function to destroy existing chart | |
function destroyChart(containerId) { | |
if (chartInstances[containerId]) { | |
chartInstances[containerId].destroy(); | |
delete chartInstances[containerId]; | |
} | |
} | |
// Helper to extract top N items | |
function getTopItems(data, valueField, labelField, n = 10) { | |
return [...data] | |
.sort((a, b) => b[valueField] - a[valueField]) | |
.slice(0, n) | |
.map(item => ({ | |
value: item[valueField], | |
label: item[labelField] | |
})); | |
} | |
// Create bar chart | |
async function createBarChart(containerId, data, options = {}) { | |
await ensureChartJsLoaded(); | |
const container = document.getElementById(containerId); | |
if (!container) return null; | |
// Create or get canvas | |
let canvas = container.querySelector('canvas'); | |
if (!canvas) { | |
canvas = document.createElement('canvas'); | |
container.innerHTML = ''; | |
container.appendChild(canvas); | |
} | |
destroyChart(containerId); | |
// Default options | |
const defaults = { | |
valueField: 'value', | |
labelField: 'country', | |
title: 'Trade Data', | |
horizontal: false, | |
limit: 10, | |
showLegend: false, | |
animation: true | |
}; | |
const chartOptions = { ...defaults, ...options }; | |
// Prepare data - limit to top N items and process data | |
let chartData; | |
if (Array.isArray(data.rows)) { | |
chartData = getTopItems( | |
data.rows, | |
chartOptions.valueField, | |
chartOptions.labelField, | |
chartOptions.limit | |
); | |
} else if (Array.isArray(data)) { | |
chartData = getTopItems( | |
data, | |
chartOptions.valueField, | |
chartOptions.labelField, | |
chartOptions.limit | |
); | |
} else { | |
console.error('Invalid data format for bar chart'); | |
return null; | |
} | |
// Get context | |
const ctx = canvas.getContext('2d'); | |
// Create chart | |
chartInstances[containerId] = new Chart(ctx, { | |
type: chartOptions.horizontal ? 'horizontalBar' : 'bar', | |
data: { | |
labels: chartData.map(item => item.label), | |
datasets: [{ | |
label: chartOptions.title, | |
data: chartData.map(item => item.value), | |
backgroundColor: colorSchemes.default, | |
borderColor: colorSchemes.borders, | |
borderWidth: 1 | |
}] | |
}, | |
options: { | |
indexAxis: chartOptions.horizontal ? 'y' : 'x', | |
responsive: true, | |
maintainAspectRatio: false, | |
plugins: { | |
legend: { | |
display: chartOptions.showLegend | |
}, | |
title: { | |
display: true, | |
text: chartOptions.title, | |
font: { | |
size: 16 | |
} | |
}, | |
tooltip: { | |
callbacks: { | |
label: function(context) { | |
let label = context.dataset.label || ''; | |
if (label) { | |
label += ': '; | |
} | |
if (context.parsed.y !== null) { | |
label += new Intl.NumberFormat().format( | |
chartOptions.horizontal ? context.parsed.x : context.parsed.y | |
); | |
} | |
return label; | |
} | |
} | |
} | |
}, | |
animation: chartOptions.animation, | |
scales: { | |
y: { | |
beginAtZero: true, | |
ticks: { | |
callback: function(value) { | |
if (value >= 1000000000) { | |
return (value / 1000000000).toFixed(1) + 'B'; | |
} else if (value >= 1000000) { | |
return (value / 1000000).toFixed(1) + 'M'; | |
} else if (value >= 1000) { | |
return (value / 1000).toFixed(1) + 'K'; | |
} | |
return value; | |
} | |
} | |
} | |
} | |
} | |
}); | |
return chartInstances[containerId]; | |
} | |
// Create pie chart | |
async function createPieChart(containerId, data, options = {}) { | |
await ensureChartJsLoaded(); | |
const container = document.getElementById(containerId); | |
if (!container) return null; | |
// Create or get canvas | |
let canvas = container.querySelector('canvas'); | |
if (!canvas) { | |
canvas = document.createElement('canvas'); | |
container.innerHTML = ''; | |
container.appendChild(canvas); | |
} | |
destroyChart(containerId); | |
// Default options | |
const defaults = { | |
valueField: 'value', | |
labelField: 'country', | |
title: 'Trade Distribution', | |
limit: 10, | |
showLegend: true, | |
animation: true, | |
doughnut: false | |
}; | |
const chartOptions = { ...defaults, ...options }; | |
// Prepare data - limit to top N items | |
let chartData; | |
if (Array.isArray(data.rows)) { | |
chartData = getTopItems( | |
data.rows, | |
chartOptions.valueField, | |
chartOptions.labelField, | |
chartOptions.limit | |
); | |
} else if (Array.isArray(data)) { | |
chartData = getTopItems( | |
data, | |
chartOptions.valueField, | |
chartOptions.labelField, | |
chartOptions.limit | |
); | |
} else { | |
console.error('Invalid data format for pie chart'); | |
return null; | |
} | |
// Get context | |
const ctx = canvas.getContext('2d'); | |
// Create chart | |
chartInstances[containerId] = new Chart(ctx, { | |
type: chartOptions.doughnut ? 'doughnut' : 'pie', | |
data: { | |
labels: chartData.map(item => item.label), | |
datasets: [{ | |
data: chartData.map(item => item.value), | |
backgroundColor: colorSchemes.default, | |
borderColor: colorSchemes.borders, | |
borderWidth: 1 | |
}] | |
}, | |
options: { | |
responsive: true, | |
maintainAspectRatio: false, | |
plugins: { | |
legend: { | |
display: chartOptions.showLegend, | |
position: 'right' | |
}, | |
title: { | |
display: true, | |
text: chartOptions.title, | |
font: { | |
size: 16 | |
} | |
}, | |
tooltip: { | |
callbacks: { | |
label: function(context) { | |
const label = context.label || ''; | |
const value = context.raw; | |
const total = context.dataset.data.reduce((a, b) => a + b, 0); | |
const percentage = ((value / total) * 100).toFixed(1); | |
return `${label}: ${new Intl.NumberFormat().format(value)} (${percentage}%)`; | |
} | |
} | |
} | |
}, | |
animation: chartOptions.animation | |
} | |
}); | |
return chartInstances[containerId]; | |
} | |
// Create line chart | |
async function createLineChart(containerId, data, options = {}) { | |
await ensureChartJsLoaded(); | |
const container = document.getElementById(containerId); | |
if (!container) return null; | |
// Create or get canvas | |
let canvas = container.querySelector('canvas'); | |
if (!canvas) { | |
canvas = document.createElement('canvas'); | |
container.innerHTML = ''; | |
container.appendChild(canvas); | |
} | |
destroyChart(containerId); | |
// Default options | |
const defaults = { | |
valueField: 'value', | |
labelField: 'year', | |
title: 'Trade Trends', | |
showLegend: true, | |
animation: true, | |
fill: true, | |
seriesField: null, // If provided, creates multiple series based on this field | |
timeScale: false | |
}; | |
const chartOptions = { ...defaults, ...options }; | |
// Get context | |
const ctx = canvas.getContext('2d'); | |
// Prepare datasets | |
let datasets = []; | |
if (chartOptions.seriesField) { | |
// Group data by series field | |
const seriesData = {}; | |
const sourceData = Array.isArray(data.rows) ? data.rows : data; | |
sourceData.forEach(item => { | |
const seriesKey = item[chartOptions.seriesField]; | |
if (!seriesData[seriesKey]) { | |
seriesData[seriesKey] = []; | |
} | |
seriesData[seriesKey].push({ | |
x: item[chartOptions.labelField], | |
y: item[chartOptions.valueField] | |
}); | |
}); | |
// Create a dataset for each series | |
let colorIndex = 0; | |
for (const seriesKey in seriesData) { | |
datasets.push({ | |
label: seriesKey, | |
data: seriesData[seriesKey], | |
backgroundColor: chartOptions.fill ? colorSchemes.gradients(ctx)[colorIndex % 10] : colorSchemes.default[colorIndex % 10], | |
borderColor: colorSchemes.borders[colorIndex % 10], | |
borderWidth: 2, | |
fill: chartOptions.fill, | |
tension: 0.1 | |
}); | |
colorIndex++; | |
} | |
} else { | |
// Single series | |
const sourceData = Array.isArray(data.rows) ? data.rows : data; | |
// Sort data by label (typically year) | |
sourceData.sort((a, b) => { | |
if (chartOptions.timeScale) { | |
return new Date(a[chartOptions.labelField]) - new Date(b[chartOptions.labelField]); | |
} | |
return a[chartOptions.labelField] - b[chartOptions.labelField]; | |
}); | |
datasets.push({ | |
label: chartOptions.title, | |
data: sourceData.map(item => ({ | |
x: item[chartOptions.labelField], | |
y: item[chartOptions.valueField] | |
})), | |
backgroundColor: chartOptions.fill ? colorSchemes.gradients(ctx)[0] : colorSchemes.default[0], | |
borderColor: colorSchemes.borders[0], | |
borderWidth: 2, | |
fill: chartOptions.fill, | |
tension: 0.1 | |
}); | |
} | |
// Create chart | |
chartInstances[containerId] = new Chart(ctx, { | |
type: 'line', | |
data: { | |
datasets: datasets | |
}, | |
options: { | |
responsive: true, | |
maintainAspectRatio: false, | |
plugins: { | |
legend: { | |
display: chartOptions.showLegend | |
}, | |
title: { | |
display: true, | |
text: chartOptions.title, | |
font: { | |
size: 16 | |
} | |
}, | |
tooltip: { | |
callbacks: { | |
label: function(context) { | |
let label = context.dataset.label || ''; | |
if (label) { | |
label += ': '; | |
} | |
if (context.parsed.y !== null) { | |
label += new Intl.NumberFormat().format(context.parsed.y); | |
} | |
return label; | |
} | |
} | |
} | |
}, | |
animation: chartOptions.animation, | |
scales: { | |
x: { | |
type: chartOptions.timeScale ? 'time' : 'category', | |
time: chartOptions.timeScale ? { | |
unit: 'year', | |
displayFormats: { | |
year: 'yyyy' | |
} | |
} : undefined | |
}, | |
y: { | |
beginAtZero: true, | |
ticks: { | |
callback: function(value) { | |
if (value >= 1000000000) { | |
return (value / 1000000000).toFixed(1) + 'B'; | |
} else if (value >= 1000000) { | |
return (value / 1000000).toFixed(1) + 'M'; | |
} else if (value >= 1000) { | |
return (value / 1000).toFixed(1) + 'K'; | |
} | |
return value; | |
} | |
} | |
} | |
} | |
} | |
}); | |
return chartInstances[containerId]; | |
} | |
// Create treemap for product/country hierarchies | |
async function createTreemap(containerId, data, options = {}) { | |
// This is a simplified treemap using divs since Chart.js doesn't have built-in treemap | |
const container = document.getElementById(containerId); | |
if (!container) return null; | |
// Default options | |
const defaults = { | |
valueField: 'value', | |
labelField: 'country', | |
title: 'Trade Distribution', | |
limit: 20 | |
}; | |
const chartOptions = { ...defaults, ...options }; | |
// Prepare data - limit to top N items | |
let chartData; | |
if (Array.isArray(data.rows)) { | |
chartData = getTopItems( | |
data.rows, | |
chartOptions.valueField, | |
chartOptions.labelField, | |
chartOptions.limit | |
); | |
} else if (Array.isArray(data)) { | |
chartData = getTopItems( | |
data, | |
chartOptions.valueField, | |
chartOptions.labelField, | |
chartOptions.limit | |
); | |
} else { | |
console.error('Invalid data format for treemap'); | |
return null; | |
} | |
// Calculate total for percentages | |
const total = chartData.reduce((sum, item) => sum + item.value, 0); | |
// Create treemap container | |
container.innerHTML = ` | |
<div class="treemap-title">${chartOptions.title}</div> | |
<div class="treemap-container"></div> | |
`; | |
const treemapContainer = container.querySelector('.treemap-container'); | |
treemapContainer.style.display = 'flex'; | |
treemapContainer.style.flexWrap = 'wrap'; | |
treemapContainer.style.height = '400px'; | |
treemapContainer.style.position = 'relative'; | |
// Create rectangles | |
chartData.forEach((item, index) => { | |
const percentage = (item.value / total * 100).toFixed(1); | |
const div = document.createElement('div'); | |
div.className = 'treemap-item'; | |
div.style.backgroundColor = colorSchemes.default[index % colorSchemes.default.length]; | |
div.style.color = '#fff'; | |
div.style.padding = '8px'; | |
div.style.boxSizing = 'border-box'; | |
div.style.overflow = 'hidden'; | |
div.style.fontSize = '12px'; | |
div.style.position = 'relative'; | |
div.style.flexGrow = item.value; | |
// Size must be proportional to value | |
div.style.width = `${Math.sqrt(percentage)}%`; | |
div.style.height = `${Math.sqrt(percentage) * 2}%`; | |
div.style.margin = '2px'; | |
// Text with truncation | |
div.innerHTML = ` | |
<div style="font-weight:bold;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"> | |
${item.label} | |
</div> | |
<div>${percentage}%</div> | |
`; | |
// Tooltip on hover | |
div.title = `${item.label}: ${new Intl.NumberFormat().format(item.value)} (${percentage}%)`; | |
treemapContainer.appendChild(div); | |
}); | |
return treemapContainer; | |
} | |
// Create a world map visualization | |
async function createWorldMapChart(containerId, data, options = {}) { | |
// Load leaflet script if not already loaded | |
if (!window.L) { | |
await new Promise((resolve, reject) => { | |
// Load CSS | |
const leafletCss = document.createElement('link'); | |
leafletCss.rel = 'stylesheet'; | |
leafletCss.href = 'https://unpkg.com/leaflet/dist/leaflet.css'; | |
document.head.appendChild(leafletCss); | |
// Load script | |
const script = document.createElement('script'); | |
script.src = 'https://unpkg.com/leaflet/dist/leaflet.js'; | |
script.onload = resolve; | |
script.onerror = reject; | |
document.body.appendChild(script); | |
}); | |
} | |
const container = document.getElementById(containerId); | |
if (!container) return null; | |
// Default options | |
const defaults = { | |
valueField: 'value', | |
labelField: 'country', | |
countryCodeField: 'code', | |
title: 'World Trade Map', | |
colorScale: ['#e6f7ff', '#0077be'], | |
zoom: 2 | |
}; | |
const chartOptions = { ...defaults, ...options }; | |
// Set container height if not already set | |
if (!container.style.height || container.style.height === 'auto') { | |
container.style.height = '400px'; | |
} | |
// Clear previous map | |
container.innerHTML = ''; | |
// Create map | |
const map = L.map(containerId).setView([20, 0], chartOptions.zoom); | |
// Add tile layer | |
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { | |
maxZoom: 19, | |
attribution: '© OpenStreetMap contributors' | |
}).addTo(map); | |
// Process data | |
const sourceData = Array.isArray(data.rows) ? data.rows : data; | |
// Find min/max for color scaling | |
const values = sourceData.map(item => item[chartOptions.valueField]); | |
const min = Math.min(...values); | |
const max = Math.max(...values); | |
// Function to compute color based on value | |
function getColor(value) { | |
const ratio = (value - min) / (max - min || 1); | |
// Linear interpolation between start and end colors | |
const startColor = chartOptions.colorScale[0]; | |
const endColor = chartOptions.colorScale[1]; | |
// Parse hex colors | |
const startRGB = { | |
r: parseInt(startColor.slice(1, 3), 16), | |
g: parseInt(startColor.slice(3, 5), 16), | |
b: parseInt(startColor.slice(5, 7), 16) | |
}; | |
const endRGB = { | |
r: parseInt(endColor.slice(1, 3), 16), | |
g: parseInt(endColor.slice(3, 5), 16), | |
b: parseInt(endColor.slice(5, 7), 16) | |
}; | |
// Interpolate | |
const r = Math.round(startRGB.r + ratio * (endRGB.r - startRGB.r)); | |
const g = Math.round(startRGB.g + ratio * (endRGB.g - startRGB.g)); | |
const b = Math.round(startRGB.b + ratio * (endRGB.b - startRGB.b)); | |
return `rgb(${r}, ${g}, ${b})`; | |
} | |
// Add country polygons if country GeoJSON is available | |
// For now we'll use circles at country coordinates as a simplified version | |
// Find coordinates for countries (simplified - real app would use GeoJSON) | |
const countryCoordinates = { | |
// Sample coordinates for major countries | |
'842': [37.0902, -95.7129], // USA | |
'156': [35.8617, 104.1954], // China | |
'276': [51.1657, 10.4515], // Germany | |
'392': [36.2048, 138.2529], // Japan | |
'826': [55.3781, -3.4360], // UK | |
'250': [46.2276, 2.2137], // France | |
'380': [41.8719, 12.5674], // Italy | |
'124': [56.1304, -106.3468], // Canada | |
'410': [35.9078, 127.7669], // South Korea | |
'484': [23.6345, -102.5528], // Mexico | |
// Default coordinates for unknown countries | |
'default': [0, 0] | |
}; | |
// Add circles for each country | |
sourceData.forEach(item => { | |
const code = item[chartOptions.countryCodeField]; | |
const value = item[chartOptions.valueField]; | |
const coords = countryCoordinates[code] || countryCoordinates.default; | |
if (coords[0] !== 0 || coords[1] !== 0) { | |
// Size circle based on value | |
const radius = Math.max(5, Math.min(20, 5 + (value - min) / (max - min || 1) * 15)); | |
L.circleMarker(coords, { | |
radius: radius, | |
fillColor: getColor(value), | |
color: '#fff', | |
weight: 1, | |
opacity: 1, | |
fillOpacity: 0.8 | |
}) | |
.addTo(map) | |
.bindPopup(` | |
<strong>${item[chartOptions.labelField]}</strong><br> | |
Value: ${new Intl.NumberFormat().format(value)} | |
`); | |
} | |
}); | |
// Add legend | |
const legend = L.control({ position: 'bottomright' }); | |
legend.onAdd = function() { | |
const div = L.DomUtil.create('div', 'info legend'); | |
div.style.backgroundColor = 'white'; | |
div.style.padding = '10px'; | |
div.style.borderRadius = '5px'; | |
div.style.boxShadow = '0 0 5px rgba(0,0,0,0.2)'; | |
div.innerHTML = ` | |
<div style="font-weight:bold;margin-bottom:5px;">${chartOptions.title}</div> | |
<div style="display:flex;align-items:center;margin-bottom:5px;"> | |
<div style="width:20px;height:20px;background:${chartOptions.colorScale[0]};margin-right:5px;"></div> | |
<span>${new Intl.NumberFormat().format(min)}</span> | |
</div> | |
<div style="display:flex;align-items:center;"> | |
<div style="width:20px;height:20px;background:${chartOptions.colorScale[1]};margin-right:5px;"></div> | |
<span>${new Intl.NumberFormat().format(max)}</span> | |
</div> | |
`; | |
return div; | |
}; | |
legend.addTo(map); | |
return map; | |
} | |
// Public API | |
return { | |
createBarChart, | |
createPieChart, | |
createLineChart, | |
createTreemap, | |
createWorldMapChart, | |
destroyChart | |
}; | |
})(); | |