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>
This commit is contained in:
aaverbitskiy 2026-08-15 08:40:48 +00:00
parent 8e6c19e9a4
commit e83bb5ad49
4 changed files with 165 additions and 16 deletions

View File

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

View File

@ -380,6 +380,7 @@ async function refreshMap(): Promise<void> {
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)
}

View File

@ -62,6 +62,7 @@ export interface MapView {
render(surfaces: MapSurface[], managerView: boolean): void;
ensureInit(): Promise<void>;
invalidateSize(): void;
closePanel(): void;
}
// "2026-06-01" -> "01.06.2026"
@ -204,6 +205,112 @@ function setupBalloonPhotos(): void {
});
}
// ---- custom surface panel (replaces the Yandex balloon) -------------------
// A floating card: fits its content (no forced scroll), draggable by the header,
// and resizable from the bottom-right corner (CSS resize).
interface Panel { el: HTMLElement; title: HTMLElement; tabs: HTMLElement; body: HTMLElement; surfaces: MapSurface[]; }
interface PanelPlace { x: number; y: number; bounds: DOMRect; }
let mpanel: Panel | null = null;
// Open the panel next to the clicked cluster, offset toward the free side: which
// half of the map the cluster sits in decides the direction (e.g. bottom-left
// cluster -> panel opens up and to the right), then clamp inside the map.
function positionPanel(el: HTMLElement, place: PanelPlace): void {
const gap = 14;
const m = 8;
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));
el.style.left = left + 'px';
el.style.top = top + 'px';
}
function renderPanel(idx: number): void {
if (!mpanel) return;
const s = mpanel.surfaces[idx];
if (!s) return;
mpanel.title.textContent = s.board_id;
if (mpanel.surfaces.length > 1) {
mpanel.tabs.style.display = '';
mpanel.tabs.innerHTML = mpanel.surfaces
.map((x, i) => `<button type="button" class="mpanel-tab${i === idx ? ' mpanel-tab-act' : ''}" data-i="${i}">${escapeHtml(x.board_id)}</button>`)
.join('');
} else {
mpanel.tabs.style.display = 'none';
mpanel.tabs.innerHTML = '';
}
mpanel.body.innerHTML = balloonBody(s);
}
function ensureMapPanel(): Panel {
if (mpanel) return mpanel;
const el = document.createElement('div');
el.className = 'mpanel';
el.style.display = 'none';
el.innerHTML =
'<div class="mpanel-head"><span class="mpanel-title"></span><button type="button" class="mpanel-close" aria-label="Закрыть">✕</button></div>' +
'<div class="mpanel-tabs"></div><div class="mpanel-body"></div>';
document.body.appendChild(el);
mpanel = {
el,
title: el.querySelector('.mpanel-title') as HTMLElement,
tabs: el.querySelector('.mpanel-tabs') as HTMLElement,
body: el.querySelector('.mpanel-body') as HTMLElement,
surfaces: [],
};
el.querySelector('.mpanel-close')!.addEventListener('click', closeMapPanel);
mpanel.tabs.addEventListener('click', (e) => {
const t = (e.target as HTMLElement).closest('.mpanel-tab') as HTMLElement | null;
if (t) renderPanel(Number(t.dataset.i || 0));
});
// Drag by the header.
const head = el.querySelector('.mpanel-head') as HTMLElement;
head.addEventListener('mousedown', (e) => {
if ((e.target as HTMLElement).closest('.mpanel-close')) return;
const r = el.getBoundingClientRect();
const ox = e.clientX - r.left;
const oy = e.clientY - r.top;
const move = (ev: MouseEvent): void => {
el.style.left = Math.max(0, ev.clientX - ox) + 'px';
el.style.top = Math.max(0, ev.clientY - oy) + 'px';
};
const up = (): void => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
e.preventDefault();
});
return mpanel;
}
function showMapPanel(surfaces: MapSurface[], idx: number, place: PanelPlace): void {
const p = ensureMapPanel();
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 = '';
p.el.style.height = '';
}
p.el.style.display = '';
renderPanel(idx);
// Position only on a fresh open (measure the now-rendered size first, so we can
// offset it toward the free side); keep position/size while it stays open.
if (firstOpen) positionPanel(p.el, place);
}
function closeMapPanel(): void {
if (mpanel) mpanel.el.style.display = 'none';
}
// Hover tooltip for a single marker: code, size, address (+ brand/company if booked).
function singleTip(s: MapSurface): string {
const parts: string[] = [];
@ -245,6 +352,11 @@ function clusterTip(list: MapSurface[]): string {
export function createMapView(els: MapElements, apiKey: string): MapView {
setupBalloonPhotos();
// A "place" for the panel: the click point (cluster/marker screen position) and
// the current map bounds, so the panel can open next to it toward the free side.
function clusterPlace(x: number, y: number): PanelPlace {
return { x, y, bounds: els.container.getBoundingClientRect() };
}
let ymaps: any = null;
let map: any = null;
let objectManager: any = null;
@ -320,6 +432,7 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
function draw(surfaces: MapSurface[]): void {
if (!objectManager) return;
closeMapPanel();
const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null);
const noCoordsList = surfaces.filter((s) => s.lat == null || s.lon == null);
drawn = withCoords;
@ -329,13 +442,10 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
type: 'Feature',
id: i,
geometry: { type: 'Point', coordinates: [s.lat as number, s.lon as number] },
properties: {
balloonContentHeader: escapeHtml(s.board_id),
balloonContentBody: balloonBody(s),
occupied: s.occupied_now,
},
// Clicks open our own resizable, content-fitting panel — not a Yandex balloon.
properties: { occupied: s.occupied_now },
// Same colours as the donut cluster (islands#circleDotIcon is colourable).
options: { preset: 'islands#circleDotIcon', iconColor: s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE },
options: { preset: 'islands#circleDotIcon', iconColor: s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE, hasBalloon: false },
}));
hideTip();
objectManager.removeAll();
@ -365,9 +475,12 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
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 balloon so the user lands right on the card.
if (features.length === 1 && done && typeof done.then === 'function') {
done.then(() => objectManager.objects.balloon.open(features[0].id));
// 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 });
});
}
}
}
@ -420,8 +533,9 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
objectManager = new ymaps.ObjectManager({
clusterize: true,
gridSize: 112,
// Click opens the balloon at any zoom (instead of zooming into the cluster).
// No Yandex balloons — clicks open our own panel. Clusters must not zoom.
clusterDisableClickZoom: true,
clusterHasBalloon: false,
clusterIconLayout: DonutClusterLayout,
// Hit area for hover/click — a circle centred on the geo point matching
// the 60px donut (radius 30). Without a shape the icon is inert.
@ -442,11 +556,19 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
if (list.length) showTip(clusterTip(list));
});
objectManager.clusters.events.add(['mouseleave'], hideTip);
// Hide the hover tooltip when a balloon opens — otherwise mouseleave may
// not fire (the balloon covers the marker) and the tip stays stuck,
// following the cursor across the whole map.
objectManager.objects.events.add(['click'], hideTip);
objectManager.clusters.events.add(['click'], hideTip);
// Click opens our own panel (and hides the hover tip so it doesn't stick).
objectManager.objects.events.add(['click'], (e: any) => {
hideTip();
const s = drawn[e.get('objectId') as number];
if (s) showMapPanel([s], 0, clusterPlace(mx, my));
});
objectManager.clusters.events.add(['click'], (e: any) => {
hideTip();
const cluster = objectManager.clusters.getById(e.get('objectId'));
const objs = cluster ? cluster.properties.geoObjects : [];
const list = objs.map((o: any) => drawn[o.id as number]).filter(Boolean);
if (list.length) showMapPanel(list, 0, clusterPlace(mx, my));
});
if (pending) {
draw(pending);
@ -458,5 +580,8 @@ export function createMapView(els: MapElements, apiKey: string): MapView {
invalidateSize(): void {
if (map) map.container.fitToViewport();
},
closePanel(): void {
closeMapPanel();
},
};
}

