ravim254 commited on
Commit
e92ee1c
·
verified ·
1 Parent(s): 72e1bde

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +102 -184
app.py CHANGED
@@ -1,13 +1,14 @@
1
- # app.py — Gradio version that matches Streamlit behavior + tooltips + smart filters
2
  import pandas as pd
3
  import gradio as gr
4
  from html import escape as esc
5
  import re, json
6
 
 
 
7
  # ---------------- Load & normalize ----------------
8
  DF = pd.read_csv("offers_with_prices.csv")
9
  DF.columns = [c.strip().lower() for c in DF.columns]
10
-
11
  for col in [
12
  "country","dealer","brand","model","trim_or_variant",
13
  "starting_price","heading","offer_description",
@@ -17,8 +18,7 @@ for col in [
17
  DF[col] = ""
18
 
19
  def _first_image(val) -> str:
20
- if not val:
21
- return ""
22
  s = str(val).strip().replace("&", "&")
23
  try:
24
  if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
@@ -50,34 +50,22 @@ def _card_html(row: pd.Series) -> str:
50
  price = esc(str(row.get("starting_price","")))
51
  url = str(row.get("vehicle_offer_url","") or row.get("vehcileoffer_url","") or "#")
52
 
53
- country = esc(str(row.get("country","")))
54
- brand = esc(str(row.get("brand","")))
55
- dealer = esc(str(row.get("dealer","")))
56
- model = esc(str(row.get("model","")))
57
  trim = esc(str(row.get("trim_or_variant","")))
58
 
59
- chips = " ".join(
60
- f"<span class='chip'>{v}</span>" for v in [country, brand, dealer, model, trim] if v
61
- )
62
-
63
- # Tooltip template stored hidden; JS moves it into the portal
64
  tooltip = f"""
65
  <div class="card-tooltip-template" aria-hidden="true" style="display:none">
66
- <div class="tt-head">
67
- <span class="tt-dot"></span>
68
- <div class="tt-title">{heading}</div>
69
- </div>
70
- <div class="tt-meta">
71
- {"".join(f"<span class='tt-chip'>{x}</span>" for x in [country, brand, dealer, model, trim] if x)}
72
- </div>
73
  <div class="tt-grid">
74
  <div class="tt-k">Starting Price</div><div class="tt-v">{price or "—"}</div>
75
  <div class="tt-k">Details</div><div class="tt-v">{offer or "—"}</div>
76
  </div>
77
- {"<a class='tt-link' href='"+esc(url)+"' target='_blank' rel='noopener'>Open offer page ↗</a>" if url and url != "#" else ""}
78
  </div>
79
  """
80
-
81
  return f"""
82
  <div class="card" onclick="window.open('{url}','_blank')" tabindex="0">
83
  <div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div>
@@ -101,48 +89,38 @@ def _grid_html(df: pd.DataFrame) -> str:
101
  """
102
 
103
  # ---------------- Filtering helpers ----------------
104
- ALL = "All"
105
-
106
  def _choices(series: pd.Series):
107
  vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()]
108
  return [ALL] + sorted(vals)
109
 
110
  def _filtered_df(country: str, dealer: str, brand: str) -> pd.DataFrame:
111
  df = DF.copy()
112
- if country and country != ALL:
113
- df = df[df["country"].astype(str) == country]
114
- if dealer and dealer != ALL:
115
- df = df[df["dealer"].astype(str) == dealer]
116
- if brand and brand != ALL:
117
- df = df[df["brand"].astype(str) == brand]
118
  return df
119
 
120
- # ---------------- HTML shell (CSS + JS + content slot) ----------------
121
- SHELL_CSS = """
122
- /* Unify font everywhere */
 
123
  body, .gradio-container, .gradio-container * {
124
  font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important;
125
  }
126
-
127
  /* Single scroll region */
128
  #cards-pane { height: 820px; overflow: auto; border-radius: 12px; }
129
-
130
- /* Page background and card styling */
131
  :root{ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb; --chip:#eef2f7; --accent:#2563eb; }
132
  #cards-root{ background: var(--bg); padding: 8px; }
133
- .cards{
134
- display:grid;
135
- grid-template-columns: repeat(4, minmax(0, 1fr)); /* 4 per row on wide screens */
136
- gap:18px;
137
- }
138
- @media (max-width: 1300px){ .cards{ grid-template-columns: repeat(3, minmax(0, 1fr)); } }
139
- @media (max-width: 1000px){ .cards{ grid-template-columns: repeat(2, minmax(0, 1fr)); } }
140
- @media (max-width: 640px){ .cards{ grid-template-columns: 1fr; } }
141
 
142
  .card{
143
  background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden;
144
- box-shadow:0 4px 12px rgba(0,0,0,.06); cursor:pointer;
145
- display:flex; flex-direction:column; transition: transform .08s ease, box-shadow .2s ease;
146
  }
147
  .card:hover{ transform: translateY(-2px); box-shadow:0 8px 20px rgba(0,0,0,.12); }
148
  .thumb{ height:160px; overflow:hidden; background:#000; }
@@ -156,24 +134,17 @@ body, .gradio-container, .gradio-container * {
156
  .meta{ display:flex; gap:6px; flex-wrap:wrap; padding:0 12px 12px; margin-top:auto; }
157
  .chip{ font-size:11px; color:#374151; background:var(--chip); border-radius:999px; padding:4px 8px; }
158
 
159
- /* Tooltip portal (fixed within cards-root container) */
160
  #tt-portal{
161
  position: fixed; top: 0; left: 0; z-index: 2147483647; display: none;
162
- width: min(520px, 86vw); max-height: 72vh;
163
- overflow-x: hidden; overflow-y: auto;
164
- white-space: normal; overflow-wrap: anywhere; word-break: break-word;
165
-
166
  background: rgba(255,255,255,.92);
167
- -webkit-backdrop-filter: saturate(1.4) blur(8px);
168
- backdrop-filter: saturate(1.4) blur(8px);
169
-
170
  border: 1px solid transparent; border-radius: 14px; box-shadow: 0 18px 50px rgba(2,6,23,.18);
171
  padding: 14px 16px; color: #0f172a; font-size: 13px; line-height: 1.5;
172
-
173
  background-clip: padding-box, border-box;
174
- background-image:
175
- linear-gradient(rgba(255,255,255,.92), rgba(255,255,255,.92)),
176
- linear-gradient(135deg, rgba(99,102,241,.25), rgba(99,102,241,.05));
177
  }
178
  #tt-portal.show { display:block; animation: tt-fade .16s ease-out; }
179
  @keyframes tt-fade { from { opacity:0; transform: translateY(-2px); } to { opacity:1; transform: translateY(0); } }
@@ -186,60 +157,57 @@ body, .gradio-container, .gradio-container * {
186
  #tt-portal.left::after{ right:-8px; left:auto; border-left:none; border-top:none;
187
  border-right:1px solid rgba(99,102,241,.18); border-bottom:1px solid rgba(99,102,241,.18); }
188
 
189
- /* Tooltip inner content */
190
  .tt-head{ display:flex; align-items:flex-start; gap:10px; margin-bottom:10px; }
191
  .tt-dot{ width:10px; height:10px; border-radius:999px; margin-top:4px;
192
  background: radial-gradient(circle at 30% 30%, #a78bfa, #6366f1); box-shadow: 0 0 0 3px rgba(99,102,241,.12); flex:0 0 auto; }
193
  .tt-title{ font-weight:800; font-size:15px; color:#0b1220; line-height:1.25; }
194
  .tt-meta{ display:flex; gap:6px; flex-wrap:wrap; margin:2px 0 8px; }
195
  .tt-chip{ font-size:11px; color:#475569; background:#f8fafc; border:1px solid #e2e8f0; padding:4px 8px; border-radius:10px; }
196
- .tt-grid{
197
- display:grid; grid-template-columns: 120px minmax(0,1fr); gap:8px 12px;
198
  background: linear-gradient(180deg, rgba(99,102,241,.06), rgba(99,102,241,0) 45%);
199
- border:1px dashed rgba(99,102,241,.15); border-radius:10px; padding:10px 12px; margin:8px 0;
200
- }
201
  .tt-k{ color:#334155; font-weight:600; font-size:12px; }
202
  .tt-v{ color:#0f172a; font-size:13px; }
203
- .tt-link{ display:inline-flex; align-items:center; gap:6px; margin-top:8px; color:var(--accent); text-decoration:none; font-weight:600; }
204
  .tt-link:hover{ text-decoration:underline; }
205
 
206
- /* Avoid horizontal scroll in tooltip */
207
  #tt-portal, #tt-portal *{ box-sizing: border-box; }
208
  #tt-portal img, #tt-portal svg, #tt-portal video{ max-width:100%; height:auto; display:block; }
 
209
  """
210
 
211
- SHELL_JS = """
212
- (function(){
213
- // Tooltip portal logic (hover on desktop; click/tap toggles on mobile)
214
- const root = document.getElementById('cards-root');
215
- if(!root) return;
216
- let portal = document.getElementById('tt-portal');
217
- if(!portal){ portal = document.createElement('div'); portal.id = 'tt-portal'; root.prepend(portal); }
 
 
 
 
 
 
218
 
219
  let activeCard = null, overPortal = false, hideTimer = null;
220
-
221
- function clamp(n,min,max){ return Math.max(min, Math.min(max, n)); }
222
 
223
  function placePortal(anchor, side){
224
  const a = anchor.getBoundingClientRect();
225
  const p = portal.getBoundingClientRect();
226
  const vw = window.innerWidth, vh = window.innerHeight;
227
- const gap = 14;
228
- let left = a.right + gap;
229
  portal.classList.remove('left','right');
230
 
231
  if (side === 'left' || left + p.width > vw - 6){
232
- left = Math.max(6, a.left - gap - p.width);
233
- portal.classList.add('left');
234
  } else {
235
- left = Math.min(left, vw - 6 - p.width);
236
- portal.classList.add('right');
237
  }
238
-
239
  const topDesired = a.top + 10;
240
  const top = clamp(topDesired, 6, vh - p.height - 6);
241
- portal.style.left = left + 'px';
242
- portal.style.top = top + 'px';
243
  }
244
 
245
  function showFor(card, prefer='right'){
@@ -256,7 +224,6 @@ SHELL_JS = """
256
  });
