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

197
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
async function loadAvailableMonths() {
// Load TinyColor first
@ -878,39 +952,92 @@ 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 = '';
monthList.innerHTML = '';
months.forEach(month => {
// 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
@ -918,9 +1045,9 @@ async function loadAvailableMonths() {
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;
}
});
@ -938,7 +1065,8 @@ async function loadAvailableMonths() {
layoutAnimation: true,
animationDuration: 500,
animationEasing: 'cubicInOut'
}]
}],
graphic: option.graphic
}, {
lazyUpdate: false,
silent: false
@ -946,7 +1074,36 @@ async function loadAvailableMonths() {
// 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

View File

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

View File

@ -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,6 +255,17 @@ 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;
}
@ -187,3 +290,25 @@ body {
}
}
@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;
}
}