mapdash/frontend/src/map.ts
aaverbitskiy 5cabad9c33 feat: all-surfaces timeline + UI polish (v0.2.0)
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>
2026-08-14 14:48:25 +00:00

347 lines
15 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[]): 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();
},
};
}