Prathamesh Bhamare commited on
Commit
e1bc923
·
1 Parent(s): 08e36e8

Added Head-to-Head Compare UI and Endpoint

Browse files
Files changed (3) hide show
  1. api/main.py +126 -0
  2. frontend/app/page.js +188 -55
  3. frontend/app/page.module.css +89 -0
api/main.py CHANGED
@@ -628,6 +628,132 @@ async def root():
628
  "health": "/health",
629
  }
630
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
 
632
  # ============================================================================
633
  # Error handlers
 
628
  "health": "/health",
629
  }
630
 
631
+ @app.post(
632
+ "/predict/compare",
633
+ response_model=CompareResponse,
634
+ responses={
635
+ 400: {"model": ErrorResponse, "description": "Invalid query or driver not found"},
636
+ 503: {"model": ErrorResponse, "description": "Model not loaded"},
637
+ },
638
+ )
639
+ async def predict_compare(request: CompareRequest) -> CompareResponse:
640
+ """
641
+ Head-to-Head Driver Comparison.
642
+ """
643
+ if _model is None:
644
+ raise HTTPException(
645
+ status_code=503,
646
+ detail="Model not loaded. Set KRONECTOR_MODEL_RUN_ID.",
647
+ )
648
+
649
+ if _races_data is None and not _prerace_data:
650
+ raise HTTPException(
651
+ status_code=503,
652
+ detail="Race data not loaded. Check data_output/fastf1_races.parquet",
653
+ )
654
+
655
+ season = request.season
656
+ round_num = request.round
657
+
658
+ # If not provided, find the latest pre-race data or historical data
659
+ if not season or not round_num:
660
+ if _prerace_data:
661
+ latest_key = list(_prerace_data.keys())[-1]
662
+ season, round_num = map(int, latest_key.split("_"))
663
+ elif _races_data is not None:
664
+ season = int(_races_data["season"].max())
665
+ round_num = int(_races_data[_races_data["season"] == season]["round"].max())
666
+
667
+ df = pd.DataFrame()
668
+ key = f"{season}_{round_num}"
669
+ if _prerace_data and key in _prerace_data:
670
+ df = _prerace_data[key].copy()
671
+ elif _races_data is not None:
672
+ df = _races_data[(_races_data["season"] == season) & (_races_data["round"] == round_num)].copy()
673
+
674
+ if df.empty:
675
+ raise HTTPException(
676
+ status_code=400,
677
+ detail=f"No data for season {season} round {round_num}"
678
+ )
679
+
680
+ # Find driver 1 and driver 2
681
+ def find_driver(driver_str: str, data: pd.DataFrame):
682
+ driver_str = driver_str.lower()
683
+ match = data[data["driver_id"].str.lower() == driver_str]
684
+ if match.empty:
685
+ match = data[data["driver_name"].str.lower().str.contains(driver_str)]
686
+ return match
687
+
688
+ d1_df = find_driver(request.driver1, df)
689
+ d2_df = find_driver(request.driver2, df)
690
+
691
+ if d1_df.empty:
692
+ raise HTTPException(status_code=400, detail=f"Driver 1 '{request.driver1}' not found in race")
693
+ if d2_df.empty:
694
+ raise HTTPException(status_code=400, detail=f"Driver 2 '{request.driver2}' not found in race")
695
+
696
+ d1_row = d1_df.iloc[[0]]
697
+ d2_row = d2_df.iloc[[0]]
698
+
699
+ # Generate predictions
700
+ from ml.predict import predict_dataframe
701
+ d1_pred = predict_dataframe(d1_row, _model, _encoders, explain=True)
702
+ d2_pred = predict_dataframe(d2_row, _model, _encoders, explain=True)
703
+
704
+ d1_prob = float(d1_pred["win_probability"].iloc[0])
705
+ d2_prob = float(d2_pred["win_probability"].iloc[0])
706
+
707
+ d1_shap = d1_pred["shap_values"].iloc[0]
708
+ d2_shap = d2_pred["shap_values"].iloc[0]
709
+
710
+ # Calculate SHAP deltas (D1 - D2)
711
+ shap_deltas = {}
712
+ for feature in d1_shap.keys():
713
+ shap_deltas[feature] = float(d1_shap[feature] - d2_shap.get(feature, 0.0))
714
+
715
+ d1_name = str(d1_row["driver_name"].iloc[0])
716
+ d2_name = str(d2_row["driver_name"].iloc[0])
717
+
718
+ # Get quali status if available
719
+ d1_status = str(d1_row.get("quali_status", pd.Series(["Unknown"])).iloc[0]) if "quali_status" in d1_row.columns else "Unknown"
720
+ d2_status = str(d2_row.get("quali_status", pd.Series(["Unknown"])).iloc[0]) if "quali_status" in d2_row.columns else "Unknown"
721
+
722
+ circuit = str(df["circuit_id"].iloc[0])
723
+ race_context = f"{season} Round {round_num} at {circuit}"
724
+
725
+ from agents.compare_agent import compare_agent
726
+ llm_analysis = compare_agent(
727
+ driver1_name=d1_name,
728
+ driver1_prob=d1_prob * 100,
729
+ driver1_status=d1_status,
730
+ driver2_name=d2_name,
731
+ driver2_prob=d2_prob * 100,
732
+ driver2_status=d2_status,
733
+ shap_deltas=shap_deltas,
734
+ race_context=race_context
735
+ )
736
+
737
+ return CompareResponse(
738
+ driver1=PredictionMetadata(
739
+ season=season, round=round_num,
740
+ driver_id=str(d1_row["driver_id"].iloc[0]),
741
+ driver_name=d1_name,
742
+ team=str(d1_row["team"].iloc[0]),
743
+ grid_position=float(d1_row["grid_position"].iloc[0])
744
+ ),
745
+ driver2=PredictionMetadata(
746
+ season=season, round=round_num,
747
+ driver_id=str(d2_row["driver_id"].iloc[0]),
748
+ driver_name=d2_name,
749
+ team=str(d2_row["team"].iloc[0]),
750
+ grid_position=float(d2_row["grid_position"].iloc[0])
751
+ ),
752
+ driver1_win_probability=d1_prob,
753
+ driver2_win_probability=d2_prob,
754
+ shap_deltas=shap_deltas,
755
+ llm_analysis=llm_analysis
756
+ )
757
 
