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 @@
+
+
+
+
-
Нет данных по выбранным фильтрам
+
+
+
Ничего не найдено
+
Под текущие фильтры нет броней
+
+
@@ -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 = '';