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( - '
' + - `
${title}` + - `№${escapeHtml(b.task_id)} ↗
` + - 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 + }, }; }