Fix consistent subcategory colors and mini-chart sync

- Add fixed subcategory color mapping based on December 2025 data
- Update click handler and transformDrillDownData to use getSubcategoryColor
- Rotate mini-charts 90° clockwise to match main chart orientation
- Change month selector active state from blue to neutral gray (#4a4a4a)
- Ensure colors stay consistent when switching months while drilled down

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-01-31 18:26:18 +03:00
parent b0983a751e
commit e8318166dd
2 changed files with 99 additions and 24 deletions

101
app.js
View File

@ -729,7 +729,7 @@ function renderChart(data) {
// Process each child
sortedChildren.forEach((child, i) => {
const color = colorPalette[i % colorPalette.length];
const color = getSubcategoryColor(params.name, child.name, i);
const newCategory = {
name: child.name,
value: child.value,
@ -1011,6 +1011,74 @@ const categoryOrder = [
'Здоровье', 'Логистика', 'Расходники', 'Красота'
];
// Fixed subcategory colors based on December 2025 data (sorted by value)
// This ensures consistent colors across all months
const subcategoryColors = {
'Квартира': {
'ЖКХ': '#5470c6',
'Лев Петрович': '#91cc75'
},
'Еда': {
'Продукты': '#5470c6',
'Готовая': '#91cc75'
},
'Технологии': {
'Инфраструктура домашнего интернета': '#5470c6',
'Умный дом': '#91cc75',
'AI': '#fac858',
'Мобильная связь': '#ee6666'
},
'Развлечения': {
'Подарки': '#5470c6',
'Хобби Антона': '#91cc75',
'Хобби Залины': '#fac858',
'Подписки': '#ee6666',
'Чтение': '#73c0de',
'Работа Залины': '#3ba272',
'Девайсы': '#fc8452'
},
'Семьи': {
'Залина': '#5470c6',
'Антон': '#91cc75'
},
'Здоровье': {
'Терапия': '#5470c6',
'Лекарства': '#91cc75',
'Спортивное питание': '#fac858'
},
'Логистика': {
'Такси': '#5470c6',
'Доставка': '#91cc75',
'Чаевые': '#fac858'
},
'Расходники': {
'Замена разных фильтров': '#5470c6',
'Санитарное': '#91cc75',
'Батарейки': '#fac858',
'Мелкий ремонт': '#ee6666',
'Средства для посудомоек': '#73c0de'
},
'Красота': {
'Одежда': '#5470c6',
'Салоны красоты': '#91cc75',
'Кремы': '#fac858'
}
};
// Color palette for items not in the predefined mapping
const defaultColorPalette = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
];
// Get color for a subcategory, using fixed mapping or fallback to index-based
function getSubcategoryColor(category, subcategory, index) {
if (subcategoryColors[category] && subcategoryColors[category][subcategory]) {
return subcategoryColors[category][subcategory];
}
return defaultColorPalette[index % defaultColorPalette.length];
}
// Generate conic-gradient CSS for a month's category breakdown
function generateMonthPreviewGradient(data) {
// Group by category and sum amounts
@ -1026,7 +1094,7 @@ function generateMonthPreviewGradient(data) {
}
});
if (total === 0) return 'conic-gradient(#eee 0deg 360deg)';
if (total === 0) return 'conic-gradient(from 90deg, #eee 0deg 360deg)';
// Sort categories by predefined order
const sortedCategories = Object.keys(categoryTotals).sort((a, b) => {
@ -1052,7 +1120,7 @@ function generateMonthPreviewGradient(data) {
currentAngle += angle;
});
return `conic-gradient(${gradientStops.join(', ')})`;
return `conic-gradient(from 90deg, ${gradientStops.join(', ')})`;
}
// Find category data at a given path in month's transaction data
@ -1128,7 +1196,7 @@ function generateDrilledDownGradient(monthData, path) {
// Empty state - category doesn't exist
if (result.empty) {
return 'conic-gradient(#e0e0e0 0deg 360deg)';
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
}
const { transactions, level } = result;
@ -1151,15 +1219,14 @@ function generateDrilledDownGradient(monthData, path) {
});
if (total === 0) {
return 'conic-gradient(#e0e0e0 0deg 360deg)';
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
}
// Sort by value descending
const sortedKeys = Object.keys(totals).sort((a, b) => totals[b] - totals[a]);
// Generate gradient with color variations
const baseColor = getPathBaseColor(path);
const colors = generateColorGradient(baseColor, sortedKeys.length);
// Get the parent category for color lookup
const parentCategory = path.length > 0 ? path[0] : null;
const gradientStops = [];
let currentAngle = 0;
@ -1167,13 +1234,16 @@ function generateDrilledDownGradient(monthData, path) {
sortedKeys.forEach((key, index) => {
const percentage = totals[key] / total;
const angle = percentage * 360;
const color = colors[index] || categoryColors[index % categoryColors.length];
// Use fixed subcategory colors for first level, fallback to palette for deeper levels
const color = (level === 'subcategory' && parentCategory)
? getSubcategoryColor(parentCategory, key, index)
: defaultColorPalette[index % defaultColorPalette.length];
gradientStops.push(`${color} ${currentAngle}deg ${currentAngle + angle}deg`);
currentAngle += angle;
});
return `conic-gradient(${gradientStops.join(', ')})`;
return `conic-gradient(from 90deg, ${gradientStops.join(', ')})`;
}
// Update all month preview gradients based on current drill path
@ -1279,17 +1349,12 @@ async function loadAvailableMonths() {
}
// Transform children data for drill-down (extracted from click handler)
function transformDrillDownData(parentNode) {
const colorPalette = [
'#5470c6', '#91cc75', '#fac858', '#ee6666', '#73c0de',
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
];
function transformDrillDownData(parentNode, parentCategoryName) {
const newData = [];
const sortedChildren = [...parentNode.children].sort((a, b) => b.value - a.value);
sortedChildren.forEach((child, i) => {
const color = colorPalette[i % colorPalette.length];
const color = getSubcategoryColor(parentCategoryName, child.name, i);
const newCategory = {
name: child.name,
value: child.value,
@ -1454,7 +1519,7 @@ function navigateToPath(sunburstData, path) {
// Transform this level's children to become top-level (same as click handler logic)
if (found.children && found.children.length > 0) {
currentData = transformDrillDownData(found);
currentData = transformDrillDownData(found, targetName);
} else if (found.transactions && found.transactions.length > 0) {
currentData = transformTransactionData(found);
} else {

View File

@ -90,8 +90,8 @@ body {
}
.month-btn.active {
background-color: #5470c6;
border-color: #5470c6;
background-color: #4a4a4a;
border-color: #4a4a4a;
color: white;
}
@ -120,7 +120,7 @@ body {
}
.month-btn.active .month-preview::after {
background: #5470c6;
background: #4a4a4a;
}
.month-label {
@ -234,23 +234,33 @@ body {
}
.top-item-name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
min-width: 0;
width: 50%;
flex-shrink: 0;
}
.top-item-amount {
margin-left: 15px;
margin-left: auto;
font-weight: bold;
min-width: 120px;
min-width: 100px;
text-align: right;
flex-shrink: 0;
}
.top-item-percent {
margin-left: 8px;
width: 45px;
text-align: right;
flex-shrink: 0;
color: #999;
font-weight: 400;
}
/* Add responsive design for smaller screens */
@media (max-width: 1200px) {
#details-box {