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:
parent
d04e0ae024
commit
68417bf45e
@ -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>
|
||||
|
||||
@ -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)),
|
||||
};
|
||||
|
||||
@ -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
216
frontend/src/map.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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
103
frontend/src/viewmode.ts
Normal 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
119
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")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user