758
  # ============================================================================
759
  # Error handlers
frontend/app/page.js CHANGED
@@ -4,34 +4,53 @@ import { useState } from 'react';
4
  import styles from './page.module.css';
5
 
6
  export default function Home() {
 
 
 
7
  const [query, setQuery] = useState('');
 
 
 
 
 
 
 
8
  const [loading, setLoading] = useState(false);
9
  const [result, setResult] = useState(null);
10
  const [error, setError] = useState(null);
11
 
12
- const handleSubmit = async (e) => {
13
  e.preventDefault();
14
  if (!query.trim()) return;
 
 
 
 
 
 
 
 
 
 
 
15
 
 
16
  setLoading(true);
17
  setError(null);
18
  setResult(null);
19
-
20
  try {
21
- const res = await fetch('https://prats010-kronector.hf.space/predict/f1', {
22
  method: 'POST',
23
- headers: {
24
- 'Content-Type': 'application/json',
25
- },
26
- body: JSON.stringify({ query }),
27
  });
28
 
29
  if (!res.ok) {
30
- throw new Error(`API Error: ${res.status}`);
 
31
  }
32
-
33
  const data = await res.json();
34
- setResult(data);
35
  } catch (err) {
36
  setError(err.message || 'Failed to fetch prediction. Please try again.');
37
  } finally {
@@ -39,6 +58,41 @@ export default function Home() {
39
  }
40
  };
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  return (
43
  <main className={styles.main}>
44
  <div className={styles.header}>
@@ -46,29 +100,69 @@ export default function Home() {
46
  <p className={styles.subtitle}>F1 Intelligence Terminal. Powered by LLMs and LightGBM.</p>
47
  </div>
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  <div className={styles.queryContainer}>
50
- <form onSubmit={handleSubmit} className={styles.inputWrapper}>
51
- <input
52
- type="text"
53
- className={styles.input}
54
- placeholder="Ask anything (e.g., 'What's Max Verstappen's win probability at Monaco 2023?')"
55
- value={query}
56
- onChange={(e) => setQuery(e.target.value)}
57
- disabled={loading}
58
- />
59
- <button type="submit" className={styles.submitBtn} disabled={loading || !query.trim()}>
60
- {loading ? <div className={styles.loader} /> : (
61
- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
62
- <line x1="22" y1="2" x2="11" y2="13"></line>
63
- <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
64
- </svg>
65
- )}
66
- </button>
67
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  {error && <div style={{ color: 'var(--neon-red)', marginTop: '1rem', textAlign: 'center' }}>{error}</div>}
69
  </div>
70
 
71
- {result && (
72
  <div className={`${styles.dashboardGrid} animate-fade-in-up`}>
73
  {/* Left Column: Gauge */}
74
  <div className={`${styles.panel} glass-panel`}>
@@ -114,35 +208,74 @@ export default function Home() {
114
  Key Factors (SHAP)
115
  </h2>
116
  <div className={styles.shapContainer}>
117
- {result.shap_values && Object.entries(result.shap_values)
118
- .sort(([,a], [,b]) => Math.abs(b) - Math.abs(a))
119
- .slice(0, 6)
120
- .map(([key, value]) => {
121
- const absVal = Math.abs(value);
122
- const isPositive = value > 0;
123
- // Normalize to max 50% width
124
- const width = Math.min((absVal / 2) * 100, 50);
125
-
126
- return (
127
- <div key={key} className={styles.shapRow}>
128
- <div className={styles.shapLabel} title={key}>
129
- {key.replace(/_/g, ' ')}
130
- </div>
131
- <div className={styles.barTrack}>
132
- <div
133
- className={`${styles.barFill} ${isPositive ? styles.barPositive : styles.barNegative}`}
134
- style={{ width: `${width}%` }}
135
- />
136
- </div>
137
- <div className={`${styles.shapValue} ${isPositive ? 'text-gradient' : ''}`} style={!isPositive ? {color: 'var(--neon-red)'} : {}}>
138
- {value > 0 ? '+' : ''}{value.toFixed(3)}
139
- </div>
140
- </div>
141
- );
142
- })}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  </div>
144
  </div>
145
  </div>
 
146
  </div>
147
  )}
148
  </main>
 
4
  import styles from './page.module.css';
5
 
6
  export default function Home() {
7
+ const [mode, setMode] = useState('predict'); // 'predict' | 'compare'
8
+
9
+ // Single predict state
10
  const [query, setQuery] = useState('');
11
+
12
+ // Compare state
13
+ const [driver1, setDriver1] = useState('');
14
+ const [driver2, setDriver2] = useState('');
15
+ const [season, setSeason] = useState('');
16
+ const [round, setRound] = useState('');
17
+
18
  const [loading, setLoading] = useState(false);
19
  const [result, setResult] = useState(null);
20
  const [error, setError] = useState(null);
21
 
22
+ const handleSubmitPredict = async (e) => {
23
  e.preventDefault();
24
  if (!query.trim()) return;
25
+ await fetchResult('predict/f1', { query });
26
+ };
27
+
28
+ const handleSubmitCompare = async (e) => {
29
+ e.preventDefault();
30
+ if (!driver1.trim() || !driver2.trim()) return;
31
+ const body = { driver1, driver2 };
32
+ if (season) body.season = parseInt(season);
33
+ if (round) body.round = parseInt(round);
34
+ await fetchResult('predict/compare', body);
35
+ };
36
 
37
+ const fetchResult = async (endpoint, body) => {
38
  setLoading(true);
39
  setError(null);
40
  setResult(null);
 
41
  try {
42
+ const res = await fetch(`https://prats010-kronector.hf.space/${endpoint}`, {
43
  method: 'POST',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify(body),
 
 
46
  });
47
 
48
  if (!res.ok) {
49
+ const errData = await res.json().catch(() => ({}));
50
+ throw new Error(errData.detail || `API Error: ${res.status}`);
51
  }
 
52
  const data = await res.json();
53
+ setResult({ ...data, _type: endpoint });
54
  } catch (err) {
55
  setError(err.message || 'Failed to fetch prediction. Please try again.');
56
  } finally {
 
58
  }
59
  };
60
 
61
+ const renderShapBars = (shapValues, isCompare = false) => {
62
+ if (!shapValues) return null;
63
+ return Object.entries(shapValues)
64
+ .sort(([,a], [,b]) => Math.abs(b) - Math.abs(a))
65
+ .slice(0, 6)
66
+ .map(([key, value]) => {
67
+ const absVal = Math.abs(value);
68
+ const isPositive = value > 0;
69
+
70
+ let width = Math.min((absVal / 2) * 100, 50);
71
+ if (isCompare) {
72
+ // For delta: positive = driver 1 (green, left side), negative = driver 2 (red, right side)
73
+ // Wait, standard UI: positive is driver 1.
74
+ width = Math.min((absVal / 5) * 100, 50); // Scale down slightly for deltas since they can be larger
75
+ }
76
+
77
+ return (
78
+ <div key={key} className={styles.shapRow}>
79
+ <div className={styles.shapLabel} title={key}>
80
+ {key.replace(/_/g, ' ')}
81
+ </div>
82
+ <div className={styles.barTrack}>
83
+ <div
84
+ className={`${styles.barFill} ${isPositive ? styles.barPositive : styles.barNegative}`}
85
+ style={{ width: `${width}%` }}
86
+ />
87
+ </div>
88
+ <div className={`${styles.shapValue} ${isPositive ? 'text-gradient' : ''}`} style={!isPositive ? {color: 'var(--neon-red)'} : {}}>
89
+ {value > 0 ? '+' : ''}{value.toFixed(3)}
90
+ </div>
91
+ </div>
92
+ );
93
+ });
94
+ };
95
+
96
  return (
97
  <main className={styles.main}>
98
  <div className={styles.header}>
 
100
  <p className={styles.subtitle}>F1 Intelligence Terminal. Powered by LLMs and LightGBM.</p>
101
  </div>
102
 
103
+ <div className={styles.tabsContainer}>
104
+ <button
105
+ className={`${styles.tabBtn} ${mode === 'predict' ? styles.tabActive : ''}`}
106
+ onClick={() => { setMode('predict'); setResult(null); setError(null); }}
107
+ >
108
+ Single Prediction
109
+ </button>
110
+ <button
111
+ className={`${styles.tabBtn} ${mode === 'compare' ? styles.tabActive : ''}`}
112
+ onClick={() => { setMode('compare'); setResult(null); setError(null); }}
113
+ >
114
+ Head-to-Head Compare
115
+ </button>
116
+ </div>
117
+
118
  <div className={styles.queryContainer}>
119
+ {mode === 'predict' ? (
120
+ <form onSubmit={handleSubmitPredict} className={styles.inputWrapper}>
121
+ <input
122
+ type="text"
123
+ className={styles.input}
124
+ placeholder="Ask anything (e.g., 'What's Max Verstappen's win probability at Monaco 2023?')"
125
+ value={query}
126
+ onChange={(e) => setQuery(e.target.value)}
127
+ disabled={loading}
128
+ />
129
+ <button type="submit" className={styles.submitBtn} disabled={loading || !query.trim()}>
130
+ {loading ? <div className={styles.loader} /> : (
131
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
132
+ <line x1="22" y1="2" x2="11" y2="13"></line>
133
+ <polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
134
+ </svg>
135
+ )}
136
+ </button>
137
+ </form>
138
+ ) : (
139
+ <form onSubmit={handleSubmitCompare} className={styles.compareForm}>
140
+ <div className={styles.compareInputsRow}>
141
+ <div className={styles.inputWrapper}>
142
+ <input type="text" className={styles.input} placeholder="Driver 1 (e.g., Max Verstappen)" value={driver1} onChange={e=>setDriver1(e.target.value)} required disabled={loading} />
143
+ </div>
144
+ <span className={styles.vsText}>VS</span>
145
+ <div className={styles.inputWrapper}>
146
+ <input type="text" className={styles.input} placeholder="Driver 2 (e.g., Kimi Antonelli)" value={driver2} onChange={e=>setDriver2(e.target.value)} required disabled={loading} />
147
+ </div>
148
+ </div>
149
+ <div className={styles.compareInputsRowSmall}>
150
+ <div className={styles.inputWrapper} style={{maxWidth: '200px'}}>
151
+ <input type="number" className={styles.input} style={{padding: '1rem'}} placeholder="Season (Optional)" value={season} onChange={e=>setSeason(e.target.value)} disabled={loading} />
152
+ </div>
153
+ <div className={styles.inputWrapper} style={{maxWidth: '200px'}}>
154
+ <input type="number" className={styles.input} style={{padding: '1rem'}} placeholder="Round (Optional)" value={round} onChange={e=>setRound(e.target.value)} disabled={loading} />
155
+ </div>
156
+ </div>
157
+ <button type="submit" className={styles.compareSubmitBtn} disabled={loading || !driver1.trim() || !driver2.trim()}>
158
+ {loading ? 'Analyzing...' : 'Run Head-to-Head Comparison'}
159
+ </button>
160
+ </form>
161
+ )}
162
  {error && <div style={{ color: 'var(--neon-red)', marginTop: '1rem', textAlign: 'center' }}>{error}</div>}
163
  </div>
164
 
165
+ {result && result._type === 'predict/f1' && (
166
  <div className={`${styles.dashboardGrid} animate-fade-in-up`}>
167
  {/* Left Column: Gauge */}
168
  <div className={`${styles.panel} glass-panel`}>
 
208
  Key Factors (SHAP)
209
  </h2>
210
  <div className={styles.shapContainer}>
211
+ {renderShapBars(result.shap_values)}
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ )}
217
+
218
+ {result && result._type === 'predict/compare' && (
219
+ <div className={`${styles.dashboardGrid} animate-fade-in-up`} style={{gridTemplateColumns: '1fr'}}>
220
+
221
+ <div className={`${styles.panel} glass-panel`} style={{marginBottom: '2rem'}}>
222
+ <h2 className={styles.panelTitle} style={{justifyContent: 'center', fontSize: '1.5rem'}}>
223
+ {result.driver1.driver_name} vs {result.driver2.driver_name}
224
+ </h2>
225
+ <div className={styles.gaugeContainer} style={{flexDirection: 'row', gap: '4rem'}}>
226
+ {/* Driver 1 */}
227
+ <div style={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
228
+ <div className={styles.gaugeCircle} style={{ '--prob': `${(result.driver1_win_probability * 100).toFixed(1)}%` }}>
229
+ <span className={styles.gaugeText}>
230
+ {(result.driver1_win_probability * 100).toFixed(1)}<span style={{fontSize: '1.5rem', color: 'var(--text-secondary)'}}>%</span>
231
+ </span>
232
+ </div>
233
+ <div className={styles.gaugeLabel}>{result.driver1.driver_name}</div>
234
+ </div>
235
+
236
+ {/* VS */}
237
+ <div className={styles.vsText} style={{fontSize: '2rem'}}>VS</div>
238
+
239
+ {/* Driver 2 */}
240
+ <div style={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
241
+ <div className={styles.gaugeCircle} style={{ '--prob': `${(result.driver2_win_probability * 100).toFixed(1)}%`, '--neon-cyan': 'var(--neon-red)' }}>
242
+ <span className={styles.gaugeText}>
243
+ {(result.driver2_win_probability * 100).toFixed(1)}<span style={{fontSize: '1.5rem', color: 'var(--text-secondary)'}}>%</span>
244
+ </span>
245
+ </div>
246
+ <div className={styles.gaugeLabel}>{result.driver2.driver_name}</div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+
251
+ <div className={`${styles.dashboardGrid}`} style={{gap: '2rem'}}>
252
+ {/* LLM Insight */}
253
+ <div className={`${styles.panel} glass-panel delay-100`}>
254
+ <h2 className={styles.panelTitle}>
255
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
256
+ Tale of the Tape
257
+ </h2>
258
+ <div className={styles.insightText} style={{whiteSpace: 'pre-wrap'}}>
259
+ {result.llm_analysis}
260
+ </div>
261
+ </div>
262
+
263
+ {/* SHAP Deltas */}
264
+ <div className={`${styles.panel} glass-panel delay-200`}>
265
+ <h2 className={styles.panelTitle}>
266
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
267
+ Mathematical Edges (Tug of War)
268
+ </h2>
269
+ <div className={styles.shapContainer}>
270
+ {renderShapBars(result.shap_deltas, true)}
271
+ <div style={{display: 'flex', justifyContent: 'space-between', marginTop: '1rem', color: 'var(--text-secondary)', fontSize: '0.8rem'}}>
272
+ <span>← Advantage {result.driver2.driver_name}</span>
273
+ <span>Advantage {result.driver1.driver_name} →</span>
274
+ </div>
275
  </div>
276
  </div>
277
  </div>
278
+
279
  </div>
280
  )}
281
  </main>
frontend/app/page.module.css CHANGED
@@ -274,3 +274,92 @@
274
  font-size: 0.85rem;
275
  font-family: var(--font-geist-mono), monospace;
276
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  font-size: 0.85rem;
275
  font-family: var(--font-geist-mono), monospace;
276
  }
277
+
278
+ /* Tabs */
279
+ .tabsContainer {
280
+ display: flex;
281
+ gap: 1rem;
282
+ margin-bottom: 2rem;
283
+ z-index: 10;
284
+ }
285
+
286
+ .tabBtn {
287
+ background: rgba(0, 0, 0, 0.3);
288
+ border: 1px solid rgba(0, 240, 255, 0.3);
289
+ color: var(--text-muted);
290
+ padding: 0.75rem 2rem;
291
+ border-radius: 20px;
292
+ cursor: pointer;
293
+ transition: var(--transition-fast);
294
+ font-family: inherit;
295
+ font-size: 1rem;
296
+ text-transform: uppercase;
297
+ letter-spacing: 1px;
298
+ }
299
+
300
+ .tabBtn:hover {
301
+ background: rgba(0, 240, 255, 0.1);
302
+ color: var(--neon-cyan);
303
+ }
304
+
305
+ .tabActive {
306
+ background: rgba(0, 240, 255, 0.15);
307
+ border-color: var(--neon-cyan);
308
+ color: var(--neon-cyan);
309
+ box-shadow: 0 0 15px rgba(0, 240, 255, 0.3);
310
+ }
311
+
312
+ /* Compare Form */
313
+ .compareForm {
314
+ display: flex;
315
+ flex-direction: column;
316
+ gap: 1.5rem;
317
+ width: 100%;
318
+ }
319
+
320
+ .compareInputsRow {
321
+ display: flex;
322
+ align-items: center;
323
+ justify-content: space-between;
324
+ gap: 1rem;
325
+ }
326
+
327
+ .vsText {
328
+ font-size: 1.5rem;
329
+ font-weight: 800;
330
+ color: var(--text-muted);
331
+ font-style: italic;
332
+ }
333
+
334
+ .compareInputsRowSmall {
335
+ display: flex;
336
+ gap: 1rem;
337
+ justify-content: center;
338
+ }
339
+
340
+ .compareSubmitBtn {
341
+ background: linear-gradient(90deg, rgba(0, 240, 255, 0.2), rgba(0, 240, 255, 0.05));
342
+ border: 1px solid var(--neon-cyan);
343
+ color: var(--neon-cyan);
344
+ padding: 1rem 2rem;
345
+ border-radius: var(--border-radius-lg);
346
+ cursor: pointer;
347
+ font-family: inherit;
348
+ font-size: 1.1rem;
349
+ font-weight: 600;
350
+ text-transform: uppercase;
351
+ letter-spacing: 2px;
352
+ transition: var(--transition-fast);
353
+ }
354
+
355
+ .compareSubmitBtn:hover:not(:disabled) {
356
+ background: rgba(0, 240, 255, 0.2);
357
+ box-shadow: var(--shadow-glow);
358
+ }
359
+
360
+ .compareSubmitBtn:disabled {
361
+ border-color: rgba(0, 240, 255, 0.1);
362
+ color: var(--text-muted);
363
+ cursor: not-allowed;
364
+ background: transparent;
365
+ }