diff --git a/app.js b/app.js index 3e3b87b..0fca778 100644 --- a/app.js +++ b/app.js @@ -37,6 +37,7 @@ const myChart = echarts.init(chartDom); let option; let originalSunburstData = null; // Stores the original data for the current month (for reset on center click) let showDefaultView = null; // Reference to reset details panel to default view +let currentView = 'month'; // 'month' | 'timeline' // Drill-down history for back/forward navigation let drillDownHistory = []; @@ -455,6 +456,210 @@ function transformToSunburst(data) { }; } +// Build ECharts stacked bar option for timeline view +function buildTimelineOption() { + const months = availableMonths; + const xLabels = months.map(m => formatMonthLabel(m)); + + // Collect per-category totals for each month + const categoryTotals = {}; // { categoryName: [amountForMonth0, amountForMonth1, ...] } + + months.forEach((month, mi) => { + const data = monthDataCache[month]; + if (!data) return; + data.forEach(item => { + const cat = item.category || ''; + if (!cat) return; + const amount = Math.abs(parseFloat(item.amount_rub)); + if (isNaN(amount)) return; + if (!categoryTotals[cat]) { + categoryTotals[cat] = new Array(months.length).fill(0); + } + categoryTotals[cat][mi] += amount; + }); + }); + + // Build series in categoryOrder, then append any unknown categories + const seriesList = []; + const usedCategories = new Set(); + + categoryOrder.forEach((catName, ci) => { + if (!categoryTotals[catName]) return; + usedCategories.add(catName); + seriesList.push({ + name: catName, + type: 'bar', + stack: 'total', + barMaxWidth: 50, + barCategoryGap: '35%', + data: categoryTotals[catName], + itemStyle: { color: categoryColors[ci] } + }); + }); + + // Unknown categories (not in categoryOrder) + Object.keys(categoryTotals).forEach(catName => { + if (usedCategories.has(catName)) return; + seriesList.push({ + name: catName, + type: 'bar', + stack: 'total', + barMaxWidth: 50, + barCategoryGap: '35%', + data: categoryTotals[catName] + }); + }); + + return { + backgroundColor: '#fff', + tooltip: { show: false }, + grid: { + left: 30, + right: '28%', + top: 30, + bottom: 30, + containLabel: true + }, + xAxis: { + type: 'category', + data: xLabels, + axisLabel: { fontSize: 11 } + }, + yAxis: { + type: 'value', + axisLabel: { + formatter: function(val) { + if (val >= 1000) return Math.round(val / 1000) + 'k'; + return val; + }, + fontSize: 11 + } + }, + series: seriesList + }; +} + +// Update details panel for timeline view +function updateTimelineDetails(monthIndex) { + const detailsHeader = document.getElementById('details-header'); + const topItemsEl = document.getElementById('top-items'); + + // Reset header to clean state (remove any eye buttons from sunburst mode) + detailsHeader.innerHTML = ''; + const hoverName = detailsHeader.querySelector('.hover-name'); + const hoverAmount = detailsHeader.querySelector('.hover-amount'); + + let monthLabel, data; + if (monthIndex === null || monthIndex === undefined) { + monthLabel = 'Все месяцы'; + data = []; + for (const month of availableMonths) { + if (monthDataCache[month]) data.push(...monthDataCache[month]); + } + } else { + const month = availableMonths[monthIndex]; + monthLabel = formatMonthLabel(month); + data = monthDataCache[month] || []; + } + + // Compute category totals + const catTotals = {}; + let total = 0; + data.forEach(item => { + const cat = item.category || ''; + if (!cat) return; + const amount = Math.abs(parseFloat(item.amount_rub)); + if (isNaN(amount)) return; + catTotals[cat] = (catTotals[cat] || 0) + amount; + total += amount; + }); + + hoverName.textContent = monthLabel; + hoverAmount.textContent = formatRUB(total) + '\u202F₽'; + + // Sort by value descending + const sorted = Object.entries(catTotals).sort((a, b) => b[1] - a[1]); + + topItemsEl.innerHTML = ''; + sorted.forEach(([cat, amount]) => { + const pct = ((amount / total) * 100).toFixed(1); + const colorIdx = categoryOrder.indexOf(cat); + const color = colorIdx !== -1 ? categoryColors[colorIdx] : '#999'; + + const item = document.createElement('div'); + item.className = 'top-item'; + item.innerHTML = `${cat}${pct}%${formatRUB(amount)}\u202F₽`; + topItemsEl.appendChild(item); + }); +} + +// Switch between month (sunburst) and timeline (stacked bar) views +function switchView(viewName) { + if (viewName === currentView) return; + currentView = viewName; + + // Update switcher button active state + document.querySelectorAll('.view-switcher-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.view === viewName); + }); + + const container = document.querySelector('.container'); + + if (viewName === 'timeline') { + container.classList.add('timeline-mode'); + // Remove all chart event listeners + myChart.off('click'); + myChart.off('mouseover'); + myChart.off('mouseout'); + myChart.off('globalout'); + // Resize after layout change, then render stacked bar + myChart.resize(); + myChart.setOption(buildTimelineOption(), true); + + // Hover events update the details panel + myChart.on('mouseover', function(params) { + if (params.componentType === 'series') { + updateTimelineDetails(params.dataIndex); + } + }); + myChart.on('globalout', function() { + updateTimelineDetails(null); + }); + // Show aggregate by default + updateTimelineDetails(null); + + // Click handler: clicking a bar switches to month view for that month + myChart.on('click', function(params) { + if (params.componentType === 'series') { + const monthIndex = params.dataIndex; + if (monthIndex >= 0 && monthIndex < availableMonths.length) { + selectedMonthIndices.clear(); + selectedMonthIndices.add(monthIndex); + currentMonthIndex = monthIndex; + switchView('month'); + selectMonth(monthIndex); + saveMonthSelectionState(); + } + } + }); + } else { + container.classList.remove('timeline-mode'); + // Remove all chart event listeners + myChart.off('click'); + myChart.off('mouseover'); + myChart.off('mouseout'); + myChart.off('globalout'); + // Clear chart completely so no bar chart remnants remain + myChart.clear(); + option = null; + // Resize after layout change, then re-render sunburst from scratch + myChart.resize(); + renderSelectedMonths(); + } + + saveMonthSelectionState(); +} + // Function to get Russian month name from YYYY-MM format function getRussianMonthName(dateStr) { const monthNum = parseInt(dateStr.split('-')[1]); @@ -1080,7 +1285,8 @@ function saveMonthSelectionState() { localStorage.setItem(MONTH_SELECTION_STORAGE_KEY, JSON.stringify({ currentMonth, selectedMonths, - drillPath: [...currentDrillPath] + drillPath: [...currentDrillPath], + view: currentView })); } catch (error) { // Ignore storage failures (private mode, disabled storage, etc.) @@ -1120,6 +1326,11 @@ function restoreMonthSelectionState() { currentDrillPath = []; } + // Defer timeline restore so chart is initialized first + if (saved.view === 'timeline') { + requestAnimationFrame(() => switchView('timeline')); + } + return true; } catch (error) { return false; @@ -1575,6 +1786,13 @@ async function loadAvailableMonths() { } }); } + + // Set up view switcher + document.querySelectorAll('.view-switcher-btn').forEach(btn => { + btn.addEventListener('click', () => { + switchView(btn.dataset.view); + }); + }); } // Transform children data for drill-down (extracted from click handler) @@ -2076,10 +2294,10 @@ initVisualization(); // Handle window resize window.addEventListener('resize', function() { - adjustChartSize(); + if (currentView === 'month') { + adjustChartSize(); + } myChart.resize(); - - // Recalculate chart center and radius for hover detection will be done automatically by setupHoverEvents }); // Function to adjust chart size based on screen width diff --git a/index.html b/index.html index a65163f..1b7a023 100644 --- a/index.html +++ b/index.html @@ -12,6 +12,10 @@