feat: add multi-level drill-down, browser history, and legend filtering to timeline
- Support 3-level drill: category → subcategory → microcategory - Integrate browser back/forward with drill path and legend selection state - Cmd-click isolates single series, Opt-click toggles series off - Dynamic total labels update to reflect only visible series - Add "Очистить" reset button when series are filtered - Click month labels to switch to that month's donut view - Persist timeline drill path in localStorage across reloads Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9b848610ae
commit
06fb6bf768
342
app.js
342
app.js
@ -138,8 +138,36 @@ window.addEventListener('popstate', function(e) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No modal open - handle drill-down navigation
|
// No modal open - handle navigation
|
||||||
|
if (e.state && e.state.timelineDrill !== undefined) {
|
||||||
|
// Timeline drill-down navigation (possibly with legend selection)
|
||||||
|
if (currentView !== 'timeline') {
|
||||||
|
currentView = 'timeline';
|
||||||
|
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.view === 'timeline');
|
||||||
|
});
|
||||||
|
document.querySelector('.container').classList.add('timeline-mode');
|
||||||
|
myChart.resize();
|
||||||
|
}
|
||||||
|
renderTimelineChart(e.state.timelineDrill, 'none', e.state.legendSelected || null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.state && e.state.monthView) {
|
||||||
|
// Month view navigation (from view switch)
|
||||||
|
if (currentView !== 'month') {
|
||||||
|
switchView('month');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.state && e.state.drillDown !== undefined) {
|
if (e.state && e.state.drillDown !== undefined) {
|
||||||
|
// Donut drill-down navigation
|
||||||
|
if (currentView !== 'month') {
|
||||||
|
// Switch from timeline to month view, then re-render donut from scratch
|
||||||
|
switchView('month');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const stateIndex = e.state.drillDown;
|
const stateIndex = e.state.drillDown;
|
||||||
if (stateIndex >= 0 && stateIndex < drillDownHistory.length) {
|
if (stateIndex >= 0 && stateIndex < drillDownHistory.length) {
|
||||||
historyIndex = stateIndex;
|
historyIndex = stateIndex;
|
||||||
@ -457,8 +485,10 @@ function transformToSunburst(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build ECharts stacked bar option for timeline view
|
// Build ECharts stacked bar option for timeline view
|
||||||
// drillCategory: if set, show subcategories of this category instead of top-level categories
|
// drillPath: array like [] (categories), ['Cat'] (subcategories), ['Cat','Sub'] (microcategories)
|
||||||
function buildTimelineOption(drillCategory) {
|
function buildTimelineOption(drillPath) {
|
||||||
|
drillPath = drillPath || [];
|
||||||
|
const depth = drillPath.length; // 0=category, 1=subcategory, 2=microcategory
|
||||||
const months = availableMonths;
|
const months = availableMonths;
|
||||||
const xLabels = months.map(m => formatMonthLabel(m));
|
const xLabels = months.map(m => formatMonthLabel(m));
|
||||||
|
|
||||||
@ -473,28 +503,42 @@ function buildTimelineOption(drillCategory) {
|
|||||||
color: '#555', backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 2, padding: [2, 4]
|
color: '#555', backgroundColor: 'rgba(255,255,255,0.9)', borderRadius: 2, padding: [2, 4]
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!drillCategory) {
|
// Hierarchy fields in order: category → subcategory → microcategory
|
||||||
// Top-level: collect per-category totals for each month
|
const hierarchyFields = ['category', 'subcategory', 'microcategory'];
|
||||||
const categoryTotals = {};
|
|
||||||
|
// Collect totals grouped by the next level in the hierarchy
|
||||||
|
const groupTotals = {};
|
||||||
|
|
||||||
months.forEach((month, mi) => {
|
months.forEach((month, mi) => {
|
||||||
const data = monthDataCache[month];
|
const data = monthDataCache[month];
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
const cat = item.category || '';
|
|
||||||
if (!cat) return;
|
|
||||||
const amount = Math.abs(parseFloat(item.amount_rub));
|
const amount = Math.abs(parseFloat(item.amount_rub));
|
||||||
if (isNaN(amount)) return;
|
if (isNaN(amount)) return;
|
||||||
if (!categoryTotals[cat]) {
|
|
||||||
categoryTotals[cat] = new Array(months.length).fill(0);
|
// Filter: item must match all path segments
|
||||||
|
for (let i = 0; i < depth; i++) {
|
||||||
|
const field = hierarchyFields[i];
|
||||||
|
const itemVal = item[field] || '';
|
||||||
|
if (itemVal !== drillPath[i]) return;
|
||||||
}
|
}
|
||||||
categoryTotals[cat][mi] += amount;
|
|
||||||
|
// Group by the next field in the hierarchy
|
||||||
|
const groupField = hierarchyFields[depth];
|
||||||
|
const groupName = item[groupField] || 'Другое';
|
||||||
|
if (!groupTotals[groupName]) {
|
||||||
|
groupTotals[groupName] = new Array(months.length).fill(0);
|
||||||
|
}
|
||||||
|
groupTotals[groupName][mi] += amount;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build series with appropriate ordering and colors per depth level
|
||||||
|
if (depth === 0) {
|
||||||
|
// Level 0: use categoryOrder and categoryColors
|
||||||
const usedCategories = new Set();
|
const usedCategories = new Set();
|
||||||
categoryOrder.forEach((catName, ci) => {
|
categoryOrder.forEach((catName, ci) => {
|
||||||
if (!categoryTotals[catName]) return;
|
if (!groupTotals[catName]) return;
|
||||||
usedCategories.add(catName);
|
usedCategories.add(catName);
|
||||||
legendData.push(catName);
|
legendData.push(catName);
|
||||||
seriesList.push({
|
seriesList.push({
|
||||||
@ -503,7 +547,7 @@ function buildTimelineOption(drillCategory) {
|
|||||||
stack: 'total',
|
stack: 'total',
|
||||||
barMaxWidth: 50,
|
barMaxWidth: 50,
|
||||||
barCategoryGap: '35%',
|
barCategoryGap: '35%',
|
||||||
data: categoryTotals[catName],
|
data: groupTotals[catName],
|
||||||
itemStyle: { color: categoryColors[ci] },
|
itemStyle: { color: categoryColors[ci] },
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
emphasis: { focus: 'series', label: emphLabel },
|
emphasis: { focus: 'series', label: emphLabel },
|
||||||
@ -511,7 +555,7 @@ function buildTimelineOption(drillCategory) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.keys(categoryTotals).forEach(catName => {
|
Object.keys(groupTotals).forEach(catName => {
|
||||||
if (usedCategories.has(catName)) return;
|
if (usedCategories.has(catName)) return;
|
||||||
legendData.push(catName);
|
legendData.push(catName);
|
||||||
seriesList.push({
|
seriesList.push({
|
||||||
@ -520,38 +564,21 @@ function buildTimelineOption(drillCategory) {
|
|||||||
stack: 'total',
|
stack: 'total',
|
||||||
barMaxWidth: 50,
|
barMaxWidth: 50,
|
||||||
barCategoryGap: '35%',
|
barCategoryGap: '35%',
|
||||||
data: categoryTotals[catName],
|
data: groupTotals[catName],
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
emphasis: { focus: 'series', label: emphLabel },
|
emphasis: { focus: 'series', label: emphLabel },
|
||||||
blur: { itemStyle: { opacity: 0.15 } }
|
blur: { itemStyle: { opacity: 0.15 } }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else if (depth === 1) {
|
||||||
// Drill-down: collect per-subcategory totals for the given category
|
// Level 1: use subcategoryOrder and getSubcategoryColor
|
||||||
const subTotals = {};
|
const parentCategory = drillPath[0];
|
||||||
|
const order = subcategoryOrder[parentCategory] || [];
|
||||||
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();
|
const usedSubs = new Set();
|
||||||
let unknownIdx = 0;
|
let unknownIdx = 0;
|
||||||
|
|
||||||
order.forEach((subName, si) => {
|
order.forEach((subName, si) => {
|
||||||
if (!subTotals[subName]) return;
|
if (!groupTotals[subName]) return;
|
||||||
usedSubs.add(subName);
|
usedSubs.add(subName);
|
||||||
legendData.push(subName);
|
legendData.push(subName);
|
||||||
seriesList.push({
|
seriesList.push({
|
||||||
@ -560,15 +587,15 @@ function buildTimelineOption(drillCategory) {
|
|||||||
stack: 'total',
|
stack: 'total',
|
||||||
barMaxWidth: 50,
|
barMaxWidth: 50,
|
||||||
barCategoryGap: '35%',
|
barCategoryGap: '35%',
|
||||||
data: subTotals[subName],
|
data: groupTotals[subName],
|
||||||
itemStyle: { color: getSubcategoryColor(drillCategory, subName, si) },
|
itemStyle: { color: getSubcategoryColor(parentCategory, subName, si) },
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
emphasis: { focus: 'series', label: emphLabel },
|
emphasis: { focus: 'series', label: emphLabel },
|
||||||
blur: { itemStyle: { opacity: 0.15 } }
|
blur: { itemStyle: { opacity: 0.15 } }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.keys(subTotals).forEach(subName => {
|
Object.keys(groupTotals).forEach(subName => {
|
||||||
if (usedSubs.has(subName)) return;
|
if (usedSubs.has(subName)) return;
|
||||||
legendData.push(subName);
|
legendData.push(subName);
|
||||||
seriesList.push({
|
seriesList.push({
|
||||||
@ -577,16 +604,46 @@ function buildTimelineOption(drillCategory) {
|
|||||||
stack: 'total',
|
stack: 'total',
|
||||||
barMaxWidth: 50,
|
barMaxWidth: 50,
|
||||||
barCategoryGap: '35%',
|
barCategoryGap: '35%',
|
||||||
data: subTotals[subName],
|
data: groupTotals[subName],
|
||||||
itemStyle: { color: getSubcategoryColor(drillCategory, subName, 0, unknownIdx++) },
|
itemStyle: { color: getSubcategoryColor(parentCategory, subName, 0, unknownIdx++) },
|
||||||
label: { show: false },
|
label: { show: false },
|
||||||
emphasis: { focus: 'series', label: emphLabel },
|
emphasis: { focus: 'series', label: emphLabel },
|
||||||
blur: { itemStyle: { opacity: 0.15 } }
|
blur: { itemStyle: { opacity: 0.15 } }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Level 2 (microcategory): auto-colors from defaultColorPalette
|
||||||
|
let colorIdx = 0;
|
||||||
|
// Sort by total value descending for consistent ordering
|
||||||
|
const sortedNames = Object.keys(groupTotals).sort((a, b) => {
|
||||||
|
const sumA = groupTotals[a].reduce((s, v) => s + v, 0);
|
||||||
|
const sumB = groupTotals[b].reduce((s, v) => s + v, 0);
|
||||||
|
return sumB - sumA;
|
||||||
|
});
|
||||||
|
|
||||||
|
sortedNames.forEach(name => {
|
||||||
|
legendData.push(name);
|
||||||
|
seriesList.push({
|
||||||
|
name: name,
|
||||||
|
type: 'bar',
|
||||||
|
stack: 'total',
|
||||||
|
barMaxWidth: 50,
|
||||||
|
barCategoryGap: '35%',
|
||||||
|
data: groupTotals[name],
|
||||||
|
itemStyle: { color: defaultColorPalette[colorIdx % defaultColorPalette.length] },
|
||||||
|
label: { show: false },
|
||||||
|
emphasis: { focus: 'series', label: emphLabel },
|
||||||
|
blur: { itemStyle: { opacity: 0.15 } }
|
||||||
|
});
|
||||||
|
colorIdx++;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invisible "total" series on top — shows sum labels by default
|
// Build per-series data map for dynamic total recalculation
|
||||||
|
const seriesDataMap = {};
|
||||||
|
seriesList.forEach(s => { seriesDataMap[s.name] = s.data; });
|
||||||
|
|
||||||
|
// Invisible "total" series on top — shows sum labels that update with legend selection
|
||||||
const totalPerMonth = new Array(months.length).fill(0);
|
const totalPerMonth = new Array(months.length).fill(0);
|
||||||
seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); });
|
seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); });
|
||||||
|
|
||||||
@ -612,8 +669,9 @@ function buildTimelineOption(drillCategory) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
const chartTitle = drillCategory ? {
|
const isDrilled = drillPath.length > 0;
|
||||||
text: '← ' + drillCategory,
|
const chartTitle = isDrilled ? {
|
||||||
|
text: '← ' + drillPath.join(' › '),
|
||||||
left: 'center',
|
left: 'center',
|
||||||
top: 6,
|
top: 6,
|
||||||
textStyle: {
|
textStyle: {
|
||||||
@ -631,7 +689,7 @@ function buildTimelineOption(drillCategory) {
|
|||||||
tooltip: { show: false },
|
tooltip: { show: false },
|
||||||
legend: {
|
legend: {
|
||||||
data: legendData,
|
data: legendData,
|
||||||
top: drillCategory ? 42 : 10,
|
top: isDrilled ? 42 : 10,
|
||||||
right: 10,
|
right: 10,
|
||||||
orient: 'vertical',
|
orient: 'vertical',
|
||||||
itemWidth: 12,
|
itemWidth: 12,
|
||||||
@ -646,7 +704,7 @@ function buildTimelineOption(drillCategory) {
|
|||||||
grid: {
|
grid: {
|
||||||
left: 30,
|
left: 30,
|
||||||
right: 180,
|
right: 180,
|
||||||
top: drillCategory ? 55 : 40,
|
top: isDrilled ? 55 : 40,
|
||||||
bottom: 30,
|
bottom: 30,
|
||||||
containLabel: true
|
containLabel: true
|
||||||
},
|
},
|
||||||
@ -665,25 +723,62 @@ function buildTimelineOption(drillCategory) {
|
|||||||
fontSize: 13
|
fontSize: 13
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
series: seriesList
|
series: seriesList,
|
||||||
|
_seriesDataMap: seriesDataMap,
|
||||||
|
_monthCount: months.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let timelineDrillCategory = null;
|
let timelineDrillPath = [];
|
||||||
|
|
||||||
// Render (or re-render) the timeline chart, optionally drilled into a category
|
// Render (or re-render) the timeline chart, optionally drilled into a category path
|
||||||
function renderTimelineChart(drillCategory) {
|
// historyAction: 'push' (default, user drill-down), 'replace' (initial/view switch), 'none' (popstate restore)
|
||||||
timelineDrillCategory = drillCategory;
|
function renderTimelineChart(drillPath, historyAction, legendSelected) {
|
||||||
|
drillPath = drillPath || [];
|
||||||
|
historyAction = historyAction || 'push';
|
||||||
|
timelineDrillPath = drillPath;
|
||||||
|
|
||||||
myChart.off('click');
|
myChart.off('click');
|
||||||
myChart.off('mouseover');
|
myChart.off('mouseover');
|
||||||
myChart.off('mouseout');
|
myChart.off('mouseout');
|
||||||
myChart.off('globalout');
|
myChart.off('globalout');
|
||||||
|
myChart.off('legendselectchanged');
|
||||||
|
// Clean up previous zrender label-click handler
|
||||||
|
if (window._timelineZrLabelHandler) {
|
||||||
|
myChart.getZr().off('click', window._timelineZrLabelHandler);
|
||||||
|
window._timelineZrLabelHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
const tlOption = buildTimelineOption(drillCategory);
|
const tlOption = buildTimelineOption(drillPath);
|
||||||
myChart.clear();
|
myChart.clear();
|
||||||
myChart.setOption(tlOption, true);
|
myChart.setOption(tlOption, true);
|
||||||
|
|
||||||
|
// Restore legend selection if provided (from popstate)
|
||||||
|
if (legendSelected) {
|
||||||
|
window._suppressLegendHistory = true;
|
||||||
|
Object.keys(legendSelected).forEach(name => {
|
||||||
|
myChart.dispatchAction({
|
||||||
|
type: legendSelected[name] === false ? 'legendUnSelect' : 'legendSelect',
|
||||||
|
name: name
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window._suppressLegendHistory = false;
|
||||||
|
// Update totals and reset button for the restored selection
|
||||||
|
updateTotalsAndResetBtn(legendSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update browser history state
|
||||||
|
const histState = { timelineDrill: drillPath };
|
||||||
|
if (legendSelected) histState.legendSelected = legendSelected;
|
||||||
|
if (historyAction === 'replace') {
|
||||||
|
history.replaceState(histState, '');
|
||||||
|
} else if (historyAction === 'push') {
|
||||||
|
history.pushState(histState, '');
|
||||||
|
}
|
||||||
|
// 'none': skip history manipulation (popstate restore)
|
||||||
|
|
||||||
|
saveMonthSelectionState();
|
||||||
|
|
||||||
// Highlight entire series on hover so all bars show emphasis.label
|
// Highlight entire series on hover so all bars show emphasis.label
|
||||||
// Also highlight the corresponding legend item
|
// Also highlight the corresponding legend item
|
||||||
const legendNames = tlOption.legend.data;
|
const legendNames = tlOption.legend.data;
|
||||||
@ -723,34 +818,125 @@ function renderTimelineChart(drillCategory) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clean up previous zrender handlers
|
// Track whether all series are selected
|
||||||
if (window._timelineZrClickHandler) {
|
function updateTotalsAndResetBtn(selected) {
|
||||||
myChart.getZr().off('click', window._timelineZrClickHandler);
|
const dataMap = tlOption._seriesDataMap;
|
||||||
window._timelineZrClickHandler = null;
|
const n = tlOption._monthCount;
|
||||||
|
const visibleTotals = new Array(n).fill(0);
|
||||||
|
const allNames = tlOption.legend.data;
|
||||||
|
let allSelected = true;
|
||||||
|
Object.keys(dataMap).forEach(name => {
|
||||||
|
if (selected[name] !== false) {
|
||||||
|
dataMap[name].forEach((v, i) => { visibleTotals[i] += v; });
|
||||||
|
} else {
|
||||||
|
allSelected = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
myChart.setOption({
|
||||||
|
series: [{ name: '__total__', label: {
|
||||||
|
formatter: (p) => Math.round(visibleTotals[p.dataIndex] / 1000) + 'к'
|
||||||
|
}}],
|
||||||
|
graphic: [{
|
||||||
|
type: 'text',
|
||||||
|
id: 'resetBtn',
|
||||||
|
right: 22,
|
||||||
|
top: (drillPath.length > 0 ? 42 : 10) + allNames.length * 20 + 16,
|
||||||
|
style: {
|
||||||
|
text: allSelected ? '' : 'Очистить',
|
||||||
|
fontSize: 13,
|
||||||
|
fill: '#999',
|
||||||
|
fontFamily: '-apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", system-ui, sans-serif'
|
||||||
|
},
|
||||||
|
cursor: 'pointer',
|
||||||
|
onclick: function() {
|
||||||
|
window._suppressLegendHistory = true;
|
||||||
|
allNames.forEach(name => {
|
||||||
|
myChart.dispatchAction({ type: 'legendSelect', name: name });
|
||||||
|
});
|
||||||
|
window._suppressLegendHistory = false;
|
||||||
|
const resetSelected = {};
|
||||||
|
allNames.forEach(name => { resetSelected[name] = true; });
|
||||||
|
updateTotalsAndResetBtn(resetSelected);
|
||||||
|
history.pushState({ timelineDrill: drillPath }, '');
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click handler: drill down into category, or go back from subcategory view
|
// Update total labels when legend selection changes (cmd-click / opt-click / legend click)
|
||||||
|
myChart.on('legendselectchanged', function(params) {
|
||||||
|
updateTotalsAndResetBtn(params.selected);
|
||||||
|
// Push legend selection to browser history (skip during popstate restore)
|
||||||
|
if (!window._suppressLegendHistory) {
|
||||||
|
history.pushState({ timelineDrill: drillPath, legendSelected: params.selected }, '');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click handler: drill deeper or go up via title
|
||||||
myChart.on('click', function(params) {
|
myChart.on('click', function(params) {
|
||||||
if (params.componentType === 'title') {
|
if (params.componentType === 'title') {
|
||||||
renderTimelineChart(null);
|
// Go up one level
|
||||||
|
renderTimelineChart(drillPath.slice(0, -1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (params.componentType === 'series' && params.seriesName !== '__total__') {
|
if (params.componentType === 'series' && params.seriesName !== '__total__') {
|
||||||
if (!drillCategory) {
|
const nativeEvent = params.event && params.event.event;
|
||||||
renderTimelineChart(params.seriesName);
|
// Cmd-click: select only this series (hide all others)
|
||||||
|
if (nativeEvent && nativeEvent.metaKey) {
|
||||||
|
const allNames = tlOption.legend.data;
|
||||||
|
window._suppressLegendHistory = true;
|
||||||
|
allNames.forEach(name => {
|
||||||
|
myChart.dispatchAction({
|
||||||
|
type: name === params.seriesName ? 'legendSelect' : 'legendUnSelect',
|
||||||
|
name: name
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window._suppressLegendHistory = false;
|
||||||
|
// Build selected map and push once
|
||||||
|
const selected = {};
|
||||||
|
allNames.forEach(name => { selected[name] = name === params.seriesName; });
|
||||||
|
updateTotalsAndResetBtn(selected);
|
||||||
|
history.pushState({ timelineDrill: drillPath, legendSelected: selected }, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Opt-click: toggle this series off (single action — legendselectchanged handles push)
|
||||||
|
if (nativeEvent && nativeEvent.altKey) {
|
||||||
|
myChart.dispatchAction({ type: 'legendToggleSelect', name: params.seriesName });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Plain click: drill deeper if not at max depth (2 = microcategory level)
|
||||||
|
if (drillPath.length < 2) {
|
||||||
|
renderTimelineChart([...drillPath, params.seriesName]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background click (zrender level): go back when drilled
|
// Zrender-level click: detect clicks in the x-axis label area → switch to that month
|
||||||
if (drillCategory) {
|
window._timelineZrLabelHandler = function(e) {
|
||||||
window._timelineZrClickHandler = function(e) {
|
const y = e.offsetY;
|
||||||
if (!e.target) {
|
const yBottom = myChart.convertToPixel({yAxisIndex: 0}, 0);
|
||||||
renderTimelineChart(null);
|
// Click must be below the bar area (in the label zone)
|
||||||
|
if (y > yBottom + 5) {
|
||||||
|
const x = e.offsetX;
|
||||||
|
// Find nearest bar index
|
||||||
|
let bestIdx = -1, bestDist = Infinity;
|
||||||
|
for (let i = 0; i < availableMonths.length; i++) {
|
||||||
|
const barX = myChart.convertToPixel({xAxisIndex: 0}, i);
|
||||||
|
const dist = Math.abs(x - barX);
|
||||||
|
if (dist < bestDist) { bestDist = dist; bestIdx = i; }
|
||||||
|
}
|
||||||
|
// Accept if within half the bar spacing
|
||||||
|
const spacing = availableMonths.length > 1
|
||||||
|
? Math.abs(myChart.convertToPixel({xAxisIndex: 0}, 1) - myChart.convertToPixel({xAxisIndex: 0}, 0))
|
||||||
|
: 100;
|
||||||
|
if (bestIdx >= 0 && bestDist < spacing * 0.6) {
|
||||||
|
history.pushState({ monthView: true }, '');
|
||||||
|
switchView('month');
|
||||||
|
selectSingleMonth(bestIdx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
myChart.getZr().on('click', window._timelineZrClickHandler);
|
myChart.getZr().on('click', window._timelineZrLabelHandler);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch between month (sunburst) and timeline (stacked bar) views
|
// Switch between month (sunburst) and timeline (stacked bar) views
|
||||||
@ -772,9 +958,12 @@ function switchView(viewName) {
|
|||||||
myChart.off('mouseover');
|
myChart.off('mouseover');
|
||||||
myChart.off('mouseout');
|
myChart.off('mouseout');
|
||||||
myChart.off('globalout');
|
myChart.off('globalout');
|
||||||
|
myChart.off('legendselectchanged');
|
||||||
// Resize after layout change, then render stacked bar
|
// Resize after layout change, then render stacked bar
|
||||||
myChart.resize();
|
myChart.resize();
|
||||||
renderTimelineChart(null);
|
renderTimelineChart(timelineDrillPath, 'replace');
|
||||||
|
// Ensure resize after CSS layout fully settles (needed for deferred restore)
|
||||||
|
requestAnimationFrame(() => myChart.resize());
|
||||||
} else {
|
} else {
|
||||||
container.classList.remove('timeline-mode');
|
container.classList.remove('timeline-mode');
|
||||||
// Remove all chart event listeners
|
// Remove all chart event listeners
|
||||||
@ -782,6 +971,7 @@ function switchView(viewName) {
|
|||||||
myChart.off('mouseover');
|
myChart.off('mouseover');
|
||||||
myChart.off('mouseout');
|
myChart.off('mouseout');
|
||||||
myChart.off('globalout');
|
myChart.off('globalout');
|
||||||
|
myChart.off('legendselectchanged');
|
||||||
// Clear chart completely so no bar chart remnants remain
|
// Clear chart completely so no bar chart remnants remain
|
||||||
myChart.clear();
|
myChart.clear();
|
||||||
option = null;
|
option = null;
|
||||||
@ -1419,7 +1609,8 @@ function saveMonthSelectionState() {
|
|||||||
currentMonth,
|
currentMonth,
|
||||||
selectedMonths,
|
selectedMonths,
|
||||||
drillPath: [...currentDrillPath],
|
drillPath: [...currentDrillPath],
|
||||||
view: currentView
|
view: currentView,
|
||||||
|
timelineDrillPath: [...timelineDrillPath]
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore storage failures (private mode, disabled storage, etc.)
|
// Ignore storage failures (private mode, disabled storage, etc.)
|
||||||
@ -1459,6 +1650,11 @@ function restoreMonthSelectionState() {
|
|||||||
currentDrillPath = [];
|
currentDrillPath = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore timeline drill path if saved
|
||||||
|
if (Array.isArray(saved.timelineDrillPath)) {
|
||||||
|
timelineDrillPath = [...saved.timelineDrillPath];
|
||||||
|
}
|
||||||
|
|
||||||
// Defer timeline restore so chart is initialized first
|
// Defer timeline restore so chart is initialized first
|
||||||
if (saved.view === 'timeline') {
|
if (saved.view === 'timeline') {
|
||||||
requestAnimationFrame(() => switchView('timeline'));
|
requestAnimationFrame(() => switchView('timeline'));
|
||||||
@ -1923,7 +2119,11 @@ async function loadAvailableMonths() {
|
|||||||
// Set up view switcher
|
// Set up view switcher
|
||||||
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
|
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
switchView(btn.dataset.view);
|
const target = btn.dataset.view;
|
||||||
|
if (target !== currentView) {
|
||||||
|
history.pushState(target === 'timeline' ? { timelineDrill: timelineDrillPath } : { monthView: true }, '');
|
||||||
|
}
|
||||||
|
switchView(target);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user