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 @@

Семейные траты

+
+ + +
diff --git a/styles.css b/styles.css index 82be595..ecd16b7 100644 --- a/styles.css +++ b/styles.css @@ -18,8 +18,8 @@ body { .header { display: flex; - justify-content: space-between; align-items: center; + gap: 16px; margin-bottom: 20px; } @@ -29,6 +29,7 @@ body { align-items: center; gap: 8px; max-width: 60%; + margin-left: auto; } .nav-arrow { @@ -127,6 +128,62 @@ body { font-size: 12px; } +/* View switcher */ +.view-switcher { + display: inline-flex; + gap: 4px; +} + +.view-switcher-btn { + background: none; + border: none; + border-bottom: 2px solid transparent; + padding: 4px 8px; + font-size: 14px; + font-family: inherit; + color: #999; + cursor: pointer; + transition: color 0.2s, border-color 0.2s; +} + +.view-switcher-btn:hover { + color: #666; +} + +.view-switcher-btn.active { + color: #333; + border-bottom-color: #333; +} + +/* Timeline mode modifiers */ +.container.timeline-mode #center-label, +.container.timeline-mode #chart-eye-btn { + display: none !important; +} + +.container.timeline-mode .month-navigator { + visibility: hidden; + pointer-events: none; +} + +.container.timeline-mode .chart-wrapper { + width: 100%; +} + +.container.timeline-mode #details-box { + position: absolute; + top: 20px; + right: 30px; + width: 280px; + min-width: 0; + max-width: 280px; + background: transparent; + box-shadow: none; + border: none; + opacity: 1; + z-index: 10; +} + .content-wrapper { display: flex; position: relative; @@ -347,7 +404,48 @@ body { @media (max-width: 850px) { + .view-switcher { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 900; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + display: flex; + justify-content: center; + gap: 0; + padding: 8px 16px; + padding-bottom: calc(8px + env(safe-area-inset-bottom)); + border-top: 1px solid #e0e0e0; + } + + .view-switcher-btn { + flex: 1; + max-width: 160px; + padding: 8px 16px; + border-radius: 20px; + border-bottom: none; + font-size: 14px; + font-weight: 500; + text-align: center; + color: #666; + background: transparent; + } + + .view-switcher-btn.active { + background: #333; + color: #fff; + border-bottom: none; + } + + .container { + padding-bottom: calc(70px + env(safe-area-inset-bottom)); + } + .header { + display: flex; flex-direction: column; align-items: flex-start; gap: 15px; @@ -378,6 +476,23 @@ body { overflow-y: auto; } + .container.timeline-mode .chart-wrapper { + width: 100%; + } + + .container.timeline-mode #details-box { + position: relative; + top: auto; + right: auto; + width: 100%; + min-width: 100%; + max-width: none; + background: white; + box-shadow: 0 2px 15px rgba(0, 0, 0, 0.15); + border: 1px solid #eee; + margin-top: 15px; + } + .top-item-amount { min-width: 80px; }