257
  card.classList.add('active');
258
  }
259
-
260
  function hideSoon(){
261
  clearTimeout(hideTimer);
262
  hideTimer = setTimeout(()=>{
@@ -264,32 +231,23 @@ SHELL_JS = """
264
  if(!overPortal && !stillOnCard){ hideNow(); }
265
  }, 120);
266
  }
267
-
268
  function hideNow(){
269
  portal.classList.remove('show');
270
  if(activeCard){ activeCard.classList.remove('active'); activeCard = null; }
271
  }
272
 
273
- function bindCard(card){
274
- // Desktop hover
275
- card.addEventListener('mouseenter', ()=>showFor(card));
276
- card.addEventListener('mouseleave', hideSoon);
277
- // Focus (keyboard)
278
- card.addEventListener('focusin', ()=>showFor(card));
279
- card.addEventListener('focusout', hideSoon);
280
- // Mobile / click toggle
281
- card.addEventListener('click', (e)=>{
282
- // If already showing for this card, close
283
  if(activeCard === card){ hideNow(); e.stopPropagation(); return; }
284
- showFor(card);
285
- e.stopPropagation();
286
- });
287
- }
288
-
289
- // Bind all cards
290
- root.querySelectorAll('.card').forEach(bindCard);
291
 
292
- // Keep positioned
293
  function maybeReposition(){
294
  if(!portal.classList.contains('show') || !activeCard) return;
295
  const side = portal.classList.contains('left') ? 'left' : 'right';
@@ -298,71 +256,38 @@ SHELL_JS = """
298
  window.addEventListener('scroll', maybeReposition, {passive:true});
299
  window.addEventListener('resize', maybeReposition, {passive:true});
300
 
301
- // Keep open when hovered
302
- portal.addEventListener('mouseenter', ()=>{ overPortal = true; clearTimeout(hideTimer); });
303
- portal.addEventListener('mouseleave', ()=>{ overPortal = false; hideSoon(); });
304
 
305
- // Clicking anywhere else closes
306
- document.addEventListener('click', (e)=>{
307
- if(!portal.contains(e.target)){
308
- hideNow();
309
- }
310
- });
311
- })();
312
  """
313
 
314
- def _page_html(df_slice: pd.DataFrame) -> str:
315
- return f"""
316
- <html>
317
- <head>
318
- <meta charset="utf-8" />
319
- <style>{SHELL_CSS}</style>
320
- </head>
321
- <body>
322
- {_grid_html(df_slice)}
323
- <script>{SHELL_JS}</script>
324
- </body>
325
- </html>
326
- """
327
-
328
- # ---------------- Rendered content ----------------
329
  def render_cards(country: str, dealer: str, brand: str, limit: int):
330
  df = _filtered_df(country, dealer, brand).head(int(limit))
331
- return _page_html(df)
332
 
333
- # ---------------- Cascading updates ----------------
334
  def on_country_change(country, dealer, brand, limit):
335
- # When country changes → reset dealer & brand to All
336
  df_c = _filtered_df(country, ALL, ALL)
337
- dealer_choices = _choices(df_c["dealer"])
338
- brand_choices = _choices(df_c["brand"])
339
-
340
- dealer_val = ALL
341
- brand_val = ALL
342
  html = render_cards(country, dealer_val, brand_val, limit)
343
-
344
- return (
345
- gr.update(choices=dealer_choices, value=dealer_val),
346
- gr.update(choices=brand_choices, value=brand_val),
347
- html
348
- )
349
 
350
  def on_dealer_change(country, dealer, brand, limit):
351
- # When dealer changes → narrow brands within country+dealer
352
  df_cd = _filtered_df(country, dealer, ALL)
353
  brand_choices = _choices(df_cd["brand"])
354
- # If current brand not valid under new dealer, reset to All
355
- brand_val = brand if (brand in brand_choices) else ALL
356
-
357
  html = render_cards(country, dealer, brand_val, limit)
358
  return gr.update(choices=brand_choices, value=brand_val), html
359
 
360
  def on_brand_change(country, dealer, brand, limit):
361
- # Bidirectional: if brand chosen → narrow dealers within country+brand
362
  df_cb = _filtered_df(country, ALL, brand)
363
  dealer_choices = _choices(df_cb["dealer"])
364
- dealer_val = dealer if (dealer in dealer_choices) else ALL
365
-
366
  html = render_cards(country, dealer_val, brand, limit)
367
  return gr.update(choices=dealer_choices, value=dealer_val), html
368
 
@@ -372,15 +297,12 @@ def on_limit_change(country, dealer, brand, limit):
372
  # ---------------- Build Gradio UI ----------------
373
  def app():
374
  countries = _choices(DF["country"])
375
- # default All for others; will be updated by country change
376
  dealers = _choices(DF["dealer"])
377
  brands = _choices(DF["brand"])
378
 
379
- with gr.Blocks(title="Vehicle Offers", css="""
380
- /* keep page itself from scrolling too much; use cards-pane */
381
- .gradio-container { max-width: 1400px; margin: 0 auto; }
382
- """) as demo:
383
  gr.Markdown("## 🚗 Vehicle Offers")
 
384
 
385
  with gr.Row():
386
  dd_country = gr.Dropdown(choices=countries, value=ALL, label="Country")
@@ -390,36 +312,32 @@ def app():
390
 
391
  out_html = gr.HTML(elem_id="cards-pane")
392
 
393
- # Initial render
394
- demo.load(render_cards, [dd_country, dd_dealer, dd_brand, s_limit], out_html)
395
-
396
- # Country change → reset dealer & brand, refresh HTML
397
- dd_country.change(
398
- on_country_change,
399
- [dd_country, dd_dealer, dd_brand, s_limit],
400
- [dd_dealer, dd_brand, out_html]
401
- )
402
-
403
- # Dealer change → update brand list (country+dealer), refresh HTML
404
- dd_dealer.change(
405
- on_dealer_change,
406
- [dd_country, dd_dealer, dd_brand, s_limit],
407
- [dd_brand, out_html]
408
- )
409
-
410
- # Brand change → update dealer list (country+brand), refresh HTML
411
- dd_brand.change(
412
- on_brand_change,
413
- [dd_country, dd_dealer, dd_brand, s_limit],
414
- [dd_dealer, out_html]
415
- )
416
-
417
- # Limit change → refresh HTML only
418
- s_limit.change(
419
- on_limit_change,
420
- [dd_country, dd_dealer, dd_brand, s_limit],
421
- out_html
422
- )
423
 
424
  return demo
425
 
 
1
+ # app.py — Gradio + tooltips (executed via js=), smart cascading filters, aligned cards
2
  import pandas as pd
3
  import gradio as gr
4
  from html import escape as esc
5
  import re, json
6
 
7
+ ALL = "All"
8
+
9
  # ---------------- Load & normalize ----------------
10
  DF = pd.read_csv("offers_with_prices.csv")
11
  DF.columns = [c.strip().lower() for c in DF.columns]
 
12
  for col in [
13
  "country","dealer","brand","model","trim_or_variant",
14
  "starting_price","heading","offer_description",
 
18
  DF[col] = ""
19
 
20
  def _first_image(val) -> str:
21
+ if not val: return ""
 
22
  s = str(val).strip().replace("&amp;", "&")
23
  try:
24
  if s[:1] in "[{" or (s[:1] == '"' and s[-1:] == '"'):
 
50
  price = esc(str(row.get("starting_price","")))
51
  url = str(row.get("vehicle_offer_url","") or row.get("vehcileoffer_url","") or "#")
52
 
53
+ country = esc(str(row.get("country",""))); brand = esc(str(row.get("brand","")))
54
+ dealer = esc(str(row.get("dealer",""))); model = esc(str(row.get("model","")))
 
 
55
  trim = esc(str(row.get("trim_or_variant","")))
56
 
57
+ chips = " ".join(f"<span class='chip'>{v}</span>" for v in [country, brand, dealer, model, trim] if v)
 
 
 
 
58
  tooltip = f"""
59
  <div class="card-tooltip-template" aria-hidden="true" style="display:none">
60
+ <div class="tt-head"><span class="tt-dot"></span><div class="tt-title">{heading}</div></div>
61
+ <div class="tt-meta">{"".join(f"<span class='tt-chip'>{x}</span>" for x in [country,brand,dealer,model,trim] if x)}</div>
 
 
 
 
 
62
  <div class="tt-grid">
63
  <div class="tt-k">Starting Price</div><div class="tt-v">{price or "—"}</div>
64
  <div class="tt-k">Details</div><div class="tt-v">{offer or "—"}</div>
65
  </div>
66
+ {"<a class='tt-link' href='"+esc(url)+"' target='_blank' rel='noopener'>Open offer ↗</a>" if url and url != "#" else ""}
67
  </div>
68
  """
 
69
  return f"""
70
  <div class="card" onclick="window.open('{url}','_blank')" tabindex="0">
71
  <div class="thumb"><img src="{img}" alt="{heading}" onerror="this.style.opacity=0.2"></div>
 
89
  """
90
 
91
  # ---------------- Filtering helpers ----------------
 
 
92
  def _choices(series: pd.Series):
93
  vals = [x for x in series.dropna().astype(str).unique() if str(x).strip()]
94
  return [ALL] + sorted(vals)
95
 
96
  def _filtered_df(country: str, dealer: str, brand: str) -> pd.DataFrame:
97
  df = DF.copy()
98
+ if country and country != ALL: df = df[df["country"].astype(str) == country]
99
+ if dealer and dealer != ALL: df = df[df["dealer"].astype(str) == dealer]
100
+ if brand and brand != ALL: df = df[df["brand"].astype(str) == brand]
 
 
 
101
  return df
102
 
103
+ # ---------------- CSS (injected once as a separate HTML) ----------------
104
+ GLOBAL_STYLE = """
105
+ <style>
106
+ /* Unify font */
107
  body, .gradio-container, .gradio-container * {
108
  font-family: Inter, Segoe UI, Roboto, Helvetica, Arial, sans-serif !important;
109
  }
 
110
  /* Single scroll region */
111
  #cards-pane { height: 820px; overflow: auto; border-radius: 12px; }
112
+ /* Colors + layout */
 
113
  :root{ --bg:#f6f7fb; --card:#ffffff; --ink:#111827; --muted:#6b7280; --line:#e5e7eb; --chip:#eef2f7; --accent:#2563eb; }
114
  #cards-root{ background: var(--bg); padding: 8px; }
115
+ .cards{ display:grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap:18px; }
116
+ @media (max-width:1300px){ .cards{ grid-template-columns: repeat(3, minmax(0, 1fr)); } }
117
+ @media (max-width:1000px){ .cards{ grid-template-columns: repeat(2, minmax(0, 1fr)); } }
118
+ @media (max-width:640px){ .cards{ grid-template-columns: 1fr; } }
 
 
 
 
119
 
120
  .card{
121
  background:var(--card); border:1px solid var(--line); border-radius:16px; overflow:hidden;
122
+ box-shadow:0 4px 12px rgba(0,0,0,.06); cursor:pointer; display:flex; flex-direction:column;
123
+ transition: transform .08s ease, box-shadow .2s ease;
124
  }
125
  .card:hover{ transform: translateY(-2px); box-shadow:0 8px 20px rgba(0,0,0,.12); }
126
  .thumb{ height:160px; overflow:hidden; background:#000; }
 
134
  .meta{ display:flex; gap:6px; flex-wrap:wrap; padding:0 12px 12px; margin-top:auto; }
135
  .chip{ font-size:11px; color:#374151; background:var(--chip); border-radius:999px; padding:4px 8px; }
136
 
137
+ /* Tooltip portal */
138
  #tt-portal{
139
  position: fixed; top: 0; left: 0; z-index: 2147483647; display: none;
140
+ width: min(520px, 86vw); max-height: 72vh; overflow-x: hidden; overflow-y: auto;
 
 
 
141
  background: rgba(255,255,255,.92);
142
+ -webkit-backdrop-filter: saturate(1.4) blur(8px); backdrop-filter: saturate(1.4) blur(8px);
 
 
143
  border: 1px solid transparent; border-radius: 14px; box-shadow: 0 18px 50px rgba(2,6,23,.18);
144
  padding: 14px 16px; color: #0f172a; font-size: 13px; line-height: 1.5;
 
145
  background-clip: padding-box, border-box;
146
+ background-image: linear-gradient(rgba(255,255,255,.92), rgba(255,255,255,.92)),
147
+ linear-gradient(135deg, rgba(99,102,241,.25), rgba(99,102,241,.05));
 
148
  }
149
  #tt-portal.show { display:block; animation: tt-fade .16s ease-out; }
150
  @keyframes tt-fade { from { opacity:0; transform: translateY(-2px); } to { opacity:1; transform: translateY(0); } }
 
157
  #tt-portal.left::after{ right:-8px; left:auto; border-left:none; border-top:none;
158
  border-right:1px solid rgba(99,102,241,.18); border-bottom:1px solid rgba(99,102,241,.18); }
159
 
 
160
  .tt-head{ display:flex; align-items:flex-start; gap:10px; margin-bottom:10px; }
161
  .tt-dot{ width:10px; height:10px; border-radius:999px; margin-top:4px;
162
  background: radial-gradient(circle at 30% 30%, #a78bfa, #6366f1); box-shadow: 0 0 0 3px rgba(99,102,241,.12); flex:0 0 auto; }
163
  .tt-title{ font-weight:800; font-size:15px; color:#0b1220; line-height:1.25; }
164
  .tt-meta{ display:flex; gap:6px; flex-wrap:wrap; margin:2px 0 8px; }
165
  .tt-chip{ font-size:11px; color:#475569; background:#f8fafc; border:1px solid #e2e8f0; padding:4px 8px; border-radius:10px; }
166
+ .tt-grid{ display:grid; grid-template-columns: 120px minmax(0,1fr); gap:8px 12px;
 
167
  background: linear-gradient(180deg, rgba(99,102,241,.06), rgba(99,102,241,0) 45%);
168
+ border:1px dashed rgba(99,102,241,.15); border-radius:10px; padding:10px 12px; margin:8px 0; }
 
169
  .tt-k{ color:#334155; font-weight:600; font-size:12px; }
170
  .tt-v{ color:#0f172a; font-size:13px; }
171
+ .tt-link{ display:inline-flex; align-items:center; gap:6px; margin-top:8px; color:#2563eb; text-decoration:none; font-weight:600; }
172
  .tt-link:hover{ text-decoration:underline; }
173
 
 
174
  #tt-portal, #tt-portal *{ box-sizing: border-box; }
175
  #tt-portal img, #tt-portal svg, #tt-portal video{ max-width:100%; height:auto; display:block; }
176
+ </style>
177
  """
178
 
179
+ # ---------------- JS to bind tooltips (executed via js= on every update) ----------------
180
+ TOOLTIP_BIND_JS = r"""
181
+ () => {
182
+ const root = document.querySelector('#cards-pane #cards-root') || document.querySelector('#cards-root');
183
+ if (!root) return;
184
+
185
+ // Prepare portal
186
+ let portal = root.querySelector('#tt-portal') || document.getElementById('tt-portal');
187
+ if (!portal) {
188
+ portal = document.createElement('div');
189
+ portal.id = 'tt-portal';
190
+ root.prepend(portal);
191
+ }
192
 
193
  let activeCard = null, overPortal = false, hideTimer = null;
194
+ const clamp = (n,min,max)=> Math.max(min, Math.min(max, n));
 
195
 
196
  function placePortal(anchor, side){
197
  const a = anchor.getBoundingClientRect();
198
  const p = portal.getBoundingClientRect();
199
  const vw = window.innerWidth, vh = window.innerHeight;
200
+ const gap = 14; let left = a.right + gap;
 
201
  portal.classList.remove('left','right');
202
 
203
  if (side === 'left' || left + p.width > vw - 6){
204
+ left = Math.max(6, a.left - gap - p.width); portal.classList.add('left');
 
205
  } else {
206
+ left = Math.min(left, vw - 6 - p.width); portal.classList.add('right');
 
207
  }
 
208
  const topDesired = a.top + 10;
209
  const top = clamp(topDesired, 6, vh - p.height - 6);
210
+ portal.style.left = left + 'px'; portal.style.top = top + 'px';
 
211
  }
212
 
213
  function showFor(card, prefer='right'){
 
224
  });
225
  card.classList.add('active');
226
  }
 
227
  function hideSoon(){
228
  clearTimeout(hideTimer);
229
  hideTimer = setTimeout(()=>{
 
231
  if(!overPortal && !stillOnCard){ hideNow(); }
232
  }, 120);
233
  }
 
234
  function hideNow(){
235
  portal.classList.remove('show');
236
  if(activeCard){ activeCard.classList.remove('active'); activeCard = null; }
237
  }
238
 
239
+ // (Re)bind all cards each time this JS runs
240
+ root.querySelectorAll('.card').forEach(card=>{
241
+ card.onmouseenter = ()=>showFor(card);
242
+ card.onmouseleave = hideSoon;
243
+ card.onfocusin = ()=>showFor(card);
244
+ card.onfocusout = hideSoon;
245
+ card.onclick = (e)=>{
 
 
 
246
  if(activeCard === card){ hideNow(); e.stopPropagation(); return; }
247
+ showFor(card); e.stopPropagation();
248
+ };
249
+ });
 
 
 
 
250
 
 
251
  function maybeReposition(){
252
  if(!portal.classList.contains('show') || !activeCard) return;
253
  const side = portal.classList.contains('left') ? 'left' : 'right';
 
256
  window.addEventListener('scroll', maybeReposition, {passive:true});
257
  window.addEventListener('resize', maybeReposition, {passive:true});
258
 
259
+ portal.onmouseenter = ()=>{ overPortal = true; clearTimeout(hideTimer); };
260
+ portal.onmouseleave = ()=>{ overPortal = false; hideSoon(); };
261
+ document.addEventListener('click', (e)=>{ if(!portal.contains(e.target)) hideNow(); }, {once:true});
262
 
263
+ // Done
264
+ return null;
265
+ }
 
 
 
 
266
  """
267
 
268
+ # ---------------- Render functions ----------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
  def render_cards(country: str, dealer: str, brand: str, limit: int):
270
  df = _filtered_df(country, dealer, brand).head(int(limit))
271
+ return _grid_html(df)
272
 
 
273
  def on_country_change(country, dealer, brand, limit):
 
274
  df_c = _filtered_df(country, ALL, ALL)
275
+ dealer_choices = _choices(df_c["dealer"]); brand_choices = _choices(df_c["brand"])
276
+ dealer_val = brand_val = ALL
 
 
 
277
  html = render_cards(country, dealer_val, brand_val, limit)
278
+ return gr.update(choices=dealer_choices, value=dealer_val), gr.update(choices=brand_choices, value=brand_val), html
 
 
 
 
 
279
 
280
  def on_dealer_change(country, dealer, brand, limit):
 
281
  df_cd = _filtered_df(country, dealer, ALL)
282
  brand_choices = _choices(df_cd["brand"])
283
+ brand_val = brand if brand in brand_choices else ALL
 
 
284
  html = render_cards(country, dealer, brand_val, limit)
285
  return gr.update(choices=brand_choices, value=brand_val), html
286
 
287
  def on_brand_change(country, dealer, brand, limit):
 
288
  df_cb = _filtered_df(country, ALL, brand)
289
  dealer_choices = _choices(df_cb["dealer"])
290
+ dealer_val = dealer if dealer in dealer_choices else ALL
 
291
  html = render_cards(country, dealer_val, brand, limit)
292
  return gr.update(choices=dealer_choices, value=dealer_val), html
293
 
 
297
  # ---------------- Build Gradio UI ----------------
298
  def app():
299
  countries = _choices(DF["country"])
 
300
  dealers = _choices(DF["dealer"])
301
  brands = _choices(DF["brand"])
302
 
303
+ with gr.Blocks(title="Vehicle Offers", css=".gradio-container{max-width:1400px;margin:0 auto;}") as demo:
 
 
 
304
  gr.Markdown("## 🚗 Vehicle Offers")
305
+ gr.HTML(GLOBAL_STYLE) # inject styles once
306
 
307
  with gr.Row():
308
  dd_country = gr.Dropdown(choices=countries, value=ALL, label="Country")
 
312
 
313
  out_html = gr.HTML(elem_id="cards-pane")
314
 
315
+ # Initial render + bind tooltips via JS
316
+ demo.load(render_cards, [dd_country, dd_dealer, dd_brand, s_limit], out_html, js=TOOLTIP_BIND_JS)
317
+
318
+ # Country change → reset dealer & brand + rebind JS
319
+ dd_country.change(on_country_change,
320
+ [dd_country, dd_dealer, dd_brand, s_limit],
321
+ [dd_dealer, dd_brand, out_html],
322
+ js=TOOLTIP_BIND_JS)
323
+
324
+ # Dealer change → update brand + rebind JS
325
+ dd_dealer.change(on_dealer_change,
326
+ [dd_country, dd_dealer, dd_brand, s_limit],
327
+ [dd_brand, out_html],
328
+ js=TOOLTIP_BIND_JS)
329
+
330
+ # Brand change → update dealer + rebind JS
331
+ dd_brand.change(on_brand_change,
332
+ [dd_country, dd_dealer, dd_brand, s_limit],
333
+ [dd_dealer, out_html],
334
+ js=TOOLTIP_BIND_JS)
335
+
336
+ # Limit change → just re-render + rebind JS
337
+ s_limit.change(on_limit_change,
338
+ [dd_country, dd_dealer, dd_brand, s_limit],
339
+ out_html,
340
+ js=TOOLTIP_BIND_JS)
 
 
 
 
341
 
342
  return demo
343