// 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 (
``
);
}
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 += '
`);
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(
`
`);
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
},
};
}