map: Yandex map section (3 modes + resizable split), green/red markers, hover tooltips for markers+clusters; fix map init race; header keeps counters inline (fields shrink first); add Manager (Менеджер) faceted filter

This commit is contained in:
aaverbitskiy 2026-07-26 05:05:04 +00:00
parent d04e0ae024
commit 68417bf45e
8 changed files with 645 additions and 22 deletions

View File

@ -16,6 +16,15 @@
</div>
</div>
<!-- Group 1b: view mode switch -->
<div class="hgroup hgroup-modes">
<div class="mode-switch">
<button type="button" class="mode-btn active" id="mode-timeline">График</button>
<button type="button" class="mode-btn" id="mode-map">Карта</button>
<button type="button" class="mode-btn" id="mode-split">Карта и график</button>
</div>
</div>
<!-- Group 2: fields -->
<div class="hgroup hgroup-fields">
<div class="fcol">
@ -63,6 +72,17 @@
</div>
</div>
<div class="field">
<label>Менеджер</label>
<div class="dropdown" id="manager-dropdown">
<button type="button" class="dropdown-btn" id="manager-btn">
<span id="manager-btn-text">Все менеджеры</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="manager-panel"></div>
</div>
</div>
<div class="field">
<label for="search">Поиск (адрес / код)</label>
<input type="text" id="search" placeholder="например, Азина" />
@ -100,10 +120,24 @@
</div>
</header>
<div id="chart-wrap">
<div id="timeline"></div>
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
<div id="empty" style="display:none">Нет данных по выбранным фильтрам</div>
<div id="view" class="mode-timeline">
<div id="pane-timeline">
<div id="chart-wrap">
<div id="timeline"></div>
<div id="col-resizer" title="Потяните, чтобы изменить ширину колонки"></div>
<div id="empty" style="display:none">Нет данных по выбранным фильтрам</div>
</div>
</div>
<div id="split-resizer" title="Потяните, чтобы изменить пропорции"></div>
<div id="pane-map">
<div id="map"></div>
<div id="map-legend">
<span class="lg-item"><span class="lg-dot lg-free"></span>свободна сейчас</span>
<span class="lg-item"><span class="lg-dot lg-busy"></span>занята сейчас</span>
<span class="lg-item" id="map-counter"></span>
</div>
<div id="map-empty" style="display:none">Нет поверхностей с координатами</div>
</div>
</div>
<div id="tooltip"></div>

View File

