Spaces:
Running
Running
Download frontend/src/scripts/app.js from alexp97/agrovision: direct link, hf CLI and curl.
- Browser
- Download file 110 kB
-
https://huggingface.co/spaces/alexp97/agrovision/resolve/main/frontend/src/scripts/app.js
- Command line
-
hf download hf://spaces/alexp97/agrovision/frontend/src/scripts/app.js
-
curl -L -o app.js https://huggingface.co/spaces/alexp97/agrovision/resolve/main/frontend/src/scripts/app.js
110 kB
| /** | |
| * Script cliente de AgroVisión: navegación por pestañas, estado efímero, | |
| * mapa Leaflet, gráficos Chart.js, modales y llamadas al gateway. | |
| */ | |
| // ===== Estado EFÍMERO (solo memoria; se borra al refrescar) ===== | |
| let creds = {}; | |
| let parcels = []; | |
| let drawnGeojson = null; | |
| let map = null, drawnItems = null, parcelsLayer = null; | |
| let chartResumen = null, chartSatelite = null, chartWeather = null, chartAnalysis = null; | |
| const sessionId = "sess-" + Math.random().toString(36).slice(2); | |
| let backendOk = false; | |
| let appEnv = "development"; | |
| const VALID_INDICES = ["ndvi", "evi", "savi", "ndwi", "ndre"]; | |
| const INDEX_LABELS = { ndvi: "NDVI", evi: "EVI", savi: "SAVI", ndwi: "NDWI", ndre: "NDRE" }; | |
| const RECOMMENDED_INDICES_BY_CROP = { | |
| "Arándanos": ["ndvi", "ndwi", "ndre"], | |
| "Uva": ["ndvi", "savi", "ndwi"], | |
| "Uvas": ["ndvi", "savi", "ndwi"], | |
| "Paltos": ["ndvi", "evi", "ndre"], | |
| "Cítricos": ["ndvi", "evi", "ndre"], | |
| "Espárragos": ["ndvi", "savi"], | |
| "Otros": ["ndvi"], | |
| }; | |
| let activeParcelIndices = []; | |
| // Perfiles y Modos de Sesión (Fase 15) | |
| let profileId = null; | |
| let currentProfile = null; | |
| let fingerprint = null; | |
| let isSharedMode = false; | |
| async function sha256(message) { | |
| const msgBuffer = new TextEncoder().encode(message); | |
| const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer); | |
| const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); | |
| } | |
| const $ = (id) => document.getElementById(id); | |
| function authHeaders() { | |
| const h = {}; | |
| if (creds.groq) h["X-User-Groq-Key"] = creds.groq; | |
| if (creds.copId) h["X-User-Copernicus-Id"] = creds.copId; | |
| if (creds.copSecret) h["X-User-Copernicus-Secret"] = creds.copSecret; | |
| if (creds.sbUrl) h["X-User-Supabase-Url"] = creds.sbUrl; | |
| if (creds.sbKey) h["X-User-Supabase-Key"] = creds.sbKey; | |
| if (creds.dbUrl) h["X-User-Database-Url"] = creds.dbUrl; | |
| return h; | |
| } | |
| function notify(title, body) { | |
| $("notif-title").innerText = title; $("notif-body").innerText = body || ""; | |
| const n = $("notif"); n.classList.remove("translate-x-[150%]"); | |
| setTimeout(() => n.classList.add("translate-x-[150%]"), 3500); | |
| } | |
| function updateStatus() { | |
| const el = $("db-status-text"); | |
| const dot = $("db-status").querySelector("span.relative"); | |
| if (!backendOk) { | |
| el.innerText = "Backend no disponible"; | |
| el.className = "text-red-500"; | |
| dot.className = "relative inline-flex rounded-full h-2 w-2 bg-red-500"; | |
| return; | |
| } | |
| fetch("./api/credentials/status", { headers: authHeaders() }) | |
| .then((r) => r.ok ? r.json() : null) | |
| .then((s) => { | |
| if (!s) { | |
| el.innerText = "Backend conectado · Status desconocido"; | |
| el.className = "text-amber-400"; | |
| dot.className = "relative inline-flex rounded-full h-2 w-2 bg-amber-500"; | |
| return; | |
| } | |
| const missing = []; | |
| if (!s.groq) missing.push("Groq"); | |
| if (!s.copernicus) missing.push("Copernicus"); | |
| if (!s.supabase) missing.push("Supabase"); | |
| if (missing.length === 0) { | |
| el.innerText = "Backend conectado · Todo OK"; | |
| el.className = "text-emerald-400"; | |
| dot.className = "relative inline-flex rounded-full h-2 w-2 bg-emerald-500"; | |
| } else { | |
| el.innerText = `Backend conectado · Faltan: ${missing.join(", ")}`; | |
| el.className = "text-amber-400"; | |
| dot.className = "relative inline-flex rounded-full h-2 w-2 bg-amber-500"; | |
| } | |
| }) | |
| .catch(() => { | |
| el.innerText = "Backend conectado · Error al verificar credenciales"; | |
| el.className = "text-amber-400"; | |
| dot.className = "relative inline-flex rounded-full h-2 w-2 bg-amber-500"; | |
| }); | |
| } | |
| // ===== Telemetría (Fase 9) ===== | |
| function track(action, meta = {}) { | |
| try { | |
| fetch("./api/events", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ action, meta, session_id: sessionId, ts: new Date().toISOString() }), | |
| keepalive: true, | |
| }).catch(() => {}); | |
| } catch { /* la telemetría nunca debe propagar errores */ } | |
| } | |
| window.addEventListener("error", (e) => track("js_error", { message: String(e.message || e), where: "window" })); | |
| window.addEventListener("unhandledrejection", (e) => track("js_error", { message: String(e.reason || ""), where: "promise" })); | |
| async function api(path, opts = {}) { | |
| try { | |
| const res = await fetch("./api" + path, { | |
| ...opts, | |
| headers: { ...(opts.body ? { "Content-Type": "application/json" } : {}), ...authHeaders(), ...(opts.headers || {}) }, | |
| }); | |
| if (!res.ok) track("api_error", { where: path, status: res.status, method: opts.method || "GET" }); | |
| return res; | |
| } catch (e) { | |
| track("api_error", { where: path, status: "network", method: opts.method || "GET" }); | |
| throw e; | |
| } | |
| } | |
| function getCheckedIndices(selector) { | |
| return Array.from(document.querySelectorAll(`${selector}:checked`)).map((checkbox) => checkbox.value); | |
| } | |
| function setCheckedIndices(selector, indices) { | |
| const selected = new Set(indices || []); | |
| document.querySelectorAll(selector).forEach((checkbox) => { | |
| checkbox.checked = selected.has(checkbox.value); | |
| }); | |
| } | |
| function normalizeIndices(indices) { | |
| const valid = Array.isArray(indices) ? indices.filter((index) => VALID_INDICES.includes(index)) : []; | |
| return valid.length ? [...new Set(valid)] : ["ndvi"]; | |
| } | |
| function formatIndices(indices) { | |
| return (indices || []).map((index) => INDEX_LABELS[index] || index.toUpperCase()).join(", "); | |
| } | |
| function getParcelIndices(fieldId) { | |
| const parcel = parcels.find((item) => item.id === fieldId); | |
| return normalizeIndices(parcel?.selected_indices); | |
| } | |
| function setParcelIndices(fieldId, indices) { | |
| const normalized = normalizeIndices(indices); | |
| const parcel = parcels.find((item) => item.id === fieldId); | |
| if (parcel) parcel.selected_indices = normalized; | |
| activeParcelIndices = normalized; | |
| return normalized; | |
| } | |
| function renderActiveIndexBadges(indices) { | |
| const container = $("parcel-active-indices"); | |
| if (!container) return; | |
| const selected = new Set(indices || []); | |
| container.innerHTML = VALID_INDICES.map((index) => { | |
| const isActive = selected.has(index); | |
| const badgeClass = isActive | |
| ? "bg-emerald-100 text-emerald-800 border-emerald-200" | |
| : "bg-stone-100 text-stone-400 border-stone-200"; | |
| const icon = isActive ? "fa-circle-check" : "fa-circle"; | |
| return `<span class="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full border ${badgeClass}" title="${isActive ? "Variable activa" : "Variable no activa"}"><i class="fa-solid ${icon} text-[9px]"></i>${INDEX_LABELS[index]}</span>`; | |
| }).join(""); | |
| } | |
| function updateIndexSelectOptions(indices) { | |
| const selected = new Set(indices || []); | |
| document.querySelectorAll("#select-index option").forEach((option) => { | |
| const baseLabel = option.dataset.indexLabel || option.textContent.replace(/ · (activa|agregar)$/, ""); | |
| option.textContent = `${baseLabel} · ${selected.has(option.value) ? "activa" : "agregar"}`; | |
| }); | |
| } | |
| function syncSatelliteIndexControls(fieldId, field = null) { | |
| const indices = fieldId ? normalizeIndices(field?.selected_indices ?? getParcelIndices(fieldId)) : []; | |
| activeParcelIndices = indices; | |
| setCheckedIndices(".attr-index-checkbox", indices); | |
| setCheckedIndices(".reprocess-index-checkbox", indices); | |
| renderActiveIndexBadges(indices); | |
| updateIndexSelectOptions(indices); | |
| } | |
| function applyCreatorIndices(indices) { | |
| setCheckedIndices(".creator-index-checkbox", normalizeIndices(indices)); | |
| $("creator-indices-error")?.classList.add("hidden"); | |
| } | |
| // ===== Navegación ===== | |
| const TITLES = { | |
| "tab-resumen": ["Resumen de Campo", "Visión analítica unificada de parcelas."], | |
| "tab-satelite": ["Teledetección (GIS)", "NDVI 5 años + clima + heatmap (solo lectura)."], | |
| "tab-weather": ["Meteorología y Clima", "Historial agroclimático mensual de Open-Meteo."], | |
| "tab-analysis": ["Análisis Cruzado", "Correlación temporal entre índices y variables climáticas."], | |
| "tab-vision": ["Detección por Dron", "Conteo de plantas — EN DESARROLLO."], | |
| "tab-data": ["Explorador de Datos", "Consulta tus datos directamente (Fase 11)."], | |
| "tab-config": ["Credenciales & APIs", "Llaves efímeras (solo en esta sesión)."], | |
| "tab-settings": ["Ajustes de Perfil", "Gestión del modo de sesión y preferencias de usuario."], | |
| }; | |
| function showSection2Chat() { | |
| const form = $("section2-form"); | |
| const chat = $("section2-chat"); | |
| const icon = $("branding-icon"); | |
| const btn = $("btn-branding-toggle"); | |
| if (form && chat) { | |
| form.classList.add("hidden"); | |
| chat.classList.remove("hidden"); | |
| } | |
| if (icon) icon.className = "fa-solid fa-sliders text-base"; | |
| if (btn) btn.title = "Mostrar Ajustes de Parcela"; | |
| loadChatHistory(); | |
| } | |
| function switchTab(id) { | |
| document.querySelectorAll(".tab-content").forEach((s) => s.classList.remove("active")); | |
| document.querySelectorAll(".nav-btn").forEach((b) => b.classList.remove("nav-btn-active")); | |
| const sec = $(id); if (!sec) return; | |
| sec.classList.add("active"); | |
| // Mantener seleccionada la opción correcta del menú lateral | |
| document.querySelector(`.nav-btn[data-tab="${id}"]`)?.classList.add("nav-btn-active"); | |
| if (location.hash !== "#" + id) history.replaceState(null, "", "#" + id); | |
| track("nav", { tab: id }); | |
| if (id === "tab-resumen") { | |
| setTimeout(initMap, 50); | |
| loadResumen(); | |
| showSection2Chat(); | |
| } | |
| if (id === "tab-satelite") { | |
| loadTeledeteccion(); | |
| } | |
| if (id === "tab-weather") { | |
| loadWeather(); | |
| } | |
| if (id === "tab-analysis") { | |
| loadAnalysis(); | |
| } | |
| if (id === "tab-data") loadDataExplorer(); | |
| if (id === "tab-chat") loadChatHistory(); | |
| } | |
| document.querySelectorAll(".nav-btn").forEach((b) => | |
| b.addEventListener("click", () => switchTab(b.dataset.tab)) | |
| ); | |
| // Botón "Volver al Mapa" para regresar al visor principal del mapa | |
| document.querySelectorAll(".btn-volver-mapa").forEach((btn) => { | |
| btn.addEventListener("click", () => switchTab("tab-resumen")); | |
| }); | |
| // ===== Sidebar colapsable manual (Sección 1) ===== | |
| $("btn-sidebar-toggle").addEventListener("click", () => { | |
| const isExpanded = $("sidebar").classList.toggle("expanded"); | |
| $("toggle-icon").className = isExpanded ? "fa-solid fa-angles-left" : "fa-solid fa-bars"; | |
| track("sidebar_toggle", { expanded: isExpanded }); | |
| if (map) setTimeout(() => map.invalidateSize(), 300); | |
| }); | |
| $("btn-footer").addEventListener("click", () => { | |
| $("footer-body").classList.toggle("hidden"); | |
| $("footer-chevron").classList.toggle("fa-chevron-up"); | |
| $("footer-chevron").classList.toggle("fa-chevron-down"); | |
| }); | |
| // ===== Alternador de Chat y Formulario en Sección 2 ===== | |
| $("btn-branding-toggle").addEventListener("click", () => { | |
| const isFormVisible = !$("section2-form").classList.contains("hidden"); | |
| if (isFormVisible) { | |
| $("section2-form").classList.add("hidden"); | |
| $("section2-chat").classList.remove("hidden"); | |
| $("branding-icon").className = "fa-solid fa-sliders text-base"; | |
| $("btn-branding-toggle").title = "Mostrar Ajustes de Parcela"; | |
| loadChatHistory(); | |
| track("chat_toggle", { state: "open" }); | |
| } else { | |
| $("section2-form").classList.remove("hidden"); | |
| $("section2-chat").classList.add("hidden"); | |
| $("branding-icon").className = "fa-solid fa-robot text-base"; | |
| $("btn-branding-toggle").title = "Mostrar Chat con Asistente"; | |
| track("chat_toggle", { state: "closed" }); | |
| } | |
| }); | |
| // ===== Gestión de Atributos de Ajustes de Parcela (Sección 2) ===== | |
| async function loadFieldAttributes(fid) { | |
| if (!fid) { | |
| clearFieldAttributes(); | |
| return; | |
| } | |
| try { | |
| const res = await api(`/fields/${fid}`); | |
| if (res.ok) { | |
| const field = await res.json(); | |
| $("attr-crop-variety").value = field.crop_variety || ""; | |
| $("attr-field-type").value = field.field_type || ""; | |
| $("attr-soil-type").value = field.soil_type || ""; | |
| $("attr-irrigation-system").value = field.irrigation_system || ""; | |
| $("attr-pests-diseases").value = field.pests_diseases || ""; | |
| $("attr-plantation-date").value = field.plantation_date || ""; | |
| $("attr-num-plants").value = field.num_plants || ""; | |
| $("attr-historical-yield").value = field.historical_yield || ""; | |
| $("attr-target-market").value = field.target_market || ""; | |
| $("attr-document-metadata").value = field.document_metadata || ""; | |
| const fieldIndices = Array.isArray(field.selected_indices) ? field.selected_indices : getParcelIndices(fid); | |
| setParcelIndices(fid, fieldIndices); | |
| syncSatelliteIndexControls(fid); | |
| $("active-parcel-name-display").innerText = field.name; | |
| } | |
| } catch (e) { | |
| console.error("Error al cargar atributos de la parcela", e); | |
| } | |
| } | |
| function clearFieldAttributes() { | |
| $("attr-crop-variety").value = ""; | |
| $("attr-field-type").value = ""; | |
| $("attr-soil-type").value = ""; | |
| $("attr-irrigation-system").value = ""; | |
| $("attr-pests-diseases").value = ""; | |
| $("attr-plantation-date").value = ""; | |
| $("attr-num-plants").value = ""; | |
| $("attr-historical-yield").value = ""; | |
| $("attr-target-market").value = ""; | |
| $("attr-document-metadata").value = ""; | |
| syncSatelliteIndexControls(null); | |
| $("active-parcel-name-display").innerText = "—"; | |
| } | |
| async function saveFieldAttributes(fid, isAutosave) { | |
| if (!fid) return; | |
| const previousIndices = getParcelIndices(fid); | |
| const selectedIndices = getCheckedIndices(".attr-index-checkbox"); | |
| const nextIndices = selectedIndices.length ? normalizeIndices(selectedIndices) : ["ndvi"]; | |
| const body = { | |
| crop_variety: $("attr-crop-variety").value.trim() || null, | |
| field_type: $("attr-field-type").value.trim() || null, | |
| soil_type: $("attr-soil-type").value.trim() || null, | |
| irrigation_system: $("attr-irrigation-system").value.trim() || null, | |
| pests_diseases: $("attr-pests-diseases").value.trim() || null, | |
| plantation_date: $("attr-plantation-date").value || null, | |
| num_plants: $("attr-num-plants").value ? parseInt($("attr-num-plants").value, 10) : null, | |
| historical_yield: $("attr-historical-yield").value.trim() || null, | |
| target_market: $("attr-target-market").value.trim() || null, | |
| document_metadata: $("attr-document-metadata").value.trim() || null, | |
| selected_indices: nextIndices | |
| }; | |
| try { | |
| const res = await api(`/fields/${fid}`, { | |
| method: "PATCH", | |
| body: JSON.stringify(body) | |
| }); | |
| if (res.ok) { | |
| const addedIndices = nextIndices.filter((index) => !previousIndices.includes(index)); | |
| setParcelIndices(fid, nextIndices); | |
| syncSatelliteIndexControls(fid); | |
| if (!isAutosave) { | |
| if (addedIndices.length) { | |
| notify("Ajustes guardados", `Se agregaron ${formatIndices(addedIndices)}. El cálculo satelital continúa en segundo plano.`); | |
| } else { | |
| notify("Ajustes guardados", "Los atributos de la parcela se han guardado."); | |
| } | |
| } else if (addedIndices.length) { | |
| notify("Nuevas variables satelitales", `Calculando ${formatIndices(addedIndices)} en segundo plano.`); | |
| } | |
| track("field_attributes_saved", { | |
| field_id: fid, | |
| autosave: isAutosave, | |
| selected_indices: nextIndices, | |
| added_indices: addedIndices | |
| }); | |
| } else { | |
| const e = await res.json().catch(() => ({})); | |
| if (!isAutosave) { | |
| notify("Error al guardar", e.detail || "Error desconocido"); | |
| } | |
| } | |
| } catch (e) { | |
| if (!isAutosave) { | |
| notify("Error de red", String(e)); | |
| } | |
| } | |
| } | |
| let autosaveTimeout = null; | |
| function triggerAutosave() { | |
| const fid = $("select-parcela").value; | |
| if (!fid) return; | |
| if (autosaveTimeout) clearTimeout(autosaveTimeout); | |
| autosaveTimeout = setTimeout(() => { | |
| saveFieldAttributes(fid, true); | |
| }, 1200); // 1.2s debounced auto-save | |
| } | |
| // Conectar eventos de autosave (input change o blur) | |
| [ | |
| "attr-crop-variety", "attr-field-type", "attr-soil-type", "attr-irrigation-system", | |
| "attr-pests-diseases", "attr-plantation-date", "attr-num-plants", "attr-historical-yield", | |
| "attr-target-market", "attr-document-metadata" | |
| ].forEach(id => { | |
| $(id).addEventListener("input", triggerAutosave); | |
| $(id).addEventListener("blur", () => { | |
| const fid = $("select-parcela").value; | |
| if (fid) saveFieldAttributes(fid, false); // guardar inmediatamente al perder el foco | |
| }); | |
| }); | |
| document.querySelectorAll(".attr-index-checkbox").forEach((checkbox) => { | |
| checkbox.addEventListener("change", triggerAutosave); | |
| }); | |
| $("btn-save-attributes").addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| const fid = $("select-parcela").value; | |
| if (!fid) return notify("Error", "Selecciona una parcela primero."); | |
| saveFieldAttributes(fid, false); | |
| }); | |
| // ===== Gestión de Parcelas y Mapa (Sección 3) ===== | |
| function renderFloatingParcels() { | |
| const list = $("floating-parcelas-list"); | |
| if (!list) return; | |
| const activeId = $("select-parcela").value; | |
| list.innerHTML = parcels.length | |
| ? parcels.map((p) => { | |
| const isActive = p.id === activeId; | |
| const circleIcon = isActive | |
| ? '<i class="fa-solid fa-circle-dot text-emerald-500"></i>' | |
| : '<i class="fa-regular fa-circle text-stone-500"></i>'; | |
| const bgClass = isActive | |
| ? 'bg-stone-900 border-emerald-800/40' | |
| : 'hover:bg-stone-900/50 border-transparent'; | |
| return ` | |
| <div data-pid="${p.id}" class="floating-parcel-item flex items-center justify-between p-2 rounded-lg border ${bgClass} cursor-pointer transition-all"> | |
| <div class="flex items-center gap-2 select-item-click"> | |
| ${circleIcon} | |
| <span class="text-xs font-semibold text-stone-200">${p.name}</span> | |
| </div> | |
| <i class="fa-solid fa-gear text-stone-500 hover:text-stone-300 cursor-pointer btn-gear-click" title="Ajustes de Parcela"></i> | |
| </div> | |
| `; | |
| }).join("") | |
| : '<div class="text-xs text-stone-500 py-2">Sin parcelas aún.</div>'; | |
| // Actualizar el botón principal y el estado en el widget | |
| const activeParcel = parcels.find(p => p.id === activeId); | |
| if (activeParcel) { | |
| $("current-farm-name").innerText = activeParcel.name; | |
| if ($("floating-parcel-name")) $("floating-parcel-name").innerText = activeParcel.name; | |
| if ($("floating-parcel-area")) $("floating-parcel-area").innerText = `${activeParcel.area_ha != null ? activeParcel.area_ha.toFixed(1) : "—"} ha`; | |
| } else { | |
| $("current-farm-name").innerText = "Seleccionar Parcela"; | |
| if ($("floating-parcel-name")) $("floating-parcel-name").innerText = "Ninguna seleccionada"; | |
| if ($("floating-parcel-area")) $("floating-parcel-area").innerText = "— ha"; | |
| } | |
| } | |
| function selectParcel(id) { | |
| const sel = $("select-parcela"); | |
| if (sel) { | |
| sel.value = id; | |
| sel.dispatchEvent(new Event("change")); | |
| } | |
| } | |
| async function refreshParcels() { | |
| const previousIndices = new Map(parcels.map((parcel) => [parcel.id, parcel.selected_indices])); | |
| try { | |
| const r = await api("/fields"); | |
| parcels = r.ok ? await r.json() : []; | |
| } catch { parcels = []; } | |
| parcels = parcels.map((parcel) => { | |
| const backendIndices = Array.isArray(parcel.selected_indices) ? parcel.selected_indices : null; | |
| const fallbackIndices = previousIndices.get(parcel.id); | |
| const indices = backendIndices?.length ? backendIndices : fallbackIndices; | |
| return { ...parcel, selected_indices: normalizeIndices(indices) }; | |
| }); | |
| const sel = $("select-parcela"); | |
| const previousActiveId = sel.value; | |
| sel.innerHTML = parcels.length | |
| ? parcels.map((p) => `<option value="${p.id}">${p.name}</option>`).join("") | |
| : '<option value="">(sin parcelas)</option>'; | |
| if (previousActiveId && sel.querySelector(`option[value="${previousActiveId}"]`)) { | |
| sel.value = previousActiveId; | |
| } | |
| if ($("parcelas-list")) { | |
| $("parcelas-list").innerHTML = parcels.length | |
| ? parcels.map((p) => `<li data-pid="${p.id}" title="Ir a la parcela" class="flex items-center justify-between bg-stone-50 border border-stone-200 rounded px-2.5 py-1.5 cursor-pointer hover:bg-stone-100"><span class="font-semibold text-stone-700 text-[11px]"><i class="fa-solid fa-location-dot text-emerald-600 mr-1"></i>${p.name}</span><i class="fa-solid fa-crosshairs text-stone-400"></i></li>`).join("") | |
| : '<li class="text-stone-500">Sin parcelas aún.</li>'; | |
| } | |
| renderFloatingParcels(); | |
| renderParcelsOnMap(); | |
| } | |
| // Recarga canónica de parcelas (Fase 10: delega en refreshParcels). | |
| const loadParcels = refreshParcels; | |
| function initMap() { | |
| if (map) { map.invalidateSize(); return; } | |
| const osm = L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { attribution: "© OpenStreetMap" }); | |
| const sat = L.tileLayer("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}", { attribution: "© Esri World Imagery", maxZoom: 19 }); | |
| const topo = L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", { attribution: "© OpenTopoMap", maxZoom: 17 }); | |
| map = L.map("map", { center: [-11.37, -77.37], zoom: 11, layers: [sat] }); // Lomas de Lachay | |
| L.control.layers({ "Satélite": sat, "Normal": osm, "Relieve": topo }).addTo(map); | |
| drawnItems = new L.FeatureGroup().addTo(map); | |
| parcelsLayer = L.geoJSON(null, { | |
| style: { color: "#15803d", weight: 2, fillColor: "#15803d", fillOpacity: 0.15 }, | |
| onEachFeature: (f, layer) => layer.bindPopup(f.properties.name), | |
| }).addTo(map); | |
| renderParcelsOnMap(); | |
| const drawControl = new L.Control.Draw({ | |
| draw: { polygon: true, marker: false, polyline: false, circle: false, rectangle: false, circlemarker: false }, | |
| edit: { featureGroup: drawnItems, edit: false, remove: true }, | |
| }); | |
| map.addControl(drawControl); | |
| map.on(L.Draw.Event.CREATED, (e) => { | |
| drawnItems.clearLayers(); | |
| drawnItems.addLayer(e.layer); | |
| drawnGeojson = e.layer.toGeoJSON().geometry; | |
| let areaHa = 1.25; | |
| try { | |
| const latlngs = e.layer.getLatLngs()[0]; | |
| const areaM2 = L.GeometryUtil.geodesicArea(latlngs); | |
| areaHa = areaM2 / 10000; | |
| } catch(err) { | |
| console.log("No L.GeometryUtil, using default estimation."); | |
| } | |
| $("creator-area-ha").innerText = `${areaHa.toFixed(2)} ha`; | |
| $("creator-name-input").value = `Lote_${parcels.length + 1}`; | |
| // Cambiar a Paso B (Detalles) en Seccion 2 | |
| $("creator-step-drawing").classList.add("hidden"); | |
| $("creator-step-details").classList.remove("hidden"); | |
| // Ocultar banner de dibujo | |
| $("map-draw-banner").classList.add("hidden"); | |
| if (polygonDrawer) { | |
| try { polygonDrawer.disable(); } catch(err){} | |
| } | |
| }); | |
| map.on(L.Draw.Event.DRAWSTART, () => { | |
| $("map-draw-banner").classList.remove("hidden"); | |
| $("map-draw-banner-text").innerText = "Haz clic en el mapa para marcar tu primer punto."; | |
| }); | |
| map.on(L.Draw.Event.DRAWVERTEX, () => { | |
| $("map-draw-banner-text").innerText = "Completa tu lote marcando los puntos restantes en el mapa. Haz doble clic o cierra para terminar."; | |
| }); | |
| } | |
| function renderParcelsOnMap() { | |
| if (!parcelsLayer) return; | |
| parcelsLayer.clearLayers(); | |
| const feats = parcels | |
| .filter((p) => p.geojson) | |
| .map((p) => ({ type: "Feature", properties: { name: p.name, id: p.id }, geometry: p.geojson })); | |
| if (!feats.length) return; | |
| parcelsLayer.addData({ type: "FeatureCollection", features: feats }); | |
| try { map.fitBounds(parcelsLayer.getBounds(), { maxZoom: 14, padding: [25, 25] }); } catch {} | |
| } | |
| function flyToParcel(id) { | |
| const p = parcels.find((x) => x.id === id); | |
| if (!p || !p.geojson || !map) return; | |
| try { map.flyToBounds(L.geoJSON(p.geojson).getBounds(), { maxZoom: 15, padding: [25, 25] }); } catch {} | |
| } | |
| // Control del selector flotante de parcelas | |
| if ($("btn-farm-selector-toggle")) { | |
| $("btn-farm-selector-toggle").addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| $("farm-selector-dropdown").classList.toggle("hidden"); | |
| }); | |
| } | |
| document.addEventListener("click", (e) => { | |
| const dropdown = $("farm-selector-dropdown"); | |
| const toggle = $("btn-farm-selector-toggle"); | |
| if (dropdown && !dropdown.classList.contains("hidden") && !dropdown.contains(e.target) && e.target !== toggle && !toggle?.contains(e.target)) { | |
| dropdown.classList.add("hidden"); | |
| } | |
| }); | |
| let polygonDrawer = null; | |
| function enableDrawingMode() { | |
| // Mostrar panel de creación | |
| $("section2-form").classList.add("hidden"); | |
| $("section2-chat").classList.add("hidden"); | |
| $("section2-creator").classList.remove("hidden"); | |
| // Poner Paso A activo (Instrucciones) | |
| $("creator-step-drawing").classList.remove("hidden"); | |
| $("creator-step-details").classList.add("hidden"); | |
| $("creator-step-loading").classList.add("hidden"); | |
| // Activar el dibujador de polígonos de Leaflet | |
| if (polygonDrawer) { | |
| try { polygonDrawer.disable(); } catch(err){} | |
| } | |
| drawnItems.clearLayers(); | |
| drawnGeojson = null; | |
| polygonDrawer = new L.Draw.Polygon(map, { | |
| shapeOptions: { color: "#15803d", weight: 2, fillColor: "#15803d", fillOpacity: 0.15 } | |
| }); | |
| polygonDrawer.enable(); | |
| } | |
| function disableDrawingMode() { | |
| if (polygonDrawer) { | |
| try { polygonDrawer.disable(); } catch(err){} | |
| } | |
| $("map-draw-banner").classList.add("hidden"); | |
| drawnItems.clearLayers(); | |
| drawnGeojson = null; | |
| $("section2-creator").classList.add("hidden"); | |
| // Regresar a la vista correspondiente | |
| showSection2Chat(); | |
| } | |
| if ($("btn-floating-add-parcel")) { | |
| $("btn-floating-add-parcel").addEventListener("click", () => { | |
| $("farm-selector-dropdown").classList.add("hidden"); | |
| enableDrawingMode(); | |
| }); | |
| } | |
| if ($("btn-cancel-draw")) { | |
| $("btn-cancel-draw").addEventListener("click", () => { | |
| disableDrawingMode(); | |
| }); | |
| } | |
| if ($("btn-creator-cancel")) { | |
| $("btn-creator-cancel").addEventListener("click", () => { | |
| disableDrawingMode(); | |
| }); | |
| } | |
| if ($("btn-show-creator")) { | |
| $("btn-show-creator").addEventListener("click", () => { | |
| enableDrawingMode(); | |
| }); | |
| } | |
| if ($("btn-draw-undo")) { | |
| $("btn-draw-undo").addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (polygonDrawer && typeof polygonDrawer.deleteLastVertex === 'function') { | |
| polygonDrawer.deleteLastVertex(); | |
| } | |
| }); | |
| } | |
| if ($("creator-crop-select")) { | |
| $("creator-crop-select").addEventListener("change", () => { | |
| const crop = $("creator-crop-select").value; | |
| applyCreatorIndices(RECOMMENDED_INDICES_BY_CROP[crop] || ["ndvi"]); | |
| }); | |
| } | |
| if ($("btn-creator-select-recommended-indices")) { | |
| $("btn-creator-select-recommended-indices").addEventListener("click", () => { | |
| const crop = $("creator-crop-select").value; | |
| applyCreatorIndices(RECOMMENDED_INDICES_BY_CROP[crop] || ["ndvi"]); | |
| }); | |
| } | |
| if ($("btn-creator-select-all-indices")) { | |
| $("btn-creator-select-all-indices").addEventListener("click", () => { | |
| applyCreatorIndices(VALID_INDICES); | |
| }); | |
| } | |
| if ($("btn-creator-save")) { | |
| $("btn-creator-save").addEventListener("click", async () => { | |
| const name = $("creator-name-input").value.trim(); | |
| const crop = $("creator-crop-select").value; | |
| const selectedIndices = getCheckedIndices(".creator-index-checkbox"); | |
| if (!drawnGeojson) return notify("Falta el polígono", "Dibuja un polígono en el mapa."); | |
| if (!name) return notify("Falta el nombre", "Escribe un nombre."); | |
| if (!selectedIndices.length) { | |
| $("creator-indices-error")?.classList.remove("hidden"); | |
| return notify("Falta una variable satelital", "Marca al menos una variable satelital para calcular."); | |
| } | |
| $("creator-indices-error")?.classList.add("hidden"); | |
| // Mostrar pantalla de carga (Paso C) | |
| $("creator-step-details").classList.add("hidden"); | |
| $("creator-step-loading").classList.remove("hidden"); | |
| try { | |
| track("parcel_draw_save", { name, crop, indices: selectedIndices }); | |
| // 1. Crear la parcela en la base de datos | |
| const r = await api("/fields", { | |
| method: "POST", | |
| body: JSON.stringify({ | |
| name, | |
| geojson: drawnGeojson, | |
| crop_variety: crop || null, | |
| selected_indices: selectedIndices | |
| }) | |
| }); | |
| if (r.ok) { | |
| const field = await r.json(); | |
| const newFieldId = field.id; | |
| // 2. Si seleccionó cultivo o variables, guardarlas vía PATCH | |
| if (crop || selectedIndices.length) { | |
| await api(`/fields/${newFieldId}`, { | |
| method: "PATCH", | |
| body: JSON.stringify({ crop_variety: crop || null, selected_indices: selectedIndices }) | |
| }); | |
| } | |
| track("parcel_create_wizard_success", { name, crop, indices: selectedIndices }); | |
| // 3. Simular procesamiento para esperar recálculo e iniciar notificaciones escalonadas | |
| setTimeout(async () => { | |
| // Limpiar cargando | |
| $("section2-creator").classList.add("hidden"); | |
| showSection2Chat(); | |
| // Notificaciones escalonadas (toasts en la esquina superior derecha) | |
| setTimeout(() => { | |
| notify("Meteorología Lista", `El clima histórico de Open-Meteo para ${name} está disponible.`, "success"); | |
| }, 100); | |
| setTimeout(() => { | |
| notify("Teledetección Lista", `Sentinel-2 calculará ${formatIndices(selectedIndices)} en ${name}.`, "success"); | |
| }, 800); | |
| setTimeout(() => { | |
| notify("Asistente Listo", `El agente RAG de AgroVisión ya tiene cargado el contexto completo de ${name}.`, "success"); | |
| }, 1500); | |
| drawnGeojson = null; | |
| drawnItems.clearLayers(); | |
| await refreshParcels(); | |
| setParcelIndices(newFieldId, selectedIndices); | |
| selectParcel(newFieldId); | |
| flyToParcel(newFieldId); | |
| }, 3000); | |
| } else { | |
| const e = await r.json().catch(() => ({})); | |
| notify("Error " + r.status, e.detail || ""); | |
| $("creator-step-loading").classList.add("hidden"); | |
| $("creator-step-details").classList.remove("hidden"); | |
| } | |
| } catch (e) { | |
| notify("Error de red", String(e)); | |
| $("creator-step-loading").classList.add("hidden"); | |
| $("creator-step-details").classList.remove("hidden"); | |
| } | |
| }); | |
| } | |
| if ($("floating-parcelas-list")) { | |
| $("floating-parcelas-list").addEventListener("click", (e) => { | |
| const item = e.target.closest(".floating-parcel-item"); | |
| if (!item) return; | |
| const pid = item.dataset.pid; | |
| const gear = e.target.closest(".btn-gear-click"); | |
| selectParcel(pid); | |
| flyToParcel(pid); | |
| if (gear) { | |
| // Mostrar formulario de Ajustes | |
| $("section2-form").classList.remove("hidden"); | |
| $("section2-chat").classList.add("hidden"); | |
| $("section2-creator").classList.add("hidden"); | |
| $("branding-icon").className = "fa-solid fa-robot text-base"; | |
| $("btn-branding-toggle").title = "Mostrar Chat con Asistente"; | |
| } | |
| $("farm-selector-dropdown").classList.add("hidden"); | |
| }); | |
| } | |
| // ===== Resumen KPIs y Gráfico NDVI ===== | |
| async function loadResumen() { | |
| renderFloatingParcels(); | |
| } | |
| // ===== Teledetección (GIS + Heatmap + Índices) ===== | |
| const INDEX_RANGES = { ndvi: [0, 1], evi: [0, 1], savi: [-0.4, 0.8], ndwi: [-0.6, 0.7], ndre: [0, 0.5] }; | |
| let currentIndex = "ndvi"; | |
| $("select-index").addEventListener("change", () => { | |
| currentIndex = $("select-index").value; | |
| loadTeledeteccion(); | |
| }); | |
| $("btn-add-satellite-var").addEventListener("click", async () => { | |
| const fid = $("select-parcela").value; | |
| if (!fid) return notify("Selecciona una parcela", "Elige una parcela antes de agregar variables satelitales."); | |
| const index = $("select-index").value; | |
| const currentIndices = getParcelIndices(fid); | |
| if (!currentIndices.includes(index)) { | |
| const nextIndices = normalizeIndices([...currentIndices, index]); | |
| try { | |
| const res = await api(`/fields/${fid}`, { | |
| method: "PATCH", | |
| body: JSON.stringify({ selected_indices: nextIndices }) | |
| }); | |
| if (!res.ok) { | |
| const error = await res.json().catch(() => ({})); | |
| return notify("No se pudo agregar la variable", error.detail || `Error ${res.status}`); | |
| } | |
| setParcelIndices(fid, nextIndices); | |
| syncSatelliteIndexControls(fid); | |
| track("satellite_index_added", { field_id: fid, index }); | |
| notify("Variable satelital habilitada", `${INDEX_LABELS[index] || index.toUpperCase()} se agregó a la parcela. Puedes calcularla ahora.`); | |
| } catch (error) { | |
| return notify("Error de red", String(error)); | |
| } | |
| } else { | |
| notify("Variable ya activa", `${INDEX_LABELS[index] || index.toUpperCase()} ya está activa en la parcela. Puedes reprocesarla.`); | |
| } | |
| showReprocessModal(index); | |
| }); | |
| async function loadTeledeteccion() { | |
| const fid = $("select-parcela").value; | |
| if (!fid) { $("satelite-empty").classList.remove("hidden"); return; } | |
| $("satelite-empty").classList.add("hidden"); | |
| const parcelIndices = getParcelIndices(fid); | |
| activeParcelIndices = parcelIndices; | |
| renderActiveIndexBadges(parcelIndices); | |
| updateIndexSelectOptions(parcelIndices); | |
| const rI = await api("/vegetation/" + currentIndex, { method: "POST", body: JSON.stringify({ field_id: fid }) }); | |
| const series = rI.ok ? (await rI.json()).series : []; | |
| if (!series.length) { $("satelite-empty").classList.remove("hidden"); return; } | |
| const label = INDEX_LABELS[currentIndex] || currentIndex.toUpperCase(); | |
| const range = INDEX_RANGES[currentIndex] || [0, 1]; | |
| chartSatelite?.destroy(); | |
| chartSatelite = new Chart($("chart-satelite"), { | |
| type: "line", | |
| data: { | |
| labels: series.map((p) => p.date), | |
| datasets: [ | |
| { label: label, data: series.map((p) => p.mean_value), borderColor: "#15803d", tension: 0.3, fill: false } | |
| ], | |
| }, | |
| options: { responsive: true, maintainAspectRatio: false, scales: { y: { min: range[0], max: range[1], title: { display: true, text: label } } } }, | |
| }); | |
| track("chart_view", { chart: "teledeteccion", index: currentIndex, points: series.length }); | |
| } | |
| $("btn-heatmap").addEventListener("click", async () => { | |
| const fid = $("select-parcela").value; | |
| if (!fid) return notify("Selecciona una parcela", ""); | |
| const idx = currentIndex; | |
| track("heatmap", { field_id: fid, index: idx }); | |
| $("heatmap-box").innerHTML = '<span class="text-xs text-stone-500">Generando…</span>'; | |
| try { | |
| const r = await api("/vegetation/" + idx + "/raster", { method: "POST", body: JSON.stringify({ field_id: fid }) }); | |
| if (r.ok) { const url = URL.createObjectURL(await r.blob()); $("heatmap-box").innerHTML = `<img src="${url}" class="max-w-full rounded shadow" />`; } | |
| else { $("heatmap-box").innerHTML = '<span class="text-xs text-red-500">No se pudo generar. Verifica Copernicus.</span>'; } | |
| } catch (e) { $("heatmap-box").innerHTML = '<span class="text-xs text-red-500">Error de red.</span>'; } | |
| }); | |
| // ===== Meteorología (Weather) ===== | |
| let currentGranularity = 'horario'; | |
| let currentWeatherVariable = 'temperatures'; | |
| let currentBottomChartFilter = 'water_balance'; | |
| let weatherRawData = []; | |
| let weatherCampaignStart = ""; | |
| let activeFieldId = ""; | |
| let chartWaterBalance = null; // Store bottom chart instance globally in this block | |
| // Helper to calculate Dew Point (Punto de Rocío) using Magnus-Tetens formula: | |
| // Td = (b * alpha(T,RH)) / (a - alpha(T,RH)) where alpha(T,RH) = (a*T)/(b+T) + ln(RH/100) | |
| // Standard values: a = 17.27, b = 237.7 | |
| function calculateDewPoint(temp, rh) { | |
| if (temp === null || temp === undefined || rh === null || rh === undefined || rh <= 0) return temp; | |
| const a = 17.27; | |
| const b = 237.7; | |
| const alpha = ((a * temp) / (b + temp)) + Math.log(rh / 100); | |
| const dewPoint = (b * alpha) / (a - alpha); | |
| return parseFloat(dewPoint.toFixed(1)); | |
| } | |
| // Helper for VPD (Déficit de Presión de Vapor) in kPa: | |
| // Es = 0.6108 * exp((17.27 * T) / (T + 237.3)) | |
| // Ea = Es * (RH / 100) | |
| // VPD = Es - Ea | |
| function calculateVPD(temp, rh) { | |
| if (temp === null || temp === undefined || rh === null || rh === undefined) return 0; | |
| const Es = 0.6108 * Math.exp((17.27 * temp) / (temp + 237.3)); | |
| const Ea = Es * (rh / 100); | |
| return parseFloat(Math.max(0, Es - Ea).toFixed(2)); | |
| } | |
| // Agronomic stressors definitions & note strings | |
| const weatherNotesData = { | |
| gdd: { | |
| title: "Grados Día Acumulados (GDD)", | |
| icon: "fa-fire-flame-simple text-orange-600", | |
| bg: "bg-orange-50 border-orange-100 text-orange-950", | |
| text: "Los Grados Día acumulados miden el calor total que la planta ha absorbido por encima de una temperatura base (10°C). Es un indicador clave para predecir las fases de brotación, floración y maduración del cultivo." | |
| }, | |
| chill_hours: { | |
| title: "Horas Frío (HF)", | |
| icon: "fa-snowflake text-blue-600", | |
| bg: "bg-blue-50 border-blue-100 text-blue-950", | |
| text: "Mide las horas acumuladas con temperaturas inferiores a 7.2°C. Es fundamental para variedades caducifolias o de bajo requerimiento frío para romper el letargo invernal de forma uniforme." | |
| }, | |
| thermal_stress: { | |
| title: "Horas de Estrés Térmico", | |
| icon: "fa-triangle-exclamation text-red-650", | |
| bg: "bg-red-50 border-red-100 text-red-950", | |
| text: "Registra las horas acumuladas con temperaturas superiores a 32°C. Provoca un aumento drástico en la tasa de respiración de la planta, deshidratación foliar y posible aborto de flores o frutos tiernos." | |
| }, | |
| dpv_stress: { | |
| title: "Horas de Estrés por DPV", | |
| icon: "fa-wind text-amber-600", | |
| bg: "bg-amber-50 border-amber-100 text-amber-950", | |
| text: "Suma de horas con Déficit de Presión de Vapor superior a 2.0 kPa. Bajo un aire muy seco, las plantas cierran estomas para retener agua, lo que detiene la fotosíntesis y ralentiza el crecimiento." | |
| }, | |
| frost_hours: { | |
| title: "Horas Heladas", | |
| icon: "fa-temperature-arrow-down text-indigo-700", | |
| bg: "bg-indigo-50 border-indigo-100 text-indigo-950", | |
| text: "Horas acumuladas por debajo de 0°C. Representa un riesgo inminente de congelación celular y necrosis de brotes nuevos, flores expuestas y frutos en desarrollo." | |
| }, | |
| et0_accum: { | |
| title: "Evapotranspiración (ET0) Acumulada", | |
| icon: "fa-droplet-slash text-emerald-600", | |
| bg: "bg-emerald-50 border-emerald-100 text-emerald-950", | |
| text: "La ET0 acumulada estima la demanda hídrica teórica del suelo y vegetación (FAO-56 Penman-Monteith). Es la referencia base para planificar los tiempos y volúmenes de riego semanales." | |
| }, | |
| balance: { | |
| title: "Estimación de Balance Hídrico", | |
| icon: "fa-wand-magic-sparkles text-emerald-600", | |
| bg: "bg-emerald-50 border-emerald-100 text-emerald-950", | |
| text: "Muestra la diferencia acumulada entre precipitaciones y evapotranspiración. Un balance negativo indica déficit hídrico neto que debe compensarse mediante riego programado." | |
| } | |
| }; | |
| function updateAgronomicNote(type) { | |
| const note = weatherNotesData[type] || weatherNotesData.balance; | |
| const box = $("agronomic-note-box"); | |
| const icon = $("agronomic-note-icon"); | |
| const title = $("agronomic-note-title"); | |
| const text = $("agronomic-note-text"); | |
| if (box && icon && title && text) { | |
| box.className = `${note.bg} border rounded-xl p-3 text-[10px] leading-relaxed flex gap-2 cursor-pointer transition-all duration-300 hover:shadow-sm`; | |
| icon.className = `fa-solid ${note.icon} text-xs shrink-0 mt-0.5`; | |
| title.textContent = note.title; | |
| text.textContent = note.text; | |
| } | |
| } | |
| function setupWeatherEventListeners() { | |
| // Toggle buttons for granularity | |
| const btnHorario = $("btn-gran-horario"); | |
| const btnDiario = $("btn-gran-diario"); | |
| const btnSemanal = $("btn-gran-semanal"); | |
| const updateGranularityUI = (selected) => { | |
| currentGranularity = selected; | |
| [btnHorario, btnDiario, btnSemanal].forEach(btn => { | |
| if (btn) { | |
| const isSelected = btn.id === `btn-gran-${selected}`; | |
| btn.className = isSelected | |
| ? "flex-1 text-center py-2 bg-white text-emerald-800 font-extrabold rounded-lg text-xs shadow-sm transition-all" | |
| : "flex-1 text-center py-2 text-stone-500 hover:text-stone-850 font-bold rounded-lg text-xs transition-all"; | |
| } | |
| }); | |
| const dateFilter = $("filter-date-container"); | |
| const campInfo = $("campaign-info-container"); | |
| if (selected === 'horario') { | |
| dateFilter?.classList.remove('hidden'); | |
| campInfo?.classList.add('hidden'); | |
| } else { | |
| dateFilter?.classList.add('hidden'); | |
| campInfo?.classList.remove('hidden'); | |
| } | |
| renderDynamicKPIs(); | |
| drawTopWeatherChart(); | |
| }; | |
| if (btnHorario) btnHorario.addEventListener("click", () => updateGranularityUI("horario")); | |
| if (btnDiario) btnDiario.addEventListener("click", () => updateGranularityUI("diario")); | |
| if (btnSemanal) btnSemanal.addEventListener("click", () => updateGranularityUI("semanal")); | |
| // Historical Date Picker | |
| const datePicker = $("historical-date-picker"); | |
| if (datePicker) { | |
| datePicker.addEventListener("change", () => { | |
| if (currentGranularity === "horario") { | |
| renderDynamicKPIs(); | |
| drawTopWeatherChart(); | |
| } | |
| }); | |
| } | |
| // Top chart variable selector | |
| const varSelect = $("weather-variable-select"); | |
| if (varSelect) { | |
| varSelect.addEventListener("change", (e) => { | |
| currentWeatherVariable = e.target.value; | |
| drawTopWeatherChart(); | |
| }); | |
| } | |
| // Bottom chart selector | |
| const bottomSelect = $("bottom-chart-filter-select"); | |
| if (bottomSelect) { | |
| bottomSelect.addEventListener("change", (e) => { | |
| currentBottomChartFilter = e.target.value; | |
| drawBottomWeatherChart(); | |
| }); | |
| } | |
| // Setup Agronomic Stress Cards Clicks | |
| const bindCardClick = (id, type) => { | |
| const card = $(id); | |
| if (card) { | |
| card.addEventListener("click", () => updateAgronomicNote(type)); | |
| } | |
| }; | |
| bindCardClick("btn-indicator-gdd", "gdd"); | |
| bindCardClick("btn-indicator-chill", "chill_hours"); | |
| bindCardClick("btn-indicator-thermal", "thermal_stress"); | |
| bindCardClick("btn-indicator-dpv", "dpv_stress"); | |
| bindCardClick("btn-indicator-frost", "frost_hours"); | |
| bindCardClick("btn-indicator-et0", "et0_accum"); | |
| const noteBox = $("agronomic-note-box"); | |
| if (noteBox) { | |
| noteBox.addEventListener("click", () => updateAgronomicNote("balance")); | |
| } | |
| } | |
| // Group records helpers | |
| function getAggregatedData() { | |
| if (!weatherRawData || !weatherRawData.length) return { labels: [], datasets: [] }; | |
| if (currentGranularity === 'horario') { | |
| // Filter records by historical-date-picker value (YYYY-MM-DD) | |
| const datePicker = $("historical-date-picker"); | |
| let selectedDateStr = datePicker ? datePicker.value : ""; | |
| if (!selectedDateStr) { | |
| // Default to latest date in raw data | |
| const dates = weatherRawData.map(r => r.date.split("T")[0]); | |
| selectedDateStr = dates.reduce((max, d) => d > max ? d : max, dates[0]); | |
| if (datePicker) datePicker.value = selectedDateStr; | |
| } | |
| const filtered = weatherRawData.filter(r => { | |
| if (!r.date) return false; | |
| const d = new Date(r.date); | |
| const y = d.getFullYear(); | |
| const m = String(d.getMonth() + 1).padStart(2, "0"); | |
| const dateStr = String(d.getDate()).padStart(2, "0"); | |
| return `${y}-${m}-${dateStr}` === selectedDateStr; | |
| }); | |
| // Produce 24 points (hours 0-23) | |
| const hoursData = Array.from({ length: 24 }, (_, hour) => { | |
| const match = filtered.find(r => { | |
| if (!r.date) return false; | |
| return new Date(r.date).getHours() === hour; | |
| }); | |
| return match || { | |
| temp_c: 0, | |
| relative_humidity_percentage: 0, | |
| soil_temperature_0_to_7cm_c: 0, | |
| soil_moisture_0_to_7cm_m3_m3: 0, | |
| precip_mm: 0, | |
| solar_radiation_w_m2: 0, | |
| wind_speed_10m_km_h: 0, | |
| et0_fao_evapotranspiration: 0 | |
| }; | |
| }); | |
| const labels = Array.from({ length: 24 }, (_, i) => `${i}:00`); | |
| return { labels, rawRecords: hoursData, mode: 'horario' }; | |
| } else if (currentGranularity === 'diario') { | |
| // Group by day YYYY-MM-DD | |
| const dailyMap = new Map(); | |
| weatherRawData.forEach(r => { | |
| const day = r.date.split("T")[0]; | |
| if (!dailyMap.has(day)) dailyMap.set(day, []); | |
| dailyMap.get(day).push(r); | |
| }); | |
| const sortedDays = Array.from(dailyMap.keys()).sort(); | |
| const dailyRecords = sortedDays.map(day => { | |
| const records = dailyMap.get(day); | |
| const temps = records.map(r => r.temp_c).filter(v => v !== null && v !== undefined); | |
| const rhs = records.map(r => r.relative_humidity_percentage).filter(v => v !== null && v !== undefined); | |
| const winds = records.map(r => r.wind_speed_10m_km_h).filter(v => v !== null && v !== undefined); | |
| const rads = records.map(r => r.solar_radiation_w_m2).filter(v => v !== null && v !== undefined); | |
| const soils = records.map(r => r.soil_moisture_0_to_7cm_m3_m3).filter(v => v !== null && v !== undefined); | |
| const maxTemp = temps.length ? Math.max(...temps) : 0; | |
| const minTemp = temps.length ? Math.min(...temps) : 0; | |
| const meanTemp = temps.length ? (temps.reduce((s, v) => s + v, 0) / temps.length) : 0; | |
| const maxRH = rhs.length ? Math.max(...rhs) : 0; | |
| const minRH = rhs.length ? Math.min(...rhs) : 0; | |
| const meanRH = rhs.length ? (rhs.reduce((s, v) => s + v, 0) / rhs.length) : 0; | |
| const precip = records.reduce((s, r) => s + (r.precip_mm || 0), 0); | |
| const maxRad = rads.length ? Math.max(...rads) : 0; | |
| const maxWind = winds.length ? Math.max(...winds) : 0; | |
| const minWind = winds.length ? Math.min(...winds) : 0; | |
| const meanWind = winds.length ? (winds.reduce((s, v) => s + v, 0) / winds.length) : 0; | |
| const meanSoil = soils.length ? (soils.reduce((s, v) => s + v, 0) / soils.length) : 0; | |
| // VPD stress calculation inside daily aggregator | |
| const vpds = records.map(r => calculateVPD(r.temp_c, r.relative_humidity_percentage)); | |
| const meanVPD = vpds.length ? (vpds.reduce((s, v) => s + v, 0) / vpds.length) : 0; | |
| return { | |
| day, | |
| maxTemp, | |
| minTemp, | |
| meanTemp, | |
| maxRH, | |
| minRH, | |
| meanRH, | |
| precip, | |
| maxRad, | |
| maxWind, | |
| minWind, | |
| meanWind, | |
| meanSoil, | |
| meanVPD | |
| }; | |
| }); | |
| return { labels: sortedDays, records: dailyRecords, mode: 'diario' }; | |
| } else { | |
| // Semanal: Group raw records by 7-day calendar windows starting from the campaign start date | |
| const campaignStartMs = new Date(weatherCampaignStart).getTime(); | |
| const weeklyMap = new Map(); | |
| weatherRawData.forEach(r => { | |
| const recordMs = new Date(r.date).getTime(); | |
| const diffDays = Math.floor((recordMs - campaignStartMs) / (1000 * 60 * 60 * 24)); | |
| const weekIndex = Math.max(0, Math.floor(diffDays / 7)) + 1; | |
| if (!weeklyMap.has(weekIndex)) weeklyMap.set(weekIndex, []); | |
| weeklyMap.get(weekIndex).push(r); | |
| }); | |
| const sortedWeeks = Array.from(weeklyMap.keys()).sort((a, b) => a - b); | |
| const weeklyRecords = sortedWeeks.map(wIdx => { | |
| const records = weeklyMap.get(wIdx); | |
| const temps = records.map(r => r.temp_c).filter(v => v !== null && v !== undefined); | |
| const rhs = records.map(r => r.relative_humidity_percentage).filter(v => v !== null && v !== undefined); | |
| const winds = records.map(r => r.wind_speed_10m_km_h).filter(v => v !== null && v !== undefined); | |
| const rads = records.map(r => r.solar_radiation_w_m2).filter(v => v !== null && v !== undefined); | |
| const soils = records.map(r => r.soil_moisture_0_to_7cm_m3_m3).filter(v => v !== null && v !== undefined); | |
| const maxTemp = temps.length ? Math.max(...temps) : 0; | |
| const minTemp = temps.length ? Math.min(...temps) : 0; | |
| const meanTemp = temps.length ? (temps.reduce((s, v) => s + v, 0) / temps.length) : 0; | |
| const maxRH = rhs.length ? Math.max(...rhs) : 0; | |
| const minRH = rhs.length ? Math.min(...rhs) : 0; | |
| const precip = records.reduce((s, r) => s + (r.precip_mm || 0), 0); | |
| const maxRad = rads.length ? Math.max(...rads) : 0; | |
| const maxWind = winds.length ? Math.max(...winds) : 0; | |
| const minWind = winds.length ? Math.min(...winds) : 0; | |
| const meanSoil = soils.length ? (soils.reduce((s, v) => s + v, 0) / soils.length) : 0; | |
| const vpds = records.map(r => calculateVPD(r.temp_c, r.relative_humidity_percentage)); | |
| const meanVPD = vpds.length ? (vpds.reduce((s, v) => s + v, 0) / vpds.length) : 0; | |
| return { | |
| weekIndex, | |
| maxTemp, | |
| minTemp, | |
| meanTemp, | |
| maxRH, | |
| minRH, | |
| precip, | |
| maxRad, | |
| maxWind, | |
| minWind, | |
| meanSoil, | |
| meanVPD | |
| }; | |
| }); | |
| return { labels: sortedWeeks.map(w => `Semana ${w}`), records: weeklyRecords, mode: 'semanal' }; | |
| } | |
| } | |
| function renderDynamicKPIs() { | |
| const data = getAggregatedData(); | |
| const container = $("kpis-container"); | |
| if (!container) return; | |
| let kpis = []; | |
| if (data.mode === 'horario') { | |
| // Find latest record or active record matching picker | |
| const list = data.rawRecords; | |
| // Calculate values for "Current hour" (we take the last record from the subset, which would correspond to 23:00 or closest) | |
| // To make it dynamic, let's grab the last record in this filtered day that is non-zero, or just the last record. | |
| const currentRec = list[list.length - 1] || {}; | |
| kpis = [ | |
| { label: 'Temperatura actual', value: `${(currentRec.temp_c || 0).toFixed(1)} °C`, icon: 'fa-temperature-half', color: 'orange' }, | |
| { label: 'Humedad Suelo', value: `${((currentRec.soil_moisture_0_to_7cm_m3_m3 || 0) * 100).toFixed(1)} %`, icon: 'fa-droplet', color: 'blue' }, | |
| { label: 'Viento actual', value: `${(currentRec.wind_speed_10m_km_h || 0).toFixed(1)} km/h`, icon: 'fa-wind', color: 'cyan' }, | |
| { label: 'Radiación Solar', value: `${(currentRec.solar_radiation_w_m2 || 0).toFixed(0)} W/m²`, icon: 'fa-sun', color: 'yellow' }, | |
| { label: 'Precipitación', value: `${(currentRec.precip_mm || 0).toFixed(1)} mm`, icon: 'fa-cloud-rain', color: 'indigo' }, | |
| { label: 'DPV actual', value: `${calculateVPD(currentRec.temp_c, currentRec.relative_humidity_percentage).toFixed(2)} kPa`, icon: 'fa-droplet-slash', color: 'teal' } | |
| ]; | |
| } else if (data.mode === 'diario') { | |
| const list = data.records; | |
| // Compute average/max stats over the entire list of daily records | |
| const maxTemps = list.map(r => r.maxTemp); | |
| const minTemps = list.map(r => r.minTemp); | |
| const maxTemp = maxTemps.length ? Math.max(...maxTemps) : 0; | |
| const minTemp = minTemps.length ? Math.min(...minTemps) : 0; | |
| const maxRHs = list.map(r => r.maxRH); | |
| const minRHs = list.map(r => r.minRH); | |
| const maxRH = maxRHs.length ? Math.max(...maxRHs) : 0; | |
| const minRH = minRHs.length ? Math.min(...minRHs) : 0; | |
| const maxRads = list.map(r => r.maxRad); | |
| const maxRad = maxRads.length ? Math.max(...maxRads) : 0; | |
| const totalPrecip = list.reduce((s, r) => s + r.precip, 0); | |
| const maxWinds = list.map(r => r.maxWind); | |
| const minWinds = list.map(r => r.minWind); | |
| const maxWind = maxWinds.length ? Math.max(...maxWinds) : 0; | |
| const minWind = minWinds.length ? Math.min(...minWinds) : 0; | |
| const meanVPDs = list.map(r => r.meanVPD); | |
| const avgVPD = meanVPDs.length ? (meanVPDs.reduce((s, v) => s + v, 0) / meanVPDs.length) : 0; | |
| kpis = [ | |
| { label: 'Temp Máx / Mín', value: `${maxTemp.toFixed(1)}°C / ${minTemp.toFixed(1)}°C`, icon: 'fa-temperature-half', color: 'orange' }, | |
| { label: 'HR Máx / Mín', value: `${maxRH.toFixed(0)}% / ${minRH.toFixed(0)}%`, icon: 'fa-droplet', color: 'blue' }, | |
| { label: 'Radiación Máx', value: `${maxRad.toFixed(0)} W/m²`, icon: 'fa-sun', color: 'yellow' }, | |
| { label: 'Precipitación Acum.', value: `${totalPrecip.toFixed(1)} mm`, icon: 'fa-cloud-showers-water', color: 'indigo' }, | |
| { label: 'Viento Máx / Mín', value: `${maxWind.toFixed(1)} / ${minWind.toFixed(1)} km/h`, icon: 'fa-wind', color: 'cyan' }, | |
| { label: 'DPV Promedio', value: `${avgVPD.toFixed(2)} kPa`, icon: 'fa-droplet-slash', color: 'teal' } | |
| ]; | |
| } else { | |
| // Semanal | |
| const list = data.records; | |
| const maxTemps = list.map(r => r.maxTemp); | |
| const minTemps = list.map(r => r.minTemp); | |
| const maxTempProm = maxTemps.length ? (maxTemps.reduce((s, v) => s + v, 0) / maxTemps.length) : 0; | |
| const minTempProm = minTemps.length ? (minTemps.reduce((s, v) => s + v, 0) / minTemps.length) : 0; | |
| const maxRHs = list.map(r => r.maxRH); | |
| const minRHs = list.map(r => r.minRH); | |
| const maxRHProm = maxRHs.length ? (maxRHs.reduce((s, v) => s + v, 0) / maxRHs.length) : 0; | |
| const minRHProm = minRHs.length ? (minRHs.reduce((s, v) => s + v, 0) / minRHs.length) : 0; | |
| const maxRads = list.map(r => r.maxRad); | |
| const maxRadHist = maxRads.length ? Math.max(...maxRads) : 0; | |
| const totalPrecip = list.reduce((s, r) => s + r.precip, 0); | |
| const maxWinds = list.map(r => r.maxWind); | |
| const minWinds = list.map(r => r.minWind); | |
| const maxWindProm = maxWinds.length ? (maxWinds.reduce((s, v) => s + v, 0) / maxWinds.length) : 0; | |
| const minWindProm = minWinds.length ? (minWinds.reduce((s, v) => s + v, 0) / minWinds.length) : 0; | |
| const meanVPDs = list.map(r => r.meanVPD); | |
| const avgVPD = meanVPDs.length ? (meanVPDs.reduce((s, v) => s + v, 0) / meanVPDs.length) : 0; | |
| kpis = [ | |
| { label: 'Temp Máx/Mín Prom.', value: `${maxTempProm.toFixed(1)}°C / ${minTempProm.toFixed(1)}°C`, icon: 'fa-temperature-half', color: 'orange' }, | |
| { label: 'HR Máx/Mín Prom.', value: `${maxRHProm.toFixed(0)}% / ${minRHProm.toFixed(0)}%`, icon: 'fa-droplet', color: 'blue' }, | |
| { label: 'Radiación Máx Hist.', value: `${maxRadHist.toFixed(0)} W/m²`, icon: 'fa-sun', color: 'yellow' }, | |
| { label: 'Precipitación Acum.', value: `${totalPrecip.toFixed(1)} mm`, icon: 'fa-cloud-showers-water', color: 'indigo' }, | |
| { label: 'Viento Prom. Máx/Mín', value: `${maxWindProm.toFixed(1)} / ${minWindProm.toFixed(1)} km/h`, icon: 'fa-wind', color: 'cyan' }, | |
| { label: 'DPV Promedio Sem.', value: `${avgVPD.toFixed(2)} kPa`, icon: 'fa-droplet-slash', color: 'teal' } | |
| ]; | |
| } | |
| container.innerHTML = ''; | |
| kpis.forEach(kpi => { | |
| const card = document.createElement('div'); | |
| card.className = 'bg-white p-4 rounded-xl border border-stone-200/80 shadow-sm flex items-center justify-between'; | |
| let iconBg = ''; | |
| let iconColor = ''; | |
| if (kpi.color === 'orange') { iconBg = 'bg-orange-50'; iconColor = 'text-orange-600'; } | |
| else if (kpi.color === 'blue') { iconBg = 'bg-blue-50'; iconColor = 'text-blue-600'; } | |
| else if (kpi.color === 'cyan') { iconBg = 'bg-cyan-50'; iconColor = 'text-cyan-600'; } | |
| else if (kpi.color === 'yellow') { iconBg = 'bg-yellow-50'; iconColor = 'text-yellow-600'; } | |
| else if (kpi.color === 'indigo') { iconBg = 'bg-indigo-50'; iconColor = 'text-indigo-600'; } | |
| else { iconBg = 'bg-teal-50'; iconColor = 'text-teal-600'; } | |
| card.innerHTML = ` | |
| <div> | |
| <span class="text-[9px] font-bold text-stone-400 uppercase tracking-wide block">${kpi.label}</span> | |
| <h4 class="text-sm font-extrabold text-stone-800 mt-1">${kpi.value}</h4> | |
| </div> | |
| <div class="w-8 h-8 rounded-lg ${iconBg} ${iconColor} flex items-center justify-center text-sm shrink-0"> | |
| <i class="fa-solid ${kpi.icon}"></i> | |
| </div> | |
| `; | |
| container.appendChild(card); | |
| }); | |
| } | |
| function drawTopWeatherChart() { | |
| const ctx = $("main-weather-chart")?.getContext('2d'); | |
| if (!ctx) return; | |
| chartWeather?.destroy(); | |
| const data = getAggregatedData(); | |
| let labels = data.labels; | |
| let datasets = []; | |
| let scalesConfig = { | |
| x: { grid: { color: '#f3f4f6' } }, | |
| y: { type: 'linear', display: true, position: 'left', grid: { color: '#f3f4f6' } } | |
| }; | |
| if (data.mode === 'horario') { | |
| const recs = data.rawRecords; | |
| if (currentWeatherVariable === 'temperatures') { | |
| datasets = [ | |
| { label: 'Temperatura (°C)', data: recs.map(r => r.temp_c), borderColor: '#ea580c', tension: 0.3, borderWidth: 2 }, | |
| { label: 'Punto de Rocío (°C)', data: recs.map(r => calculateDewPoint(r.temp_c, r.relative_humidity_percentage)), borderColor: '#3b82f6', borderDash: [4, 4], tension: 0.3, borderWidth: 1.5 } | |
| ]; | |
| } else if (currentWeatherVariable === 'humidity') { | |
| datasets = [ | |
| { label: 'Humedad Relativa (%)', data: recs.map(r => r.relative_humidity_percentage), borderColor: '#06b6d4', tension: 0.3, borderWidth: 2 }, | |
| { label: 'Humedad del Suelo (0-7cm %)', data: recs.map(r => (r.soil_moisture_0_to_7cm_m3_m3 || 0) * 100), borderColor: '#2563eb', tension: 0.3, borderWidth: 2 } | |
| ]; | |
| } else if (currentWeatherVariable === 'water') { | |
| datasets = [ | |
| { label: 'Precipitación Horaria (mm)', type: 'bar', data: recs.map(r => r.precip_mm), backgroundColor: '#3b82f6', yAxisID: 'y' }, | |
| { label: 'Déficit de Presión de Vapor DPV (kPa)', data: recs.map(r => calculateVPD(r.temp_c, r.relative_humidity_percentage)), borderColor: '#0d9488', tension: 0.3, borderWidth: 2, yAxisID: 'y1' } | |
| ]; | |
| scalesConfig = { | |
| x: { grid: { color: '#f3f4f6' } }, | |
| y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Precipitación (mm)', font: { size: 9, family: 'Plus Jakarta Sans' } } }, | |
| y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'DPV (kPa)', font: { size: 9, family: 'Plus Jakarta Sans' } }, grid: { drawOnChartArea: false } } | |
| }; | |
| } else { | |
| datasets = [ | |
| { label: 'Radiación Solar Directa (W/m²)', data: recs.map(r => r.solar_radiation_w_m2), borderColor: '#eab308', tension: 0.3, borderWidth: 2, yAxisID: 'y' }, | |
| { label: 'Velocidad de Viento Promedio (km/h)', data: recs.map(r => r.wind_speed_10m_km_h), borderColor: '#06b6d4', tension: 0.3, borderWidth: 1.5, yAxisID: 'y1' } | |
| ]; | |
| scalesConfig = { | |
| x: { grid: { color: '#f3f4f6' } }, | |
| y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Radiación Directa (W/m²)', font: { size: 9, family: 'Plus Jakarta Sans' } } }, | |
| y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Viento Promedio (km/h)', font: { size: 9, family: 'Plus Jakarta Sans' } }, grid: { drawOnChartArea: false } } | |
| }; | |
| } | |
| } else if (data.mode === 'diario') { | |
| const recs = data.records; | |
| if (currentWeatherVariable === 'temperatures') { | |
| datasets = [ | |
| { label: 'Temp Máxima Diario (°C)', data: recs.map(r => r.maxTemp), borderColor: '#dc2626', tension: 0.3, borderWidth: 2 }, | |
| { label: 'Temp Mínima Diario (°C)', data: recs.map(r => r.minTemp), borderColor: '#2563eb', tension: 0.3, borderWidth: 1.5 } | |
| ]; | |
| } else if (currentWeatherVariable === 'humidity') { | |
| datasets = [ | |
| { label: 'HR Máxima Diario (%)', data: recs.map(r => r.maxRH), borderColor: '#0d9488', tension: 0.3 }, | |
| { label: 'HR Mínima Diario (%)', data: recs.map(r => r.minRH), borderColor: '#06b6d4', tension: 0.3, borderDash: [3, 3] }, | |
| { label: 'Humedad Suelo Media (%)', data: recs.map(r => (r.meanSoil || 0) * 100), borderColor: '#2563eb', tension: 0.3 } | |
| ]; | |
| } else if (currentWeatherVariable === 'water') { | |
| datasets = [ | |
| { label: 'Precipitación Acumulada Diario (mm)', type: 'bar', data: recs.map(r => r.precip), backgroundColor: '#3b82f6', yAxisID: 'y' }, | |
| { label: 'DPV Promedio Diario (kPa)', data: recs.map(r => r.meanVPD), borderColor: '#0ea5e9', tension: 0.3, yAxisID: 'y1' } | |
| ]; | |
| scalesConfig = { | |
| x: { grid: { color: '#f3f4f6' } }, | |
| y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Precipitación (mm)', font: { size: 9, family: 'Plus Jakarta Sans' } } }, | |
| y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'DPV (kPa)', font: { size: 9, family: 'Plus Jakarta Sans' } }, grid: { drawOnChartArea: false } } | |
| }; | |
| } else { | |
| datasets = [ | |
| { label: 'Radiación Máxima Diaria (W/m²)', data: recs.map(r => r.maxRad), borderColor: '#eab308', tension: 0.3, yAxisID: 'y' }, | |
| { label: 'Viento Promedio Diario (km/h)', data: recs.map(r => /** @type {any} */ (r).meanWind), borderColor: '#06b6d4', tension: 0.3, yAxisID: 'y1' } | |
| ]; | |
| scalesConfig = { | |
| x: { grid: { color: '#f3f4f6' } }, | |
| y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Radiación Directa (W/m²)', font: { size: 9, family: 'Plus Jakarta Sans' } } }, | |
| y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Viento Promedio (km/h)', font: { size: 9, family: 'Plus Jakarta Sans' } }, grid: { drawOnChartArea: false } } | |
| }; | |
| } | |
| } else { | |
| // Semanal | |
| const recs = data.records; | |
| if (currentWeatherVariable === 'temperatures') { | |
| datasets = [ | |
| { label: 'Temp Máxima Promedio Semanal (°C)', data: recs.map(r => r.maxTemp), borderColor: '#dc2626', tension: 0.3, borderWidth: 2 }, | |
| { label: 'Temp Mínima Promedio Semanal (°C)', data: recs.map(r => r.minTemp), borderColor: '#2563eb', tension: 0.3, borderWidth: 1.5 } | |
| ]; | |
| } else if (currentWeatherVariable === 'humidity') { | |
| datasets = [ | |
| { label: 'HR Máxima Promedio Semanal (%)', data: recs.map(r => r.maxRH), borderColor: '#0d9488', tension: 0.3 }, | |
| { label: 'HR Mínima Promedio Semanal (%)', data: recs.map(r => r.minRH), borderColor: '#06b6d4', tension: 0.3, borderDash: [3, 3] }, | |
| { label: 'Humedad Suelo Semanal (%)', data: recs.map(r => (r.meanSoil || 0) * 100), borderColor: '#2563eb', tension: 0.3 } | |
| ]; | |
| } else if (currentWeatherVariable === 'water') { | |
| datasets = [ | |
| { label: 'Precipitación Total Semanal (mm)', type: 'bar', data: recs.map(r => r.precip), backgroundColor: '#3b82f6', yAxisID: 'y' }, | |
| { label: 'DPV Promedio Semanal (kPa)', data: recs.map(r => r.meanVPD), borderColor: '#0ea5e9', tension: 0.3, yAxisID: 'y1' } | |
| ]; | |
| scalesConfig = { | |
| x: { grid: { color: '#f3f4f6' } }, | |
| y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Precipitación (mm)', font: { size: 9, family: 'Plus Jakarta Sans' } } }, | |
| y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'DPV (kPa)', font: { size: 9, family: 'Plus Jakarta Sans' } }, grid: { drawOnChartArea: false } } | |
| }; | |
| } else { | |
| datasets = [ | |
| { label: 'Radiación Máxima Semanal (W/m²)', data: recs.map(r => r.maxRad), borderColor: '#eab308', tension: 0.3, yAxisID: 'y' }, | |
| { label: 'Viento Promedio Semanal (km/h)', data: recs.map(r => r.minWind), borderColor: '#06b6d4', tension: 0.3, yAxisID: 'y1' } // using minWind as a filler or let's use a mean of winds | |
| ]; | |
| scalesConfig = { | |
| x: { grid: { color: '#f3f4f6' } }, | |
| y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Radiación Directa (W/m²)', font: { size: 9, family: 'Plus Jakarta Sans' } } }, | |
| y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Viento Promedio (km/h)', font: { size: 9, family: 'Plus Jakarta Sans' } }, grid: { drawOnChartArea: false } } | |
| }; | |
| } | |
| } | |
| chartWeather = new Chart(ctx, { | |
| type: 'line', | |
| data: { labels, datasets }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| position: 'bottom', | |
| labels: { boxWidth: 12, font: { size: 10, family: 'Plus Jakarta Sans' } } | |
| } | |
| }, | |
| scales: scalesConfig | |
| } | |
| }); | |
| } | |
| function drawBottomWeatherChart() { | |
| const ctx = $("water-balance-chart")?.getContext('2d'); | |
| const titleEl = $("bottom-chart-title"); | |
| const subtitleEl = $("bottom-chart-subtitle"); | |
| if (!ctx) return; | |
| chartWaterBalance?.destroy(); | |
| // Calculations are strictly weekly, starting from the campaign start date | |
| const campaignStartMs = new Date(weatherCampaignStart).getTime(); | |
| const weeklyMap = new Map(); | |
| weatherRawData.forEach(r => { | |
| const recordMs = new Date(r.date).getTime(); | |
| const diffDays = Math.floor((recordMs - campaignStartMs) / (1000 * 60 * 60 * 24)); | |
| const weekIndex = Math.max(0, Math.floor(diffDays / 7)) + 1; | |
| if (!weeklyMap.has(weekIndex)) weeklyMap.set(weekIndex, []); | |
| weeklyMap.get(weekIndex).push(r); | |
| }); | |
| const sortedWeeks = Array.from(weeklyMap.keys()).sort((a, b) => a - b); | |
| const labelsSemanas = sortedWeeks.map(w => `Sem <sup></sup>${w}`); | |
| let datasets = []; | |
| let yTitleLeft = ''; | |
| let yTitleRight = ''; | |
| let showY1 = true; | |
| if (currentBottomChartFilter === 'water_balance') { | |
| if (titleEl) titleEl.innerHTML = '<i class="fa-solid fa-droplet text-blue-600"></i> Balance Hídrico Acumulado Semanal'; | |
| if (subtitleEl) subtitleEl.textContent = "Diferencia entre Precipitación (ingreso) y Evapotranspiración ET0 (pérdida)."; | |
| yTitleLeft = 'Semanal (mm)'; | |
| yTitleRight = 'Acumulado (mm)'; | |
| const et0s = []; | |
| const precips = []; | |
| const deficits = []; | |
| let accDeficit = 0; | |
| sortedWeeks.forEach(w => { | |
| const list = weeklyMap.get(w); | |
| const wET0 = list.reduce((s, r) => s + (r.et0_fao_evapotranspiration || 0), 0); | |
| const wPrecip = list.reduce((s, r) => s + (r.precip_mm || 0), 0); | |
| et0s.push(parseFloat(wET0.toFixed(1))); | |
| precips.push(parseFloat(wPrecip.toFixed(1))); | |
| const diff = wPrecip - wET0; | |
| accDeficit += diff; | |
| deficits.push(parseFloat(accDeficit.toFixed(1))); | |
| }); | |
| datasets = [ | |
| { | |
| type: 'line', | |
| label: 'Evapotranspiración FAO-56 (ET0 - mm)', | |
| data: et0s, | |
| borderColor: '#ea580c', | |
| borderWidth: 2, | |
| tension: 0.3, | |
| yAxisID: 'y' | |
| }, | |
| { | |
| label: 'Precipitación Semanal (mm)', | |
| data: precips, | |
| backgroundColor: '#3b82f6', | |
| borderRadius: 4, | |
| yAxisID: 'y' | |
| }, | |
| { | |
| type: 'line', | |
| label: 'Déficit Hídrico Acumulado (mm)', | |
| data: deficits, | |
| borderColor: '#dc2626', | |
| borderWidth: 2, | |
| fill: false, | |
| tension: 0.3, | |
| yAxisID: 'y1', | |
| borderDash: [3, 3] | |
| } | |
| ]; | |
| } else if (currentBottomChartFilter === 'gdd') { | |
| if (titleEl) titleEl.innerHTML = '<i class="fa-solid fa-fire-flame-simple text-orange-500"></i> Grados Día Acumulados (GDD)'; | |
| if (subtitleEl) subtitleEl.textContent = "Acumulación de calor por encima de la temperatura base de la variedad (Base 10°C)."; | |
| yTitleLeft = 'Semanal (GD)'; | |
| yTitleRight = 'Acumulado (GD)'; | |
| // Daily GDD is computed as max(0, (max_temp + min_temp)/2 - 10) | |
| // We group by day first to get correct GDD values | |
| const dailyGDDMap = new Map(); | |
| weatherRawData.forEach(r => { | |
| const day = r.date.split("T")[0]; | |
| if (!dailyGDDMap.has(day)) dailyGDDMap.set(day, []); | |
| dailyGDDMap.get(day).push(r.temp_c); | |
| }); | |
| const dailyGDDList = Array.from(dailyGDDMap.keys()).sort().map(day => { | |
| const temps = dailyGDDMap.get(day).filter(v => v !== null && v !== undefined); | |
| if (!temps.length) return { day, gdd: 0 }; | |
| const max = Math.max(...temps); | |
| const min = Math.min(...temps); | |
| const val = Math.max(0, ((max + min) / 2) - 10); | |
| return { day, gdd: val }; | |
| }); | |
| // Sum GDDs per week index | |
| const weeklyGDDSums = []; | |
| const weeklyGDDAccs = []; | |
| let accGDD = 0; | |
| sortedWeeks.forEach(w => { | |
| const list = weeklyMap.get(w); | |
| const weekDays = new Set(list.map(r => r.date.split("T")[0])); | |
| const wSum = dailyGDDList | |
| .filter(d => weekDays.has(d.day)) | |
| .reduce((s, d) => s + d.gdd, 0); | |
| weeklyGDDSums.push(parseFloat(wSum.toFixed(1))); | |
| accGDD += wSum; | |
| weeklyGDDAccs.push(parseFloat(accGDD.toFixed(1))); | |
| }); | |
| datasets = [ | |
| { | |
| label: 'Grados Día Semanales (GD)', | |
| data: weeklyGDDSums, | |
| backgroundColor: '#f97316', | |
| borderRadius: 4, | |
| yAxisID: 'y' | |
| }, | |
| { | |
| type: 'line', | |
| label: 'GDD Acumulados (GD)', | |
| data: weeklyGDDAccs, | |
| borderColor: '#dc2626', | |
| borderWidth: 2.5, | |
| tension: 0.2, | |
| yAxisID: 'y1' | |
| } | |
| ]; | |
| } else if (currentBottomChartFilter === 'chill_hours') { | |
| if (titleEl) titleEl.innerHTML = '<i class="fa-solid fa-snowflake text-blue-500"></i> Horas Frío Acumuladas'; | |
| if (subtitleEl) subtitleEl.textContent = "Suma de horas bajo el umbral crítico de vernalización (< 7.2°C) en la campaña."; | |
| yTitleLeft = 'Semanales (Horas)'; | |
| yTitleRight = 'Acumuladas (Horas)'; | |
| const weeklyChillSums = []; | |
| const weeklyChillAccs = []; | |
| let accChill = 0; | |
| sortedWeeks.forEach(w => { | |
| const list = weeklyMap.get(w); | |
| const wSum = list.filter(r => r.temp_c !== null && r.temp_c < 7.2).length; | |
| weeklyChillSums.push(wSum); | |
| accChill += wSum; | |
| weeklyChillAccs.push(accChill); | |
| }); | |
| datasets = [ | |
| { | |
| label: 'Horas Frío Semanales', | |
| data: weeklyChillSums, | |
| backgroundColor: '#60a5fa', | |
| borderRadius: 4, | |
| yAxisID: 'y' | |
| }, | |
| { | |
| type: 'line', | |
| label: 'Horas Frío Acumuladas', | |
| data: weeklyChillAccs, | |
| borderColor: '#4f46e5', | |
| borderWidth: 2.5, | |
| tension: 0.2, | |
| yAxisID: 'y1' | |
| } | |
| ]; | |
| } else if (currentBottomChartFilter === 'stress_hours') { | |
| if (titleEl) titleEl.innerHTML = '<i class="fa-solid fa-triangle-exclamation text-red-500"></i> Horas de Estrés de Campaña'; | |
| if (subtitleEl) subtitleEl.textContent = "Horas acumuladas semanales de estrés por calor (> 32°C) y déficit de presión de vapor (> 2.0 kPa)."; | |
| yTitleLeft = 'Horas de Estrés'; | |
| showY1 = false; | |
| const weeklyThermal = []; | |
| const weeklyDPV = []; | |
| sortedWeeks.forEach(w => { | |
| const list = weeklyMap.get(w); | |
| const wThermal = list.filter(r => r.temp_c !== null && r.temp_c > 32).length; | |
| const wDPV = list.filter(r => calculateVPD(r.temp_c, r.relative_humidity_percentage) > 2.0).length; | |
| weeklyThermal.push(wThermal); | |
| weeklyDPV.push(wDPV); | |
| }); | |
| datasets = [ | |
| { | |
| label: 'Estrés Térmico (> 32°C)', | |
| data: weeklyThermal, | |
| backgroundColor: '#ef4444', | |
| borderRadius: 4, | |
| yAxisID: 'y' | |
| }, | |
| { | |
| label: 'Estrés por DPV (> 2.0 kPa)', | |
| data: weeklyDPV, | |
| backgroundColor: '#f59e0b', | |
| borderRadius: 4, | |
| yAxisID: 'y' | |
| } | |
| ]; | |
| } | |
| const scalesConfig = { | |
| x: { grid: { color: '#f3f4f6' } }, | |
| y: { | |
| type: 'linear', | |
| display: true, | |
| position: 'left', | |
| title: { display: true, text: yTitleLeft, font: { size: 9, family: 'Plus Jakarta Sans' } }, | |
| grid: { color: '#f3f4f6' } | |
| } | |
| }; | |
| if (showY1) { | |
| scalesConfig.y1 = { | |
| type: 'linear', | |
| display: true, | |
| position: 'right', | |
| title: { display: true, text: yTitleRight, font: { size: 9, family: 'Plus Jakarta Sans' } }, | |
| grid: { drawOnChartArea: false } | |
| }; | |
| } | |
| chartWaterBalance = new Chart(ctx, { | |
| type: 'bar', | |
| data: { labels: labelsSemanas, datasets }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| plugins: { | |
| legend: { | |
| position: 'bottom', | |
| labels: { boxWidth: 10, font: { size: 9, family: 'Plus Jakarta Sans' } } | |
| } | |
| }, | |
| scales: scalesConfig | |
| } | |
| }); | |
| } | |
| async function loadWeather() { | |
| const fid = $("select-parcela").value; | |
| if (!fid) { | |
| $("weather-empty")?.classList.remove("hidden"); | |
| return; | |
| } | |
| const parcel = parcels.find((p) => p.id === fid); | |
| if (!parcel) { | |
| $("weather-empty")?.classList.remove("hidden"); | |
| return; | |
| } | |
| $("weather-empty")?.classList.add("hidden"); | |
| // Compute campaign start date: use parcel.plantation_date or default to Jan 1st of current year | |
| const plantationDate = parcel.plantation_date; | |
| if (plantationDate) { | |
| weatherCampaignStart = plantationDate.split("T")[0]; | |
| } else { | |
| const currentYear = new Date().getFullYear(); | |
| weatherCampaignStart = `${currentYear}-01-01`; | |
| } | |
| // Update Campaign Start UI element | |
| const startBadge = $("campaign-start-badge"); | |
| if (startBadge) { | |
| // Format start date nicely | |
| try { | |
| const parts = weatherCampaignStart.split("-"); | |
| startBadge.textContent = `${parts[2]}/${parts[1]}/${parts[0]}`; | |
| } catch { | |
| startBadge.textContent = weatherCampaignStart; | |
| } | |
| } | |
| const today = new Date().toISOString().split("T")[0]; | |
| try { | |
| const rW = await api("/weather", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...authHeaders() | |
| }, | |
| body: JSON.stringify({ | |
| field_id: fid, | |
| start: weatherCampaignStart, | |
| end: today, | |
| raw: true | |
| }) | |
| }); | |
| if (!rW.ok) { | |
| console.error("Failed to load weather data"); | |
| return; | |
| } | |
| const responseData = await rW.json(); | |
| const rawSeries = responseData.series || []; | |
| weatherRawData = rawSeries.map(r => ({ | |
| date: r.timestamp || "", | |
| temp_c: r.temperature_2m, | |
| relative_humidity_percentage: r.relative_humidity_2m, | |
| soil_temperature_0_to_7cm_c: r.soil_temperature_0_to_7cm, | |
| soil_moisture_0_to_7cm_m3_m3: r.soil_moisture_0_to_7cm, | |
| precip_mm: r.precipitation, | |
| solar_radiation_w_m2: r.shortwave_radiation, | |
| wind_speed_10m_km_h: r.wind_speed_10m, | |
| et0_fao_evapotranspiration: r.et0_fao_evapotranspiration, | |
| vapour_pressure_deficit: r.vapour_pressure_deficit | |
| })); | |
| if (!weatherRawData.length) { | |
| console.warn("No weather raw records returned"); | |
| return; | |
| } | |
| // Calculate Campaign Agronomic KPIs | |
| // GDD (Base 10°C): sum of daily max(0, (max_temp + min_temp)/2 - 10) | |
| const dailyTempsMap = new Map(); | |
| weatherRawData.forEach(r => { | |
| const day = r.date.split("T")[0]; | |
| if (!dailyTempsMap.has(day)) dailyTempsMap.set(day, []); | |
| dailyTempsMap.get(day).push(r.temp_c); | |
| }); | |
| let totalGDD = 0; | |
| dailyTempsMap.forEach((temps) => { | |
| const validTemps = temps.filter(v => v !== null && v !== undefined); | |
| if (validTemps.length) { | |
| const max = Math.max(...validTemps); | |
| const min = Math.min(...validTemps); | |
| totalGDD += Math.max(0, ((max + min) / 2) - 10); | |
| } | |
| }); | |
| // Chill hours: sum of hours where temp < 7.2°C | |
| const chillHours = weatherRawData.filter(r => r.temp_c !== null && r.temp_c < 7.2).length; | |
| // Heat stress hours: sum of hours where temp > 32°C | |
| const heatStressHours = weatherRawData.filter(r => r.temp_c !== null && r.temp_c > 32).length; | |
| // VPD stress hours: sum of hours where VPD > 2.0 kPa | |
| const vpdStressHours = weatherRawData.filter(r => { | |
| const vpd = calculateVPD(r.temp_c, r.relative_humidity_percentage); | |
| return vpd > 2.0; | |
| }).length; | |
| // Frost hours: sum of hours where temp < 0°C | |
| const frostHours = weatherRawData.filter(r => r.temp_c !== null && r.temp_c < 0).length; | |
| // ET0 accumulation: sum of et0_fao_evapotranspiration | |
| const et0Accum = weatherRawData.reduce((s, r) => s + (r.et0_fao_evapotranspiration || 0), 0); | |
| // Net Water Balance: sum of precip_mm - sum of et0_fao_evapotranspiration | |
| const totalPrecip = weatherRawData.reduce((s, r) => s + (r.precip_mm || 0), 0); | |
| const netWaterBalance = totalPrecip - et0Accum; | |
| // Bind Elements | |
| const bindText = (id, val) => { | |
| const el = $(id); | |
| if (el) el.textContent = val; | |
| }; | |
| bindText("val-gdd", `${totalGDD.toFixed(1)} GD`); | |
| bindText("val-chill", `${chillHours} horas`); | |
| bindText("val-thermal", `${heatStressHours} horas`); | |
| bindText("val-dpv", `${vpdStressHours} horas`); | |
| bindText("val-frost", `${frostHours} horas`); | |
| bindText("val-et0", `${et0Accum.toFixed(1)} mm`); | |
| // Update agronomic notes with actual balance deficit | |
| weatherNotesData.balance.text = `El déficit hídrico neto acumulado de esta campaña es de ${netWaterBalance.toFixed(1)} mm. Se aconseja ajustar la telemetría del sistema de riego por goteo para compensar la alta evapotranspiración de esta semana.`; | |
| // If active field changed, reset selectors to default or setup event listeners first time | |
| if (activeFieldId !== fid) { | |
| activeFieldId = fid; | |
| currentGranularity = 'horario'; | |
| currentWeatherVariable = 'temperatures'; | |
| currentBottomChartFilter = 'water_balance'; | |
| // Set defaults on selector elements | |
| const selectVar = $("weather-variable-select"); | |
| if (selectVar) selectVar.value = "temperatures"; | |
| const selectBottom = $("bottom-chart-filter-select"); | |
| if (selectBottom) selectBottom.value = "water_balance"; | |
| // Set historical date picker to default (latest date in the dataset) | |
| const datePicker = $("historical-date-picker"); | |
| if (datePicker && weatherRawData.length) { | |
| const dates = weatherRawData.map(r => r.date.split("T")[0]); | |
| const latestDate = dates.reduce((max, d) => d > max ? d : max, dates[0]); | |
| datePicker.value = latestDate; | |
| } | |
| // Bind listeners only once or when field is changed | |
| setupWeatherEventListeners(); | |
| // Set default active note | |
| updateAgronomicNote("balance"); | |
| } | |
| // Initial Render | |
| renderDynamicKPIs(); | |
| drawTopWeatherChart(); | |
| drawBottomWeatherChart(); | |
| track("chart_view", { chart: "weather_advanced", points: weatherRawData.length }); | |
| } catch (error) { | |
| console.error("Error in loadWeather: ", error); | |
| } | |
| } | |
| // ===== Análisis Cruzado (Correlación) ===== | |
| async function loadAnalysis() { | |
| const fid = $("select-parcela").value; | |
| if (!fid) { $("analysis-empty").classList.remove("hidden"); return; } | |
| const parcel = parcels.find((p) => p.id === fid); | |
| if (!parcel) { $("analysis-empty").classList.remove("hidden"); return; } | |
| $("analysis-empty").classList.add("hidden"); | |
| const rI = await api("/vegetation/ndvi", { method: "POST", body: JSON.stringify({ field_id: fid }) }); | |
| const series = rI.ok ? (await rI.json()).series : []; | |
| let weather = []; | |
| if (parcel.lat != null) { | |
| const rW = await api("/weather", { method: "POST", body: JSON.stringify({ field_id: fid }) }); | |
| weather = rW.ok ? (await rW.json()).series : []; | |
| } | |
| if (!series.length && !weather.length) { $("analysis-empty").classList.remove("hidden"); return; } | |
| const weatherMap = new Map(weather.map(w => [w.date, w])); | |
| const labels = series.map(s => s.date); | |
| const ndviData = series.map(s => s.mean_value); | |
| const tempMeanData = series.map(s => { | |
| const w = weatherMap.get(s.date); | |
| return w ? w.temp_mean_c : null; | |
| }); | |
| const precipData = series.map(s => { | |
| const w = weatherMap.get(s.date); | |
| return w ? w.precip_mm : null; | |
| }); | |
| const ctx = $("chart-analysis"); | |
| chartAnalysis?.destroy(); | |
| chartAnalysis = new Chart(ctx, { | |
| data: { | |
| labels: labels, | |
| datasets: [ | |
| { type: "line", label: "NDVI", data: ndviData, borderColor: "#15803d", tension: 0.3, yAxisID: "y" }, | |
| { type: "line", label: "Temp Media (°C)", data: tempMeanData, borderColor: "#f97316", tension: 0.3, yAxisID: "y2" }, | |
| { type: "bar", label: "Precipitación (mm)", data: precipData, backgroundColor: "rgba(59,130,246,0.4)", yAxisID: "y2" } | |
| ] | |
| }, | |
| options: { | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| scales: { | |
| y: { position: "left", min: 0, max: 1, title: { display: true, text: "NDVI" } }, | |
| y2: { position: "right", grid: { drawOnChartArea: false }, title: { display: true, text: "Clima (°C / mm)" } } | |
| } | |
| } | |
| }); | |
| track("chart_view", { chart: "analysis", points: series.length }); | |
| } | |
| // ===== Reprocesar Modal ===== | |
| function showReprocessModal(preselectIndex = null) { | |
| const fid = $("select-parcela").value; | |
| if (!fid) return notify("Selecciona una parcela", ""); | |
| const indices = getParcelIndices(fid); | |
| setCheckedIndices(".reprocess-index-checkbox", indices); | |
| if (VALID_INDICES.includes(preselectIndex)) { | |
| document.querySelectorAll(".reprocess-index-checkbox").forEach((checkbox) => { | |
| if (checkbox.value === preselectIndex) checkbox.checked = true; | |
| }); | |
| } | |
| $("reprocess-indices-error")?.classList.add("hidden"); | |
| $("reprocess-modal").classList.remove("hidden"); | |
| $("reprocess-modal").classList.add("flex"); | |
| $("reprocess-status").classList.add("hidden"); | |
| $("reprocess-status").innerText = ""; | |
| track("reprocess_modal_open", { field_id: fid, indices }); | |
| } | |
| function closeReprocessModal() { | |
| $("reprocess-modal").classList.add("hidden"); | |
| $("reprocess-modal").classList.remove("flex"); | |
| } | |
| $("btn-reprocess").addEventListener("click", () => showReprocessModal()); | |
| $("btn-close-reprocess").addEventListener("click", closeReprocessModal); | |
| $("btn-cancel-reprocess").addEventListener("click", closeReprocessModal); | |
| $("reprocess-modal").addEventListener("click", (e) => { if (e.target === e.currentTarget) closeReprocessModal(); }); | |
| $("btn-reprocess-select-all-indices").addEventListener("click", () => { | |
| setCheckedIndices(".reprocess-index-checkbox", VALID_INDICES); | |
| }); | |
| $("btn-run-reprocess").addEventListener("click", async () => { | |
| const fid = $("select-parcela").value; | |
| const mode = document.querySelector('input[name="reprocess-mode"]:checked')?.value || "all"; | |
| const selectedIndices = getCheckedIndices(".reprocess-index-checkbox"); | |
| const indicesError = $("reprocess-indices-error"); | |
| if (mode !== "weather" && !selectedIndices.length) { | |
| indicesError?.classList.remove("hidden"); | |
| return notify("Falta una variable satelital", "Marca al menos una variable para reprocesar."); | |
| } | |
| indicesError?.classList.add("hidden"); | |
| const btn = $("btn-run-reprocess"); | |
| btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Procesando…'; | |
| $("reprocess-status").classList.remove("hidden"); | |
| $("reprocess-status").innerText = "Reprocesando… esto puede tomar varios minutos."; | |
| const payload = { field_id: fid, mode }; | |
| if (mode !== "weather") payload.indices = selectedIndices; | |
| track("reprocess_start", { field_id: fid, mode, indices: selectedIndices }); | |
| try { | |
| const r = await api("/vegetation/reprocess", { method: "POST", body: JSON.stringify(payload) }); | |
| if (r.ok) { | |
| const data = await r.json(); | |
| const idxCount = data.indices?.length || 0; | |
| const totalPoints = data.indices?.reduce((s, i) => s + i.points, 0) || 0; | |
| const mergedIndices = mode === "weather" ? getParcelIndices(fid) : normalizeIndices([...getParcelIndices(fid), ...selectedIndices]); | |
| const indexSummary = mode === "weather" ? "" : ` (${formatIndices(selectedIndices)})`; | |
| setParcelIndices(fid, mergedIndices); | |
| syncSatelliteIndexControls(fid); | |
| $("reprocess-status").innerText = `Reproceso completado: ${idxCount} índices${indexSummary}, ${totalPoints} puntos.${data.weather ? " Clima actualizado." : ""}`; | |
| $("reprocess-status").className = "mt-3 text-xs text-emerald-600"; | |
| track("reprocess_done", { field_id: fid, mode, indices: idxCount, points: totalPoints, selected_indices: selectedIndices }); | |
| setTimeout(async () => { | |
| closeReprocessModal(); | |
| await refreshParcels(); | |
| setParcelIndices(fid, mergedIndices); | |
| syncSatelliteIndexControls(fid); | |
| await loadFieldAttributes(fid); | |
| if (document.querySelector(".tab-content.active")?.id === "tab-satelite") await loadTeledeteccion(); | |
| }, 1500); | |
| } else { | |
| const err = await r.json().catch(() => ({})); | |
| $("reprocess-status").innerText = "Error: " + (err.detail || r.status); | |
| $("reprocess-status").className = "mt-3 text-xs text-red-600"; | |
| track("reprocess_error", { field_id: fid, mode, status: r.status }); | |
| } | |
| } catch (e) { | |
| $("reprocess-status").innerText = "Error de red: " + e; | |
| $("reprocess-status").className = "mt-3 text-xs text-red-600"; | |
| } finally { | |
| btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-play"></i> Ejecutar'; | |
| } | |
| }); | |
| // ===== Chat ===== | |
| function appendChat(who, text, tools) { | |
| const div = document.createElement("div"); | |
| const mine = who === "user"; | |
| div.className = "p-3 rounded border max-w-[85%] " + (mine ? "bg-white border-stone-200 ml-auto text-right" : "bg-stone-900 text-stone-100 border-stone-800 text-left"); | |
| div.innerHTML = `<p class="font-bold text-[9px] uppercase mb-1 ${mine ? "text-stone-500" : "text-emerald-400"}">${mine ? "🧑 Tú" : "🤖 Asistente"}${tools && tools.length ? " · " + tools.join(", ") : ""}</p><p class="leading-relaxed whitespace-pre-wrap">${text}</p>`; | |
| $("chat-history").appendChild(div); $("chat-history").scrollTop = 1e9; | |
| } | |
| async function loadChatHistory() { | |
| const historyEl = $("chat-history"); | |
| historyEl.innerHTML = '<span class="text-xs text-stone-500">Cargando historial...</span>'; | |
| const fid = $("select-parcela")?.value || ""; | |
| try { | |
| let url = "/chat/history/" + sessionId; | |
| if (fid) url += "?field_id=" + fid; | |
| const r = await api(url); | |
| if (r.ok) { | |
| const data = await r.json(); | |
| historyEl.innerHTML = ""; | |
| if (data.length === 0) { | |
| historyEl.innerHTML = `<p class="text-stone-500">Pregúntame sobre el NDVI, el clima o la densidad de tus parcelas.</p>`; | |
| } else { | |
| data.forEach((msg) => { | |
| appendChat(msg.role, msg.content); | |
| }); | |
| } | |
| } else { | |
| historyEl.innerHTML = '<p class="text-stone-500">Error al cargar historial.</p>'; | |
| } | |
| } catch (e) { | |
| historyEl.innerHTML = `<p class="text-stone-500">Error de red al cargar historial: ${e}</p>`; | |
| } | |
| } | |
| async function sendChat() { | |
| const msg = $("chat-input").value.trim(); if (!msg) return; | |
| const fid = $("select-parcela")?.value || null; | |
| track("chat_send", { len: msg.length, field_id: fid }); | |
| appendChat("user", msg); $("chat-input").value = ""; | |
| try { | |
| const r = await api("/chat", { method: "POST", body: JSON.stringify({ session_id: sessionId, message: msg, field_id: fid }) }); | |
| if (r.ok) { const d = await r.json(); appendChat("assistant", d.reply, (d.tool_logs || []).map((t) => t.tool)); } | |
| else { const e = await r.json().catch(() => ({})); appendChat("assistant", "Error: " + (e.detail || r.status)); } | |
| } catch (e) { appendChat("assistant", "Error de red: " + e); } | |
| } | |
| $("btn-chat").addEventListener("click", sendChat); | |
| $("chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter") sendChat(); }); | |
| // ===== Telemetría Modal ===== | |
| function openTelemetryModal() { | |
| $("telemetry-modal").classList.remove("hidden"); | |
| $("telemetry-modal").classList.add("flex"); | |
| $("telemetry-session-id").innerText = sessionId; | |
| loadTelemetryEvents(); | |
| track("telemetry_open", {}); | |
| } | |
| function closeTelemetryModal() { | |
| $("telemetry-modal").classList.add("hidden"); | |
| $("telemetry-modal").classList.remove("flex"); | |
| } | |
| async function loadTelemetryEvents() { | |
| const content = $("telemetry-content"); | |
| content.innerHTML = '<p class="text-xs text-stone-500">Cargando eventos...</p>'; | |
| try { | |
| let events = []; | |
| try { | |
| const r = await api("/events/all?limit=200"); | |
| if (r.ok) { | |
| events = await r.json(); | |
| } | |
| } catch {} | |
| if (!events || events.length === 0) { | |
| try { | |
| const r2 = await api("/events/recent?limit=200"); | |
| if (r2.ok) { | |
| events = await r2.json(); | |
| } | |
| } catch {} | |
| } | |
| if (!events || events.length === 0) { | |
| content.innerHTML = '<p class="text-xs text-stone-500">Sin eventos en esta sesión.</p>'; | |
| return; | |
| } | |
| renderTelemetryEvents(events, content); | |
| } catch (e) { | |
| content.innerHTML = `<p class="text-xs text-red-500">Error al cargar eventos: ${e}</p>`; | |
| } | |
| } | |
| function renderTelemetryEvents(events, container) { | |
| const html = events.map((ev) => { | |
| const ts = ev.created_at || ev.ts || ""; | |
| const time = ts ? new Date(ts).toLocaleTimeString() : ""; | |
| const isError = ev.action.includes("error"); | |
| const bg = isError ? "bg-red-50 border-red-200" : "bg-stone-50 border-stone-200"; | |
| const icon = isError ? "fa-circle-exclamation text-red-500" : "fa-circle-dot text-emerald-500"; | |
| const metaStr = ev.meta && Object.keys(ev.meta).length ? JSON.stringify(ev.meta).slice(0, 120) : ""; | |
| return `<div class="border ${bg} rounded-lg px-3 py-2 text-[10px]"> | |
| <div class="flex items-center gap-2 mb-1"> | |
| <i class="fa-solid ${icon}"></i> | |
| <span class="font-bold text-stone-700">${ev.action}</span> | |
| <span class="text-stone-400 ml-auto">${time}</span> | |
| </div> | |
| ${metaStr ? `<div class="text-stone-500 font-mono truncate">${metaStr}</div>` : ""} | |
| </div>`; | |
| }).join(""); | |
| container.innerHTML = html; | |
| } | |
| $("btn-telemetry").addEventListener("click", openTelemetryModal); | |
| $("btn-close-telemetry").addEventListener("click", closeTelemetryModal); | |
| $("btn-refresh-telemetry").addEventListener("click", loadTelemetryEvents); | |
| $("telemetry-modal").addEventListener("click", (e) => { if (e.target === $("telemetry-modal")) closeTelemetryModal(); }); | |
| // ===== Explorador de Datos ===== | |
| const TABLES = ["fields", "vegetation_indices", "weather_data", "chat_messages", "events", "plant_counts"]; | |
| async function loadDataExplorer() { | |
| loadSchema(); | |
| const list = $("tables-list"); | |
| list.innerHTML = TABLES.map((t) => | |
| `<button data-table="${t}" class="w-full text-left text-xs px-3 py-2 rounded-lg border border-stone-200 bg-stone-50 hover:bg-stone-100 font-semibold text-stone-700"><i class="fa-solid fa-table mr-1 text-emerald-600"></i>${t}</button>` | |
| ).join(""); | |
| list.querySelectorAll("button").forEach((b) => | |
| b.addEventListener("click", () => loadTableData(b.dataset.table)) | |
| ); | |
| } | |
| async function loadSchema() { | |
| const el = $("er-diagram"); | |
| try { | |
| const r = await api("/data/schema"); | |
| if (!r.ok) { el.innerHTML = '<p class="text-xs text-stone-500">Error al cargar esquema.</p>'; return; } | |
| const d = await r.json(); | |
| const tables = d.tables || {}; | |
| const shortType = (t) => { | |
| if (t.includes("timestamp")) return "timestamptz"; | |
| if (t.includes("double")) return "float"; | |
| if (t === "USER-DEFINED") return "geometry"; | |
| return t; | |
| }; | |
| let mmd = "erDiagram\n"; | |
| for (const [name, cols] of Object.entries(tables)) { | |
| mmd += ` ${name} {\n`; | |
| for (const c of cols) { | |
| const ann = c.name === "id" ? " PK" : c.name.endsWith("_id") ? " FK" : ""; | |
| mmd += ` ${shortType(c.type)} ${c.name}${ann}\n`; | |
| } | |
| mmd += " }\n"; | |
| } | |
| mmd += ' fields ||--o{ vegetation_indices : "tiene"\n'; | |
| mmd += ' fields ||--o{ weather_data : "registra clima"\n'; | |
| mmd += ' fields ||--o{ plant_counts : "tiene"\n'; | |
| mmd += ' fields ||--o{ events : "genera"\n'; | |
| mmd += ' fields ||--o{ chat_messages : "genera"\n'; | |
| const id = "er-" + Date.now(); | |
| const { svg } = await mermaid.render(id, mmd); | |
| el.innerHTML = svg; | |
| } catch (e) { | |
| el.innerHTML = '<p class="text-xs text-red-500">Error al renderizar diagrama ER: ' + String(e?.message || e) + '</p>'; | |
| } | |
| } | |
| async function loadTableData(table) { | |
| $("data-view-title").innerText = `Tabla: ${table}`; | |
| $("data-view-content").innerHTML = '<p class="text-xs text-stone-500">Cargando...</p>'; | |
| try { | |
| const r = await api(`/data/${table}`); | |
| if (!r.ok) { $("data-view-content").innerHTML = '<p class="text-xs text-red-500">Error al cargar. Verifica credenciales.</p>'; return; } | |
| const d = await r.json(); | |
| const rows = d.rows || []; | |
| $("data-view-count").innerText = `${rows.length} registros`; | |
| if (!rows.length) { $("data-view-content").innerHTML = '<p class="text-xs text-stone-500">Sin registros.</p>'; return; } | |
| const cols = Object.keys(rows[0]); | |
| const html = `<table class="w-full text-[10px] border-collapse"><thead><tr>${cols.map((c) => `<th class="border border-stone-200 px-2 py-1 bg-stone-100 font-bold text-stone-700">${c}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${cols.map((c) => `<td class="border border-stone-200 px-2 py-1">${formatCell(row[c])}</td>`).join("")}</tr>`).join("")}</tbody></table>`; | |
| $("data-view-content").innerHTML = html; | |
| } catch (e) { $("data-view-content").innerHTML = `<p class="text-xs text-red-500">Error: ${e}</p>`; } | |
| } | |
| function formatCell(v) { | |
| if (v === null || v === undefined) return '<span class="text-stone-400">NULL</span>'; | |
| if (typeof v === "object") return `<code class="text-[9px] bg-stone-100 px-1 rounded">${JSON.stringify(v).slice(0, 80)}</code>`; | |
| return String(v).slice(0, 100); | |
| } | |
| $("btn-run-query").addEventListener("click", async () => { | |
| const sql = $("sql-query").value.trim(); | |
| if (!sql) return notify("Falta la consulta", "Escribe una consulta SQL."); | |
| $("query-result").innerHTML = '<p class="text-xs text-stone-500">Ejecutando...</p>'; | |
| try { | |
| const r = await api("/data/query", { method: "POST", body: JSON.stringify({ sql }) }); | |
| if (!r.ok) { const e = await r.json().catch(() => ({})); $("query-result").innerHTML = `<p class="text-xs text-red-500">Error: ${e.detail || r.status}</p>`; return; } | |
| const d = await r.json(); | |
| const rows = d.rows || []; | |
| if (!rows.length) { $("query-result").innerHTML = '<p class="text-xs text-stone-500">Sin registros.</p>'; return; } | |
| const cols = Object.keys(rows[0]); | |
| const html = `<table class="w-full text-[10px] border-collapse"><thead><tr>${cols.map((c) => `<th class="border border-stone-200 px-2 py-1 bg-stone-100 font-bold text-stone-700">${c}</th>`).join("")}</tr></thead><tbody>${rows.map((row) => `<tr>${cols.map((c) => `<td class="border border-stone-200 px-2 py-1">${formatCell(row[c])}</td>`).join("")}</tr>`).join("")}</tbody></table>`; | |
| $("query-result").innerHTML = html; | |
| } catch (e) { $("query-result").innerHTML = `<p class="text-xs text-red-500">Error: ${e}</p>`; } | |
| }); | |
| $("btn-clear-query").addEventListener("click", () => { $("sql-query").value = ""; $("query-result").innerHTML = ""; }); | |
| // ===== Credenciales ===== | |
| async function connectUserDatabase() { | |
| if (!creds.dbUrl) return null; | |
| track("connect_db_attempt", {}); | |
| try { | |
| const r = await fetch("./api/credentials/connect-db", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", ...authHeaders() }, | |
| body: JSON.stringify({ database_url: creds.dbUrl }) | |
| }); | |
| if (!r.ok) { | |
| const e = await r.json().catch(() => ({})); | |
| track("connect_db_error", { status: r.status }); | |
| notify( | |
| r.status === 404 ? "Auto-conexión no disponible" : "Error al conectar la base de datos", | |
| r.status === 404 | |
| ? "El backend todavía no expone connect-db (pendiente Fase 9)." | |
| : (e.detail || `Error ${r.status} al conectar con tu Supabase.`) | |
| ); | |
| return null; | |
| } | |
| const data = await r.json(); | |
| const migraciones = Array.isArray(data.migrations_applied) ? data.migrations_applied.length : 0; | |
| if (migraciones > 0) { | |
| notify("Esquema inicializado", `Se creó la arquitectura PostGIS automáticamente (${migraciones} migración(es) aplicadas).`); | |
| } else if ((data.existing_parcels ?? 0) > 0) { | |
| notify("Base de datos conectada", `Se detectaron ${data.existing_parcels} parcela(s) existentes en tu Supabase.`); | |
| } else { | |
| notify("Base de datos conectada", "Tu Supabase se conectó correctamente."); | |
| } | |
| track("connect_db_success", { migraciones, existing_parcels: data.existing_parcels ?? 0 }); | |
| updateStatus(); | |
| return data; | |
| } catch (e) { | |
| track("connect_db_error", { error: String(e) }); | |
| notify("Error de red", `No se pudo conectar a tu base de datos: ${e}`); | |
| return null; | |
| } | |
| } | |
| $("btn-save-creds").addEventListener("click", async () => { | |
| creds = { groq: $("in-groq").value, copId: $("in-copid").value, copSecret: $("in-copsecret").value, sbUrl: $("in-sburl").value, sbKey: $("in-sbkey").value, dbUrl: $("cred-db-url").value }; | |
| const n = Object.values(creds).filter(Boolean).length; | |
| track("creds_set", { count: n }); | |
| $("creds-state").innerText = `${n} llave(s) activas (se borran al refrescar)`; | |
| notify("Credenciales en sesión", `${n} llave(s) activas solo en esta sesión.`); | |
| updateStatus(); | |
| checkMissingCredentials(); | |
| await connectUserDatabase(); | |
| await refreshParcels(); | |
| await connectUserProfile(); | |
| }); | |
| $("btn-check-env").addEventListener("click", async () => { | |
| try { | |
| const r = await fetch("./api/credentials/status"); | |
| if (!r.ok) return notify("No disponible", "No se pudo consultar el .env del servidor."); | |
| const s = await r.json(); | |
| appEnv = s.env || "development"; | |
| const have = Object.entries({ Groq: s.groq, Copernicus: s.copernicus, Supabase: s.supabase }).filter(([, v]) => v).map(([k]) => k); | |
| track("env_check", { have, env: appEnv }); | |
| if (appEnv === "production") { | |
| $("env-status").innerHTML = " <b>Producción:</b> cada usuario debe ingresar sus propias credenciales."; | |
| } else { | |
| $("env-status").innerHTML = have.length | |
| ? `✅ En el servidor (.env): <b>${have.join(", ")}</b>. Se usan automáticamente; no necesitas reescribirlas aquí.` | |
| : "No se detectaron credenciales en el .env del servidor."; | |
| } | |
| } catch { notify("Error", "No se pudo consultar el .env del servidor."); } | |
| }); | |
| // ===== Perfiles y Modos de Sesión ===== | |
| $("btn-profile-menu").addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| $("profile-dropdown").classList.toggle("hidden"); | |
| }); | |
| document.addEventListener("click", () => { | |
| $("profile-dropdown").classList.add("hidden"); | |
| }); | |
| $("profile-dropdown").addEventListener("click", (e) => { | |
| e.stopPropagation(); | |
| }); | |
| document.querySelectorAll(".dropdown-btn").forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| $("profile-dropdown").classList.add("hidden"); | |
| switchTab(btn.dataset.action); | |
| }); | |
| }); | |
| $("dropdown-telemetry").addEventListener("click", () => { | |
| $("profile-dropdown").classList.add("hidden"); | |
| openTelemetryModal(); | |
| }); | |
| $("dropdown-logout").addEventListener("click", () => { | |
| $("profile-dropdown").classList.add("hidden"); | |
| logout(); | |
| }); | |
| $("btn-save-settings").addEventListener("click", async () => { | |
| if (isSharedMode) return notify("Modo compartido", "No puedes editar ajustes en modo de solo lectura."); | |
| const displayName = $("settings-display-name").value.trim(); | |
| const sessionMode = $("settings-session-mode").value; | |
| const cfgDbUrl = ($("cfg-db-url")?.value || "").trim(); | |
| if (cfgDbUrl) { | |
| creds.dbUrl = cfgDbUrl; | |
| updateStatus(); | |
| } | |
| if (!displayName) return notify("Falta el nombre", "Escribe un nombre para el perfil."); | |
| if (!profileId) return notify("Error", "No hay un perfil conectado."); | |
| try { | |
| track("profile_update_attempt", { displayName, sessionMode }); | |
| const r = await api(`/profiles/${profileId}`, { | |
| method: "PATCH", | |
| body: JSON.stringify({ | |
| display_name: displayName, | |
| session_mode: sessionMode | |
| }) | |
| }); | |
| if (r.ok) { | |
| currentProfile = await r.json(); | |
| notify("Ajustes guardados", "El perfil se ha actualizado correctamente."); | |
| updateProfileUI(); | |
| if (sessionMode === "saved") { | |
| localStorage.setItem("agrovision_profile_id", profileId); | |
| } else { | |
| localStorage.removeItem("agrovision_profile_id"); | |
| } | |
| track("profile_update_success", { displayName, sessionMode }); | |
| if (creds.dbUrl) { | |
| await connectUserDatabase(); | |
| await refreshParcels(); | |
| } | |
| } else { | |
| const e = await r.json().catch(() => ({})); | |
| notify("Error " + r.status, e.detail || "No se pudo actualizar el perfil."); | |
| } | |
| } catch (e) { | |
| notify("Error de red", String(e)); | |
| } | |
| }); | |
| $("btn-delete-profile").addEventListener("click", async () => { | |
| if (isSharedMode) return notify("Modo compartido", "No puedes eliminar perfiles en modo de solo lectura."); | |
| if (!profileId) return; | |
| if (!confirm("¿Estás seguro de que deseas eliminar permanentemente tu perfil? Se perderán todas tus preferencias.")) return; | |
| try { | |
| track("profile_delete_attempt", { profileId }); | |
| const r = await api(`/profiles/${profileId}`, { method: "DELETE" }); | |
| if (r.ok) { | |
| track("profile_delete_success", { profileId }); | |
| notify("Perfil eliminado", "Tu perfil ha sido eliminado. Volviendo a sesión efímera."); | |
| logout(); | |
| } else { | |
| const e = await r.json().catch(() => ({})); | |
| notify("Error " + r.status, e.detail || "No se pudo eliminar el perfil."); | |
| } | |
| } catch (e) { | |
| notify("Error de red", String(e)); | |
| } | |
| }); | |
| function logout() { | |
| track("session_logout", { profileId }); | |
| localStorage.removeItem("agrovision_profile_id"); | |
| profileId = null; | |
| currentProfile = null; | |
| creds = {}; | |
| $("in-groq").value = ""; | |
| $("in-copid").value = ""; | |
| $("in-copsecret").value = ""; | |
| $("in-sburl").value = ""; | |
| $("in-sbkey").value = ""; | |
| $("cred-db-url").value = ""; | |
| $("cfg-db-url").value = ""; | |
| $("creds-state").innerText = "0 llaves activas (se borran al refrescar)"; | |
| $("settings-display-name").value = "Agrónomo"; | |
| $("settings-session-mode").value = "ephemeral"; | |
| updateProfileUI(); | |
| updateStatus(); | |
| checkMissingCredentials(); | |
| refreshParcels(); | |
| switchTab("tab-resumen"); | |
| notify("Sesión cerrada", "Se han limpiado todas las llaves y preferencias."); | |
| } | |
| async function connectUserProfile() { | |
| if (isSharedMode) return; | |
| let sbUrl = creds.sbUrl || creds.dbUrl; | |
| if (!sbUrl) { | |
| try { | |
| const r = await fetch("./api/credentials/status", { headers: authHeaders() }); | |
| if (r.ok) { | |
| const s = await r.json(); | |
| if (s.supabase_url) sbUrl = s.supabase_url; | |
| } | |
| } catch {} | |
| } | |
| if (!sbUrl) { | |
| profileId = null; | |
| currentProfile = null; | |
| updateProfileUI(); | |
| return; | |
| } | |
| try { | |
| fingerprint = await sha256(sbUrl); | |
| track("profile_connect_attempt", { hash: fingerprint }); | |
| const r = await fetch("./api/profiles/connect", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ supabase_url_hash: fingerprint, display_name: $("settings-display-name").value || "Agrónomo" }) | |
| }); | |
| if (r.ok) { | |
| currentProfile = await r.json(); | |
| profileId = currentProfile.id; | |
| updateProfileUI(); | |
| if (currentProfile.active_field_id) { | |
| localStorage.setItem("agrovision_last_field_id", currentProfile.active_field_id); | |
| } | |
| if (currentProfile.session_mode === "saved") { | |
| localStorage.setItem("agrovision_profile_id", profileId); | |
| } else { | |
| localStorage.removeItem("agrovision_profile_id"); | |
| } | |
| track("profile_connect_success", { profileId, mode: currentProfile.session_mode }); | |
| } | |
| } catch (e) { | |
| track("profile_connect_error", { error: String(e) }); | |
| } | |
| } | |
| async function loadSharedProfile(id) { | |
| isSharedMode = true; | |
| profileId = id; | |
| track("shared_mode_start", { profileId }); | |
| document.body.classList.add("shared-read-only"); | |
| $("btn-save-parcela").disabled = true; | |
| $("btn-save-parcela").classList.add("opacity-50", "cursor-not-allowed"); | |
| $("btn-demo").style.display = "none"; | |
| $("btn-save-creds").style.display = "none"; | |
| $("btn-save-settings").style.display = "none"; | |
| $("btn-delete-profile").style.display = "none"; | |
| const droneBtn = document.querySelector('[data-tab="tab-vision"]'); | |
| if (droneBtn) droneBtn.style.display = "none"; | |
| const settingsBtn = document.querySelector('[data-action="tab-settings"]'); | |
| if (settingsBtn) settingsBtn.style.display = "none"; | |
| const logoutBtn = $("dropdown-logout"); | |
| if (logoutBtn) logoutBtn.style.display = "none"; | |
| $("btn-telemetry").style.display = "none"; | |
| try { | |
| const r = await fetch(`./api/profiles/${id}`); | |
| if (r.ok) { | |
| currentProfile = await r.json(); | |
| updateProfileUI(); | |
| notify("Modo compartido", `Visualizando perfil de: ${currentProfile.display_name}`); | |
| } else { | |
| notify("Error", "No se pudo cargar el perfil compartido."); | |
| } | |
| } catch (e) { | |
| notify("Error de red", String(e)); | |
| } | |
| } | |
| function updateProfileUI() { | |
| if (currentProfile) { | |
| $("user-display-name").innerText = currentProfile.display_name; | |
| $("avatar-circle").innerText = currentProfile.display_name.slice(0, 2).toUpperCase(); | |
| $("settings-display-name").value = currentProfile.display_name; | |
| $("settings-session-mode").value = currentProfile.session_mode; | |
| let modeText = "Sesión efímera (BYOK)"; | |
| if (isSharedMode) { | |
| modeText = "Modo compartido (Lectura)"; | |
| } else if (currentProfile.session_mode === "saved") { | |
| modeText = "Modo guardado temporal"; | |
| } | |
| $("user-session-mode").innerText = modeText; | |
| // Sincronizar con widget flotante | |
| if ($("floating-user-name")) $("floating-user-name").innerText = currentProfile.display_name; | |
| if ($("floating-avatar")) $("floating-avatar").innerText = currentProfile.display_name.slice(0, 2).toUpperCase(); | |
| if ($("floating-user-email")) { | |
| $("floating-user-email").innerText = currentProfile.display_name.toLowerCase().replace(/\s+/g, "") === "alexprieto" | |
| ? "alexprieto1997@gmail.com" | |
| : `${currentProfile.display_name.toLowerCase().replace(/\s+/g, ".")}@agrovision.com`; | |
| } | |
| } else { | |
| $("user-display-name").innerText = "Agrónomo"; | |
| $("avatar-circle").innerText = "AG"; | |
| $("settings-display-name").value = "Agrónomo"; | |
| $("settings-session-mode").value = "ephemeral"; | |
| $("user-session-mode").innerText = "Sesión efímera (BYOK)"; | |
| // Valores por defecto para widget flotante | |
| if ($("floating-user-name")) $("floating-user-name").innerText = "Agrónomo"; | |
| if ($("floating-avatar")) $("floating-avatar").innerText = "AG"; | |
| if ($("floating-user-email")) $("floating-user-email").innerText = "alexprieto1997@gmail.com"; | |
| } | |
| } | |
| async function checkMissingCredentials() { | |
| try { | |
| const r = await fetch("./api/credentials/status", { headers: authHeaders() }); | |
| if (!r.ok) return; | |
| const s = await r.json(); | |
| appEnv = s.env || "development"; | |
| if (appEnv === "production") { | |
| const missing = []; | |
| const hasGroq = creds.groq || s.groq; | |
| const hasCop = (creds.copId && creds.copSecret) || s.copernicus; | |
| const hasSb = Boolean(creds.dbUrl || (creds.sbUrl && creds.sbKey) || s.supabase); | |
| if (!hasGroq) missing.push("Groq API Key"); | |
| if (!hasCop) missing.push("Copernicus Credentials"); | |
| if (!hasSb) missing.push("Supabase Connection"); | |
| const toast = $("creds-toast-warning"); | |
| if (missing.length > 0) { | |
| toast.innerHTML = `<div class="bg-amber-500 text-stone-950 p-4 rounded-xl shadow-2xl border border-amber-400 flex flex-col gap-2"> | |
| <div class="flex items-center gap-2"> | |
| <i class="fa-solid fa-triangle-exclamation text-lg"></i> | |
| <span class="font-bold text-xs">Faltan credenciales críticas:</span> | |
| </div> | |
| <p class="text-[10px] text-stone-900 leading-tight">Para la funcionalidad completa necesitas configurar: ${missing.join(", ")}.</p> | |
| <button onclick="switchTab('tab-config')" class="mt-1 bg-stone-900 text-amber-400 font-bold text-[10px] px-3 py-1.5 rounded-lg border border-stone-800 hover:bg-stone-800 transition-all"> | |
| Configurar Credenciales | |
| </button> | |
| </div>`; | |
| toast.classList.remove("hidden"); | |
| } else { | |
| toast.classList.add("hidden"); | |
| } | |
| } | |
| } catch {} | |
| } | |
| // ===== Sincronización del Selector de Parcela ===== | |
| $("select-parcela").addEventListener("change", async () => { | |
| const fid = $("select-parcela").value; | |
| if (fid) { | |
| localStorage.setItem("agrovision_last_field_id", fid); | |
| loadFieldAttributes(fid); | |
| syncSatelliteIndexControls(fid); | |
| if (profileId && !isSharedMode) { | |
| try { | |
| await api(`/profiles/${profileId}`, { | |
| method: "PATCH", | |
| body: JSON.stringify({ active_field_id: fid }) | |
| }); | |
| } catch {} | |
| } | |
| } else { | |
| clearFieldAttributes(); | |
| } | |
| const active = document.querySelector(".tab-content.active")?.id; | |
| if (active === "tab-satelite") loadTeledeteccion(); | |
| else if (active === "tab-weather") loadWeather(); | |
| else if (active === "tab-analysis") loadAnalysis(); | |
| else loadResumen(); | |
| }); | |
| (async function init() { | |
| track("session_start", { w: window.innerWidth, h: window.innerHeight }); | |
| const hashParams = new URLSearchParams(location.hash.slice(1)); | |
| const sharedProfileId = hashParams.get("profile"); | |
| try { | |
| const r = await fetch("./api/status"); | |
| backendOk = r.ok; | |
| $("db-status-text").innerText = backendOk ? "Backend conectado" : "Backend no disponible"; | |
| if (backendOk) { $("db-status-text").classList.replace("text-stone-400", "text-emerald-400"); } | |
| } catch { $("db-status-text").innerText = "Backend no disponible"; } | |
| try { | |
| const r = await fetch("./api/credentials/status"); | |
| if (r.ok) { | |
| const s = await r.json(); | |
| appEnv = s.env || "development"; | |
| if (appEnv === "production") { | |
| $("creds-production-warning").classList.remove("hidden"); | |
| $("btn-check-env").style.display = "none"; | |
| } | |
| } | |
| } catch {} | |
| if (sharedProfileId) { | |
| await loadSharedProfile(sharedProfileId); | |
| } else { | |
| const savedProfileId = localStorage.getItem("agrovision_profile_id"); | |
| if (savedProfileId) { | |
| try { | |
| const r = await fetch(`./api/profiles/${savedProfileId}`); | |
| if (r.ok) { | |
| currentProfile = await r.json(); | |
| profileId = currentProfile.id; | |
| updateProfileUI(); | |
| track("profile_reconnect_success", { profileId }); | |
| } else { | |
| localStorage.removeItem("agrovision_profile_id"); | |
| } | |
| } catch (e) { | |
| track("profile_reconnect_error", { error: String(e) }); | |
| } | |
| } else { | |
| await connectUserProfile(); | |
| } | |
| } | |
| updateStatus(); | |
| checkMissingCredentials(); | |
| await refreshParcels(); | |
| const lastFieldId = localStorage.getItem("agrovision_last_field_id") || (currentProfile ? currentProfile.active_field_id : null); | |
| if (lastFieldId && $("select-parcela").querySelector(`option[value="${lastFieldId}"]`)) { | |
| $("select-parcela").value = lastFieldId; | |
| loadFieldAttributes(lastFieldId); | |
| } | |
| const initialFieldId = $("select-parcela").value; | |
| if (initialFieldId) syncSatelliteIndexControls(initialFieldId); | |
| let initial = "tab-resumen"; | |
| if (location.hash && location.hash.startsWith("#tab-")) { | |
| initial = location.hash.slice(1); | |
| } | |
| switchTab(document.getElementById(initial) ? initial : "tab-resumen"); | |
| })(); | |