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:
parent
a805914fe6
commit
9a0589d3f9
197
app.js
197
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
|
// Load all available month files
|
||||||
async function loadAvailableMonths() {
|
async function loadAvailableMonths() {
|
||||||
// Load TinyColor first
|
// Load TinyColor first
|
||||||
@ -878,39 +952,92 @@ 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
|
||||||
@ -918,9 +1045,9 @@ async function loadAvailableMonths() {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -938,7 +1065,8 @@ 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
|
||||||
@ -946,7 +1074,36 @@ async function loadAvailableMonths() {
|
|||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@ -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">‹</button>
|
||||||
<select id="month-select"></select>
|
<div class="month-list" id="month-list"></div>
|
||||||
|
<button class="nav-arrow nav-next" id="next-month">›</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>
|
||||||
|
|||||||
135
styles.css
135
styles.css
@ -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,6 +255,17 @@ 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;
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user