diff --git a/app.js b/app.js index 9a416c9..ffe1f29 100644 --- a/app.js +++ b/app.js @@ -8,16 +8,20 @@ let showDefaultView = null; // Reference to reset details panel to default view // Drill-down history for back/forward navigation let drillDownHistory = []; let historyIndex = -1; +let currentDrillPath = []; // Tracks drill-down hierarchy: ["Еда", "Рестораны", ...] // Save current chart state to history -function saveToHistory(data, total, contextName, isInitial = false) { +function saveToHistory(data, total, contextName, isInitial = false, path = []) { // Remove any forward history when drilling down from middle of history if (historyIndex < drillDownHistory.length - 1) { drillDownHistory = drillDownHistory.slice(0, historyIndex + 1); } - drillDownHistory.push({ data, total, contextName }); + drillDownHistory.push({ data, total, contextName, path: [...path] }); historyIndex = drillDownHistory.length - 1; + // Update current drill path + currentDrillPath = [...path]; + // Push to browser history (use replaceState for initial state) const state = { drillDown: historyIndex }; if (isInitial) { @@ -31,6 +35,7 @@ function saveToHistory(data, total, contextName, isInitial = false) { function resetHistory() { drillDownHistory = []; historyIndex = -1; + currentDrillPath = []; } // Navigate to a specific history state @@ -46,6 +51,10 @@ function navigateToHistoryState(state) { myChart.setOption(option, { replaceMerge: ['series'] }); setupHoverEvents({ total: state.total, data: state.data }, state.contextName); + + // Restore drill path and update mini-charts + currentDrillPath = state.path ? [...state.path] : []; + updateAllMonthPreviews(); } // Go back in drill-down history @@ -421,7 +430,7 @@ function renderChart(data) { // Reset and initialize history with the root state resetHistory(); - saveToHistory(sunburstData.data, sunburstData.total, null, true); + saveToHistory(sunburstData.data, sunburstData.total, null, true, []); // Get the currently selected month const selectedMonth = document.getElementById('month-select').value; @@ -876,8 +885,9 @@ function renderChart(data) { }); } - // Save new state to history - saveToHistory(newData, params.value, params.name); + // Build the new path by appending the clicked category + const newPath = [...currentDrillPath, params.name]; + saveToHistory(newData, params.value, params.name, false, newPath); // Update the chart with the new data structure option.series.data = newData; @@ -890,6 +900,9 @@ function renderChart(data) { // Update hover events with the new data structure, passing the drilled-down name setupHoverEvents({ total: params.value, data: newData }, params.name); + + // Update mini-chart previews to reflect drill-down + updateAllMonthPreviews(); } }); @@ -1042,6 +1055,141 @@ function generateMonthPreviewGradient(data) { return `conic-gradient(${gradientStops.join(', ')})`; } +// Find category data at a given path in month's transaction data +function findCategoryDataAtPath(monthData, path) { + if (!path || path.length === 0) { + return null; // No drill-down, use full data + } + + // Group transactions by the hierarchy at this path level + const categoryName = path[0]; + + // Filter transactions belonging to this category + const filteredTransactions = monthData.filter(item => item.category === categoryName); + + if (filteredTransactions.length === 0) { + return { empty: true }; // Category doesn't exist in this month + } + + if (path.length === 1) { + // Return subcategory breakdown + return { transactions: filteredTransactions, level: 'subcategory' }; + } + + // Path length >= 2, filter by subcategory + const subcategoryName = path[1]; + const subFiltered = filteredTransactions.filter(item => item.subcategory === subcategoryName); + + if (subFiltered.length === 0) { + return { empty: true }; + } + + if (path.length === 2) { + // Return microcategory breakdown + return { transactions: subFiltered, level: 'microcategory' }; + } + + // Path length >= 3, filter by microcategory + const microcategoryName = path[2]; + const microFiltered = subFiltered.filter(item => item.microcategory === microcategoryName); + + if (microFiltered.length === 0) { + return { empty: true }; + } + + // Return transaction-level breakdown + return { transactions: microFiltered, level: 'transaction' }; +} + +// Get the base color for a drill-down path +function getPathBaseColor(path) { + if (!path || path.length === 0) { + return categoryColors[0]; + } + + const categoryName = path[0]; + const categoryIndex = categoryOrder.indexOf(categoryName); + + if (categoryIndex !== -1) { + return categoryColors[categoryIndex]; + } + + return categoryColors[0]; +} + +// Generate gradient for a month at the current drill-down path +function generateDrilledDownGradient(monthData, path) { + const result = findCategoryDataAtPath(monthData, path); + + // No drill-down - use original function + if (!result) { + return generateMonthPreviewGradient(monthData); + } + + // Empty state - category doesn't exist + if (result.empty) { + return 'conic-gradient(#e0e0e0 0deg 360deg)'; + } + + const { transactions, level } = result; + + // Group by the appropriate field based on level + const groupField = level === 'subcategory' ? 'subcategory' + : level === 'microcategory' ? 'microcategory' + : 'simple_name'; + + const totals = {}; + let total = 0; + + transactions.forEach(item => { + const key = item[groupField] || '(без категории)'; + const amount = Math.abs(parseFloat(item.amount_rub)); + if (!isNaN(amount)) { + totals[key] = (totals[key] || 0) + amount; + total += amount; + } + }); + + if (total === 0) { + return 'conic-gradient(#e0e0e0 0deg 360deg)'; + } + + // Sort by value descending + const sortedKeys = Object.keys(totals).sort((a, b) => totals[b] - totals[a]); + + // Generate gradient with color variations + const baseColor = getPathBaseColor(path); + const colors = generateColorGradient(baseColor, sortedKeys.length); + + const gradientStops = []; + let currentAngle = 0; + + sortedKeys.forEach((key, index) => { + const percentage = totals[key] / total; + const angle = percentage * 360; + const color = colors[index] || categoryColors[index % categoryColors.length]; + + gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`); + currentAngle += angle; + }); + + return `conic-gradient(${gradientStops.join(', ')})`; +} + +// Update all month preview gradients based on current drill path +function updateAllMonthPreviews() { + const buttons = document.querySelectorAll('.month-btn'); + + buttons.forEach(btn => { + const month = btn.dataset.month; + const preview = btn.querySelector('.month-preview'); + + if (preview && monthDataCache[month]) { + preview.style.background = generateDrilledDownGradient(monthDataCache[month], currentDrillPath); + } + }); +} + // Format month for display: "2025-01" -> "Январь'25" function formatMonthLabel(dateStr) { const [year, month] = dateStr.split('-'); @@ -1130,6 +1278,203 @@ async function loadAvailableMonths() { }); } +// Transform children data for drill-down (extracted from click handler) +function transformDrillDownData(parentNode) { + const colorPalette = [ + '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', + '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72' + ]; + + const newData = []; + const sortedChildren = [...parentNode.children].sort((a, b) => b.value - a.value); + + sortedChildren.forEach((child, i) => { + const color = colorPalette[i % colorPalette.length]; + const newCategory = { + name: child.name, + value: child.value, + transactions: child.transactions, + itemStyle: { color: color }, + children: [] + }; + + if (child.children && child.children.length > 0) { + const sortedMicros = [...child.children].sort((a, b) => b.value - a.value); + const microColors = generateColorGradient(color, sortedMicros.length); + + sortedMicros.forEach((micro, j) => { + const microCategory = { + name: micro.name, + value: micro.value, + transactions: micro.transactions, + itemStyle: { color: microColors[j] }, + children: [] + }; + + if (micro.transactions && micro.transactions.length > 0) { + const sortedTransactions = [...micro.transactions].sort((a, b) => b.value - a.value); + const transactionColors = generateColorGradient(microColors[j], sortedTransactions.length); + + sortedTransactions.forEach((transaction, k) => { + microCategory.children.push({ + name: transaction.name, + value: transaction.value, + itemStyle: { color: transactionColors[k] }, + originalRow: transaction.originalRow + }); + }); + } + + newCategory.children.push(microCategory); + }); + } + + if (child.transactions) { + const transactionsWithoutMicro = child.transactions.filter(t => t.displayMicrocategory === ''); + if (transactionsWithoutMicro.length > 0) { + const transactionGroups = {}; + transactionsWithoutMicro.forEach(t => { + if (!transactionGroups[t.name]) { + transactionGroups[t.name] = { name: t.name, value: 0, transactions: [] }; + } + transactionGroups[t.name].value += t.value; + transactionGroups[t.name].transactions.push(t); + }); + + const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value); + const transactionColors = generateColorGradient(color, groups.length); + + groups.forEach((group, j) => { + const transactionCategory = { + name: group.name, + value: group.value, + transactions: group.transactions, + itemStyle: { color: transactionColors[j] }, + children: [] + }; + + if (group.transactions.length > 0) { + const sortedTransactions = [...group.transactions].sort((a, b) => b.value - a.value); + const individualColors = generateColorGradient(transactionColors[j], sortedTransactions.length); + + sortedTransactions.forEach((transaction, k) => { + transactionCategory.children.push({ + name: transaction.name, + value: transaction.value, + itemStyle: { color: individualColors[k] }, + originalRow: transaction.originalRow + }); + }); + } + + newCategory.children.push(transactionCategory); + }); + } + } + + newData.push(newCategory); + }); + + return newData; +} + +// Transform transaction-only data for drill-down +function transformTransactionData(node) { + const colorPalette = [ + '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', + '#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72' + ]; + + const transactionGroups = {}; + node.transactions.forEach(t => { + if (!transactionGroups[t.name]) { + transactionGroups[t.name] = { name: t.name, value: 0, transactions: [] }; + } + transactionGroups[t.name].value += t.value; + transactionGroups[t.name].transactions.push(t); + }); + + const newData = []; + const groups = Object.values(transactionGroups).sort((a, b) => b.value - a.value); + + groups.forEach((group, i) => { + const color = colorPalette[i % colorPalette.length]; + const transactionCategory = { + name: group.name, + value: group.value, + transactions: group.transactions, + itemStyle: { color: color }, + children: [] + }; + + if (group.transactions.length > 0) { + const sortedTransactions = [...group.transactions].sort((a, b) => b.value - a.value); + const transactionColors = generateColorGradient(color, sortedTransactions.length); + + sortedTransactions.forEach((transaction, j) => { + transactionCategory.children.push({ + name: transaction.name, + value: transaction.value, + itemStyle: { color: transactionColors[j] }, + originalRow: transaction.originalRow + }); + }); + } + + newData.push(transactionCategory); + }); + + return newData; +} + +// Navigate to a path in the given sunburst data, returns drilled data or null if path not found +function navigateToPath(sunburstData, path) { + if (!path || path.length === 0) { + return null; // Stay at root + } + + // Try to find each level of the path + let currentData = sunburstData.data; + let currentTotal = sunburstData.total; + let currentName = null; + let validPath = []; + + for (let i = 0; i < path.length; i++) { + const targetName = path[i]; + const found = currentData.find(item => item.name === targetName); + + if (!found) { + // Path level not found, return what we have so far + break; + } + + validPath.push(targetName); + currentName = targetName; + currentTotal = found.value; + + // Transform this level's children to become top-level (same as click handler logic) + if (found.children && found.children.length > 0) { + currentData = transformDrillDownData(found); + } else if (found.transactions && found.transactions.length > 0) { + currentData = transformTransactionData(found); + } else { + // No more levels to drill into + break; + } + } + + if (validPath.length === 0) { + return null; // Couldn't match any part of path + } + + return { + data: currentData, + total: currentTotal, + contextName: currentName, + path: validPath + }; +} + // Select and load a specific month async function selectMonth(index) { currentMonthIndex = index; @@ -1143,7 +1488,7 @@ async function selectMonth(index) { updateMonthNavigator(); // Load and render data - const data = await parseCSV(`altcats-${month}.csv`); + const data = monthDataCache[month] || await parseCSV(`altcats-${month}.csv`); // Check if chart already has data (for animation) if (option && option.series && option.series.data) { @@ -1152,32 +1497,49 @@ async function selectMonth(index) { // Update the module-level original data for center-click reset originalSunburstData = JSON.parse(JSON.stringify(sunburstData)); - // Reset and initialize history for the new month + // Save the current drill path before modifying state + const savedPath = [...currentDrillPath]; + + // Reset history for the new month resetHistory(); - saveToHistory(sunburstData.data, sunburstData.total, null, true); - // Update only the series data and preserve layout - const oldData = option.series.data; - const newData = sunburstData.data; + // Try to navigate to the same path in the new month + const navigatedState = navigateToPath(sunburstData, savedPath); - // Map old values to new data to preserve positions - newData.forEach((newItem, idx) => { - if (oldData[idx]) { - newItem.layoutId = oldData[idx].name; - } - }); + let targetData, targetTotal, targetName, targetPath; + + if (navigatedState) { + // Successfully navigated to (part of) the path + targetData = navigatedState.data; + targetTotal = navigatedState.total; + targetName = navigatedState.contextName; + targetPath = navigatedState.path; + } else { + // Stay at root level + targetData = sunburstData.data; + targetTotal = sunburstData.total; + targetName = null; + targetPath = []; + } + + // Save initial state with the path + saveToHistory(targetData, targetTotal, targetName, true, targetPath); // Update the data - option.series.data = newData; + option.series.data = targetData; // 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() + ' ₽'; + if (targetName) { + option.graphic.elements[0].style.text = `${russianMonth}\n${targetName}\n${targetTotal.toFixed(0).toLocaleString()} ₽`; + } else { + option.graphic.elements[0].style.text = russianMonth + '\n' + targetTotal.toFixed(0).toLocaleString() + ' ₽'; + } myChart.setOption({ series: [{ type: 'sunburst', - data: newData, + data: targetData, layoutAnimation: true, animationDuration: 500, animationEasing: 'cubicInOut' @@ -1189,7 +1551,10 @@ async function selectMonth(index) { }); // Update hover events - setupHoverEvents(sunburstData); + setupHoverEvents({ total: targetTotal, data: targetData }, targetName); + + // Update mini-chart previews + updateAllMonthPreviews(); } else { // Initial render renderChart(data); @@ -1563,6 +1928,15 @@ function setupHoverEvents(sunburstData, contextName = null) { nameSpan.appendChild(document.createTextNode(item.name)); itemDiv.appendChild(nameSpan); + // Add percentage after name if we have a parent value + if (parentValue) { + const percentSpan = document.createElement('span'); + percentSpan.className = 'top-item-percent'; + const percentage = ((item.value / parentValue) * 100).toFixed(1); + percentSpan.textContent = percentage + '%'; + itemDiv.appendChild(percentSpan); + } + // Add eye button for viewing transaction details const eyeBtn = document.createElement('button'); eyeBtn.className = 'eye-btn'; @@ -1577,13 +1951,6 @@ function setupHoverEvents(sunburstData, contextName = null) { const amountSpan = document.createElement('span'); amountSpan.className = 'top-item-amount'; amountSpan.textContent = item.value.toLocaleString() + ' ₽'; - - // Add percentage if we have a parent value - if (parentValue) { - const percentage = ((item.value / parentValue) * 100).toFixed(1); - amountSpan.textContent += ` (${percentage}%)`; - } - itemDiv.appendChild(amountSpan); topItemsElement.appendChild(itemDiv); }); @@ -1663,8 +2030,8 @@ function setupHoverEvents(sunburstData, contextName = null) { }); } - // Show top categories as default items - displayDetailsItems(sunburstData.data, sunburstData.total); + // Show top categories as default items (sorted by value descending) + displayDetailsItems([...sunburstData.data].sort((a, b) => b.value - a.value), sunburstData.total); } // Show default view initially