@ -1,12 +1,13 @@
// Typed wrappers around the mapdash HTTP API. All network access for the app
// goes through here, so response shapes stay in one typed place.
import type { Meta, Board, Booking, Filters } from './types';
import type { Meta, Board, Booking, Filters, MapSurface } from './types';
function buildQuery(f: Filters): string {
const params = new URLSearchParams({
city: f.cities.join(','),
dimension: f.dimensions.join(','),
brand: f.brands.join(','),
manager: f.managers.join(','),
search: f.search,
date_start: f.dateStart,
date_end: f.dateEnd,
@ -26,4 +27,7 @@ export const api = {
meta: (f: Filters): Promise<Meta> => getJson<Meta>('/api/meta?' + buildQuery(f)),
boards: (f: Filters): Promise<Board[]> => getJson<Board[]>('/api/boards?' + buildQuery(f)),
bookings: (f: Filters): Promise<Booking[]> => getJson<Booking[]>('/api/bookings?' + buildQuery(f)),
// Map surfaces. The backend ignores the date params here (occupancy is "today"),
// but we reuse buildQuery — extra query params are harmless.
map: (f: Filters): Promise<MapSurface[]> => getJson<MapSurface[]>('/api/map?' + buildQuery(f)),
};

View File

@ -6,6 +6,8 @@ import type { Board, Booking, Filters } from './types';
import { createDropdown } from './dropdown';
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
import { loadAppearance, applyCssVars, buildAppearanceMenu } from './appearance';
import { createMapView } from './map';
import { createViewModes, type ViewMode } from './viewmode';
const el = {
search: document.getElementById('search') as HTMLInputElement,
@ -24,14 +26,25 @@ const el = {
colResizer: document.getElementById('col-resizer') as HTMLElement,
decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
decorMenu: document.getElementById('decor-menu') as HTMLElement,
view: document.getElementById('view') as HTMLElement,
splitResizer: document.getElementById('split-resizer') as HTMLElement,
modeTimeline: document.getElementById('mode-timeline') as HTMLElement,
modeMap: document.getElementById('mode-map') as HTMLElement,
modeSplit: document.getElementById('mode-split') as HTMLElement,
mapEl: document.getElementById('map') as HTMLElement,
mapEmpty: document.getElementById('map-empty') as HTMLElement,
mapCounter: document.getElementById('map-counter') as HTMLElement,
};
const YANDEX_KEY = ((import.meta as any).env?.VITE_YANDEX_KEY as string) || '';
const appearance = loadAppearance();
applyCssVars(appearance);
const cityDropdown = createDropdown('city');
const brandDropdown = createDropdown('brand');
const dimensionDropdown = createDropdown('dimension');
const managerDropdown = createDropdown('manager');
const view = createTimelineView(
{
timelineEl: el.timelineEl,
@ -43,6 +56,11 @@ const view = createTimelineView(
appearance,
);
const mapView = createMapView(
{ container: el.mapEl, empty: el.mapEmpty, counter: el.mapCounter },
YANDEX_KEY,
);
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
@ -59,6 +77,7 @@ function currentFilters(): Filters {
cities: cityDropdown.getSelected(),
dimensions: dimensionDropdown.getSelected(),
brands: brandDropdown.getSelected(),
managers: managerDropdown.getSelected(),
search: el.search.value.trim(),
dateStart: el.dateStart.value,
dateEnd: el.dateEnd.value,
@ -96,13 +115,63 @@ async function loadData(fit: boolean): Promise<void> {
cityDropdown.updateValues(meta.cities);
brandDropdown.updateValues(meta.brands);
dimensionDropdown.updateValues(meta.dimensions);
managerDropdown.updateValues(meta.managers);
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.innerHTML = `Бордов: ${boards.length}<br>Бронирований: ${bookings.length}`;
rebuildDecorMenu(); // status list may have changed
// Keep the map in sync when it is visible (facets filter it; dates do not).
if (mapVisible()) void refreshMap();
}
// ---- map ----
// Map surfaces are keyed by the facet filters only. We cache the last key so we
// don't refetch when merely toggling view modes.
let mapLoadedKey = '';
function facetKey(): string {
const f = currentFilters();
return JSON.stringify([f.cities, f.dimensions, f.brands, f.search]);
}
function mapVisible(): boolean {
return !!viewModes && viewModes.getMode() !== 'timeline';
}
async function refreshMap(): Promise<void> {
try {
await mapView.ensureInit();
mapView.invalidateSize();
const key = facetKey();
if (key === mapLoadedKey) return;
mapLoadedKey = key;
const surfaces = await api.map(currentFilters());
mapView.render(surfaces);
} catch (e) {
console.error('map load failed', e);
}
}
async function onModeChange(mode: ViewMode): Promise<void> {
if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split)
if (mode !== 'timeline') await refreshMap(); // map visible (map or split)
}
const viewModes = createViewModes(
{
view: el.view,
splitResizer: el.splitResizer,
btnTimeline: el.modeTimeline,
btnMap: el.modeMap,
btnSplit: el.modeSplit,
},
{
onChange: (mode) => void onModeChange(mode),
onResize: () => {
view.applyLayout();
mapView.invalidateSize();
},
},
);
let debounceTimer: number | undefined;
function scheduleReload(): void {
clearTimeout(debounceTimer);
@ -117,6 +186,7 @@ for (const cb of [el.showBrand, el.showCompany, el.showCollisions]) {
cityDropdown.onChange(scheduleReload);
brandDropdown.onChange(scheduleReload);
dimensionDropdown.onChange(scheduleReload);
managerDropdown.onChange(scheduleReload);
el.search.addEventListener('input', scheduleReload);
el.dateStart.addEventListener('change', scheduleReload);
el.dateEnd.addEventListener('change', scheduleReload);
@ -131,6 +201,7 @@ async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города');
brandDropdown.setValues([], 'Все бренды');
dimensionDropdown.setValues([], 'Все размеры');
managerDropdown.setValues([], 'Все менеджеры');
rebuildDecorMenu();
view.applyLayout();
await loadData(true);
@ -138,6 +209,8 @@ async function init(): Promise<void> {
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
view.setScale(zoomSteps[defaultZoomIdx]!.days);
// Apply the persisted view mode (inits the map if it starts visible).
void onModeChange(viewModes.getMode());
}
void init();

216
frontend/src/map.ts Normal file
View File

@ -0,0 +1,216 @@
// 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. Kept as a single function so the colour scheme is easy to change later.
function presetFor(s: MapSurface): string {
return s.occupied_now ? 'islands#redCircleDotIcon' : 'islands#greenCircleDotIcon';
}
function dot(occupied: boolean): string {
return `<span class="tip-dot ${occupied ? 'tip-busy' : 'tip-free'}"></span>`;
}
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;
}
// Balloon (click) — richer, with PlanFix links.
function balloonBody(s: MapSurface): string {
const rows: string[] = [];
rows.push(`<div><b>${escapeHtml(s.board_id)}</b> · ${escapeHtml(s.dimension || '')} · ${escapeHtml(s.board_type || '')}</div>`);
rows.push(`<div>${escapeHtml(s.city || '')}, ${escapeHtml(s.address || '')}</div>`);
rows.push(
`<div style="margin:6px 0;font-weight:600;color:${s.occupied_now ? '#c0392b' : '#2e7d32'}">` +
(s.occupied_now ? 'Занята сейчас' : 'Свободна сейчас') +
'</div>',
);
if (s.bookings.length) {
rows.push('<div style="font-size:12px">Текущие размещения:</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">${escapeHtml(b.task_id)}</a>`;
rows.push(`<div style="font-size:12px">• ${label} (${escapeHtml(b.start_date)}${escapeHtml(b.end_date)}) · ${link}</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)) {
rows.push(`<div class="tip-row">${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}</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[] = [];
// 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 {
tip.style.left = mx + 14 + 'px';
tip.style.top = my + 14 + '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);
drawn = withCoords;
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),
},
options: { preset: presetFor(s) },
}));
hideTip();
objectManager.removeAll();
objectManager.add({ type: 'FeatureCollection', features });
const noCoords = surfaces.length - withCoords.length;
els.counter.textContent = `Поверхностей: ${withCoords.length}` + (noCoords ? ` (без координат: ${noCoords})` : '');
els.empty.style.display = withCoords.length ? 'none' : 'block';
if (features.length) {
const points = features.map((f) => f.geometry.coordinates);
const bounds = ymaps.util.bounds.fromPoints(points);
map.setBounds(bounds, { checkZoomRange: true, zoomMargin: 40 });
}
}
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 },
);
objectManager = new ymaps.ObjectManager({
clusterize: true,
gridSize: 64,
clusterDisableClickZoom: false,
});
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);
if (pending) {
draw(pending);
pending = null;
}
})();
return initPromise;
},
invalidateSize(): void {
if (map) map.container.fitToViewport();
},
};
}

