diff --git a/app.js b/app.js index 391f2a8..7493418 100644 --- a/app.js +++ b/app.js @@ -871,6 +871,80 @@ function loadTinyColor() { }); } +// Global state for month navigation +let availableMonths = []; +let currentMonthIndex = 0; +let monthDataCache = {}; // Cache for month data previews + +// Predefined colors for categories (same as in transformToSunburst) +const categoryColors = [ + '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', + '#2d8041', '#fc8452', '#7b4d90', '#ea7ccc', '#4cae72', + '#d56358', '#82b1ff', '#f19143', '#addf84', '#6f7787' +]; + +// Fixed order for categories +const categoryOrder = [ + 'Квартира', 'Еда', 'Технологии', 'Развлечения', 'Семьи', + 'Здоровье', 'Логистика', 'Расходники', 'Красота' +]; + +// Generate conic-gradient CSS for a month's category breakdown +function generateMonthPreviewGradient(data) { + // Group by category and sum amounts + const categoryTotals = {}; + let total = 0; + + data.forEach(item => { + const category = item.category || ''; + const amount = Math.abs(parseFloat(item.amount_rub)); + if (!isNaN(amount) && category) { + categoryTotals[category] = (categoryTotals[category] || 0) + amount; + total += amount; + } + }); + + if (total === 0) return 'conic-gradient(#eee 0deg 360deg)'; + + // Sort categories by predefined order + const sortedCategories = Object.keys(categoryTotals).sort((a, b) => { + const indexA = categoryOrder.indexOf(a); + const indexB = categoryOrder.indexOf(b); + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return categoryTotals[b] - categoryTotals[a]; + }); + + // Build conic-gradient + const gradientStops = []; + let currentAngle = 0; + + sortedCategories.forEach((category, index) => { + const percentage = categoryTotals[category] / total; + const angle = percentage * 360; + const colorIndex = categoryOrder.indexOf(category); + const color = colorIndex !== -1 ? categoryColors[colorIndex] : categoryColors[index % categoryColors.length]; + + gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`); + currentAngle += angle; + }); + + return `conic-gradient(${gradientStops.join(', ')})`; +} + +// Format month for display: "2025-01" -> "Январь'25" +function formatMonthLabel(dateStr) { + const [year, month] = dateStr.split('-'); + const shortYear = year.slice(-2); + const russianMonths = [ + 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', + 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь' + ]; + const monthName = russianMonths[parseInt(month) - 1]; + return `${monthName}'${shortYear}`; +} + // Load all available month files async function loadAvailableMonths() { // Load TinyColor first @@ -878,59 +952,112 @@ async function loadAvailableMonths() { // Fetch available months from server const response = await fetch('/api/months'); - const months = await response.json(); + availableMonths = await response.json(); - if (months.length === 0) { + if (availableMonths.length === 0) { console.error('No month data files found'); return; } const select = document.getElementById('month-select'); - + const monthList = document.getElementById('month-list'); + // Clear existing options select.innerHTML = ''; - - months.forEach(month => { + monthList.innerHTML = ''; + + // Populate hidden select (for compatibility with renderChart) + availableMonths.forEach(month => { const option = document.createElement('option'); option.value = month; option.textContent = month; select.appendChild(option); }); - - // Load the most recent month by default - const latestMonth = months[months.length - 1]; - - // Set the select value to match BEFORE loading data - select.value = latestMonth; - - // Now load the data for the selected month - const data = await parseCSV(`altcats-${latestMonth}.csv`); - renderChart(data); - - // Add event listener for month selection - select.addEventListener('change', async (e) => { - const month = e.target.value; + + // Load data for all months and create buttons with previews + await Promise.all(availableMonths.map(async (month, index) => { const data = await parseCSV(`altcats-${month}.csv`); + monthDataCache[month] = data; + })); + + // Create month buttons with previews + availableMonths.forEach((month, index) => { + const btn = document.createElement('button'); + btn.className = 'month-btn'; + btn.dataset.month = month; + btn.dataset.index = index; + + // Create preview circle + const preview = document.createElement('div'); + preview.className = 'month-preview'; + preview.style.background = generateMonthPreviewGradient(monthDataCache[month]); + + // Create label + const label = document.createElement('span'); + label.className = 'month-label'; + label.textContent = formatMonthLabel(month); + + btn.appendChild(preview); + btn.appendChild(label); + btn.addEventListener('click', () => selectMonth(index)); + monthList.appendChild(btn); + }); + + // Load the most recent month by default + currentMonthIndex = availableMonths.length - 1; + await selectMonth(currentMonthIndex); + + // Set up arrow button handlers + document.getElementById('prev-month').addEventListener('click', () => { + if (currentMonthIndex > 0) { + selectMonth(currentMonthIndex - 1); + } + }); + + document.getElementById('next-month').addEventListener('click', () => { + if (currentMonthIndex < availableMonths.length - 1) { + selectMonth(currentMonthIndex + 1); + } + }); +} + +// Select and load a specific month +async function selectMonth(index) { + currentMonthIndex = index; + const month = availableMonths[index]; + + // Update hidden select for compatibility + const select = document.getElementById('month-select'); + select.value = month; + + // Update month button active states + updateMonthNavigator(); + + // Load and render data + const data = await parseCSV(`altcats-${month}.csv`); + + // Check if chart already has data (for animation) + if (option && option.series && option.series.data) { const sunburstData = transformToSunburst(data); - + // Update only the series data and preserve layout const oldData = option.series.data; const newData = sunburstData.data; - + // Map old values to new data to preserve positions - newData.forEach((newItem, index) => { - if (oldData[index]) { - newItem.layoutId = oldData[index].name; // Use name as layout identifier + newData.forEach((newItem, idx) => { + if (oldData[idx]) { + newItem.layoutId = oldData[idx].name; } }); - + // Update the data option.series.data = newData; - + // Update the total amount in the center text const russianMonth = getRussianMonthName(month); option.graphic.elements[0].style.text = russianMonth + '\n' + sunburstData.total.toFixed(0).toLocaleString() + ' ₽'; - + myChart.setOption({ series: [{ type: 'sunburst', @@ -938,15 +1065,45 @@ async function loadAvailableMonths() { layoutAnimation: true, animationDuration: 500, animationEasing: 'cubicInOut' - }] + }], + graphic: option.graphic }, { lazyUpdate: false, silent: false }); - + // Update hover events setupHoverEvents(sunburstData); + } else { + // Initial render + renderChart(data); + } + + // Scroll selected month button into view + const activeBtn = document.querySelector('.month-btn.active'); + if (activeBtn) { + activeBtn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + } +} + +// Update month navigator UI state +function updateMonthNavigator() { + // Update button active states + const buttons = document.querySelectorAll('.month-btn'); + buttons.forEach((btn, index) => { + if (index === currentMonthIndex) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } }); + + // Update arrow disabled states + const prevBtn = document.getElementById('prev-month'); + const nextBtn = document.getElementById('next-month'); + + prevBtn.disabled = currentMonthIndex === 0; + nextBtn.disabled = currentMonthIndex === availableMonths.length - 1; } // Initialize the visualization diff --git a/index.html b/index.html index a02271e..6141925 100644 --- a/index.html +++ b/index.html @@ -12,10 +12,12 @@

