mapdash/frontend/src/main.ts
aaverbitskiy e83bb5ad49 feat: custom map surface panel (fits content, resizable, smart-positioned) (v0.2.8)
Replace the Yandex balloon with our own floating card, opened on marker/cluster
click. It fits its content (no forced scroll), is draggable by the header and
resizable from the bottom-right corner, and — for clusters — carries tabs to
switch between the co-located surfaces. Photo gallery + lightbox are preserved.

On open it positions itself next to the clicked cluster and offsets toward the
free side of the map (the map half the cluster sits in picks the direction, e.g.
bottom-left cluster -> panel opens up and to the right), clamped inside the map.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-15 08:40:48 +00:00

512 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 { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus, statusLabel } from './appearance';
import { createMapView } from './map';
import { createViewModes, type ViewMode } from './viewmode';
import { escapeHtml } from './util';
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,
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 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;
};
// ---- 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(() => {
share.textContent = 'Ссылка скопирована';
window.setTimeout(() => { share.textContent = 'Скопировать ссылку'; }, 1600);
})
.catch(() => { /* clipboard unavailable */ });
});
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.textContent = 'Загрузка…';
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.textContent = `Поверхности: ${boards.length}`;
rebuildDecorMenu(); // status list may have changed
renderChips();
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);
el.zoomSlider.addEventListener('input', () => {
const step = zoomSteps[parseInt(el.zoomSlider.value, 10)]!;
el.zoomLabel.textContent = step.label;
view.setScale(step.days);
});
// ---- board detail panel (opened from a timeline row label) ----
const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
const boardPanel = document.getElementById('board-panel') as HTMLElement;
const boardBackdrop = document.getElementById('board-backdrop') as HTMLElement;
const boardPanelTitle = document.getElementById('board-panel-title') as HTMLElement;
const boardPanelBody = document.getElementById('board-panel-body') as HTMLElement;
function closeBoardPanel(): void {
boardPanel.hidden = true;
boardBackdrop.hidden = true;
}
function openBoardPanel(boardId: string): void {
// Detail panel exposes client/brand/manager + Planfix deep-links — managers only.
if (!isManager()) return;
const board = lastBoards.find((b) => b.board_id === boardId);
const bookings = lastBookings
.filter((b) => b.board_id === boardId)
.sort((a, b) => a.start_date.localeCompare(b.start_date));
boardPanelTitle.textContent = boardId;
const parts: string[] = [];
if (board) {
const line1 = [board.board_city, board.board_dimension].filter(Boolean).map(escapeHtml).join(' · ');
if (line1) parts.push(`<div class="bp-meta">${line1}</div>`);
if (board.board_address) parts.push(`<div class="bp-meta">${escapeHtml(board.board_address)}</div>`);
}
parts.push(`<div class="bp-label">Брони (${bookings.length})</div>`);
if (!bookings.length) {
parts.push('<div class="bp-empty">Броней нет</div>');
} else {
for (const b of bookings) {
const title = escapeHtml(b.brand || b.company_name || 'Без названия');
const sub = b.brand && b.company_name ? `<div class="bp-b-sub">${escapeHtml(b.company_name)}</div>` : '';
const color = colorForStatus(appearance, b.task_status);
const mgr = (b.manager || []).filter(Boolean).join(', ');
parts.push(
'<div class="bp-booking">' +
`<div class="bp-b-head"><span class="bp-b-title">${title}</span>` +
`<a class="bp-b-link" href="${PLANFIX_TASK_URL}${encodeURIComponent(b.task_id)}" target="_blank" rel="noopener">№${escapeHtml(b.task_id)} ↗</a></div>` +
sub +
`<div class="bp-b-dates">${fmtD(b.start_date)} ${fmtD(b.end_date)}</div>` +
`<div class="bp-b-row"><span class="bp-dot" style="background:${color}"></span>${escapeHtml(b.task_status || '')}</div>` +
(mgr ? `<div class="bp-b-row">👤 ${escapeHtml(mgr)}</div>` : '') +
'</div>',
);
}
}
boardPanelBody.innerHTML = parts.join('');
boardPanel.hidden = false;
boardBackdrop.hidden = false;
}
(document.getElementById('board-panel-close') as HTMLElement).addEventListener('click', closeBoardPanel);
boardBackdrop.addEventListener('click', closeBoardPanel);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeBoardPanel(); });
view.setOnBoardClick(openBoardPanel);
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 = 3;
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days);
// Apply the persisted view mode (inits the map if it starts visible).
void onModeChange(viewModes.getMode());
}
void init();