// 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 = '';
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 {
el.status.innerHTML = 'Поверхности…';
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 = `Поверхности${boards.length}`;
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 {
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 {
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 {
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();