diff --git a/frontend/index.html b/frontend/index.html index 2e485e2..577edd8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,6 +13,8 @@ + +
@@ -127,12 +129,19 @@
+ +
- +
@@ -145,7 +154,12 @@
- + diff --git a/frontend/src/dropdown.ts b/frontend/src/dropdown.ts index a811b5b..2f2f2d0 100644 --- a/frontend/src/dropdown.ts +++ b/frontend/src/dropdown.ts @@ -7,6 +7,10 @@ export interface Dropdown { /** Replace the available options while keeping the current selection. */ updateValues(values: string[]): void; getSelected(): string[]; + /** Replace the current selection (does NOT fire onChange — caller reloads). */ + setSelected(values: string[]): void; + /** Clear the selection (does NOT fire onChange — caller reloads). */ + clear(): void; onChange(fn: () => void): void; } @@ -108,6 +112,16 @@ export function createDropdown(id: string): Dropdown { getSelected(): string[] { return Array.from(selected); }, + setSelected(values: string[]): void { + selected = new Set(values); + syncCheckboxes(); + updateBtnText(); + }, + clear(): void { + selected.clear(); + syncCheckboxes(); + updateBtnText(); + }, onChange(fn: () => void): void { onChange = fn; }, diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 5a8eca3..dce8be2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -45,6 +45,10 @@ const el = { mapEmpty: document.getElementById('map-empty') as HTMLElement, mapCounter: document.getElementById('map-counter') as HTMLElement, themeToggle: document.getElementById('theme-toggle') as HTMLButtonElement, + filterChips: document.getElementById('filter-chips') as HTMLElement, + loadBar: document.getElementById('load-bar') as HTMLElement, + emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement, + emptyResetMap: document.getElementById('empty-reset-map') as HTMLButtonElement, }; // ---- theme toggle (data-theme is set pre-paint by the inline head script) ---- @@ -137,23 +141,94 @@ document.addEventListener('click', (e) => { } }); +// ---- loading indicator (delayed, so fast loads don't flash) ---- +let loadTimer: number | undefined; +let inflight = 0; +function loadStart(): void { + inflight++; + clearTimeout(loadTimer); + loadTimer = window.setTimeout(() => el.loadBar.classList.add('active'), 250); +} +function loadEnd(): void { + inflight = Math.max(0, inflight - 1); + if (inflight === 0) { + clearTimeout(loadTimer); + el.loadBar.classList.remove('active'); + } +} + +// ---- filters: reset + active-filter chips ---- +const fmtD = (iso: string): string => { + const p = iso.split('-'); + return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso; +}; +function resetFilters(): void { + for (const d of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) d.clear(); + el.search.value = ''; + el.dateStart.value = ''; + el.dateEnd.value = ''; + void loadData(true); +} +function renderChips(): void { + const items: { label: string; remove: () => void }[] = []; + for (const dd of [cityDropdown, brandDropdown, dimensionDropdown, managerDropdown, statusDropdown]) { + for (const v of dd.getSelected()) { + items.push({ label: v, remove: () => { dd.setSelected(dd.getSelected().filter((x) => x !== v)); void loadData(true); } }); + } + } + if (el.search.value.trim()) items.push({ label: 'Поиск: ' + el.search.value.trim(), remove: () => { el.search.value = ''; void loadData(true); } }); + if (el.dateStart.value) items.push({ label: 'с ' + fmtD(el.dateStart.value), remove: () => { el.dateStart.value = ''; void loadData(true); } }); + if (el.dateEnd.value) items.push({ label: 'по ' + fmtD(el.dateEnd.value), remove: () => { el.dateEnd.value = ''; void loadData(true); } }); + + el.filterChips.innerHTML = ''; + if (!items.length) { el.filterChips.style.display = 'none'; return; } + el.filterChips.style.display = ''; + for (const it of items) { + const chip = document.createElement('span'); + chip.className = 'chip'; + chip.appendChild(document.createTextNode(it.label)); + const x = document.createElement('button'); + x.type = 'button'; + x.className = 'chip-x'; + x.setAttribute('aria-label', 'Убрать фильтр'); + x.textContent = '×'; + x.addEventListener('click', it.remove); + chip.appendChild(x); + el.filterChips.appendChild(chip); + } + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'chips-clear'; + clear.textContent = 'Сбросить всё'; + clear.addEventListener('click', resetFilters); + el.filterChips.appendChild(clear); +} +el.emptyResetTimeline.addEventListener('click', resetFilters); +el.emptyResetMap.addEventListener('click', resetFilters); + async function loadData(fit: boolean): Promise { el.status.textContent = 'Загрузка…'; - const f = currentFilters(); - const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f), api.bookings(f)]); - // Dependent filters: refresh each dropdown's options (selections preserved). - cityDropdown.updateValues(meta.cities); - brandDropdown.updateValues(meta.brands); - dimensionDropdown.updateValues(meta.dimensions); - managerDropdown.updateValues(meta.managers); - statusDropdown.updateValues(meta.statuses); - 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(); + loadStart(); + try { + const f = currentFilters(); + const [meta, boards, bookings] = await Promise.all([api.meta(f), api.boards(f), api.bookings(f)]); + // Dependent filters: refresh each dropdown's options (selections preserved). + cityDropdown.updateValues(meta.cities); + brandDropdown.updateValues(meta.brands); + dimensionDropdown.updateValues(meta.dimensions); + managerDropdown.updateValues(meta.managers); + statusDropdown.updateValues(meta.statuses); + lastBoards = boards; + lastBookings = bookings; + view.render(boards, bookings, fit, flags()); + el.status.innerHTML = `Поверхности: ${boards.length}
Брони: ${bookings.length}`; + rebuildDecorMenu(); // status list may have changed + renderChips(); + // Keep the map in sync when it is visible (facets filter it; dates do not). + if (mapVisible()) void refreshMap(); + } finally { + loadEnd(); + } } // ---- map ---- @@ -168,6 +243,7 @@ function mapVisible(): boolean { return !!viewModes && viewModes.getMode() !== 'timeline'; } async function refreshMap(): Promise { + loadStart(); try { await mapView.ensureInit(); mapView.invalidateSize(); @@ -178,6 +254,8 @@ async function refreshMap(): Promise { mapView.render(surfaces); } catch (e) { console.error('map load failed', e); + } finally { + loadEnd(); } } diff --git a/frontend/src/map.ts b/frontend/src/map.ts index 7671844..cf24670 100644 --- a/frontend/src/map.ts +++ b/frontend/src/map.ts @@ -241,7 +241,7 @@ export function createMapView(els: MapElements, apiKey: string): MapView { if (!noCoordsList.length) noCoordsPanel.style.display = 'none'; } - els.empty.style.display = withCoords.length ? 'none' : 'block'; + els.empty.style.display = withCoords.length ? 'none' : 'flex'; if (features.length) { const points = features.map((f) => f.geometry.coordinates); diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c34abcd..df48a64 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -397,10 +397,7 @@ input::placeholder { color: var(--text-muted); } .bl-dates { color: var(--text-muted); } .bl-link { color: var(--accent); text-decoration: none; font-weight: 600; } .bl-link:hover { text-decoration: underline; } -#map-empty { - position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); - color: var(--text-muted); font-size: 14px; z-index: 5; -} +#map-empty.empty-state { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 5; } /* Custom hover tooltip for map markers and clusters. */ .map-tip { position: fixed; z-index: 1000; pointer-events: none; @@ -514,4 +511,69 @@ input::placeholder { color: var(--text-muted); } .tt-warn-row { font-size: 11.5px; color: #f0d9b0; margin: 2px 0; } .tt-warn-dates { color: var(--tip-sub); } .tt-hint { margin-top: 9px; color: var(--tip-sub); font-size: 11px; } -#empty { padding: 40px; text-align: center; color: var(--text-muted); font-size: 13px; } +/* ---- empty states (timeline + map) ---- */ +.empty-state { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: var(--size-2); text-align: center; color: var(--text-muted); padding: var(--size-5); +} +#empty.empty-state { position: absolute; inset: 0; } +.empty-ico { width: 40px; height: 40px; color: var(--border-strong); } +.empty-title { font-size: 15px; font-weight: 600; color: var(--text); } +.empty-sub { font-size: 13px; color: var(--text-muted); } +.empty-reset { + margin-top: var(--size-1); border: 1px solid var(--border); background: var(--bg); color: var(--accent); + font-size: 13px; font-weight: 500; padding: 7px 14px; border-radius: var(--radius-sm); cursor: pointer; + transition: border-color var(--transition), background var(--transition); +} +.empty-reset:hover { border-color: var(--accent); background: var(--accent-weak); } + +/* ---- loading bar (delayed, indeterminate) ---- */ +#load-bar { + position: fixed; top: 0; left: 0; right: 0; height: 3px; z-index: 200; overflow: hidden; + opacity: 0; transition: opacity .2s var(--ease-3); pointer-events: none; +} +#load-bar.active { opacity: 1; } +#load-bar::before { + content: ""; position: absolute; top: 0; bottom: 0; width: 35%; + background: var(--accent); border-radius: 0 3px 3px 0; animation: loadbar 1.1s var(--ease-3) infinite; +} +@keyframes loadbar { 0% { left: -35%; } 100% { left: 100%; } } + +/* ---- active-filter chips ---- */ +.chips { + flex: 0 0 auto; display: flex; flex-wrap: wrap; align-items: center; gap: var(--size-2); + padding: var(--size-2) var(--size-3); border-bottom: 1px solid var(--border); background: var(--bg); z-index: 15; +} +.chip { + display: inline-flex; align-items: center; gap: 5px; + background: var(--accent-weak); color: var(--accent); + font-size: 12px; font-weight: 500; padding: 3px 4px 3px 10px; border-radius: 999px; white-space: nowrap; +} +.chip-x { + border: none; background: transparent; color: inherit; cursor: pointer; line-height: 1; font-size: 15px; + width: 18px; height: 18px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; + transition: background var(--transition); +} +.chip-x:hover { background: rgba(79, 70, 229, .18); } +.chips-clear { + border: none; background: transparent; color: var(--text-muted); cursor: pointer; + font-size: 12px; font-weight: 500; padding: 3px 8px; border-radius: var(--radius-sm); margin-left: 2px; + transition: color var(--transition), background var(--transition); +} +.chips-clear:hover { color: var(--text); background: var(--surface-2); } + +/* ---- polish: focus rings, animations, scrollbars, tabular figures ---- */ +button:focus-visible, input[type=text]:focus-visible, input[type=date]:focus-visible, +.dropdown-btn:focus-visible, .mode-btn:focus-visible, a:focus-visible { + outline: none; box-shadow: 0 0 0 3px var(--accent-ring); +} +input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.dropdown.open .dropdown-panel, .decor-menu.open { animation: pop-in .13s var(--ease-3); } +@keyframes pop-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } +.mode-btn:active, .decor-btn:active, .empty-reset:active, .chips-clear:active, .lg-nocoords:active { transform: scale(.97); } +.dropdown-panel, #map-nocoords-panel, .decor-menu { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } +.dropdown-panel::-webkit-scrollbar, #map-nocoords-panel::-webkit-scrollbar, .decor-menu::-webkit-scrollbar { width: 9px; } +.dropdown-panel::-webkit-scrollbar-thumb, #map-nocoords-panel::-webkit-scrollbar-thumb, .decor-menu::-webkit-scrollbar-thumb { + background: var(--border-strong); border-radius: 999px; border: 2px solid var(--bg); +} +.vis-time-axis .vis-text, #tooltip, .map-tip, .bl, input[type=date] { font-variant-numeric: tabular-nums; } diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts index 1b41532..a0131df 100644 --- a/frontend/src/timeline.ts +++ b/frontend/src/timeline.ts @@ -274,7 +274,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) groupsDS.clear(); itemsDS.clear(); el.timelineEl.style.display = 'none'; - el.empty.style.display = 'block'; + el.empty.style.display = 'flex'; return; } el.timelineEl.style.display = '';