From 68417bf45ec60f1278f1de0008cfd13bc7122103 Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Sun, 26 Jul 2026 05:05:04 +0000 Subject: [PATCH] =?UTF-8?q?map:=20Yandex=20map=20section=20(3=20modes=20+?= =?UTF-8?q?=20resizable=20split),=20green/red=20markers,=20hover=20tooltip?= =?UTF-8?q?s=20for=20markers+clusters;=20fix=20map=20init=20race;=20header?= =?UTF-8?q?=20keeps=20counters=20inline=20(fields=20shrink=20first);=20add?= =?UTF-8?q?=20Manager=20(=D0=9C=D0=B5=D0=BD=D0=B5=D0=B4=D0=B6=D0=B5=D1=80)?= =?UTF-8?q?=20faceted=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/index.html | 42 +++++++- frontend/src/api.ts | 6 +- frontend/src/main.ts | 73 +++++++++++++ frontend/src/map.ts | 216 +++++++++++++++++++++++++++++++++++++++ frontend/src/styles.css | 81 ++++++++++++++- frontend/src/types.ts | 27 +++++ frontend/src/viewmode.ts | 103 +++++++++++++++++++ main.py | 119 ++++++++++++++++++--- 8 files changed, 645 insertions(+), 22 deletions(-) create mode 100644 frontend/src/map.ts create mode 100644 frontend/src/viewmode.ts diff --git a/frontend/index.html b/frontend/index.html index 0136314..521f0f6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -16,6 +16,15 @@ + +
+
+ + + +
+
+
@@ -63,6 +72,17 @@
+
+ + +
+
@@ -100,10 +120,24 @@
-
-
-
- +
+
+
+
+
+ +
+
+
+
+
+
+ свободна сейчас + занята сейчас + +
+ +
diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 9f4c8d1..e2480fd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 => getJson('/api/meta?' + buildQuery(f)), boards: (f: Filters): Promise => getJson('/api/boards?' + buildQuery(f)), bookings: (f: Filters): Promise => getJson('/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 => getJson('/api/map?' + buildQuery(f)), }; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index a45a7f7..47f4272 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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 { 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}
Бронирований: ${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 { + 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 { + 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 { cityDropdown.setValues([], 'Все города'); brandDropdown.setValues([], 'Все бренды'); dimensionDropdown.setValues([], 'Все размеры'); + managerDropdown.setValues([], 'Все менеджеры'); rebuildDecorMenu(); view.applyLayout(); await loadData(true); @@ -138,6 +209,8 @@ async function init(): Promise { 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(); diff --git a/frontend/src/map.ts b/frontend/src/map.ts new file mode 100644 index 0000000..c34548d --- /dev/null +++ b/frontend/src/map.ts @@ -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 ``; +} + +let ymapsPromise: Promise | null = null; +function loadYmaps(apiKey: string): Promise { + 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; + invalidateSize(): void; +} + +// Balloon (click) — richer, with PlanFix links. +function balloonBody(s: MapSurface): string { + const rows: string[] = []; + rows.push(`
${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')} · ${escapeHtml(s.board_type || '')}
`); + 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} (${escapeHtml(b.start_date)} — ${escapeHtml(b.end_date)}) · ${link}
`); + } + } + 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)) { + rows.push(`
${dot(s.occupied_now)}${escapeHtml(s.board_id)} · ${escapeHtml(s.dimension || '')}
`); + } + 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[] = []; + + // 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 { + 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(); + }, + }; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a0c6627..01ac3cd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1077eca..c1e60f2 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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 = 'РК. Архив'; diff --git a/frontend/src/viewmode.ts b/frontend/src/viewmode.ts new file mode 100644 index 0000000..a6ab2cd --- /dev/null +++ b/frontend/src/viewmode.ts @@ -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, + }; +} diff --git a/main.py b/main.py index 100cadf..a54eea7 100644 --- a/main.py +++ b/main.py @@ -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")