// Yandex Maps view: surfaces from /api/map as clustered green/red markers. // The map is created lazily (only when a map-containing view mode is first shown) // because ymaps needs a sized, visible container to initialise correctly. import { escapeHtml } from './util'; import type { MapSurface } from './types'; const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/'; // Marker colour is intentionally "occupied today", independent of the date // filters. Single markers reuse the exact donut colours (below) via iconColor. function dot(occupied: boolean): string { return ``; } // Cluster icon: a donut split green (free) / red (busy) by occupancy ratio, // with the surface count in the centre. free = green ring, busy = red arc. const CLUSTER_FREE = '#37b24d'; const CLUSTER_BUSY = '#e24b4a'; function donutSvg(total: number, busy: number): string { const r = 22; const sw = 8; const c = 2 * Math.PI * r; const busyLen = total > 0 ? (busy / total) * c : 0; const size = (r + sw) * 2; const m = size / 2; return ( `` + `` + `` + `${total}` + `` ); } let ymapsPromise: Promise | null = null; function loadYmaps(apiKey: string): Promise { if (ymapsPromise) return ymapsPromise; ymapsPromise = new Promise((resolve, reject) => { const w = window as any; if (w.ymaps && w.ymaps.Map) { w.ymaps.ready(() => resolve(w.ymaps)); return; } const s = document.createElement('script'); s.src = `https://api-maps.yandex.ru/2.1/?apikey=${encodeURIComponent(apiKey)}&lang=ru_RU`; s.async = true; s.onload = () => (window as any).ymaps.ready(() => resolve((window as any).ymaps)); s.onerror = () => reject(new Error('Не удалось загрузить Яндекс.Карты')); document.head.appendChild(s); }); return ymapsPromise; } export interface MapElements { container: HTMLElement; empty: HTMLElement; counter: HTMLElement; } export interface MapView { render(surfaces: MapSurface[], managerView: boolean): void; 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" function fmtD(iso: string): string { const p = (iso || '').split('-'); return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; } // Photo gallery block for the balloon: main image + thumbnail strip (up to // MAX_THUMBS, then a "+N" chip). Clicks are handled by delegation (setupBalloonPhotos): // a thumb swaps the main image, the main image / "+N" opens the lightbox. const MAX_THUMBS = 4; function photoGallery(s: MapSurface): string { const ph = s.photos || []; if (!ph.length) return ''; const data = escapeHtml(ph.join('|')); const n = ph.length; let h = `
`; h += `
`; h += ``; if (n > 1) h += `1 / ${n}`; h += `
`; if (n > 1) { h += '
'; const shown = Math.min(n, MAX_THUMBS); for (let i = 0; i < shown; i++) { h += ``; } if (n > MAX_THUMBS) h += ``; h += '
'; } return h + '
'; } // Balloon (click) — structured card matching the timeline tooltip style. function balloonBody(s: MapSurface): string { const busyColor = s.occupied_now ? CLUSTER_BUSY : CLUSTER_FREE; const rows: string[] = ['
']; const gallery = photoGallery(s); if (gallery) rows.push(gallery); const meta = [s.dimension, s.board_type].filter(Boolean).map(escapeHtml).join(' · '); if (meta) rows.push(`
${meta}
`); rows.push(`
${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}
`); rows.push( `
` + (s.occupied_now ? 'Занята сейчас' : 'Свободна сейчас') + '
', ); if (s.bookings.length) { rows.push('
Текущие размещения
'); for (const b of s.bookings) { const label = escapeHtml(b.brand || b.company_name || 'Без названия'); const link = `№${escapeHtml(b.task_id)} ↗`; rows.push(`
${label} ${fmtD(b.start_date)}–${fmtD(b.end_date)} ${link}
`); } } rows.push('
'); return rows.join(''); } // ---- photo lightbox (fullscreen overlay above the map) -------------------- interface Lightbox { overlay: HTMLElement; img: HTMLImageElement; cnt: HTMLElement; prev: HTMLElement; next: HTMLElement; photos: string[]; idx: number; } let lb: Lightbox | null = null; function lbShow(i: number): void { if (!lb || !lb.photos.length) return; const n = lb.photos.length; lb.idx = (i % n + n) % n; lb.img.src = lb.photos[lb.idx]!; lb.cnt.textContent = n > 1 ? `${lb.idx + 1} / ${n}` : ''; lb.prev.style.display = lb.next.style.display = n > 1 ? '' : 'none'; } function ensureLightbox(): Lightbox { if (lb) return lb; const overlay = document.createElement('div'); overlay.className = 'lb-overlay'; overlay.innerHTML = '' + '' + '' + '' + '
'; document.body.appendChild(overlay); lb = { overlay, img: overlay.querySelector('.lb-img') as HTMLImageElement, cnt: overlay.querySelector('.lb-count') as HTMLElement, prev: overlay.querySelector('.lb-prev') as HTMLElement, next: overlay.querySelector('.lb-next') as HTMLElement, photos: [], idx: 0, }; const close = (): void => overlay.classList.remove('open'); overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target === lb!.img) close(); }); overlay.querySelector('.lb-close')!.addEventListener('click', close); lb.prev.addEventListener('click', (e) => { e.stopPropagation(); lbShow(lb!.idx - 1); }); lb.next.addEventListener('click', (e) => { e.stopPropagation(); lbShow(lb!.idx + 1); }); document.addEventListener('keydown', (e) => { if (!overlay.classList.contains('open')) return; if (e.key === 'Escape') close(); else if (e.key === 'ArrowLeft') lbShow(lb!.idx - 1); else if (e.key === 'ArrowRight') lbShow(lb!.idx + 1); }); return lb; } function openLightbox(photos: string[], idx: number): void { const l = ensureLightbox(); l.photos = photos; l.overlay.classList.add('open'); lbShow(idx); } // Delegated handling of balloon photo clicks (attached once). Thumbnails swap // the main image; the main image and the "+N" chip open the lightbox. let balloonPhotosBound = false; function setupBalloonPhotos(): void { if (balloonPhotosBound) return; balloonPhotosBound = true; document.addEventListener('click', (e) => { const t = e.target as HTMLElement; const wrap = t.closest('.bl-photos') as HTMLElement | null; if (!wrap) return; const photos = (wrap.dataset.photos || '').split('|').filter(Boolean); if (!photos.length) return; const main = wrap.querySelector('.bl-photo') as HTMLElement; const thumb = t.closest('.bl-thumb') as HTMLElement | null; if (thumb) { const i = Number(thumb.dataset.th || 0); (main.querySelector('img') as HTMLImageElement).src = photos[i]!; main.dataset.idx = String(i); const cnt = main.querySelector('.bl-count'); if (cnt) cnt.textContent = `${i + 1} / ${photos.length}`; wrap.querySelectorAll('.bl-thumb').forEach((el) => el.classList.toggle('bl-thumb-act', el === thumb)); return; } if (t.closest('.bl-photo')) { openLightbox(photos, Number(main.dataset.idx || 0)); return; } if (t.closest('.bl-more')) { openLightbox(photos, MAX_THUMBS); } }); } // ---- 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; // Timestamp of the last panel open. Yandex fires the map's own 'click' for the // very same click that opened a cluster panel, so the map-click close handler // ignores clicks landing within a short window of an open. let panelOpenedAt = 0; // 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 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 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(availTop, Math.min(top, availBottom - ph)); 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(); p.surfaces = surfaces; // Every click is a fresh panel: reset size, widen when there are many surfaces // (tabs flow into more columns → shorter panel), then reposition next to the // just-clicked cluster. A new click never reuses the previous position. 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); positionPanel(p.el, place); // measures the rendered size, then offsets to the free side // Restart the pop-in animation so each click reads as a new panel appearing. p.el.style.animation = 'none'; void p.el.offsetHeight; p.el.style.animation = ''; panelOpenedAt = Date.now(); } 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[] = []; parts.push(`
${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}
`); parts.push(`
${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}
`); if (s.occupied_now && s.bookings.length) { for (const b of s.bookings) { const brand = escapeHtml(b.brand || ''); const company = escapeHtml(b.company_name || ''); const line = [brand, company].filter(Boolean).join(' — ') || 'Без названия'; parts.push(`
${line}
`); } } return parts.join(''); } // Hover tooltip for a cluster: list surfaces with their colour. function clusterTip(list: MapSurface[]): string { const busy = list.filter((s) => s.occupied_now).length; const rows: string[] = []; rows.push(`
Поверхностей: ${list.length} (занято ${busy}, свободно ${list.length - busy})
`); const LIMIT = 25; for (const s of list.slice(0, LIMIT)) { // For occupied surfaces, show which brand(s) currently hold them. let brandHtml = ''; if (s.occupied_now && s.bookings.length) { const names = Array.from( new Set(s.bookings.map((b) => b.brand || b.company_name || '').filter(Boolean)), ).join(', '); if (names) brandHtml = ` · ${escapeHtml(names)}`; } rows.push( `
${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}${brandHtml}
`, ); } if (list.length > LIMIT) rows.push(`
…и ещё ${list.length - LIMIT}
`); return rows.join(''); } 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; let pending: MapSurface[] | null = null; // Guards against a double-init race: ensureInit may be called concurrently // (e.g. from loadData and from applying the saved view mode) and the ymaps // script load is async, so a plain `if (map)` check before the await isn't // enough — without this the map would be created twice in one container and // the marker layer would be clobbered. let initPromise: Promise | null = null; // feature id (index) -> surface, so hover handlers can look data up. let drawn: MapSurface[] = []; // Legend / no-coords panel refs (static markup in index.html). const legendFree = document.getElementById('lg-free'); const legendBusy = document.getElementById('lg-busy'); const noCoordsBtn = document.getElementById('map-nocoords-btn'); const noCoordsPanel = document.getElementById('map-nocoords-panel'); if (noCoordsBtn && noCoordsPanel) { noCoordsBtn.addEventListener('click', (e) => { e.stopPropagation(); noCoordsPanel.style.display = noCoordsPanel.style.display === 'none' ? 'block' : 'none'; }); } function renderNoCoords(list: MapSurface[]): string { const items = list.map((s) => `${escapeHtml(s.board_id)}`).join(''); return `
Без координат
${items}
`; } // Custom floating tooltip (ymaps hints are too limited for our content). const tip = document.createElement('div'); tip.className = 'map-tip'; tip.style.display = 'none'; document.body.appendChild(tip); let mx = 0; let my = 0; function positionTip(): void { const pad = 14; const margin = 6; const vw = window.innerWidth; const vh = window.innerHeight; const r = tip.getBoundingClientRect(); const tw = r.width || 260; const th = r.height || 120; // Horizontal: to the right of the cursor, flip left if it would overflow. let left = mx + pad; if (left + tw > vw - margin) left = mx - pad - tw; left = Math.max(margin, Math.min(left, vw - tw - margin)); // Vertical: below the cursor, flip above if it would overflow the bottom. let top = my + pad; if (top + th > vh - margin) top = my - pad - th; top = Math.max(margin, Math.min(top, vh - th - margin)); tip.style.left = left + 'px'; tip.style.top = top + 'px'; } document.addEventListener('mousemove', (e) => { mx = e.clientX; my = e.clientY; if (tip.style.display !== 'none') positionTip(); }); function showTip(html: string): void { tip.innerHTML = html; tip.style.display = 'block'; positionTip(); } function hideTip(): void { tip.style.display = 'none'; } // Whether the current viewer is a manager. The "no coordinates" badge is a // 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(); const withCoords = surfaces.filter((s) => s.lat != null && s.lon != null); const noCoordsList = surfaces.filter((s) => s.lat == null || s.lon == null); drawn = withCoords; const busy = withCoords.filter((s) => s.occupied_now).length; const free = withCoords.length - busy; const features = withCoords.map((s, i) => ({ type: 'Feature', id: i, geometry: { type: 'Point', coordinates: [s.lat as number, s.lon as number] }, // 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, hasBalloon: false }, })); hideTip(); objectManager.removeAll(); objectManager.add({ type: 'FeatureCollection', features }); // Live legend counts (react to the current filter) — values only; the labels // are static in the tile markup. if (legendFree) legendFree.textContent = String(free); if (legendBusy) legendBusy.textContent = String(busy); // Total counts the whole inventory, including surfaces without coordinates // (which can't be drawn but are still surfaces — listed under "без координат"). els.counter.textContent = String(surfaces.length); // Actionable "no coordinates" tile + list — manager-only. if (noCoordsBtn) { noCoordsBtn.style.display = managerView && noCoordsList.length ? '' : 'none'; const ncVal = noCoordsBtn.querySelector('.st-v'); if (ncVal) ncVal.textContent = String(noCoordsList.length); } if (noCoordsPanel) { noCoordsPanel.innerHTML = renderNoCoords(noCoordsList); if (!managerView || !noCoordsList.length) noCoordsPanel.style.display = 'none'; } els.empty.style.display = withCoords.length ? 'none' : 'flex'; if (features.length) { 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 }); }); } } } } return { render(surfaces: MapSurface[], mv: boolean): void { managerView = mv; if (!objectManager) { pending = surfaces; return; } draw(surfaces); }, ensureInit(): Promise { if (map) return Promise.resolve(); if (initPromise) return initPromise; initPromise = (async () => { ymaps = await loadYmaps(apiKey); map = new ymaps.Map( els.container, { center: [56.85, 53.2], zoom: 7, controls: ['zoomControl', 'geolocationControl', 'fullscreenControl'] }, { suppressMapOpenBlock: true }, ); // Custom cluster icon: a donut coloured by the free/busy split of the // surfaces it contains (each feature carries `occupied` in properties). const DonutClusterLayout = ymaps.templateLayoutFactory.createClass( '
', { build: function (this: any) { DonutClusterLayout.superclass.build.call(this); try { const props = this.getData() && this.getData().properties; const objs = (props && (props.get ? props.get('geoObjects') : props.geoObjects)) || []; let busy = 0; for (const o of objs) { const p = o && o.properties; const occ = p && (typeof p.get === 'function' ? p.get('occupied') : p.occupied); if (occ) busy++; } const parent = this.getParentElement && this.getParentElement(); const el = parent && parent.getElementsByClassName('mapdash-cluster')[0]; if (el) el.innerHTML = donutSvg(objs.length, busy); } catch (e) { console.error('cluster layout build failed', e); } }, }, ); objectManager = new ymaps.ObjectManager({ clusterize: true, gridSize: 112, // Even a lone surface becomes a cluster of 1 (donut showing "1"), so all // map graphics share the donut style and every click flows through the // one cluster handler -> our panel. minClusterSize: 1, // 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. clusterIconShape: { type: 'Circle', coordinates: [0, 0], radius: 30 }, }); map.geoObjects.add(objectManager); // Hover tooltips: single markers and clusters. objectManager.objects.events.add(['mouseenter'], (e: any) => { const s = drawn[e.get('objectId') as number]; if (s) showTip(singleTip(s)); }); objectManager.objects.events.add(['mouseleave'], hideTip); objectManager.clusters.events.add(['mouseenter'], (e: any) => { 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); // A cluster of one keeps the rich single-surface tooltip. if (list.length === 1) showTip(singleTip(list[0]!)); else if (list.length) showTip(clusterTip(list)); }); objectManager.clusters.events.add(['mouseleave'], 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)); }); // Clicking the empty map (anywhere but a cluster) closes an open panel. // Yandex also fires this map 'click' for the same click that opened a // cluster panel, so skip closes within a short window of an open. map.events.add('click', () => { if (Date.now() - panelOpenedAt > 250) closeMapPanel(); }); if (pending) { draw(pending); pending = null; } })(); return initPromise; }, invalidateSize(): void { if (map) map.container.fitToViewport(); }, closePanel(): void { closeMapPanel(); }, focusBoard(boardId: string): void { pendingFocus = boardId; applyFocus(); // applies now if already rendered, else waits for the next draw }, }; }