Ineema
Centre d'intelligence agricole · Satellite, terrain et IA
🛰️ Satellite live
🌧️ Pluie saison
📡 Images terrain
🤖 Conseil terrain
Portail producteur
🤖 Assistant Ineema Pret · conseiller terrain
Demandez simplement
Est-ce que mon champ va bien aujourd'hui ?
Est-ce qu'il a assez plu dans ma zone ?
Mes feuilles jaunissent : que dois-je faire ?
Dois-je arroser maintenant ou attendre ?
Cette parcelle peut-elle bien produire ?
Donne-moi un conseil simple pour cette semaine.
Ineema parle simplement. Posez une question avec le nom du village, de la region ou du champ. L'assistant vous donne une reponse claire : quoi verifier, quoi faire et quand demander une visite terrain.
🏔️ Relief agricole 3D
Glisser pour tourner · molette pour zoomer · clic droit pour incliner
🗂️ Carte satellite & repères
🟢 Santé végétation
≥ 0.55 Tres bon
0.40 – 0.55 Bon
0.25 – 0.40 Moyen
0.10 – 0.25 Stress
< 0.10 Tres faible
Villages
Limites
Diagnostic regional Ineema
📍 Analyse de parcelle
📅 Historique de santé du champ
2013 (Landsat)2019 (Sentinel-2)Aujourd hui
// ── Translations ──────────────────────────────────────────────────────────── const OT = { en: { sub: "Centre d'intelligence agricole · Satellite, terrain et IA", farmerLink: "Portail producteur", country: "Pays", ph: "Rechercher un pays...", prov: "Region", dist: "Cercle / district optionnel", year: "Campagne d analyse", btn: "Analyser cette zone", loading: "Chargement des limites administratives...", analysing: "Analyse satellite Ineema en cours...", needCountry: "Selectionnez d abord un pays", needProv: "Selectionnez une region ou un district", noData: "Aucune donnee satellite pour cette zone / annee", errBoundary: "Recherche administrative indisponible — utilisez les coordonnees de la parcelle", resTitle: "Diagnostic regional Ineema", ndvi: "Vigueur vegetation (NDVI)", evi: "Vegetation amelioree (EVI)", mndwi: "Indice eau (MNDWI)", rain: "Pluie annuelle", sar: "Radar (SAR)", modis: "Temperature (MODIS)", trend: "Tendance NDVI (2013 – aujourd hui)", recommend: "Recommandations", area: "Surface", coords: "Coordonnees du centre", farmerCount: "Producteurs inscrits", adminLevel: "Niveau administratif", source: "Source des donnees", mm: "mm / an", km2: "km²", soilMoist: "Humidite du sol (VV)", vegDens: "Densite vegetation (VH)", sumT: "Temperature chaude (jour)", winT: "Temperature froide (nuit)", frost: "Risque de froid", frostY: "Oui — risque possible", frostN: "Pas de risque", good: "Bon", mod: "Moyen", low: "Faible", water: "Eau presente", drought: "Risque secheresse", district: "District", province: "Region", country2: "Pays", village: "Village", vil: "Village optionnel", savi: "Soil-Adj. Vegetation (SAVI)", registeredPlots: "Parcelles producteur enregistrees", fieldUnit: "parcelles en base", legVil: "Villages", legProv: "Regions", noPlots: "Aucune parcelle enregistree", loadingPlots: "Chargement des parcelles…", detectBtn: "🛰️ Detecter automatiquement les parcelles", detecting: "🛰️ Detection satellite des parcelles…", detectedFields: "Parcelles detectees automatiquement", detectedUnit: "parcelles cartographiees", farmers: "Producteurs inscrits", noFarmers: "Aucun producteur inscrit dans cette zone.", joined: "Inscrit", fields2: "parcelles", population: "Population", popDens: "personnes / km²", popSrc: "WorldPop 100m", landcover: "Occupation du sol", cropArea: "Cultures", treeArea: "Foret / arbres", builtArea: "Bati / urbain", waterArea: "Eau", bareArea: "Sol nu", grassArea: "Herbe / arbustes", snowArea: "Neige / glace", floodedArea: "Vegetation inondee", terrain: "Terrain (altitude)", elevMean: "Altitude moyenne", elevRange: "Amplitude", slopeMean: "Pente moyenne", flatLand: "Terrain plat — adapte a la mecanisation", gentleSlope: "Pente douce — adaptee a la plupart des cultures", steepSlope: "Pente forte — risque d erosion", waterBodies: "Plans d eau permanents", waterHa: "ha d eau", cropCal: "Profil saisonnier NDVI (calendrier cultural)", cropIntel: "Information culture", months: { 1: "Jan", 2: "Fev", 3: "Mar", 4: "Avr", 5: "Mai", 6: "Juin", 7: "Juil", 8: "Aout", 9: "Sept", 10: "Oct", 11: "Nov", 12: "Dec", }, insuffData: "Donnees satellite insuffisantes pour les recommandations.", shrubArea: "Arbustes", lcDominant: "Occupation dominante du sol", lcTotal: "de la surface totale", // Layer panel (Step 5) lpTitle: "🗂️ Carte satellite & reperes", lpBaseMap: "Fonds de carte", lpLabels: "🏷️ Noms et etiquettes", lpSatTitle: "Donnees satellite GEE", lpOnDemand: "calcul a la demande · environ 30 a 60 s", lpLandCover: "🌍 Occupation du sol", lpNdvi: "🟢 Vigueur vegetation NDVI", lpWater: "💧 Humidite / eau de surface", lpBareSoil: "🏜️ Sol nu / zone recoltee", lpCropType: "🌾 Culture probable", lpForest: "🌲 Arbres / vegetation dense", lpEstimate: "indicatif", lpContextTitle: "Reperes terrain", lpRoads: "🛣️ Routes et pistes", lpSettlements: "🏘️ Villages et localites", lpRivers: "🌊 Cours d'eau", lpOsmNote: "Les reperes viennent d'OpenStreetMap. Si une piste ou un village manque, la carte satellite reste la reference visuelle.", }, fa: { sub: "داشبورد افسر — اطلاعات منطقه‌ای", farmerLink: "برنامه دهقان", country: "کشور", ph: "جستجوی کشور...", prov: "ولایت / منطقه", dist: "ولسوالی (اختیاری)", year: "سال تحلیل", btn: "تحلیل منطقه", loading: "بار کردن داده‌های سرحد...", analysing: "تحلیل ماهواره‌ای جریان دارد...", needCountry: "لطفاً ابتدا یک کشور انتخاب کنید", needProv: "لطفاً ولایت یا ولسوالی انتخاب کنید", noData: "داده‌های ماهواره‌ای برای این منطقه/سال موجود نیست", errBoundary: "بار کردن داده‌های سرحد ناموفق — نام کشور را بررسی کنید", resTitle: "نتایج تحلیل منطقه‌ای", ndvi: "صحت پوشش گیاهی (NDVI)", evi: "پوشش گیاهی پیشرفته (EVI)", mndwi: "شاخص آب (MNDWI)", rain: "باران سالانه", sar: "رادار (SAR)", modis: "دما (MODIS)", trend: "روند NDVI (۲۰۱۳ تا کنون)", recommend: "توصیه‌ها", area: "مساحت", coords: "مختصات مرکزی", farmerCount: "دهقانان ثبت‌شده", adminLevel: "سطح اداری", source: "منبع داده", mm: "میلی‌متر/سال", km2: "کیلومتر مربع", soilMoist: "رطوبت خاک (VV)", vegDens: "تراکم پوشش (VH)", sumT: "دمای تابستان (روز)", winT: "دمای زمستان (شب)", frost: "خطر یخبندان", frostY: "بله — یخبندان محتمل", frostN: "بدون خطر یخبندان", good: "خوب", mod: "متوسط", low: "کم", water: "آب موجود", drought: "خطر خشکسالی", district: "ولسوالی", province: "ولایت", country2: "کشور", village: "قریه", vil: "قریه (اختیاری)", savi: "پوشش گیاهی تعدیل‌شده (SAVI)", registeredPlots: "زمین‌های ثبت‌شده", fieldUnit: "مزرعه در DB", legVil: "قریه‌ها", legProv: "ولایت‌ها", noPlots: "هیچ زمینی ثبت نشده", loadingPlots: "بار کردن زمین‌ها…", detectBtn: "🛰️ شناسایی خودکار زمین‌ها از ماهواره", detecting: "🛰️ شناسایی زمین‌ها…", detectedFields: "زمین‌های شناسایی‌شده از ماهواره", detectedUnit: "زمین شناسایی‌شده", farmers: "دهقانان ثبت‌شده", noFarmers: "هنوز هیچ دهقانی در این منطقه ثبت نشده.", joined: "تاریخ ثبت", fields2: "مزرعه", population: "نفوس", popDens: "نفر/کیلومتر مربع", popSrc: "WorldPop 100m", landcover: "پوشش زمین", cropArea: "زمین زراعتی", treeArea: "جنگل/درخت", builtArea: "مناطق شهری", waterArea: "آب", bareArea: "زمین بایر", grassArea: "علفزار/بوته", snowArea: "برف/یخ", floodedArea: "پوشش آبگیر", terrain: "ارتفاع", elevMean: "ارتفاع متوسط", elevRange: "محدوده", slopeMean: "شیب متوسط", flatLand: "مسطح — مناسب برای زراعت مکانیزه", gentleSlope: "شیب ملایم — مناسب برای اکثر محصولات", steepSlope: "شیب تند — زراعت پله‌ای، خطر فرسایش", waterBodies: "منابع آبی دائمی", waterHa: "هکتار آب", cropCal: "پروفایل NDVI فصلی (تقویم زراعتی)", cropIntel: "اطلاعات محصول", months: { 1: "ژانویه", 2: "فوریه", 3: "مارس", 4: "آوریل", 5: "مه", 6: "ژوئن", 7: "جولای", 8: "اوت", 9: "سپتامبر", 10: "اکتبر", 11: "نوامبر", 12: "دسامبر", }, insuffData: "داده‌های کافی ماهواره‌ای برای توصیه موجود نیست.", shrubArea: "بوته‌زار", lcDominant: "غالب‌ترین کاربری زمین", lcTotal: "از کل مساحت", // Recommendations (French) recIrrigate: "آبیاری فوری — رطوبت خاک پایین است", recNoIrrigate: "نیازی به آبیاری نیست — رطوبت کافی", recFrost: "هشدار یخبندان — محصولات را محافظت کنید", recDrought: "خطر خشکسالی — آبیاری مکمل ضروری است", recSain: "وضعیت خوب — محصول سالم به نظر می‌رسد", recLowNdvi: "پوشش گیاهی ضعیف — بررسی آفت یا کمبود غذایی", lpTitle: "🗂️ لایه‌ها", lpBaseMap: "نقشه پایه", lpLabels: "🏷️ برچسب‌ها", lpSatTitle: "تحلیل ماهواره‌ای", lpOnDemand: "درخواست در لحظه · ۳۰-۶۰ ثانیه", lpLandCover: "🌍 پوشش زمین", lpNdvi: "🟢 صحت NDVI", lpWater: "💧 شاخص آب", lpBareSoil: "🏜️ خاک برهنه", lpCropType: "🌾 نوع محصول", lpForest: "🌲 جنگل", lpEstimate: "تخمینی", lpContextTitle: "زمینه نقشه", lpRoads: "🛣️ جاده‌ها", lpSettlements: "🏘️ سکونتگاه‌ها", lpRivers: "🌊 رودخانه‌ها", lpOsmNote: "⚠️ پوشش OSM در مناطق روستایی دور ممکن است ناقص باشد", }, ps: { sub: "د افسر ډشبورډ — د سیمې معلومات", farmerLink: "د کروندګر اپلیکیشن", country: "هیواد", ph: "د هیواد لټون...", prov: "ولایت / سیمه", dist: "ولسوالۍ (اختیاري)", year: "د تحلیل کال", btn: "سیمه تحلیل کړئ", loading: "د سرحد معلومات ډاونلوډ کیږي...", analysing: "د ماهوارې تحلیل روان دی...", needCountry: "مهرباني وکړئ لومړی یو هیواد وټاکئ", needProv: "مهرباني وکړئ ولایت یا ولسوالۍ وټاکئ", noData: "د دې سیمې/کال لپاره د ماهوارې معلومات شتون نلري", errBoundary: "د سرحد معلومات پورته نشول — د هیواد نوم وګورئ", resTitle: "د سیمې د تحلیل پایلې", ndvi: "د نباتاتو روغتیا (NDVI)", evi: "پرمختللي نباتات (EVI)", mndwi: "د اوبو شاخص (MNDWI)", rain: "کلني باران", sar: "رادار (SAR)", modis: "تودوخه (MODIS)", trend: "د NDVI رجحان (۲۰۱۳ تر اوسه)", recommend: "سپارښتنې", area: "ساحه", coords: "د مرکز مختصات", farmerCount: "ثبت شوي کروندګر", adminLevel: "اداري کچه", source: "د معلوماتو سرچینه", mm: "ملیمتر/کال", km2: "مربع کیلومتر", soilMoist: "د خاورې لندوالی (VV)", vegDens: "د نباتاتو کثافت (VH)", sumT: "د اوړي تودوخه (ورځ)", winT: "د ژمي تودوخه (شپه)", frost: "د یخ خطر", frostY: "هو — یخ احتمالي دی", frostN: "د یخ خطر نشته", good: "ښه", mod: "منځنی", low: "کم", water: "اوبه شتون لري", drought: "د وچکالۍ خطر", district: "ولسوالۍ", province: "ولایت", country2: "هیواد", village: "کلی", vil: "کلی (اختیاري)", savi: "د خاورې پوښ (SAVI)", registeredPlots: "ثبت‌شوي ځمکې", fieldUnit: "مزرعه DB کې", legVil: "کلي", legProv: "ولایتونه", noPlots: "هیڅ ثبت‌شوې ځمکه نشته", loadingPlots: "د ځمکو ډاونلوډ…", detectBtn: "🛰️ د ماهوارې له لارې د ځمکو اتوماتیک کشف", detecting: "🛰️ ځمکې کشفیږي…", detectedFields: "د ماهوارې له لارې کشف‌شوي ځمکې", detectedUnit: "کشف‌شوې ځمکه", farmers: "ثبت‌شوي کروندګر", noFarmers: "لا د دې سیمې کروندګر ثبت نشوي.", joined: "د ثبت نیټه", fields2: "ځمکه", population: "نفوس", popDens: "کس/مربع کیلومتر", popSrc: "WorldPop 100m", landcover: "د ځمکې پوښ", cropArea: "د کرهڼې ځمکه", treeArea: "ځنګل/ونې", builtArea: "ښاري سیمې", waterArea: "اوبه", bareArea: "بنجره ځمکه", grassArea: "واښه/بوټي", snowArea: "واوره/یخ", floodedArea: "اوبه‌ناک پوښ", terrain: "لوړوالی", elevMean: "منځني لوړوالی", elevRange: "سلسله", slopeMean: "منځنی کج", flatLand: "مسطحه — د مکانیزه کرهڼې لپاره مناسبه", gentleSlope: "ملایم کج — د ډیری محصولاتو لپاره مناسب", steepSlope: "تیز کج — پله‌ایزه کرهڼه، د فرسایش خطر", waterBodies: "دایمي اوبه", waterHa: "د اوبو هکتار", cropCal: "د NDVI موسمي پروفایل (د کرهڼې تقویم)", cropIntel: "د محصول معلومات", months: { 1: "جنوري", 2: "فبروري", 3: "مارچ", 4: "اپریل", 5: "مې", 6: "جون", 7: "جولای", 8: "اګست", 9: "سپتامبر", 10: "اکتوبر", 11: "نومبر", 12: "دسامبر", }, insuffData: "د سپارښتنو لپاره کافي د ماهوارې معلومات نشته.", shrubArea: "بوټي", lcDominant: "لومړنۍ د ځمکې کارونه", lcTotal: "د ټول ساحې", // Recommendations (Bambara) recIrrigate: "سمدلاسه اوبه ورکول — د خاورې لندوالی ټیټ دی", recNoIrrigate: "اوبه ورکولو ته اړتیا نشته — لندوالی کافي دی", recFrost: "د یخ خبرداری — خپل محصولات وساتئ", recDrought: "د وچکالۍ خطر — اضافي اوبه ورکول اړین دي", recSain: "ښه وضعیت — محصول روغ ښکاري", recLowNdvi: "کمزوری پوشش — آفت یا د غذا کمښت وګورئ", lpTitle: "🗂️ پرده‌ونه", lpBaseMap: "اساسي نقشه", lpLabels: "🏷️ لیبلونه", lpSatTitle: "د ماهوارې تحلیل", lpOnDemand: "د غوښتنې سره بار · ۳۰-۶۰ ثانیې", lpLandCover: "🌍 د ځمکې پوښ", lpNdvi: "🟢 NDVI روغتیا", lpWater: "💧 د اوبو شاخص", lpBareSoil: "🏜️ لوڅه خاوره", lpCropType: "🌾 د محصول ډول", lpForest: "🌲 ځنګل", lpEstimate: "اټکل", lpContextTitle: "د نقشې سیاق", lpRoads: "🛣️ لارې", lpSettlements: "🏘️ د ابادۍ ځایونه", lpRivers: "🌊 سیندونه", lpOsmNote: "⚠️ د لرو کلیوالو سیمو کې د OSM پوښښ ممکن نامکمل وي", }, }; // ── Country list (ISO-3) ──────────────────────────────────────────────────── const COUNTRIES = [ { n: "Mali", iso: "MLI" }, { n: "Albania", iso: "ALB" }, { n: "Algeria", iso: "DZA" }, { n: "Angola", iso: "AGO" }, { n: "Argentina", iso: "ARG" }, { n: "Armenia", iso: "ARM" }, { n: "Australia", iso: "AUS" }, { n: "Austria", iso: "AUT" }, { n: "Azerbaijan", iso: "AZE" }, { n: "Bangladesh", iso: "BGD" }, { n: "Belarus", iso: "BLR" }, { n: "Belgium", iso: "BEL" }, { n: "Benin", iso: "BEN" }, { n: "Bolivia", iso: "BOL" }, { n: "Bosnia and Herzegovina", iso: "BIH" }, { n: "Botswana", iso: "BWA" }, { n: "Brazil", iso: "BRA" }, { n: "Bulgaria", iso: "BGR" }, { n: "Burkina Faso", iso: "BFA" }, { n: "Burundi", iso: "BDI" }, { n: "Cambodia", iso: "KHM" }, { n: "Cameroon", iso: "CMR" }, { n: "Canada", iso: "CAN" }, { n: "Chad", iso: "TCD" }, { n: "Chile", iso: "CHL" }, { n: "China", iso: "CHN" }, { n: "Colombia", iso: "COL" }, { n: "Congo", iso: "COG" }, { n: "Costa Rica", iso: "CRI" }, { n: "Croatia", iso: "HRV" }, { n: "Cuba", iso: "CUB" }, { n: "Czech Republic", iso: "CZE" }, { n: "DR Congo", iso: "COD" }, { n: "Denmark", iso: "DNK" }, { n: "Ecuador", iso: "ECU" }, { n: "Egypt", iso: "EGY" }, { n: "El Salvador", iso: "SLV" }, { n: "Ethiopia", iso: "ETH" }, { n: "Finland", iso: "FIN" }, { n: "France", iso: "FRA" }, { n: "Gabon", iso: "GAB" }, { n: "Georgia", iso: "GEO" }, { n: "Germany", iso: "DEU" }, { n: "Ghana", iso: "GHA" }, { n: "Greece", iso: "GRC" }, { n: "Guatemala", iso: "GTM" }, { n: "Guinea", iso: "GIN" }, { n: "Honduras", iso: "HND" }, { n: "Hungary", iso: "HUN" }, { n: "India", iso: "IND" }, { n: "Indonesia", iso: "IDN" }, { n: "Iran", iso: "IRN" }, { n: "Iraq", iso: "IRQ" }, { n: "Ireland", iso: "IRL" }, { n: "Israel", iso: "ISR" }, { n: "Italy", iso: "ITA" }, { n: "Ivory Coast", iso: "CIV" }, { n: "Japan", iso: "JPN" }, { n: "Jordan", iso: "JOR" }, { n: "Kazakhstan", iso: "KAZ" }, { n: "Kenya", iso: "KEN" }, { n: "Kuwait", iso: "KWT" }, { n: "Kyrgyzstan", iso: "KGZ" }, { n: "Laos", iso: "LAO" }, { n: "Lebanon", iso: "LBN" }, { n: "Libya", iso: "LBY" }, { n: "Lithuania", iso: "LTU" }, { n: "Madagascar", iso: "MDG" }, { n: "Malawi", iso: "MWI" }, { n: "Malaysia", iso: "MYS" }, { n: "Mali", iso: "MLI" }, { n: "Mauritania", iso: "MRT" }, { n: "Mexico", iso: "MEX" }, { n: "Moldova", iso: "MDA" }, { n: "Mongolia", iso: "MNG" }, { n: "Morocco", iso: "MAR" }, { n: "Mozambique", iso: "MOZ" }, { n: "Myanmar", iso: "MMR" }, { n: "Namibia", iso: "NAM" }, { n: "Nepal", iso: "NPL" }, { n: "Netherlands", iso: "NLD" }, { n: "New Zealand", iso: "NZL" }, { n: "Nicaragua", iso: "NIC" }, { n: "Niger", iso: "NER" }, { n: "Nigeria", iso: "NGA" }, { n: "North Korea", iso: "PRK" }, { n: "North Macedonia", iso: "MKD" }, { n: "Norway", iso: "NOR" }, { n: "Oman", iso: "OMN" }, { n: "Palestine", iso: "PSE" }, { n: "Panama", iso: "PAN" }, { n: "Paraguay", iso: "PRY" }, { n: "Peru", iso: "PER" }, { n: "Philippines", iso: "PHL" }, { n: "Poland", iso: "POL" }, { n: "Portugal", iso: "PRT" }, { n: "Qatar", iso: "QAT" }, { n: "Romania", iso: "ROU" }, { n: "Russia", iso: "RUS" }, { n: "Rwanda", iso: "RWA" }, { n: "Saudi Arabia", iso: "SAU" }, { n: "Senegal", iso: "SEN" }, { n: "Serbia", iso: "SRB" }, { n: "Sierra Leone", iso: "SLE" }, { n: "Somalia", iso: "SOM" }, { n: "South Africa", iso: "ZAF" }, { n: "South Korea", iso: "KOR" }, { n: "South Sudan", iso: "SSD" }, { n: "Spain", iso: "ESP" }, { n: "Sri Lanka", iso: "LKA" }, { n: "Sudan", iso: "SDN" }, { n: "Sweden", iso: "SWE" }, { n: "Switzerland", iso: "CHE" }, { n: "Syria", iso: "SYR" }, { n: "Tajikistan", iso: "TJK" }, { n: "Tanzania", iso: "TZA" }, { n: "Thailand", iso: "THA" }, { n: "Togo", iso: "TGO" }, { n: "Tunisia", iso: "TUN" }, { n: "Turkey", iso: "TUR" }, { n: "Turkmenistan", iso: "TKM" }, { n: "Uganda", iso: "UGA" }, { n: "Ukraine", iso: "UKR" }, { n: "United Arab Emirates", iso: "ARE" }, { n: "United Kingdom", iso: "GBR" }, { n: "United States", iso: "USA" }, { n: "Uruguay", iso: "URY" }, { n: "Uzbekistan", iso: "UZB" }, { n: "Venezuela", iso: "VEN" }, { n: "Vietnam", iso: "VNM" }, { n: "Yemen", iso: "YEM" }, { n: "Zambia", iso: "ZMB" }, { n: "Zimbabwe", iso: "ZWE" }, ]; // ── State ─────────────────────────────────────────────────────────────────── let lang = "en"; let map, bndLayer, hlLayer, vilBndLayer, plotLayer, detectLayer; let userLocationLayer = null; let l1Data = null, l2Data = null, l3Data = null; let selFeat = null; let curISO = null; const API = ""; let _agentStatus = { atessa: false, anthropic: false, gemini: false, gee: false, database: false, }; // ── Satellite layer state (Step 2) ────────────────────────────────────────── const _satLayers = {}; // name → Leaflet layer (or null if not yet loaded) const _satLoading = {}; // name → true while GEE task in flight // ── Satellite layer colour functions — value-based, not flat class buckets ──── // NDVI: 7-tier health gradient function _ndviGradient(n) { if (n === null || n === undefined) return "#607d8b"; if (n >= 0.65) return "#1b5e20"; // Tres dense / excellent if (n >= 0.5) return "#2e7d32"; // Sain if (n >= 0.35) return "#558b2f"; // Good if (n >= 0.25) return "#f9a825"; // Moyen / needs attention if (n >= 0.15) return "#ef6c00"; // Low / stressed if (n >= 0.05) return "#bf360c"; // Very low / near-bare return "#6d4c41"; // Bare soil / non-vegetated } // MNDWI: continuous blue gradient (wetter = darker blue) function _mndwiColor(cls) { const c = cls ?? 0; if (c >= 6) return "#01579b"; // Open surface water if (c >= 3) return "#0288d1"; // Water body / flooded if (c >= 1) return "#29b6f6"; // Very wet / irrigated if (c >= -1) return "#80cbc4"; // Moist soil if (c >= -3) return "#a5d6a7"; // Slightly dry if (c >= -6) return "#a1887f"; // Dry soil return "#6d4c41"; // Very dry / bare } // BSI: orange-brown gradient (higher = more exposed bare soil) function _bsiColor(cls) { const c = cls ?? 0; if (c >= 6) return "#bf360c"; // Heavily exposed — erosion risk if (c >= 4) return "#e64a19"; if (c >= 2) return "#f57c00"; if (c >= 1) return "#ffa726"; return "#ffe0b2"; // Slight bare soil } // DW class: NDVI-aware per class (crops use full health gradient) function _dwColor(lc, ndvi) { const n = ndvi ?? 0.3; switch (lc) { case 0: return n < -0.05 ? "#01579b" : "#0288d1"; // permanent vs seasonal water case 1: return n > 0.6 ? "#1b5e20" : "#2e7d32"; // dense vs moderate forest case 2: return "#7cb342"; // grassland case 3: return "#00838f"; // flooded veg / paddy case 4: return _ndviGradient(n); // crops: full NDVI health scale case 5: return "#9e9d24"; // shrub/scrub case 6: return "#b71c1c"; // built-up / urban case 7: return n > 0.12 ? "#8d6e63" : "#5d4037"; // bare: thin veg vs pure bare case 8: return "#e1f5fe"; // snow / ice default: return "#607d8b"; } } const _cropColors = { 1: "#f39c12", 2: "#27ae60", 3: "#1a7a40", 4: "#a04000", }; const _cropLabels = { 1: "Ble", 2: "Legumes", 3: "Vergers / arbres", 4: "Nu / jachere", }; const _cropLabelsFA = { 1: "گندم", 2: "سبزیجات", 3: "باغ", 4: "بایر", }; const _cropLabelsPS = { 1: "غنم", 2: "سبزیجات", 3: "باغ", 4: "بایره ځمکه", }; // ── Health badge HTML helper ────────────────────────────────────────────────── function _ndviBadge(n) { if (n === null) return ""; if (n >= 0.45) return `● Sain`; if (n >= 0.28) return `● Moyen`; return `● Stresse`; } // ── Context-aware insights per DW class ────────────────────────────────────── function _dwInsight(lc, ndvi) { const n = ndvi ?? 0; const insights = { 0: n < -0.05 ? "Eau permanente — source possible pour l irrigation" : "Plan d eau saisonnier — peut secher hors saison des pluies", 1: n > 0.6 ? "Foret dense — biomasse elevee et stockage carbone" : "Foret moyenne — surveiller degradation ou coupe", 2: "Prairie / parcours — paturage ou jachere saisonniere", 3: "Vegetation inondee — riz, bas-fond ou canal d irrigation", 4: n >= 0.45 ? "Culture saine — bonnes conditions de croissance" : n >= 0.28 ? "Culture moyenne — irrigation ou fertilisation a envisager" : "Culture stressee — verifier irrigation, sol, ravageurs", 5: "Arbustes — terre degradee avec potentiel de restauration", 6: "Zone batie — acces possible au marche et aux intrants", 7: n > 0.12 ? "Sol nu avec vegetation faible — recolte recente ou degradation" : "Sol expose — risque erosion; couverture conseillee", 8: "Neige / glace — saisonniere ou permanente", }; return insights[lc] ?? ""; } function _satLayerStyle(layerName, feat) { const ndvi = feat.properties?.mean ?? null; switch (layerName) { case "ndvi": { const col = _ndviGradient(ndvi); return { color: col, weight: 0.5, fillColor: col, fillOpacity: 0.68, }; } case "water": { const cls = feat.properties?.mndwi_class ?? 0; const col = _mndwiColor(cls); const wet = (cls ?? 0) >= 1; return { color: wet ? "#0277bd" : "#fff", weight: wet ? 0.8 : 0.5, fillColor: col, fillOpacity: 0.7, }; } case "baresoil": { const cls = feat.properties?.bsi_class ?? 0; const col = _bsiColor(cls); return { color: "#fff", weight: 0.5, fillColor: col, fillOpacity: 0.68, }; } case "croptype": { const cc = feat.properties?.crop_class ?? 4; const col = _cropColors[cc] || "#888"; return { color: "#fff", weight: 0.7, fillColor: col, fillOpacity: 0.7, }; } case "forest": { const n = feat.properties?.mean ?? 0.5; const col = n > 0.65 ? "#1b5e20" : "#2e7d32"; return { color: "#fff", weight: 0.5, fillColor: col, fillOpacity: 0.65, }; } default: return { color: "#fff", weight: 0.6, fillColor: "#888", fillOpacity: 0.55, }; } } function _satLayerPopup(layerName, feat) { const ndvi = feat.properties?.mean ?? null; const ndviStr = ndvi !== null ? ndvi.toFixed(3) : "—"; const badge = _ndviBadge(ndvi); switch (layerName) { case "ndvi": { const cat = ndvi === null ? "—" : ndvi >= 0.65 ? "Tres dense" : ndvi >= 0.5 ? "Sain" : ndvi >= 0.35 ? "Good" : ndvi >= 0.25 ? "Moyen" : ndvi >= 0.15 ? "Low" : "Sol nu/stresse"; return `
🟢 NDVI Vegetation Health
NDVI${ndviStr}
Category${cat}
${badge}
Sentinel-2 · growing season composite
`; } case "water": { const cls = feat.properties?.mndwi_class ?? 0; const mndwi = cls / 20; const waterType = cls >= 6 ? "Surface water" : cls >= 1 ? "Wet / flooded" : cls >= -1 ? "Moist soil" : cls >= -3 ? "Semi-dry" : "Dry soil"; return `
💧 Eau / humidite (MNDWI)
MNDWI~${mndwi.toFixed(2)}
Type${waterType}
${cls >= 3 ? "💧 Irrigation potential" : "🏜️ Low water — supplemental irrigation needed"}
`; } case "baresoil": { const cls = feat.properties?.bsi_class ?? 0; const risk = cls >= 6 ? "⚠️ Very high erosion risk" : cls >= 3 ? "⚠️ High erosion risk" : cls >= 1 ? "Moyen bare exposure" : "Slight bare soil"; return `
🏜️ Indice sol nu (BSI)
BSI class${cls}
Risk${risk}
NDVI${ndviStr}
${cls >= 3 ? "Couverts vegetaux ou paillage conseilles" : "Niveau de sol nu acceptable"}
`; } case "croptype": { const cc = feat.properties?.crop_class ?? 4; const lbl = lang === "fa" ? _cropLabelsFA[cc] || "—" : lang === "ps" ? _cropLabelsPS[cc] || "—" : _cropLabels[cc] || "—"; const actions = { 1: "Semer oct-nov · apport d uree en fevrier", 2: "Irrigation goutte a goutte · recolte juil-oct", 3: "Taille · irrigation apres recolte", 4: "Analyse du sol · rotation a envisager", }; return `
🌾 Type de culture (estimation)
Type${lbl}
NDVI${ndviStr}
${badge}
${actions[cc] || ""} · a verifier sur le terrain
`; } case "forest": { const n = feat.properties?.mean ?? null; const den = n === null ? "—" : n >= 0.65 ? "Foret dense" : n >= 0.5 ? "Foret moyenne" : "Couvert ouvert"; return `
🌲 Foret / vegetation dense
NDVI${n !== null ? n.toFixed(3) : "—"}
Densite${den}
Bassin versant et carbone · NDVI >= 0.45 toute l annee
`; } default: return `
${layerName}
`; } } async function toggleSatLayer(layerName, show) { if (!show) { if (_satLayers[layerName]) { map.removeLayer(_satLayers[layerName]); } return; } if (!_agentStatus.gee) { const cb = document.getElementById(`lp-${layerName}`); if (cb) cb.checked = false; setStatus( "GEE live est en attente : les couches satellite seront disponibles apres configuration Earth Engine.", "err", ); return; } // Already loaded — just re-add if (_satLayers[layerName]) { _satLayers[layerName].addTo(map); return; } // Already loading — ignore double-click if (_satLoading[layerName]) return; if (!selFeat) { setStatus( "Selectionnez une parcelle ou une region d abord", "err", ); return; } _satLoading[layerName] = true; setStatus(`Chargement ${layerName}...`); const coords = extractCoords(selFeat); const year = parseInt( document.getElementById("year-sel").value, ); try { // Start GEE task const r = await fetch(`${API}/officer/layer/${layerName}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ coords, year }), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error( err.error || "Couche satellite indisponible", ); } const start = await r.json(); if (start.error) throw new Error(start.error); const taskId = start.task_id; // Poll let geojson = null; for (let i = 0; i < 40 && !geojson; i++) { await new Promise((res) => setTimeout(res, 5000)); const poll = await fetch( `${API}/officer/layer-result/${taskId}`, ); const pd = await poll.json(); if (pd.status === "done") geojson = pd.data; if (pd.status === "error") throw new Error(pd.error); } if (!geojson) throw new Error("Layer timed out. Try a smaller area."); const lyr = L.geoJSON(geojson, { style: (feat) => _satLayerStyle(layerName, feat), onEachFeature: (feat, layer) => { layer.bindPopup(_satLayerPopup(layerName, feat), { maxWidth: 240, }); }, }).addTo(map); _satLayers[layerName] = lyr; updateMapLegend(layerName); setStatus(`✓ Couche ${layerName} chargee`, "ok"); } catch (e) { setStatus( e.message || "Couche satellite indisponible", "err", ); // Uncheck the toggle if it failed const cb = document.getElementById(`lp-${layerName}`); if (cb) cb.checked = false; } finally { _satLoading[layerName] = false; } } // ── Init ──────────────────────────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", () => { map = L.map("map", { zoomControl: true }).setView( [17.5707, -3.9962], 6, ); // ── Base layers ────────────────────────────────────────────────────────── const esriSat = L.tileLayer( "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { attribution: "Tiles © Esri — Esri, DigitalGlobe, GeoEye, CNES/Airbus DS, USDA, USGS, AeroGRID, IGN", maxZoom: 19, }, ); const esriLabels = L.tileLayer( "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", { attribution: "", maxZoom: 19, opacity: 0.9, zIndex: 600 }, ); const googleSat = L.tileLayer( "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", { attribution: "© Google", maxZoom: 20 }, ); const osmLayer = L.tileLayer( "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap contributors", maxZoom: 19, }, ); const darkLayer = L.tileLayer( "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", { attribution: "© OSM © CARTO", maxZoom: 18 }, ); // Step 4: Terrain / hillshade base layer (ESRI World Shaded Relief) const terrainLayer = L.tileLayer( "https://server.arcgisonline.com/ArcGIS/rest/services/World_Shaded_Relief/MapServer/tile/{z}/{y}/{x}", { attribution: "Tiles © Esri — Source: Esri", maxZoom: 13, }, ); esriSat.addTo(map); esriLabels.addTo(map); keepMapVisible(); window.addEventListener("resize", keepMapVisible); if (window.ResizeObserver) { new ResizeObserver(keepMapVisible).observe( document.getElementById("map"), ); } [esriSat, googleSat, osmLayer, darkLayer, terrainLayer].forEach( (layer) => { layer.on("tileerror", () => { const hasVisibleTile = [ ...document.querySelectorAll(".leaflet-tile"), ].some( (img) => img.complete && img.naturalWidth > 0, ); if (!hasVisibleTile && !map.hasLayer(osmLayer)) { try { map.removeLayer(_activeBase); } catch (e) {} _activeBase = osmLayer; osmLayer.addTo(map); setStatus( "Fond de carte retabli avec OpenStreetMap.", "ok", ); keepMapVisible(); } }); }, ); // ── Layer panel: base map switching ───────────────────────────────── const _baseLayers = { esri: esriSat, google: googleSat, osm: osmLayer, dark: darkLayer, terrain: terrainLayer, }; let _activeBase = esriSat; document .querySelectorAll('input[name="lp-base"]') .forEach((radio) => { radio.addEventListener("change", function () { if (!this.checked) return; map.removeLayer(_activeBase); _activeBase = _baseLayers[this.value]; _activeBase.addTo(map); keepMapVisible(); }); }); // Labels overlay toggle document .getElementById("lp-labels") .addEventListener("change", function () { this.checked ? esriLabels.addTo(map) : map.removeLayer(esriLabels); }); // Wire satellite layer checkboxes (Steps 2 + 4) — already enabled in HTML [ "landcover", "ndvi", "water", "baresoil", "croptype", "forest", ].forEach((name) => { const cb = document.getElementById(`lp-${name}`); if (cb) cb.addEventListener("change", function () { toggleSatLayer(name, this.checked); }); }); refreshSystemStatus(); if ( window.matchMedia && window.matchMedia("(max-width: 900px)").matches ) { const body = document.getElementById("layer-panel-body"); const chev = document.getElementById("lp-chevron"); if (body) body.style.display = "none"; if (chev) { chev.classList.remove("open"); chev.textContent = "▼"; } } // ── OSM context layers (Step 3) ───────────────────────────────────────── // Stamen/CARTO vector-tile overlays — frontend only, no backend needed. // Note: OSM coverage may be incomplete in remote rural areas. // Roads: OpenStreetMap Carto roads-only tile (transparent bg, z-index above sat) const _osmRoads = L.tileLayer( "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap", opacity: 0.45, maxZoom: 19, zIndex: 700, }, ); // Buildings: use OpenStreetMap standard with higher opacity for urban context // A single roads+buildings OSM layer serves both — toggled independently // so we use a WMS-style or vector approach. // Practical approach: use Stamen Toner-hybrid which shows roads+buildings only const _osmBuildings = L.tileLayer( "https://stamen-tiles-{s}.a.ssl.fastly.net/toner-hybrid/{z}/{x}/{y}{r}.png", { attribution: 'Map tiles by Stamen Design, under CC BY 3.0. Data by OpenStreetMap', opacity: 0.55, maxZoom: 18, zIndex: 710, }, ); // Rivers: WMS from OpenStreetMap through a public OSM-WMS endpoint // Using ESRI's World Hydrology tile layer (free, no key) const _osmRivers = L.tileLayer( "https://server.arcgisonline.com/ArcGIS/rest/services/Specialty/World_Navigation_Charts/MapServer/tile/{z}/{y}/{x}", { attribution: "Esri · NOAA", opacity: 0.4, maxZoom: 18, zIndex: 720, }, ); const _osmLayerMap = { roads: _osmRoads, buildings: _osmBuildings, rivers: _osmRivers, }; ["roads", "buildings", "rivers"].forEach((name) => { const cb = document.getElementById(`lp-${name}`); if (!cb) return; cb.disabled = false; cb.closest(".lp-item").classList.remove("lp-off"); cb.addEventListener("change", function () { const lyr = _osmLayerMap[name]; this.checked ? lyr.addTo(map) : map.removeLayer(lyr); }); if (cb.checked) _osmLayerMap[name].addTo(map); }); const ySel = document.getElementById("year-sel"); const cur = new Date().getFullYear(); for (let y = cur; y >= 2015; y--) { const o = document.createElement("option"); o.value = y; o.textContent = y; ySel.appendChild(o); } const dl = document.getElementById("country-dl"); COUNTRIES.forEach((c) => { const o = document.createElement("option"); o.value = c.n; dl.appendChild(o); }); applyLang(); }); // ── Language ──────────────────────────────────────────────────────────────── function t(k) { return OT[lang][k] || OT.en[k] || k; } function setLang(l) { lang = l; const rtl = l === "fa" || l === "ps"; document.documentElement.lang = l; document.body.dir = rtl ? "rtl" : "ltr"; document .querySelectorAll(".lang-btn") .forEach((b) => b.classList.toggle( "active", b.textContent.toLowerCase() === l, ), ); applyLang(); } function applyLang() { document.getElementById("tb-sub").textContent = t("sub"); document.getElementById("lnk-farmer").textContent = t("farmerLink"); document.getElementById("lbl-country").textContent = t("country"); document.getElementById("lbl-prov").textContent = t("prov"); document.getElementById("lbl-dist").textContent = t("dist"); document.getElementById("lbl-vil").textContent = t("vil"); document.getElementById("lbl-year").textContent = t("year"); document.getElementById("analyse-btn").textContent = t("btn"); document.getElementById("country-in").placeholder = t("ph"); document.getElementById("leg-vil").textContent = t("legVil"); document.getElementById("leg-prov").textContent = t("legProv"); document.getElementById("detect-btn").textContent = t("detectBtn"); if ( document .getElementById("results") .classList.contains("open") ) document.getElementById("res-title").textContent = t("resTitle"); // Layer panel translations (Step 5) const _lp = { "lp-hdr-text": "lpTitle", "lp-t-base": "lpBaseMap", "lp-t-labels": "lpLabels", "lp-t-sat": "lpSatTitle", "lp-note-demand": "lpOnDemand", "lp-t-landcover": "lpLandCover", "lp-t-ndvi": "lpNdvi", "lp-t-water": "lpWater", "lp-t-baresoil": "lpBareSoil", "lp-t-croptype": "lpCropType", "lp-t-forest": "lpForest", "lp-est-badge": "lpEstimate", "lp-t-context": "lpContextTitle", "lp-t-roads": "lpRoads", "lp-t-buildings": "lpSettlements", "lp-t-rivers": "lpRivers", "lp-osm-note": "lpOsmNote", }; Object.entries(_lp).forEach(([id, key]) => { const el = document.getElementById(id); if (el) el.textContent = t(key); }); } function setStatus(msg, cls = "") { const el = document.getElementById("status"); el.textContent = msg; el.className = cls; } async function refreshSystemStatus() { try { const r = await fetch(`${API}/agent/status`, { cache: "no-cache", }); const d = await r.json(); _agentStatus = d; const ap = document.getElementById("ap-status"); if (ap) { const aiReady = d.ai_configured || d.ai === "configured" || d.ai === "openai-compatible" || d.active_backend === "openai-compatible"; ap.textContent = aiReady && d.gee ? "Pret · conseiller terrain" : aiReady ? "Pret · satellite en verification" : "Conseiller indisponible"; ap.style.color = aiReady ? "#8aa8c0" : "#ff6b6b"; } const sys = document.getElementById("system-status-card"); if (sys) { const aiOk = d.ai_configured || d.ai === "configured" || d.ai === "openai-compatible" || d.active_backend === "openai-compatible"; const geeDetail = d.gee ? "connecte" : d.gee_error && d.gee_error.includes("not registered") ? "en attente · projet non inscrit a Earth Engine" : d.gee_error ? "a verifier · configuration satellite" : "en attente · mode regional actif"; sys.innerHTML = `
Services Ineema
🤖 Conseiller terrain : ${aiOk ? "pret" : "indisponible"}
🗄️ Base agricole : ${d.database ? "connectee" : "en attente"}
🛰️ Satellite live : ${geeDetail}
`; } updateGeeControls(); } catch (e) { const ap = document.getElementById("ap-status"); if (ap) { ap.textContent = "Agent hors ligne"; ap.style.color = "#e74c3c"; } } } function updateGeeControls() { const geeReady = !!_agentStatus.gee; [ "lp-landcover", "lp-ndvi", "lp-water", "lp-baresoil", "lp-croptype", "lp-forest", ].forEach((id) => { const cb = document.getElementById(id); if (!cb) return; cb.disabled = !geeReady; if (!geeReady) cb.checked = false; cb.closest(".lp-item")?.classList.toggle( "lp-off", !geeReady, ); cb.closest(".lp-item")?.setAttribute( "title", geeReady ? "" : "GEE live en attente: cette couche sera active apres configuration Earth Engine", ); }); if (detectLayer && !geeReady) { map.removeLayer(detectLayer); detectLayer = null; } if (!geeReady) updateMapLegend(null); } // ── Country input ─────────────────────────────────────────────────────────── const cIn = document.getElementById("country-in"); cIn.addEventListener("change", onCountryInput); cIn.addEventListener("input", function () { const m = COUNTRIES.find( (c) => c.n.toLowerCase() === this.value.trim().toLowerCase(), ); if (m) onCountrySelected(m); }); function onCountryInput() { const m = COUNTRIES.find( (c) => c.n.toLowerCase() === cIn.value.trim().toLowerCase(), ); if (m) onCountrySelected(m); } async function onCountrySelected(country) { curISO = country.iso; selFeat = null; l1Data = null; l2Data = null; l3Data = null; const ps = document.getElementById("prov-sel"); const ds = document.getElementById("dist-sel"); const vs = document.getElementById("vil-sel"); ps.disabled = true; ps.innerHTML = ""; ds.disabled = true; ds.innerHTML = ``; vs.disabled = true; vs.innerHTML = ``; document.getElementById("analyse-btn").disabled = true; setStatus(t("loading")); if (bndLayer) { map.removeLayer(bndLayer); bndLayer = null; } if (hlLayer) { map.removeLayer(hlLayer); hlLayer = null; } if (vilBndLayer) { map.removeLayer(vilBndLayer); vilBndLayer = null; } if (plotLayer) { map.removeLayer(plotLayer); plotLayer = null; } if (detectLayer) { map.removeLayer(detectLayer); detectLayer = null; } document.getElementById("detect-btn").disabled = true; closeResults(); try { setStatus("⏳ Chargement des limites…"); // GADM endpoint returns {task_id} for async fetch or cached data directly const url = `${API}/gadm/${country.iso}/1`; const init = await fetch(url); let l1Raw; if (init.status === 202) { // Async — poll until done const { task_id } = await init.json(); for (let i = 0; i < 40; i++) { await new Promise((res) => setTimeout(res, 3000)); const poll = await fetch( `${API}/gadm-result/${task_id}`, ); const pd = await poll.json(); if (pd.status === "done" || pd.features) { l1Raw = pd.features ? pd : pd; break; } if (pd.status === "error") throw new Error(pd.error); } if (!l1Raw) throw new Error( "Boundary load timed out — try again", ); } else if (init.ok) { l1Raw = await init.json(); } else { throw new Error(`HTTP ${init.status}`); } l1Data = l1Raw; const names = [ ...new Set( l1Data.features .map((f) => f.properties.NAME_1) .filter(Boolean), ), ].sort(); ps.innerHTML = ``; names.forEach((n) => { const o = document.createElement("option"); o.value = n; o.textContent = n; ps.appendChild(o); }); ps.disabled = false; // Enable parcel analysis at country level (no province needed) document.getElementById("parcel-btn").disabled = false; bndLayer = L.geoJSON(l1Data, { style: { color: "#ffffff", weight: 1.5, fillColor: "#27ae60", fillOpacity: 0.05, }, onEachFeature: (f, layer) => { layer.on("click", () => { ps.value = f.properties.NAME_1; onProvClicked(f); }); layer.on("contextmenu", (e) => { L.DomEvent.stopPropagation(e); _showParcelMenu( f, f.properties.NAME_1, "province", e.latlng, ); }); layer.bindTooltip(f.properties.NAME_1, { sticky: true, className: "leaflet-tooltip-dark", }); }, }).addTo(map); map.fitBounds(bndLayer.getBounds(), { padding: [20, 20] }); setStatus(""); } catch (e) { setStatus(t("errBoundary"), "err"); ps.innerHTML = ``; } } // ── Province select ───────────────────────────────────────────────────────── document .getElementById("prov-sel") .addEventListener("change", function () { if (!this.value || !l1Data) return; const f = l1Data.features.find( (f) => f.properties.NAME_1 === this.value, ); if (f) onProvClicked(f); }); function onProvClicked(feat) { selFeat = feat; highlight(feat); document.getElementById("analyse-btn").disabled = false; document.getElementById("parcel-btn").disabled = false; const ds = document.getElementById("dist-sel"); ds.disabled = true; ds.innerHTML = ``; const vs = document.getElementById("vil-sel"); vs.disabled = true; vs.innerHTML = ``; if (vilBndLayer) { map.removeLayer(vilBndLayer); vilBndLayer = null; } if (plotLayer) { map.removeLayer(plotLayer); plotLayer = null; } if (detectLayer) { map.removeLayer(detectLayer); detectLayer = null; } document.getElementById("detect-btn").disabled = false; document.getElementById("parcel-btn").disabled = false; l3Data = null; loadDistricts(curISO, feat.properties.GID_1); } async function _gadmFetch(iso, level, province) { // Build URL — pass province filter for level 2/3 to fetch only one province's districts let url = `${API}/gadm/${iso}/${level}`; if (province) url += `?province=${encodeURIComponent(province)}`; const init = await fetch(url); if (init.status === 202) { const { task_id } = await init.json(); for (let i = 0; i < 50; i++) { await new Promise((res) => setTimeout(res, 3000)); const poll = await fetch( `${API}/gadm-result/${task_id}`, ); const pd = await poll.json(); if (pd.features) return pd; // raw GeoJSON returned if (pd.status === "done") return pd.data || pd; // shouldn't happen but guard if (pd.status === "error") throw new Error(pd.error); // still pending → keep polling } throw new Error("Boundary load timed out — try again"); } if (!init.ok) throw new Error(`HTTP ${init.status}`); return await init.json(); } async function loadDistricts(iso, gid1) { const ds = document.getElementById("dist-sel"); try { // Derive province name from selFeat (used as server-side filter for efficiency) const provName = selFeat?.properties?.NAME_1 || selFeat?.properties?.ADM1_NAME || ""; const cacheKey = `${iso}_2_${provName}`; if (!l2Data || l2Data._cacheKey !== cacheKey) { setStatus("⏳ Chargement des districts…"); const raw = await _gadmFetch(iso, 2, provName); raw._cacheKey = cacheKey; // mark so we don't re-fetch same province l2Data = raw; setStatus(""); } // Filter by GID_1 (province-scoped fetch) OR accept all if single-province load const feats = l2Data.features.filter( (f) => f.properties.GID_1 === gid1 || l2Data.features.length < 500, ); if (!feats.length) { ds.innerHTML = ''; return; } const names = [ ...new Set( feats .map((f) => f.properties.NAME_2) .filter(Boolean), ), ].sort(); ds.innerHTML = ``; names.forEach((n) => { const o = document.createElement("option"); o.value = n; o.textContent = n; ds.appendChild(o); }); ds.disabled = false; } catch (e) { ds.innerHTML = ''; } } document .getElementById("dist-sel") .addEventListener("change", function () { const vs = document.getElementById("vil-sel"); vs.disabled = true; vs.innerHTML = ``; if (vilBndLayer) { map.removeLayer(vilBndLayer); vilBndLayer = null; } l3Data = null; if (!this.value || !l2Data) { vs.innerHTML = ``; return; } const f = l2Data.features.find( (f) => f.properties.NAME_2 === this.value, ); if (f) { selFeat = f; highlight(f); loadVillages(curISO, f.properties.GID_2); } }); async function loadVillages(iso, gid2) { const vs = document.getElementById("vil-sel"); try { if (!l3Data) { setStatus("⏳ Chargement des villages…"); const provName = selFeat?.properties?.NAME_1 || ""; l3Data = await _gadmFetch(iso, 3, provName); setStatus(""); } const feats = l3Data.features.filter( (f) => f.properties.GID_2 === gid2, ); if (!feats.length) { vs.innerHTML = ''; return; } drawVillageBoundaries(feats); const names = feats.map((f) => f.properties.NAME_3).sort(); vs.innerHTML = ``; names.forEach((n) => { const o = document.createElement("option"); o.value = n; o.textContent = n; vs.appendChild(o); }); vs.disabled = false; } catch (e) { vs.innerHTML = ''; setStatus(""); } } function drawVillageBoundaries(feats) { if (vilBndLayer) { map.removeLayer(vilBndLayer); vilBndLayer = null; } vilBndLayer = L.geoJSON( { type: "FeatureCollection", features: feats }, { style: { color: "#ffe066", weight: 1, fillColor: "#ffe066", fillOpacity: 0.06, }, onEachFeature: (f, layer) => { const name = f.properties.NAME_3 || f.properties.NAME_2 || "Parcel"; layer.bindTooltip(`${name} — click to select`, { sticky: true, className: "leaflet-tooltip-dark", }); layer.on("click", (e) => { const vs = document.getElementById("vil-sel"); vs.value = name; selFeat = f; highlight(f); document.getElementById("parcel-btn").disabled = false; document.getElementById("detect-btn").disabled = false; }); layer.on("contextmenu", (e) => { L.DomEvent.stopPropagation(e); _showParcelMenu(f, name, "village", e.latlng); }); }, }, ).addTo(map); } // ── Parcel context menu (right-click popup) ────────────────────────────────── let _parcelMenuPopup = null; let _pendingFeat = null, _pendingName = "", _pendingLevel = ""; function _showParcelMenu(feature, name, level, latlng) { if (_parcelMenuPopup) { _parcelMenuPopup.remove(); } _pendingFeat = feature; _pendingName = name; _pendingLevel = level; const safeName = name .replace(//g, ">"); const html = `
📍 ${safeName}
Export GeoJSON · CSV · KML
`; _parcelMenuPopup = L.popup({ closeButton: true }) .setLatLng(latlng) .setContent(html) .openOn(map); } function _analyseFromMenu() { if (_parcelMenuPopup) { _parcelMenuPopup.remove(); _parcelMenuPopup = null; } if (_pendingFeat) analyseParcel(_pendingFeat, _pendingName, _pendingLevel); } // Called from "Analyse & Export Parcel" sidebar button — works at ANY admin level function analyseSelectedParcel() { let feat = selFeat, name, lvl; if (feat) { const p = feat.properties; name = p.NAME_3 || p.NAME_2 || p.NAME_1 || cIn.value.trim() || "Region"; lvl = p.NAME_3 ? "village" : p.NAME_2 ? "district" : "province"; } else if (bndLayer) { // Country level — build bounding-box polygon from all provinces const b = bndLayer.getBounds(); feat = { type: "Feature", geometry: { type: "Polygon", coordinates: [ [ [b.getWest(), b.getSouth()], [b.getWest(), b.getNorth()], [b.getEast(), b.getNorth()], [b.getEast(), b.getSouth()], [b.getWest(), b.getSouth()], ], ], }, properties: {}, }; name = cIn.value.trim() || "Country"; lvl = "country"; } else { setStatus(t("needProv"), "err"); return; } analyseParcel(feat, name, lvl); } // ── Image download functions ───────────────────────────────────────────────── // ── Layer metadata (legend content per layer type) ──────────────────────────── const LAYER_META = { natural: { title: "Couleur naturelle (Sentinel-2)", source: "B4/B3/B2 · Sentinel-2 SR", legend: [], }, // no colour legend for natural colour ndvi: { title: "Sante vegetation (NDVI)", source: "B8/B4 · Sentinel-2 SR", legend: [ ["#1b5e20", "≥ 0.65 Tres dense"], ["#2e7d32", "0.50–0.65 Sain"], ["#558b2f", "0.35–0.50 Bon"], ["#f9a825", "0.25–0.35 Moyen"], ["#ef6c00", "0.15–0.25 Faible/stresse"], ["#bf360c", "< 0.15 Nu/clairseme"], ], }, landcover: { title: "Classification occupation du sol", source: "Dynamic World V1 · 10 m", legend: [ ["#2980b9", "💧 Eau"], ["#1a7a40", "🌲 Foret / arbres"], ["#8bc34a", "🌿 Prairie"], ["#1abc9c", "🌊 Vegetation inondee"], ["#f39c12", "🌾 Cultures"], ["#95a5a6", "🌱 Arbustes"], ["#e74c3c", "🏘️ Bati / urbain"], ["#a04000", "🏜️ Sol nu"], ["#ecf0f1", "❄️ Neige / glace"], ], }, water: { title: "Eau / humidite (MNDWI)", source: "B3/B11 · Sentinel-2 SR", legend: [ ["#01579b", "Eau de surface"], ["#0288d1", "Plan d eau / inonde"], ["#29b6f6", "Tres humide / irrigue"], ["#80cbc4", "Sol humide"], ["#a1887f", "Sol semi-sec"], ["#6d4c41", "Sec / nu"], ], }, baresoil: { title: "Indice sol nu (BSI)", source: "(SWIR+RED-NIR-BLUE) · Sentinel-2", legend: [ ["#ffe0b2", "Sol nu minimal"], ["#ffa726", "Exposition faible"], ["#f57c00", "Exposition moyenne"], ["#e64a19", "Exposition forte"], ["#bf360c", "Risque erosion tres eleve"], ], }, false_color: { title: "Fausses couleurs (NIR)", source: "B8/B4/B3 · Sentinel-2 SR", legend: [ ["#cc0000", "Vegetation dense (rouge = NIR)"], ["#ff9900", "Vegetation moyenne"], ["#cccccc", "Urbain / nu / sol"], ], }, }; async function downloadParcelImage(layer) { if (!_parcelState) { alert("Lancez d abord l analyse de parcelle."); return; } if (!_agentStatus.gee) { const prev = document.getElementById("pp-img-preview"); const note = document.getElementById("pp-img-note"); if (prev) prev.style.display = "none"; if (note) note.textContent = ""; setStatus( "PNG satellite indisponible : GEE live est en attente. GeoJSON, CSV, KML et capture carte restent disponibles.", "err", ); return; } const { coords, year, name, analysis } = _parcelState; // Show loading state const prev = document.getElementById("pp-img-preview"); const img = document.getElementById("pp-sat-img"); const note = document.getElementById("pp-img-note"); prev.style.display = "block"; img.src = ""; note.textContent = `Chargement image ${layer}...`; try { // Get GEE thumbnail URL const r = await fetch(`${API}/officer/parcel-thumbnail`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ coords, year, layer }), }); const d = await r.json(); if (d.error) throw new Error(d.error); note.textContent = "Preparation de l image annotee..."; // Compose annotated image with legend + stats on canvas const blob = await composeAnnotatedImage( d.url, layer, analysis, name, year, ); const url = URL.createObjectURL(blob); img.src = url; note.textContent = `${(LAYER_META[layer] || {}).title || layer} · ${year}`; // Download const safeName = (name || "parcel").replace( /[^a-zA-Z0-9_]/g, "_", ); const a = document.createElement("a"); a.href = url; a.download = `Ineema_${safeName}_${layer}_${year}.png`; a.click(); } catch (e) { note.textContent = "Erreur : " + e.message; console.error(e); } } // ── Full annotated map image composer ───────────────────────────────────────── async function composeAnnotatedImage( geeUrl, layer, analysis, name, year, ) { const meta = LAYER_META[layer] || {}; const W = 1200, H_IMG = 640, H_LEGEND = 320, H_FOOT = 44; const H = 80 + H_IMG + H_LEGEND + H_FOOT; // header + image + stats/legend + footer const canvas = document.createElement("canvas"); canvas.width = W; canvas.height = H; const ctx = canvas.getContext("2d"); // ── Background ──────────────────────────────────────────────────────────── ctx.fillStyle = "#080f1e"; ctx.fillRect(0, 0, W, H); // ── Header bar ──────────────────────────────────────────────────────────── const hh = 80; ctx.fillStyle = "#0c1a30"; ctx.fillRect(0, 0, W, hh); ctx.fillStyle = "#27ae60"; ctx.fillRect(0, 0, 6, hh); ctx.fillStyle = "#27ae60"; ctx.font = "bold 13px sans-serif"; ctx.fillText("● Ineema — Analyse satellite", 18, 26); ctx.fillStyle = "#dde4f0"; ctx.font = "bold 22px sans-serif"; ctx.fillText(`${meta.title || layer.toUpperCase()}`, 18, 54); ctx.fillStyle = "#5a7a9a"; ctx.font = "13px sans-serif"; ctx.textAlign = "right"; ctx.fillText(`${name} · ${year}`, W - 14, 32); ctx.fillText(`${meta.source || "Sentinel-2"}`, W - 14, 54); ctx.textAlign = "left"; // ── Load and draw satellite image ───────────────────────────────────────── const proxyUrl = `${API}/officer/proxy-image?url=${encodeURIComponent(geeUrl)}`; const satImg = await new Promise((res, rej) => { const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => res(i); i.onerror = (e) => rej( new Error( "Impossible de charger l image satellite", ), ); i.src = proxyUrl; }); // Left 66% = image, right 34% = legend panel const IMG_W = Math.round(W * 0.66), IMG_H = H_IMG; ctx.drawImage(satImg, 0, hh, IMG_W, IMG_H); // Subtle border ctx.strokeStyle = "#163352"; ctx.lineWidth = 1; ctx.strokeRect(0, hh, IMG_W, IMG_H); // ── Legend panel (right side) ───────────────────────────────────────────── const LP_X = IMG_W + 1, LP_W = W - IMG_W - 1; ctx.fillStyle = "#0c1a30"; ctx.fillRect(LP_X, hh, LP_W, IMG_H); ctx.strokeStyle = "#163352"; ctx.strokeRect(LP_X, hh, LP_W, IMG_H); let ly = hh + 22; ctx.fillStyle = "#5a7a9a"; ctx.font = "bold 10px sans-serif"; ctx.fillText("LEGENDE", LP_X + 14, ly); ly += 20; const legend = meta.legend || []; for (const [col, lbl] of legend) { ctx.fillStyle = col; ctx.fillRect(LP_X + 14, ly - 12, 18, 14); ctx.strokeStyle = "#163352"; ctx.lineWidth = 0.5; ctx.strokeRect(LP_X + 14, ly - 12, 18, 14); ctx.fillStyle = "#dde4f0"; ctx.font = "11px sans-serif"; ctx.fillText(lbl, LP_X + 38, ly); ly += 22; } // ── Statistics section (right panel, below legend) ──────────────────────── if (analysis) { ly = Math.max(ly, hh + IMG_H / 2); ctx.fillStyle = "#5a7a9a"; ctx.font = "bold 10px sans-serif"; ctx.fillText("CHIFFRES CLES", LP_X + 14, ly); ly += 18; const stats = []; if (analysis.ndvi != null) stats.push(["NDVI", analysis.ndvi.toFixed(3)]); if (analysis.rain != null) stats.push([ "Pluie", Math.round(analysis.rain) + " mm/an", ]); if (analysis.area_ha != null) stats.push([ "Surface", Number(analysis.area_ha).toFixed(2) + " ha", ]); else if (analysis.area_km2 != null) stats.push([ "Surface", analysis.area_km2 < 1 ? Math.round(analysis.area_km2 * 100) + " ha" : Math.round( analysis.area_km2, ).toLocaleString() + " km²", ]); const cropPct = analysis.landcover?.crop_pct ?? analysis.landcover?.classes?.crops; if (cropPct != null) stats.push([ "Cultures", Number(cropPct).toFixed(1) + "%", ]); if (analysis.terrain?.elev_mean_m != null) stats.push([ "Altitude", Math.round(analysis.terrain.elev_mean_m) + " m", ]); if (analysis.population?.total != null) stats.push([ "Population", analysis.population.total.toLocaleString(), ]); for (const [k, v] of stats) { ctx.fillStyle = "#5a7a9a"; ctx.font = "10px sans-serif"; ctx.fillText(k + ":", LP_X + 14, ly); ctx.fillStyle = "#dde4f0"; ctx.font = "bold 11px sans-serif"; ctx.fillText(v, LP_X + 104, ly); ly += 18; } } // ── Bottom statistics bar ───────────────────────────────────────────────── const BY = hh + H_IMG; ctx.fillStyle = "#0a1525"; ctx.fillRect(0, BY, W, H_LEGEND); if (analysis?.landcover?.classes) { const cls = analysis.landcover.classes; const sorted = Object.entries(cls) .filter(([, v]) => v > 1) .sort(([, a], [, b]) => b - a); const BAR_X = 20, BAR_Y = BY + 14, BAR_W = W - 40, BAR_H = 20; // Color bar let bx = BAR_X; for (const [klass, pct] of sorted) { const col = _LC_DEF_COL[klass] || "#888"; const w = (pct / 100) * BAR_W; ctx.fillStyle = col; ctx.fillRect(bx, BAR_Y, Math.max(1, w), BAR_H); bx += w; } ctx.strokeStyle = "#163352"; ctx.lineWidth = 1; ctx.strokeRect(BAR_X, BAR_Y, BAR_W, BAR_H); // Class labels under bar ctx.font = "10px sans-serif"; bx = BAR_X; let row = 0; for (const [klass, pct] of sorted.slice(0, 8)) { const col = _LC_DEF_COL[klass] || "#888"; const label = _LC_DEF_LBL[klass] || klass; const col_x = BAR_X + (row % 4) * ((W - 40) / 4); const col_y = BAR_Y + BAR_H + 16 + Math.floor(row / 4) * 18; ctx.fillStyle = col; ctx.fillRect(col_x, col_y - 10, 12, 12); ctx.fillStyle = "#8aa8c0"; ctx.fillText( `${label}: ${pct.toFixed(1)}%`, col_x + 16, col_y, ); row++; } } // ── Footer ──────────────────────────────────────────────────────────────── const FY = H - H_FOOT; ctx.fillStyle = "#040d1a"; ctx.fillRect(0, FY, W, H_FOOT); ctx.fillStyle = "#27ae60"; ctx.fillRect(0, FY, 4, H_FOOT); ctx.fillStyle = "#334a60"; ctx.font = "10px sans-serif"; ctx.fillText( `Sources : ${meta.source || "Sentinel-2"} · pluie CHIRPS · relief SRTM 30 m · population WorldPop`, 14, FY + 18, ); ctx.fillText( `Produit le ${new Date().toISOString().slice(0, 10)} · Ineema · Initiative Ineema Afrique · ineema.africa`, 14, FY + 34, ); return new Promise((res) => canvas.toBlob(res, "image/png")); } // LC definition helpers for canvas legend bar const _LC_DEF_COL = { water: "#2980b9", trees: "#1a7a40", grass: "#8bc34a", flooded_veg: "#1abc9c", crops: "#f39c12", shrub_scrub: "#95a5a6", built_up: "#e74c3c", bare_ground: "#a04000", snow_ice: "#ecf0f1", }; const _LC_DEF_LBL = { water: "Eau", trees: "Foret", grass: "Herbe", flooded_veg: "Zone humide", crops: "Cultures", shrub_scrub: "Arbustes", built_up: "Bati", bare_ground: "Sol nu", snow_ice: "Neige", }; // ── AI Agent Panel ──────────────────────────────────────────────────────────── let _apRunning = false; let _apSession = "officer_" + Date.now(); let _apLang = "en"; function setApLang(l, btn) { _apLang = l; document .querySelectorAll(".ap-lang-btn") .forEach((b) => b.classList.remove("active")); btn.classList.add("active"); const inp = document.getElementById("ap-input"); inp.placeholder = l === "fa" ? "سوال بپرسید یا نام ولایت را بنویسید…" : l === "ps" ? "پوښتنه وکړئ یا د ولسوالۍ نوم ولیکئ…" : "Type a place or question…"; } function toggleAgentPanel() { document.getElementById("agent-panel").classList.toggle("open"); keepMapVisible(); } function apMarkdown(txt) { if (!txt) return ""; txt = txt .replace(/Claude Code/gi, "Agent Ineema") .replace( /assistant pour les tâches de développement logiciel/gi, "assistant agricole pour Ineema Mali", ) .replace( /je n'ai pas accès aux données de végétation en temps réel/gi, "GEE live est en attente; j utilise les donnees regionales disponibles avec prudence", ); return txt .replace(/&/g, "&") .replace(//g, ">") .replace(/\*\*(.+?)\*\*/g, "$1") .replace(/`(.+?)`/g, "$1") .replace( /^#{1,3} (.+)$/gm, '
$1
', ) .replace( /^\| (.+)$/gm, '
$1
', ) .replace( /^[-*] (.+)$/gm, '
• $1
', ) .replace(/\n{2,}/g, "

") .replace(/\n/g, "
"); } function apAppend(role, html, meta) { const msgs = document.getElementById("ap-messages"); const div = document.createElement("div"); div.className = `ap-bubble ${role}`; if (role === "bot") { let content = html; // Tool pills if (meta?.tool_calls?.length) { const pills = meta.tool_calls .map( (tc) => `✓ ${tc.tool.replace(/_/g, " ")}`, ) .join(""); content += `
${pills}
`; } // Satellite data mini-card const sat = meta?.tool_calls?.find( (tc) => tc.tool === "query_satellite_data" && tc.output?.ndvi != null, ); if (sat) { const d = sat.output; const col = d.ndvi >= 0.5 ? "#27ae60" : d.ndvi >= 0.35 ? "#f6ad55" : d.ndvi >= 0.2 ? "#ef6c00" : "#e74c3c"; content += `
📡 SATELLITE DATA
NDVI ${d.ndvi?.toFixed(3) || "—"}
${d.rainfall_mm != null ? `
Pluie${Math.round(d.rainfall_mm)} mm/an
` : ""} ${d.temperature_c != null ? `
Temperature${d.temperature_c.toFixed(1)}°C
` : ""} ${d.population != null ? `
Population${d.population.toLocaleString()}
` : ""}
`; // If coords returned, zoom map to the location if (meta._query_coords) _apZoomMap(meta._query_coords); } // Steps info if (meta?.iterations) { const b = meta.backend === "atessa" ? "🧠" : meta.backend === "anthropic" ? "🧠" : "⚡"; content += `
${b} ${meta.iterations} step${meta.iterations !== 1 ? "s" : ""} · ${(meta.tool_calls || []).length} tools
`; } div.innerHTML = content; } else { div.textContent = html; } msgs.appendChild(div); msgs.scrollTop = msgs.scrollHeight; } function apThinking() { const msgs = document.getElementById("ap-messages"); const div = document.createElement("div"); div.id = "ap-think"; div.className = "ap-thinking"; div.innerHTML = 'Agent en cours…'; msgs.appendChild(div); msgs.scrollTop = msgs.scrollHeight; } function apRemoveThink() { document.getElementById("ap-think")?.remove(); } function _apZoomMap(coords) { if (!coords || coords.length < 2 || !map) return; try { const lats = coords.map((c) => c[0]); const lons = coords.map((c) => c[1]); const b = L.latLngBounds( [Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)], ); map.fitBounds(b, { padding: [40, 40], duration: 0.8 }); } catch (e) {} } function keepMapVisible() { if (!map) return; [0, 120, 360, 800].forEach((delay) => { setTimeout(() => { try { const el = document.getElementById("map"); if (!el) return; const r = el.getBoundingClientRect(); if (r.width < 80 || r.height < 120) return; map.invalidateSize({ animate: false }); } catch (e) {} }, delay); }); } function zoomMapIn() { if (!map) return; map.zoomIn(); keepMapVisible(); } function zoomMapOut() { if (!map) return; map.zoomOut(); keepMapVisible(); } function useMyLocation() { if (!navigator.geolocation) { setStatus( "Geolocalisation non disponible sur ce navigateur.", "err", ); return; } const btn = document.querySelector(".geo-btn"); if (btn) { btn.disabled = true; btn.textContent = "📍 Localisation..."; } setStatus("Recherche de votre position GPS...", "ok"); navigator.geolocation.getCurrentPosition( (pos) => { const lat = pos.coords.latitude; const lon = pos.coords.longitude; const acc = Math.round(pos.coords.accuracy || 0); document.getElementById("field-lat").value = lat.toFixed(6); document.getElementById("field-lon").value = lon.toFixed(6); document.getElementById("field-coords").value = `${lat.toFixed(6)}, ${lon.toFixed(6)}`; if (!document.getElementById("field-area").value) { document.getElementById("field-area").value = "1"; } if (!document.getElementById("field-label").value) { document.getElementById("field-label").value = "Ma parcelle"; } if (map) { map.setView([lat, lon], 16); if (userLocationLayer) map.removeLayer(userLocationLayer); userLocationLayer = L.layerGroup([ L.circle([lat, lon], { radius: Math.max(acc, 20), color: "#58bdfb", weight: 1, fillColor: "#58bdfb", fillOpacity: 0.08, }), L.circleMarker([lat, lon], { radius: 7, color: "#03110b", weight: 2, fillColor: "#3df08f", fillOpacity: 1, }), ]).addTo(map); userLocationLayer .bindPopup( `📍 Position actuelle
Precision environ ${acc || "—"} m`, ) .openPopup(); } setStatus( `Position ajoutee · precision environ ${acc || "—"} m. Entrez la surface puis lancez l'analyse.`, "ok", ); if (btn) { btn.disabled = false; btn.textContent = "📍 Ma position"; } }, (err) => { const msg = err.code === 1 ? "Autorisez la localisation dans le navigateur pour utiliser votre position." : err.code === 2 ? "Position introuvable. Verifiez le GPS ou le reseau." : "Delai depasse. Reessayez dehors ou avec une meilleure connexion."; setStatus(msg, "err"); if (btn) { btn.disabled = false; btn.textContent = "📍 Ma position"; } }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 30000, }, ); } function focusCurrentParcel() { try { if (hlLayer && hlLayer.getBounds) { map.fitBounds(hlLayer.getBounds(), { padding: [36, 36], }); } else if (_parcelState?.feature) { map.fitBounds( L.geoJSON(_parcelState.feature).getBounds(), { padding: [36, 36] }, ); } else if (selFeat) { map.fitBounds(L.geoJSON(selFeat).getBounds(), { padding: [36, 36], }); } else { map.setView([17.5707, -3.9962], 6); } } catch (e) { map.setView([17.5707, -3.9962], 6); } keepMapVisible(); } function toggleSidebar() { const collapsed = document.body.classList.toggle("sidebar-collapsed"); const btn = document.getElementById("sidebar-toggle-btn"); if (btn) btn.textContent = collapsed ? "☰ Panneau" : "Masquer panneau"; keepMapVisible(); } function buildAgentParcelContext() { if (!_parcelState?.analysis) return ""; const d = _parcelState.analysis; const lc = d.landcover?.classes || {}; const topLc = Object.entries(lc).sort( (a, b) => (b[1] || 0) - (a[1] || 0), )[0]; const topLcTxt = topLc ? `${topLc[0]} ${Number(topLc[1]).toFixed(1)}%` : "non disponible"; const source = d.source === "gee_live" || d.source === "gee" ? "donnees satellite GEE" : d.source || "estimation regionale"; const coords = (_parcelState.coords || []) .slice(0, 4) .map( (c) => `${c[1]?.toFixed?.(5) || c[1]},${c[0]?.toFixed?.(5) || c[0]}`, ) .join(" | "); return `[Contexte parcelle Ineema deja analyse: Nom: ${_parcelState.name}. Source: ${source}. Important: les valeurs suivantes sont deja disponibles; ne dis pas que tu as besoin d'acceder aux donnees satellite pour evaluer cette parcelle. NDVI: ${d.ndvi ?? "non disponible"}. EVI: ${d.evi ?? "non disponible"}. SAVI: ${d.savi ?? "non disponible"}. MNDWI/eau: ${d.mndwi ?? d.water ?? "non disponible"}. Pluie annuelle CHIRPS: ${d.rain ?? "non disponible"} mm/an. Surface: ${d.area_ha ?? "non disponible"} ha. Occupation du sol dominante: ${topLcTxt}. Coordonnees: ${coords}. Reponds en francais avec un diagnostic court et des actions terrain au Mali.]`; } async function apSend(preText) { const inp = document.getElementById("ap-input"); const q = (preText || inp.value).trim(); if (!q || _apRunning) return; document.getElementById("ap-welcome")?.remove(); inp.value = ""; // Show user bubble apAppend("user", q); apThinking(); _apRunning = true; document.getElementById("ap-send-btn").disabled = true; // Include current map location + language hint in context const mapCenter = map?.getCenter(); const langHint = _apLang === "fa" ? "[پاسخ به زبان FR بده]" : _apLang === "ps" ? "[ځواب BM کې راکه]" : ""; const parcelContext = buildAgentParcelContext(); const contextQ = langHint + (parcelContext ? `${parcelContext} ${q}` : selFeat ? `[Current selection: ${selFeat.properties?.NAME_1 || ""} ${selFeat.properties?.NAME_2 || ""}, coords: ${extractCoords(selFeat).slice(0, 4)}] ${q}` : mapCenter ? `[Map centred on lat:${mapCenter.lat.toFixed(3)} lon:${mapCenter.lng.toFixed(3)}] ${q}` : q); try { // Step 1: start task const r = await fetch(`${API}/agent/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ question: contextQ, session_id: _apSession, role: "officer", language: _apLang || lang || "en", coords: _parcelState?.coords ? _parcelState.coords.slice(0, 20) : selFeat ? extractCoords(selFeat).slice(0, 20) : null, }), }); const init = await r.json(); if (init.answer && !init.task_id) { apRemoveThink(); apAppend("bot", apMarkdown(init.answer), {}); return; } if (init.error) { apRemoveThink(); apAppend("bot", `⚠️ ${init.error}`, {}); return; } // Step 2: poll let data = null; for (let i = 0; i < 60 && !data; i++) { await new Promise((res) => setTimeout(res, 4000)); const poll = await fetch( `${API}/agent/result/${init.task_id}`, ); const pd = await poll.json(); if (pd.status === "done") data = pd; if (pd.status === "error") throw new Error(pd.error); } apRemoveThink(); if (!data) { apAppend( "bot", "⚠️ Timed out. Try a simpler question.", {}, ); return; } // Attach query coords for map zoom const satCall = data.tool_calls?.find( (tc) => tc.tool === "query_satellite_data", ); if (satCall && selFeat) data._query_coords = extractCoords(selFeat).slice( 0, 10, ); apAppend("bot", apMarkdown(data.answer), data); } catch (e) { apRemoveThink(); apAppend("bot", `⚠️ Error: ${e.message}`, {}); } finally { _apRunning = false; document.getElementById("ap-send-btn").disabled = false; } } // ── 3D Terrain View (Maplibre GL JS) ───────────────────────────────────────── let _3dMap = null, _3dLCVisible = false, _3dSatOn = true, _3dTerrainOn = true; const _3D_STYLE = { version: 8, glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", sources: { sat: { type: "raster", tileSize: 256, tiles: [ "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", ], attribution: "Esri", }, labels: { type: "raster", tileSize: 256, tiles: [ "https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}", ], attribution: "Esri", }, terrain: { type: "raster-dem", tileSize: 256, encoding: "terrarium", maxzoom: 15, tiles: [ "https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png", ], attribution: "Mapzen/AWS", }, }, layers: [ { id: "sat", type: "raster", source: "sat" }, { id: "labels", type: "raster", source: "labels", paint: { "raster-opacity": 0.75 }, }, ], sky: { "sky-color": "#08101e", "horizon-color": "#0c1a30", "horizon-fog-blend": 0.6, "fog-color": "#080f1e", "fog-ground-blend": 0.9, }, }; function open3DView() { const ov = document.getElementById("view3d-overlay"); ov.style.display = "block"; if (_3dMap) { _3dMap.resize(); return; } const centre = map.getCenter(); const zoom = Math.min(map.getZoom(), 12); _3dMap = new maplibregl.Map({ container: "view3d-map", style: _3D_STYLE, center: [centre.lng, centre.lat], zoom, pitch: 55, bearing: -25, antialias: true, terrain: { source: "terrain", exaggeration: 1.8 }, }); _3dMap.addControl( new maplibregl.NavigationControl({ visualizePitch: true }), "top-right", ); _3dMap.addControl( new maplibregl.ScaleControl({ unit: "metric" }), "bottom-right", ); _3dMap.on("load", () => { _3dMap.setTerrain({ source: "terrain", exaggeration: 1.8 }); _add3DProvinceLayers(); _add3DLandcoverLayers(); _3dFlyToRegion(); }); const regionName = selFeat?.properties?.NAME_1 || cIn.value.trim() || ""; document.getElementById("v3d-region").textContent = regionName; } function close3DView() { document.getElementById("view3d-overlay").style.display = "none"; } function _add3DProvinceLayers() { if (!_3dMap || !l1Data) return; if (_3dMap.getSource("provinces")) return; _3dMap.addSource("provinces", { type: "geojson", data: l1Data, }); // Province fill extrusion (low, translucent walls) _3dMap.addLayer({ id: "prov-walls", type: "fill-extrusion", source: "provinces", paint: { "fill-extrusion-color": "#27ae60", "fill-extrusion-height": 800, "fill-extrusion-base": 0, "fill-extrusion-opacity": 0.18, }, }); // Province outlines (white, sharp) _3dMap.addLayer({ id: "prov-lines", type: "line", source: "provinces", paint: { "line-color": "#ffffff", "line-width": 1.5, "line-opacity": 0.8, }, }); // Province labels _3dMap.addLayer({ id: "prov-labels", type: "symbol", source: "provinces", layout: { "text-field": ["get", "NAME_1"], "text-size": 11, "text-font": ["Open Sans Regular"], "text-anchor": "center", }, paint: { "text-color": "#dde4f0", "text-halo-color": "#080f1e", "text-halo-width": 1, }, }); // Village layer (if loaded) if (l3Data) { _3dMap.addSource("villages", { type: "geojson", data: { type: "FeatureCollection", features: l3Data.features, }, }); _3dMap.addLayer({ id: "vil-lines", type: "line", source: "villages", paint: { "line-color": "#ffe066", "line-width": 0.8, "line-opacity": 0.6, }, }); } } function _add3DLandcoverLayers() { if (!_3dMap || !detectLayer) return; try { const gj = detectLayer.toGeoJSON(); if (_3dMap.getSource("lc3d")) _3dMap.removeLayer("lc3d-extrude"); else _3dMap.addSource("lc3d", { type: "geojson", data: gj }); // Extrusion heights by DW class (crops low, forest tall, urban medium) const heightExpr = [ "match", ["get", "lc"], 0, 400, // water → flat blue 1, 9000, // forest → tall green 2, 600, // grass → low 3, 500, // flooded → near flat 4, 800, // crops → low 5, 1200, // shrub → low-medium 6, 4000, // urban → medium 7, 300, // bare → very flat 8, 200, // snow → flat 500, // default ]; const colorExpr = [ "match", ["get", "lc"], 0, "#2980b9", 1, "#1a7a40", 2, "#8bc34a", 3, "#1abc9c", 4, "#f39c12", 5, "#95a5a6", 6, "#e74c3c", 7, "#a04000", 8, "#ecf0f1", "#888", ]; _3dMap.addLayer({ id: "lc3d-extrude", type: "fill-extrusion", source: "lc3d", paint: { "fill-extrusion-color": colorExpr, "fill-extrusion-height": heightExpr, "fill-extrusion-base": 0, "fill-extrusion-opacity": 0.78, }, }); _3dLCVisible = true; document.getElementById("v3d-lc-btn").classList.add("on"); } catch (e) { console.warn("3D LC error:", e); } } function _3dFlyToRegion() { if (!_3dMap || !selFeat) return; try { const bounds = L.geoJSON(selFeat).getBounds(); _3dMap.fitBounds( [ [bounds.getWest(), bounds.getSouth()], [bounds.getEast(), bounds.getNorth()], ], { padding: 60, pitch: 55, bearing: -25, duration: 1800, }, ); } catch (e) {} } function toggle3DTerrain() { if (!_3dMap) return; _3dTerrainOn = !_3dTerrainOn; _3dMap.setTerrain( _3dTerrainOn ? { source: "terrain", exaggeration: 1.8 } : null, ); document.getElementById("v3d-terrain-btn").textContent = `⛰️ Terrain ${_3dTerrainOn ? "ON" : "OFF"}`; document .getElementById("v3d-terrain-btn") .classList.toggle("on", _3dTerrainOn); } function toggle3DLandcover() { if (!_3dMap) return; if (!_3dLCVisible && detectLayer) { _add3DLandcoverLayers(); return; } if (_3dMap.getLayer("lc3d-extrude")) { _3dLCVisible ? _3dMap.setLayoutProperty( "lc3d-extrude", "visibility", "none", ) : _3dMap.setLayoutProperty( "lc3d-extrude", "visibility", "visible", ); _3dLCVisible = !_3dLCVisible; document .getElementById("v3d-lc-btn") .classList.toggle("on", _3dLCVisible); } } function toggle3DSat() { if (!_3dMap) return; _3dSatOn = !_3dSatOn; _3dMap.setLayoutProperty( "sat", "visibility", _3dSatOn ? "visible" : "none", ); document .getElementById("v3d-sat-btn") .classList.toggle("on", _3dSatOn); } let _3dPitch = 55; function toggle3DTilt() { if (!_3dMap) return; _3dPitch = _3dPitch === 55 ? 75 : _3dPitch === 75 ? 0 : 55; _3dMap.easeTo({ pitch: _3dPitch, duration: 800 }); } // ── Time-series / dynamic change explorer ──────────────────────────────────── let _tsState = { coords: null, trend: {}, playing: false, playTimer: null, }; function showTimeSeries(trend) { if (!trend || !Object.keys(trend).length) return; _tsState.trend = trend; const years = Object.keys(trend).map(Number).sort(); const currentYear = new Date().getFullYear(); const availableYears = years.filter((y) => trend[y] != null); const minYear = years[0] || 2013; const maxYear = Math.max( currentYear, years[years.length - 1] || currentYear, ); const defaultYear = availableYears.includes(currentYear) ? currentYear : availableYears[availableYears.length - 1] || maxYear; const slider = document.getElementById("pp-ts-slider"); slider.min = minYear; slider.max = maxYear; slider.value = defaultYear; const maxLabel = document.getElementById("pp-ts-max-label"); if (maxLabel) maxLabel.textContent = String(maxYear); // Build trend bar chart const vals = years.map((y) => trend[y] ?? 0); const mx = Math.max(...vals.filter((v) => v)) || 0.8; const bars = years .map((y) => { const v = trend[y] ?? 0; const pct = Math.round((v / mx) * 100); const col = v >= 0.45 ? "#2e7d32" : v >= 0.28 ? "#f9a825" : v >= 0.15 ? "#ef6c00" : "#bf360c"; return `
`; }) .join(""); document.getElementById("pp-ts-bar").innerHTML = bars; document.getElementById("pp-timeseries").style.display = "block"; onTsSlider(defaultYear); } // Cache for timelapse images so we don't re-fetch on repeat plays const _tsImgCache = {}; async function onTsSlider(year) { year = parseInt(year); document.getElementById("pp-ts-slider").value = year; const v = _tsState.trend[year]; const ndviCol = v != null ? v >= 0.45 ? "#2e7d32" : v >= 0.28 ? "#f9a825" : v >= 0.15 ? "#ef6c00" : "#bf360c" : "#607d8b"; const health = v == null ? { label: "Données indisponibles", icon: "⚪", advice: "Aucune lecture claire pour cette année. Regardez une autre année ou vérifiez la parcelle sur le terrain." } : v >= 0.45 ? { label: "Végétation bonne", icon: "🟢", advice: "Le champ paraît bien couvert. Continuez le suivi normal : mauvaises herbes, ravageurs et humidité du sol." } : v >= 0.28 ? { label: "Végétation moyenne", icon: "🟡", advice: "Le champ pousse, mais il faut surveiller. Vérifiez l'eau, les zones jaunes et la présence d'insectes." } : v >= 0.15 ? { label: "Végétation faible", icon: "🟠", advice: "Le champ semble en difficulté. Vérifiez rapidement l'humidité du sol, le jaunissement des feuilles et les zones nues." } : { label: "Très faible ou sol nu", icon: "🔴", advice: "La parcelle peut être sèche, récoltée, en jachère ou mal détectée. Confirmez sur le terrain avant toute décision." }; document.getElementById("pp-ts-year").innerHTML = `${year}` + `${health.icon} ${health.label}`; const advice = document.getElementById("pp-ts-advice"); if (advice) { advice.innerHTML = `${health.label} · ${health.advice}
Indice satellite : ${v != null ? v.toFixed(3) : "—"}. Ce chiffre aide le conseiller, mais la décision finale doit être confirmée au champ.`; } if (!_parcelState?.coords) return; const wrap = document.getElementById("pp-ts-img-wrap"); wrap.style.display = "block"; const img = document.getElementById("pp-ts-img"); // Check cache first if (_tsImgCache[year]) { img.src = _tsImgCache[year]; img.style.opacity = "1"; return; } img.style.opacity = "0.25"; try { const r = await fetch(`${API}/officer/parcel-thumbnail`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ coords: _parcelState.coords, year, layer: "ndvi", }), }); const d = await r.json(); if (d.url) { // Wait for image to actually load before returning (important for timelapse) await new Promise((res, rej) => { const i = new Image(); i.onload = () => { _tsImgCache[year] = d.url; img.src = d.url; img.style.opacity = "1"; res(); }; i.onerror = () => res(); // don't block on error i.src = d.url; }); } } catch (e) { img.style.opacity = "0.5"; } } function _dlTsImage() { const year = parseInt( document.getElementById("pp-ts-slider").value, ); if (_parcelState) { const orig = _parcelState.year; _parcelState.year = year; downloadParcelImage("ndvi"); _parcelState.year = orig; } } let _playIdx = 0; async function _playTimelapse() { if (_tsState.playing) { _tsState.playing = false; document.getElementById("pp-ts-play").textContent = "▶ Lire le timelapse"; return; } const years = Object.keys(_tsState.trend) .map(Number) .sort() .filter((y) => _tsState.trend[y]); if (!years.length) return; _tsState.playing = true; _playIdx = 0; document.getElementById("pp-ts-play").textContent = "⏹ Arreter"; document.getElementById("pp-ts-play").style.background = "#2a0a0a"; for (const y of years) { if (!_tsState.playing) break; await onTsSlider(y); // waits for image to load await new Promise((res) => setTimeout(res, 2200)); // hold 2.2s per frame } _tsState.playing = false; document.getElementById("pp-ts-play").textContent = "▶ Lire le timelapse"; document.getElementById("pp-ts-play").style.background = ""; } // ── Village 10m crop type map ──────────────────────────────────────────────── let _villageCropLayer = null; async function loadVillageCrops() { if (!_parcelState) { alert("Lancez d abord l analyse de parcelle."); return; } const btn = document.querySelector("#pp-crop-bar button"); btn.textContent = "⏳ Chargement carte cultures 10 m..."; btn.disabled = true; const { coords, year } = _parcelState; try { // Start task const r = await fetch(`${API}/officer/village-crops`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ coords, year }), }); const start = await r.json(); if (start.error) throw new Error(start.error); // Poll let geojson = null; for (let i = 0; i < 40 && !geojson; i++) { await new Promise((res) => setTimeout(res, 5000)); const poll = await fetch( `${API}/officer/analyse-result/${start.task_id}`, ); const pd = await poll.json(); if (pd.status === "done") geojson = pd.data; if (pd.status === "error") throw new Error(pd.error); } if (!geojson) throw new Error( "Delai depasse. Essayez une zone plus petite.", ); // Remove old layer if (_villageCropLayer) { map.removeLayer(_villageCropLayer); } const CROP_COLORS = { 1: "#f39c12", 2: "#27ae60", 3: "#1a7a40", 4: "#a04000", }; const CROP_NAMES = { 1: "Ble", 2: "Legumes", 3: "Vergers / arbres", 4: "Nu / jachere", }; _villageCropLayer = L.geoJSON(geojson, { style: (feat) => { const cc = feat.properties?.crop_class ?? 4; const col = CROP_COLORS[cc] || "#888"; return { color: "#fff", weight: 0.6, fillColor: col, fillOpacity: 0.7, }; }, onEachFeature: (feat, layer) => { const cc = feat.properties?.crop_class ?? 4; const ndvi = feat.properties?.mean ?? null; layer.bindPopup( `
🌾 ${CROP_NAMES[cc] || "Inconnu"}
NDVI${ndvi !== null ? ndvi.toFixed(3) : "—"}
10 m · Sentinel-2 · estimation automatique · a verifier sur le terrain
`, { maxWidth: 200 }, ); layer.bindTooltip(CROP_NAMES[cc] || "—", { sticky: true, className: "leaflet-tooltip-dark", }); }, }).addTo(map); const count = geojson.features?.length || 0; btn.textContent = `✓ ${count} polygones de culture charges`; document.getElementById("pp-crop-legend").style.display = "block"; // Zoom to the crop polygons if (count > 0) map.fitBounds(_villageCropLayer.getBounds(), { padding: [20, 20], }); } catch (e) { btn.textContent = `Erreur : ${e.message}`; btn.disabled = false; } } // Download satellite image for any polygon (called from DW layer popups) async function _dlDWImg(layer, coords, year, label) { const safeName = (label || "parcel").replace( /[^a-zA-Z0-9_]/g, "_", ); try { const r = await fetch(`${API}/officer/parcel-thumbnail`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ coords, year, layer }), }); const d = await r.json(); if (d.error) { alert("Image error: " + d.error); return; } const a = document.createElement("a"); a.href = d.url; a.target = "_blank"; a.download = `Ineema_${safeName}_${layer}_${year}.png`; a.click(); } catch (e) { alert("Echec : " + e.message); } } async function downloadMapScreenshot() { const name = ( _parcelState?.name || selFeat?.properties?.NAME_1 || "map" ).replace(/[^a-zA-Z0-9_]/g, "_"); try { const canvas = await html2canvas( document.getElementById("map"), { useCORS: true, allowTaint: true, scale: 1.5, backgroundColor: "#080f1e", }, ); const a = document.createElement("a"); a.download = `Ineema_${name}_map.png`; a.href = canvas.toDataURL("image/png"); a.click(); } catch (e) { alert( "Capture impossible : " + e.message + "\nEssayez plutot le bouton PNG satellite.", ); } } document .getElementById("vil-sel") .addEventListener("change", function () { if (!this.value || !l3Data || !l2Data) return; const distName = document.getElementById("dist-sel").value; const distFeat = l2Data.features.find( (f) => f.properties.NAME_2 === distName, ); if (!distFeat) return; const f = l3Data.features.find( (f) => f.properties.NAME_3 === this.value && f.properties.GID_2 === distFeat.properties.GID_2, ); if (f) { selFeat = f; highlight(f); } }); function highlight(feat) { if (hlLayer) { map.removeLayer(hlLayer); hlLayer = null; } hlLayer = L.geoJSON(feat, { style: { color: "#f39c12", weight: 2.5, fillColor: "#27ae60", fillOpacity: 0.18, }, }).addTo(map); map.fitBounds(hlLayer.getBounds(), { padding: [30, 30] }); } function buildFieldFeature(lat, lon, areaHa, label) { const sideM = Math.sqrt(areaHa * 10000); const halfLat = sideM / 111320 / 2; const latRad = (lat * Math.PI) / 180; const metersPerLon = 111320 * Math.max(Math.cos(latRad), 0.01); const halfLon = sideM / metersPerLon / 2; return { type: "Feature", geometry: { type: "Polygon", coordinates: [ [ [lon - halfLon, lat - halfLat], [lon + halfLon, lat - halfLat], [lon + halfLon, lat + halfLat], [lon - halfLon, lat + halfLat], [lon - halfLon, lat - halfLat], ], ], }, properties: { NAME_1: "Mali", NAME_2: label || "Parcelle", FIELD_AREA_HA: areaHa, FIELD_CENTER_LAT: lat, FIELD_CENTER_LON: lon, }, }; } let _drawMode = false; let _drawPoints = []; let _drawLayer = null; let _drawPreview = null; let _drawMarkers = []; let _currentFarmer = null; let _savedFields = []; function normalizePhone(phone) { return String(phone || "") .replace(/\s+/g, "") .trim(); } function maskPhone(phone) { const p = normalizePhone(phone); if (p.length <= 6) return p; return `${p.slice(0, 4)}••••${p.slice(-3)}`; } function openAccountModal() { const modal = document.getElementById("account-modal"); modal?.classList.add("open"); setTimeout(() => document.getElementById("farmer-phone")?.focus(), 50); } function closeAccountModal() { document.getElementById("account-modal")?.classList.remove("open"); } function getCurrentFieldPayload() { if (!selFeat) return null; const coords = extractCoords(selFeat); if (!coords || coords.length < 3) return null; const label = ( document.getElementById("field-label")?.value || selFeat.properties?.NAME_2 || "Ma parcelle" ).trim(); const areaHa = parseFloat(document.getElementById("field-area")?.value) || selFeat.properties?.FIELD_AREA_HA || 0; return { coords, label, province: cIn?.value || "Mali", area_ha: areaHa, area_jereb: areaHa * 5, }; } function renderSavedFields() { const list = document.getElementById("saved-fields-list"); const status = document.getElementById("farmer-account-status"); const pill = document.getElementById("farmer-account-pill"); if (!list || !status) return; if (!_currentFarmer) { status.textContent = "Vos champs apparaitront ici apres ouverture du compte."; if (pill) pill.textContent = "🔒 Aucun compte ouvert"; list.innerHTML = ""; return; } const masked = maskPhone(_currentFarmer.phone); if (pill) pill.textContent = `✅ Compte ${masked}`; status.textContent = `${masked} · ${_savedFields.length} champ(s) sauvegarde(s)`; if (!_savedFields.length) { list.innerHTML = `
Aucun champ sauvegarde. Analysez ou dessinez un champ, puis cliquez sur Enregistrer.
`; return; } list.innerHTML = _savedFields .map((f, idx) => { const area = Number(f.area_ha || 0).toFixed(2); return `
${f.label || "Champ"}
${area} ha · ${f.province || "Mali"}
`; }) .join(""); } async function loginFarmerAccount() { const input = document.getElementById("farmer-phone"); const phone = normalizePhone(input?.value); if (!phone || phone.length < 6) { setStatus( "Entrez un numero WhatsApp valide pour creer ou ouvrir le compte.", "err", ); return; } if (input) input.value = phone; localStorage.setItem("ineema_farmer_phone", phone); setStatus("Ouverture du compte terrain...", "ok"); try { const r = await fetch(`${API}/db/farmer`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone, language: lang || "en", province: cIn?.value || "Mali", }), }); const data = await r.json(); if (!r.ok) { const msg = data.error === "Database unavailable" ? "La sauvegarde des comptes est momentanement indisponible. Verifiez la configuration Supabase, puis rechargez la page." : data.error || "Compte indisponible"; throw new Error(msg); } _currentFarmer = data.farmer; _savedFields = data.fields || []; renderSavedFields(); closeAccountModal(); setStatus( `Compte ouvert · ${_savedFields.length} champ(s) sauvegarde(s).`, "ok", ); } catch (e) { setStatus( e.message || "Impossible d ouvrir le compte.", "err", ); } } async function saveCurrentFieldForFarmer() { if (!_currentFarmer) { setStatus( "Ouvrez d'abord le compte avec le numero WhatsApp.", "err", ); openAccountModal(); return; } const payload = getCurrentFieldPayload(); if (!payload) { setStatus( "Analysez ou dessinez d'abord un champ avant de l'enregistrer.", "err", ); return; } setStatus("Enregistrement du champ...", "ok"); try { const r = await fetch(`${API}/db/field/save`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ farmer_id: _currentFarmer.id, ...payload, }), }); const data = await r.json(); if (!r.ok) throw new Error( data.error || "Enregistrement impossible", ); _savedFields.unshift(data.field); renderSavedFields(); setStatus(`Champ "${payload.label}" sauvegarde.`, "ok"); } catch (e) { setStatus( e.message || "Impossible d enregistrer le champ.", "err", ); } } function openSavedField(index) { const f = _savedFields[index]; if (!f) return; let coords = f.coords; if (typeof coords === "string") { try { coords = JSON.parse(coords); } catch (e) { coords = []; } } if (!coords || coords.length < 3) { setStatus("Coordonnees du champ introuvables.", "err"); return; } const label = f.label || "Champ sauvegarde"; const areaHa = Number(f.area_ha || 0); const points = coords.map(([lat, lon]) => [lat, lon]); const feature = buildDrawnFieldFeature(points, label); feature.properties.FIELD_AREA_HA = areaHa || feature.properties.FIELD_AREA_HA; selFeat = feature; document.getElementById("field-label").value = label; document.getElementById("field-area").value = ( areaHa || feature.properties.FIELD_AREA_HA || 0 ).toFixed(2); document.getElementById("field-lat").value = feature.properties.FIELD_CENTER_LAT.toFixed(6); document.getElementById("field-lon").value = feature.properties.FIELD_CENTER_LON.toFixed(6); document.getElementById("field-coords").value = `${feature.properties.FIELD_CENTER_LAT.toFixed(6)}, ${feature.properties.FIELD_CENTER_LON.toFixed(6)}`; highlight(feature); document.getElementById("parcel-btn").disabled = false; document.getElementById("detect-btn").disabled = false; setStatus(`Champ "${label}" ouvert. Analyse en cours...`, "ok"); analyseParcel(feature, label, "parcelle"); } async function deleteSavedField(index) { const f = _savedFields[index]; if (!_currentFarmer || !f?.id) return; if (!confirm(`Supprimer "${f.label || "ce champ"}" ?`)) return; try { const r = await fetch(`${API}/db/field/delete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ farmer_id: _currentFarmer.id, field_id: f.id, }), }); const data = await r.json(); if (!r.ok) throw new Error(data.error || "Suppression impossible"); _savedFields.splice(index, 1); renderSavedFields(); setStatus("Champ supprime.", "ok"); } catch (e) { setStatus( e.message || "Impossible de supprimer le champ.", "err", ); } } window.addEventListener("DOMContentLoaded", () => { const savedPhone = localStorage.getItem("ineema_farmer_phone"); if (savedPhone) { const input = document.getElementById("farmer-phone"); if (input) input.value = savedPhone; } renderSavedFields(); }); function updateDrawUi() { const start = document.getElementById("draw-start-btn"); const finish = document.getElementById("draw-finish-btn"); const clear = document.getElementById("draw-clear-btn"); const hint = document.getElementById("draw-hint"); if (start) start.textContent = _drawMode ? "Dessin en cours..." : "Dessiner sur la carte"; if (start) start.disabled = _drawMode; if (finish) finish.disabled = _drawPoints.length < 3; if (clear) clear.style.display = _drawMode || _drawPoints.length ? "block" : "none"; if (hint) hint.classList.toggle("on", _drawMode); } function startFieldDrawing() { if (!map) return; clearFieldDrawing(false); const coordText = [ document.getElementById("field-coords")?.value || "", document.getElementById("field-lat")?.value || "", document.getElementById("field-lon")?.value || "", ].join(" "); const parsed = parseCoordinateText(coordText); if ( parsed && Number.isFinite(parsed.lat) && Number.isFinite(parsed.lon) ) { document.getElementById("field-lat").value = parsed.lat.toFixed(6); document.getElementById("field-lon").value = parsed.lon.toFixed(6); map.setView( [parsed.lat, parsed.lon], Math.max(map.getZoom(), 17), { animate: true }, ); } else if (map.getZoom() < 14) { map.setZoom(14); } _drawMode = true; map.doubleClickZoom.disable(); map.getContainer().style.cursor = "crosshair"; map.on("click", onFieldDrawClick); map.on("dblclick", onFieldDrawDoubleClick); updateDrawUi(); setStatus( "Mode dessin actif : zoomez sur le champ, cliquez autour du contour, puis Terminer.", "ok", ); keepMapVisible(); } function onFieldDrawClick(e) { if (!_drawMode) return; _drawPoints.push([e.latlng.lat, e.latlng.lng]); renderFieldDrawing(); updateDrawUi(); } function onFieldDrawDoubleClick(e) { if (!_drawMode) return; if (e?.originalEvent) { e.originalEvent.preventDefault(); e.originalEvent.stopPropagation(); } finishFieldDrawing(); } function renderFieldDrawing() { if (!map) return; if (_drawLayer) { map.removeLayer(_drawLayer); _drawLayer = null; } if (_drawPreview) { map.removeLayer(_drawPreview); _drawPreview = null; } _drawMarkers.forEach((m) => map.removeLayer(m)); _drawMarkers = []; const markerIcon = (isLast = false) => L.divIcon({ className: isLast ? "draw-marker last" : "draw-marker", iconSize: [12, 12], iconAnchor: [6, 6], }); _drawPoints.forEach((pt, idx) => { _drawMarkers.push( L.marker(pt, { icon: markerIcon(idx === _drawPoints.length - 1), interactive: false, }).addTo(map), ); }); if (_drawPoints.length >= 2) { _drawPreview = L.polyline(_drawPoints, { color: "#f39c12", weight: 2.4, dashArray: "6 5", opacity: 0.95, }).addTo(map); } if (_drawPoints.length >= 3) { _drawLayer = L.polygon(_drawPoints, { color: "#27ae60", weight: 2.5, fillColor: "#27ae60", fillOpacity: 0.18, }).addTo(map); } } function clearFieldDrawing(resetStatus = true) { if (map) { map.off("click", onFieldDrawClick); map.off("dblclick", onFieldDrawDoubleClick); map.doubleClickZoom.enable(); map.getContainer().style.cursor = ""; } if (_drawLayer) { map.removeLayer(_drawLayer); _drawLayer = null; } if (_drawPreview) { map.removeLayer(_drawPreview); _drawPreview = null; } _drawMarkers.forEach((m) => map.removeLayer(m)); _drawMarkers = []; _drawPoints = []; _drawMode = false; updateDrawUi(); if (resetStatus) setStatus("Dessin efface. Vous pouvez recommencer.", "ok"); keepMapVisible(); } function clearDrawingPreviewOnly() { if (_drawLayer) { map.removeLayer(_drawLayer); _drawLayer = null; } if (_drawPreview) { map.removeLayer(_drawPreview); _drawPreview = null; } _drawMarkers.forEach((m) => map.removeLayer(m)); _drawMarkers = []; _drawPoints = []; updateDrawUi(); } function polygonAreaHa(latLngs) { if (!latLngs || latLngs.length < 3) return 0; const pts = latLngs.map(([lat, lon]) => { const latRad = (lat * Math.PI) / 180; return { x: lon * 111320 * Math.cos(latRad), y: lat * 111320, }; }); let sum = 0; for (let i = 0; i < pts.length; i++) { const j = (i + 1) % pts.length; sum += pts[i].x * pts[j].y - pts[j].x * pts[i].y; } return Math.abs(sum) / 2 / 10000; } function buildDrawnFieldFeature(points, label) { const ring = points.map(([lat, lon]) => [lon, lat]); ring.push([points[0][1], points[0][0]]); const areaHa = polygonAreaHa(points); const centerLat = points.reduce((s, p) => s + p[0], 0) / points.length; const centerLon = points.reduce((s, p) => s + p[1], 0) / points.length; return { type: "Feature", geometry: { type: "Polygon", coordinates: [ring] }, properties: { NAME_1: "Mali", NAME_2: label || "Parcelle dessinee", FIELD_AREA_HA: areaHa, FIELD_CENTER_LAT: centerLat, FIELD_CENTER_LON: centerLon, }, }; } function finishFieldDrawing() { if (_drawPoints.length < 3) { setStatus( "Ajoutez au moins 3 points pour fermer la parcelle.", "err", ); return; } const label = ( document.getElementById("field-label").value || "" ).trim() || "Parcelle dessinee"; const feature = buildDrawnFieldFeature(_drawPoints, label); const areaHa = feature.properties.FIELD_AREA_HA || 0; const lat = feature.properties.FIELD_CENTER_LAT; const lon = feature.properties.FIELD_CENTER_LON; document.getElementById("field-lat").value = lat.toFixed(6); document.getElementById("field-lon").value = lon.toFixed(6); document.getElementById("field-area").value = areaHa.toFixed(2); document.getElementById("field-coords").value = `${lat.toFixed(6)}, ${lon.toFixed(6)}`; if (map) { map.off("click", onFieldDrawClick); map.off("dblclick", onFieldDrawDoubleClick); map.doubleClickZoom.enable(); map.getContainer().style.cursor = ""; } _drawMode = false; updateDrawUi(); selFeat = feature; if (cIn && !cIn.value.trim()) cIn.value = "Mali"; highlight(feature); clearDrawingPreviewOnly(); document.getElementById("parcel-btn").disabled = false; document.getElementById("detect-btn").disabled = false; setStatus( `Parcelle dessinee "${label}" selectionnee (${areaHa.toFixed(2)} ha). Analyse en cours...`, "ok", ); analyseParcel(feature, label, "parcelle"); } function parseDmsCoord(part) { const m = String(part || "") .trim() .match( /(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)\D+(\d+(?:\.\d+)?)\D*([NSEW])/i, ); if (!m) return null; const deg = parseFloat(m[1]); const min = parseFloat(m[2]); const sec = parseFloat(m[3]); const hemi = m[4].toUpperCase(); let val = deg + min / 60 + sec / 3600; if (hemi === "S" || hemi === "W") val = -val; return val; } function parseCoordinateText(text) { const cleaned = String(text || "") .replace(/[’‘´`]/g, "'") .replace(/[“”]/g, '"') .replace(/,/g, " ") .trim(); const dms = cleaned.match( /\d+(?:\.\d+)?\D+\d+(?:\.\d+)?\D+\d+(?:\.\d+)?\D*[NSEW]/gi, ); if (dms && dms.length >= 2) { const a = parseDmsCoord(dms[0]); const b = parseDmsCoord(dms[1]); if (a === null || b === null) return null; const firstHemi = dms[0] .match(/[NSEW]/i)?.[0] .toUpperCase(); return firstHemi === "E" || firstHemi === "W" ? { lat: b, lon: a } : { lat: a, lon: b }; } const nums = cleaned.match(/-?\d+(?:\.\d+)?/g); if (nums && nums.length >= 2) return { lat: parseFloat(nums[0]), lon: parseFloat(nums[1]), }; return null; } function analyseCoordinateField() { if (_drawMode || _drawPoints.length) clearFieldDrawing(false); const coordText = [ document.getElementById("field-coords")?.value || "", document.getElementById("field-lat")?.value || "", document.getElementById("field-lon")?.value || "", ].join(" "); const parsed = parseCoordinateText(coordText); let lat = parseFloat( document.getElementById("field-lat").value, ); let lon = parseFloat( document.getElementById("field-lon").value, ); if (parsed) { lat = parsed.lat; lon = parsed.lon; document.getElementById("field-lat").value = lat.toFixed(6); document.getElementById("field-lon").value = lon.toFixed(6); } const areaHa = parseFloat( document.getElementById("field-area").value, ); const label = ( document.getElementById("field-label").value || "" ).trim() || "Parcelle"; if (!Number.isFinite(lat) || lat < -90 || lat > 90) { setStatus( "Latitude invalide. Exemple au Mali : 12.6392", "err", ); return; } if (!Number.isFinite(lon) || lon < -180 || lon > 180) { setStatus( "Longitude invalide. Exemple au Mali : -8.0029", "err", ); return; } if (!Number.isFinite(areaHa) || areaHa <= 0) { setStatus( "Surface invalide. Entrez une valeur en hectares, par exemple 1.5", "err", ); return; } const feature = buildFieldFeature(lat, lon, areaHa, label); selFeat = feature; if (cIn && !cIn.value.trim()) cIn.value = "Mali"; highlight(feature); document.getElementById("parcel-btn").disabled = false; document.getElementById("detect-btn").disabled = false; setStatus( `Parcelle "${label}" selectionnee (${areaHa} ha). Analyse en cours...`, "ok", ); analyseParcel(feature, label, "parcelle"); } // ── NDVI colour scale ─────────────────────────────────────────────────────── function ndviToColor(v) { if (v === null || v === undefined) return "#607d8b"; if (v >= 0.65) return "#1b5e20"; if (v >= 0.5) return "#2e7d32"; if (v >= 0.35) return "#558b2f"; if (v >= 0.25) return "#f9a825"; if (v >= 0.15) return "#ef6c00"; if (v >= 0.05) return "#bf360c"; return "#6d4c41"; } // ndviToColor is an alias for _ndviGradient (defined above) for backwards compat // ── Dynamic legend ──────────────────────────────────────────────────────────── function updateMapLegend(activeLayer) { const leg = document.getElementById("map-legend"); if (!leg) return; let html = ""; if (!activeLayer || activeLayer === "landcover") { // DW class legend html = `
Occupation du sol
`; [ [0, "#0288d1", "Eau"], [1, "#2e7d32", "Foret"], [2, "#7cb342", "Prairie"], [3, "#00838f", "Vegetation inondee"], [4, "#f9a825", "Cultures (sante NDVI)"], [5, "#9e9d24", "Arbustes"], [6, "#b71c1c", "Bati"], [7, "#6d4c41", "Sol nu"], ].forEach(([, col, lbl]) => { html += `
${lbl}
`; }); } else if (activeLayer === "ndvi") { html = `
Sante NDVI
`; [ ["≥ 0.65", "#1b5e20", "Tres dense"], ["0.50–0.65", "#2e7d32", "Sain"], ["0.35–0.50", "#558b2f", "Bon"], ["0.25–0.35", "#f9a825", "Moyen"], ["0.15–0.25", "#ef6c00", "Faible"], ["< 0.15", "#bf360c", "Sol nu/stresse"], ].forEach(([v, col, lbl]) => { html += `
${v} ${lbl}
`; }); } else if (activeLayer === "water") { html = `
Eau / MNDWI
`; [ ["#01579b", "Eau de surface"], ["#0288d1", "Plan d eau"], ["#29b6f6", "Humide / irrigue"], ["#80cbc4", "Sol humide"], ["#a1887f", "Sol sec"], ["#6d4c41", "Tres sec"], ].forEach(([col, lbl]) => { html += `
${lbl}
`; }); } else if (activeLayer === "baresoil") { html = `
Sol nu (BSI)
`; [ ["#ffe0b2", "Tres faible"], ["#ffa726", "Faible"], ["#f57c00", "Moyen"], ["#e64a19", "Fort"], ["#bf360c", "Risque erosion tres eleve"], ].forEach(([col, lbl]) => { html += `
${lbl}
`; }); } else if (activeLayer === "croptype") { html = `
Type de culture
`; [ ["#f39c12", "Ble"], ["#27ae60", "Legumes"], ["#1a7a40", "Verger / arbres"], ["#a04000", "Nu / jachere"], ].forEach(([col, lbl]) => { html += `
${lbl}
`; }); } else if (activeLayer === "forest") { html = `
Densite foret
`; [ ["#1b5e20", "Dense (NDVI > 0.65)"], ["#2e7d32", "Moyenne (0.50–0.65)"], ["#558b2f", "Couvert ouvert (0.45–0.50)"], ].forEach(([col, lbl]) => { html += `
${lbl}
`; }); } if (html) leg.innerHTML = html + `
${t("legVil")}
${t("legProv")}
`; } // ── Load & draw all farmer plots in a province ────────────────────────────── async function loadRegionFields(province) { if (plotLayer) { map.removeLayer(plotLayer); plotLayer = null; } const card = document.getElementById("plots-card"); if (card) card.querySelector(".mc-val").textContent = t("loadingPlots"); if (!province) return; try { const r = await fetch( `${API}/officer/fields?province=${encodeURIComponent(province)}`, ); if (!r.ok) return; const data = await r.json(); const fields = data.fields || []; if (card) { card.querySelector(".mc-val").textContent = fields.length; card.querySelector(".mc-note").textContent = fields.length ? `${fields.filter((f) => f.analysis).length} with satellite data` : t("noPlots"); } if (!fields.length) return; plotLayer = L.layerGroup().addTo(map); fields.forEach((f) => { let coords = f.coords; if (!coords || coords.length < 3) return; const ndvi = f.analysis?.ndvi ?? null; const col = ndviToColor(ndvi); const badge = ndvi === null ? "" : ndvi >= 0.4 ? `Bon` : ndvi >= 0.25 ? `Moyen` : `Faible`; const popHtml = `
${f.label || "Parcelle"}
NDVI${ndvi !== null ? ndvi.toFixed(3) : "—"}
EVI${f.analysis?.evi != null ? f.analysis.evi.toFixed(3) : "—"}
Eau (MNDWI)${f.analysis?.mndwi != null ? f.analysis.mndwi.toFixed(3) : "—"}
Rain${f.analysis?.rain != null ? Math.round(f.analysis.rain) + " mm" : "—"}
Surface${f.area_ha ? f.area_ha.toFixed(2) + " ha" : "—"}
${badge}`; const poly = L.polygon( coords.map((c) => [c[0], c[1]]), { color: "#ffffff", weight: 1.8, fillColor: col, fillOpacity: ndvi !== null ? 0.5 : 0.25, }, ); poly.bindTooltip( `${f.label || "Parcelle"} · NDVI ${ndvi !== null ? ndvi.toFixed(2) : "—"}`, { sticky: true, className: "leaflet-tooltip-dark" }, ); poly.bindPopup(popHtml, { maxWidth: 240 }); poly.addTo(plotLayer); }); } catch (e) { console.error("loadRegionFields:", e); } } // ── Satellite field auto-detection ───────────────────────────────────────── async function detectFields() { if (!selFeat) { setStatus(t("needProv"), "err"); return; } if (!_agentStatus.gee) { setStatus( "Detection satellite indisponible : GEE live est en attente. La parcelle reste analysable avec les donnees regionales.", "err", ); return; } const btn = document.getElementById("detect-btn"); const year = document.getElementById("year-sel").value; btn.disabled = true; btn.textContent = t("detecting"); setStatus(t("detecting")); if (detectLayer) { map.removeLayer(detectLayer); detectLayer = null; } try { const coords = extractCoords(selFeat); // Step 1: start the task (returns immediately with task_id) const r = await fetch(`${API}/officer/detect-fields`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ coords, year: parseInt(year) }), }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `HTTP ${r.status}`); } const start = await r.json(); if (start.error) throw new Error(start.error); const taskId = start.task_id; // Step 2: poll every 5 s until done (GEE takes 30-120 s) let geojson = null; for (let i = 0; i < 40 && !geojson; i++) { await new Promise((res) => setTimeout(res, 5000)); if (i === 4) setStatus( "⏳ Server starting up — first request takes ~30 s after sleep…", ); const poll = await fetch( `${API}/officer/detect-fields/${taskId}`, ); if (!poll.ok) { const e = await poll.json().catch(() => ({})); throw new Error( e.error || `Poll HTTP ${poll.status}`, ); } const pd = await poll.json(); if (pd.status === "done") geojson = pd.data; else if (pd.status === "error") throw new Error(pd.error); // 'pending' → keep polling } if (!geojson) throw new Error( "Detection timed out after 3 minutes. Try a smaller region.", ); const count = geojson.features?.length || 0; // Dynamic World land cover colours and labels const DW_COL = { 0: "#2980b9", 1: "#1a7a40", 2: "#8bc34a", 3: "#1abc9c", 4: "#f39c12", 5: "#95a5a6", 6: "#e74c3c", 7: "#a04000", 8: "#ecf0f1", }; const DW_NAME = { 0: "Eau", 1: "Foret / arbres", 2: "Prairie", 3: "Vegetation inondee", 4: "Cultures", 5: "Arbustes", 6: "Bati / urbain", 7: "Sol nu / desert", 8: "Neige / glace", }; const DW_ICON = { 0: "💧", 1: "🌲", 2: "🌿", 3: "🌊", 4: "🌾", 5: "🌱", 6: "🏘️", 7: "🏜️", 8: "❄️", }; detectLayer = L.geoJSON(geojson, { style: (feat) => { const lc = feat.properties?.lc; const ndvi = feat.properties?.mean ?? null; const col = lc !== undefined && lc !== null ? _dwColor(lc, ndvi) : _ndviGradient(ndvi); const wet = lc === 0 || lc === 3; // water / flooded get blue stroke const urb = lc === 6; // built-up gets darker stroke const stroke = wet ? "#0277bd" : urb ? "#7f0000" : "#ffffff"; const wt = wet || urb ? 1.2 : 0.7; return { color: stroke, weight: wt, fillColor: col, fillOpacity: 0.62, }; }, onEachFeature: (feat, layer) => { const lc = feat.properties?.lc; const ndvi = feat.properties?.mean ?? null; const col = lc !== undefined && lc !== null ? _dwColor(lc, ndvi) : _ndviGradient(ndvi); const className = lc !== undefined && lc !== null ? DW_NAME[lc] : "Vegetation"; const icon = lc !== undefined && lc !== null ? DW_ICON[lc] : "🌿"; const badge = lc === 4 || lc === undefined ? _ndviBadge(ndvi) : ""; const insight = lc !== undefined ? _dwInsight(lc, ndvi) : ""; const dwCoords = extractCoords(feat); const dwYear = parseInt( document.getElementById("year-sel").value, ); layer.bindPopup( `
${icon} ${className}
${ndvi !== null ? `
NDVI${ndvi.toFixed(3)}
` : ""} ${badge} ${insight ? `
${insight}
` : ""}
Occupation du sol Dynamic World V1 · Sentinel-2 10 m · boutons image pour telecharger les PNG
`, { maxWidth: 260 }, ); layer.bindTooltip( `${icon} ${className}${ndvi !== null ? " · NDVI " + ndvi.toFixed(2) : ""}`, { sticky: true, className: "leaflet-tooltip-dark", }, ); // Hover + right-click for parcel analysis layer.on("mouseover", function () { this.setStyle({ weight: 2.0, fillOpacity: 0.8, }); }); layer.on("mouseout", function () { detectLayer?.resetStyle(this); }); layer.on("contextmenu", (e) => { L.DomEvent.stopPropagation(e); const lbl = `${className} (NDVI ${ndvi !== null ? ndvi.toFixed(2) : "—"})`; _showParcelMenu( feat, lbl, "detected-field", e.latlng, ); }); }, }).addTo(map); setStatus(`✓ ${count} ${t("detectedUnit")}`, "ok"); syncLandCoverToggle(true); updateMapLegend("landcover"); const dc = document.getElementById("detect-count"); if (dc) { dc.querySelector(".mc-val").textContent = count; dc.style.display = ""; } } catch (e) { setStatus(e.message || "Detection failed", "err"); } finally { btn.disabled = false; btn.textContent = t("detectBtn"); } } // ── Load registered farmers for a province ────────────────────────────────── async function loadRegionFarmers(province) { const section = document.getElementById("farmers-section"); const list = document.getElementById("farmers-list"); if (!section || !list || !province) return; list.innerHTML = `
Chargement…
`; try { const r = await fetch( `${API}/officer/farmers?province=${encodeURIComponent(province)}`, ); if (!r.ok) return; const data = await r.json(); const farmers = data.farmers || []; const hdr = section.querySelector("h4"); if (hdr) hdr.textContent = `${t("farmers")} (${farmers.length})`; if (!farmers.length) { list.innerHTML = `
${t("noFarmers")}
`; return; } const flag = { en: "🇬🇧", fa: "🇦🇫", ps: "🇦🇫" }; list.innerHTML = farmers .map( (f) => `
${flag[f.language] || "🌍"}
${f.phone}
${t("joined")}: ${f.joined || "—"}
${f.field_count}
${t("fields2")}
`, ) .join(""); } catch (e) { list.innerHTML = `
Impossible de charger les producteurs
`; } } // ── Coordinate extraction ─────────────────────────────────────────────────── function extractCoords(feat) { // Walk any GeoJSON geometry type and return the largest polygon ring as [lat,lon] pairs. // GAUL/GEE features can return GeometryCollection, LineString, Point etc. — // we need to find the largest Polygon ring within whatever geometry is present. const g = feat.geometry; let bestRing = null; function tryRing(ring) { if ( ring && ring.length > 2 && (!bestRing || ring.length > bestRing.length) ) bestRing = ring; } function walk(geom) { if (!geom) return; switch (geom.type) { case "Polygon": tryRing(geom.coordinates[0]); break; case "MultiPolygon": geom.coordinates.forEach((p) => tryRing(p[0])); break; case "GeometryCollection": (geom.geometries || []).forEach(walk); break; // LineString, Point — cannot form an analysis polygon, skip } } walk(g); if (bestRing) return bestRing.map(([lon, lat]) => [lat, lon]); // Last resort: use the feature's bounding box as a rectangle if (feat.bbox || feat.geometry) { try { const layer = L.geoJSON(feat); const b = layer.getBounds(); if (b.isValid()) { return [ [b.getSouth(), b.getWest()], [b.getNorth(), b.getWest()], [b.getNorth(), b.getEast()], [b.getSouth(), b.getEast()], [b.getSouth(), b.getWest()], ]; } } catch (e) {} } return []; } // ── Main analyse ──────────────────────────────────────────────────────────── async function analyseAdmin() { if (!selFeat) { setStatus(t("needProv"), "err"); return; } const year = document.getElementById("year-sel").value; const btn = document.getElementById("analyse-btn"); btn.disabled = true; btn.innerHTML = `${t("analysing")}`; setStatus(t("analysing")); closeResults(); try { const coords = extractCoords(selFeat); const p = selFeat.properties; const country = cIn.value.trim(); const province = p.NAME_1 || ""; const district = p.NAME_2 || ""; const village = p.NAME_3 || ""; // Step 1: start async task (returns immediately) const resp = await fetch(`${API}/officer/analyse`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ coords, year: parseInt(year), country, province, district, village, lang, geometry: selFeat.geometry, // raw GeoJSON geometry for server-side bbox fallback }), }); if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.error || `HTTP ${resp.status}`); } const start = await resp.json(); if (start.error) throw new Error(start.error); const taskId = start.task_id; // Step 2: poll every 5 s until done (GEE takes 60-180 s) let data = null; for (let i = 0; i < 60 && !data; i++) { await new Promise((res) => setTimeout(res, 5000)); // Cold-start notice for slow local/VPS startup if (i === 4) setStatus( "⏳ Server starting up — first request takes ~30 s after sleep…", ); if (i === 10) setStatus( `${t("analysing")} (satellite data loading…)`, ); const poll = await fetch( `${API}/officer/analyse-result/${taskId}`, ); if (!poll.ok) { const e = await poll.json().catch(() => ({})); throw new Error( e.error || `Poll HTTP ${poll.status}`, ); } const pd = await poll.json(); if (pd.status === "done") data = pd.data; if (pd.status === "error") throw new Error(pd.error); } if (!data) throw new Error( "Analysis timed out after 5 minutes. Try a smaller region or district.", ); renderResults(data, country, province, district, village); setStatus("", "ok"); loadRegionFields(province); loadRegionFarmers(province); } catch (e) { setStatus(e.message || t("noData"), "err"); } finally { btn.disabled = false; btn.textContent = t("btn"); } } // ── Full land cover breakdown (all 9 DW classes) ──────────────────────────── const _LC_DEF = { water: { icon: "💧", col: "#2980b9", key: "waterArea" }, trees: { icon: "🌲", col: "#1a7a40", key: "treeArea" }, grass: { icon: "🌿", col: "#8bc34a", key: "grassArea" }, flooded_veg: { icon: "🌊", col: "#1abc9c", key: "floodedArea" }, crops: { icon: "🌾", col: "#f39c12", key: "cropArea" }, shrub_scrub: { icon: "🌱", col: "#95a5a6", key: "shrubArea" }, built_up: { icon: "🏘️", col: "#e74c3c", key: "builtArea" }, bare_ground: { icon: "🏜️", col: "#a04000", key: "bareArea" }, snow_ice: { icon: "❄️", col: "#dce7f3", key: "snowArea" }, }; function lcBreakdown(classes, area_ha) { if (!classes || !Object.keys(classes).length) return ""; const total_ha = area_ha || 0; const sorted = Object.entries(classes) .filter(([, v]) => v > 0.1) .sort(([, a], [, b]) => b - a); if (!sorted.length) return ""; // Dominant class line const [topName, topPct] = sorted[0]; const topDef = _LC_DEF[topName] || { icon: "•", col: "#888", key: null, }; const topLbl = topDef.key ? t(topDef.key) : topName.replace(/_/g, " "); const domLine = `
${topDef.icon}
${t("lcDominant")}
${topLbl} · ${topPct.toFixed(1)}%
`; const rows = sorted .map(([name, pct]) => { const def = _LC_DEF[name] || { icon: "•", col: "#888", key: null, }; const lbl = def.key ? t(def.key) : name.replace(/_/g, " "); const ha = total_ha ? Math.round( (total_ha * pct) / 100, ).toLocaleString() : null; const km2 = total_ha ? ((total_ha * pct) / 100 / 100).toFixed(0) : null; return `
${def.icon} ${lbl} ${pct.toFixed(1)}%${ha ? " · " + ha + " ha" : ""}
`; }) .join(""); return domLine + rows; } // ── Land cover colour map ──────────────────────────────────────────────────── const LC_COLORS = { water: "#2980b9", trees: "#27ae60", grass: "#8bc34a", flooded_veg: "#1abc9c", crops: "#f39c12", shrub_scrub: "#95a5a6", built_up: "#e74c3c", bare_ground: "#a04000", snow_ice: "#ecf0f1", }; const LC_LABELS = { water: "waterArea", trees: "treeArea", grass: "grassArea", flooded_veg: "floodedArea", crops: "cropArea", shrub_scrub: "grassArea", built_up: "builtArea", bare_ground: "bareArea", snow_ice: "snowArea", }; function lcBar(classes) { if (!classes || !Object.keys(classes).length) return ""; const order = [ "crops", "trees", "grass", "shrub_scrub", "water", "flooded_veg", "built_up", "bare_ground", "snow_ice", ]; const segs = order .filter((k) => classes[k] > 0) .map((k) => { const pct = classes[k] || 0; return `
`; }) .join(""); const legend = order .filter((k) => classes[k] > 0) .map((k) => { const pct = classes[k] || 0; return `
${t(LC_LABELS[k]) || k}: ${pct}%
`; }) .join(""); return `
${segs}
${legend}
`; } function cropCalendar(monthly) { if (!monthly || !Object.keys(monthly).length) return ""; const months = [1, 3, 5, 7, 9, 11]; const vals = months .map((m) => monthly[m]) .filter((v) => v !== null && v !== undefined); if (!vals.length) return ""; const mx = Math.max(...vals) || 0.7, mn = Math.min(...vals) || 0; const cells = months .map((m) => { const v = monthly[m]; if (v === null || v === undefined) return `
${(t("months") || {})[m] || m}
`; const pct = mx > mn ? (v - mn) / (mx - mn) : 0.5; const r = Math.round(231 - pct * 200), g = Math.round(76 + pct * 102), b = Math.round(60 + pct * 0); const bg = `rgba(${r},${g},${b},0.25)`, col = `rgb(${r},${g},${b})`; return `
${(t("months") || {})[m] || m}
${v.toFixed(2)}
`; }) .join(""); return `
${cells}
`; } function inferCrops(d) { const ndvi = d.ndvi ?? 0.3; const rain = d.rain ?? 300; const tmax = d.modis?.summer_day_c ?? null; const frost = d.modis?.frost_risk ?? null; const elev = d.terrain?.elev_mean_m ?? null; const slope = d.terrain?.slope_deg ?? 0; const crop_pct = d.landcover?.crop_pct ?? null; const crop_ha = d.landcover?.crop_ha ?? null; const cs = (v) => `${v}`; // Non-agricultural if (crop_pct !== null && crop_pct < 3 && ndvi < 0.15) { const msg = lang === "fa" ? "زمین غیرزراعتی — منطقه خشک، بایر یا جنگلی. زمین زراعتی < ۳٪." : lang === "ps" ? "غیرزراعتي ځمکه — وچه، بنجره یا ځنګلي سیمه. د کرهڼې ځمکه < ۳٪." : "Zone peu agricole — secteur aride, nu ou boise. Cultures < 3%."; return `
${msg}
`; } const L = (en, fa, ps) => lang === "fa" ? fa : lang === "ps" ? ps : en; const lines = []; // Crop area summary if (crop_ha !== null) { const ha = Math.round(crop_ha).toLocaleString(); lines.push( L( `🌍 Estimated cropland: ${cs(ha + " ha")} (${crop_pct?.toFixed(1)}% of region)`, `🌍 زمین زراعتی: ${cs(ha + " هکتار")} (${crop_pct?.toFixed(1)}٪ منطقه)`, `🌍 د کرهڼې ځمکه: ${cs(ha + " هکتار")} (${crop_pct?.toFixed(1)}٪ سیمه)`, ), ); } // Rain-based crop types if (rain >= 1000 && (tmax === null || tmax >= 26)) { lines.push( L( `🌾 ${cs("Rice (paddy)")} — high rainfall + warm summers support wet-rice cultivation`, `🌾 ${cs("وریجه")} — باران زیاد + تابستان گرم از کشت وریجه حمایت می‌کند`, `🌾 ${cs("وریجه")} — زیات باران + ګرم اوړي ددی ملاتړ کوي`, ), ); lines.push( L( `🌴 ${cs("Tropical crops")} (banana, cassava, sugarcane) likely in lowland areas`, `🌴 ${cs("محصولات گرمسیری")} (موز، نیشکر) در مناطق پست احتمالی`, `🌴 ${cs("استوایي محصولات")} (خرما، نیشکر) د ټیټو سیمو کې احتمالي`, ), ); } else if (rain >= 600) { lines.push( L( `🌽 ${cs("Maize (corn)")} — primary crop in this rainfall regime`, `🌽 ${cs("جواری")} — محصول اصلی در این رژیم باران`, `🌽 ${cs("جواري")} — د دې باران رژیم کې لومړنی محصول`, ), ); lines.push( L( `🫘 ${cs("Soybeans / legumes")} — common rotation with maize`, `🫘 ${cs("سویا / حبوبات")} — تناوب رایج با جواری`, `🫘 ${cs("سویا / لوبیا")} — د جواري سره لوبدل`, ), ); if (tmax && tmax >= 28) lines.push( L( `🍅 ${cs("Vegetables")} (tomato, pepper, eggplant) viable year-round`, `🍅 ${cs("سبزیجات")} (بادنجان رومی، مرچ) سراسر سال امکانپذیر`, `🍅 ${cs("سبزیجات")} (ٹماٹ، مرچ) کلني ممکن`, ), ); } else if (rain >= 280) { lines.push( L( `🌾 ${cs("Wheat & barley")} — dominant staple crops in 280–600 mm rainfall zones`, `🌾 ${cs("گندم و جو")} — محصولات غالب در مناطق با ۲۸۰–۶۰۰ م.م باران`, `🌾 ${cs("غنم او جو")} — د ۲۸۰–۶۰۰ م.م باران سیمو کې غالب محصولات`, ), ); if (!frost) lines.push( L( `🍈 ${cs("Melon / watermelon")} — common in dry summers with irrigation`, `🍈 ${cs("خربوزه / هندوانه")} — با آبیاری در تابستان‌های خشک رایج`, `🍈 ${cs("خربوزه / هندوانه")} — د اوبو لګولو سره د وچو اوړو کې عام`, ), ); if (ndvi >= 0.4) lines.push( L( `🌿 ${cs("Irrigated fields detected")} — cotton, orchards, or vegetables likely`, `🌿 ${cs("مزارع آبیاری‌شده")} — پنبه، باغات یا سبزیجات محتمل`, `🌿 ${cs("د اوبو لګولو ځمکې")} — پنبه، باغونه یا سبزیجات احتمالي`, ), ); } else { lines.push( L( `🌵 ${cs("Dryland farming")} — millet, sorghum, or nomadic pastoralism`, `🌵 ${cs("زراعت دیم")} — ارزن، سورگوم یا چراگاه کوچ‌نشین`, `🌵 ${cs("وچه زراعت")} — ارزن، سورګم یا کوچیانه چرتلاش`, ), ); if (ndvi >= 0.3) lines.push( L( `🌿 ${cs("Irrigation pockets")} visible — oasis or canal-fed orchards`, `🌿 ${cs("جزایر آبیاری")} — باغ‌های کانال‌دار یا واحه`, `🌿 ${cs("د اوبو کولو ټاپوګانه")} — کانالي باغونه یا واحه`, ), ); } // Frost / heat if (frost === true) lines.push( L( `❄️ ${cs("Frost winters")} — focus on cold-hardy varieties (winter wheat, potato)`, `❄️ ${cs("زمستان یخبندان")} — بر ارقام سرماپذیر تمرکز کنید (گندم زمستانه، کچالو)`, `❄️ ${cs("د یخ ژمی")} — د سړو مقاوم ډولونو باندې تمرکز (د ژمي غنم، کچالو)`, ), ); if (tmax && tmax >= 35) lines.push( L( `☀️ Heat stress risk — ${cs("drought-tolerant varieties")} advised`, `☀️ خطر گرما — ${cs("ارقام مقاوم به خشکی")} توصیه می‌شود`, `☀️ د ګرمۍ فشار خطر — ${cs("د خشکسالۍ مقاوم ډولونه")} سپارل کیږي`, ), ); // Elevation if (elev !== null) { if (elev > 3000) lines.push( L( `🏔️ High altitude (${Math.round(elev)}m) — ${cs("potato, buckwheat, herbs")}; short season`, `🏔️ ارتفاع بالا (${Math.round(elev)}م) — ${cs("کچالو، گندم سیاه، گیاهان دارویی")}؛ فصل کوتاه`, `🏔️ لوړ لوړوالی (${Math.round(elev)}م) — ${cs("کچالو، دارویي نباتات")}؛ لنډ موسم`, ), ); else if (elev > 1500) lines.push( L( `⛰️ Mid-altitude (${Math.round(elev)}m) — ${cs("apple, apricot, grape orchards")}`, `⛰️ ارتفاع متوسط (${Math.round(elev)}م) — ${cs("سیب، زردآلو، انگور")}`, `⛰️ منځنی لوړوالی (${Math.round(elev)}م) — ${cs("مڼه، خوبانۍ، انگور")}`, ), ); } // Slope if (slope > 15) lines.push( L( `📐 Steep terrain (${slope.toFixed(1)}°) — ${cs("terraced farming")} or pastoral use`, `📐 زمین پر شیب (${slope.toFixed(1)}°) — ${cs("زراعت پله‌ای")} یا چراگاه`, `📐 تیز ځمکه (${slope.toFixed(1)}°) — ${cs("پله‌ایزه زراعت")} یا چرتلاش`, ), ); return lines .map( (l) => `
${l}
`, ) .join(""); } // ── Render results ────────────────────────────────────────────────────────── function renderResults(d, country, province, district, village) { const body = document.getElementById("res-body"); const adminName = village || district || province || country; const adminLvl = village ? t("village") : district ? t("district") : province ? t("province") : t("country2"); const ndvi = d.ndvi ?? null; const evi = d.evi ?? null; const savi = d.savi ?? null; const mndwi = d.mndwi ?? d.water ?? null; const rain = d.rain ?? null; const pop = d.population ?? null; const lc = d.landcover ?? null; const ter = d.terrain ?? null; const wb = d.water_bodies ?? null; const monthly = d.ndvi_monthly ?? {}; // NDVI bar & badge const ndviPct = ndvi === null ? 0 : Math.max(0, Math.min(100, (ndvi / 0.7) * 100)); const eviPct = evi === null ? 0 : Math.max(0, Math.min(100, (evi / 0.6) * 100)); const saviPct = savi === null ? 0 : Math.max(0, Math.min(100, (savi / 0.7) * 100)); const ndviCol = ndviToColor(ndvi); const ndviBadge = ndvi === null ? "" : ndvi >= 0.4 ? `${t("good")}` : ndvi >= 0.25 ? `${t("mod")}` : `${t("low")}`; // MNDWI colour & label const wColor = mndwi === null ? "#888" : mndwi >= 0.1 ? "#3498db" : mndwi >= -0.1 ? "#f39c12" : "#e74c3c"; const wLabel = mndwi === null ? "—" : mndwi >= 0.1 ? t("water") : mndwi >= -0.1 ? t("mod") : t("drought"); // SAR card let sarHTML = ""; if (d.sar) { sarHTML = `
${t("sar")}
${t("soilMoist")}${d.sar.vv_db ?? "—"} dB
${t("vegDens")}${d.sar.vh_db ?? "—"} dB
Sentinel-1 · cloud-free radar · 10 m
`; } // MODIS card let modisHTML = ""; if (d.modis) { const fr = d.modis.frost_risk; const frT = fr === true ? t("frostY") : fr === false ? t("frostN") : "—"; const frC = fr === true ? "#e74c3c" : "#27ae60"; modisHTML = `
${t("modis")}
${t("sumT")}${d.modis.summer_day_c ?? "—"}°C
${t("winT")}${d.modis.winter_night_c ?? "—"}°C
${t("frost")}${frT}
MODIS MOD11A2 · 1 km LST
`; } const trendData = d.combined_trend || d.trend || {}; const hasTrend = Object.values(trendData).some( (v) => v !== null, ); const farmerRow = d.farmer_count != null ? `
${t("farmerCount")}
${d.farmer_count}
` : ""; body.innerHTML = `
${t("adminLevel")}
${adminLvl}: ${adminName}
${t("area")}
${d.area_km2 ? Math.round(d.area_km2).toLocaleString() : "—"} ${t("km2")}
${t("coords")}
${d.lat ?? "—"}°, ${d.lon ?? "—"}°
${farmerRow}
${t("source")}
${d.source || "—"} · ${d.image_date || ""} · ${d.analysis_scale_m ? d.analysis_scale_m + "m" : "—"}
${t("ndvi")}
${ndvi !== null ? ndvi.toFixed(3) : "—"}
${ndviBadge}
Sentinel-2 B8/B4 · normalized
${t("evi")}
${evi !== null ? evi.toFixed(3) : "—"}
Enhanced · canopy background corrected
${t("savi")}
${savi !== null ? savi.toFixed(3) : "—"}
Soil-adjusted · L=0.5
${t("mndwi")}
${mndwi !== null ? mndwi.toFixed(3) : "—"}
${wLabel}
Green/SWIR · water & moisture
${t("rain")}
${rain !== null ? Math.round(rain) : "—"}
${t("mm")}
CHIRPS daily · annual sum
${t("registeredPlots")}
${t("fieldUnit")}
Click any plot on map for detail
${sarHTML} ${modisHTML} ${ pop ? `
${t("population")}
${pop.total != null ? pop.total.toLocaleString() : "—"}
${pop.per_km2 != null ? pop.per_km2.toLocaleString() + " " + t("popDens") : ""}
${t("popSrc")} · ${pop.year || ""}
` : "" } ${ ter ? `
${t("terrain")}
${ter.elev_mean_m != null ? Math.round(ter.elev_mean_m) + " m" : "—"}
${t("elevRange")}${ter.elev_min_m != null ? Math.round(ter.elev_min_m) : "—"} – ${ter.elev_max_m != null ? Math.round(ter.elev_max_m) : "—"} m
${t("slopeMean")}${ter.slope_deg != null ? ter.slope_deg.toFixed(1) + "°" : "—"}
${ter.slope_deg != null ? (ter.slope_deg < 3 ? t("flatLand") : ter.slope_deg < 12 ? t("gentleSlope") : t("steepSlope")) : "SRTM 30m"}
` : "" } ${ wb ? `
${t("waterBodies")}
${wb.pct != null ? wb.pct.toFixed(1) + "%" : "—"}
${wb.ha != null ? wb.ha.toLocaleString() + " " + t("waterHa") : ""}
JRC GSW · ≥50% occurrence
` : "" } ${ lc ? `

${t("landcover")} ${d.area_km2 ? Math.round(d.area_km2).toLocaleString() + " " + t("km2") : ""} · ${lc.source || "DynamicWorld"}

${lcBar(lc.classes)}
${lcBreakdown(lc.classes, d.area_ha)}
` : "" } ${ Object.keys(monthly).length ? `

${t("cropCal")}

${cropCalendar(monthly)}
` : "" } ${hasTrend ? `

${t("trend")}

` : ""}

${t("cropIntel")}

${inferCrops(d)}

${t("recommend")}

${buildRec(d)}

${t("farmers")}

Chargement…
`; document.getElementById("res-title").textContent = t("resTitle"); document.getElementById("results").classList.add("open"); keepMapVisible(); if (hasTrend) setTimeout(() => drawTrend(trendData), 60); } // ── Recommendations ───────────────────────────────────────────────────────── function buildRec(d) { const ndvi = d.ndvi, rain = d.rain, mndwi = d.mndwi ?? d.water; if (ndvi === null) return t("insuffData"); // Pull land cover percentages from DW classes const cls = d.landcover?.classes || {}; const crop_pct = cls.crops ?? d.landcover?.crop_pct ?? 0; const bare_pct = cls.bare_ground ?? d.landcover?.bare_pct ?? 0; const built_pct = cls.built_up ?? d.landcover?.built_pct ?? 0; const tree_pct = cls.trees ?? d.landcover?.tree_pct ?? 0; const water_pct = cls.water ?? d.landcover?.water_pct ?? 0; const L = (en, fa, ps) => lang === "fa" ? fa : lang === "ps" ? ps : en; const lines = []; // ── Land cover insights (if available) ──────────────────────────────────── if (d.landcover) { if (crop_pct > 40) lines.push( L( `🌾 ${crop_pct.toFixed(0)}% cropland — high-intensity agricultural region.`, `🌾 ${crop_pct.toFixed(0)}٪ زمین زراعتی — منطقه با کشاورزی پرفشار.`, `🌾 ${crop_pct.toFixed(0)}٪ کرهڼیزه ځمکه — د لوړ زراعتي تولید سیمه.`, ), ); if (bare_pct > 35) lines.push( L( `🏜️ ${bare_pct.toFixed(0)}% bare ground — erosion risk; cover crops or reforestation advised.`, `🏜️ ${bare_pct.toFixed(0)}٪ خاک برهنه — خطر فرسایش؛ پوشش گیاهی توصیه می‌شود.`, `🏜️ ${bare_pct.toFixed(0)}٪ لوڅه خاوره — د فرسایش خطر؛ د نباتاتو پوښنه سپارل کیږي.`, ), ); if (built_pct > 20) lines.push( L( `🏘️ ${built_pct.toFixed(0)}% urban/built-up — market access nearby, consider high-value crops.`, `🏘️ ${built_pct.toFixed(0)}٪ مناطق شهری — بازار نزدیک؛ محصولات پرارزش را در نظر بگیرید.`, `🏘️ ${built_pct.toFixed(0)}٪ ابادي — نږدې بازار شتون لري؛ د لوړ ارزښت محصولاتو فکر وکړئ.`, ), ); if (tree_pct > 30) lines.push( L( `🌲 ${tree_pct.toFixed(0)}% forest cover — protect watershed; agroforestry potential.`, `🌲 ${tree_pct.toFixed(0)}٪ پوشش جنگلی — از حوضه آبریز حفاظت کنید؛ جنگل‌زراعی ممکن.`, `🌲 ${tree_pct.toFixed(0)}٪ ځنګل — د اوبو سرچینې ساتنه؛ د ځنګل-زراعت فرصت.`, ), ); if (water_pct > 5) lines.push( L( `💧 ${water_pct.toFixed(0)}% water bodies — irrigation potential high.`, `💧 ${water_pct.toFixed(0)}٪ منابع آبی — پتانسیل آبیاری بالا.`, `💧 ${water_pct.toFixed(0)}٪ اوبه — د آبیاری ظرفیت لوړ دی.`, ), ); } // ── NDVI ────────────────────────────────────────────────────────────────── if (ndvi >= 0.4) lines.push( L( "Bonne couverture vegetale — region suitable for intensive cropping.", "پوشش گیاهی خوب — منطقه برای محصولات متراکم مناسب است.", "ښه د نباتاتو پوښښ — سیمه د متراکمو محصولاتو لپاره مناسبه ده.", ), ); else if (ndvi >= 0.25) lines.push( L( "Moyen vegetation — drought-tolerant crops recommended.", "پوشش گیاهی متوسط — محصولات مقاوم به خشکی پیشنهاد می‌شود.", "منځنی پوښښ — د وچکالۍ مقاوم محصولات سپارل کیږي.", ), ); else lines.push( L( "Couverture vegetale faible — land may be saline, degraded, or fallow.", "پوشش گیاهی ضعیف — زمین ممکن است شور، خشک یا رها شده باشد.", "کمزوری پوښښ — ځمکه ممکن مالګینه، وچه یا پریښودل شوې وي.", ), ); // ── Rain ────────────────────────────────────────────────────────────────── if (rain != null) { if (rain < 200) lines.push( L( `Pluie faible (${Math.round(rain)} mm) — supplemental irrigation essential.`, `باران کم (${Math.round(rain)} mm) — آبیاری تکمیلی ضروری است.`, `لږ باران (${Math.round(rain)} mm) — اضافي اوبه لګول اړین دي.`, ), ); else if (rain > 600) lines.push( L( `High rainfall (${Math.round(rain)} mm) — good drainage important.`, `باران زیاد (${Math.round(rain)} mm) — زهکشی مناسب مهم است.`, `زیات باران (${Math.round(rain)} mm) — ښه ناوه اړینه ده.`, ), ); else lines.push( L( `Moyen rainfall (${Math.round(rain)} mm) — suitable for wheat, barley, and pulses.`, `باران متوسط (${Math.round(rain)} mm) — مناسب برای گندم، جو و حبوبات.`, `منځنی باران (${Math.round(rain)} mm) — د غنمو، جوارو او لوبیا لپاره مناسب.`, ), ); } // ── Water / SAR / Frost ─────────────────────────────────────────────────── if (mndwi != null && mndwi < -0.2) lines.push( L( "Indice eau faible — drought risk. Soil moisture monitoring advised.", "شاخص آب پایین — خطر خشکسالی. نظارت بر رطوبت خاک.", "ټیټ د اوبو شاخص — د وچکالۍ خطر. د خاورې لندوالي نظارت.", ), ); if (d.modis?.frost_risk) lines.push( L( "Frost risk in winter — avoid early planting of cold-sensitive crops.", "خطر یخبندان — از کشت زودهنگام محصولات حساس به سرما پرهیز شود.", "د ژمي یخ خطر — د یخ حساسو محصولاتو ژر کرل مه کوئ.", ), ); if (d.sar?.vv_db != null && d.sar.vv_db < -15) lines.push( L( "SAR radar shows low soil moisture — review irrigation management.", "رادار SAR رطوبت کم خاک — مدیریت آبیاری را مرور کنید.", "SAR رادار د خاورې لږ لندوالی ښيي — د اوبو مدیریت بیاکتنه.", ), ); return lines.map((l) => `• ${l}`).join("
") || "—"; } // ── SVG Trend chart ───────────────────────────────────────────────────────── function drawTrend(trend) { const svg = document.getElementById("trend-svg"); if (!svg) return; const entries = Object.entries(trend) .filter(([, v]) => v !== null) .sort(([a], [b]) => +a - +b); if (entries.length < 2) { svg.innerHTML = `Insufficient trend data`; return; } const W = svg.parentElement.clientWidth - 22 || 400, H = 72; svg.setAttribute("viewBox", `0 0 ${W} ${H}`); const vals = entries.map(([, v]) => v); const minV = Math.min(...vals), maxV = Math.max(...vals); const rng = maxV - minV || 0.01; const px = (i) => (i / (entries.length - 1)) * (W - 24) + 12; const py = (v) => H - 12 - ((v - minV) / rng) * (H - 22); let grid = "", labels = ""; [minV, (minV + maxV) / 2, maxV].forEach((v) => { const y = py(v); grid += ``; grid += `${v.toFixed(2)}`; }); const step = Math.max(1, Math.floor(entries.length / 7)); entries.forEach(([yr], i) => { if (i % step === 0 || i === entries.length - 1) labels += `${yr}`; }); const pts = entries .map( ([, v], i) => `${px(i).toFixed(1)},${py(v).toFixed(1)}`, ) .join(" "); const dots = entries .map( ([, v], i) => ``, ) .join(""); svg.innerHTML = `${grid}${dots}${labels}`; } function closeResults() { document.getElementById("results").classList.remove("open"); keepMapVisible(); } // ── Analyse de parcelle ────────────────────────────────────────────────────────── // Stores the last analysed parcel for downloads let _parcelState = null; // {feature, name, adminLevel, analysis} function setParcelReopenVisible(show) { document .getElementById("parcel-reopen-fab") ?.classList.toggle("show", !!show); } function minimizeParcelPanel() { document .getElementById("parcel-panel") .classList.remove("open"); setParcelReopenVisible(!!_parcelState); keepMapVisible(); } function reopenParcelPanel() { if (!_parcelState) return; document.getElementById("parcel-panel").classList.add("open"); setParcelReopenVisible(false); keepMapVisible(); } function toggleParcelExpanded() { const panel = document.getElementById("parcel-panel"); const expanded = panel.classList.toggle("expanded"); const btn = document.getElementById("pp-expand-btn"); if (btn) btn.textContent = expanded ? "Réduire taille" : "Agrandir"; keepMapVisible(); } function closeParcelPanel() { document .getElementById("parcel-panel") .classList.remove("open", "expanded"); const btn = document.getElementById("pp-expand-btn"); if (btn) btn.textContent = "Agrandir"; _parcelState = null; setParcelReopenVisible(false); keepMapVisible(); } // Entry point: call when any map polygon is right-clicked or has "Analyse" clicked async function analyseParcel(feature, name, adminLevel) { const coords = extractCoords(feature); if (!coords || coords.length < 3) { setStatus( "Impossible d extraire les coordonnees du polygone", "err", ); return; } const year = parseInt( document.getElementById("year-sel").value, ); const panel = document.getElementById("parcel-panel"); const body = document.getElementById("pp-body"); document.getElementById("pp-title").textContent = `📍 ${name}`; document.getElementById("pp-dl-bar").style.display = "none"; document.getElementById("pp-dl-note").style.display = "none"; document.getElementById("pp-crop-bar").style.display = "none"; document.getElementById("pp-timeseries").style.display = "none"; document.getElementById("pp-img-preview").style.display = "none"; if (_villageCropLayer) { map.removeLayer(_villageCropLayer); _villageCropLayer = null; } body.innerHTML = `
🛰️
Analyse de ${name}...
Chargement des donnees satellite
`; panel.classList.add("open"); setParcelReopenVisible(false); keepMapVisible(); // Start async task try { const r = await fetch(`${API}/officer/analyse`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ coords, year, country: cIn.value.trim(), province: feature.properties?.NAME_1 || "", district: feature.properties?.NAME_2 || "", village: feature.properties?.NAME_3 || "", geometry: feature.geometry, }), }); const start = await r.json(); if (start.error) throw new Error(start.error); const taskId = start.task_id; // Poll let data = null; for (let i = 0; i < 60 && !data; i++) { await new Promise((res) => setTimeout(res, 5000)); if (i === 3) body.innerHTML += '
Le serveur local prepare les donnees — environ 30 s au premier lancement
'; const poll = await fetch( `${API}/officer/analyse-result/${taskId}`, ); const pd = await poll.json(); if (pd.status === "done") data = pd.data; if (pd.status === "error") throw new Error(pd.error); } if (!data) throw new Error( "Analyse trop longue. Essayez une parcelle plus petite.", ); _parcelState = { feature, name, adminLevel, analysis: data, coords, year, }; renderParcelResults(data, name); } catch (e) { body.innerHTML = `
Erreur : ${e.message}
`; } } function renderParcelResults(d, name) { const body = document.getElementById("pp-body"); const lc = d.landcover; const ter = d.terrain; const pop = d.population; const ndvi = d.ndvi ?? null; const ndviC = ndviToColor(ndvi); const vegState = parcelVegetationState(d); const areaText = d.area_km2 ? d.area_km2 < 1 ? `${Math.round(d.area_km2 * 100)} ha` : `${Math.round(d.area_km2).toLocaleString()} km²` : ""; const sourceText = { climate_zone_subtropical: "estimation regionale Mali", regional_fallback: "estimation regionale Mali", fallback: "estimation regionale Mali", gee_live: "donnees satellite GEE", gee: "donnees satellite GEE", satellite: "donnees satellite", }[d.source] || d.source || "estimation regionale"; body.innerHTML = `
${areaText} · ${d.year || ""} · ${sourceText}
Etat de la vegetation de ce champ
${vegState.title}
${vegState.icon}
${vegState.text}
Sante NDVI
${ndvi !== null ? ndvi.toFixed(3) : "—"}
${ndvi === null ? "" : ndvi >= 0.45 ? "● Sain" : ndvi >= 0.28 ? "● Moyen" : "● Stresse"}
Pluie annuelle
${d.rain != null ? Math.round(d.rain) : "—"}
mm/an · CHIRPS
${ ter ? `
Altitude
${Math.round(ter.elev_mean_m || 0)}
m · SRTM 30m
Pente moyenne
${(ter.slope_deg || 0).toFixed(1)}°
${(ter.slope_deg || 0) < 3 ? "Plat" : ter.slope_deg < 12 ? "Doux" : "Fort"}
` : "" } ${ pop ? `
Population
${pop.total != null ? pop.total.toLocaleString() : "—"}
${pop.per_km2 != null ? pop.per_km2.toLocaleString() + " /km²" : ""}
` : "" }
${ lc ? `
Occupation du sol · Dynamic World
${lcBreakdown(lc.classes, d.area_ha)}
` : "" } `; document.getElementById("pp-dl-bar").style.display = "flex"; const dlNote = document.getElementById("pp-dl-note"); dlNote.style.display = "block"; document.querySelectorAll(".pp-gee-only").forEach((btn) => { btn.style.display = _agentStatus.gee ? "flex" : "none"; }); document.getElementById("pp-img-preview").style.display = "none"; dlNote.textContent = _agentStatus.gee ? "GeoJSON et KML s ouvrent dans QGIS ou ArcGIS Pro · CSV pour les tableaux · PNG pour les rapports" : "GeoJSON, CSV, KML et capture de carte sont disponibles. Les PNG satellite et NDVI seront actifs apres configuration GEE."; // Show village crop map button for small areas (< 50 km²) const ppCropBar = document.getElementById("pp-crop-bar"); if (d.area_km2 && d.area_km2 < 50) { ppCropBar.style.display = "block"; } else { ppCropBar.style.display = "none"; } // Show time-series explorer from NDVI trend data _tsState.coords = _parcelState.coords; const trend = d.combined_trend || d.ndvi_trend || d.trend || {}; if (Object.keys(trend).length) showTimeSeries(trend); setStatus( `Analyse terminee pour "${name}". Exports disponibles.`, "ok", ); keepMapVisible(); } function parcelVegetationState(d) { const ndvi = d.ndvi ?? null; const water = d.mndwi ?? d.water ?? null; const rain = d.rain ?? null; const source = _agentStatus.gee ? "donnees satellite GEE" : "estimation regionale"; if (ndvi === null) { return { icon: "🟡", color: "#f39c12", title: "Vegetation a verifier", text: `Ineema n a pas encore assez de donnees NDVI pour conclure. Utilisez la carte, les photos terrain et l agent pour confirmer l etat du champ. Source : ${source}.`, }; } if (ndvi >= 0.55) { return { icon: "🟢", color: "#27ae60", title: `Vegetation tres bonne · NDVI ${ndvi.toFixed(3)}`, text: `Le couvert vegetal est dense et actif. Le champ semble en bon etat; continuez le suivi de l humidite et surveillez seulement les zones plus claires sur l image satellite. Source : ${source}.`, }; } if (ndvi >= 0.4) { return { icon: "🟢", color: "#2ecc71", title: `Vegetation bonne · NDVI ${ndvi.toFixed(3)}`, text: `Le champ montre une vegetation active. ${water != null && water < -0.1 ? "L indice eau est limite, donc surveillez l arrosage ou les prochaines pluies." : "Le niveau de vegetation est favorable pour la saison."} ${rain != null ? `Pluie estimee : ${Math.round(rain)} mm/an.` : ""} Source : ${source}.`, }; } if (ndvi >= 0.28) { return { icon: "🟡", color: "#f39c12", title: `Vegetation moyenne · NDVI ${ndvi.toFixed(3)}`, text: `Le champ est productif mais pas au maximum. Verifiez les zones faibles, l humidite du sol, les adventices et la fertilisation avant de conclure. ${rain != null ? `Pluie estimee : ${Math.round(rain)} mm/an.` : ""} Source : ${source}.`, }; } return { icon: "🔴", color: "#e74c3c", title: `Vegetation faible ou stressee · NDVI ${ndvi.toFixed(3)}`, text: `Le champ peut etre en stress hydrique, en jachere, recolte recente ou sol nu. Il faut verifier sur le terrain et comparer avec l image satellite PNG. Source : ${source}.`, }; } function askParcelVegetation() { if (!_parcelState) { setStatus("Lancez d abord l analyse de parcelle.", "err"); return; } const d = _parcelState.analysis || {}; const coords = (_parcelState.coords || []) .slice(0, 4) .map( (c) => `${c[1]?.toFixed?.(5) || c[1]},${c[0]?.toFixed?.(5) || c[0]}`, ) .join(" | "); const source = d.source === "gee_live" || d.source === "gee" ? "donnees satellite GEE" : d.source || "estimation regionale"; document.getElementById("agent-panel")?.classList.add("open"); apSend( `Quel est l'etat de la vegetation de ce champ "${_parcelState.name}" ? NDVI ${d.ndvi ?? "inconnu"}, pluie ${d.rain ?? "inconnue"} mm/an, source ${source}, coordonnees ${coords}. Donne une reponse courte avec actions terrain au Mali.`, ); } // ── GIS Download functions (client-side, no backend needed) ────────────────── function _parcelProps() { if (!_parcelState) return null; const d = _parcelState.analysis; const lc = d.landcover?.classes || {}; return { name: _parcelState.name, admin_level: _parcelState.adminLevel || "parcel", country: d.country || "", province: d.province || "", district: d.district || "", area_ha: d.area_ha, area_km2: d.area_km2, analysis_year: d.year, analysis_date: new Date().toISOString().slice(0, 10), source: d.source || "Sentinel-2 + Landsat + SRTM", // Satellite indices ndvi: d.ndvi, evi: d.evi, savi: d.savi, mndwi: d.mndwi ?? d.water, rain_mm: d.rain, // Land cover (%) lc_crops_pct: lc.crops ?? d.landcover?.crop_pct, lc_trees_pct: lc.trees ?? d.landcover?.tree_pct, lc_grass_pct: lc.grass ?? d.landcover?.grass_pct, lc_bare_pct: lc.bare_ground ?? d.landcover?.bare_pct, lc_built_pct: lc.built_up ?? d.landcover?.built_pct, lc_water_pct: lc.water ?? d.landcover?.water_pct, // Terrain elev_mean_m: d.terrain?.elev_mean_m, elev_min_m: d.terrain?.elev_min_m, elev_max_m: d.terrain?.elev_max_m, slope_deg: d.terrain?.slope_deg, // Population pop_total: d.population?.total, pop_per_km2: d.population?.per_km2, // Centroid centroid_lat: d.lat, centroid_lon: d.lon, }; } function _downloadBlob(content, filename, mime) { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); } function downloadParcel(format) { if (!_parcelState) { alert("No parcel analysis loaded."); return; } const props = _parcelProps(); const geom = _parcelState.feature.geometry; const safeName = (_parcelState.name || "parcel").replace( /[^a-zA-Z0-9_]/g, "_", ); if (format === "geojson") { const fc = { type: "FeatureCollection", name: "Ineema_Parcel_Analysis", crs: { type: "name", properties: { name: "urn:ogc:def:crs:OGC:1.3:CRS84", }, }, features: [ { type: "Feature", geometry: geom, properties: props, }, ], }; _downloadBlob( JSON.stringify(fc, null, 2), `Ineema_${safeName}_${props.analysis_year}.geojson`, "application/geo+json", ); } else if (format === "csv") { const keys = Object.keys(props); const vals = keys.map((k) => { const v = props[k]; if (v === null || v === undefined) return ""; return typeof v === "string" && v.includes(",") ? `"${v}"` : v; }); const csv = keys.join(",") + "\n" + vals.join(","); _downloadBlob( csv, `Ineema_${safeName}_${props.analysis_year}.csv`, "text/csv", ); } else if (format === "kml") { // Build KML polygon let coords_kml = ""; const toKmlCoords = (ring) => ring.map(([lon, lat]) => `${lon},${lat},0`).join(" "); if (geom.type === "Polygon") { coords_kml = `${toKmlCoords(geom.coordinates[0])}`; } else if (geom.type === "MultiPolygon") { const polys = geom.coordinates .map( (poly) => `${toKmlCoords(poly[0])}`, ) .join(""); coords_kml = `${polys}`; } const descRows = Object.entries(props) .filter(([, v]) => v != null) .map( ([k, v]) => `${k}${v}`, ) .join(""); const kml = ` Ineema Analyse de parcelle — ${props.name} ${props.name} #parcelStyle ${descRows}
Ineema Analyse satellite · ${props.analysis_date} · ineema.africa ]]>
${coords_kml}
`; _downloadBlob( kml, `Ineema_${safeName}_${props.analysis_year}.kml`, "application/vnd.google-earth.kml+xml", ); } } // ── Layer toggle panel ─────────────────────────────────────────────────────── function showLayerTab(name, btn) { document .querySelectorAll(".lp-pane") .forEach((pane) => pane.classList.remove("active")); document .querySelectorAll(".lp-tab") .forEach((tab) => tab.classList.remove("active")); document .getElementById(`lp-pane-${name}`) ?.classList.add("active"); btn?.classList.add("active"); } function toggleLayerPanel() { const body = document.getElementById("layer-panel-body"); const chev = document.getElementById("lp-chevron"); const open = body.style.display !== "none"; body.style.display = open ? "none" : "block"; chev.classList.toggle("open", !open); chev.textContent = open ? "▼" : "▲"; } // Land Cover toggle — shows/hides the detectLayer (Dynamic World result) document.addEventListener("DOMContentLoaded", () => { document .getElementById("lp-landcover") .addEventListener("change", function () { if (!detectLayer) return; this.checked ? detectLayer.addTo(map) : map.removeLayer(detectLayer); }); }); // Called by detectFields() after a layer is created so the panel stays in sync function syncLandCoverToggle(added) { const cb = document.getElementById("lp-landcover"); if (cb) cb.checked = added; }