Timeline can now list the entire inventory (occupied + free + never booked), not just booked surfaces. New manager-only "Все поверхности" checkbox (on by default; forced on and hidden for anonymous visitors so they can spot what is currently free with no future bookings). /api/boards gains all_surfaces: sources rows from board_info and unions pf_board so a just-booked surface not yet in the inventory snapshot still gets a row. Surface facets (city/dimension/search) narrow rows; booking facets (brand/manager/status) narrow to booked surfaces; dates are a window that narrows bars, not the surface set. UI polish: - timeline tooltip flips up/left near viewport edges - anonymous "Войти" button filled accent, inverts to white-on-accent hover - appearance button uses a gear icon instead of "+" - header counter shows only "Поверхности: N" - map "всего" counter includes surfaces without coordinates Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
347 lines
15 KiB
TypeScript
347 lines
15 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[]): void;
|
||
ensureInit(): Promise<void>;
|
||
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[] = ['<div class="bl">'];
|
||
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('');
|
||
}
|
||
|
||
// 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 {
|
||
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';
|
||
}
|
||
|
||
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<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,
|
||
// 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();
|
||
},
|
||
};
|
||
}
|