dependent (faceted) filters: city/brand/dimension/search narrow each other
This commit is contained in:
parent
5db77b7a06
commit
fe06cdab72
@ -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)),
|
||||
};
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -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
61
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user