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

291
app.js
View File

@ -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
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';
// 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 = '';
// Find top items if there are children
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 => {
// 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';
nameSpan.textContent = child.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 = child.value.toLocaleString() + ' ₽';
itemDiv.appendChild(amountSpan);
amountSpan.textContent = item.value.toLocaleString() + ' ₽';
// Add percentage
const percentage = ((child.value / params.value) * 100).toFixed(1);
// 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 children, show a message
topItemsElement.innerHTML = '<div>No subcategories available</div>';
// 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();
}
});
// Reset on mouseout from the chart
myChart.on('mouseout', function(params) {
if (!params.data) {
hoverNameElement.textContent = 'Hover over a segment to see details';
hoverAmountElement.textContent = '';
topItemsElement.innerHTML = '';
// Add mouseover event listener for sectors
myChart.on('mouseover', function(params) {
// Only process data nodes, not empty areas
if (params.data) {
isInsideSection = true;
// Set up the details header with name and amount
const itemColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
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) {
// Sort children by value
const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value);
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);
}
});
// 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) {
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();
});
}

View File

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

View File

@ -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;
}