diff --git a/frontend/package.json b/frontend/package.json index 1772a48..9b6558c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "0.2.7", + "version": "0.2.8", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 10482a2..9f58fd3 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -380,6 +380,7 @@ async function refreshMap(): Promise { async function onModeChange(mode: ViewMode): Promise { 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) } diff --git a/frontend/src/map.ts b/frontend/src/map.ts index a653749..c0cd7bf 100644 --- a/frontend/src/map.ts +++ b/frontend/src/map.ts @@ -62,6 +62,7 @@ export interface MapView { render(surfaces: MapSurface[], managerView: boolean): void; ensureInit(): Promise; 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) => ``) + .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 = + '
' + + '
'; + 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(); + }, }; } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 76992b4..7110ed3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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 {