feat: timeline label -> fly map to surface; keep panel inside the window (v0.2.10)

Timeline: clicking a surface in the left column now flies the map to it — it
switches to the combined "Карта и график" view when only the timeline is open,
then centres+zooms on the surface's cluster (deferred focus waits for the map to
render; works for anonymous and manager users). Replaces the old manager-only
board detail panel on that click (the map card already shows photos + bookings).

Map panel: for clusters with many surfaces it now widens (tabs flow into 3-4
columns) and its height is capped to the space available in the window, so it
never overflows the browser window and scrolls inside instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-15 09:59:52 +00:00
parent 78d62ae36c
commit f4c1de70b4
3 changed files with 58 additions and 69 deletions

View File

@ -1,7 +1,7 @@
{
"name": "mapdash-frontend",
"private": true,
"version": "0.2.9",
"version": "0.2.10",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -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(`<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);
view.setOnBoardClick(goToSurfaceOnMap);
async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города');

View File

@ -63,6 +63,8 @@ export interface MapView {
ensureInit(): Promise<void>;
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
},
};
}