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
360
app.js
360
app.js
@ -138,8 +138,36 @@ window.addEventListener('popstate', function(e) {
|
||||
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) {
|
||||
// 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;
|
||||
if (stateIndex >= 0 && stateIndex < drillDownHistory.length) {
|
||||
historyIndex = stateIndex;
|
||||
@ -457,8 +485,10 @@ function transformToSunburst(data) {
|
||||
}
|
||||
|
||||
// Build ECharts stacked bar option for timeline view
|
||||
// drillCategory: if set, show subcategories of this category instead of top-level categories
|
||||
function buildTimelineOption(drillCategory) {
|
||||
// drillPath: array like [] (categories), ['Cat'] (subcategories), ['Cat','Sub'] (microcategories)
|
||||
function buildTimelineOption(drillPath) {
|
||||
drillPath = drillPath || [];
|
||||
const depth = drillPath.length; // 0=category, 1=subcategory, 2=microcategory
|
||||
const months = availableMonths;
|
||||
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]
|
||||
};
|
||||
|
||||
if (!drillCategory) {
|
||||
// Top-level: collect per-category totals for each month
|
||||
const categoryTotals = {};
|
||||
// Hierarchy fields in order: category → subcategory → microcategory
|
||||
const hierarchyFields = ['category', 'subcategory', 'microcategory'];
|
||||
|
||||
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;
|
||||
});
|
||||
// Collect totals grouped by the next level in the hierarchy
|
||||
const groupTotals = {};
|
||||
|
||||
months.forEach((month, mi) => {
|
||||
const data = monthDataCache[month];
|
||||
if (!data) return;
|
||||
data.forEach(item => {
|
||||
const amount = Math.abs(parseFloat(item.amount_rub));
|
||||
if (isNaN(amount)) return;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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();
|
||||
categoryOrder.forEach((catName, ci) => {
|
||||
if (!categoryTotals[catName]) return;
|
||||
if (!groupTotals[catName]) return;
|
||||
usedCategories.add(catName);
|
||||
legendData.push(catName);
|
||||
seriesList.push({
|
||||
@ -503,7 +547,7 @@ function buildTimelineOption(drillCategory) {
|
||||
stack: 'total',
|
||||
barMaxWidth: 50,
|
||||
barCategoryGap: '35%',
|
||||
data: categoryTotals[catName],
|
||||
data: groupTotals[catName],
|
||||
itemStyle: { color: categoryColors[ci] },
|
||||
label: { show: false },
|
||||
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;
|
||||
legendData.push(catName);
|
||||
seriesList.push({
|
||||
@ -520,38 +564,21 @@ function buildTimelineOption(drillCategory) {
|
||||
stack: 'total',
|
||||
barMaxWidth: 50,
|
||||
barCategoryGap: '35%',
|
||||
data: categoryTotals[catName],
|
||||
data: groupTotals[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] || [];
|
||||
} else if (depth === 1) {
|
||||
// Level 1: use subcategoryOrder and getSubcategoryColor
|
||||
const parentCategory = drillPath[0];
|
||||
const order = subcategoryOrder[parentCategory] || [];
|
||||
const usedSubs = new Set();
|
||||
let unknownIdx = 0;
|
||||
|
||||
order.forEach((subName, si) => {
|
||||
if (!subTotals[subName]) return;
|
||||
if (!groupTotals[subName]) return;
|
||||
usedSubs.add(subName);
|
||||
legendData.push(subName);
|
||||
seriesList.push({
|
||||
@ -560,15 +587,15 @@ function buildTimelineOption(drillCategory) {
|
||||
stack: 'total',
|
||||
barMaxWidth: 50,
|
||||
barCategoryGap: '35%',
|
||||
data: subTotals[subName],
|
||||
itemStyle: { color: getSubcategoryColor(drillCategory, subName, si) },
|
||||
data: groupTotals[subName],
|
||||
itemStyle: { color: getSubcategoryColor(parentCategory, subName, si) },
|
||||
label: { show: false },
|
||||
emphasis: { focus: 'series', label: emphLabel },
|
||||
blur: { itemStyle: { opacity: 0.15 } }
|
||||
});
|
||||
});
|
||||
|
||||
Object.keys(subTotals).forEach(subName => {
|
||||
Object.keys(groupTotals).forEach(subName => {
|
||||
if (usedSubs.has(subName)) return;
|
||||
legendData.push(subName);
|
||||
seriesList.push({
|
||||
@ -577,16 +604,46 @@ function buildTimelineOption(drillCategory) {
|
||||
stack: 'total',
|
||||
barMaxWidth: 50,
|
||||
barCategoryGap: '35%',
|
||||
data: subTotals[subName],
|
||||
itemStyle: { color: getSubcategoryColor(drillCategory, subName, 0, unknownIdx++) },
|
||||
data: groupTotals[subName],
|
||||
itemStyle: { color: getSubcategoryColor(parentCategory, subName, 0, unknownIdx++) },
|
||||
label: { show: false },
|
||||
emphasis: { focus: 'series', label: emphLabel },
|
||||
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);
|
||||
seriesList.forEach(s => { s.data.forEach((v, i) => { totalPerMonth[i] += v; }); });
|
||||
|
||||
@ -612,8 +669,9 @@ function buildTimelineOption(drillCategory) {
|
||||
|
||||
|
||||
|
||||
const chartTitle = drillCategory ? {
|
||||
text: '← ' + drillCategory,
|
||||
const isDrilled = drillPath.length > 0;
|
||||
const chartTitle = isDrilled ? {
|
||||
text: '← ' + drillPath.join(' › '),
|
||||
left: 'center',
|
||||
top: 6,
|
||||
textStyle: {
|
||||
@ -631,7 +689,7 @@ function buildTimelineOption(drillCategory) {
|
||||
tooltip: { show: false },
|
||||
legend: {
|
||||
data: legendData,
|
||||
top: drillCategory ? 42 : 10,
|
||||
top: isDrilled ? 42 : 10,
|
||||
right: 10,
|
||||
orient: 'vertical',
|
||||
itemWidth: 12,
|
||||
@ -646,7 +704,7 @@ function buildTimelineOption(drillCategory) {
|
||||
grid: {
|
||||
left: 30,
|
||||
right: 180,
|
||||
top: drillCategory ? 55 : 40,
|
||||
top: isDrilled ? 55 : 40,
|
||||
bottom: 30,
|
||||
containLabel: true
|
||||
},
|
||||
@ -665,25 +723,62 @@ function buildTimelineOption(drillCategory) {
|
||||
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
|
||||
function renderTimelineChart(drillCategory) {
|
||||
timelineDrillCategory = drillCategory;
|
||||
// Render (or re-render) the timeline chart, optionally drilled into a category path
|
||||
// historyAction: 'push' (default, user drill-down), 'replace' (initial/view switch), 'none' (popstate restore)
|
||||
function renderTimelineChart(drillPath, historyAction, legendSelected) {
|
||||
drillPath = drillPath || [];
|
||||
historyAction = historyAction || 'push';
|
||||
timelineDrillPath = drillPath;
|
||||
|
||||
myChart.off('click');
|
||||
myChart.off('mouseover');
|
||||
myChart.off('mouseout');
|
||||
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.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
|
||||
// Also highlight the corresponding legend item
|
||||
const legendNames = tlOption.legend.data;
|
||||
@ -723,34 +818,125 @@ function renderTimelineChart(drillCategory) {
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up previous zrender handlers
|
||||
if (window._timelineZrClickHandler) {
|
||||
myChart.getZr().off('click', window._timelineZrClickHandler);
|
||||
window._timelineZrClickHandler = null;
|
||||
// Track whether all series are selected
|
||||
function updateTotalsAndResetBtn(selected) {
|
||||
const dataMap = tlOption._seriesDataMap;
|
||||
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) {
|
||||
if (params.componentType === 'title') {
|
||||
renderTimelineChart(null);
|
||||
// Go up one level
|
||||
renderTimelineChart(drillPath.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (params.componentType === 'series' && params.seriesName !== '__total__') {
|
||||
if (!drillCategory) {
|
||||
renderTimelineChart(params.seriesName);
|
||||
const nativeEvent = params.event && params.event.event;
|
||||
// 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
|
||||
if (drillCategory) {
|
||||
window._timelineZrClickHandler = function(e) {
|
||||
if (!e.target) {
|
||||
renderTimelineChart(null);
|
||||
// Zrender-level click: detect clicks in the x-axis label area → switch to that month
|
||||
window._timelineZrLabelHandler = function(e) {
|
||||
const y = e.offsetY;
|
||||
const yBottom = myChart.convertToPixel({yAxisIndex: 0}, 0);
|
||||
// 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; }
|
||||
}
|
||||
};
|
||||
myChart.getZr().on('click', window._timelineZrClickHandler);
|
||||
}
|
||||
// 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._timelineZrLabelHandler);
|
||||
}
|
||||
|
||||
// Switch between month (sunburst) and timeline (stacked bar) views
|
||||
@ -772,9 +958,12 @@ function switchView(viewName) {
|
||||
myChart.off('mouseover');
|
||||
myChart.off('mouseout');
|
||||
myChart.off('globalout');
|
||||
myChart.off('legendselectchanged');
|
||||
// Resize after layout change, then render stacked bar
|
||||
myChart.resize();
|
||||
renderTimelineChart(null);
|
||||
renderTimelineChart(timelineDrillPath, 'replace');
|
||||
// Ensure resize after CSS layout fully settles (needed for deferred restore)
|
||||
requestAnimationFrame(() => myChart.resize());
|
||||
} else {
|
||||
container.classList.remove('timeline-mode');
|
||||
// Remove all chart event listeners
|
||||
@ -782,6 +971,7 @@ function switchView(viewName) {
|
||||
myChart.off('mouseover');
|
||||
myChart.off('mouseout');
|
||||
myChart.off('globalout');
|
||||
myChart.off('legendselectchanged');
|
||||
// Clear chart completely so no bar chart remnants remain
|
||||
myChart.clear();
|
||||
option = null;
|
||||
@ -1419,7 +1609,8 @@ function saveMonthSelectionState() {
|
||||
currentMonth,
|
||||
selectedMonths,
|
||||
drillPath: [...currentDrillPath],
|
||||
view: currentView
|
||||
view: currentView,
|
||||
timelineDrillPath: [...timelineDrillPath]
|
||||
}));
|
||||
} catch (error) {
|
||||
// Ignore storage failures (private mode, disabled storage, etc.)
|
||||
@ -1459,6 +1650,11 @@ function restoreMonthSelectionState() {
|
||||
currentDrillPath = [];
|
||||
}
|
||||
|
||||
// Restore timeline drill path if saved
|
||||
if (Array.isArray(saved.timelineDrillPath)) {
|
||||
timelineDrillPath = [...saved.timelineDrillPath];
|
||||
}
|
||||
|
||||
// Defer timeline restore so chart is initialized first
|
||||
if (saved.view === 'timeline') {
|
||||
requestAnimationFrame(() => switchView('timeline'));
|
||||
@ -1923,7 +2119,11 @@ async function loadAvailableMonths() {
|
||||
// Set up view switcher
|
||||
document.querySelectorAll('.view-switcher-btn').forEach(btn => {
|
||||
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