Ineema
Centre d'intelligence agricole · Satellite, terrain et
IA
🛰️ Satellite live
🌧️ Pluie saison
📡 Images terrain
🤖 Conseil terrain
EN
FR
BM
Portail producteur
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.
EN
FR
BM
↑
← Carte 2D
⛰️ Terrain actif
🌍 Occupation du sol
🛰️ Satellite
📐 Inclinaison
🏔️ Relief agricole 3D
Glisser pour tourner · molette pour zoomer · clic droit
pour incliner
📊 Rouvrir diagnostic
+
−
⌖
🗂️ Carte satellite & repères
▼
Diagnostic regional Ineema
✕
📍 Analyse de parcelle
Agrandir
Réduire
✕
🌾 Charger la carte des cultures 10 m
(detail village)
Types de culture
Ble
Legumes
Vergers / arbres
Nu / jachere
10 m · Sentinel-2 · estimation automatique · a verifier
sur le terrain
📅 Historique de santé du champ
2013 (Landsat) 2019 (Sentinel-2) Aujourd hui
⬇️ Telecharger PNG
▶ Lire le timelapse
Apercu image satellite
GeoJSON et KML s ouvrent dans QGIS ou ArcGIS Pro · CSV pour les
tableaux · PNG apres activation GEE
="account-modal-title" id="account-modal-title">
🔐 Compte terrain sécurisé
Entrez votre numéro WhatsApp. Ineema ouvre ou crée
votre compte sans mot de passe, puis retrouve vos
champs sauvegardés.
✕
🔒 >
Votre numéro sert uniquement à retrouver vos champs.
Les coordonnées enregistrées restent liées à ce compte terrain.
Annuler
Ouvrir mon compte
// ── 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 = "Chargement… ";
ds.disabled = true;
ds.innerHTML = `— ${t("prov")} first — `;
vs.disabled = true;
vs.innerHTML = `— selectionnez un district — `;
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 = `— select — `;
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 = `— error — `;
}
}
// ── 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 = `Chargement des districts… `;
const vs = document.getElementById("vil-sel");
vs.disabled = true;
vs.innerHTML = `— selectionnez un district — `;
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 =
'No district data ';
return;
}
const names = [
...new Set(
feats
.map((f) => f.properties.NAME_2)
.filter(Boolean),
),
].sort();
ds.innerHTML = `— whole province — `;
names.forEach((n) => {
const o = document.createElement("option");
o.value = n;
o.textContent = n;
ds.appendChild(o);
});
ds.disabled = false;
} catch (e) {
ds.innerHTML =
'Donnees district indisponibles ';
}
}
document
.getElementById("dist-sel")
.addEventListener("change", function () {
const vs = document.getElementById("vil-sel");
vs.disabled = true;
vs.innerHTML = `Chargement des villages… `;
if (vilBndLayer) {
map.removeLayer(vilBndLayer);
vilBndLayer = null;
}
l3Data = null;
if (!this.value || !l2Data) {
vs.innerHTML = `— selectionnez un district — `;
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 =
'No village data ';
return;
}
drawVillageBoundaries(feats);
const names = feats.map((f) => f.properties.NAME_3).sort();
vs.innerHTML = `— whole district — `;
names.forEach((n) => {
const o = document.createElement("option");
o.value = n;
o.textContent = n;
vs.appendChild(o);
});
vs.disabled = false;
} catch (e) {
vs.innerHTML =
'Donnees village indisponibles ';
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}
🛰️ Analyser cette parcelle
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"}
Ouvrir
Supprimer
`;
})
.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 += ``;
});
} 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 += ``;
});
} 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 += ``;
});
} 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 += ``;
});
} else if (activeLayer === "croptype") {
html = `Type de culture
`;
[
["#f39c12", "Ble"],
["#27ae60", "Legumes"],
["#1a7a40", "Verger / arbres"],
["#a04000", "Nu / jachere"],
].forEach(([col, lbl]) => {
html += ``;
});
} 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 += ``;
});
}
if (html)
leg.innerHTML =
html +
`
`;
}
// ── 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}
` : ""}
🛰️ Satellite
🌍 Land Cover
🟢 NDVI
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
${t("detectedFields")}
—
${t("detectedUnit")}
🛰️ NDVI · Sentinel-2 · auto-mapped
${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)}
`;
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}
Demander a l'agent : quel est l'etat de ce champ ?
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;
}