Spaces:
Sleeping
Sleeping
| # 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"<span class='chip'>{v}</span>" for v in [country, brand, dealer, model, trim] if v) | |
| tooltip = f""" | |
| <div class="card-tooltip-template" aria-hidden="true" style="display:none"> | |
| <div class="tt-head"><span class="tt-dot"></span><div class="tt-title">{heading}</div></div> | |
| <div class="tt-meta">{"".join(f"<span class='tt-chip'>{x}</span>" for x in [country,brand,dealer,model,trim] if x)}</div> | |
| <div class="tt-grid"> | |
| <div class="tt-k">Starting Price</div><div class="tt-v">{price or "—"}</div> | |
| <div class="tt-k">Details</div><div class="tt-v">{offer or "—"}</div> | |
| </div> | |
| {"<a class='tt-link' href='"+esc(url)+"' target='_blank' rel='noopener'>Open offer ↗</a>" if url and url != "#" else ""} | |
| </div> | |
| """ | |
| return f""" | |
| <div class="card" onclick="window.open('{url}','_blank')" tabindex="0"> | |
| <div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div> | |
| <div class="body"> | |
| <div class="sp">Starting Price<strong>{price or "—"}</strong></div> | |
| <div class="heading">{heading}</div> | |
| <div class="offer">{offer}</div> | |
| </div> | |
| <div class="meta">{chips}</div> | |
| {tooltip} | |
| </div> | |
| """ | |
| def _grid_html(df: pd.DataFrame) -> str: | |
| cards = "\n".join(_card_html(r) for _, r in df.iterrows()) | |
| return f""" | |
| <div id="cards-root"> | |
| <div id="tt-portal" aria-live="polite"></div> | |
| <div class="cards">{cards}</div> | |
| </div> | |
| """ | |
| # ---------------- 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 = """ | |
| <style> | |
| body, .gradio-container, .gradio-container * { font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important; } | |
| #cards-pane { height: 820px; overflow: auto; border-radius: 12px; } | |
| :root{ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb; --chip:#eef2f7; --accent:#2563eb; } | |
| #cards-root{ background: var(--bg); padding: 8px; } | |
| .cards{ display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap:18px; } | |
| @media (max-width:1300px){ .cards{ grid-template-columns: repeat(3, minmax(0, 1fr)); } } | |
| @media (max-width:1000px){ .cards{ grid-template-columns: repeat(2, minmax(0, 1fr)); } } | |
| @media (max-width:640px){ .cards{ grid-template-columns: 1fr; } } | |
| .card{ background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden; box-shadow:0 4px 12px rgba(0,0,0,.06); cursor:pointer; display:flex; flex-direction:column; transition: transform .08s ease, box-shadow .2s ease; } | |
| .card:hover{ transform: translateY(-2px); box-shadow:0 8px 20px rgba(0,0,0,.12); } | |
| .thumb{ height:160px; overflow:hidden; background:#000; } | |
| .thumb img{ width:100%; height:100%; object-fit:cover; } | |
| .body{ padding:12px; display:flex; flex-direction:column; gap:6px; flex:1 1 auto; } | |
| .sp{ font-size:13px; color:var(--muted); } | |
| .sp strong{ display:block; font-size:20px; color:#e5b000; margin-top:2px; } | |
| .heading{ font-weight:700; color:var(--ink); font-size:15px; line-height:1.25; } | |
| .offer{ color:var(--ink); font-size:13px; line-height:1.45; opacity:.9; display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; } | |
| .meta{ display:flex; gap:6px; flex-wrap:wrap; padding:0 12px 12px; margin-top:auto; } | |
| .chip{ font-size:11px; color:#374151; background:var(--chip); border-radius:999px; padding:4px 8px; } | |
| #tt-portal{ position: fixed; top: 0; left: 0; z-index: 2147483647; display: none; width: min(520px, 86vw); max-height: 72vh; overflow-x: hidden; overflow-y: auto; background: rgba(255,255,255,.92); -webkit-backdrop-filter: saturate(1.4) blur(8px); backdrop-filter: saturate(1.4) blur(8px); border: 1px solid transparent; border-radius: 14px; box-shadow: 0 18px 50px rgba(2,6,23,.18); padding: 14px 16px; color: #0f172a; font-size: 13px; line-height: 1.5; background-clip: padding-box, border-box; background-image: linear-gradient(rgba(255,255,255,.92), rgba(255,255,255,.92)), linear-gradient(135deg, rgba(99,102,241,.25), rgba(99,102,241,.05)); } | |
| #tt-portal.show { display:block; animation: tt-fade .16s ease-out; } | |
| @keyframes tt-fade { from { opacity:0; transform: translateY(-2px); } to { opacity:1; transform: translateY(0); } } | |
| #tt-portal::after{ content:""; position:absolute; top:16px; width:12px; height:12px; transform: rotate(45deg); background: rgba(255,255,255,.92); border-left: 1px solid rgba(99,102,241,.18); border-top: 1px solid rgba(99,102,241,.18); } | |
| #tt-portal.right::after{ left:-8px; } | |
| #tt-portal.left::after{ right:-8px; left:auto; border-left:none; border-top:none; border-right:1px solid rgba(99,102,241,.18); border-bottom:1px solid rgba(99,102,241,.18); } | |
| .tt-head{ display:flex; align-items:flex-start; gap:10px; margin-bottom:10px; } | |
| .tt-dot{ width:10px; height:10px; border-radius:999px; margin-top:4px; background: radial-gradient(circle at 30% 30%, #a78bfa, #6366f1); box-shadow: 0 0 0 3px rgba(99,102,241,.12); flex:0 0 auto; } | |
| .tt-title{ font-weight:800; font-size:15px; color:#0b1220; line-height:1.25; } | |
| .tt-meta{ display:flex; gap:6px; flex-wrap:wrap; margin:2px 0 8px; } | |
| .tt-chip{ font-size:11px; color:#475569; background:#f8fafc; border:1px solid #e2e8f0; padding:4px 8px; border-radius:10px; } | |
| .tt-grid{ display:grid; grid-template-columns: 120px minmax(0,1fr); gap:8px 12px; background: linear-gradient(180deg, rgba(99,102,241,.06), rgba(99,102,241,0) 45%); border:1px dashed rgba(99,102,241,.15); border-radius:10px; padding:10px 12px; margin:8px 0; } | |
| .tt-k{ color:#334155; font-weight:600; font-size:12px; } | |
| .tt-v{ color:#0f172a; font-size:13px; } | |
| .tt-link{ display:inline-flex; align-items:center; gap:6px; margin-top:8px; color:#2563eb; text-decoration:none; font-weight:600; } | |
| .tt-link:hover{ text-decoration:underline; } | |
| #tt-portal, #tt-portal *{ box-sizing: border-box; } | |
| #tt-portal img, #tt-portal svg, #tt-portal video{ max-width:100%; height:auto; display:block; } | |
| </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() | |