mapdash/frontend/src/map.ts
aaverbitskiy f0c53e2ce9 Выгрузка карты из памяти + фиксы столбца адресов
1) Карта выгружается (map.destroy()) через 2с после ухода в режим «График»
   — освобождает тайлы/canvas/GPU (~350–450 МБ); отменяется при возврате.
   Метод MapView.destroy() (сброс map/objectManager/initPromise/pending);
   в onModeChange сброс mapLoadedKey, иначе refreshMap короткозамыкал render и
   свежий ObjectManager оставался пустым.

2) MIN_LABEL_W 140→200 — три пилюли Город/Адрес/Код не обрезаются на узком
   столбце; container-query прячет подпись «Показывать» при ширине < 255px.

3) Правый/левый отступ 7px у содержимого строки (#timeline .vis-label .vis-inner,
   специфичность выше собственного padding vis-timeline) — адрес не липнет к
   границе столбца.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 05:33:30 +00:00

659 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 `<span class="tip-dot ${occupied ? 'tip-busy' : 'tip-free'}"></span>`;
}
// 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 (
`<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">` +
`<circle cx="${m}" cy="${m}" r="${r}" fill="#ffffff" stroke="${CLUSTER_FREE}" stroke-width="${sw}"/>` +
`<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${CLUSTER_BUSY}" stroke-width="${sw}" ` +
`stroke-dasharray="${busyLen.toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 ${m} ${m})"/>` +
`<text x="${m}" y="${m}" text-anchor="middle" dominant-baseline="central" ` +
`font-family="'IBM Plex Sans',sans-serif" font-size="14" font-weight="700" fill="#1f2430">${total}</text>` +
`</svg>`
);
}
let ymapsPromise: Promise<any> | null = null;
function loadYmaps(apiKey: string): Promise<any> {
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<void>;
invalidateSize(): void;
closePanel(): void;
/** Destroy the Yandex map and free its tiles/canvas/GPU memory. The next
* ensureInit() re-creates it from scratch. */
destroy(): 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 = `<div class="bl-photos" data-photos="${data}">`;
h += `<div class="bl-photo" data-idx="0" data-lb="main">`;
h += `<img src="${escapeHtml(ph[0]!)}" loading="lazy" alt="" />`;
if (n > 1) h += `<span class="bl-count">1 / ${n}</span>`;
h += `<span class="bl-exp" aria-hidden="true">⛶</span></div>`;
if (n > 1) {
h += '<div class="bl-thumbs">';
const shown = Math.min(n, MAX_THUMBS);
for (let i = 0; i < shown; i++) {
h += `<button type="button" class="bl-thumb${i === 0 ? ' bl-thumb-act' : ''}" data-th="${i}"><img src="${escapeHtml(ph[i]!)}" loading="lazy" alt="" /></button>`;
}
if (n > MAX_THUMBS) h += `<button type="button" class="bl-more" data-lb="more">+${n - MAX_THUMBS}</button>`;
h += '</div>';
}
return h + '</div>';
}
// 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[] = ['<div class="bl">'];
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(`<div class="bl-sub">${meta}</div>`);
rows.push(`<div class="bl-sub">${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
rows.push(
`<div class="bl-status"><span class="bl-dot" style="background:${busyColor}"></span>` +
(s.occupied_now ? 'Занята сейчас' : 'Свободна сейчас') +
'</div>',
);
if (s.bookings.length) {
rows.push('<div class="bl-sep"></div><div class="bl-label">Текущие размещения</div>');
for (const b of s.bookings) {
const label = escapeHtml(b.brand || b.company_name || 'Без названия');
const link = `<a href="${PLANFIX_TASK_URL}${encodeURIComponent(b.task_id)}" target="_blank" rel="noopener" class="bl-link">№${escapeHtml(b.task_id)} ↗</a>`;
rows.push(`<div class="bl-booking">${label} <span class="bl-dates">${fmtD(b.start_date)}${fmtD(b.end_date)}</span> ${link}</div>`);
}
}
rows.push('</div>');
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 =
'<button type="button" class="lb-close" aria-label="Закрыть">✕</button>' +
'<button type="button" class="lb-prev" aria-label="Предыдущее"></button>' +
'<img class="lb-img" alt="" />' +
'<button type="button" class="lb-next" aria-label="Следующее"></button>' +
'<div class="lb-count"></div>';
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) => `<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();
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(`<div class="tip-head">${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}</div>`);
parts.push(`<div class="tip-sub">${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
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(`<div>${line}</div>`);
}
}
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(`<div class="tip-head">Поверхностей: ${list.length} (занято ${busy}, свободно ${list.length - busy})</div>`);
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 = ` · <span class="tip-brand">${escapeHtml(names)}</span>`;
}
rows.push(
`<div class="tip-row">${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}${brandHtml}</div>`,
);
}
if (list.length > LIMIT) rows.push(`<div class="tip-more">…и ещё ${list.length - LIMIT}</div>`);
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<void> | 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) => `<span class="nc-code">${escapeHtml(s.board_id)}</span>`).join('');
return `<div class="nc-head">Без координат</div><div class="nc-list">${items}</div>`;
}
// 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<HTMLElement>('.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<void> {
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(
'<div class="mapdash-cluster"></div>',
{
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();
},
destroy(): void {
closeMapPanel();
// map.destroy() tears down Yandex's DOM inside #map and releases its tile
// cache / canvas / GPU textures. Our legend/counter overlays are siblings
// of #map, so they survive. Reset state so ensureInit() rebuilds cleanly.
if (map) {
try { map.destroy(); } catch { /* already torn down */ }
}
map = null;
objectManager = null;
initPromise = null;
pendingFocus = null;
pending = null;
},
focusBoard(boardId: string): void {
pendingFocus = boardId;
applyFocus(); // applies now if already rendered, else waits for the next draw
},
};
}