// 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[]): void; ensureInit(): Promise; invalidateSize(): 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; } // 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 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(''); } // 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 { 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'; } function draw(surfaces: MapSurface[]): void { if (!objectManager) return; 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] }, properties: { balloonContentHeader: escapeHtml(s.board_id), balloonContentBody: balloonBody(s), 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 }, })); hideTip(); objectManager.removeAll(); objectManager.add({ type: 'FeatureCollection', features }); // Live legend counts (react to the current filter). if (legendFree) legendFree.textContent = `свободно: ${free}`; if (legendBusy) legendBusy.textContent = `занято: ${busy}`; // Total counts the whole inventory, including surfaces without coordinates // (which can't be drawn but are still surfaces — listed under "без координат"). els.counter.textContent = `всего: ${surfaces.length}`; // Actionable "no coordinates" badge + list. if (noCoordsBtn) { noCoordsBtn.style.display = noCoordsList.length ? '' : 'none'; noCoordsBtn.textContent = `без координат: ${noCoordsList.length}`; } if (noCoordsPanel) { noCoordsPanel.innerHTML = renderNoCoords(noCoordsList); if (!noCoordsList.length) noCoordsPanel.style.display = 'none'; } 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 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)); } } } return { render(surfaces: MapSurface[]): void { 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, // Click opens the balloon at any zoom (instead of zooming into the cluster). clusterDisableClickZoom: true, 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); 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); if (pending) { draw(pending); pending = null; } })(); return initPromise; }, invalidateSize(): void { if (map) map.container.fitToViewport(); }, }; }