From ab3db538178c72feda7de26197cc61b26bbd65b9 Mon Sep 17 00:00:00 2001 From: Anton Volnuhin Date: Wed, 19 Mar 2025 22:47:33 +0300 Subject: [PATCH] better colors --- app.js | 311 +++++++++++++++++++++++++++++++++++++++++------------ index.html | 8 +- styles.css | 44 ++++++-- 3 files changed, 282 insertions(+), 81 deletions(-) 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 = '
No details available
'; + } + } + + // Show the default view with top categories + function showDefaultView() { + detailsHeader.innerHTML = ` + + + Total + + ${sunburstData.total.toLocaleString()} ₽ + `; + + // Show top categories as default items + displayDetailsItems(sunburstData.data, sunburstData.total); + } + + // Show default view initially + showDefaultView(); + + // Track if we're inside a section to handle sector exit properly + let isInsideSection = false; + + // Variables to track the circular boundary + const chartCenter = option.series.center; + const chartCenterX = chartDom.offsetWidth * (parseFloat(chartCenter[0]) / 100); + const chartCenterY = chartDom.offsetHeight * (parseFloat(chartCenter[1]) / 100); + const chartRadius = Math.min(chartDom.offsetWidth, chartDom.offsetHeight) * + (parseFloat(option.series.radius[1]) / 100); + + // Check if point is inside the sunburst chart circle + function isInsideChart(x, y) { + const dx = x - chartCenterX; + const dy = y - chartCenterY; + const distance = Math.sqrt(dx * dx + dy * dy); + return distance <= chartRadius; + } + + // Add general mousemove event listener to detect when outside chart circle + chartDom.addEventListener('mousemove', function(e) { + // Get mouse position relative to chart container + const rect = chartDom.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // If not inside the chart circle and we were inside a section, show default view + if (!isInsideChart(mouseX, mouseY) && isInsideSection) { + isInsideSection = false; + showDefaultView(); + } + }); + + // Add mouseover event listener for sectors myChart.on('mouseover', function(params) { // Only process data nodes, not empty areas if (params.data) { - // Set the name and amount - hoverNameElement.textContent = params.name; - hoverAmountElement.textContent = params.value.toLocaleString() + ' RUB'; + isInsideSection = true; - // Clear previous top items - topItemsElement.innerHTML = ''; + // Set up the details header with name and amount + const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc'); - // Find top items if there are children + detailsHeader.innerHTML = ` + + + ${params.name} + + ${params.value.toLocaleString()} ₽ + `; + + // Number of items to show in the details box + const MAX_DETAIL_ITEMS = 5; + let itemsToShow = []; + + // Check if the node has children (subcategories or microcategories) if (params.data.children && params.data.children.length > 0) { // Sort children by value const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value); - - // Display top 10 or fewer - const topChildren = sortedChildren.slice(0, 10); - - // Create elements for each top item - topChildren.forEach(child => { - const itemDiv = document.createElement('div'); - itemDiv.className = 'top-item'; - - const nameSpan = document.createElement('span'); - nameSpan.className = 'top-item-name'; - nameSpan.textContent = child.name; - itemDiv.appendChild(nameSpan); - - const amountSpan = document.createElement('span'); - amountSpan.className = 'top-item-amount'; - amountSpan.textContent = child.value.toLocaleString() + ' ₽'; - itemDiv.appendChild(amountSpan); - - // Add percentage - const percentage = ((child.value / params.value) * 100).toFixed(1); - amountSpan.textContent += ` (${percentage}%)`; - - topItemsElement.appendChild(itemDiv); - }); - } else { - // No children, show a message - topItemsElement.innerHTML = '
No subcategories available
'; + itemsToShow = sortedChildren.slice(0, MAX_DETAIL_ITEMS); } + + // If we have transaction data and not enough children, fill with transactions + if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) { + // Sort transactions by value + const sortedTransactions = [...params.data.transactions].sort((a, b) => b.value - a.value); + + // Add color to transactions + const coloredTransactions = sortedTransactions.map(t => { + return { + ...t, + color: params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc') + }; + }); + + // Take only what we need to reach MAX_DETAIL_ITEMS + const neededTransactions = coloredTransactions.slice(0, MAX_DETAIL_ITEMS - itemsToShow.length); + itemsToShow = [...itemsToShow, ...neededTransactions]; + } + + // Display the items + displayDetailsItems(itemsToShow, params.value); } }); - // Reset on mouseout from the chart + // When mouse leaves a section but is still within the chart, we'll handle it with mousemove myChart.on('mouseout', function(params) { - if (!params.data) { - hoverNameElement.textContent = 'Hover over a segment to see details'; - hoverAmountElement.textContent = ''; - topItemsElement.innerHTML = ''; + if (params.data) { + isInsideSection = false; + // Reset details immediately when leaving a section + showDefaultView(); } }); + + // Add back the downplay event handler - this is triggered when sections lose emphasis + myChart.on('downplay', function(params) { + // Reset to default view when a section is no longer emphasized + showDefaultView(); + }); } \ No newline at end of file diff --git a/index.html b/index.html index b216c06..a1873f5 100644 --- a/index.html +++ b/index.html @@ -19,10 +19,12 @@
-
+

Details

-
Hover over a segment to see details
-
+
+ Hover over a segment to see details + +

Top Items:

diff --git a/styles.css b/styles.css index 38b4236..0a47702 100644 --- a/styles.css +++ b/styles.css @@ -79,17 +79,41 @@ body { font-size: 14px; } -#hover-name { - font-weight: bold; - margin-bottom: 5px; - color: #333; - word-break: break-word; +.details-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 8px; + border-bottom: 1px solid #eee; } -#hover-amount { - font-size: 18px; - margin-bottom: 10px; +.hover-name { + font-weight: bold; + color: #333; + word-break: break-word; + flex: 1; + display: flex; + align-items: center; +} + +.hover-amount { + font-size: 16px; color: #0066cc; + font-weight: bold; + margin-left: 10px; + white-space: nowrap; + text-align: left; + min-width: 100px; +} + +.color-circle { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 8px; + flex-shrink: 0; } #top-items { @@ -112,9 +136,13 @@ body { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + display: flex; + align-items: center; } .top-item-amount { margin-left: 10px; font-weight: bold; + min-width: 100px; + text-align: left; } \ No newline at end of file