diff --git a/app.js b/app.js
index 0fca778..1055321 100644
--- a/app.js
+++ b/app.js
@@ -457,140 +457,272 @@ function transformToSunburst(data) {
}
// Build ECharts stacked bar option for timeline view
-function buildTimelineOption() {
+// drillCategory: if set, show subcategories of this category instead of top-level categories
+function buildTimelineOption(drillCategory) {
const months = availableMonths;
const xLabels = months.map(m => formatMonthLabel(m));
- // Collect per-category totals for each month
- const categoryTotals = {}; // { categoryName: [amountForMonth0, amountForMonth1, ...] }
-
- months.forEach((month, mi) => {
- const data = monthDataCache[month];
- if (!data) return;
- data.forEach(item => {
- const cat = item.category || '';
- if (!cat) return;
- const amount = Math.abs(parseFloat(item.amount_rub));
- if (isNaN(amount)) return;
- if (!categoryTotals[cat]) {
- categoryTotals[cat] = new Array(months.length).fill(0);
- }
- categoryTotals[cat][mi] += amount;
- });
- });
-
- // Build series in categoryOrder, then append any unknown categories
const seriesList = [];
- const usedCategories = new Set();
+ const legendData = [];
- categoryOrder.forEach((catName, ci) => {
- if (!categoryTotals[catName]) return;
- usedCategories.add(catName);
- seriesList.push({
- name: catName,
- type: 'bar',
- stack: 'total',
- barMaxWidth: 50,
- barCategoryGap: '35%',
- data: categoryTotals[catName],
- itemStyle: { color: categoryColors[ci] }
+ const emphLabel = {
+ show: true, position: 'top',
+ formatter: (p) => p.value >= 1000 ? Math.round(p.value / 1000) + 'к' : '',
+ fontSize: 14, fontWeight: 'bold',
+ fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
+ color: '#555', backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 2, padding: [2, 4]
+ };
+
+ if (!drillCategory) {
+ // Top-level: collect per-category totals for each month
+ const categoryTotals = {};
+
+ months.forEach((month, mi) => {
+ const data = monthDataCache[month];
+ if (!data) return;
+ data.forEach(item => {
+ const cat = item.category || '';
+ if (!cat) return;
+ const amount = Math.abs(parseFloat(item.amount_rub));
+ if (isNaN(amount)) return;
+ if (!categoryTotals[cat]) {
+ categoryTotals[cat] = new Array(months.length).fill(0);
+ }
+ categoryTotals[cat][mi] += amount;
+ });
});
+
+ const usedCategories = new Set();
+ categoryOrder.forEach((catName, ci) => {
+ if (!categoryTotals[catName]) return;
+ usedCategories.add(catName);
+ legendData.push(catName);
+ seriesList.push({
+ name: catName,
+ type: 'bar',
+ stack: 'total',
+ barMaxWidth: 50,
+ barCategoryGap: '35%',
+ data: categoryTotals[catName],
+ itemStyle: { color: categoryColors[ci] },
+ label: { show: false },
+ emphasis: { focus: 'series', label: emphLabel },
+ blur: { itemStyle: { opacity: 0.15 } }
+ });
+ });
+
+ Object.keys(categoryTotals).forEach(catName => {
+ if (usedCategories.has(catName)) return;
+ legendData.push(catName);
+ seriesList.push({
+ name: catName,
+ type: 'bar',
+ stack: 'total',
+ barMaxWidth: 50,
+ barCategoryGap: '35%',
+ data: categoryTotals[catName],
+ label: { show: false },
+ emphasis: { focus: 'series', label: emphLabel },
+ blur: { itemStyle: { opacity: 0.15 } }
+ });
+ });
+ } else {
+ // Drill-down: collect per-subcategory totals for the given category
+ const subTotals = {};
+
+ months.forEach((month, mi) => {
+ const data = monthDataCache[month];
+ if (!data) return;
+ data.forEach(item => {
+ if ((item.category || '') !== drillCategory) return;
+ const sub = item.subcategory || 'Другое';
+ const amount = Math.abs(parseFloat(item.amount_rub));
+ if (isNaN(amount)) return;
+ if (!subTotals[sub]) {
+ subTotals[sub] = new Array(months.length).fill(0);
+ }
+ subTotals[sub][mi] += amount;
+ });
+ });
+
+ // Use subcategoryOrder for consistent ordering and colors
+ const order = subcategoryOrder[drillCategory] || [];
+ const usedSubs = new Set();
+ let unknownIdx = 0;
+
+ order.forEach((subName, si) => {
+ if (!subTotals[subName]) return;
+ usedSubs.add(subName);
+ legendData.push(subName);
+ seriesList.push({
+ name: subName,
+ type: 'bar',
+ stack: 'total',
+ barMaxWidth: 50,
+ barCategoryGap: '35%',
+ data: subTotals[subName],
+ itemStyle: { color: getSubcategoryColor(drillCategory, subName, si) },
+ label: { show: false },
+ emphasis: { focus: 'series', label: emphLabel },
+ blur: { itemStyle: { opacity: 0.15 } }
+ });
+ });
+
+ Object.keys(subTotals).forEach(subName => {
+ if (usedSubs.has(subName)) return;
+ legendData.push(subName);
+ seriesList.push({
+ name: subName,
+ type: 'bar',
+ stack: 'total',
+ barMaxWidth: 50,
+ barCategoryGap: '35%',
+ data: subTotals[subName],
+ itemStyle: { color: getSubcategoryColor(drillCategory, subName, 0, unknownIdx++) },
+ label: { show: false },
+ emphasis: { focus: 'series', label: emphLabel },
+ blur: { itemStyle: { opacity: 0.15 } }
+ });
+ });
+ }
+
+ // Invisible "total" series on top — shows sum labels by default
+ const totalPerMonth = new Array(months.length).fill(0);
+ seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); });
+
+ seriesList.push({
+ name: '__total__',
+ type: 'bar',
+ stack: 'total',
+ barMaxWidth: 50,
+ data: totalPerMonth.map(() => 1),
+ itemStyle: { color: 'transparent' },
+ label: {
+ show: true,
+ position: 'top',
+ formatter: (params) => Math.round(totalPerMonth[params.dataIndex] / 1000) + 'к',
+ fontSize: 14,
+ fontWeight: 'bold',
+ fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif',
+ color: '#666'
+ },
+ emphasis: { disabled: true },
+ blur: { label: { show: false }, itemStyle: { opacity: 0 } }
});
- // Unknown categories (not in categoryOrder)
- Object.keys(categoryTotals).forEach(catName => {
- if (usedCategories.has(catName)) return;
- seriesList.push({
- name: catName,
- type: 'bar',
- stack: 'total',
- barMaxWidth: 50,
- barCategoryGap: '35%',
- data: categoryTotals[catName]
- });
- });
+
+
+ const chartTitle = drillCategory ? {
+ text: '← ' + drillCategory,
+ left: 20,
+ top: 10,
+ textStyle: { fontSize: 16, fontWeight: 500, color: '#555' },
+ triggerEvent: true
+ } : { show: false };
return {
backgroundColor: '#fff',
+ title: chartTitle,
tooltip: { show: false },
+ legend: {
+ data: legendData,
+ bottom: 0,
+ left: 'center',
+ itemWidth: 14,
+ itemHeight: 14,
+ textStyle: { fontSize: 13 },
+ itemGap: 16
+ },
grid: {
left: 30,
- right: '28%',
- top: 30,
- bottom: 30,
+ right: 30,
+ top: drillCategory ? 50 : 40,
+ bottom: 50,
containLabel: true
},
xAxis: {
type: 'category',
data: xLabels,
- axisLabel: { fontSize: 11 }
+ axisLabel: { fontSize: 13, rotate: -45 }
},
yAxis: {
type: 'value',
axisLabel: {
formatter: function(val) {
- if (val >= 1000) return Math.round(val / 1000) + 'k';
+ if (val >= 1000) return Math.round(val / 1000) + 'к\u202F₽';
return val;
},
- fontSize: 11
+ fontSize: 13
}
},
series: seriesList
};
}
-// Update details panel for timeline view
-function updateTimelineDetails(monthIndex) {
- const detailsHeader = document.getElementById('details-header');
- const topItemsEl = document.getElementById('top-items');
+let timelineDrillCategory = null;
- // Reset header to clean state (remove any eye buttons from sunburst mode)
- detailsHeader.innerHTML = '';
- const hoverName = detailsHeader.querySelector('.hover-name');
- const hoverAmount = detailsHeader.querySelector('.hover-amount');
+// Render (or re-render) the timeline chart, optionally drilled into a category
+function renderTimelineChart(drillCategory) {
+ timelineDrillCategory = drillCategory;
- let monthLabel, data;
- if (monthIndex === null || monthIndex === undefined) {
- monthLabel = 'Все месяцы';
- data = [];
- for (const month of availableMonths) {
- if (monthDataCache[month]) data.push(...monthDataCache[month]);
+ myChart.off('click');
+ myChart.off('mouseover');
+ myChart.off('mouseout');
+ myChart.off('globalout');
+
+ const tlOption = buildTimelineOption(drillCategory);
+ myChart.clear();
+ myChart.setOption(tlOption, true);
+
+ // Highlight entire series on hover so all bars show emphasis.label
+ let hlSeries = null;
+ myChart.on('mouseover', function(params) {
+ if (params.componentType !== 'series' || params.seriesName === '__total__') return;
+ if (hlSeries === params.seriesName) return;
+ if (hlSeries) myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
+ hlSeries = params.seriesName;
+ myChart.dispatchAction({ type: 'highlight', seriesName: params.seriesName });
+ });
+ myChart.on('mouseout', function(params) {
+ if (params.componentType !== 'series' || !hlSeries) return;
+ myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
+ hlSeries = null;
+ });
+ myChart.on('globalout', function() {
+ if (hlSeries) {
+ myChart.dispatchAction({ type: 'downplay', seriesName: hlSeries });
+ hlSeries = null;
}
- } else {
- const month = availableMonths[monthIndex];
- monthLabel = formatMonthLabel(month);
- data = monthDataCache[month] || [];
+ });
+
+ // Clean up previous zrender handlers
+ if (window._timelineZrClickHandler) {
+ myChart.getZr().off('click', window._timelineZrClickHandler);
+ window._timelineZrClickHandler = null;
}
- // Compute category totals
- const catTotals = {};
- let total = 0;
- data.forEach(item => {
- const cat = item.category || '';
- if (!cat) return;
- const amount = Math.abs(parseFloat(item.amount_rub));
- if (isNaN(amount)) return;
- catTotals[cat] = (catTotals[cat] || 0) + amount;
- total += amount;
+ // Click handler: drill down into category, or go back from subcategory view
+ myChart.on('click', function(params) {
+ if (params.componentType === 'title') {
+ renderTimelineChart(null);
+ return;
+ }
+ if (params.componentType === 'series' && params.seriesName !== '__total__') {
+ if (!drillCategory) {
+ renderTimelineChart(params.seriesName);
+ }
+ }
});
- hoverName.textContent = monthLabel;
- hoverAmount.textContent = formatRUB(total) + '\u202F₽';
-
- // Sort by value descending
- const sorted = Object.entries(catTotals).sort((a, b) => b[1] - a[1]);
-
- topItemsEl.innerHTML = '';
- sorted.forEach(([cat, amount]) => {
- const pct = ((amount / total) * 100).toFixed(1);
- const colorIdx = categoryOrder.indexOf(cat);
- const color = colorIdx !== -1 ? categoryColors[colorIdx] : '#999';
-
- const item = document.createElement('div');
- item.className = 'top-item';
- item.innerHTML = `${cat}${pct}%${formatRUB(amount)}\u202F₽`;
- topItemsEl.appendChild(item);
- });
+ // Background click (zrender level): go back when drilled
+ if (drillCategory) {
+ window._timelineZrClickHandler = function(e) {
+ if (!e.target) {
+ renderTimelineChart(null);
+ }
+ };
+ myChart.getZr().on('click', window._timelineZrClickHandler);
+ }
}
// Switch between month (sunburst) and timeline (stacked bar) views
@@ -614,34 +746,7 @@ function switchView(viewName) {
myChart.off('globalout');
// Resize after layout change, then render stacked bar
myChart.resize();
- myChart.setOption(buildTimelineOption(), true);
-
- // Hover events update the details panel
- myChart.on('mouseover', function(params) {
- if (params.componentType === 'series') {
- updateTimelineDetails(params.dataIndex);
- }
- });
- myChart.on('globalout', function() {
- updateTimelineDetails(null);
- });
- // Show aggregate by default
- updateTimelineDetails(null);
-
- // Click handler: clicking a bar switches to month view for that month
- myChart.on('click', function(params) {
- if (params.componentType === 'series') {
- const monthIndex = params.dataIndex;
- if (monthIndex >= 0 && monthIndex < availableMonths.length) {
- selectedMonthIndices.clear();
- selectedMonthIndices.add(monthIndex);
- currentMonthIndex = monthIndex;
- switchView('month');
- selectMonth(monthIndex);
- saveMonthSelectionState();
- }
- }
- });
+ renderTimelineChart(null);
} else {
container.classList.remove('timeline-mode');
// Remove all chart event listeners
diff --git a/styles.css b/styles.css
index ecd16b7..a448142 100644
--- a/styles.css
+++ b/styles.css
@@ -157,7 +157,8 @@ body {
/* Timeline mode modifiers */
.container.timeline-mode #center-label,
-.container.timeline-mode #chart-eye-btn {
+.container.timeline-mode #chart-eye-btn,
+.container.timeline-mode #details-box {
display: none !important;
}
@@ -170,20 +171,6 @@ body {
width: 100%;
}
-.container.timeline-mode #details-box {
- position: absolute;
- top: 20px;
- right: 30px;
- width: 280px;
- min-width: 0;
- max-width: 280px;
- background: transparent;
- box-shadow: none;
- border: none;
- opacity: 1;
- z-index: 10;
-}
-
.content-wrapper {
display: flex;
position: relative;
@@ -480,19 +467,6 @@ body {
width: 100%;
}
- .container.timeline-mode #details-box {
- position: relative;
- top: auto;
- right: auto;
- width: 100%;
- min-width: 100%;
- max-width: none;
- background: white;
- box-shadow: 0 2px 15px rgba(0, 0, 0, 0.15);
- border: 1px solid #eee;
- margin-top: 15px;
- }
-
.top-item-amount {
min-width: 80px;
}