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:
parent
17c54db501
commit
52a1a7cb2d
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
},
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user