Grailleton commited on
Commit
aaa2e91
·
0 Parent(s):

initial commit Immo-Predict V4 !

Browse files
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pkl
4
+ data/
5
+ .ipynb_checkpoints/
6
+ outputs/
7
+ *.pyc
8
+ *.egg-info/
9
+ .env
.~lock.documentation_immo_predict_v2_v3.docx# ADDED
@@ -0,0 +1 @@
 
 
1
+ ,DESKTOP-AUN3R3J/Elyas,,12.03.2026 21:10,file:///C:/Users/Elyas/AppData/Roaming/OfficeDocOpener/4;
documentation_immo_predict.docx ADDED
Binary file (16.3 kB). View file
 
documentation_immo_predict_v2_v3.docx ADDED
Binary file (14.5 kB). View file
 
notebooks/analyse_immo.ipynb ADDED
The diff for this file is too large to render. See raw diff
 
src/api.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, jsonify, request
2
+ from flask_cors import CORS
3
+ import joblib
4
+ import pandas as pd
5
+
6
+ app = Flask(__name__, static_folder='static', static_url_path='')
7
+ CORS(app)
8
+
9
+ # Lazy loading
10
+ _models = {}
11
+
12
+ def get_models():
13
+ if not _models:
14
+ print("Chargement des modèles...")
15
+ _models['model_multi'] = joblib.load('src/model_multi.pkl')
16
+ _models['modeles_segment'] = joblib.load('src/modeles_segment.pkl')
17
+ _models['le'] = joblib.load('src/label_encoder_multi.pkl')
18
+ _models['features'] = joblib.load('src/features_multi.pkl')
19
+ print("Modèles chargés !")
20
+ return _models
21
+
22
+ # Chargement des communes (léger, pas de modèle ML)
23
+ _df_ref = None
24
+
25
+ def get_df_ref():
26
+ global _df_ref
27
+ if _df_ref is None:
28
+ _df_ref = pd.read_csv(
29
+ 'data/valeursfoncieres-2025-s1.txt/ValeursFoncieres-2025-S1.txt',
30
+ sep='|', low_memory=False,
31
+ usecols=['Commune', 'Code departement']
32
+ ).dropna()
33
+ _df_ref['Commune'] = _df_ref['Commune'].str.strip().str.upper()
34
+ _df_ref['Code departement'] = _df_ref['Code departement'].str.strip()
35
+ return _df_ref
36
+
37
+ def assigner_segment(type_bien, prix):
38
+ t = 'appart' if type_bien == 'Appartement' else 'maison'
39
+ if prix < 100000: g = 'bas'
40
+ elif prix < 250000: g = 'moyen_bas'
41
+ elif prix < 500000: g = 'moyen_haut'
42
+ else: g = 'haut'
43
+ return f"{t}_{g}"
44
+
45
+ precision_map = {
46
+ 'appart_bas': 65.9, 'appart_moyen_bas': 83.4,
47
+ 'appart_moyen_haut': 87.5, 'appart_haut': 79.5,
48
+ 'maison_bas': 50.4, 'maison_moyen_bas': 81.1,
49
+ 'maison_moyen_haut': 86.7, 'maison_haut': 78.8
50
+ }
51
+
52
+ @app.route('/')
53
+ def index():
54
+ return app.send_static_file('index.html')
55
+
56
+ @app.route('/api/departements')
57
+ def get_departements():
58
+ df = get_df_ref()
59
+ depts = sorted(df['Code departement'].unique().tolist())
60
+ return jsonify(depts)
61
+
62
+ @app.route('/api/communes/<departement>')
63
+ def get_communes(departement):
64
+ df = get_df_ref()
65
+ communes = sorted(
66
+ df[df['Code departement'] == departement]['Commune'].unique().tolist()
67
+ )
68
+ return jsonify(communes)
69
+
70
+ @app.route('/api/predict', methods=['POST'])
71
+ def predict():
72
+ m = get_models()
73
+ model_multi = m['model_multi']
74
+ modeles_segment = m['modeles_segment']
75
+ le = m['le']
76
+ features = m['features']
77
+
78
+ data = request.json
79
+ type_bien = data['type_bien']
80
+ surface = float(data['surface'])
81
+ nb_pieces = float(data['nb_pieces'])
82
+ nb_lots = float(data['nb_lots'])
83
+ surface_terrain = float(data['surface_terrain'])
84
+ commune = data['commune'].upper()
85
+ departement = data['departement']
86
+
87
+ type_encode = 0 if type_bien == 'Appartement' else 1
88
+
89
+ try:
90
+ commune_encode = le.transform([commune])[0]
91
+ except:
92
+ commune_encode = 0
93
+
94
+ try:
95
+ dept_encode = le.transform([departement])[0]
96
+ except:
97
+ dept_encode = 0
98
+
99
+ print(f"Commune: {commune} → encode: {commune_encode}")
100
+ print(f"Dept: {departement} → encode: {dept_encode}")
101
+ print(f"Segment: {segment}")
102
+
103
+
104
+ X_pred = pd.DataFrame(
105
+ [[type_encode, surface, nb_pieces, nb_lots, surface_terrain, commune_encode, dept_encode]],
106
+ columns=features
107
+ )
108
+
109
+ prix_estime = model_multi.predict(X_pred)[0]
110
+ segment = assigner_segment(type_bien, prix_estime)
111
+
112
+ if segment in modeles_segment:
113
+ prix_final = float(modeles_segment[segment].predict(X_pred)[0])
114
+ else:
115
+ prix_final = float(prix_estime)
116
+
117
+ precision = precision_map.get(segment, 75.0)
118
+
119
+ return jsonify({
120
+ 'prix': round(prix_final),
121
+ 'prix_m2': round(prix_final / surface),
122
+ 'fourchette_bas': round(prix_final * 0.85),
123
+ 'fourchette_haut': round(prix_final * 1.15),
124
+ 'segment': segment.replace('_', ' '),
125
+ 'precision': precision
126
+ })
127
+
128
+
129
+ if __name__ == '__main__':
130
+ app.run(debug=False, port=5000)
src/app.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import joblib
3
+ import pandas as pd
4
+ import numpy as np
5
+
6
+ # Chargement des modèles
7
+ @st.cache_resource
8
+ def charger_modeles():
9
+ model_multi = joblib.load('src/model_multi.pkl')
10
+ modeles_segment = joblib.load('src/modeles_segment.pkl')
11
+ le = joblib.load('src/label_encoder_multi.pkl')
12
+ features = joblib.load('src/features_multi.pkl')
13
+ return model_multi, modeles_segment, le, features
14
+
15
+ # Chargement des données pour les sélecteurs
16
+ @st.cache_data
17
+ def charger_donnees():
18
+ df = pd.read_csv('data/valeursfoncieres-2025-s1.txt/ValeursFoncieres-2025-S1.txt',
19
+ sep='|', low_memory=False,
20
+ usecols=['Commune', 'Code departement'])
21
+ df = df.dropna()
22
+ df['Commune'] = df['Commune'].str.strip().str.upper()
23
+ df['Code departement'] = df['Code departement'].str.strip()
24
+ return df
25
+
26
+ def assigner_segment(type_bien, prix_estime):
27
+ t = 'appart' if type_bien == 'Appartement' else 'maison'
28
+ if prix_estime < 100000:
29
+ g = 'bas'
30
+ elif prix_estime < 250000:
31
+ g = 'moyen_bas'
32
+ elif prix_estime < 500000:
33
+ g = 'moyen_haut'
34
+ else:
35
+ g = 'haut'
36
+ return f"{t}_{g}"
37
+
38
+ model_multi, modeles_segment, le, features = charger_modeles()
39
+ df_ref = charger_donnees()
40
+ departements = sorted(df_ref['Code departement'].unique().tolist())
41
+
42
+ # Configuration
43
+ st.set_page_config(page_title="Immo Predict", page_icon="🏠", layout="centered")
44
+
45
+ st.title("🏠 Immo Predict")
46
+ st.subheader("Estimation du prix d'un bien immobilier en France")
47
+ st.caption("Basé sur 3,7 millions de transactions DVF 2022-2025")
48
+ st.divider()
49
+
50
+ # Formulaire
51
+ col1, col2 = st.columns(2)
52
+
53
+ with col1:
54
+ type_bien = st.selectbox("Type de bien", ["Appartement", "Maison"])
55
+ surface = st.number_input("Surface (m²)", min_value=9, max_value=500, value=70)
56
+ nb_pieces = st.slider("Nombre de pièces", 1, 15, 3)
57
+
58
+ with col2:
59
+ departement = st.selectbox("Département", departements,
60
+ index=departements.index('75') if '75' in departements else 0)
61
+ communes_dept = sorted(df_ref[df_ref['Code departement'] == departement]['Commune'].unique().tolist())
62
+ commune = st.selectbox("Commune", communes_dept)
63
+ surface_terrain = st.number_input("Surface terrain (m², 0 si appartement)", min_value=0, value=0)
64
+
65
+ nb_lots = st.number_input("Nombre de lots (copropriété)", min_value=0, value=1)
66
+
67
+ st.divider()
68
+
69
+ if st.button("Estimer le prix 🔍", type="primary"):
70
+ try:
71
+ type_encode = 0 if type_bien == 'Appartement' else 1
72
+
73
+ try:
74
+ commune_encode = le.transform([commune.upper()])[0]
75
+ except:
76
+ commune_encode = 0
77
+
78
+ try:
79
+ dept_encode = le.transform([departement])[0]
80
+ except:
81
+ dept_encode = 0
82
+
83
+ X_pred = pd.DataFrame([[type_encode, surface, nb_pieces, nb_lots,
84
+ surface_terrain, commune_encode, dept_encode]],
85
+ columns=features)
86
+
87
+ # Première estimation avec le modèle global
88
+ prix_estime = model_multi.predict(X_pred)[0]
89
+
90
+ # Sélection du bon segment
91
+ segment = assigner_segment(type_bien, prix_estime)
92
+
93
+ # Prédiction finale avec le modèle du segment
94
+ if segment in modeles_segment:
95
+ prix_final = modeles_segment[segment].predict(X_pred)[0]
96
+ modele_info = f"Modèle spécialisé — segment {segment.replace('_', ' ')}"
97
+ else:
98
+ prix_final = prix_estime
99
+ modele_info = "Modèle général"
100
+
101
+ # Précision selon le segment
102
+ precision_map = {
103
+ 'appart_bas': 65.9, 'appart_moyen_bas': 83.4,
104
+ 'appart_moyen_haut': 87.5, 'appart_haut': 79.5,
105
+ 'maison_bas': 50.4, 'maison_moyen_bas': 81.1,
106
+ 'maison_moyen_haut': 86.7, 'maison_haut': 78.8
107
+ }
108
+ precision = precision_map.get(segment, 75.0)
109
+
110
+ fourchette_bas = prix_final * 0.85
111
+ fourchette_haut = prix_final * 1.15
112
+ prix_m2 = prix_final / surface
113
+
114
+ st.success(f"### Prix estimé : {prix_final:,.0f} €")
115
+
116
+ col1, col2, col3 = st.columns(3)
117
+ col1.metric("Fourchette basse", f"{fourchette_bas:,.0f} €")
118
+ col2.metric("Prix au m²", f"{prix_m2:,.0f} €/m²")
119
+ col3.metric("Fourchette haute", f"{fourchette_haut:,.0f} €")
120
+
121
+ st.info(f"🎯 Précision du modèle sur ce segment : **{precision}%**")
122
+ st.caption(f"ℹ️ {modele_info}")
123
+ st.caption("⚠️ Estimation basée sur les transactions DVF 2022-2025. Hors marché du luxe (> 2M€).")
124
+
125
+ except Exception as e:
126
+ st.error(f"Erreur : {e}")
src/static/index.html ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Immo Predict</title>
7
+ <link rel="stylesheet" href="style.css">
8
+ <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Syne:wght@700;800&display=swap" rel="stylesheet">
9
+ </head>
10
+ <body>
11
+ <canvas id="bg-canvas"></canvas>
12
+
13
+ <header>
14
+ <div class="logo">IMMO<span>PREDICT</span></div>
15
+ <div class="header-right">
16
+ <span class="badge-live">● LIVE</span>
17
+ <span class="header-sub">3,7M transactions DVF</span>
18
+ </div>
19
+ </header>
20
+
21
+ <main>
22
+ <section class="hero">
23
+ <div class="hero-tag">ESTIMATION PAR INTELLIGENCE ARTIFICIELLE</div>
24
+ <h1>
25
+ <span class="line">Découvrez la</span>
26
+ <span class="line accent">valeur réelle</span>
27
+ <span class="line">de votre bien.</span>
28
+ </h1>
29
+ <div class="hero-stats">
30
+ <div class="stat">
31
+ <span class="stat-n" data-target="3700000">0</span>
32
+ <span class="stat-l">Transactions</span>
33
+ </div>
34
+ <div class="stat-div"></div>
35
+ <div class="stat">
36
+ <span class="stat-n" data-target="87">0</span>
37
+ <span class="stat-l">% Précision max</span>
38
+ </div>
39
+ <div class="stat-div"></div>
40
+ <div class="stat">
41
+ <span class="stat-n" data-target="8">0</span>
42
+ <span class="stat-l">Segments IA</span>
43
+ </div>
44
+ </div>
45
+ </section>
46
+
47
+ <section class="form-card">
48
+ <div class="type-selector">
49
+ <button class="type-btn active" data-value="Appartement" onclick="selectType(this)">
50
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
51
+ <rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.5"/>
52
+ <path d="M3 9h18M9 9v12" stroke="currentColor" stroke-width="1.5"/>
53
+ </svg>
54
+ Appartement
55
+ </button>
56
+ <button class="type-btn" data-value="Maison" onclick="selectType(this)">
57
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
58
+ <path d="M3 12L12 3l9 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
59
+ <path d="M5 10v9a1 1 0 001 1h4v-4h4v4h4a1 1 0 001-1v-9" stroke="currentColor" stroke-width="1.5"/>
60
+ </svg>
61
+ Maison
62
+ </button>
63
+ </div>
64
+
65
+ <div class="form-grid">
66
+ <div class="field">
67
+ <label>Surface habitable</label>
68
+ <div class="input-wrap">
69
+ <input type="number" id="surface" value="70" min="9" max="500">
70
+ <span class="unit">m²</span>
71
+ </div>
72
+ </div>
73
+
74
+ <div class="field">
75
+ <label>Nombre de pièces</label>
76
+ <div class="stepper">
77
+ <button class="step-btn" onclick="step('pieces',-1)">−</button>
78
+ <span id="pieces-val">3</span>
79
+ <input type="hidden" id="pieces" value="3">
80
+ <button class="step-btn" onclick="step('pieces',1)">+</button>
81
+ </div>
82
+ </div>
83
+
84
+ <div class="field">
85
+ <label>Département</label>
86
+ <div class="select-wrap">
87
+ <select id="departement" onchange="loadCommunes()"></select>
88
+ </div>
89
+ </div>
90
+
91
+ <div class="field">
92
+ <label>Commune</label>
93
+ <div class="select-wrap">
94
+ <select id="commune"></select>
95
+ </div>
96
+ </div>
97
+
98
+ <div class="field">
99
+ <label>Surface terrain</label>
100
+ <div class="input-wrap">
101
+ <input type="number" id="terrain" value="0" min="0">
102
+ <span class="unit">m²</span>
103
+ </div>
104
+ </div>
105
+
106
+ <div class="field">
107
+ <label>Lots copropriété</label>
108
+ <div class="stepper">
109
+ <button class="step-btn" onclick="step('lots',-1)">−</button>
110
+ <span id="lots-val">1</span>
111
+ <input type="hidden" id="lots" value="1">
112
+ <button class="step-btn" onclick="step('lots',1)">+</button>
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ <button class="cta" onclick="predict()" id="cta-btn">
118
+ <span id="cta-label">Estimer maintenant</span>
119
+ <svg id="cta-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
120
+ <path d="M4 10h12M10 4l6 6-6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
121
+ </svg>
122
+ <div class="cta-loader" id="cta-loader"></div>
123
+ </button>
124
+ </section>
125
+
126
+ <section class="result" id="result">
127
+ <div class="result-top">
128
+ <div class="result-label">PRIX ESTIMÉ</div>
129
+ <div class="result-price" id="result-price">—</div>
130
+ <div class="result-sub" id="result-sub">—</div>
131
+ </div>
132
+
133
+ <div class="result-grid">
134
+ <div class="res-card">
135
+ <div class="res-card-label">Fourchette basse</div>
136
+ <div class="res-card-val" id="r-bas">—</div>
137
+ </div>
138
+ <div class="res-card res-card-mid">
139
+ <div class="res-card-label">Prix au m²</div>
140
+ <div class="res-card-val" id="r-m2">—</div>
141
+ </div>
142
+ <div class="res-card">
143
+ <div class="res-card-label">Fourchette haute</div>
144
+ <div class="res-card-val" id="r-haut">—</div>
145
+ </div>
146
+ </div>
147
+
148
+ <div class="precision-block">
149
+ <div class="precision-top">
150
+ <span>Précision du modèle</span>
151
+ <span id="r-precision" class="precision-pct">—</span>
152
+ </div>
153
+ <div class="bar-track">
154
+ <div class="bar-fill" id="bar-fill"></div>
155
+ </div>
156
+ <div class="segment-row">
157
+ <span class="segment-badge" id="r-segment">—</span>
158
+ <span class="disclaimer">Données DVF 2022–2025 · Hors luxe > 2M€</span>
159
+ </div>
160
+ </div>
161
+ </section>
162
+ </main>
163
+
164
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
165
+ <script src="script.js"></script>
166
+ </body>
167
+ </html>
src/static/script.js ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let selectedType = 'Appartement';
2
+ const steppers = { pieces: 3, lots: 1 };
3
+
4
+ // ── Three.js background ──
5
+ function initThree() {
6
+ const canvas = document.getElementById('bg-canvas');
7
+ const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
8
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
9
+ renderer.setSize(window.innerWidth, window.innerHeight);
10
+
11
+ const scene = new THREE.Scene();
12
+ const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
13
+ camera.position.z = 30;
14
+
15
+ // Particules
16
+ const count = 1200;
17
+ const positions = new Float32Array(count * 3);
18
+ const colors = new Float32Array(count * 3);
19
+
20
+ for (let i = 0; i < count; i++) {
21
+ positions[i * 3] = (Math.random() - 0.5) * 80;
22
+ positions[i * 3 + 1] = (Math.random() - 0.5) * 80;
23
+ positions[i * 3 + 2] = (Math.random() - 0.5) * 40;
24
+
25
+ const r = Math.random();
26
+ if (r < 0.5) {
27
+ // Bleu
28
+ colors[i * 3] = 0.23;
29
+ colors[i * 3 + 1] = 0.51;
30
+ colors[i * 3 + 2] = 0.96;
31
+ } else if (r < 0.8) {
32
+ // Cyan
33
+ colors[i * 3] = 0.02;
34
+ colors[i * 3 + 1] = 0.71;
35
+ colors[i * 3 + 2] = 0.83;
36
+ } else {
37
+ // Blanc doux
38
+ colors[i * 3] = 0.55;
39
+ colors[i * 3 + 1] = 0.58;
40
+ colors[i * 3 + 2] = 0.70;
41
+ }
42
+ }
43
+
44
+ const geo = new THREE.BufferGeometry();
45
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
46
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
47
+
48
+ const mat = new THREE.PointsMaterial({
49
+ size: 0.12,
50
+ vertexColors: true,
51
+ transparent: true,
52
+ opacity: 0.7,
53
+ sizeAttenuation: true
54
+ });
55
+
56
+ const points = new THREE.Points(geo, mat);
57
+ scene.add(points);
58
+
59
+ // Lignes de grille en perspective
60
+ const lineMat = new THREE.LineBasicMaterial({
61
+ color: 0x1e3a5f,
62
+ transparent: true,
63
+ opacity: 0.15
64
+ });
65
+
66
+ for (let i = -5; i <= 5; i++) {
67
+ const hGeo = new THREE.BufferGeometry().setFromPoints([
68
+ new THREE.Vector3(-40, i * 8, -10),
69
+ new THREE.Vector3(40, i * 8, -10)
70
+ ]);
71
+ scene.add(new THREE.Line(hGeo, lineMat));
72
+
73
+ const vGeo = new THREE.BufferGeometry().setFromPoints([
74
+ new THREE.Vector3(i * 8, -40, -10),
75
+ new THREE.Vector3(i * 8, 40, -10)
76
+ ]);
77
+ scene.add(new THREE.Line(vGeo, lineMat));
78
+ }
79
+
80
+ let mouse = { x: 0, y: 0 };
81
+ document.addEventListener('mousemove', e => {
82
+ mouse.x = (e.clientX / window.innerWidth - 0.5) * 0.3;
83
+ mouse.y = (e.clientY / window.innerHeight - 0.5) * 0.3;
84
+ });
85
+
86
+ window.addEventListener('resize', () => {
87
+ camera.aspect = window.innerWidth / window.innerHeight;
88
+ camera.updateProjectionMatrix();
89
+ renderer.setSize(window.innerWidth, window.innerHeight);
90
+ });
91
+
92
+ let t = 0;
93
+ function animate() {
94
+ requestAnimationFrame(animate);
95
+ t += 0.0005;
96
+ points.rotation.y = t + mouse.x;
97
+ points.rotation.x = mouse.y * 0.5;
98
+ points.rotation.z = t * 0.3;
99
+ renderer.render(scene, camera);
100
+ }
101
+ animate();
102
+ }
103
+
104
+ // ── Compteur animé ──
105
+ function animateCounters() {
106
+ document.querySelectorAll('.stat-n').forEach(el => {
107
+ const target = parseInt(el.dataset.target);
108
+ const duration = 1500;
109
+ const start = performance.now();
110
+
111
+ function update(now) {
112
+ const progress = Math.min((now - start) / duration, 1);
113
+ const ease = 1 - Math.pow(1 - progress, 3);
114
+ const val = Math.floor(ease * target);
115
+ el.textContent = target >= 1000000
116
+ ? (val / 1000000).toFixed(1) + 'M'
117
+ : val.toLocaleString('fr-FR');
118
+ if (progress < 1) requestAnimationFrame(update);
119
+ }
120
+ requestAnimationFrame(update);
121
+ });
122
+ }
123
+
124
+ // ── Formulaire ──
125
+ function selectType(btn) {
126
+ document.querySelectorAll('.type-btn').forEach(b => b.classList.remove('active'));
127
+ btn.classList.add('active');
128
+ selectedType = btn.dataset.value;
129
+ }
130
+
131
+ function step(id, delta) {
132
+ const min = id === 'pieces' ? 1 : 0;
133
+ const max = id === 'pieces' ? 15 : 50;
134
+ steppers[id] = Math.min(max, Math.max(min, steppers[id] + delta));
135
+ document.getElementById(id + '-val').textContent = steppers[id];
136
+ document.getElementById(id).value = steppers[id];
137
+ }
138
+
139
+ function fmt(n) {
140
+ return new Intl.NumberFormat('fr-FR').format(n) + ' €';
141
+ }
142
+
143
+ async function loadDepartements() {
144
+ const res = await fetch('/api/departements');
145
+ const depts = await res.json();
146
+ const sel = document.getElementById('departement');
147
+ sel.innerHTML = depts.map(d => `<option value="${d}">${d}</option>`).join('');
148
+ const idx = depts.indexOf('75');
149
+ if (idx >= 0) sel.selectedIndex = idx;
150
+ loadCommunes();
151
+ }
152
+
153
+ async function loadCommunes() {
154
+ const dept = document.getElementById('departement').value;
155
+ if (!dept) return;
156
+ const res = await fetch(`/api/communes/${dept}`);
157
+ const communes = await res.json();
158
+ const sel = document.getElementById('commune');
159
+ sel.innerHTML = communes.map(c => `<option value="${c}">${c}</option>`).join('');
160
+ }
161
+
162
+ // ── Prédiction ──
163
+ async function predict() {
164
+ const btn = document.getElementById('cta-btn');
165
+ const label = document.getElementById('cta-label');
166
+ const icon = document.getElementById('cta-icon');
167
+ const loader = document.getElementById('cta-loader');
168
+
169
+ label.textContent = 'Analyse en cours';
170
+ icon.style.display = 'none';
171
+ loader.style.display = 'block';
172
+ btn.disabled = true;
173
+
174
+ try {
175
+ const res = await fetch('/api/predict', {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({
179
+ type_bien: selectedType,
180
+ surface: document.getElementById('surface').value,
181
+ nb_pieces: steppers.pieces,
182
+ nb_lots: steppers.lots,
183
+ surface_terrain: document.getElementById('terrain').value,
184
+ commune: document.getElementById('commune').value,
185
+ departement: document.getElementById('departement').value
186
+ })
187
+ });
188
+
189
+ const data = await res.json();
190
+
191
+ // Remplir les résultats
192
+ document.getElementById('result-price').textContent = fmt(data.prix);
193
+ document.getElementById('result-sub').textContent =
194
+ selectedType + ' · ' + document.getElementById('commune').value + ' (' + document.getElementById('departement').value + ')';
195
+ document.getElementById('r-bas').textContent = fmt(data.fourchette_bas);
196
+ document.getElementById('r-haut').textContent = fmt(data.fourchette_haut);
197
+ document.getElementById('r-m2').textContent = new Intl.NumberFormat('fr-FR').format(data.prix_m2) + ' €/m²';
198
+ document.getElementById('r-precision').textContent = data.precision + '%';
199
+ document.getElementById('r-segment').textContent = data.segment.replace(/_/g, ' ');
200
+
201
+ // Afficher et animer
202
+ const result = document.getElementById('result');
203
+ result.style.display = 'block';
204
+ setTimeout(() => {
205
+ document.getElementById('bar-fill').style.width = data.precision + '%';
206
+ }, 100);
207
+
208
+ result.scrollIntoView({ behavior: 'smooth', block: 'start' });
209
+
210
+ } catch (e) {
211
+ alert('Erreur de connexion. Vérifiez que Flask tourne bien sur le port 5000.');
212
+ } finally {
213
+ label.textContent = 'Estimer maintenant';
214
+ icon.style.display = 'block';
215
+ loader.style.display = 'none';
216
+ btn.disabled = false;
217
+ }
218
+ }
219
+
220
+ // ── Init ──
221
+ initThree();
222
+ animateCounters();
223
+ loadDepartements();
src/static/style.css ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
+
3
+ :root {
4
+ --bg: #060a12;
5
+ --surface: #0d1525;
6
+ --surface2: #111d30;
7
+ --border: rgba(99, 179, 237, 0.08);
8
+ --border2: rgba(99, 179, 237, 0.15);
9
+ --accent: #3b82f6;
10
+ --accent2: #60a5fa;
11
+ --cyan: #06b6d4;
12
+ --text: #f0f4ff;
13
+ --muted: #4a5a7a;
14
+ --muted2: #8895b3;
15
+ --green: #10b981;
16
+ --radius: 20px;
17
+ }
18
+
19
+ body {
20
+ background: var(--bg);
21
+ color: var(--text);
22
+ font-family: 'Space Grotesk', sans-serif;
23
+ font-weight: 400;
24
+ min-height: 100vh;
25
+ overflow-x: hidden;
26
+ }
27
+
28
+ #bg-canvas {
29
+ position: fixed;
30
+ inset: 0;
31
+ z-index: 0;
32
+ pointer-events: none;
33
+ }
34
+
35
+ header {
36
+ position: relative; z-index: 10;
37
+ display: flex; align-items: center; justify-content: space-between;
38
+ padding: 24px 48px;
39
+ border-bottom: 1px solid var(--border);
40
+ backdrop-filter: blur(12px);
41
+ background: rgba(6, 10, 18, 0.6);
42
+ }
43
+
44
+ .logo {
45
+ font-family: 'Syne', sans-serif;
46
+ font-weight: 800;
47
+ font-size: 18px;
48
+ letter-spacing: 0.05em;
49
+ color: var(--text);
50
+ }
51
+ .logo span { color: var(--accent); }
52
+
53
+ .header-right { display: flex; align-items: center; gap: 16px; }
54
+
55
+ .badge-live {
56
+ font-size: 11px;
57
+ font-weight: 600;
58
+ letter-spacing: 0.1em;
59
+ color: var(--green);
60
+ padding: 4px 10px;
61
+ border: 1px solid rgba(16,185,129,0.3);
62
+ border-radius: 20px;
63
+ background: rgba(16,185,129,0.08);
64
+ animation: pulse 2s infinite;
65
+ }
66
+
67
+ @keyframes pulse {
68
+ 0%, 100% { opacity: 1; }
69
+ 50% { opacity: 0.6; }
70
+ }
71
+
72
+ .header-sub {
73
+ font-size: 12px;
74
+ color: var(--muted2);
75
+ }
76
+
77
+ main {
78
+ position: relative; z-index: 1;
79
+ max-width: 900px;
80
+ margin: 0 auto;
81
+ padding: 72px 24px 100px;
82
+ }
83
+
84
+ /* HERO */
85
+ .hero { margin-bottom: 64px; }
86
+
87
+ .hero-tag {
88
+ font-size: 11px;
89
+ font-weight: 600;
90
+ letter-spacing: 0.2em;
91
+ color: var(--accent);
92
+ margin-bottom: 24px;
93
+ display: flex; align-items: center; gap: 8px;
94
+ }
95
+ .hero-tag::before {
96
+ content: '';
97
+ display: inline-block;
98
+ width: 24px; height: 1px;
99
+ background: var(--accent);
100
+ }
101
+
102
+ h1 {
103
+ font-family: 'Syne', sans-serif;
104
+ font-weight: 800;
105
+ font-size: clamp(44px, 6vw, 76px);
106
+ line-height: 1.05;
107
+ margin-bottom: 48px;
108
+ }
109
+
110
+ .line { display: block; opacity: 0; transform: translateY(24px);
111
+ animation: slideUp 0.6s forwards; }
112
+ .line:nth-child(1) { animation-delay: 0.1s; }
113
+ .line:nth-child(2) { animation-delay: 0.2s; }
114
+ .line:nth-child(3) { animation-delay: 0.3s; }
115
+
116
+ @keyframes slideUp {
117
+ to { opacity: 1; transform: translateY(0); }
118
+ }
119
+
120
+ .accent {
121
+ background: linear-gradient(135deg, var(--accent), var(--cyan));
122
+ -webkit-background-clip: text;
123
+ -webkit-text-fill-color: transparent;
124
+ background-clip: text;
125
+ }
126
+
127
+ .hero-stats {
128
+ display: flex; align-items: center; gap: 32px;
129
+ opacity: 0; animation: slideUp 0.6s 0.4s forwards;
130
+ }
131
+
132
+ .stat { display: flex; flex-direction: column; gap: 4px; }
133
+
134
+ .stat-n {
135
+ font-family: 'Syne', sans-serif;
136
+ font-size: 28px;
137
+ font-weight: 700;
138
+ color: var(--text);
139
+ }
140
+
141
+ .stat-l {
142
+ font-size: 11px;
143
+ color: var(--muted2);
144
+ letter-spacing: 0.05em;
145
+ }
146
+
147
+ .stat-div {
148
+ width: 1px; height: 40px;
149
+ background: var(--border2);
150
+ }
151
+
152
+ /* FORM */
153
+ .form-card {
154
+ background: rgba(13, 21, 37, 0.8);
155
+ border: 1px solid var(--border2);
156
+ border-radius: var(--radius);
157
+ padding: 40px;
158
+ backdrop-filter: blur(20px);
159
+ box-shadow: 0 0 80px rgba(59, 130, 246, 0.05);
160
+ opacity: 0; animation: slideUp 0.6s 0.5s forwards;
161
+ }
162
+
163
+ .type-selector {
164
+ display: flex; gap: 12px;
165
+ margin-bottom: 32px;
166
+ }
167
+
168
+ .type-btn {
169
+ flex: 1; display: flex; align-items: center; justify-content: center; gap: 10px;
170
+ padding: 14px 20px;
171
+ background: var(--surface2);
172
+ border: 1px solid var(--border);
173
+ border-radius: 12px;
174
+ color: var(--muted2);
175
+ font-family: 'Space Grotesk', sans-serif;
176
+ font-size: 15px;
177
+ font-weight: 500;
178
+ cursor: pointer;
179
+ transition: all 0.2s;
180
+ }
181
+
182
+ .type-btn:hover { border-color: var(--border2); color: var(--text); }
183
+
184
+ .type-btn.active {
185
+ background: rgba(59, 130, 246, 0.12);
186
+ border-color: var(--accent);
187
+ color: var(--accent2);
188
+ }
189
+
190
+ .form-grid {
191
+ display: grid;
192
+ grid-template-columns: 1fr 1fr;
193
+ gap: 20px;
194
+ margin-bottom: 32px;
195
+ }
196
+
197
+ .field { display: flex; flex-direction: column; gap: 8px; }
198
+
199
+ label {
200
+ font-size: 11px;
201
+ font-weight: 600;
202
+ letter-spacing: 0.12em;
203
+ text-transform: uppercase;
204
+ color: var(--muted2);
205
+ }
206
+
207
+ .input-wrap {
208
+ display: flex; align-items: center;
209
+ background: var(--surface2);
210
+ border: 1px solid var(--border);
211
+ border-radius: 10px;
212
+ overflow: hidden;
213
+ transition: border-color 0.2s, box-shadow 0.2s;
214
+ }
215
+ .input-wrap:focus-within {
216
+ border-color: var(--accent);
217
+ box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
218
+ }
219
+
220
+ .input-wrap input {
221
+ flex: 1; padding: 12px 16px;
222
+ background: transparent; border: none; outline: none;
223
+ color: var(--text);
224
+ font-family: 'Space Grotesk', sans-serif;
225
+ font-size: 15px;
226
+ }
227
+
228
+ .unit {
229
+ padding: 12px 14px;
230
+ font-size: 12px;
231
+ font-weight: 600;
232
+ color: var(--muted);
233
+ border-left: 1px solid var(--border);
234
+ }
235
+
236
+ .stepper {
237
+ display: flex; align-items: center;
238
+ background: var(--surface2);
239
+ border: 1px solid var(--border);
240
+ border-radius: 10px;
241
+ overflow: hidden;
242
+ height: 46px;
243
+ }
244
+
245
+ .step-btn {
246
+ width: 44px; height: 100%;
247
+ background: transparent; border: none; outline: none;
248
+ color: var(--accent2);
249
+ font-size: 20px;
250
+ cursor: pointer;
251
+ transition: background 0.2s;
252
+ flex-shrink: 0;
253
+ }
254
+ .step-btn:hover { background: rgba(59,130,246,0.1); }
255
+
256
+ .stepper span {
257
+ flex: 1; text-align: center;
258
+ font-size: 16px; font-weight: 600;
259
+ color: var(--text);
260
+ }
261
+
262
+ .select-wrap {
263
+ position: relative;
264
+ }
265
+
266
+ .select-wrap select {
267
+ width: 100%; padding: 12px 40px 12px 16px;
268
+ background: var(--surface2);
269
+ border: 1px solid var(--border);
270
+ border-radius: 10px;
271
+ color: var(--text);
272
+ font-family: 'Space Grotesk', sans-serif;
273
+ font-size: 14px;
274
+ font-weight: 500;
275
+ outline: none; cursor: pointer;
276
+ appearance: none;
277
+ transition: border-color 0.2s, box-shadow 0.2s;
278
+ }
279
+ .select-wrap select:focus {
280
+ border-color: var(--accent);
281
+ box-shadow: 0 0 0 3px rgba(59,130,246,0.1);
282
+ }
283
+ .select-wrap select option { background: #0d1525; }
284
+
285
+ .select-wrap::after {
286
+ content: '';
287
+ position: absolute; right: 14px; top: 50%;
288
+ transform: translateY(-50%);
289
+ width: 0; height: 0;
290
+ border-left: 4px solid transparent;
291
+ border-right: 4px solid transparent;
292
+ border-top: 5px solid var(--muted2);
293
+ pointer-events: none;
294
+ }
295
+
296
+ /* CTA */
297
+ .cta {
298
+ width: 100%; padding: 16px 32px;
299
+ background: linear-gradient(135deg, var(--accent), #2563eb);
300
+ border: none; border-radius: 12px;
301
+ color: #fff;
302
+ font-family: 'Space Grotesk', sans-serif;
303
+ font-size: 15px; font-weight: 600;
304
+ letter-spacing: 0.03em;
305
+ cursor: pointer;
306
+ display: flex; align-items: center; justify-content: center; gap: 12px;
307
+ transition: all 0.25s;
308
+ position: relative; overflow: hidden;
309
+ }
310
+ .cta::before {
311
+ content: '';
312
+ position: absolute; inset: 0;
313
+ background: linear-gradient(135deg, #60a5fa, var(--accent));
314
+ opacity: 0; transition: opacity 0.25s;
315
+ }
316
+ .cta:hover::before { opacity: 1; }
317
+ .cta:hover { transform: translateY(-2px); box-shadow: 0 12px 40px rgba(59,130,246,0.35); }
318
+ .cta:active { transform: translateY(0); }
319
+ .cta:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
320
+
321
+ .cta span, .cta svg { position: relative; z-index: 1; }
322
+
323
+ .cta-loader {
324
+ display: none;
325
+ width: 18px; height: 18px;
326
+ border: 2px solid rgba(255,255,255,0.3);
327
+ border-top-color: #fff;
328
+ border-radius: 50%;
329
+ animation: spin 0.7s linear infinite;
330
+ position: relative; z-index: 1;
331
+ }
332
+ @keyframes spin { to { transform: rotate(360deg); } }
333
+
334
+ /* RESULT */
335
+ .result {
336
+ margin-top: 24px;
337
+ background: rgba(13, 21, 37, 0.8);
338
+ border: 1px solid rgba(59,130,246,0.2);
339
+ border-radius: var(--radius);
340
+ padding: 40px;
341
+ backdrop-filter: blur(20px);
342
+ display: none;
343
+ animation: fadeUp 0.5s ease;
344
+ }
345
+
346
+ @keyframes fadeUp {
347
+ from { opacity: 0; transform: translateY(20px); }
348
+ to { opacity: 1; transform: translateY(0); }
349
+ }
350
+
351
+ .result-top { text-align: center; margin-bottom: 36px; }
352
+
353
+ .result-label {
354
+ font-size: 11px; font-weight: 600;
355
+ letter-spacing: 0.2em;
356
+ color: var(--muted2);
357
+ margin-bottom: 12px;
358
+ }
359
+
360
+ .result-price {
361
+ font-family: 'Syne', sans-serif;
362
+ font-size: clamp(48px, 8vw, 80px);
363
+ font-weight: 800;
364
+ background: linear-gradient(135deg, var(--accent2), var(--cyan));
365
+ -webkit-background-clip: text;
366
+ -webkit-text-fill-color: transparent;
367
+ background-clip: text;
368
+ line-height: 1;
369
+ }
370
+
371
+ .result-sub {
372
+ margin-top: 8px;
373
+ font-size: 13px;
374
+ color: var(--muted2);
375
+ }
376
+
377
+ .result-grid {
378
+ display: grid;
379
+ grid-template-columns: 1fr 1fr 1fr;
380
+ gap: 16px;
381
+ margin-bottom: 28px;
382
+ }
383
+
384
+ .res-card {
385
+ background: var(--surface2);
386
+ border: 1px solid var(--border);
387
+ border-radius: 12px;
388
+ padding: 20px 16px;
389
+ text-align: center;
390
+ transition: border-color 0.2s;
391
+ }
392
+ .res-card:hover { border-color: var(--border2); }
393
+
394
+ .res-card-mid {
395
+ border-color: rgba(59,130,246,0.25);
396
+ background: rgba(59,130,246,0.06);
397
+ }
398
+
399
+ .res-card-label {
400
+ font-size: 10px; font-weight: 600;
401
+ letter-spacing: 0.12em;
402
+ text-transform: uppercase;
403
+ color: var(--muted2);
404
+ margin-bottom: 8px;
405
+ }
406
+
407
+ .res-card-val {
408
+ font-family: 'Syne', sans-serif;
409
+ font-size: 20px; font-weight: 700;
410
+ color: var(--text);
411
+ }
412
+
413
+ .precision-block { padding-top: 24px; border-top: 1px solid var(--border); }
414
+
415
+ .precision-top {
416
+ display: flex; justify-content: space-between; align-items: center;
417
+ margin-bottom: 10px;
418
+ font-size: 12px; font-weight: 500;
419
+ color: var(--muted2);
420
+ }
421
+
422
+ .precision-pct {
423
+ font-family: 'Syne', sans-serif;
424
+ font-size: 18px; font-weight: 700;
425
+ color: var(--accent2);
426
+ }
427
+
428
+ .bar-track {
429
+ height: 6px;
430
+ background: var(--surface2);
431
+ border-radius: 3px;
432
+ overflow: hidden;
433
+ margin-bottom: 16px;
434
+ }
435
+
436
+ .bar-fill {
437
+ height: 100%;
438
+ background: linear-gradient(90deg, var(--accent), var(--cyan), var(--green));
439
+ border-radius: 3px;
440
+ width: 0;
441
+ transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
442
+ }
443
+
444
+ .segment-row {
445
+ display: flex; align-items: center; justify-content: space-between;
446
+ }
447
+
448
+ .segment-badge {
449
+ padding: 5px 14px;
450
+ background: rgba(59,130,246,0.1);
451
+ border: 1px solid rgba(59,130,246,0.25);
452
+ border-radius: 20px;
453
+ font-size: 12px; font-weight: 600;
454
+ color: var(--accent2);
455
+ text-transform: capitalize;
456
+ }
457
+
458
+ .disclaimer {
459
+ font-size: 11px;
460
+ color: var(--muted);
461
+ }
462
+
463
+ @media (max-width: 600px) {
464
+ header { padding: 16px 20px; }
465
+ main { padding: 48px 16px 80px; }
466
+ .form-card, .result { padding: 24px; }
467
+ .form-grid { grid-template-columns: 1fr; }
468
+ .result-grid { grid-template-columns: 1fr; }
469
+ .segment-row { flex-direction: column; gap: 8px; align-items: flex-start; }
470
+ }