dependent (faceted) filters: city/brand/dimension/search narrow each other

This commit is contained in:
aaverbitskiy 2026-07-21 08:38:53 +00:00
parent 5db77b7a06
commit fe06cdab72
4 changed files with 61 additions and 33 deletions

View File

@ -21,7 +21,7 @@ async function getJson<T>(url: string): Promise<T> {
}
export const api = {
meta: (): Promise<Meta> => getJson<Meta>('/api/meta'),
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)),
};

View File

@ -4,6 +4,8 @@
export interface Dropdown {
setValues(values: string[], label: string): void;
/** Replace the available options while keeping the current selection. */
updateValues(values: string[]): void;
getSelected(): string[];
onChange(fn: () => void): void;
}
@ -56,7 +58,12 @@ export function createDropdown(id: string): Dropdown {
actions.appendChild(selNone);
panel.appendChild(actions);
for (const v of allValues) {
// Show all available options, plus any currently-selected value that is no
// longer among them (so a selection made incompatible by other filters
// stays visible and can still be unchecked).
const extra = Array.from(selected).filter((v) => !allValues.includes(v));
const display = allValues.concat(extra);
for (const v of display) {
const label = document.createElement('label');
const cb = document.createElement('input');
cb.type = 'checkbox';
@ -93,6 +100,11 @@ export function createDropdown(id: string): Dropdown {
updateBtnText();
renderPanel();
},
updateValues(values: string[]): void {
allValues = values;
updateBtnText();
renderPanel();
},
getSelected(): string[] {
return Array.from(selected);
},

View File

@ -84,17 +84,14 @@ document.addEventListener('click', (e) => {
}
});
async function loadMeta(): Promise<void> {
const meta = await api.meta();
cityDropdown.setValues(meta.cities, 'Все города');
brandDropdown.setValues(meta.brands, 'Все бренды');
dimensionDropdown.setValues(meta.dimensions, 'Все размеры');
}
async function loadData(fit: boolean): Promise<void> {
el.status.textContent = 'Загрузка…';
const f = currentFilters();
const [boards, bookings] = await Promise.all([api.boards(f), api.bookings(f)]);
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);
lastBoards = boards;
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
@ -125,9 +122,11 @@ el.zoomSlider.addEventListener('input', () => {
});
async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города');
brandDropdown.setValues([], 'Все бренды');
dimensionDropdown.setValues([], 'Все размеры');
rebuildDecorMenu();
view.applyLayout();
await loadMeta();
await loadData(true);
const defaultZoomIdx = 3;
el.zoomSlider.value = String(defaultZoomIdx);

61
main.py
View File

@ -12,17 +12,33 @@ CH_URL = f"http://{CH_HOST}:{CH_PORT}/"
app = FastAPI()
FILTER_SQL = """
char_length(city) > 0
AND (length({cities:Array(String)}) = 0 OR city IN {cities:Array(String)})
AND (length({dimensions:Array(String)}) = 0 OR dimension IN {dimensions:Array(String)})
AND (length({brands:Array(String)}) = 0 OR brand IN {brands:Array(String)})
AND (
{search:String} = ''
OR positionCaseInsensitive(address, {search:String}) > 0
OR positionCaseInsensitive(board_id, {search:String}) > 0
)
"""
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)})"
SEARCH_COND = (
"({search:String} = ''"
" OR positionCaseInsensitive(address, {search:String}) > 0"
" OR positionCaseInsensitive(board_id, {search:String}) > 0)"
)
def build_where(city=False, dimension=False, brand=False, search=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).
parts = ["char_length(city) > 0"]
if city:
parts.append(CITY_COND)
if dimension:
parts.append(DIM_COND)
if brand:
parts.append(BRAND_COND)
if search:
parts.append(SEARCH_COND)
return "\n AND ".join(parts)
WHERE_ALL = build_where(city=True, dimension=True, brand=True, search=True)
async def ch_query(sql: str, params: dict):
@ -50,18 +66,19 @@ def parse_filters(city: str, dimension: str, brand: str, search: str):
@app.get("/api/meta")
async def meta():
async def meta(city: str = "", dimension: str = "", brand: str = "", search: str = ""):
params = parse_filters(city, dimension, brand, search)
cities = await ch_query(
"SELECT DISTINCT city FROM default.pf_board WHERE char_length(city) > 0 ORDER BY city",
{},
)
dimensions = await ch_query(
"SELECT DISTINCT dimension FROM default.pf_board WHERE char_length(dimension) > 0 ORDER BY dimension",
{},
f"SELECT DISTINCT city FROM default.pf_board WHERE {build_where(dimension=True, brand=True, search=True)} AND char_length(city) > 0 ORDER BY city",
params,
)
brands = await ch_query(
"SELECT DISTINCT brand FROM default.pf_board WHERE char_length(brand) > 0 ORDER BY brand",
{},
f"SELECT DISTINCT brand FROM default.pf_board WHERE {build_where(city=True, dimension=True, search=True)} 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)} AND char_length(dimension) > 0 ORDER BY dimension",
params,
)
return {
"cities": [r["city"] for r in cities],
@ -80,7 +97,7 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", search: s
argMax(city, last_activity) AS board_city,
argMax(dimension, last_activity) AS board_dimension
FROM default.pf_board
WHERE {FILTER_SQL}
WHERE {WHERE_ALL}
GROUP BY board_id
ORDER BY board_address
"""
@ -101,7 +118,7 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", search:
brand,
company_name
FROM default.pf_board
WHERE {FILTER_SQL}
WHERE {WHERE_ALL}
ORDER BY board_id, start_date
"""
rows = await ch_query(sql, params)