# app.py — Gradio + tooltips (executed via js=), smart cascading filters, aligned cards import pandas as pd import gradio as gr from html import escape as esc import re, json ALL = "All" DEFAULT_LIMIT = 50 # fallback when slider momentarily returns None def _norm_limit(limit): try: return int(limit) if limit is not None else DEFAULT_LIMIT except Exception: return DEFAULT_LIMIT # ---------------- Load & normalize ---------------- DF = pd.read_csv("offers_with_prices.csv") DF.columns = [c.strip().lower() for c in DF.columns] for col in [ "country","dealer","brand","model","trim_or_variant", "starting_price","heading","offer_description", "vehicle_offer_url","vehcileoffer_url","images" ]: if col not in DF.columns: DF[col] = "" def _first_image(val) -> str: if not val: return "" s = str(val).strip().replace("&", "&") try: if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'): j = json.loads(s) if isinstance(j, list): for item in j: m = re.search(r'https?://[^\s<>()\'"]+', str(item)) if m: return m.group(0) elif isinstance(j, dict): for k in ("image","image_url","url","src"): if k in j: m = re.search(r'https?://[^\s<>()\'"]+', str(j[k])) if m: return m.group(0) except Exception: pass m = re.search(r'https?://[^\s<>()\'"]+', s) if m: return m.group(0) for sep in ("|","\n"," "): if sep in s: for part in s.split(sep): m = re.search(r'https?://[^\s<>()\'"]+', part) if m: return m.group(0) return "" def _card_html(row: pd.Series) -> str: img = _first_image(row.get("images","")) heading = esc(str(row.get("heading","")) or f"{row.get('brand','')} {row.get('model','')}") offer = esc(str(row.get("offer_description",""))) price = esc(str(row.get("starting_price",""))) url = str(row.get("vehicle_offer_url","") or row.get("vehcileoffer_url","") or "#") country = esc(str(row.get("country",""))); brand = esc(str(row.get("brand",""))) dealer = esc(str(row.get("dealer",""))); model = esc(str(row.get("model",""))) trim = esc(str(row.get("trim_or_variant",""))) chips = " ".join(f"{v}" for v in [country, brand, dealer, model, trim] if v) tooltip = f""" """ return f"""
{heading}
Starting Price{price or "—"}
{heading}
{offer}
{chips}
{tooltip}
""" def _grid_html(df: pd.DataFrame) -> str: cards = "\n".join(_card_html(r) for _, r in df.iterrows()) return f"""
{cards}
""" # ---------------- Filtering helpers ---------------- def _choices(series: pd.Series): vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()] return [ALL] + sorted(vals) def _filtered_df(country: str, dealer: str, brand: str) -> pd.DataFrame: df = DF.copy() if country and country != ALL: df = df[df["country"].astype(str) == country] if dealer and dealer != ALL: df = df[df["dealer"].astype(str) == dealer] if brand and brand != ALL: df = df[df["brand"].astype(str) == brand] return df # ---------------- CSS (injected once) ---------------- GLOBAL_STYLE = """ """ # ---------------- JS to bind tooltips ---------------- TOOLTIP_BIND_JS = r""" () => { const root = document.querySelector('#cards-pane #cards-root') || document.querySelector('#cards-root'); if (!root) return; let portal = root.querySelector('#tt-portal') || document.getElementById('tt-portal'); if (!portal) { portal = document.createElement('div'); portal.id = 'tt-portal'; root.prepend(portal); } let activeCard = null, overPortal = false, hideTimer = null; const clamp = (n,min,max)=> Math.max(min, Math.min(max, n)); function placePortal(anchor, side){ const a = anchor.getBoundingClientRect(); const p = portal.getBoundingClientRect(); const vw = window.innerWidth, vh = window.innerHeight; const gap = 14; let left = a.right + gap; portal.classList.remove('left','right'); if (side === 'left' || left + p.width > vw - 6){ left = Math.max(6, a.left - gap - p.width); portal.classList.add('left'); } else { left = Math.min(left, vw - 6 - p.width); portal.classList.add('right'); } const topDesired = a.top + 10; const top = clamp(topDesired, 6, vh - p.height - 6); portal.style.left = left + 'px'; portal.style.top = top + 'px'; } function showFor(card, prefer='right'){ const tpl = card.querySelector('.card-tooltip-template'); if(!tpl) return; clearTimeout(hideTimer); activeCard = card; portal.innerHTML = tpl.innerHTML; portal.classList.add('show'); requestAnimationFrame(()=>{ placePortal(card, prefer); const pr = portal.getBoundingClientRect(); if (pr.right > window.innerWidth - 2){ placePortal(card,'left'); }}); card.classList.add('active'); } function hideSoon(){ clearTimeout(hideTimer); hideTimer = setTimeout(()=>{ const still = activeCard && activeCard.matches(':hover'); if(!overPortal && !still){ hideNow(); } }, 120); } function hideNow(){ portal.classList.remove('show'); if(activeCard){ activeCard.classList.remove('active'); activeCard = null; } } root.querySelectorAll('.card').forEach(card=>{ card.onmouseenter = ()=>showFor(card); card.onmouseleave = hideSoon; card.onfocusin = ()=>showFor(card); card.onfocusout = hideSoon; }); function maybeReposition(){ if(!portal.classList.contains('show') || !activeCard) return; const side = portal.classList.contains('left') ? 'left' : 'right'; placePortal(activeCard, side); } window.addEventListener('scroll', maybeReposition, {passive:true}); window.addEventListener('resize', maybeReposition, {passive:true}); portal.onmouseenter = ()=>{ overPortal = true; clearTimeout(hideTimer); }; portal.onmouseleave = ()=>{ overPortal = false; hideSoon(); }; document.addEventListener('click', (e)=>{ if(!portal.contains(e.target)) hideNow(); }, {once:true}); return null; } """ # ---------------- Render functions ---------------- def render_cards(country: str, dealer: str, brand: str, limit): limit = _norm_limit(limit) df = _filtered_df(country, dealer, brand).head(limit) return _grid_html(df) def on_country_change(country, dealer, brand, limit): limit = _norm_limit(limit) df_c = _filtered_df(country, ALL, ALL) dealer_choices = _choices(df_c["dealer"]); brand_choices = _choices(df_c["brand"]) dealer_val = brand_val = ALL html = render_cards(country, dealer_val, brand_val, limit) return gr.update(choices=dealer_choices, value=dealer_val), gr.update(choices=brand_choices, value=brand_val), html def on_dealer_change(country, dealer, brand, limit): limit = _norm_limit(limit) df_cd = _filtered_df(country, dealer, ALL) brand_choices = _choices(df_cd["brand"]) brand_val = brand if brand in brand_choices else ALL html = render_cards(country, dealer, brand_val, limit) return gr.update(choices=brand_choices, value=brand_val), html def on_brand_change(country, dealer, brand, limit): limit = _norm_limit(limit) df_cb = _filtered_df(country, ALL, brand) dealer_choices = _choices(df_cb["dealer"]) dealer_val = dealer if dealer in dealer_choices else ALL html = render_cards(country, dealer_val, brand, limit) return gr.update(choices=dealer_choices, value=dealer_val), html def on_limit_change(country, dealer, brand, limit): limit = _norm_limit(limit) return render_cards(country, dealer, brand, limit) # ---------------- Build Gradio UI ---------------- def app(): countries = _choices(DF["country"]) dealers = _choices(DF["dealer"]) brands = _choices(DF["brand"]) with gr.Blocks(title="Vehicle Offers", css=".gradio-container{max-width:1400px;margin:0 auto;}") as demo: gr.Markdown("## 🚗 Vehicle Offers") gr.HTML(GLOBAL_STYLE) with gr.Row(): dd_country = gr.Dropdown(choices=countries, value=ALL, label="Country") dd_dealer = gr.Dropdown(choices=dealers, value=ALL, label="Dealer") dd_brand = gr.Dropdown(choices=brands, value=ALL, label="Brand") s_limit = gr.Slider(6, 200, value=DEFAULT_LIMIT, step=6, label="Max Offers") # State used ONLY for initial load to avoid None from slider limit_state = gr.State(DEFAULT_LIMIT) out_html = gr.HTML(elem_id="cards-pane") # Initial render uses limit_state (not the Slider) -> prevents None crash demo.load(render_cards, [dd_country, dd_dealer, dd_brand, limit_state], out_html, js=TOOLTIP_BIND_JS) # Country change → reset dealer & brand + rebind JS dd_country.change(on_country_change, [dd_country, dd_dealer, dd_brand, s_limit], [dd_dealer, dd_brand, out_html], js=TOOLTIP_BIND_JS) # Dealer change → update brand + rebind JS dd_dealer.change(on_dealer_change, [dd_country, dd_dealer, dd_brand, s_limit], [dd_brand, out_html], js=TOOLTIP_BIND_JS) # Brand change → update dealer + rebind JS dd_brand.change(on_brand_change, [dd_country, dd_dealer, dd_brand, s_limit], [dd_dealer, out_html], js=TOOLTIP_BIND_JS) # Limit change → just re-render + rebind JS s_limit.change(on_limit_change, [dd_country, dd_dealer, dd_brand, s_limit], out_html, js=TOOLTIP_BIND_JS) return demo if __name__ == "__main__": app().launch()