Add visual month navigator with mini donut chart previews

Replace dropdown selector with a visual navigator featuring:
- Left/right arrow buttons for month navigation
- Clickable month buttons with mini donut chart previews
- Russian month labels (e.g., "Январь'25")
- Responsive layout for mobile screens
- Arrows disable at navigation boundaries

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-01-29 14:05:30 +03:00
parent a805914fe6
commit 9a0589d3f9
3 changed files with 324 additions and 40 deletions

215
app.js
View File

@ -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 // Load all available month files
async function loadAvailableMonths() { async function loadAvailableMonths() {
// Load TinyColor first // Load TinyColor first
@ -878,59 +952,112 @@ async function loadAvailableMonths() {
// Fetch available months from server // Fetch available months from server
const response = await fetch('/api/months'); 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'); console.error('No month data files found');
return; return;
} }
const select = document.getElementById('month-select'); const select = document.getElementById('month-select');
const monthList = document.getElementById('month-list');
// Clear existing options // Clear existing options
select.innerHTML = ''; select.innerHTML = '';
monthList.innerHTML = '';
months.forEach(month => {
// Populate hidden select (for compatibility with renderChart)
availableMonths.forEach(month => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = month; option.value = month;
option.textContent = month; option.textContent = month;
select.appendChild(option); select.appendChild(option);
}); });
// Load the most recent month by default // Load data for all months and create buttons with previews
const latestMonth = months[months.length - 1]; await Promise.all(availableMonths.map(async (month, index) => {
// 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;
const data = await parseCSV(`altcats-${month}.csv`); 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); const sunburstData = transformToSunburst(data);
// Update only the series data and preserve layout // Update only the series data and preserve layout
const oldData = option.series.data; const oldData = option.series.data;
const newData = sunburstData.data; const newData = sunburstData.data;
// Map old values to new data to preserve positions // Map old values to new data to preserve positions
newData.forEach((newItem, index) => { newData.forEach((newItem, idx) => {
if (oldData[index]) { if (oldData[idx]) {
newItem.layoutId = oldData[index].name; // Use name as layout identifier newItem.layoutId = oldData[idx].name;
} }
}); });
// Update the data // Update the data
option.series.data = newData; option.series.data = newData;
// Update the total amount in the center text // Update the total amount in the center text
const russianMonth = getRussianMonthName(month); const russianMonth = getRussianMonthName(month);
option.graphic.elements[0].style.text = russianMonth + '\n' + sunburstData.total.toFixed(0).toLocaleString() + ' ₽'; option.graphic.elements[0].style.text = russianMonth + '\n' + sunburstData.total.toFixed(0).toLocaleString() + ' ₽';
myChart.setOption({ myChart.setOption({
series: [{ series: [{
type: 'sunburst', type: 'sunburst',
@ -938,15 +1065,45 @@ async function loadAvailableMonths() {
layoutAnimation: true, layoutAnimation: true,
animationDuration: 500, animationDuration: 500,
animationEasing: 'cubicInOut' animationEasing: 'cubicInOut'
}] }],
graphic: option.graphic
}, { }, {
lazyUpdate: false, lazyUpdate: false,
silent: false silent: false
}); });
// Update hover events // Update hover events
setupHoverEvents(sunburstData); 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 // Initialize the visualization

View File

@ -12,10 +12,12 @@
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>Семейные траты за месяц</h1> <h1>Семейные траты за месяц</h1>
<div class="month-selector"> <div class="month-navigator">
<label for="month-select">Месяц:</label> <button class="nav-arrow nav-prev" id="prev-month">&#8249;</button>
<select id="month-select"></select> <div class="month-list" id="month-list"></div>
<button class="nav-arrow nav-next" id="next-month">&#8250;</button>
</div> </div>
<select id="month-select" style="display: none;"></select>
</div> </div>
<div class="content-wrapper"> <div class="content-wrapper">
<div id="chart-container"></div> <div id="chart-container"></div>

View File

@ -23,16 +23,108 @@ body {
margin-bottom: 20px; margin-bottom: 20px;
} }
.month-selector { /* Month Navigator */
.month-navigator {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
max-width: 60%;
} }
#month-select { .nav-arrow {
padding: 8px; width: 36px;
border-radius: 4px; height: 36px;
border: 1px solid #ddd; 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 { .content-wrapper {
@ -163,15 +255,26 @@ body {
@media (max-width: 850px) { @media (max-width: 850px) {
.header {
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
.month-navigator {
max-width: 100%;
width: 100%;
}
.content-wrapper { .content-wrapper {
flex-direction: column; flex-direction: column;
} }
#chart-container { #chart-container {
width: 100%; width: 100%;
height: 90lvw height: 90lvw
} }
#details-box { #details-box {
position: relative; position: relative;
top: 0; top: 0;
@ -181,9 +284,31 @@ body {
max-height: 300px; max-height: 300px;
min-width: 100%; min-width: 100%;
} }
.top-item-amount { .top-item-amount {
min-width: 80px; 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;
}
}