LovnishVerma commited on
Commit
e923fd8
Β·
verified Β·
1 Parent(s): 4c0726f

Upload 5 files

Browse files
app.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify
2
+ import joblib
3
+ import numpy as np
4
+ import os
5
+
6
+ app = Flask(__name__)
7
+
8
+ # Load model at startup
9
+ MODEL_PATH = 'crop_recommendation_model.joblib'
10
+
11
+ model = None
12
+ if os.path.exists(MODEL_PATH):
13
+ model = joblib.load(MODEL_PATH)
14
+
15
+ FEATURE_INFO = {
16
+ 'N': {'label': 'Nitrogen (N)', 'unit': 'kg/ha', 'min': 0, 'max': 300, 'step': 1, 'placeholder': 'e.g. 175'},
17
+ 'P': {'label': 'Phosphorus (P)', 'unit': 'kg/ha', 'min': 0, 'max': 150, 'step': 1, 'placeholder': 'e.g. 36'},
18
+ 'K': {'label': 'Potassium (K)', 'unit': 'kg/ha', 'min': 0, 'max': 300, 'step': 1, 'placeholder': 'e.g. 216'},
19
+ 'ph': {'label': 'pH Level', 'unit': '', 'min': 0, 'max': 14, 'step': 0.01, 'placeholder': 'e.g. 5.9'},
20
+ 'EC': {'label': 'Electrical Cond.', 'unit': 'dS/m', 'min': 0, 'max': 5, 'step': 0.01, 'placeholder': 'e.g. 0.15'},
21
+ 'S': {'label': 'Sulfur (S)', 'unit': 'mg/kg', 'min': 0, 'max': 50, 'step': 0.01, 'placeholder': 'e.g. 0.28'},
22
+ 'Cu': {'label': 'Copper (Cu)', 'unit': 'mg/kg', 'min': 0, 'max': 50, 'step': 0.01, 'placeholder': 'e.g. 15.69'},
23
+ 'Fe': {'label': 'Iron (Fe)', 'unit': 'mg/kg', 'min': 0, 'max': 300, 'step': 0.01, 'placeholder': 'e.g. 114.20'},
24
+ 'Mn': {'label': 'Manganese (Mn)', 'unit': 'mg/kg', 'min': 0, 'max': 200, 'step': 0.01, 'placeholder': 'e.g. 56.87'},
25
+ 'Zn': {'label': 'Zinc (Zn)', 'unit': 'mg/kg', 'min': 0, 'max': 100, 'step': 0.01, 'placeholder': 'e.g. 31.28'},
26
+ 'B': {'label': 'Boron (B)', 'unit': 'mg/kg', 'min': 0, 'max': 100, 'step': 0.01, 'placeholder': 'e.g. 28.62'},
27
+ }
28
+
29
+ FEATURES = ['N', 'P', 'K', 'ph', 'EC', 'S', 'Cu', 'Fe', 'Mn', 'Zn', 'B']
30
+
31
+ @app.route('/')
32
+ def index():
33
+ return render_template('index.html', features=FEATURES, feature_info=FEATURE_INFO)
34
+
35
+ @app.route('/predict', methods=['POST'])
36
+ def predict():
37
+ if model is None:
38
+ return jsonify({'error': 'Model not loaded. Please ensure crop_recommendation_model.joblib exists.'}), 500
39
+
40
+ try:
41
+ values = [float(request.form.get(f, 0)) for f in FEATURES]
42
+ prediction = model.predict([values])
43
+ crop = prediction[0]
44
+
45
+ # Get probabilities if available
46
+ proba = None
47
+ if hasattr(model, 'predict_proba'):
48
+ proba_arr = model.predict_proba([values])[0]
49
+ classes = model.classes_
50
+ top3_idx = proba_arr.argsort()[-3:][::-1]
51
+ proba = [{'crop': str(classes[i]), 'prob': round(float(proba_arr[i]) * 100, 1)} for i in top3_idx]
52
+
53
+ return jsonify({'crop': str(crop), 'top3': proba})
54
+ except Exception as e:
55
+ return jsonify({'error': str(e)}), 400
56
+
57
+ if __name__ == '__main__':
58
+ app.run(debug=True)
crop_recommendation_model.joblib ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:68bbd19c2e6a03707f3e703936c79be69fcf530312cf66aaad2d2e627fc2692d
3
+ size 1879
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ flask>=2.3
2
+ scikit-learn>=1.3
3
+ pandas>=2.0
4
+ joblib>=1.3
5
+ numpy>=1.24
6
+
static/css/style.css ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ #body {
2
+
3
+ color: #e91010;
4
+
5
+ }
templates/index.html ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>CropSense β€” Soil Analysis & Crop Recommender</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=DM+Mono:wght@400;500&family=DM+Sans:wght@300;400;500&display=swap" rel="stylesheet"/>
8
+ <style>
9
+ :root {
10
+ --soil: #2c1a0e;
11
+ --earth: #5c3d1e;
12
+ --clay: #a0522d;
13
+ --wheat: #c8a96e;
14
+ --leaf: #4a7c59;
15
+ --sprout: #7dba84;
16
+ --sky: #e8f4ea;
17
+ --cream: #fdf6ec;
18
+ --ink: #1a1005;
19
+ --mist: #f5efe6;
20
+ }
21
+
22
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
23
+
24
+ body {
25
+ font-family: 'DM Sans', sans-serif;
26
+ background: var(--cream);
27
+ color: var(--ink);
28
+ min-height: 100vh;
29
+ overflow-x: hidden;
30
+ }
31
+
32
+ /* ── BACKGROUND TEXTURE ── */
33
+ body::before {
34
+ content: '';
35
+ position: fixed; inset: 0; z-index: 0;
36
+ background:
37
+ radial-gradient(ellipse 80% 60% at 10% 0%, rgba(74,124,89,0.08) 0%, transparent 60%),
38
+ radial-gradient(ellipse 60% 80% at 90% 100%, rgba(160,82,45,0.07) 0%, transparent 60%),
39
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60'%3E%3Ccircle cx='30' cy='30' r='1' fill='%235c3d1e' opacity='0.04'/%3E%3C/svg%3E");
40
+ pointer-events: none;
41
+ }
42
+
43
+ .wrapper {
44
+ position: relative; z-index: 1;
45
+ max-width: 960px;
46
+ margin: 0 auto;
47
+ padding: 2.5rem 1.5rem 4rem;
48
+ }
49
+
50
+ /* ── HEADER ── */
51
+ header {
52
+ display: flex;
53
+ align-items: flex-end;
54
+ gap: 1.2rem;
55
+ margin-bottom: 2.8rem;
56
+ padding-bottom: 1.6rem;
57
+ border-bottom: 2px solid rgba(92,61,30,0.12);
58
+ }
59
+ .logo-mark {
60
+ width: 52px; height: 52px;
61
+ background: var(--leaf);
62
+ border-radius: 14px;
63
+ display: grid; place-items: center;
64
+ font-size: 1.6rem;
65
+ box-shadow: 0 4px 18px rgba(74,124,89,0.3);
66
+ flex-shrink: 0;
67
+ }
68
+ header h1 {
69
+ font-family: 'Playfair Display', serif;
70
+ font-size: clamp(1.6rem, 4vw, 2.4rem);
71
+ font-weight: 900;
72
+ color: var(--soil);
73
+ line-height: 1;
74
+ }
75
+ header p {
76
+ font-size: 0.82rem;
77
+ color: var(--clay);
78
+ font-weight: 300;
79
+ margin-top: 0.3rem;
80
+ letter-spacing: 0.04em;
81
+ text-transform: uppercase;
82
+ }
83
+
84
+ /* ── CARD ── */
85
+ .card {
86
+ background: #fff;
87
+ border: 1.5px solid rgba(92,61,30,0.1);
88
+ border-radius: 20px;
89
+ padding: 2.2rem 2.4rem;
90
+ box-shadow: 0 8px 40px rgba(44,26,14,0.07);
91
+ }
92
+
93
+ .card-title {
94
+ font-family: 'Playfair Display', serif;
95
+ font-size: 1.05rem;
96
+ color: var(--earth);
97
+ margin-bottom: 1.6rem;
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 0.6rem;
101
+ }
102
+ .card-title::before {
103
+ content: '';
104
+ display: block;
105
+ width: 20px; height: 3px;
106
+ background: var(--sprout);
107
+ border-radius: 2px;
108
+ }
109
+
110
+ /* ── GRID ── */
111
+ .fields-grid {
112
+ display: grid;
113
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
114
+ gap: 1.1rem 1.4rem;
115
+ margin-bottom: 2rem;
116
+ }
117
+
118
+ .field {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 0.36rem;
122
+ }
123
+ .field label {
124
+ font-size: 0.76rem;
125
+ font-weight: 500;
126
+ color: var(--earth);
127
+ letter-spacing: 0.05em;
128
+ text-transform: uppercase;
129
+ }
130
+ .field-row {
131
+ display: flex;
132
+ align-items: stretch;
133
+ border: 1.5px solid rgba(92,61,30,0.18);
134
+ border-radius: 10px;
135
+ overflow: hidden;
136
+ transition: border-color 0.2s, box-shadow 0.2s;
137
+ }
138
+ .field-row:focus-within {
139
+ border-color: var(--leaf);
140
+ box-shadow: 0 0 0 3px rgba(74,124,89,0.12);
141
+ }
142
+ .field-row input {
143
+ flex: 1;
144
+ border: none; outline: none;
145
+ padding: 0.55rem 0.75rem;
146
+ font-family: 'DM Mono', monospace;
147
+ font-size: 0.88rem;
148
+ color: var(--ink);
149
+ background: transparent;
150
+ min-width: 0;
151
+ }
152
+ .field-row input::placeholder { color: #bbb; }
153
+ .unit-badge {
154
+ padding: 0 0.7rem;
155
+ background: var(--mist);
156
+ font-size: 0.68rem;
157
+ font-family: 'DM Mono', monospace;
158
+ color: var(--clay);
159
+ display: flex; align-items: center;
160
+ white-space: nowrap;
161
+ border-left: 1.5px solid rgba(92,61,30,0.1);
162
+ }
163
+
164
+ /* ── ACTIONS ── */
165
+ .actions {
166
+ display: flex;
167
+ gap: 1rem;
168
+ flex-wrap: wrap;
169
+ align-items: center;
170
+ }
171
+
172
+ .btn-predict {
173
+ font-family: 'DM Sans', sans-serif;
174
+ font-weight: 500;
175
+ font-size: 0.95rem;
176
+ padding: 0.75rem 2rem;
177
+ background: var(--leaf);
178
+ color: #fff;
179
+ border: none;
180
+ border-radius: 12px;
181
+ cursor: pointer;
182
+ letter-spacing: 0.03em;
183
+ transition: background 0.2s, transform 0.15s, box-shadow 0.2s;
184
+ box-shadow: 0 4px 14px rgba(74,124,89,0.3);
185
+ }
186
+ .btn-predict:hover { background: #3a6347; transform: translateY(-1px); box-shadow: 0 6px 20px rgba(74,124,89,0.35); }
187
+ .btn-predict:active { transform: translateY(0); }
188
+ .btn-predict:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
189
+
190
+ .btn-reset {
191
+ font-family: 'DM Sans', sans-serif;
192
+ font-size: 0.9rem;
193
+ padding: 0.75rem 1.4rem;
194
+ background: transparent;
195
+ color: var(--clay);
196
+ border: 1.5px solid rgba(92,61,30,0.2);
197
+ border-radius: 12px;
198
+ cursor: pointer;
199
+ transition: border-color 0.2s, color 0.2s;
200
+ }
201
+ .btn-reset:hover { border-color: var(--clay); color: var(--earth); }
202
+
203
+ .btn-sample {
204
+ font-size: 0.8rem;
205
+ color: var(--leaf);
206
+ background: none; border: none;
207
+ cursor: pointer;
208
+ text-decoration: underline;
209
+ text-underline-offset: 3px;
210
+ margin-left: auto;
211
+ }
212
+ .btn-sample:hover { color: var(--soil); }
213
+
214
+ /* ── RESULT ── */
215
+ #result-section {
216
+ display: none;
217
+ margin-top: 1.8rem;
218
+ }
219
+ #result-section.show { display: block; animation: fadeUp 0.4s ease; }
220
+
221
+ @keyframes fadeUp {
222
+ from { opacity: 0; transform: translateY(12px); }
223
+ to { opacity: 1; transform: translateY(0); }
224
+ }
225
+
226
+ .result-banner {
227
+ display: flex;
228
+ align-items: center;
229
+ gap: 1.2rem;
230
+ background: linear-gradient(135deg, var(--leaf) 0%, #3a6347 100%);
231
+ color: #fff;
232
+ padding: 1.4rem 1.8rem;
233
+ border-radius: 16px;
234
+ box-shadow: 0 8px 28px rgba(74,124,89,0.28);
235
+ }
236
+ .crop-icon { font-size: 2.6rem; }
237
+ .crop-label { font-size: 0.75rem; opacity: 0.8; text-transform: uppercase; letter-spacing: 0.08em; }
238
+ .crop-name {
239
+ font-family: 'Playfair Display', serif;
240
+ font-size: 2rem;
241
+ font-weight: 900;
242
+ line-height: 1.1;
243
+ text-transform: capitalize;
244
+ }
245
+
246
+ .top3 {
247
+ margin-top: 1.1rem;
248
+ display: flex;
249
+ gap: 0.8rem;
250
+ flex-wrap: wrap;
251
+ }
252
+ .top3-item {
253
+ background: var(--mist);
254
+ border: 1px solid rgba(92,61,30,0.1);
255
+ border-radius: 10px;
256
+ padding: 0.5rem 0.9rem;
257
+ font-size: 0.82rem;
258
+ }
259
+ .top3-item .t3-crop {
260
+ font-weight: 500;
261
+ text-transform: capitalize;
262
+ color: var(--soil);
263
+ }
264
+ .top3-item .t3-bar-wrap {
265
+ height: 4px; background: #e0d8cc;
266
+ border-radius: 2px; margin-top: 0.3rem;
267
+ overflow: hidden;
268
+ }
269
+ .top3-item .t3-bar {
270
+ height: 100%;
271
+ background: var(--sprout);
272
+ border-radius: 2px;
273
+ transition: width 0.6s ease;
274
+ }
275
+ .top3-item .t3-pct {
276
+ font-family: 'DM Mono', monospace;
277
+ font-size: 0.72rem;
278
+ color: var(--clay);
279
+ margin-top: 0.15rem;
280
+ }
281
+
282
+ /* ── ERROR ── */
283
+ .error-box {
284
+ background: #fef2f2;
285
+ border: 1px solid #fca5a5;
286
+ border-radius: 12px;
287
+ padding: 1rem 1.4rem;
288
+ color: #991b1b;
289
+ font-size: 0.88rem;
290
+ }
291
+
292
+ /* ── LOADING ── */
293
+ .spinner {
294
+ display: inline-block;
295
+ width: 16px; height: 16px;
296
+ border: 2px solid rgba(255,255,255,0.4);
297
+ border-top-color: #fff;
298
+ border-radius: 50%;
299
+ animation: spin 0.7s linear infinite;
300
+ vertical-align: middle;
301
+ margin-right: 0.4rem;
302
+ }
303
+ @keyframes spin { to { transform: rotate(360deg); } }
304
+
305
+ /* ── FOOTER ── */
306
+ footer {
307
+ margin-top: 3rem;
308
+ text-align: center;
309
+ font-size: 0.75rem;
310
+ color: var(--clay);
311
+ opacity: 0.6;
312
+ }
313
+
314
+ @media (max-width: 600px) {
315
+ .card { padding: 1.4rem 1.2rem; }
316
+ .fields-grid { grid-template-columns: 1fr 1fr; }
317
+ }
318
+ </style>
319
+ </head>
320
+ <body>
321
+ <div class="wrapper">
322
+
323
+ <header>
324
+ <div class="logo-mark">🌱</div>
325
+ <div>
326
+ <h1>CropSense</h1>
327
+ <p>Soil Analysis &amp; Crop Recommendation Engine</p>
328
+ </div>
329
+ </header>
330
+
331
+ <div class="card">
332
+ <div class="card-title">Enter Soil Parameters</div>
333
+
334
+ <form id="predict-form">
335
+ <div class="fields-grid">
336
+ {% for key in features %}
337
+ {% set info = feature_info[key] %}
338
+ <div class="field">
339
+ <label for="{{ key }}">{{ info.label }}</label>
340
+ <div class="field-row">
341
+ <input
342
+ type="number"
343
+ id="{{ key }}"
344
+ name="{{ key }}"
345
+ step="{{ info.step }}"
346
+ min="{{ info.min }}"
347
+ max="{{ info.max }}"
348
+ placeholder="{{ info.placeholder }}"
349
+ required
350
+ />
351
+ {% if info.unit %}
352
+ <span class="unit-badge">{{ info.unit }}</span>
353
+ {% endif %}
354
+ </div>
355
+ </div>
356
+ {% endfor %}
357
+ </div>
358
+
359
+ <div class="actions">
360
+ <button type="submit" class="btn-predict" id="submit-btn">
361
+ Analyse &amp; Predict
362
+ </button>
363
+ <button type="button" class="btn-reset" onclick="resetForm()">Clear</button>
364
+ <button type="button" class="btn-sample" onclick="loadSample()">Load sample values</button>
365
+ </div>
366
+ </form>
367
+
368
+ <div id="result-section">
369
+ <div id="result-content"></div>
370
+ </div>
371
+ </div>
372
+
373
+ <footer>Built with Flask + Scikit-learn &nbsp;Β·&nbsp; NIELIT Ropar &nbsp;Β·&nbsp; M.Tech AI</footer>
374
+ </div>
375
+
376
+ <script>
377
+ const SAMPLE = { N:175, P:36, K:216, ph:5.9, EC:0.15, S:0.28, Cu:15.69, Fe:114.20, Mn:56.87, Zn:31.28, B:28.62 };
378
+
379
+ function loadSample() {
380
+ Object.entries(SAMPLE).forEach(([k, v]) => {
381
+ const el = document.getElementById(k);
382
+ if (el) el.value = v;
383
+ });
384
+ }
385
+
386
+ function resetForm() {
387
+ document.getElementById('predict-form').reset();
388
+ const rs = document.getElementById('result-section');
389
+ rs.classList.remove('show');
390
+ }
391
+
392
+ const CROP_ICONS = {
393
+ wheat: '🌾', rice: '🌾', maize: '🌽', cotton: '🌿',
394
+ sugarcane: 'πŸŽ‹', potato: 'πŸ₯”', tomato: 'πŸ…', onion: 'πŸ§…',
395
+ mango: 'πŸ₯­', banana: '🍌', grapes: 'πŸ‡', apple: '🍎',
396
+ default: '🌱'
397
+ };
398
+
399
+ function cropIcon(name) {
400
+ const lower = (name || '').toLowerCase();
401
+ for (const [k, v] of Object.entries(CROP_ICONS)) {
402
+ if (lower.includes(k)) return v;
403
+ }
404
+ return CROP_ICONS.default;
405
+ }
406
+
407
+ document.getElementById('predict-form').addEventListener('submit', async function(e) {
408
+ e.preventDefault();
409
+ const btn = document.getElementById('submit-btn');
410
+ btn.disabled = true;
411
+ btn.innerHTML = '<span class="spinner"></span>Analysing…';
412
+
413
+ const formData = new FormData(this);
414
+
415
+ try {
416
+ const resp = await fetch('/predict', { method: 'POST', body: formData });
417
+ const data = await resp.json();
418
+ const rs = document.getElementById('result-section');
419
+ const rc = document.getElementById('result-content');
420
+
421
+ if (data.error) {
422
+ rc.innerHTML = `<div class="error-box">⚠️ ${data.error}</div>`;
423
+ } else {
424
+ let top3HTML = '';
425
+ if (data.top3 && data.top3.length > 1) {
426
+ top3HTML = `<div class="top3">` +
427
+ data.top3.map(t => `
428
+ <div class="top3-item">
429
+ <div class="t3-crop">${t.crop}</div>
430
+ <div class="t3-bar-wrap"><div class="t3-bar" style="width:${t.prob}%"></div></div>
431
+ <div class="t3-pct">${t.prob}%</div>
432
+ </div>`).join('') +
433
+ `</div>`;
434
+ }
435
+
436
+ rc.innerHTML = `
437
+ <div class="result-banner">
438
+ <div class="crop-icon">${cropIcon(data.crop)}</div>
439
+ <div>
440
+ <div class="crop-label">Recommended Crop</div>
441
+ <div class="crop-name">${data.crop}</div>
442
+ </div>
443
+ </div>
444
+ ${top3HTML}
445
+ `;
446
+
447
+ // Animate bars after render
448
+ setTimeout(() => {
449
+ document.querySelectorAll('.t3-bar').forEach(b => {
450
+ const w = b.style.width; b.style.width = '0';
451
+ requestAnimationFrame(() => { b.style.width = w; });
452
+ });
453
+ }, 50);
454
+ }
455
+
456
+ rs.classList.add('show');
457
+ rs.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
458
+ } catch (err) {
459
+ document.getElementById('result-content').innerHTML =
460
+ `<div class="error-box">⚠️ Network error: ${err.message}</div>`;
461
+ document.getElementById('result-section').classList.add('show');
462
+ } finally {
463
+ btn.disabled = false;
464
+ btn.textContent = 'Analyse & Predict';
465
+ }
466
+ });
467
+ </script>
468
+ </body>
469
+ </html>