PageSourceSearch

https://plant-health-scanner-sandy.vercel.app/js/app.js

js plant-health-scanner-sandy.vercel.app collected 2026-10-03 07:29:52 UTC 31,473 bytes, 709 lines download raw bytes

1const dropZone = document.getElementById('dropZone');
2const fileInput = document.getElementById('fileInput');
3const previewWrap = document.getElementById('previewWrap');
4const canvas = document.getElementById('plantCanvas');
5const ctx = canvas.getContext('2d');
6const scanLine = document.getElementById('scanLine');
7const scanStatus = document.getElementById('scanStatus');
8const analysisPanel = document.getElementById('analysisPanel');
9const resetBtn = document.getElementById('resetBtn');
10
11// ---- Scanner event listeners ----
12dropZone.addEventListener('click', () => fileInput.click());
13
14dropZone.addEventListener('dragover', (e) => {
15    e.preventDefault();
16    dropZone.classList.add('dragover');
17});
18
19dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover'));
20
21dropZone.addEventListener('drop', (e) => {
22    e.preventDefault();
23    dropZone.classList.remove('dragover');
24    if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
25});
26
27fileInput.addEventListener('change', (e) => {
28    if (e.target.files.length) handleFile(e.target.files[0]);
29});
30
31resetBtn.addEventListener('click', () => {
32    previewWrap.classList.remove('active');
33    analysisPanel.classList.remove('active');
34    dropZone.style.display = 'block';
35    fileInput.value = '';
36});
37
38function handleFile(file) {
39    if (!file.type.startsWith('image/')) {
40        alert('Please upload an image file.');
41        return;
42    }
43
44    const img = new Image();
45    img.onload = () => {
46        URL.revokeObjectURL(img.src);
47        processImage(img, img.width, img.height);
48    };
49    img.src = URL.createObjectURL(file);
50}
51
52/* Runs the scan on any drawable source (uploaded image or camera frame). */
53function processImage(source, srcW, srcH) {
54    {
55        const maxW = 640;
56        const scale = Math.min(1, maxW / srcW);
57        canvas.width = srcW * scale;
58        canvas.height = srcH * scale;
59        ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
60
61        dropZone.style.display = 'none';
62        previewWrap.classList.add('active');
63        scanLine.style.display = 'block';
64        analysisPanel.classList.remove('active');
65        if (window.PlantScene) window.PlantScene.startScan();
66
67        // Real CV pipeline: pixel analysis runs immediately; MobileNet
68        // identification loads on-device in parallel (skipped if the CDN
69        // is unreachable). Status text tracks the actual stages.
70        const stages = [
71            'Reading chlorophyll signatures...',
72            'Analyzing leaf color distribution...',
73            'Detecting water stress indicators...',
74            'Evaluating pest damage zones...',
75            'Running on-device neural network...',
76            'Generating care recommendations...'
77        ];
78
79        let stageIdx = 0;
80        scanStatus.textContent = stages[0];
81        const stageInterval = setInterval(() => {
82            stageIdx++;
83            if (stageIdx < stages.length) scanStatus.textContent = stages[stageIdx];
84        }, 600);
85
86        const metrics = window.PlantCV.analyze(ctx, canvas);
87        const identification = window.PlantCV.identify(canvas);
88        const minDelay = new Promise(r => setTimeout(r, 3600));
89
90        Promise.all([identification, minDelay]).then(([id]) => {
91            clearInterval(stageInterval);
92            scanLine.style.display = 'none';
93            scanStatus.textContent = 'Analysis complete ✅';
94            if (window.PlantScene) window.PlantScene.endScan();
95            showReport(metrics, id);
96        });
97    }
98}
99
100function showReport(m, id) {
101    const match = window.PlantSpecies.identify(id);
102    window.lastScan = { metrics: m, id, species: match.species, speciesMatched: match.matched };
103    analysisPanel.classList.add('active');
104
105    setBar('barHealth', 'valHealth', m.health, getHealthClass(m.health, 75, 50));
106    setBar('barWater', 'valWater', m.hydration, getHealthClass(m.hydration, 70, 40));
107    setBar('barLight', 'valLight', m.light, getHealthClass(m.light, 70, 40));
108    setBar('barLeaf', 'valLeaf', m.vitality, getHealthClass(m.vitality, 70, 45));
109    setBar('barPest', 'valPest', m.pest, getPestClass(m.pest));
110
111    renderIdentification(id, m);
112    renderSpecies();
113    renderSeverity(m);
114    generateRecommendations(m, id);
115    renderRemedies(m);
116    renderNutrients(m);
117    if (window.renderEconomics) window.renderEconomics(m);
118    if (window.resetDeepScan) window.resetDeepScan();
119    if (window.renderAssistant) window.renderAssistant();
120
121    const field = currentField();
122    window.PlantHistory.add(m, id, canvas, {
123        field: field,
124        lat: window.lastScanLocation ? window.lastScanLocation.lat : null,
125        lng: window.lastScanLocation ? window.lastScanLocation.lng : null
126    });
127    renderTrend(m, field);
128    renderHistory();
129}
130
131function currentField() {
132    const el = document.getElementById('fieldInput');
133    return el && el.value.trim() ? el.value.trim() : null;
134}
135
136function renderTrend(m, field) {
137    const box = document.getElementById('trendBox');
138    const prev = window.PlantHistory.previousInField(field);
139    if (!prev) {
140        box.style.display = 'none';
141        return;
142    }
143
144    const scope = field ? ` in ${field}` : '';
145    const when = new Date(prev.ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
146    const hDelta = m.health - prev.health;
147
148    // Prefer a severity-progression message when severity data exists —
149    // rising disease severity is the agronomic early-warning signal.
150    if (typeof m.severity === 'number' && typeof prev.severity === 'number') {
151        const sDelta = m.severity - prev.severity;
152        if (sDelta >= 3) {
153            box.className = 'trend trend-down';
154            box.textContent = `📈 Disease severity rising${scope}: ${prev.severity}% → ${m.severity}% since ${when}. Act now — progression is the early-warning sign; treat before it accelerates.`;
155            box.style.display = 'block';
156            return;
157        }
158        if (sDelta <= -3) {
159            box.className = 'trend trend-up';
160            box.textContent = `📉 Disease severity falling${scope}: ${prev.severity}% → ${m.severity}% since ${when}
160. Your treatment is working — keep it up.`;
161            box.style.display = 'block';
162            return;
163        }
164    }
165
166    if (hDelta > 2) {
167        box.className = 'trend trend-up';
168        box.textContent = `📈 Health up ${hDelta} points${scope} since ${when} — whatever you changed, keep doing it.`;
169    } else if (hDelta < -2) {
170        box.className = 'trend trend-down';
171        box.textContent = `📉 Health down ${Math.abs(hDelta)} points${scope} since ${when} — check the remedy guide below.`;
172    } else {
173        box.className = 'trend trend-flat';
174        box.textContent = `➡️ Health steady${scope} since ${when}.`;
175    }
176    box.style.display = 'block';
177}
178
179function renderHistory() {
180    const section = document.getElementById('historySection');
181    const strip = document.getElementById('historyStrip');
182    const list = window.PlantHistory.load();
183
184    if (!list.length) {
185        section.style.display = 'none';
186        return;
187    }
188
189    strip.innerHTML = '';
190    list.forEach((e) => {
191        const card = document.createElement('div');
192        card.className = 'history-card';
193
194        const img = document.createElement('img');
195        img.src = e.thumb;
196        img.alt = e.label || 'scanned plant';
197        card.appendChild(img);
198
199        const meta = document.createElement('div');
200        meta.className = 'history-meta';
201
202        const date = document.createElement('span');
203        date.className = 'history-date';
204        date.textContent = new Date(e.ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
205        meta.appendChild(date);
206
207        const score = document.createElement('span');
208        const cls = e.health > 75 ? 'health-good' : e.health > 50 ? 'health-warn' : 'health-bad';
209        score.className = 'history-score ' + cls;
210        score.textContent = e.health + '%';
211        meta.appendChild(score);
212
213        card.appendChild(meta);
214
215        const bar = document.createElement('div');
216        bar.className = 'history-bar';
217        const fill = document.createElement('div');
218        fill.className = 'history-fill';
219        fill.style.width = e.health + '%';
220        bar.appendChild(fill);
221        card.appendChild(bar);
222
223        if (typeof e.severity === 'number') {
224            const sv = document.createElement('span');
225            sv.className = 'history-sev ' + (e.severity < 10 ? 'health-good' : e.severity < 25 ? 'health-warn' : 'health-bad');
226            sv.textContent = 'sev ' + e.severity + '%';
227            card.appendChild(sv);
228        }
229        if (e.field) {
230            const f = document.createElement('span');
231            f.className = 'history-field';
232            f.textContent = '🌾 ' + e.field;
233            if (e.lat != null && e.lng != null) f.title = e.lat.toFixed(4) + ', ' + e.lng.toFixed(4);
234            card.appendChild(f);
235        }
236
237        strip.appendChild(card);
238    });
239
240    renderFieldProgression();
241    if (window.refreshFieldList) window.refreshFieldList();
242    if (window.renderFieldMap) window.renderFieldMap();
243    section.style.display = 'block';
244}
245
246/* Per-field severity progression — the agronomic early-warning view.
247 * Shows how disease severity has moved across scans in the active field. */
248function renderFieldProgression() {
249    const box = document.getElementById('fieldProgression');
250    const field = currentField();
251    box.innerHTML = '';
252    if (!field) { box.style.display = 'none'; return; }
253
254    const scans = window.PlantHistory.forField(field).filter(e => typeof e.severity === 'number');
255    if (scans.length < 2) { box.style.display = 'none'; return; }
256
257    const first = scans[0].severity, last = scans[scans.length - 1].severity;
258    const dir = last - first;
259    const arrow = dir > 2 ? '↑ worsening' : dir < -2 ? '↓ improving' : '→ stable';
260    const dirCls = dir > 2 ? 'urgent' : dir < -2 ? 'good' : 'flat';
261
262    // ---- season stats ----
263    const peak = scans.reduce((a, b) => (b.severity > a.severity ? b : a), scans[0]);
264    const days = Math.max(0, Math.round((scans[scans.length - 1].ts - scans[0].ts) / 86400000));
265    const fmt = (ts) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
266
267    // ---- time-based SVG chart: severity (red) + health (green), action band 15–20% ----
268    const W = 600, H = 190, L = 34, R = 12, T = 14, B = 30;
269    const t0 = scans[0].ts, t1 = scans[scans.length - 1].ts;
270    const span = Math.max(1, t1 - t0);
271    const sameDay = span < 60000;                      // all scans within a minute → space by index
272    const x = (e, i) => L + (sameDay ? i / (scans.length - 1) : (e.ts - t0) / span) * (W - L - R);
273    const y = (v) => T + (1 - Math.max(0, Math.min(100, v)) / 100) * (H - T - B);
274    const pts = (key) => scans.map((e, i) => x(e, i).toFixed(1) + ',' + y(e[key]).toFixed(1)).join(' ');
275    const sevColor = (v) => v < 10 ? '#00e676' : v < 25 ? '#ffab00' : '#ff5252';
276
277    const grid = [0, 25, 50, 75, 100].map(v =>
278        `<line x1="${L}" y1="${y(v).toFixed(1)}" x2="${W - R}" y2="${y(v).toFixed(1)}" class="fp-grid"/>` +
279        `<text x="${L - 6}" y="${(y(v) + 4).toFixed(1)}" class="fp-ylab">${v}</text>`).join('');
280    const band = `<rect x="${L}" y="${y(20).toFixed(1)}" width="${W - L - R}" height="${(y(15) - y(20)).toFixed(1)}" class="fp-band"/>` +
281        `<text x="${W - R - 4}" y="${(y(20) - 3).toFixed(1)}" class="fp-bandlab">action threshold 15–20%</text>`;
282    const dots = scans.map((e, i) =>
283        `<circle cx="${x(e, i).toFixed(1)}" cy="${y(e.severity).toFixed(1)}" r="4.5" fill="${sevColor(e.severity)}" class="fp-dot"><title>${fmt(e.ts)}: severity ${e.severity}%, health ${e.health}%</title></circle>`).join('');
284    const xlabs = [scans[0], scans[Math.floor((scans.length - 1) / 2)], scans[scans.length - 1]]
285        .filter((e, i, a) => a.indexOf(e) === i)
286        .map((e) => { const i = scans.indexOf(e); return `<text x="${x(e, i).toFixed(1)}" y="${H - 8}" class="fp-xlab" text-anchor="${i === 0 ? 'start' : i === scans.length - 1 ? 'end' : 'middle'}">${fmt(e.ts)}</text>`; }).join('');
287
288    const svg =
289        `<svg viewBox="0 0 ${W} ${H}" class="fp-chart" role="img" aria-label="Season trend of disease severity and health for ${escapeAttr(field)}">` +
290            grid + band +
291            `<polyline points="${pts('health')}" class="fp-line-health"/>` +
292            `<polyline points="${pts('severity')}" class="fp-line-sev"/>` +
293            dots + xlabs +
294        `</svg>`;
295
296    box.innerHTML =
297        `<div class="fp-head"><strong>🌾 ${escapeAttr(field)}</strong> · disease severity ` +
298        `<span class="fp-dir ${dirCls}">${first}% → ${last}% ${arrow}</span> ` +
299        `<span class="fp-count">(${scans.length} scans over ${days} day${days === 1 ? '' : 's'})</span></div>` +
300        svg +
301        `<div class="fp-legend"><span><i class="sev"></i>Disease severity</span><span><i class="health"></i>Overall health</span>` +
302        `<span class="fp-peak">Peak severity <b>${peak.severity}%</b> on ${fmt(peak.ts)}</span></div>`;
303    box.style.display = 'block';
304}
305
306function escapeAttr(s) {
307    return String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
308}
309
310/* ---- Field bar: persist field name, opt-in GPS tagging ---- */
311(function () {
312    const fieldInput = document.getElementById('fieldInput');
313    const geoBtn = document.getElementById('fieldGeo');
314    const geoStatus = document.getElementById('fieldGeoStatus');
315    if (!fieldInput) return;
316
317    const FKEY = 'plantvision.currentField';
318    try { fieldInput.value = localStorage.getItem(FKEY) || ''; } catch (e) { /* ignore */ }
319
320    function refreshFieldList() {
321        const dl = document.getElementById('fieldList');
322        if (!dl || !window.PlantHistory) return;
323        dl.innerHTML = window.PlantHistory.fields().map(f => `<option value="${escapeAttr(f)}">`).join('');
324    }
325    refreshFieldList();
326
327    fieldInput.addEventListener('change', () => {
328        try { localStorage.setItem(FKEY, fieldInput.value.trim()); } catch (e) { /* ignore */ }
329        if (document.getElementById('historySection').style.display !== 'none') renderFieldProgression();
330    });
331
332    geoBtn.addEventListener('click', () => {
333        if (!navigator.geolocation) { geoStatus.textContent = 'GPS not available on this device'; return; }
334        geoStatus.textContent = 'Locating…';
335        navigator.geolocation.getCurrentPosition(
336            (pos) => {
337                window.lastScanLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude };
338                geoStatus.textContent = `📍 ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)} — scans now geotagged`;
339                geoBtn.classList.add('active');
340            },
341            () => { geoStatus.textContent = 'Location permission denied'; },
342            { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
343        );
344    });
345
346    window.refreshFieldList = refreshFieldList;
347})();
348
349document.getElementById('clearHistory').addEventListener('click', () => {
350    window.PlantHistory.clear();
351    renderHistory();
352});
353
354document.getElementById('addGardenBtn').addEventListener('click', () => {
355    const scan = window.lastScan;
356    let name = 'My plant';
357    if (scan && scan.species && scan.species.id !== 'generic') {
358        name = scan.species.name;
359    } else if (scan && scan.id && scan.id.labels && scan.id.labels.length) {
360        name = scan.id.labels[0].name.replace(/\b\w/g, c => c.toUpperCase());
361    }
362    // Seed watering cadence from the species: succulents/cacti ~14 days, else 7.
363    const cat = scan && scan.species ? scan.species.category : '';
364    const interval = (cat === 'succulent' || cat === 'cactus') ? 14 : 7;
365    if (window.addToGarden) window.addToGarden(name, interval);
366});
367
368renderHistory();
369
370function renderRemedies(m) {
371    const box = document.getElementById('remedyList');
372    box.innerHTML = '';
373
374    window.PlantRemedies.diagnose(m).forEach((r, i) => {
375        const card = document.createElement('details');
376        card.className = 'remedy';
377        if (i === 0) card.open = true;
378
379        const summary = document.createElement('summary');
380        summary.textContent = `${r.icon} ${r.title}`;
381        card.appendChild(summary);
382
383        const signs = document.createElement('p');
384        signs.className = 'remedy-signs';
385        signs.textContent = r.signs;
386        card.appendChild(signs);
387
388        const ol = document.createElement('ol');
389        r.steps.forEach(s => {
390            const li = document.createElement('li');
391            li.textContent = s;
392            ol.appendChild(li);
393        });
394        card.appendChild(ol);
395
396        const prevent = document.createElement('p');
397        prevent.className = 'remedy-prevent';
398        prevent.textContent = '🛡️ Prevention: ' + r.prevent;
399        card.appendChild(prevent);
400
401        box.appendChild(card);
402    });
403
404    document.getElementById('remedyBox').style.display = 'block';
405}
406
407function renderSpecies() {
408    const scan = window.lastScan;
409    const sp = scan.species;
410    const box = document.getElementById('speciesBox');
411
412    document.getElementById('speciesEmoji').textContent = sp.emoji;
413    document.getElementById('speciesName').textContent = sp.name;
414
415    const matchEl = document.getElementById('speciesMatch');
416    if (sp.id === 'generic') {
417        matchEl.textContent = 'Couldn’t identify the species automatically — pick it below for tailored care.';
418    } else if (scan.speciesMatched) {
419        matchEl.textContent = 'Identified from your photo · care tailored to this plant';
420    } else {
421        matchEl.textContent = 'You selected this plant · care tailored below';
422    }
423
424    document.getElementById('speciesFacts').innerHTML =
425        '<div class="sp-fact"><span>☀️</span><div><strong>Light</strong>' + sp.light + '</div></div>' +
426        '<div class="sp-fact"><span>💧</span><div><strong>Water</strong>' + sp.water + '</div></div>' +
427        '<div class="sp-fact"><span>💨</span><div><strong>Humidity</strong>' + sp.humidity + '</div></div>' +
428        '<div class="sp-fact"><span>🐾</span><div><strong>Pets</strong>' + sp.pets + '</div></div>';
429
430    const pick = document.getElementById('speciesPick');
431    if (!pick.dataset.filled) {
432        let opts = '<option value="generic">— select / correct —</option>';
433        window.PlantSpecies.all().forEach(s => {
434            opts += '<option value="' + s.id + '">' + s.emoji + ' ' + s.name + '</option>';
435        });
436        pick.innerHTML = opts;
437        pick.dataset.filled = '1';
438        pick.addEventListener('change', () => {
439            const chosen = window.PlantSpecies.byId(pick.value);
440            window.lastScan.species = chosen;
441            window.lastScan.speciesMatched = false;
442            window.lastScan.speciesManual = true;
443            renderSpecies();
444            generateRecommendations(window.lastScan.metrics, window.lastScan.id);
445            if (window.renderAssistant) window.renderAssistant();
446        });
447    }
448    pick.value = sp.id;
449    box.style.display = 'block';
450}
451
452// Exposed so the deep scan can refine the species after it identifies a crop.
453window.renderSpeciesPublic = renderSpecies;
454window.regenerateRecommendations = function () {
455    if (window.lastScan) generateRecommendations(window.lastScan.metrics, window.lastScan.id);
456};
457
458/* Disease severity band — the agronomic headline. Maps % leaf area
459 * affected to the standard severity classes plant pathologists use, and
460 * frames it against the ~15–20% economic action threshold common for
461 * foliar disease decisions. */
462function renderSeverity(m) {
463    const box = document.getElementById('severityBox');
464    if (typeof m.severity !== 'number') { box.style.display = 'none'; return; }
465
466    const sev = m.severity;
467    let cls, note, noteCls = '';
468    if (sev < 3) {
469        cls = { label: 'Healthy', key: 'healthy' };
470        note = 'Negligible symptomatic tissue — no disease action needed. Keep scouting on your normal schedule.';
471    } else if (sev < 10) {
472        cls = { label: 'Trace', key: 'trace' };
473        note = 'A trace of affected tissue (below the action threshold). Monitor closely and re-scan in a few days to catch any spread early.';
474    } else if (sev < 25) {
475        cls = { label: 'Mild', key: 'mild' };
476        note = 'Approaching the ~15–20% action threshold used for many foliar diseases — plan a treatment and remove affected leaves before it advances.';
477        noteCls = 'action';
478    } else if (sev < 50) {
479        cls = { label: 'Moderate', key: 'moderate' };
480        note = 'Above the economic action threshold — treat promptly to protect yield, isolate the plant, and follow the remedy guide below.';
481        noteCls = 'action';
482    } else {
483        cls = { label: 'Severe', key: 'severe' };
484        note = 'Severe infection — a large share of leaf area is affected. Treat immediately and remove/destroy the worst tissue; consider whether the plant is worth saving vs. removing to protect neighbours.';
485        noteCls = 'urgent';
486    }
487
488    document.getElementById('severityPct').textContent = sev + '%';
489    const classEl = document.getElementById('severityClass');
490    classEl.textContent = cls.label;
491    classEl.className = 'severity-class sev-' + cls.key;
492    document.getElementById('severityMarker').style.left = sev + '%';
493
494    let noteText = note;
495    if (typeof m.confidence === 'number' && m.confidence < 55) {
496        noteText += ' (Low scan confidence — confirm with a closer, well-lit photo.)';
497    }
498    const noteEl = document.getElementById('severityNote');
499    noteEl.textContent = noteText;
500    noteEl.className = 'severity-note' + (noteCls ? ' ' + noteCls : '');
501    box.style.display = 'block';
502}
503
504function renderIdentification(id, m) {
505    const idBox = document.getElementById('idBox');
506    const idText = document.getElementById('idText');
507
508    let base;
509    if (id && id.labels && id.labels.length) {
510        const top = id.labels.filter(l => l.prob >= 5).slice(0, 2)
511            .map(l => `${l.name} (${l.prob}%)`).join(' · ');
512        base = id.looksLikePlant
513            ? `MobileNet sees: ${top || id.labels[0].name}`
514            : `⚠️ This may not be a plant — MobileNet sees: ${top || id.labels[0].name}`;
515    } else if (m.coverage < 8) {
516        base = '⚠️ Very little vegetation detected in this photo — results may not be meaningful.';
517    } else {
518        base = `Pixel analysis only (neural network unavailable) · vegetation coverage ${m.coverage}%`;
519    }
520
521    // Analysis-confidence badge so the user knows how much to trust the scan.
522    const conf = typeof m.confidence === 'number' ? m.confidence : null;
523    if (conf !== null) {
524        base += conf < 55
525            ? ` · ⚠️ low confidence (${conf}%) — retake in bright, even light with the plant filling the frame`
526            : ` · confidence ${conf}%`;
527    }
528    idText.textContent = base;
529    idBox.style.display = 'block';
530}
531
532function getHealthClass(value, goodThreshold, warnThreshold) {
533    if (value > goodThreshold) return 'health-good';
534    if (value > warnThreshold) return 'health-warn';
535    return 'health-bad';
536}
537
538function getPestClass(value) {
539    if (value < 20) return 'health-good';
540    if (value < 50) return 'health-warn';
541    return 'health-bad';
542}
543
544function setBar(barId, valId, value, colorClass) {
545    document.getElementById(barId).style.width = value + '%';
546    const valEl = document.getElementById(valId);
547    valEl.textContent = value + '%';
548    valEl.className = 'metric-value ' + colorClass;
549}
550
551function generateRecommendations(m, id) {
552    const recList = document.getElementById('recList');
553    recList.innerHTML = '';
554    const tips = [];
555
556    // Species-aware advice first — corrects generic tips for this plant.
557    const species = window.lastScan && window.lastScan.species;
558    if (species && species.id !== 'generic') {
559        window.PlantSpecies.adviceFor(species, m).forEach(t => tips.push(t));
560    }
561
562    if (m.coverage < 8) tips.push('Retake the photo closer to the plant so leaves fill most of the frame.');
563    if (id && id.labels && !id.looksLikePlant) tips.push('The neural network did not recognize a plant here — results are based on color analysis only.');
564    if (m.hydration < 50) tips.push('Foliage color looks pale or dry — check the soil and increase watering frequency.');
565    if (m.vitality < 60) tips.push('Significant yellowing/browning detected — remove damaged leaves and apply nitrogen-rich fertilizer.');
566    if (m.light < 45) tips.push('The photo is quite dark — if the plant lives here, move it to a brighter spot or add a grow light.');
567    if (m.light > 85) tips.push('Strong light detected — watch for leaf scorch and provide some midday shade.');
568    if (m.mildew >= 6) tips.push('A pale, powdery film was detected on the foliage — likely powdery mildew. Improve airflow, avoid wetting leaves, and spray a baking-sod
568a or potassium-bicarbonate solution weekly.');
569    if (m.mottle >= 45 && m.pest > 25) tips.push('Spotting / mottled texture detected across the leaves — a sign of leaf-spot disease, mosaic virus or early pest damage. Run the Deep Disease Scan below for a specific diagnosis.');
570    if (m.necroticRatio >= 0.12) tips.push('Dark necrotic lesions detected — remove and bin affected leaves (do not compost) and keep foliage dry to slow the spread.');
571    if (m.pest > 40) tips.push('Clustered damage spots detected — inspect leaf undersides for pests and consider a neem oil treatment.');
572    else if (m.pest > 25 && m.mottle < 45) tips.push('Some leaf damage detected — keep an eye on it over the next week.');
573    if (typeof m.confidence === 'number' && m.confidence < 55) tips.push('Low scan confidence — for a more reliable reading, retake the photo in bright, even light with a single leaf or plant filling the frame.');
574    if (tips.length === 0) tips.push('Your plant looks great! Keep up the current care routine.');
575
576    tips.forEach(t => {
577        const li = document.createElement('li');
578        li.textContent = t;
579        recList.appendChild(li);
580    });
581}
582
583/* ---- Scroll reveal ---- */
584const revealObserver = new IntersectionObserver((entries) => {
585    entries.forEach((entry) => {
586        if (entry.isIntersecting) {
587            entry.target.classList.add('visible');
588            revealObserver.unobserve(entry.target);
589        }
590    });
591}, { threshold: 0.12 });
592
593document.querySelectorAll('.reveal').forEach((el, i) => {
594    el.style.transitionDelay = (i % 4) * 0.08 + 's';
595    revealObserver.observe(el);
596});
597
598/* ---- Animated hero counters ---- */
599const counterObserver = new IntersectionObserver((entries) => {
600    entries.forEach((entry) => {
601        if (!entry.isIntersecting) return;
602        counterObserver.unobserve(entry.target);
603
604        const el = entry.target;
605        const target = parseInt(el.dataset.count, 10);
606        const duration = 1400;
607        const start = performance.now();
608
609        function tick(now) {
610            const p = Math.min((now - start) / duration, 1);
611            const eased = 1 - Math.pow(1 - p, 3);
612            el.textContent = Math.round(target * eased).toLocaleString();
613            if (p < 1) requestAnimationFrame(tick);
614        }
615        requestAnimationFrame(tick);
616    });
617}, { threshold: 0.6 });
618
619document.querySelectorAll('.stat-num').forEach((el) => counterObserver.observe(el));
620
621/* ---- 3D tilt cards ---- */
622const supportsHover = window.matchMedia('(hover: hover)').matches;
623if (supportsHover) {
624    document.querySelectorAll('.tilt').forEach((card) => {
625        card.addEventListener('mousemove', (e) => {
626            const r = card.getBoundingClientRect();
627            const x = (e.clientX - r.left) / r.width - 0.5;
628            const y = (e.clientY - r.top) / r.height - 0.5;
629            const max = card.classList.contains('feat') ? 8 : 3;
630            card.style.transform =
631                'perspective(900px) rotateY(' + (x * max) + 'deg) rotateX(' + (-y * max) + 'deg) translateY(-2px)';
632        });
633        card.addEventListener('mouseleave', () => {
634            card.style.transform = '';
635        });
636    });
637}
638
639/* ---- Plant Doctor UI: narrative + Q&A through the pluggable assistant ---- */
640(function () {
641    const box = document.getElementById('doctorBox');
642    if (!box || !window.PlantAssistant) return;
643    const narrativeEl = document.getElementById('doctorNarrative');
644    const engineEl = document.getElementById('doctorEngine');
645    const answerEl = document.getElementById('doctorAnswer');
646    const noteEl = document.getElementById('doctorNote');
647    const form = document.getElementById('doctorForm');
648    const qEl = document.getElementById('doctorQ');
649    let seq = 0;
650
651    function showEngine(engine, fellBack) {
652        const isModel = engine.id === 'local-llm';
653        engineEl.textContent = engine.label() + (fellBack ? ' (fallback)' : '');
654        engineEl.className = 'doctor-engine' + (isModel ? ' model' : '');
655        noteEl.textContent = isModel
656            ? 'Answers come from a local model running on this device — nothing is uploaded.'
657            : 'Runs on-device with no model loaded. A Gemma-class local model can be plugged in later (see README: Plug in a local model).';
658    }
659
660    window.renderAssistant = async function () {
661        if (!window.lastScan) return;
662        const my = ++seq;
663        box.style.display = 'block';
664        narrativeEl.textContent = 'Thinking…';
665        answerEl.classList.remove('active');
666        answerEl.textContent = '';
667        try {
668            const r = await window.PlantAssistant.narrative();
669            if (my !== seq) return;               // a newer render superseded this one
670            narrativeEl.textContent = r.text;
671            showEngine(r.engine, r.fellBack);
672        } catch (e) {
673            if (my !== seq) return;
674            narrativeEl.textContent = 'The assistant could not summarise this scan.';
675        }
676    };
677
678    form.addEventListener('submit', async (e) => {
679        e.preventDefault();
680        const q = qEl.value.trim();
681        if (!q) return;
682        answerEl.textContent = 'Thinking…';
683        answerEl.classList.add('active');
684        try {
685            const r = await window.PlantAssistant.ask(q);
686            answerEl.textContent = r.text;
687            showEngine(r.engine, r.fellBack);
688        } catch (err) {
689            answerEl.textContent = 'Sorry — I could not answer that.';
690        }
691    });
692})();
693
694/* Nutrient signals panel — indicators consistent with N / Mg-Fe / P shortfalls. */
695function renderNutrients(m) {
696    const box = document.getElementById('nutrientBox');
697    const list = document.getElementById('nutrientList');
698    if (!box || !window.PlantNutrients) return;
699    const hints = window.PlantNutrients.assess(m);
700    if (!hints.length) { box.style.display = 'none'; list.innerHTML = ''; return; }
701    list.innerHTML = hints.map(h =>
702        '<div class="nutrient-item">' +
703            '<div class="nutrient-title">' + h.icon + ' ' + h.title +
704                ' <span class="nutrient-level ' + h.level + '">' + h.level + '</span></div>' +
705            '<div class="nutrient-sign">' + h.sign + '</div>' +
706            '<div class="nutrient-action">' + h.action + '</div>' +
707        '</div>').join('');
708    box.style.display = 'block';
709}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.