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:
parent
78d62ae36c
commit
f4c1de70b4
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mapdash-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.9",
|
||||
"version": "0.2.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -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([], 'Все города');
|
||||
|
||||
@ -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
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user