ui: active-filter chips, empty/loading states, focus/animation polish

- Active-filter chips row under the header: one chip per selected city / brand /
  dimension / manager / status plus search and date bounds, each removable; a
  "Сбросить всё" clears everything. Dropdown gains setSelected()/clear().
- Empty states for the timeline and map: icon + title + subtitle + "Сбросить
  фильтры" button (wired to the same reset). (empty toggled to display:flex.)
- Delayed indeterminate loading bar (250ms threshold so fast loads don't flash),
  covering both the data and the map fetches via an in-flight counter.
- Polish: pop-in animation for dropdown/appearance menus, :focus-visible accent
  rings, :active press on buttons, thin themed scrollbars in panels, tabular
  figures on the timeline date axis and tooltips. New components use the Open
  Props --size-* spacing scale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-10 16:28:51 +00:00
parent 17c54db501
commit 52a1a7cb2d
6 changed files with 192 additions and 24 deletions

View File

@ -13,6 +13,8 @@
</head>
<body>
<div id="load-bar" aria-hidden="true"></div>
<header>
<!-- Group 1: brand + counters -->
<div class="hgroup hgroup-brand">
@ -127,12 +129,19 @@
</div>
</header>
<div id="filter-chips" class="chips" 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 id="empty" class="empty-state" style="display:none">
<svg class="empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
<div class="empty-title">Ничего не найдено</div>
<div class="empty-sub">Под текущие фильтры нет броней</div>
<button type="button" class="empty-reset" id="empty-reset-timeline">Сбросить фильтры</button>
</div>
</div>
</div>
<div id="split-resizer" title="Потяните, чтобы изменить пропорции"></div>
@ -145,7 +154,12 @@
<button type="button" class="lg-nocoords" id="map-nocoords-btn" style="display:none"></button>
</div>
<div id="map-nocoords-panel" style="display:none"></div>
<div id="map-empty" style="display:none">Нет поверхностей с координатами</div>
<div id="map-empty" class="empty-state" style="display:none">
<svg class="empty-ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 21s-7-6.3-7-11a7 7 0 0 1 14 0c0 4.7-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></svg>
<div class="empty-title">Нет поверхностей на карте</div>
<div class="empty-sub">У выбранных поверхностей нет координат</div>
<button type="button" class="empty-reset" id="empty-reset-map">Сбросить фильтры</button>
</div>
</div>
</div>

View File

@ -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;
},

View File

@ -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<void> {
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}<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();
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}<br>Брони: ${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<void> {
loadStart();
try {
await mapView.ensureInit();
mapView.invalidateSize();
@ -178,6 +254,8 @@ async function refreshMap(): Promise<void> {
mapView.render(surfaces);
} catch (e) {
console.error('map load failed', e);
} finally {
loadEnd();
}
}

View File

@ -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);

View File

@ -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; }

View File

@ -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 = '';