From fe06cdab7243261de310f08af80ee5931eda54cf Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Tue, 21 Jul 2026 08:38:53 +0000 Subject: [PATCH] dependent (faceted) filters: city/brand/dimension/search narrow each other --- frontend/src/api.ts | 2 +- frontend/src/dropdown.ts | 14 ++++++++- frontend/src/main.ts | 17 ++++++----- main.py | 61 +++++++++++++++++++++++++--------------- 4 files changed, 61 insertions(+), 33 deletions(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8343b49..cc0710c 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -21,7 +21,7 @@ async function getJson(url: string): Promise { } export const api = { - meta: (): Promise => getJson('/api/meta'), + meta: (f: Filters): Promise => getJson('/api/meta?' + buildQuery(f)), boards: (f: Filters): Promise => getJson('/api/boards?' + buildQuery(f)), bookings: (f: Filters): Promise => getJson('/api/bookings?' + buildQuery(f)), }; diff --git a/frontend/src/dropdown.ts b/frontend/src/dropdown.ts index ff7ca24..a811b5b 100644 --- a/frontend/src/dropdown.ts +++ b/frontend/src/dropdown.ts @@ -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); }, diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 23ac3c2..78170e9 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -84,17 +84,14 @@ document.addEventListener('click', (e) => { } }); -async function loadMeta(): Promise { - const meta = await api.meta(); - cityDropdown.setValues(meta.cities, 'Все города'); - brandDropdown.setValues(meta.brands, 'Все бренды'); - dimensionDropdown.setValues(meta.dimensions, 'Все размеры'); -} - async function loadData(fit: boolean): Promise { 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 { + cityDropdown.setValues([], 'Все города'); + brandDropdown.setValues([], 'Все бренды'); + dimensionDropdown.setValues([], 'Все размеры'); rebuildDecorMenu(); view.applyLayout(); - await loadMeta(); await loadData(true); const defaultZoomIdx = 3; el.zoomSlider.value = String(defaultZoomIdx); diff --git a/main.py b/main.py index bad6553..2b81bdb 100644 --- a/main.py +++ b/main.py @@ -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)