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>
659 lines
28 KiB
TypeScript
659 lines
28 KiB
TypeScript
// 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
|
||
},
|
||
};
|
||
}
|