View File

@ -451,6 +451,29 @@ html.role-pending .manager-only { display: none !important; }
.lb-prev { left: 18px; }
.lb-next { right: 18px; }
.lb-count { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); color: #fff; font-size: 13px; background: rgba(0,0,0,.4); padding: 3px 12px; border-radius: 20px; }
/* Custom surface panel (replaces the Yandex balloon): fits content, draggable
by the header, resizable from the bottom-right corner. */
.mpanel {
position: fixed; z-index: 60; width: 320px; height: auto;
min-width: 240px; min-height: 130px; max-width: 92vw; max-height: 84vh;
overflow: auto; resize: both;
background: var(--bg); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-md);
font-size: 13px; color: var(--text);
}
.mpanel-head {
position: sticky; top: 0; z-index: 1; background: var(--bg);
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 9px 12px; border-bottom: 1px solid var(--border); cursor: move;
border-radius: var(--radius) var(--radius) 0 0;
}
.mpanel-title { font-weight: 700; font-size: 15px; }
.mpanel-close { border: none; background: none; color: var(--text-muted); font-size: 16px; line-height: 1; cursor: pointer; padding: 0 2px; }
.mpanel-close:hover { color: var(--text); }
.mpanel-tabs { display: flex; flex-wrap: wrap; gap: 6px; padding: 9px 12px 0; }
.mpanel-tab { border: 1px solid var(--border); background: var(--surface); color: var(--accent); border-radius: var(--radius-sm); padding: 3px 9px; font-size: 12px; cursor: pointer; }
.mpanel-tab-act { background: var(--surface-2); color: var(--text); border-color: var(--border-strong); }
.mpanel-body { padding: 10px 12px 14px; }
#map-empty.empty-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5; }
/* Custom hover tooltip for map markers and clusters. */
.map-tip {