diff --git a/app.js b/app.js index d2119fe..2ac72d3 100644 --- a/app.js +++ b/app.js @@ -61,6 +61,9 @@ function transformToSunburst(data) { const categories = []; const categoryMap = {}; + // Store raw transactions for each microcategory to display in details + const transactionMap = {}; + // Predefined colors for categories const colors = [ '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', @@ -73,16 +76,28 @@ function transformToSunburst(data) { const subcategory = item.subcategory || ''; const microcategory = item.microcategory || ''; const amount = Math.abs(parseFloat(item.amount_rub)); + const transactionKey = `${category}|${subcategory}|${microcategory}`; if (!isNaN(amount)) { totalSpending += amount; + // Save transaction data for detail box + if (!transactionMap[transactionKey]) { + transactionMap[transactionKey] = []; + } + transactionMap[transactionKey].push({ + name: item.simple_name || 'Transaction', + value: amount, + date: item.date + }); + if (!categoryMap[category] && category !== '') { categoryMap[category] = { name: category, value: 0, children: {}, - itemStyle: {} + itemStyle: {}, + transactions: transactionMap[transactionKey] // Store transactions }; categories.push(categoryMap[category]); } @@ -93,7 +108,8 @@ function transformToSunburst(data) { name: subcategory, value: 0, children: {}, - itemStyle: {} + itemStyle: {}, + transactions: transactionMap[transactionKey] // Store transactions }; } @@ -102,7 +118,8 @@ function transformToSunburst(data) { categoryMap[category].children[subcategory].children[microcategory] = { name: microcategory, value: 0, - itemStyle: {} + itemStyle: {}, + transactions: transactionMap[transactionKey] // Store transactions }; } @@ -128,13 +145,15 @@ function transformToSunburst(data) { // Assign colors to categories categories.forEach((category, index) => { const colorIndex = index % colors.length; + const baseColor = colors[colorIndex]; const categoryNode = { name: category.name, value: category.value, children: [], itemStyle: { - color: colors[colorIndex] - } + color: baseColor + }, + transactions: category.transactions }; // Get subcategories and sort by value @@ -144,15 +163,22 @@ function transformToSunburst(data) { } subcategories.sort((a, b) => b.value - a.value); + // Generate color variations for subcategories based on their size + const subcatColors = generateColorGradient(baseColor, subcategories.length || 1); + // Process each subcategory - subcategories.forEach(subcategory => { + subcategories.forEach((subcategory, subIndex) => { + // Adjust subcategory color based on its relative size within category + const subcatColor = subcatColors[subIndex]; + const subcategoryNode = { name: subcategory.name, value: subcategory.value, children: [], itemStyle: { - color: colors[colorIndex] - } + color: subcatColor + }, + transactions: subcategory.transactions }; // Get microcategories and sort by value @@ -162,14 +188,18 @@ function transformToSunburst(data) { } microcategories.sort((a, b) => b.value - a.value); + // Generate color variations for microcategories based on their size + const microColors = generateColorGradient(subcatColor, microcategories.length || 1); + // Add microcategories to subcategory - microcategories.forEach(micro => { + microcategories.forEach((micro, microIndex) => { subcategoryNode.children.push({ name: micro.name, value: micro.value, itemStyle: { - color: colors[colorIndex] - } + color: microColors[microIndex] + }, + transactions: micro.transactions }); }); @@ -180,8 +210,9 @@ function transformToSunburst(data) { name: subcategory.name, value: subcategory.value, itemStyle: { - color: colors[colorIndex] - } + color: subcatColor + }, + transactions: subcategory.transactions }); } }); @@ -240,7 +271,7 @@ function renderChart(data) { label: { show: true, rotate: 'radial', - fontSize: 12, + fontSize: 13, lineHeight: 15, verticalAlign: 'center', position: 'inside', @@ -258,9 +289,12 @@ function renderChart(data) { r0: '45%', r: '70%', label: { - show: false, + show: function(param) { + // Show labels for sectors that are at least 5% of the total + return param.percent >= 0.05; + }, fontSize: 11, - align: 'left', + align: 'center', position: 'inside', distance: 5, formatter: function(param) { @@ -522,7 +556,7 @@ function renderChart(data) { myChart.setOption(option); // Set up hover events for the details box - setupHoverEvents(); + setupHoverEvents(sunburstData); } // Function to generate a color gradient @@ -530,18 +564,37 @@ function generateColorGradient(baseColor, steps) { const result = []; const base = tinycolor(baseColor); - // Generate lighter shades for better contrast + // Get the base hue value (0-360) + const baseHue = base.toHsl().h; + + // Create a more dramatic gradient based on size for (let i = 0; i < steps; i++) { - // Create various tints and shades based on the position - let color; - if (i % 3 === 0) { - color = base.clone().lighten(15); - } else if (i % 3 === 1) { - color = base.clone().darken(10); + // Calculate percentage position in the sequence (0 to 1) + const position = i / (steps - 1 || 1); + + let color = base.clone(); + + // Modify hue - shift around the color wheel based on size + // Smaller items (position closer to 0): shift hue towards cooler colors (-30 degrees) + // Larger items (position closer to 1): shift hue towards warmer colors (+30 degrees) + const hueShift = 15-(position*15); // Ranges from -30 to +30 + + // Apply HSL transformations + const hsl = color.toHsl(); + //hsl.h = (baseHue + hueShift) % 360; // Keep hue within 0-360 range + + // Also adjust saturation and lightness for even more distinction + /* if (position < 0.5) { + // Smaller items: more saturated, darker + hsl.s = Math.min(1, hsl.s * (1.3 - position * 0.6)); // Increase saturation up to 30% + hsl.l = Math.max(0.2, hsl.l * (0.85 + position * 0.3)); // Slightly darker } else { - color = base.clone().saturate(20); - } - result.push(color.toString()); + // Larger items: slightly less saturated, brighter + hsl.s = Math.min(1, hsl.s * (0.9 + position * 0.2)); // Slightly reduce saturation + hsl.l = Math.min(0.9, hsl.l * (1.1 + (position - 0.5) * 0.2)); // Brighter + }*/ + + result.push(tinycolor(hsl).toString()); } return result; @@ -600,64 +653,182 @@ window.addEventListener('resize', function() { }); // Add mouseover handler to update details box -function setupHoverEvents() { - const hoverNameElement = document.getElementById('hover-name'); - const hoverAmountElement = document.getElementById('hover-amount'); +function setupHoverEvents(sunburstData) { const topItemsElement = document.getElementById('top-items'); - // Add mouseover event listener + // Create a container for details header with name and amount + const detailsHeader = document.getElementById('details-header') || document.createElement('div'); + detailsHeader.id = 'details-header'; + detailsHeader.className = 'details-header'; + + if (!document.getElementById('details-header')) { + const detailsBox = document.querySelector('.details-box'); + detailsBox.insertBefore(detailsHeader, detailsBox.firstChild); + } + + // Function to display items in the details box + function displayDetailsItems(items, parentValue) { + // Clear previous top items + topItemsElement.innerHTML = ''; + + // If we have items to show + if (items.length > 0) { + // Create elements for each item + items.forEach(item => { + const itemDiv = document.createElement('div'); + itemDiv.className = 'top-item'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'top-item-name'; + + // Add colored circle + const colorCircle = document.createElement('span'); + colorCircle.className = 'color-circle'; + + // Use the item's color if available, otherwise use a default + if (item.itemStyle && item.itemStyle.color) { + colorCircle.style.backgroundColor = item.itemStyle.color; + } else if (item.color) { + colorCircle.style.backgroundColor = item.color; + } else { + colorCircle.style.backgroundColor = '#cccccc'; + } + + nameSpan.appendChild(colorCircle); + nameSpan.appendChild(document.createTextNode(item.name)); + itemDiv.appendChild(nameSpan); + + 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); + }); + } else { + // No items to show + topItemsElement.innerHTML = '