mapdash/frontend/src/main.ts
aaverbitskiy 3939dbf8f5 Плашка масштаба графика — сворачиваемая с анимацией
По умолчанию плашка свёрнута (65×35) — белая плашка с фиолетовым пилюлем текущего
масштаба («12М» и т.п.). При наведении разворачивается (145×80) в полный контрол
(лейбл + слайдер + баббл) с плавной анимацией размеров (overflow:hidden + фикс.
размеры) и кросс-фейдом содержимого. Сворачивается обратно через 1с после ухода
курсора (JS-таймер в main.ts, класс .expanded; повторное наведение отменяет). В
развёрнутом виде содержимое смещено на 4px вниз для вертикальной центровки.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:34:51 +00:00

522 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// IBM Plex Sans — self-hosted (no runtime Google Fonts dependency), latin + cyrillic.
import '@fontsource/ibm-plex-sans/400.css';
import '@fontsource/ibm-plex-sans/500.css';
import '@fontsource/ibm-plex-sans/600.css';
import '@fontsource/ibm-plex-sans/700.css';
import '@fontsource/ibm-plex-sans/cyrillic-400.css';
import '@fontsource/ibm-plex-sans/cyrillic-500.css';
import '@fontsource/ibm-plex-sans/cyrillic-600.css';
import '@fontsource/ibm-plex-sans/cyrillic-700.css';
import './styles.css';
import 'vis-timeline/styles/vis-timeline-graph2d.min.css';
import { api } from './api';
import type { Board, Booking, Filters } from './types';
import { createDropdown } from './dropdown';
import { createDateRange } from './datepicker';
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
import { loadAppearance, applyCssVars, buildAppearanceMenu, statusLabel } from './appearance';
import { createMapView } from './map';
import { createViewModes, type ViewMode } from './viewmode';
import { initAuth, isManager, isAuthenticated, login, logout } from './auth';
const el = {
search: document.getElementById('search') as HTMLInputElement,
dateStart: document.getElementById('date-start') as HTMLInputElement,
dateEnd: document.getElementById('date-end') as HTMLInputElement,
status: document.getElementById('status') as HTMLElement,
timelineEl: document.getElementById('timeline') as HTMLElement,
empty: document.getElementById('empty') as HTMLElement,
tooltip: document.getElementById('tooltip') as HTMLElement,
showBrand: document.getElementById('show-brand') as HTMLInputElement,
showCompany: document.getElementById('show-company') as HTMLInputElement,
showCollisions: document.getElementById('show-collisions') as HTMLInputElement,
showAllSurfaces: document.getElementById('show-all-surfaces') as HTMLInputElement,
zoomSlider: document.getElementById('zoom-slider') as HTMLInputElement,
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
zoomCollapsed: document.getElementById('zoom-collapsed') as HTMLElement,
zoomField: document.getElementById('zoom-field') as HTMLElement,
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
colResizer: document.getElementById('col-resizer') as HTMLElement,
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
decorMenu: document.getElementById('decor-menu') as HTMLElement,
view: document.getElementById('view') as HTMLElement,
splitResizer: document.getElementById('split-resizer') as HTMLElement,
modeTimeline: document.getElementById('mode-timeline') as HTMLElement,
modeMap: document.getElementById('mode-map') as HTMLElement,
modeSplit: document.getElementById('mode-split') as HTMLElement,
mapEl: document.getElementById('map') as HTMLElement,
mapEmpty: document.getElementById('map-empty') as HTMLElement,
mapCounter: document.getElementById('map-counter') as HTMLElement,
themeToggle: document.getElementById('theme-toggle') as HTMLButtonElement,
filterChips: document.getElementById('filter-chips') as HTMLElement,
loadBar: document.getElementById('load-bar') as HTMLElement,
emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement,
emptyResetMap: document.getElementById('empty-reset-map') as HTMLButtonElement,
authBtn: document.getElementById('auth-btn') as HTMLButtonElement,
};
// ---- role-based UI (anonymous vs manager) ----
// Backend enforces truncation; here we just hide the controls that only make
// sense for the full-data (manager) tier so the anonymous view stays clean.
function applyRoleUi(): void {
const manager = isManager();
// Role resolved — leave the pre-paint "pending" state; from here explicit
// per-element display below is the source of truth.
document.documentElement.classList.remove('role-pending');
const hideField = (node: Element | null): void => {
const f = node?.closest('.field') as HTMLElement | null;
if (f) f.style.display = manager ? '' : 'none';
};
// Client-facing facets: Brand, Manager, Status. (Search stays — it filters by
// address/board code, which anonymous visitors are allowed to use.)
hideField(document.getElementById('brand-dropdown'));
hideField(document.getElementById('manager-dropdown'));
hideField(document.getElementById('status-dropdown'));
// Brand/Company toggles and the appearance (colour) menu.
const checks = document.querySelector('.checks') as HTMLElement | null;
if (checks) checks.style.display = manager ? '' : 'none';
const decor = document.querySelector('.decor-wrap') as HTMLElement | null;
if (decor) decor.style.display = manager ? '' : 'none';
// Login / logout button.
el.authBtn.style.display = '';
el.authBtn.textContent = isAuthenticated() ? 'Выйти' : 'Войти';
// Anonymous "Войти" is highlighted (filled accent) so it stands out; once
// logged in the "Выйти" button reverts to the subtle outline style.
el.authBtn.classList.toggle('auth-btn--login', !isAuthenticated());
el.authBtn.onclick = () => (isAuthenticated() ? logout() : login());
}
// ---- theme toggle (data-theme is set pre-paint by the inline head script) ----
function currentTheme(): 'light' | 'dark' {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function applyThemeIcon(): void {
const dark = currentTheme() === 'dark';
el.themeToggle.textContent = dark ? '☀' : '☾';
el.themeToggle.title = dark ? 'Светлая тема' : 'Тёмная тема';
}
el.themeToggle.addEventListener('click', () => {
const next = currentTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('mapdash.theme', next); } catch { /* ignore */ }
applyThemeIcon();
});
applyThemeIcon();
const YANDEX_KEY = ((import.meta as any).env?.VITE_YANDEX_KEY as string) || '';
const appearance = loadAppearance();
applyCssVars(appearance);
const cityDropdown = createDropdown('city');
const brandDropdown = createDropdown('brand');
const dimensionDropdown = createDropdown('dimension');
const managerDropdown = createDropdown('manager');
const statusDropdown = createDropdown('status', statusLabel);
const dateRange = createDateRange();
const view = createTimelineView(
{
timelineEl: el.timelineEl,
chartWrap: el.chartWrap,
tooltip: el.tooltip,
empty: el.empty,
colResizer: el.colResizer,
},
appearance,
);
const mapView = createMapView(
{ container: el.mapEl, empty: el.mapEmpty, counter: el.mapCounter },
YANDEX_KEY,
);
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
function flags(): RenderFlags {
// Anonymous tier: no labels (data is blanked anyway) and no collision
// highlighting, so every bar renders in the single neutral status colour.
if (!isManager()) {
return { withBrand: false, withCompany: false, withCollisions: false, managerView: false };
}
return {
withBrand: el.showBrand.checked,
withCompany: el.showCompany.checked,
withCollisions: el.showCollisions.checked,
managerView: true,
};
}
// Whether the timeline should list every inventory surface (not just booked
// ones). The checkbox is manager-only; anonymous visitors always get all
// surfaces so they can spot what's currently free with no future bookings.
function allSurfacesParam(): boolean {
return !isManager() || el.showAllSurfaces.checked;
}
function currentFilters(): Filters {
return {
cities: cityDropdown.getSelected(),
dimensions: dimensionDropdown.getSelected(),
brands: brandDropdown.getSelected(),
managers: managerDropdown.getSelected(),
statuses: statusDropdown.getSelected(),
search: el.search.value.trim(),
dateStart: el.dateStart.value,
dateEnd: el.dateEnd.value,
};
}
// ---- appearance menu ----
function currentStatuses(): string[] {
return Array.from(new Set(lastBookings.map((b) => b.task_status).filter(Boolean)));
}
function rebuildDecorMenu(): void {
buildAppearanceMenu(el.decorMenu, currentStatuses(), appearance, () => {
applyCssVars(appearance);
view.applyLayout();
view.refresh();
});
}
// Position the (fixed) appearance menu: top 25px below the header, bottom no
// closer than 30px to the window edge; overflow scrolls inside.
function positionDecorMenu(): void {
const header = document.querySelector('header');
let refBottom = header ? header.getBoundingClientRect().bottom : 0;
// Drop below the active-filter chips row too, when it is showing.
const chips = el.filterChips;
if (chips && chips.style.display !== 'none' && chips.getClientRects().length) {
refBottom = Math.max(refBottom, chips.getBoundingClientRect().bottom);
}
const top = refBottom + 20;
el.decorMenu.style.top = top + 'px';
el.decorMenu.style.maxHeight = Math.max(120, window.innerHeight - top - 30) + 'px';
}
el.decorBtn.addEventListener('click', (e) => {
e.stopPropagation();
const willOpen = !el.decorMenu.classList.contains('open');
el.decorMenu.classList.toggle('open');
if (willOpen) positionDecorMenu();
});
window.addEventListener('resize', () => {
if (el.decorMenu.classList.contains('open')) positionDecorMenu();
});
document.addEventListener('click', (e) => {
if (!el.decorMenu.contains(e.target as Node) && e.target !== el.decorBtn) {
el.decorMenu.classList.remove('open');
}
});
// ---- loading indicator (delayed, so fast loads don't flash) ----
let loadTimer: number | undefined;
let inflight = 0;
function loadStart(): void {
inflight++;
clearTimeout(loadTimer);
loadTimer = window.setTimeout(() => el.loadBar.classList.add('active'), 250);
}
function loadEnd(): void {
inflight = Math.max(0, inflight - 1);
if (inflight === 0) {
clearTimeout(loadTimer);
el.loadBar.classList.remove('active');
}
}
// ---- filters: reset + active-filter chips ----
const fmtD = (iso: string): string => {
const p = iso.split('-');
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
};
// ---- toast notifications (transient action feedback) ----
let toastHost: HTMLElement | null = null;
function showToast(message: string): void {
if (!toastHost) {
toastHost = document.createElement('div');
toastHost.className = 'toasts';
document.body.appendChild(toastHost);
}
const t = document.createElement('div');
t.className = 'toast';
const ico = document.createElement('span');
ico.className = 'toast-ico';
ico.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
const txt = document.createElement('span');
txt.textContent = message;
t.appendChild(ico);
t.appendChild(txt);
toastHost.appendChild(t);
void t.offsetWidth; // reflow so the enter transition runs (rAF is paused in bg tabs)
t.classList.add('show');
window.setTimeout(() => {
t.classList.remove('show');
window.setTimeout(() => t.remove(), 250);
}, 2500);
}
// ---- shareable filter links: current filters <-> URL query ----
function filtersToUrl(): void {
const f = currentFilters();
const p = new URLSearchParams();
f.cities.forEach((v) => p.append('city', v));
f.brands.forEach((v) => p.append('brand', v));
f.dimensions.forEach((v) => p.append('dimension', v));
f.managers.forEach((v) => p.append('manager', v));
f.statuses.forEach((v) => p.append('status', v));
if (f.search) p.set('q', f.search);
if (f.dateStart) p.set('from', f.dateStart);
if (f.dateEnd) p.set('to', f.dateEnd);
const qs = p.toString();
history.replaceState(null, '', qs ? `${location.pathname}?${qs}` : location.pathname);
}
function applyUrlToFilters(): void {
const p = new URLSearchParams(location.search);
cityDropdown.setSelected(p.getAll('city'));
brandDropdown.setSelected(p.getAll('brand'));
dimensionDropdown.setSelected(p.getAll('dimension'));
managerDropdown.setSelected(p.getAll('manager'));
statusDropdown.setSelected(p.getAll('status'));
el.search.value = p.get('q') || '';
el.dateStart.value = p.get('from') || '';
el.dateEnd.value = p.get('to') || '';
}
function resetFilters(): void {
for (const d of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) d.clear();
el.search.value = '';
el.dateStart.value = '';
el.dateEnd.value = '';
void loadData(true);
}
function renderChips(): void {
const items: { label: string; remove: () => void }[] = [];
for (const dd of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) {
const fmt = dd === statusDropdown ? statusLabel : (x: string) => x;
for (const v of dd.getSelected()) {
items.push({ label: fmt(v), remove: () => { dd.setSelected(dd.getSelected().filter((x) => x !== v)); void loadData(true); } });
}
}
if (el.search.value.trim()) items.push({ label: 'Поиск: ' + el.search.value.trim(), remove: () => { el.search.value = ''; void loadData(true); } });
if (el.dateStart.value) items.push({ label: 'с ' + fmtD(el.dateStart.value), remove: () => { el.dateStart.value = ''; void loadData(true); } });
if (el.dateEnd.value) items.push({ label: 'по ' + fmtD(el.dateEnd.value), remove: () => { el.dateEnd.value = ''; void loadData(true); } });
el.filterChips.innerHTML = '';
if (!items.length) { el.filterChips.style.display = 'none'; return; }
el.filterChips.style.display = '';
// "Скопировать ссылку" — always leftmost, separated from the chips by a rule.
const share = document.createElement('button');
share.type = 'button';
share.className = 'chips-share';
share.textContent = 'Скопировать ссылку';
share.addEventListener('click', () => {
navigator.clipboard
?.writeText(location.href)
.then(() => showToast('Ссылка скопирована'))
.catch(() => showToast('Не удалось скопировать'));
});
el.filterChips.appendChild(share);
const divider = document.createElement('span');
divider.className = 'chips-divider';
divider.textContent = '|';
el.filterChips.appendChild(divider);
for (const it of items) {
const chip = document.createElement('span');
chip.className = 'chip';
chip.appendChild(document.createTextNode(it.label));
const x = document.createElement('button');
x.type = 'button';
x.className = 'chip-x';
x.setAttribute('aria-label', 'Убрать фильтр');
x.textContent = '×';
x.addEventListener('click', it.remove);
chip.appendChild(x);
el.filterChips.appendChild(chip);
}
const clear = document.createElement('button');
clear.type = 'button';
clear.className = 'chips-clear';
clear.textContent = 'Сбросить всё';
clear.addEventListener('click', resetFilters);
el.filterChips.appendChild(clear);
}
el.emptyResetTimeline.addEventListener('click', resetFilters);
el.emptyResetMap.addEventListener('click', resetFilters);
async function loadData(fit: boolean): Promise<void> {
el.status.innerHTML = '<span class="st-l">Поверхности</span><span class="st-v">…</span>';
loadStart();
try {
const f = currentFilters();
const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f, allSurfacesParam()), api.bookings(f)]);
// Dependent filters: refresh each dropdown's options (selections preserved).
cityDropdown.updateValues(meta.cities);
brandDropdown.updateValues(meta.brands);
dimensionDropdown.updateValues(meta.dimensions);
managerDropdown.updateValues(meta.managers);
statusDropdown.updateValues(meta.statuses);
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.innerHTML = `<span class="st-l">Поверхности</span><span class="st-v">${boards.length}</span>`;
rebuildDecorMenu(); // status list may have changed
renderChips();
dateRange.sync(); // keep the date-range field in step with reset / chip-remove / URL
filtersToUrl();
// Keep the map in sync when it is visible (facets filter it; dates do not).
if (mapVisible()) void refreshMap();
} finally {
loadEnd();
}
}
// ---- map ----
// Map surfaces are keyed by the facet filters only. We cache the last key so we
// don't refetch when merely toggling view modes.
let mapLoadedKey = '';
function facetKey(): string {
const f = currentFilters();
return JSON.stringify([f.cities, f.dimensions, f.brands, f.managers, f.statuses, f.search]);
}
function mapVisible(): boolean {
return !!viewModes && viewModes.getMode() !== 'timeline';
}
async function refreshMap(): Promise<void> {
loadStart();
try {
await mapView.ensureInit();
mapView.invalidateSize();
const key = facetKey();
if (key === mapLoadedKey) return;
mapLoadedKey = key;
const surfaces = await api.map(currentFilters());
mapView.render(surfaces, isManager());
} catch (e) {
console.error('map load failed', e);
} finally {
loadEnd();
}
}
async function onModeChange(mode: ViewMode): Promise<void> {
if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split)
if (mode === 'timeline') mapView.closePanel(); // map hidden — drop its floating card
if (mode !== 'timeline') await refreshMap(); // map visible (map or split)
}
const viewModes = createViewModes(
{
view: el.view,
splitResizer: el.splitResizer,
btnTimeline: el.modeTimeline,
btnMap: el.modeMap,
btnSplit: el.modeSplit,
},
{
onChange: (mode) => void onModeChange(mode),
onResize: () => {
view.applyLayout();
mapView.invalidateSize();
},
},
);
let debounceTimer: number | undefined;
function scheduleReload(): void {
clearTimeout(debounceTimer);
debounceTimer = window.setTimeout(() => {
void loadData(true);
}, 250);
}
for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
cb.addEventListener('change', () => view.render(lastBoards, lastBookings, false, flags()));
}
// "All surfaces" changes which rows exist (server-side), so it must refetch.
el.showAllSurfaces.addEventListener('change', scheduleReload);
cityDropdown.onChange(scheduleReload);
brandDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);
managerDropdown.onChange(scheduleReload);
statusDropdown.onChange(scheduleReload);
el.search.addEventListener('input', scheduleReload);
el.dateStart.addEventListener('change', scheduleReload);
el.dateEnd.addEventListener('change', scheduleReload);
// Fill the track up to the thumb and ride the value bubble above the thumb.
function updateZoomUi(): void {
const max = parseInt(el.zoomSlider.max, 10) || 1;
const frac = max ? parseInt(el.zoomSlider.value, 10) / max : 0;
el.zoomSlider.style.setProperty('--zoom-fill', frac * 100 + '%');
const thumb = 16;
const w = el.zoomSlider.offsetWidth || 120;
el.zoomLabel.style.left = thumb / 2 + frac * (w - thumb) + 'px';
}
el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
el.zoomLabel.textContent = step.label;
el.zoomCollapsed.textContent = step.label;
view.setScale(step.days);
updateZoomUi();
});
window.addEventListener('resize', updateZoomUi);
// The scale plate is collapsed to a small value pill by default; expand on hover
// and collapse again 1s after the pointer leaves (so it doesn't snap shut while
// you're reaching back toward it).
let zoomCollapseTimer = 0;
el.zoomField.addEventListener('mouseenter', () => {
window.clearTimeout(zoomCollapseTimer);
el.zoomField.classList.add('expanded');
updateZoomUi(); // realign the bubble now that the slider has a real width
});
el.zoomField.addEventListener('mouseleave', () => {
window.clearTimeout(zoomCollapseTimer);
zoomCollapseTimer = window.setTimeout(() => el.zoomField.classList.remove('expanded'), 1000);
});
// Clicking a surface in the timeline's left column flies the map to it: switch to
// the combined "Карта и график" view if only the timeline is open, then focus the
// surface's cluster. Works for anonymous and manager users alike.
function goToSurfaceOnMap(boardId: string): void {
if (viewModes.getMode() === 'timeline') viewModes.setMode('split');
mapView.focusBoard(boardId);
}
view.setOnBoardClick(goToSurfaceOnMap);
async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города');
brandDropdown.setValues([], 'Все бренды');
dimensionDropdown.setValues([], 'Все размеры');
managerDropdown.setValues([], 'Все менеджеры');
statusDropdown.setValues([], 'Все статусы');
await initAuth(); // resolve role before first data load (degrades to anon on failure)
applyRoleUi();
applyUrlToFilters(); // restore filters from a shared link
if (!isManager()) {
// A shared link may carry hidden facets; drop them so the anonymous view
// neither queries nor re-serialises Brand/Manager/Status.
for (const d of [brandDropdown, managerDropdown, statusDropdown]) d.clear();
}
rebuildDecorMenu();
view.applyLayout();
await loadData(true);
const defaultZoomIdx = 1; // 12М
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
el.zoomCollapsed.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days);
updateZoomUi();
// Apply the persisted view mode (inits the map if it starts visible).
void onModeChange(viewModes.getMode());
}
void init();