Second modernization batch (reference: easystudy ui_elements): - Toast/snackbar notifications (dark, green check) for actions — copy link. - Mode switch: icons (horizontal bars / pin / columns) + a sliding active indicator; fixed width so the bold active label never resizes the block. - Appearance number fields became steppers: an editable field with centred −/+ buttons, ~20% narrower. - Date filter: the two native date inputs are replaced by a custom range picker — one field "Период (даты)" + a calendar popup with month nav and range selection (circle endpoints, light band between). The native inputs stay hidden as the source of truth so all filter/URL/chip wiring is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
3.8 KiB
TypeScript
115 lines
3.8 KiB
TypeScript
// Header segmented control: "График" / "Карта" / "Карта и график" plus the
|
|
// draggable vertical splitter used in the combined mode. State (mode + split
|
|
// ratio) persists in localStorage.
|
|
export type ViewMode = 'timeline' | 'map' | 'split';
|
|
|
|
const LS_MODE = 'mapdash.viewmode';
|
|
const LS_SPLIT = 'mapdash.splitLeft';
|
|
const MIN_PCT = 20;
|
|
const MAX_PCT = 80;
|
|
|
|
export interface ViewModeElements {
|
|
view: HTMLElement;
|
|
splitResizer: HTMLElement;
|
|
btnTimeline: HTMLElement;
|
|
btnMap: HTMLElement;
|
|
btnSplit: HTMLElement;
|
|
}
|
|
|
|
export interface ViewModeController {
|
|
getMode(): ViewMode;
|
|
setMode(mode: ViewMode): void;
|
|
}
|
|
|
|
export interface ViewModeCallbacks {
|
|
// Called after the mode changes (layout already applied). mapVisible/timelineVisible
|
|
// tell the caller what to (re)initialise or resize.
|
|
onChange(mode: ViewMode): void;
|
|
// Called continuously while the splitter is dragged (rAF-throttled).
|
|
onResize(): void;
|
|
}
|
|
|
|
export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): ViewModeController {
|
|
let mode: ViewMode = 'timeline';
|
|
|
|
const savedSplit = parseFloat(localStorage.getItem(LS_SPLIT) || '');
|
|
if (savedSplit >= MIN_PCT && savedSplit <= MAX_PCT) {
|
|
els.view.style.setProperty('--split-left', savedSplit + '%');
|
|
}
|
|
|
|
function positionIndicator(): void {
|
|
const active = mode === 'timeline' ? els.btnTimeline : mode === 'map' ? els.btnMap : els.btnSplit;
|
|
const ind = els.btnTimeline.parentElement?.querySelector<HTMLElement>('.mode-ind');
|
|
if (ind && active.offsetHeight) {
|
|
ind.style.top = active.offsetTop + 'px';
|
|
ind.style.height = active.offsetHeight + 'px';
|
|
}
|
|
}
|
|
function apply(): void {
|
|
els.view.classList.remove('mode-timeline', 'mode-map', 'mode-split');
|
|
els.view.classList.add('mode-' + mode);
|
|
els.btnTimeline.classList.toggle('active', mode === 'timeline');
|
|
els.btnMap.classList.toggle('active', mode === 'map');
|
|
els.btnSplit.classList.toggle('active', mode === 'split');
|
|
positionIndicator();
|
|
}
|
|
window.addEventListener('resize', positionIndicator);
|
|
|
|
function setMode(next: ViewMode): void {
|
|
mode = next;
|
|
localStorage.setItem(LS_MODE, mode);
|
|
apply();
|
|
cb.onChange(mode);
|
|
}
|
|
|
|
els.btnTimeline.addEventListener('click', () => setMode('timeline'));
|
|
els.btnMap.addEventListener('click', () => setMode('map'));
|
|
els.btnSplit.addEventListener('click', () => setMode('split'));
|
|
|
|
// ---- splitter drag ----
|
|
let dragging = false;
|
|
let rafPending = false;
|
|
let lastPct = savedSplit >= MIN_PCT ? savedSplit : 50;
|
|
function applyDrag(): void {
|
|
rafPending = false;
|
|
els.view.style.setProperty('--split-left', lastPct + '%');
|
|
cb.onResize();
|
|
}
|
|
els.splitResizer.addEventListener('mousedown', (e) => {
|
|
if (mode !== 'split') return;
|
|
dragging = true;
|
|
document.body.style.userSelect = 'none';
|
|
document.body.style.cursor = 'col-resize';
|
|
e.preventDefault();
|
|
});
|
|
window.addEventListener('mousemove', (e) => {
|
|
if (!dragging) return;
|
|
const rect = els.view.getBoundingClientRect();
|
|
let pct = ((e.clientX - rect.left) / rect.width) * 100;
|
|
pct = Math.max(MIN_PCT, Math.min(MAX_PCT, pct));
|
|
lastPct = pct;
|
|
if (rafPending) return;
|
|
rafPending = true;
|
|
requestAnimationFrame(applyDrag);
|
|
});
|
|
window.addEventListener('mouseup', () => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
document.body.style.userSelect = '';
|
|
document.body.style.cursor = '';
|
|
localStorage.setItem(LS_SPLIT, String(lastPct));
|
|
cb.onResize();
|
|
});
|
|
|
|
// initial mode from storage
|
|
const saved = localStorage.getItem(LS_MODE) as ViewMode | null;
|
|
mode = saved === 'map' || saved === 'split' ? saved : 'timeline';
|
|
apply();
|
|
requestAnimationFrame(positionIndicator); // header is laid out by the next frame
|
|
|
|
return {
|
|
getMode: () => mode,
|
|
setMode,
|
|
};
|
|
}
|