По умолчанию плашка свёрнута (65×35) — белая плашка с фиолетовым пилюлем текущего масштаба («12М» и т.п.). При наведении разворачивается (145×80) в полный контрол (лейбл + слайдер + баббл) с плавной анимацией размеров (overflow:hidden + фикс. размеры) и кросс-фейдом содержимого. Сворачивается обратно через 1с после ухода курсора (JS-таймер в main.ts, класс .expanded; повторное наведение отменяет). В развёрнутом виде содержимое смещено на 4px вниз для вертикальной центровки. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
522 lines
21 KiB
TypeScript
522 lines
21 KiB
TypeScript
// 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();
|