Add fixed subcategory order based on December 2025 data

- Add subcategoryOrder derived from subcategoryColors
- Add sortBySubcategoryOrder helper function
- Update click handler, transformDrillDownData, and mini-chart gradient
  to use fixed order instead of value-based sorting
- Update details box to preserve fixed order for drilled-down views
  while keeping value-based sorting for root level categories

This ensures subcategories maintain consistent positions when switching
months, even when their values differ (e.g., "Хобби Залины" always comes
before "Подписки" regardless of which has higher spending that month).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Anton Volnuhin 2026-01-31 18:36:59 +03:00
parent e8318166dd
commit d934b5465b

68
app.js
View File

@ -725,7 +725,7 @@ function renderChart(data) {
// Handle different cases based on the type of node clicked
if (params.data.children && params.data.children.length > 0) {
// Case 1: Node has children (category or subcategory)
const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value);
const sortedChildren = sortBySubcategoryOrder(params.data.children, params.name);
// Process each child
sortedChildren.forEach((child, i) => {
@ -1071,6 +1071,12 @@ const defaultColorPalette = [
'#3ba272', '#fc8452', '#9a60b4', '#ea7ccc', '#4cae72'
];
// Fixed subcategory order derived from subcategoryColors (December 2025 order)
const subcategoryOrder = {};
for (const category in subcategoryColors) {
subcategoryOrder[category] = Object.keys(subcategoryColors[category]);
}
// Get color for a subcategory, using fixed mapping or fallback to index-based
function getSubcategoryColor(category, subcategory, index) {
if (subcategoryColors[category] && subcategoryColors[category][subcategory]) {
@ -1079,6 +1085,30 @@ function getSubcategoryColor(category, subcategory, index) {
return defaultColorPalette[index % defaultColorPalette.length];
}
// Sort items by fixed subcategory order, with unknown items at the end sorted by value
function sortBySubcategoryOrder(items, parentCategory, nameGetter = (item) => item.name) {
const order = subcategoryOrder[parentCategory] || [];
return [...items].sort((a, b) => {
const aName = nameGetter(a);
const bName = nameGetter(b);
const aIndex = order.indexOf(aName);
const bIndex = order.indexOf(bName);
// Both in predefined order - sort by that order
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
// Only a is in predefined order - a comes first
if (aIndex !== -1) return -1;
// Only b is in predefined order - b comes first
if (bIndex !== -1) return 1;
// Neither in predefined order - sort by value descending
const aValue = typeof a === 'object' ? (a.value || 0) : 0;
const bValue = typeof b === 'object' ? (b.value || 0) : 0;
return bValue - aValue;
});
}
// Generate conic-gradient CSS for a month's category breakdown
function generateMonthPreviewGradient(data) {
// Group by category and sum amounts
@ -1222,12 +1252,25 @@ function generateDrilledDownGradient(monthData, path) {
return 'conic-gradient(from 90deg, #e0e0e0 0deg 360deg)';
}
// Sort by value descending
const sortedKeys = Object.keys(totals).sort((a, b) => totals[b] - totals[a]);
// Get the parent category for color lookup
// Get the parent category for color lookup and ordering
const parentCategory = path.length > 0 ? path[0] : null;
// Sort by fixed subcategory order (for first level), fallback to value for unknown items
const order = subcategoryOrder[parentCategory] || [];
const sortedKeys = Object.keys(totals).sort((a, b) => {
const aIndex = order.indexOf(a);
const bIndex = order.indexOf(b);
// Both in predefined order - sort by that order
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
// Only a is in predefined order - a comes first
if (aIndex !== -1) return -1;
// Only b is in predefined order - b comes first
if (bIndex !== -1) return 1;
// Neither in predefined order - sort by value descending
return totals[b] - totals[a];
});
const gradientStops = [];
let currentAngle = 0;
@ -1351,7 +1394,7 @@ async function loadAvailableMonths() {
// Transform children data for drill-down (extracted from click handler)
function transformDrillDownData(parentNode, parentCategoryName) {
const newData = [];
const sortedChildren = [...parentNode.children].sort((a, b) => b.value - a.value);
const sortedChildren = sortBySubcategoryOrder(parentNode.children, parentCategoryName);
sortedChildren.forEach((child, i) => {
const color = getSubcategoryColor(parentCategoryName, child.name, i);
@ -2095,8 +2138,11 @@ function setupHoverEvents(sunburstData, contextName = null) {
});
}
// Show top categories as default items (sorted by value descending)
displayDetailsItems([...sunburstData.data].sort((a, b) => b.value - a.value), sunburstData.total);
// Show items - sort by value for root level, use fixed order for drilled-down
const itemsToDisplay = contextName
? [...sunburstData.data] // Drilled down: use fixed subcategory order
: [...sunburstData.data].sort((a, b) => b.value - a.value); // Root level: sort by value
displayDetailsItems(itemsToDisplay, sunburstData.total);
}
// Show default view initially
@ -2144,7 +2190,7 @@ function setupHoverEvents(sunburstData, contextName = null) {
const MAX_DETAIL_ITEMS = 10;
let itemsToShow = [];
if (params.data.children && params.data.children.length > 0) {
itemsToShow = [...params.data.children].sort((a, b) => b.value - a.value);
itemsToShow = [...params.data.children];
}
if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) {
const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
@ -2167,7 +2213,7 @@ function setupHoverEvents(sunburstData, contextName = null) {
const MAX_DETAIL_ITEMS = 10;
let itemsToShow = [];
if (params.data.children && params.data.children.length > 0) {
itemsToShow = [...params.data.children].sort((a, b) => b.value - a.value);
itemsToShow = [...params.data.children];
}
if (itemsToShow.length < MAX_DETAIL_ITEMS && params.data.transactions) {
const currentColor = params.color || (params.data.itemStyle ? params.data.itemStyle.color : '#cccccc');
@ -2189,7 +2235,7 @@ function setupHoverEvents(sunburstData, contextName = null) {
const MAX_DETAIL_ITEMS = 10;
let itemsToShow = [];
if (params.data.children && params.data.children.length > 0) {
const sortedChildren = [...params.data.children].sort((a, b) => b.value - a.value);
const sortedChildren = [...params.data.children];
const allMicrocategories = [];
const displayedNames = new Set();
for (const child of sortedChildren) {