# 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"""
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"""
"""
# ---------------- 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()