mapdash/frontend/src/viewmode.ts
aaverbitskiy e9eee60dab ui: toasts, icon segmented mode switch, steppers, date-range picker (v0.2.13)
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>
2026-08-15 19:02:45 +00:00

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,
};
}