diff --git a/app.js b/app.js index 0797f9a..d3cbab1 100644 --- a/app.js +++ b/app.js @@ -73,11 +73,14 @@ function resetHistory() { // Navigate to a specific history state function navigateToHistoryState(state) { - const russianMonth = getRussianMonthName(document.getElementById('month-select').value); + // Use multi-month label if multiple months selected, otherwise single month name + const monthLabel = selectedMonthIndices.size > 1 + ? generateSelectedMonthsLabel() + : getRussianMonthName(document.getElementById('month-select').value); option.series.data = state.data; myChart.setOption(option, { replaceMerge: ['series'] }); - updateCenterLabel(russianMonth, state.total, state.contextName); + updateCenterLabel(monthLabel, state.total, state.contextName); setupHoverEvents({ total: state.total, data: state.data }, state.contextName); // Restore drill path and update mini-charts @@ -470,9 +473,11 @@ function renderChart(data) { resetHistory(); saveToHistory(sunburstData.data, sunburstData.total, null, true, []); - // Get the currently selected month + // Get the currently selected month label const selectedMonth = document.getElementById('month-select').value; - const russianMonth = getRussianMonthName(selectedMonth); + const russianMonth = selectedMonthIndices.size > 1 + ? generateSelectedMonthsLabel() + : getRussianMonthName(selectedMonth); // Calculate the correct center position first const screenWidth = window.innerWidth; @@ -940,9 +945,11 @@ function renderChart(data) { option.series.data = newData; // Update the center text to show the drilled-down category - const russianMonth = getRussianMonthName(document.getElementById('month-select').value); + const monthLabel = selectedMonthIndices.size > 1 + ? generateSelectedMonthsLabel() + : getRussianMonthName(document.getElementById('month-select').value); myChart.setOption(option, { replaceMerge: ['series'] }); - updateCenterLabel(russianMonth, params.value, params.name); + updateCenterLabel(monthLabel, params.value, params.name); // Update hover events with the new data structure, passing the drilled-down name setupHoverEvents({ total: params.value, data: newData }, params.name); @@ -1045,6 +1052,7 @@ function loadTinyColor() { // Global state for month navigation let availableMonths = []; let currentMonthIndex = 0; +let selectedMonthIndices = new Set(); // Track all selected months for multi-selection let monthDataCache = {}; // Cache for month data previews // Predefined colors for categories (same as in transformToSunburst) @@ -1429,24 +1437,42 @@ async function loadAvailableMonths() { btn.appendChild(preview); btn.appendChild(label); - btn.addEventListener('click', () => selectMonth(index)); + btn.addEventListener('click', (event) => { + if (event.shiftKey) { + toggleMonthSelection(index); + } else { + selectSingleMonth(index); + } + }); monthList.appendChild(btn); }); // Load the most recent month by default currentMonthIndex = availableMonths.length - 1; + selectedMonthIndices.clear(); + selectedMonthIndices.add(currentMonthIndex); await selectMonth(currentMonthIndex); // Set up arrow button handlers - document.getElementById('prev-month').addEventListener('click', () => { + document.getElementById('prev-month').addEventListener('click', (event) => { if (currentMonthIndex > 0) { - selectMonth(currentMonthIndex - 1); + if (event.shiftKey) { + // Add previous month to selection + toggleMonthSelection(currentMonthIndex - 1); + } else { + selectSingleMonth(currentMonthIndex - 1); + } } }); - document.getElementById('next-month').addEventListener('click', () => { + document.getElementById('next-month').addEventListener('click', (event) => { if (currentMonthIndex < availableMonths.length - 1) { - selectMonth(currentMonthIndex + 1); + if (event.shiftKey) { + // Add next month to selection + toggleMonthSelection(currentMonthIndex + 1); + } else { + selectSingleMonth(currentMonthIndex + 1); + } } }); @@ -1656,6 +1682,136 @@ function navigateToPath(sunburstData, path) { }; } +// Toggle a month in/out of multi-selection (Shift+click behavior) +async function toggleMonthSelection(index) { + if (selectedMonthIndices.has(index)) { + // Don't allow deselecting the last month + if (selectedMonthIndices.size > 1) { + selectedMonthIndices.delete(index); + // If we removed the current navigation index, update it to another selected month + if (index === currentMonthIndex) { + currentMonthIndex = Math.max(...selectedMonthIndices); + } + } + } else { + selectedMonthIndices.add(index); + currentMonthIndex = index; // Update navigation index to the newly added month + } + await renderSelectedMonths(); +} + +// Select a single month (normal click behavior) +async function selectSingleMonth(index) { + selectedMonthIndices.clear(); + selectedMonthIndices.add(index); + currentMonthIndex = index; + await selectMonth(index); +} + +// Merge transaction data from multiple months +function mergeMonthsData(monthIndices) { + const allTransactions = []; + for (const index of monthIndices) { + const month = availableMonths[index]; + const data = monthDataCache[month]; + if (data) { + allTransactions.push(...data); + } + } + return allTransactions; +} + +// Generate label for selected months +function generateSelectedMonthsLabel() { + if (selectedMonthIndices.size === 1) { + const index = [...selectedMonthIndices][0]; + return getRussianMonthName(availableMonths[index]); + } + + const sortedIndices = [...selectedMonthIndices].sort((a, b) => a - b); + + // Check if consecutive + let isConsecutive = true; + for (let i = 1; i < sortedIndices.length; i++) { + if (sortedIndices[i] !== sortedIndices[i - 1] + 1) { + isConsecutive = false; + break; + } + } + + if (isConsecutive && sortedIndices.length > 1) { + // Range: "Январь - Март" + const firstMonth = getRussianMonthName(availableMonths[sortedIndices[0]]); + const lastMonth = getRussianMonthName(availableMonths[sortedIndices[sortedIndices.length - 1]]); + return `${firstMonth} – ${lastMonth}`; + } else { + // Non-consecutive: "3 месяца" + const count = sortedIndices.length; + // Russian plural rules for "месяц" + if (count === 1) return '1 месяц'; + if (count >= 2 && count <= 4) return `${count} месяца`; + return `${count} месяцев`; + } +} + +// Render chart with data from all selected months +async function renderSelectedMonths() { + // Merge data from all selected months + const mergedData = mergeMonthsData(selectedMonthIndices); + + // Update UI + updateMonthNavigator(); + + // Update hidden select for compatibility (use first selected month) + const sortedIndices = [...selectedMonthIndices].sort((a, b) => a - b); + const select = document.getElementById('month-select'); + select.value = availableMonths[sortedIndices[0]]; + + // Generate month label + const monthLabel = generateSelectedMonthsLabel(); + + // Transform merged data to sunburst + const sunburstData = transformToSunburst(mergedData); + + // Update the module-level original data for center-click reset + originalSunburstData = JSON.parse(JSON.stringify(sunburstData)); + + // Reset history for the new selection + resetHistory(); + saveToHistory(sunburstData.data, sunburstData.total, null, true, []); + + // Update the chart + if (option && option.series && option.series.data) { + option.series.data = sunburstData.data; + + myChart.setOption({ + series: [{ + type: 'sunburst', + data: sunburstData.data, + layoutAnimation: true, + animationDuration: 500, + animationEasing: 'cubicInOut' + }] + }, { + lazyUpdate: false, + silent: false + }); + + updateCenterLabel(monthLabel, sunburstData.total, null); + setupHoverEvents(sunburstData, null); + updateAllMonthPreviews(); + } else { + // Initial render + renderChart(mergedData); + } + + // Scroll to show the current navigation month + const activeBtn = document.querySelector('.month-btn.primary') || document.querySelector('.month-btn.active'); + if (activeBtn) { + activeBtn.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + } +} + // Select and load a specific month async function selectMonth(index) { const month = availableMonths[index]; @@ -1761,12 +1917,24 @@ async function selectMonth(index) { function updateMonthNavigator() { // Update button active states const buttons = document.querySelectorAll('.month-btn'); + const isMultiSelect = selectedMonthIndices.size > 1; + buttons.forEach((btn, index) => { - if (index === currentMonthIndex) { + const isSelected = selectedMonthIndices.has(index); + const isPrimary = index === currentMonthIndex; + + if (isSelected) { btn.classList.add('active'); } else { btn.classList.remove('active'); } + + // Primary class indicates current navigation position in multi-select + if (isMultiSelect && isPrimary) { + btn.classList.add('primary'); + } else { + btn.classList.remove('primary'); + } }); // Update arrow disabled states diff --git a/styles.css b/styles.css index 82be595..735f912 100644 --- a/styles.css +++ b/styles.css @@ -95,6 +95,23 @@ body { color: white; } +/* Primary indicator for current navigation position in multi-select */ +.month-btn.active.primary { + position: relative; +} + +.month-btn.active.primary::after { + content: ''; + position: absolute; + bottom: 4px; + left: 50%; + transform: translateX(-50%); + width: 6px; + height: 6px; + background: white; + border-radius: 50%; +} + .month-preview { width: 40px; height: 40px;