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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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.