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 = { 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)), boards: (f: Filters): Promise<Board[]> => getJson<Board[]>('/api/boards?' + buildQuery(f)),
bookings: (f: Filters): Promise<Booking[]> => getJson<Booking[]>('/api/bookings?' + buildQuery(f)), bookings: (f: Filters): Promise<Booking[]> => getJson<Booking[]>('/api/bookings?' + buildQuery(f)),
}; };

View File

@ -4,6 +4,8 @@
export interface Dropdown { export interface Dropdown {
setValues(values: string[], label: string): void; setValues(values: string[], label: string): void;
/** Replace the available options while keeping the current selection. */
updateValues(values: string[]): void;
getSelected(): string[]; getSelected(): string[];
onChange(fn: () => void): void; onChange(fn: () => void): void;
} }
@ -56,7 +58,12 @@ export function createDropdown(id: string): Dropdown {
actions.appendChild(selNone); actions.appendChild(selNone);
panel.appendChild(actions); 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 label = document.createElement('label');
const cb = document.createElement('input'); const cb = document.createElement('input');
cb.type = 'checkbox'; cb.type = 'checkbox';
@ -93,6 +100,11 @@ export function createDropdown(id: string): Dropdown {
updateBtnText(); updateBtnText();
renderPanel(); renderPanel();
}, },
updateValues(values: string[]): void {
allValues = values;
updateBtnText();
renderPanel();
},
getSelected(): string[] { getSelected(): string[] {
return Array.from(selected); 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> { async function loadData(fit: boolean): Promise<void> {
el.status.textContent = 'Загрузка…'; el.status.textContent = 'Загрузка…';
const f = currentFilters(); 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; lastBoards = boards;
lastBookings = bookings; lastBookings = bookings;
view.render(boards, bookings, fit, flags()); view.render(boards, bookings, fit, flags());
@ -125,9 +122,11 @@ el.zoomSlider.addEventListener('input', () => {
}); });
async function init(): Promise<void> { async function init(): Promise<void> {
cityDropdown.setValues([], 'Все города');
brandDropdown.setValues([], 'Все бренды');
dimensionDropdown.setValues([], 'Все размеры');
rebuildDecorMenu(); rebuildDecorMenu();
view.applyLayout(); view.applyLayout();
await loadMeta();
await loadData(true); await loadData(true);
const defaultZoomIdx = 3; const defaultZoomIdx = 3;
el.zoomSlider.value = String(defaultZoomIdx); el.zoomSlider.value = String(defaultZoomIdx);

61
main.py
View File

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