/** * QuadrantX Dashboard - Charts Module * * Handles chart creation and updates. */ class ChartManager { constructor() { this.mainChart = null; this.colors = [ '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16', '#06b6d4', '#d946ef', '#22c55e', '#eab308', '#a855f7' ]; } /** * Initialize main chart */ initMainChart(canvasId) { const ctx = document.getElementById(canvasId).getContext('2d'); this.mainChart = new Chart(ctx, { type: 'line', data: { datasets: [] }, options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'nearest', intersect: false }, plugins: { legend: { display: false // Using custom legend }, tooltip: { callbacks: { title: (items) => { if (!items.length) return ''; const date = new Date(items[0].parsed.x); return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }, label: (context) => { const dataPoint = context.raw; return [ `${context.dataset.label}: ${dataPoint.y.toFixed(1)}`, `ND: ${dataPoint.nd?.toFixed(1) || '-'}`, `Sentiment: ${dataPoint.sentiment?.toFixed(1) || '-'}`, `Report: ${dataPoint.reportId?.substring(0, 8) || '-'}` ]; } } }, zoom: { pan: { enabled: true, mode: 'xy', threshold: 5 }, zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' }, limits: { x: { min: 'original', max: 'original' }, y: { min: -50, max: 150 } } } }, scales: { x: { type: 'time', time: { unit: 'day', displayFormats: { day: 'MMM d', week: 'MMM d', month: 'MMM yyyy' } }, title: { display: true, text: 'Date' }, grid: { color: 'rgba(255, 255, 255, 0.1)' }, ticks: { color: '#a0a0b0' } }, y: { min: 0, max: 105, title: { display: true, text: 'Score' }, grid: { color: 'rgba(255, 255, 255, 0.1)' }, ticks: { color: '#a0a0b0' } } }, onClick: (event, elements) => { if (elements.length > 0) { const element = elements[0]; const dataPoint = this.mainChart.data.datasets[element.datasetIndex].data[element.index]; if (dataPoint && this.onPointClick) { this.onPointClick(dataPoint.reportId, dataPoint.vendorId, dataPoint.vendorName, dataPoint.logoFile); } } }, onHover: (event, elements) => { event.native.target.style.cursor = elements.length > 0 ? 'pointer' : 'default'; } } }); // Double-click to reset zoom document.getElementById(canvasId).addEventListener('dblclick', () => { this.mainChart.resetZoom(); }); return this.mainChart; } /** * Update chart with new data */ updateChart(data, chartType = 'quadrant') { if (!this.mainChart) return; // Group data by vendor const vendorData = {}; data.forEach(item => { if (!vendorData[item.vendor_id]) { vendorData[item.vendor_id] = { name: item.vendor_name, domain: item.domain, logoFile: item.logo_file, points: [] }; } let yValue; switch (chartType) { case 'nd': yValue = item.narrative_dominance; break; case 'sentiment': yValue = item.sentiment_score; break; case 'quadrant': default: yValue = (item.narrative_dominance + item.sentiment_score) / 2; } vendorData[item.vendor_id].points.push({ x: new Date(item.generated_at), y: yValue, nd: item.narrative_dominance, sentiment: item.sentiment_score, reportId: item.report_id, vendorId: item.vendor_id, vendorName: item.vendor_name, logoFile: item.logo_file }); }); // Create datasets const datasets = []; let colorIndex = 0; for (const [vendorId, vendor] of Object.entries(vendorData)) { const color = this.colors[colorIndex % this.colors.length]; datasets.push({ label: vendor.name, data: vendor.points.sort((a, b) => a.x - b.x), borderColor: color, backgroundColor: color + '20', pointBackgroundColor: color, pointBorderColor: color, pointRadius: 6, pointHoverRadius: 8, tension: 0.1, fill: false, vendorId: vendorId, logoFile: vendor.logoFile, domain: vendor.domain }); colorIndex++; } this.mainChart.data.datasets = datasets; // Calculate smart zoom based on data if (datasets.length > 0) { const allDates = datasets.flatMap(d => d.data.map(p => p.x.getTime())); const allValues = datasets.flatMap(d => d.data.map(p => p.y)); if (allDates.length > 0 && allValues.length > 0) { const minDate = Math.min(...allDates); const maxDate = Math.max(...allDates); const minValue = Math.min(...allValues); const maxValue = Math.max(...allValues); // X-axis: 25% padding, min 3 days const dateRange = maxDate - minDate; const xPadding = Math.max(dateRange * 0.25, 3 * 24 * 60 * 60 * 1000); // Y-axis: 15% padding, min 5 points const valueRange = maxValue - minValue; const yPadding = Math.max(valueRange * 0.15, 5); this.mainChart.options.scales.x.min = minDate - xPadding; this.mainChart.options.scales.x.max = maxDate + xPadding; this.mainChart.options.scales.y.min = Math.max(0, minValue - yPadding); this.mainChart.options.scales.y.max = Math.min(105, maxValue + yPadding); } } this.mainChart.update(); // Generate custom legend this.generateLegend(datasets); } /** * Generate custom legend with logos */ generateLegend(datasets) { const container = document.getElementById('chartLegend'); if (!container) return; container.innerHTML = ''; datasets.forEach((dataset, index) => { const item = document.createElement('div'); item.className = 'legend-item'; item.style.borderColor = dataset.borderColor; item.style.borderWidth = '2px'; item.style.borderStyle = 'solid'; // Logo or color swatch if (dataset.logoFile) { const logo = document.createElement('img'); logo.src = `/static/logos/${dataset.logoFile}`; logo.alt = dataset.label; logo.className = 'legend-logo'; logo.onerror = () => { logo.style.display = 'none'; }; item.appendChild(logo); } else { const swatch = document.createElement('div'); swatch.className = 'legend-color'; swatch.style.backgroundColor = dataset.borderColor; item.appendChild(swatch); } // Name const name = document.createElement('span'); name.className = 'legend-name'; name.textContent = dataset.label; item.appendChild(name); // Click handler: ON -> OFF -> ONLY -> ON item.addEventListener('click', () => { const legendItems = container.querySelectorAll('.legend-item'); if (item.classList.contains('only')) { // ONLY -> ON: Reset all legendItems.forEach((li, i) => { li.classList.remove('hidden', 'only'); this.mainChart.getDatasetMeta(i).hidden = false; }); } else if (item.classList.contains('hidden')) { // OFF -> ONLY: Show only this legendItems.forEach((li, i) => { if (i === index) { li.classList.remove('hidden'); li.classList.add('only'); this.mainChart.getDatasetMeta(i).hidden = false; } else { li.classList.add('hidden'); li.classList.remove('only'); this.mainChart.getDatasetMeta(i).hidden = true; } }); } else { // ON -> OFF: Hide this item.classList.add('hidden'); this.mainChart.getDatasetMeta(index).hidden = true; } this.mainChart.update(); }); container.appendChild(item); }); } /** * Set click callback for data points */ setPointClickCallback(callback) { this.onPointClick = callback; } /** * Reset chart zoom */ resetZoom() { if (this.mainChart) { this.mainChart.resetZoom(); } } /** * Destroy chart */ destroy() { if (this.mainChart) { this.mainChart.destroy(); this.mainChart = null; } } } // Export for use in app window.ChartManager = ChartManager;