diff --git a/frontend/package.json b/frontend/package.json
index c15ee38..0e4603a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
- "version": "0.2.9",
+ "version": "0.2.10",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index 9f58fd3..792f595 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -15,10 +15,9 @@ 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 { loadAppearance, applyCssVars, buildAppearanceMenu, 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 = {
@@ -429,59 +428,14 @@ el.zoomSlider.addEventListener('input', () => {
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;
+// 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);
}
-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(`
${line1}
`);
- if (board.board_address) parts.push(`${escapeHtml(board.board_address)}
`);
- }
- parts.push(`Брони (${bookings.length})
`);
- if (!bookings.length) {
- parts.push('Броней нет
');
- } else {
- for (const b of bookings) {
- const title = escapeHtml(b.brand || b.company_name || 'Без названия');
- const sub = b.brand && b.company_name ? `${escapeHtml(b.company_name)}
` : '';
- const color = colorForStatus(appearance, b.task_status);
- const mgr = (b.manager || []).filter(Boolean).join(', ');
- parts.push(
- '' +
- `
` +
- sub +
- `
${fmtD(b.start_date)} – ${fmtD(b.end_date)}
` +
- `
${escapeHtml(b.task_status || '')}
` +
- (mgr ? `
👤 ${escapeHtml(mgr)}
` : '') +
- '
',
- );
- }
- }
- 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);
+view.setOnBoardClick(goToSurfaceOnMap);
async function init(): Promise {
cityDropdown.setValues([], 'Все города');
diff --git a/frontend/src/map.ts b/frontend/src/map.ts
index 3cecb74..c1b8114 100644
--- a/frontend/src/map.ts
+++ b/frontend/src/map.ts
@@ -63,6 +63,8 @@ export interface MapView {
ensureInit(): Promise;
invalidateSize(): void;
closePanel(): void;
+ /** Pan + zoom the map to the surface's location (its cluster). No-op if it has no coords. */
+ focusBoard(boardId: string): void;
}
// "2026-06-01" -> "01.06.2026"
@@ -218,15 +220,21 @@ let mpanel: Panel | null = null;
function positionPanel(el: HTMLElement, place: PanelPlace): void {
const gap = 14;
const m = 8;
+ const b = place.bounds;
+ // Vertical space actually available inside the browser window (bounded by the
+ // map area, which starts below the header). Cap the panel to it so it can never
+ // grow past the window edge — it scrolls inside instead.
+ const availTop = Math.max(b.top, m);
+ const availBottom = Math.min(b.bottom, window.innerHeight - m);
+ el.style.maxHeight = Math.max(160, availBottom - availTop) + 'px';
const pw = el.offsetWidth;
const ph = el.offsetHeight;
- const b = place.bounds;
const midX = (b.left + b.right) / 2;
const midY = (b.top + b.bottom) / 2;
let left = place.x < midX ? place.x + gap : place.x - gap - pw;
let top = place.y < midY ? place.y + gap : place.y - gap - ph;
left = Math.max(b.left + m, Math.min(left, b.right - pw - m));
- top = Math.max(b.top + m, Math.min(top, b.bottom - ph - m));
+ top = Math.max(availTop, Math.min(top, availBottom - ph));
el.style.left = left + 'px';
el.style.top = top + 'px';
}
@@ -296,9 +304,13 @@ function showMapPanel(surfaces: MapSurface[], idx: number, place: PanelPlace): v
const firstOpen = p.el.style.display === 'none';
p.surfaces = surfaces;
if (firstOpen) {
- // Fresh open: reset to the default size (fits content).
- p.el.style.width = '';
+ // Fresh open: reset size. Widen when there are many surfaces so the tab list
+ // flows into more columns (a shorter panel, less scrolling).
p.el.style.height = '';
+ p.el.style.maxHeight = '';
+ const n = surfaces.length;
+ const cols = n <= 8 ? 2 : n <= 24 ? 3 : 4;
+ p.el.style.width = cols > 2 ? Math.min(320 + (cols - 2) * 150, Math.round(window.innerWidth * 0.92)) + 'px' : '';
}
p.el.style.display = '';
renderPanel(idx);
@@ -430,6 +442,19 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
// manager-only tool, so it stays hidden for anonymous visitors.
let managerView = false;
+ // A pending "fly to this surface" request (e.g. from a timeline label click)
+ // that is applied once the map has rendered the surfaces.
+ let pendingFocus: string | null = null;
+ function applyFocus(): void {
+ if (!map || !pendingFocus || drawn.length === 0) return; // wait for a render
+ const bid = pendingFocus;
+ pendingFocus = null;
+ const s = drawn.find((x) => x.board_id === bid);
+ if (s && s.lat != null && s.lon != null) {
+ map.setCenter([s.lat, s.lon], 16, { duration: 400, checkZoomRange: true });
+ }
+ }
+
function draw(surfaces: MapSurface[]): void {
if (!objectManager) return;
closeMapPanel();
@@ -471,16 +496,22 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
els.empty.style.display = withCoords.length ? 'none' : 'flex';
if (features.length) {
- const points = features.map((f) => f.geometry.coordinates);
- const bounds = ymaps.util.bounds.fromPoints(points);
- const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
- // Search-to-locate: when the filter narrows to a single surface, fly in
- // and open its card so the user lands right on it.
- if (withCoords.length === 1 && done && typeof done.then === 'function') {
- done.then(() => {
- const b = els.container.getBoundingClientRect();
- showMapPanel([withCoords[0]!], 0, { x: (b.left + b.right) / 2, y: (b.top + b.bottom) / 2, bounds: b });
- });
+ if (pendingFocus) {
+ // A pending "fly to this surface" (e.g. timeline label click) supersedes
+ // the default fit-to-all-points view.
+ applyFocus();
+ } else {
+ const points = features.map((f) => f.geometry.coordinates);
+ const bounds = ymaps.util.bounds.fromPoints(points);
+ const done = map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
+ // Search-to-locate: when the filter narrows to a single surface, fly in
+ // and open its card so the user lands right on it.
+ if (withCoords.length === 1 && done && typeof done.then === 'function') {
+ done.then(() => {
+ const b = els.container.getBoundingClientRect();
+ showMapPanel([withCoords[0]!], 0, { x: (b.left + b.right) / 2, y: (b.top + b.bottom) / 2, bounds: b });
+ });
+ }
}
}
}
@@ -589,5 +620,9 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
closePanel(): void {
closeMapPanel();
},
+ focusBoard(boardId: string): void {
+ pendingFocus = boardId;
+ applyFocus(); // applies now if already rendered, else waits for the next draw
+ },
};
}