View File

@ -29,12 +29,14 @@ header {
display: flex;
gap: 14px;
align-items: stretch;
flex-wrap: wrap;
/* Single row: keep every block (incl. the counters) on one line and, when the
window is too narrow, compress the fields group first instead of wrapping. */
flex-wrap: nowrap;
background: #fff;
z-index: 20;
}
/* Header groups (four boxed blocks) */
/* Header groups (boxed blocks). By default they keep their natural width. */
.hgroup {
display: flex;
align-items: center;
@ -43,7 +45,14 @@ header {
background: #f2f2f2;
border: 1px solid #e2e2e2;
border-radius: 8px;
flex-shrink: 0;
}
/* The fields group is the one that gives way first when space runs out. */
.hgroup-fields { flex-shrink: 1; min-width: 0; }
.hgroup-fields .field,
.hgroup-fields .fcol,
.hgroup-fields .dropdown,
.hgroup-fields .dropdown-btn { min-width: 0; }
.hgroup-brand { background: #eaeaea; }
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */
.hgroup-fields { align-items: flex-start; }
@ -53,7 +62,8 @@ input[type=date] {
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
min-width: 180px;
min-width: 0;
width: 100%;
box-sizing: border-box;
}
/* Compact single-column layout: the three checkboxes stack in three rows so the
@ -78,7 +88,8 @@ input[type=text] {
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
min-width: 180px;
min-width: 0;
width: 100%;
}
/* Counters as a compact gray block, pushed to the end of the header row so it
sits in line with the other boxed groups instead of floating below. */
@ -157,6 +168,68 @@ input[type=text] {
.decor-reset { margin-top: 6px; width: 100%; padding: 6px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; background: #f7f7f7; cursor: pointer; }
.decor-reset:hover { background: #efefef; }
/* ---- view modes: timeline / map / split ---- */
/* Stacked, individual buttons (compact vertical column). */
.mode-switch { display: flex; flex-direction: column; gap: 6px; }
.mode-btn {
border: 1px solid #ccc; border-radius: 6px; background: #fff; color: #333; cursor: pointer;
padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
}
.mode-btn:hover { background: #f0f0f0; }
.mode-btn.active { background: #2e66d6; border-color: #2e66d6; color: #fff; }
#view { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; --split-left: 50%; }
#pane-timeline { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
#pane-map { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
#map { width: 100%; height: 100%; }
/* timeline-only */
#view.mode-timeline #pane-timeline { flex: 1 1 auto; }
#view.mode-timeline #pane-map,
#view.mode-timeline #split-resizer { display: none; }
/* map-only */
#view.mode-map #pane-map { flex: 1 1 auto; }
#view.mode-map #pane-timeline,
#view.mode-map #split-resizer { display: none; }
/* split */
#view.mode-split #pane-timeline { flex: 0 0 var(--split-left); }
#view.mode-split #pane-map { flex: 1 1 auto; }
#view.mode-split #split-resizer { display: block; }
#split-resizer {
flex: 0 0 6px; background: #e2e2e2; cursor: col-resize; position: relative;
}
#split-resizer:hover { background: #b9c6e6; }
#map-legend {
position: absolute; left: 10px; bottom: 10px; z-index: 5;
background: rgba(255,255,255,.92); border: 1px solid #ddd; border-radius: 6px;
padding: 6px 10px; font-size: 12px; color: #333; display: flex; gap: 14px; align-items: center;
box-shadow: 0 2px 8px rgba(0,0,0,.12);
}
#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
#map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
#map-legend .lg-free { background: #59a831; }
#map-legend .lg-busy { background: #e35b45; }
#map-empty {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
color: #888; font-size: 14px; z-index: 5;
}
/* Custom hover tooltip for map markers and clusters. */
.map-tip {
position: fixed; z-index: 1000; pointer-events: none;
background: rgba(33,33,33,.94); color: #fff; font-size: 12px; line-height: 1.35;
padding: 8px 10px; border-radius: 6px; max-width: 340px;
box-shadow: 0 4px 14px rgba(0,0,0,.3);
}
.map-tip .tip-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; vertical-align: baseline; }
.map-tip .tip-free { background: #59a831; }
.map-tip .tip-busy { background: #e35b45; }
.map-tip .tip-row { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.map-tip .tip-head { font-weight: 700; margin-bottom: 3px; }
.map-tip .tip-sub { color: #cfd3d6; }
.map-tip .tip-more { color: #cfd3d6; margin-top: 3px; }
#chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }
#timeline { border: none; }

View File

@ -7,6 +7,7 @@ export interface Meta {
cities: string[];
dimensions: string[];
brands: string[];
managers: string[];
}
/** One row of GET /api/boards (grouped per board_id). */
@ -33,10 +34,36 @@ export interface Filters {
cities: string[];
dimensions: string[];
brands: string[];
managers: string[];
search: string;
dateStart: string; // YYYY-MM-DD or '' — start_date >= dateStart
dateEnd: string; // YYYY-MM-DD or '' — end_date <= dateEnd
}
/** A booking active "today" attached to a map surface (for the balloon). */
export interface MapBooking {
task_id: string;
task_status: string;
start_date: string;
end_date: string;
brand: string;
company_name: string;
}
/** One surface returned by GET /api/map (from board_info, joined to bookings). */
export interface MapSurface {
board_key: number;
board_id: string;
city: string;
address: string;
dimension: string;
board_type: string;
side: string;
lat: number | null;
lon: number | null;
occupied_now: boolean;
bookings: MapBooking[];
}
/** task_status value that marks an archived booking (painted slate #708090). */
export const ARCHIVED_STATUS = 'РК. Архив';

103
frontend/src/viewmode.ts Normal file
View File

@ -0,0 +1,103 @@
// Header segmented control: "График" / "Карта" / "Карта и график" plus the
// draggable vertical splitter used in the combined mode. State (mode + split
// ratio) persists in localStorage.
export type ViewMode = 'timeline' | 'map' | 'split';
const LS_MODE = 'mapdash.viewmode';
const LS_SPLIT = 'mapdash.splitLeft';
const MIN_PCT = 20;
const MAX_PCT = 80;
export interface ViewModeElements {
view: HTMLElement;
splitResizer: HTMLElement;
btnTimeline: HTMLElement;
btnMap: HTMLElement;
btnSplit: HTMLElement;
}
export interface ViewModeController {
getMode(): ViewMode;
setMode(mode: ViewMode): void;
}
export interface ViewModeCallbacks {
// Called after the mode changes (layout already applied). mapVisible/timelineVisible
// tell the caller what to (re)initialise or resize.
onChange(mode: ViewMode): void;
// Called continuously while the splitter is dragged (rAF-throttled).
onResize(): void;
}
export function createViewModes(els: ViewModeElements, cb: ViewModeCallbacks): ViewModeController {
let mode: ViewMode = 'timeline';
const savedSplit = parseFloat(localStorage.getItem(LS_SPLIT) || '');
if (savedSplit >= MIN_PCT && savedSplit <= MAX_PCT) {
els.view.style.setProperty('--split-left', savedSplit + '%');
}
function apply(): void {
els.view.classList.remove('mode-timeline', 'mode-map', 'mode-split');
els.view.classList.add('mode-' + mode);
els.btnTimeline.classList.toggle('active', mode === 'timeline');
els.btnMap.classList.toggle('active', mode === 'map');
els.btnSplit.classList.toggle('active', mode === 'split');
}
function setMode(next: ViewMode): void {
mode = next;
localStorage.setItem(LS_MODE, mode);
apply();
cb.onChange(mode);
}
els.btnTimeline.addEventListener('click', () => setMode('timeline'));
els.btnMap.addEventListener('click', () => setMode('map'));
els.btnSplit.addEventListener('click', () => setMode('split'));
// ---- splitter drag ----
let dragging = false;
let rafPending = false;
let lastPct = savedSplit >= MIN_PCT ? savedSplit : 50;
function applyDrag(): void {
rafPending = false;
els.view.style.setProperty('--split-left', lastPct + '%');
cb.onResize();
}
els.splitResizer.addEventListener('mousedown', (e) => {
if (mode !== 'split') return;
dragging = true;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
const rect = els.view.getBoundingClientRect();
let pct = ((e.clientX - rect.left) / rect.width) * 100;
pct = Math.max(MIN_PCT, Math.min(MAX_PCT, pct));
lastPct = pct;
if (rafPending) return;
rafPending = true;
requestAnimationFrame(applyDrag);
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
document.body.style.userSelect = '';
document.body.style.cursor = '';
localStorage.setItem(LS_SPLIT, String(lastPct));
cb.onResize();
});
// initial mode from storage
const saved = localStorage.getItem(LS_MODE) as ViewMode | null;
mode = saved === 'map' || saved === 'split' ? saved : 'timeline';
apply();
return {
getMode: () => mode,
setMode,
};
}

119
main.py
View File

@ -17,6 +17,9 @@ app = FastAPI()
CITY_COND = "(length({cities:Array(String)}) = 0 OR city IN {cities:Array(String)})"
DIM_COND = "(length({dimensions:Array(String)}) = 0 OR dimension IN {dimensions:Array(String)})"
BRAND_COND = "(length({brands:Array(String)}) = 0 OR brand IN {brands:Array(String)})"
# manager is Array(String) in pf_board (a task may have several managers), so we
# match with hasAny: keep the row if any selected manager is among its managers.
MANAGER_COND = "(length({managers:Array(String)}) = 0 OR hasAny(manager, {managers:Array(String)}))"
SEARCH_COND = (
"({search:String} = ''"
" OR positionCaseInsensitive(address, {search:String}) > 0"
@ -24,11 +27,28 @@ SEARCH_COND = (
)
# Independent date bounds: start_date >= X ("Дата начала"), end_date <= Y
# ("Дата окончания"). Each included only when its value is provided.
# NB: the bookings SELECT aliases toString(start_date) AS start_date, which would
# shadow the Date column in WHERE and cause a String-vs-Date type error. Qualify
# with the table name so the comparison always binds to the real Date column.
START_COND = "pf_board.start_date >= {date_start:Date}"
END_COND = "pf_board.end_date <= {date_end:Date}"
# Map endpoint filters board_info (the surface inventory that carries coordinates).
# board_info has no `brand` column, so the brand facet is expressed as "this
# surface has at least one booking of the selected brand" via a subquery on pf_board.
MAP_BRAND_COND = (
"(length({brands:Array(String)}) = 0"
" OR board_key IN (SELECT board_key FROM default.pf_board WHERE brand IN {brands:Array(String)}))"
)
# Same idea for the manager facet on the map: keep surfaces that have at least one
# booking managed by one of the selected managers.
MAP_MANAGER_COND = (
"(length({managers:Array(String)}) = 0"
" OR board_key IN (SELECT board_key FROM default.pf_board WHERE hasAny(manager, {managers:Array(String)})))"
)
def build_where(city=False, dimension=False, brand=False, search=False, date_start=False, date_end=False):
def build_where(city=False, dimension=False, brand=False, manager=False, search=False, date_start=False, date_end=False):
# Faceted WHERE: include only the requested facet conditions. For the
# dependent-filter option lists we exclude a facet's own condition so its
# available values reflect the OTHER filters (all-but-self).
@ -39,6 +59,8 @@ def build_where(city=False, dimension=False, brand=False, search=False, date_sta
parts.append(DIM_COND)
if brand:
parts.append(BRAND_COND)
if manager:
parts.append(MANAGER_COND)
if search:
parts.append(SEARCH_COND)
if date_start:
@ -65,14 +87,16 @@ async def ch_query(sql: str, params: dict):
return [json.loads(line) for line in text.splitlines()]
def parse_filters(city: str, dimension: str, brand: str, search: str, date_start: str, date_end: str):
def parse_filters(city: str, dimension: str, brand: str, manager: str, search: str, date_start: str, date_end: str):
cities = [c for c in city.split(",") if c] if city else []
dimensions = [d for d in dimension.split(",") if d] if dimension else []
brands = [b for b in brand.split(",") if b] if brand else []
managers = [m for m in manager.split(",") if m] if manager else []
return {
"cities": cities,
"dimensions": dimensions,
"brands": brands,
"managers": managers,
"search": search or "",
"date_start": date_start or "",
"date_end": date_end or "",
@ -80,33 +104,39 @@ def parse_filters(city: str, dimension: str, brand: str, search: str, date_start
@app.get("/api/meta")
async def meta(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, search, date_start, date_end)
async def meta(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, manager, search, date_start, date_end)
hds = bool(params["date_start"])
hde = bool(params["date_end"])
cities = await ch_query(
f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city",
f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(city) > 0 ORDER BY city",
params,
)
brands = await ch_query(
f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand",
f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(brand) > 0 ORDER BY brand",
params,
)
dimensions = await ch_query(
f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension",
f"SELECT DISTINCT dimension FROM default.pf_board WHERE {build_where(city=True, brand=True, manager=True, search=True, date_start=hds, date_end=hde)} AND char_length(dimension) > 0 ORDER BY dimension",
params,
)
# manager is an Array(String) column — ARRAY JOIN to enumerate distinct names.
managers = await ch_query(
f"SELECT DISTINCT m AS manager FROM default.pf_board ARRAY JOIN manager AS m WHERE {build_where(city=True, dimension=True, brand=True, search=True, date_start=hds, date_end=hde)} AND char_length(m) > 0 ORDER BY manager",
params,
)
return {
"cities": [r["city"] for r in cities],
"dimensions": [r["dimension"] for r in dimensions],
"brands": [r["brand"] for r in brands],
"managers": [r["manager"] for r in managers],
}
@app.get("/api/boards")
async def boards(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, manager, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, manager=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
SELECT
board_id,
@ -123,9 +153,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", search: s
@app.get("/api/bookings")
async def bookings(city: str = "", dimension: str = "", brand: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
async def bookings(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = "", date_start: str = "", date_end: str = ""):
params = parse_filters(city, dimension, brand, manager, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, manager=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
SELECT
board_id,
@ -143,4 +173,67 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", search:
return rows
@app.get("/api/map")
async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", search: str = ""):
# Surfaces come from board_info (has coordinates). Occupancy is computed "as of
# today" and is intentionally NOT affected by the date-range filters — only the
# Город/Бренд/Размер/Менеджер/Поиск facets narrow which surfaces are shown.
params = parse_filters(city, dimension, brand, manager, search, "", "")
where = " AND ".join([
"char_length(city) > 0",
CITY_COND,
DIM_COND,
SEARCH_COND,
MAP_BRAND_COND,
MAP_MANAGER_COND,
])
surfaces_sql = f"""
SELECT
board_key,
board_id,
city,
address,
dimension,
board_type,
side,
latitude AS lat,
longitude AS lon
FROM default.board_info
WHERE {where}
ORDER BY city, address
"""
surfaces = await ch_query(surfaces_sql, params)
# All bookings active today, keyed by surface, for marker color + balloon.
current_sql = """
SELECT
board_key,
toString(task_id) AS task_id,
task_status,
toString(start_date) AS start_date,
toString(end_date) AS end_date,
brand,
company_name
FROM default.pf_board
WHERE board_key != 0
AND pf_board.start_date <= today()
AND pf_board.end_date >= today()
"""
current = await ch_query(current_sql, params)
by_key: dict = {}
for b in current:
by_key.setdefault(b["board_key"], []).append(b)
out = []
for s in surfaces:
active = by_key.get(s["board_key"], [])
out.append({
**s,
"occupied_now": len(active) > 0,
"bookings": active,
})
return out
app.mount("/", StaticFiles(directory="static", html=True), name="static")