better colors

This commit is contained in:
Anton Volnuhin 2025-03-19 22:47:33 +03:00
parent b7791e4612
commit ab3db53817
3 changed files with 282 additions and 81 deletions

311
app.js
View File

@ -61,6 +61,9 @@ function transformToSunburst(data) {
const categories = []; const categories = [];
const categoryMap = {}; const categoryMap = {};
// Store raw transactions for each microcategory to display in details
const transactionMap = {};
// Predefined colors for categories // Predefined colors for categories
const colors = [ const colors = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de', '#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
@ -73,16 +76,28 @@ function transformToSunburst(data) {
const subcategory = item.subcategory || ''; const subcategory = item.subcategory || '';
const microcategory = item.microcategory || ''; const microcategory = item.microcategory || '';
const amount = Math.abs(parseFloat(item.amount_rub)); const amount = Math.abs(parseFloat(item.amount_rub));
const transactionKey = `${category}|${subcategory}|${microcategory}`;
if (!isNaN(amount)) { if (!isNaN(amount)) {
totalSpending += 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 !== '') { if (!categoryMap[category] && category !== '') {
categoryMap[category] = { categoryMap[category] = {
name: category, name: category,
value: 0, value: 0,
children: {}, children: {},
itemStyle: {} itemStyle: {},
transactions: transactionMap[transactionKey] // Store transactions
}; };
categories.push(categoryMap[category]); categories.push(categoryMap[category]);
} }
@ -93,7 +108,8 @@ function transformToSunburst(data) {
name: subcategory, name: subcategory,
value: 0, value: 0,
children: {}, children: {},
itemStyle: {} itemStyle: {},
transactions: transactionMap[transactionKey] // Store transactions
}; };
} }
@ -102,7 +118,8 @@ function transformToSunburst(data) {
categoryMap[category].children[subcategory].children[microcategory] = { categoryMap[category].children[subcategory].children[microcategory] = {
name: microcategory, name: microcategory,
value: 0, value: 0,
itemStyle: {} itemStyle: {},
transactions: transactionMap[transactionKey] // Store transactions
}; };
} }
@ -128,13 +145,15 @@ function transformToSunburst(data) {
// Assign colors to categories // Assign colors to categories
categories.forEach((category, index) => { categories.forEach((category, index) => {
const colorIndex = index % colors.length; const colorIndex = index % colors.length;
const baseColor = colors[colorIndex];
const categoryNode = { const categoryNode = {
name: category.name, name: category.name,
value: category.value, value: category.value,
children: [], children: [],
itemStyle: { itemStyle: {
color: colors[colorIndex] color: baseColor
} },
transactions: category.transactions
}; };
// Get subcategories and sort by value // Get subcategories and sort by value
@ -144,15 +163,22 @@ function transformToSunburst(data) {
} }
subcategories.sort((a, b) => b.value - a.value); 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 // 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 = { const subcategoryNode = {
name: subcategory.name, name: subcategory.name,
value: subcategory.value, value: subcategory.value,
children: [], children: [],
itemStyle: { itemStyle: {
color: colors[colorIndex] color: subcatColor
} },
transactions: subcategory.transactions
}; };
// Get microcategories and sort by value // Get microcategories and sort by value
@ -162,14 +188,18 @@ function transformToSunburst(data) {
} }
microcategories.sort((a, b) => b.value - a.value); 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 // Add microcategories to subcategory
microcategories.forEach(micro => { microcategories.forEach((micro, microIndex) => {
subcategoryNode.children.push({ subcategoryNode.children.push({
name: micro.name, name: micro.name,
value: micro.value, value: micro.value,
itemStyle: { itemStyle: {
color: colors[colorIndex] color: microColors[microIndex]
} },
transactions: micro.transactions
}); });
}); });
@ -180,8 +210,9 @@ function transformToSunburst(data) {
name: subcategory.name, name: subcategory.name,
value: subcategory.value, value: subcategory.value,
itemStyle: { itemStyle: {
color: colors[colorIndex] color: subcatColor
} },
transactions: subcategory.transactions
}); });
} }
}); });
@ -240,7 +271,7 @@ function renderChart(data) {
label: { label: {
show: true, show: true,
rotate: 'radial', rotate: 'radial',
fontSize: 12, fontSize: 13,
lineHeight: 15, lineHeight: 15,
verticalAlign: 'center', verticalAlign: 'center',
position: 'inside', position: 'inside',
@ -258,9 +289,12 @@ function renderChart(data) {
r0: '45%', r0: '45%',
r: '70%', r: '70%',
label: { 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, fontSize: 11,
align: 'left', align: 'center',
position: 'inside', position: 'inside',
distance: 5, distance: 5,
formatter: function(param) { formatter: function(param) {
@ -522,7 +556,7 @@ function renderChart(data) {
myChart.setOption(option); myChart.setOption(option);
// Set up hover events for the details box // Set up hover events for the details box
setupHoverEvents(); setupHoverEvents(sunburstData);
} }
// Function to generate a color gradient // Function to generate a color gradient
@ -530,18 +564,37 @@ function generateColorGradient(baseColor, steps) {
const result = []; const result = [];
const base = tinycolor(baseColor); 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++) { for (let i = 0; i < steps; i++) {
// Create various tints and shades based on the position // Calculate percentage position in the sequence (0 to 1)
let color; const position = i / (steps - 1 || 1);
if (i % 3 === 0) {
color = base.clone().lighten(15); let color = base.clone();
} else if (i % 3 === 1) {
color = base.clone().darken(10); // 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 { } else {
color = base.clone().saturate(20); // Larger items: slightly less saturated, brighter
} hsl.s = Math.min(1, hsl.s * (0.9 + position * 0.2)); // Slightly reduce saturation
result.push(color.toString()); hsl.l = Math.min(0.9, hsl.l * (1.1 + (position - 0.5) * 0.2)); // Brighter
}*/
result.push(tinycolor(hsl).toString());
} }
return result; return result;
@ -600,64 +653,182 @@ window.addEventListener('resize', function() {
}); });
// Add mouseover handler to update details box // Add mouseover handler to update details box
function setupHoverEvents() { function setupHoverEvents(sunburstData) {
const hoverNameElement = document.getElementById('hover-name');
const hoverAmountElement = document.getElementById('hover-amount');
const topItemsElement = document.getElementById('top-items'); 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 = '<div>No details available</div>';
}
}
// Show the default view with top categories
function showDefaultView() {
detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: #ffffff;"></span>
Total
</span>
<span class="hover-amount">${sunburstData.total.toLocaleString()} </span>
`;
// 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) { myChart.on('mouseover', function(params) {
// Only process data nodes, not empty areas // Only process data nodes, not empty areas
if (params.data) { if (params.data) {
// Set the name and amount isInsideSection = true;
hoverNameElement.textContent = params.name;
hoverAmountElement.textContent = params.value.toLocaleString() + ' RUB';
// Clear previous top items // Set up the details header with name and amount
topItemsElement.innerHTML = ''; const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
// Find top items if there are children detailsHeader.innerHTML = `
<span class="hover-name">
<span class="color-circle" style="background-color: ${itemColor};"></span>
${params.name}
</span>
<span class="hover-amount">${params.value.toLocaleString()} </span>
`;
// 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) { if (params.data.children && params.data.children.length > 0) {
// Sort children by value // Sort children by value
const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value); const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value);
itemsToShow = sortedChildren.slice(0, MAX_DETAIL_ITEMS);
// 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 = '<div>No subcategories available</div>';
} }
// 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) { myChart.on('mouseout', function(params) {
if (!params.data) { if (params.data) {
hoverNameElement.textContent = 'Hover over a segment to see details'; isInsideSection = false;
hoverAmountElement.textContent = ''; // Reset details immediately when leaving a section
topItemsElement.innerHTML = ''; 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();
});
} }

View File

@ -19,10 +19,12 @@
</div> </div>
<div class="content-wrapper"> <div class="content-wrapper">
<div id="chart-container"></div> <div id="chart-container"></div>
<div id="details-box"> <div id="details-box" class="details-box">
<h3>Details</h3> <h3>Details</h3>
<div id="hover-name">Hover over a segment to see details</div> <div id="details-header" class="details-header">
<div id="hover-amount"></div> <span class="hover-name">Hover over a segment to see details</span>
<span class="hover-amount"></span>
</div>
<h4>Top Items:</h4> <h4>Top Items:</h4>
<div id="top-items"></div> <div id="top-items"></div>
</div> </div>

View File

@ -79,17 +79,41 @@ body {
font-size: 14px; font-size: 14px;
} }
#hover-name { .details-header {
font-weight: bold; display: flex;
margin-bottom: 5px; justify-content: space-between;
color: #333; align-items: center;
word-break: break-word; margin-bottom: 15px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
} }
#hover-amount { .hover-name {
font-size: 18px; font-weight: bold;
margin-bottom: 10px; color: #333;
word-break: break-word;
flex: 1;
display: flex;
align-items: center;
}
.hover-amount {
font-size: 16px;
color: #0066cc; 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 { #top-items {
@ -112,9 +136,13 @@ body {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: flex;
align-items: center;
} }
.top-item-amount { .top-item-amount {
margin-left: 10px; margin-left: 10px;
font-weight: bold; font-weight: bold;
min-width: 100px;
text-align: left;
} }