Семейные траты за месяц

-
- - +
+ +
+
+
diff --git a/styles.css b/styles.css index 34fd997..6b95f6d 100644 --- a/styles.css +++ b/styles.css @@ -23,16 +23,108 @@ body { margin-bottom: 20px; } -.month-selector { +/* Month Navigator */ +.month-navigator { display: flex; align-items: center; - gap: 10px; + gap: 8px; + max-width: 60%; } -#month-select { - padding: 8px; - border-radius: 4px; +.nav-arrow { + width: 36px; + height: 36px; border: 1px solid #ddd; + border-radius: 4px; + background-color: white; + font-size: 24px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s, border-color 0.2s; + flex-shrink: 0; +} + +.nav-arrow:hover:not(:disabled) { + background-color: #f0f0f0; + border-color: #999; +} + +.nav-arrow:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.month-list { + display: flex; + gap: 6px; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.month-list::-webkit-scrollbar { + display: none; +} + +.month-btn { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background-color: white; + font-size: 14px; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.2s, border-color 0.2s, color 0.2s; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.month-btn:hover { + background-color: #f0f0f0; + border-color: #999; +} + +.month-btn.active { + background-color: #5470c6; + border-color: #5470c6; + color: white; +} + +.month-preview { + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid #eee; + position: relative; +} + +.month-preview::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 35%; + height: 35%; + background: white; + border-radius: 50%; +} + +.month-btn.active .month-preview { + border-color: rgba(255, 255, 255, 0.3); +} + +.month-btn.active .month-preview::after { + background: #5470c6; +} + +.month-label { + font-size: 12px; } .content-wrapper { @@ -163,15 +255,26 @@ body { @media (max-width: 850px) { + .header { + flex-direction: column; + align-items: flex-start; + gap: 15px; + } + + .month-navigator { + max-width: 100%; + width: 100%; + } + .content-wrapper { flex-direction: column; } - + #chart-container { width: 100%; height: 90lvw } - + #details-box { position: relative; top: 0; @@ -181,9 +284,31 @@ body { max-height: 300px; min-width: 100%; } - + .top-item-amount { min-width: 80px; } } +@media (max-width: 500px) { + .month-btn { + padding: 6px 8px; + font-size: 12px; + } + + .month-preview { + width: 32px; + height: 32px; + } + + .month-label { + font-size: 10px; + } + + .nav-arrow { + width: 30px; + height: 30px; + font-size: 20px; + } +} +