better colors
This commit is contained in:
parent
b7791e4612
commit
ab3db53817
291
app.js
291
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
|
||||
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();
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
|
||||
44
styles.css
44
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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user