;
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();
},
};
}