🌿 NN Flora Suite
Cinco herramientas conectadas de forma segura con NN Flora Pro.
🩺 Doctor Visual
Usa una fotografía clara de la planta, sus hojas y su tallo.
Selecciona una fotografía.
Planta:
Estado:
Riego:
Luz:
Temperatura:
Abono:
Maceta:
Poda:
Plagas:
Señales observadas:
Plan recomendado:
Observación visual:
🔍 Identificador Visual
Fotografía hojas, tallo, flores o frutos con buena iluminación.
Selecciona una fotografía.
Nombre:
Nombre científico:
Familia y tipo:
Confianza orientativa:
Descripción:
Características visibles:
Otras posibilidades:
Origen:
Hábitat:
Calidad de la imagen:
🪴 Cuidados
Luz:
Riego:
Suelo:
Temperatura:
Floración:
🥗 Alimentación y nutrición
Uso alimentario:
Partes tradicionalmente utilizadas:
Usos culinarios conocidos:
Precauciones alimentarias:
🌿 Usos medicinales tradicionales
Situación:
Partes tradicionalmente utilizadas:
Usos tradicionales descritos:
Compuestos conocidos:
Nivel de evidencia:
Precauciones:
Contraindicaciones:
Interacciones:
⚠️ Seguridad
Personas adultas:
Niños:
Embarazo y lactancia:
Perros:
Gatos:
Contacto con la piel:
Polinizadores:
Curiosidades:
🍃 HojaScan
Fotografía la hoja por ambas caras si es posible.
Selecciona una fotografía.
Planta:
Estado:
Señales observadas:
Necesidades:
Consejos:
Causas posibles:
Calidad de imagen:
🌡️ ClimaPlanta
La fotografía es opcional y puede ayudar a contextualizar el estado visible.
Introduce los datos y elige frío o calor.
Ubicación:
Planta:
Evaluación:
Orientación:
Temperatura mínima orientativa:
Temperatura máxima orientativa:
Acción recomendada:
Precauciones:
Datos adicionales útiles:
🌱 PlantSense
Se combinará con los datos de humedad, hojas, tallo y raíces.
Completa los datos y pulsa analizar.
Planta:
Estado:
Necesidad principal:
Necesidades:
Señales:
Consejos:
Prioridad:
Explicación:
🔍 Identificador Visual
Fotografía hojas, tallo, flores o frutos con buena iluminación.
Selecciona una fotografía.
Nombre:
Nombre científico:
Familia y tipo:
Confianza orientativa:
Descripción:
Características visibles:
Otras posibilidades:
Origen:
Hábitat:
Calidad de la imagen:
🪴 Cuidados
Luz:
Riego:
Suelo:
Temperatura:
Floración:
🥗 Alimentación y nutrición
Uso alimentario:
Partes tradicionalmente utilizadas:
Usos culinarios conocidos:
Precauciones alimentarias:
🌿 Usos medicinales tradicionales
Situación:
Partes tradicionalmente utilizadas:
Usos tradicionales descritos:
Compuestos conocidos:
Nivel de evidencia:
Precauciones:
Contraindicaciones:
Interacciones:
⚠️ Seguridad
Personas adultas:
Niños:
Embarazo y lactancia:
Perros:
Gatos:
Contacto con la piel:
Polinizadores:
Curiosidades:
🍃 HojaScan
Fotografía la hoja por ambas caras si es posible.
Selecciona una fotografía.
Planta:
Estado:
Señales observadas:
Necesidades:
Consejos:
Causas posibles:
Calidad de imagen:
🌡️ ClimaPlanta
La fotografía es opcional y puede ayudar a contextualizar el estado visible.
Introduce los datos y elige frío o calor.
Ubicación:
Planta:
Evaluación:
Orientación:
Temperatura mínima orientativa:
Temperatura máxima orientativa:
Acción recomendada:
Precauciones:
Datos adicionales útiles:
🌱 PlantSense
Se combinará con los datos de humedad, hojas, tallo y raíces.
Completa los datos y pulsa analizar.
Planta:
Estado:
Necesidad principal:
Necesidades:
Señales:
Consejos:
Prioridad:
Explicación:
function setBusy(
button,
busy,
normalText,
busyText
) {
button.disabled =
Boolean(busy);
button.textContent =
busy
? busyText
: normalText;
}
function validateImage(
file
) {
if (!file) {
throw new Error(
"Selecciona una fotografía."
);
}
const allowed = [
"image/jpeg",
"image/png",
"image/webp"
];
if (
file.type &&
allowed.indexOf(
file.type
) === -1
) {
throw new Error(
"La fotografía debe ser JPG, PNG o WebP."
);
}
if (
Number(
file.size || 0
) > MAX_FILE
) {
throw new Error(
"La fotografía supera el límite de 10 MB."
);
}
}
function configurePhotoSource(
prefix,
cameraId,
fileId,
imageId,
previewId,
removeId,
actionButtonId,
statusId,
required
) {
const cameraInput =
$(cameraId);
const fileInput =
$(fileId);
const image =
$(imageId);
const preview =
$(previewId);
const removeButton =
$(removeId);
const actionButton =
$(actionButtonId);
const status =
$(statusId);
let currentUrl =
"";
function clearPhoto() {
selectedPhotos[prefix] =
null;
cameraInput.value =
"";
fileInput.value =
"";
if (currentUrl) {
URL.revokeObjectURL(
currentUrl
);
currentUrl =
"";
}
image.removeAttribute(
"src"
);
preview.style.display =
"none";
removeButton.style.display =
"none";
if (required) {
actionButton.disabled =
true;
status.textContent =
"Selecciona una fotografía.";
} else {
actionButton.disabled =
false;
status.textContent =
"Fotografía opcional no seleccionada.";
}
}
function useFile(
file,
otherInput
) {
if (!file) {
return;
}
try {
validateImage(
file
);
} catch (error) {
status.textContent =
error.message;
return;
}
otherInput.value =
"";
selectedPhotos[prefix] =
file;
if (currentUrl) {
URL.revokeObjectURL(
currentUrl
);
}
currentUrl =
URL.createObjectURL(
file
);
image.onload =
function() {
preview.style.display =
"block";
removeButton.style.display =
"block";
actionButton.disabled =
false;
status.textContent =
"Fotografía preparada: " +
text(
file.name,
"imagen"
);
};
image.onerror =
function() {
clearPhoto();
status.textContent =
"No se pudo abrir la fotografía.";
};
image.src =
currentUrl;
}
cameraInput.addEventListener(
"change",
function() {
useFile(
cameraInput.files &&
cameraInput.files[0],
fileInput
);
}
);
fileInput.addEventListener(
"change",
function() {
useFile(
fileInput.files &&
fileInput.files[0],
cameraInput
);
}
);
removeButton.addEventListener(
"click",
clearPhoto
);
if (!required) {
actionButton.disabled =
false;
}
}
app.querySelectorAll(
".tab"
).forEach(
function(button) {
button.addEventListener(
"click",
function() {
app.querySelectorAll(
".tab"
).forEach(
function(tab) {
tab.classList.remove(
"on"
);
}
);
app.querySelectorAll(
".panel"
).forEach(
function(panel) {
panel.classList.remove(
"on"
);
}
);
button.classList.add(
"on"
);
const panel =
$(
button.dataset.panel
);
if (panel) {
panel.classList.add(
"on"
);
}
}
);
}
);
configurePhotoSource(
"d",
"dCamara",
"dArchivo",
"dImg",
"dPrev",
"dQuitar",
"dBtn",
"dStatus",
true
);
configurePhotoSource(
"i",
"iCamara",
"iArchivo",
"iImg",
"iPrev",
"iQuitar",
"iBtn",
"iStatus",
true
);
configurePhotoSource(
"h",
"hCamara",
"hArchivo",
"hImg",
"hPrev",
"hQuitar",
"hBtn",
"hStatus",
true
);
configurePhotoSource(
"c",
"cCamara",
"cArchivo",
"cImg",
"cPrev",
"cQuitar",
"cFrio",
"cStatus",
false
);
configurePhotoSource(
"s",
"sCamara",
"sArchivo",
"sImg",
"sPrev",
"sQuitar",
"sBtn",
"sStatus",
false
);
/* ====================================================
DOCTOR
==================================================== */
$("dBtn").addEventListener(
"click",
async function() {
const button =
$("dBtn");
clearError(
"d"
);
try {
const file =
selectedPhotos.d;
validateImage(
file
);
setBusy(
button,
true,
"Analizar planta",
"Analizando..."
);
$("dStatus").textContent =
"Analizando la fotografía y los datos aportados...";
$("dResult").style.display =
"none";
const form =
new FormData();
form.append(
"foto",
file
);
form.append(
"nombre",
$("dNombre").value
);
form.append(
"tipo",
$("dTipo").value
);
form.append(
"tierra",
$("dTierra").value
);
form.append(
"ultimoRiego",
$("dRiego").value
);
form.append(
"luz",
$("dLuz").value
);
form.append(
"hojas",
$("dHojas").value
);
form.append(
"maceta",
$("dMaceta").value
);
form.append(
"temperatura",
$("dTemp").value
);
const data =
await send(
"/flora/doctor",
{
method:
"POST",
body:
form
}
);
const health =
number(
data.salud,
5,
98
);
$("drNombre").textContent =
text(
data.nombre,
"Planta sin identificar"
);
$("drNivel").textContent =
text(
data.nivel,
"Evaluación orientativa"
) +
" — " +
health +
"%";
$("drBar").style.width =
health + "%";
$("drRiego").textContent =
text(
data.riego,
"No determinado."
);
$("drLuz").textContent =
text(
data.luz,
"No determinada."
);
$("drTemp").textContent =
text(
data.temperatura,
"No determinada."
);
$("drAbono").textContent =
text(
data.abono,
"No determinado."
);
$("drMaceta").textContent =
text(
data.maceta,
"No determinada."
);
$("drPoda").textContent =
text(
data.poda,
"No determinada."
);
$("drPlagas").textContent =
text(
data.plagas,
"No determinadas."
);
fillList(
"drSenales",
data.senales,
"Sin señales adicionales."
);
fillList(
"drPlan",
data.plan,
"Sin pasos adicionales."
);
$("drObservacion").textContent =
text(
data.observacionVisual,
"Sin observaciones adicionales."
);
$("drAdvertencia").textContent =
text(
data.advertencia,
"Esta valoración es orientativa y no confirma enfermedades ni plagas."
);
$("dResult").style.display =
"block";
$("dStatus").textContent =
"✅ Análisis terminado.";
} catch (error) {
showError(
"d",
error.message
);
} finally {
setBusy(
button,
false,
"Analizar planta",
"Analizando..."
);
button.disabled =
!selectedPhotos.d;
}
}
);
/* ====================================================
IDENTIFICADOR
==================================================== */
$("iBtn").addEventListener(
"click",
async function() {
const button =
$("iBtn");
clearError(
"i"
);
try {
const file =
selectedPhotos.i;
validateImage(
file
);
setBusy(
button,
true,
"Identificar planta",
"Identificando..."
);
$("iStatus").textContent =
"Analizando las características visibles...";
$("iResult").style.display =
"none";
const form =
new FormData();
form.append(
"foto",
file
);
const data =
await send(
"/flora/identificar",
{
method:
"POST",
body:
form
}
);
const confidence =
number(
data.confianza,
0,
100
);
const care =
data.cuidados &&
typeof data.cuidados ===
"object"
? data.cuidados
: {};
const food =
data.alimentacion &&
typeof data.alimentacion ===
"object"
? data.alimentacion
: {};
const medicinal =
data.medicinal &&
typeof data.medicinal ===
"object"
? data.medicinal
: {};
const toxicity =
data.toxicidad &&
typeof data.toxicidad ===
"object"
? data.toxicidad
: {};
$("irNombre").textContent =
text(
data.nombreComun,
"Especie no determinada"
);
$("irCientifico").textContent =
text(
data.nombreCientifico,
"No determinado"
);
$("irFamilia").textContent =
[
text(
data.familia
),
text(
data.tipo
)
]
.filter(Boolean)
.join(" · ") ||
"No determinado";
$("irConf").textContent =
confidence + "%";
$("irBar").style.width =
confidence + "%";
$("irDesc").textContent =
text(
data.descripcion,
"Sin descripción disponible."
);
fillList(
"irCar",
data.caracteristicasVisibles,
"Sin características suficientes."
);
const alternatives =
normalizeArray(
data.alternativas
).map(
function(item) {
if (
item &&
typeof item ===
"object"
) {
const name =
text(
item.nombreComun ||
item.nombre,
"Alternativa"
);
const scientific =
text(
item.nombreCientifico
);
const alternativeConfidence =
number(
item.confianza,
0,
100
);
const differences =
text(
item.diferencias
);
return (
name +
(
scientific
? " (" +
scientific +
")"
: ""
) +
" — " +
alternativeConfidence +
"%" +
(
differences
? ". " +
differences
: ""
)
);
}
return text(
item
);
}
);
fillList(
"irAlt",
alternatives,
"Sin alternativas suficientes."
);
$("irOrigen").textContent =
text(
data.origen,
"No indicado."
);
$("irHabitat").textContent =
text(
data.habitat,
"No indicado."
);
$("irCalidad").textContent =
text(
data.calidadImagen,
"No determinada."
);
$("irLuz").textContent =
text(
care.luz,
"No indicada."
);
$("irRiego").textContent =
text(
care.riego,
"No indicado."
);
$("irSuelo").textContent =
text(
care.suelo,
"No indicado."
);
$("irTemperatura").textContent =
text(
care.temperatura,
"No indicada."
);
$("irFloracion").textContent =
text(
care.floracion,
"No indicada."
);
$("irAlimentacion").textContent =
food.bloqueado
? "Identificación insuficiente para mostrar información alimentaria aplicable."
: food.usoConocido
? "Se conocen usos alimentarios, pero la especie debe confirmarse antes de cualquier consumo."
: "No consta un uso alimentario seguro.";
fillList(
"irPartesAlimentarias",
food.bloqueado
? []
: food.partesUsadas,
"Sin partes comestibles confirmadas."
);
fillList(
"irUsosCulinarios",
food.bloqueado
? []
: food.usosCulinarios,
"Sin usos culinarios confirmados."
);
fillOptionalList(
"irNutricionRow",
"irNutricion",
food.bloqueado
? []
: food.propiedadesNutricionales
);
fillOptionalList(
"irNutrientesRow",
"irNutrientes",
food.bloqueado
? []
: food.nutrientesDestacados
);
fillOptionalList(
"irPreparacionRow",
"irPreparacion",
food.bloqueado
? []
: food.preparacionSegura
);
fillList(
"irPrecaucionesAlimentarias",
food.precauciones,
"No consumir sin una identificación botánica fiable."
);
$("irMedicinal").textContent =
medicinal.bloqueado
? "Identificación insuficiente para mostrar usos medicinales aplicables."
: medicinal.usoTradicionalConocido
? "Se describen usos tradicionales; no equivalen a tratamientos médicos."
: "No consta un uso medicinal tradicional conocido.";
fillList(
"irPartesMedicinales",
medicinal.bloqueado
? []
: medicinal.partesUsadas,
"Sin partes de uso tradicional confirmadas."
);
fillList(
"irUsosMedicinales",
medicinal.bloqueado
? []
: medicinal.usosTradicionales,
"Sin usos tradicionales confirmados."
);
fillList(
"irCompuestos",
medicinal.bloqueado
? []
: medicinal.compuestosConocidos,
"Sin compuestos mostrados."
);
$("irEvidencia").textContent =
text(
medicinal.nivelEvidencia,
"No determinada."
);
fillList(
"irPrecaucionesMedicinales",
medicinal.precauciones,
"No utilizar con fines médicos sin asesoramiento sanitario."
);
fillList(
"irContraindicaciones",
medicinal.contraindicaciones,
"No determinadas."
);
fillList(
"irInteracciones",
medicinal.interacciones,
"No determinadas."
);
$("irToxicidadPersonas").textContent =
text(
toxicity.personas,
"No determinada."
);
$("irNinos").textContent =
text(
toxicity.ninos,
"No determinado."
);
$("irEmbarazo").textContent =
text(
toxicity.embarazoLactancia,
"No determinado."
);
$("irPerros").textContent =
text(
toxicity.perros,
"No determinado."
);
$("irGatos").textContent =
text(
toxicity.gatos,
"No determinado."
);
$("irPiel").textContent =
text(
toxicity.contactoPiel,
"No determinado."
);
fillList(
"irPolinizadores",
data.polinizadores,
"No indicados."
);
fillList(
"irCuriosidades",
data.curiosidades,
"Sin curiosidades adicionales."
);
$("irAdvertencia").textContent =
text(
data.advertencia,
"Nunca consumas ni uses medicinalmente una planta basándote únicamente en una fotografía."
);
$("iResult").style.display =
"block";
$("iStatus").textContent =
"✅ Identificación terminada.";
} catch (error) {
showError(
"i",
error.message
);
} finally {
setBusy(
button,
false,
"Identificar planta",
"Identificando..."
);
button.disabled =
!selectedPhotos.i;
}
}
);
/* ====================================================
HOJASCAN
==================================================== */
$("hBtn").addEventListener(
"click",
async function() {
const button =
$("hBtn");
clearError(
"h"
);
try {
const file =
selectedPhotos.h;
validateImage(
file
);
setBusy(
button,
true,
"Analizar hoja",
"Analizando..."
);
$("hStatus").textContent =
"Analizando las características visibles de la hoja...";
$("hResult").style.display =
"none";
const form =
new FormData();
form.append(
"foto",
file
);
form.append(
"nombre",
$("hNombre").value
);
form.append(
"tipoHoja",
$("hTipo").value
);
const data =
await send(
"/flora/hoja",
{
method:
"POST",
body:
form
}
);
const health =
number(
data.salud,
5,
100
);
$("hrNombre").textContent =
text(
data.nombre,
"Planta no identificada"
);
$("hrEstado").textContent =
text(
data.estado,
"Estado no determinado"
) +
" — " +
health +
"%";
$("hrBar").style.width =
health + "%";
fillList(
"hrSen",
data.senales,
"Sin señales claras."
);
fillList(
"hrNec",
data.necesidades,
"Sin necesidades determinadas."
);
fillList(
"hrCon",
data.consejos,
"Sin consejos adicionales."
);
fillList(
"hrCau",
data.posiblesCausas,
"Sin causas suficientes."
);
$("hrCal").textContent =
text(
data.calidadImagen,
"No determinada."
);
$("hrAdvertencia").textContent =
text(
data.advertencia,
"Una fotografía no permite confirmar enfermedades."
);
$("hResult").style.display =
"block";
$("hStatus").textContent =
"✅ Análisis terminado.";
} catch (error) {
showError(
"h",
error.message
);
} finally {
setBusy(
button,
false,
"Analizar hoja",
"Analizando..."
);
button.disabled =
!selectedPhotos.h;
}
}
);
/* ====================================================
CLIMA
==================================================== */
async function analyzeClimate(
choice
) {
const coldButton =
$("cFrio");
const heatButton =
$("cCalor");
clearError(
"c"
);
try {
coldButton.disabled =
true;
heatButton.disabled =
true;
$("cStatus").textContent =
"Preparando una orientación térmica...";
$("cResult").style.display =
"none";
let climateOptions;
if (selectedPhotos.c) {
const form =
new FormData();
form.append(
"foto",
selectedPhotos.c
);
form.append(
"ciudad",
$("cCiudad").value
);
form.append(
"planta",
$("cPlanta").value
);
form.append(
"temperatura",
$("cTemp").value
);
form.append(
"eleccion",
choice
);
climateOptions = {
method:
"POST",
body:
form
};
} else {
climateOptions = {
method:
"POST",
headers: {
"Content-Type":
"application/json"
},
body:
JSON.stringify({
ciudad:
$("cCiudad").value,
planta:
$("cPlanta").value,
temperatura:
$("cTemp").value,
eleccion:
choice
})
};
}
const data =
await send(
"/flora/clima",
climateOptions
);
$("crCiudad").textContent =
text(
data.ciudad,
"No indicada."
);
$("crPlanta").textContent =
text(
data.planta,
"No indicada."
);
$("crEleccion").textContent =
text(
data.eleccion,
choice
);
$("crOri").textContent =
text(
data.orientacion,
"Sin orientación suficiente."
);
$("crMin").textContent =
text(
data.temperaturaMinimaOrientativa,
"No determinada."
);
$("crMax").textContent =
text(
data.temperaturaMaximaOrientativa,
"No determinada."
);
$("crAcc").textContent =
text(
data.accionRecomendada,
"Comprueba la temperatura real antes de mover la planta."
);
fillList(
"crPre",
data.precauciones,
"Realiza cualquier cambio de ubicación de forma gradual."
);
fillList(
"crDatos",
data.datosNecesarios,
"Comprueba la temperatura y la humedad reales."
);
$("crAdvertencia").textContent =
text(
data.advertencia,
"Esta herramienta no consulta automáticamente el pronóstico meteorológico."
);
$("cResult").style.display =
"block";
$("cStatus").textContent =
"✅ Orientación preparada.";
} catch (error) {
showError(
"c",
error.message
);
} finally {
coldButton.disabled =
false;
heatButton.disabled =
false;
}
}
$("cFrio").addEventListener(
"click",
function() {
analyzeClimate(
"frio"
);
}
);
$("cCalor").addEventListener(
"click",
function() {
analyzeClimate(
"calor"
);
}
);
/* ====================================================
PLANTSENSE
==================================================== */
$("sBtn").addEventListener(
"click",
async function() {
const button =
$("sBtn");
clearError(
"s"
);
try {
setBusy(
button,
true,
"Analizar datos",
"Analizando..."
);
$("sStatus").textContent =
"Analizando los datos de cultivo...";
$("sResult").style.display =
"none";
const body = {
nombre:
$("sNombre").value,
humedad:
Number(
$("sHum").value
),
hojas:
$("sHojas").value,
tallo:
$("sTallo").value,
raices:
$("sRaices").value,
horasLuz:
Number(
$("sLuz").value
),
temperatura:
Number(
$("sTemp").value
),
frecuenciaRiego:
$("sRiego").value,
plagas:
$("sPlagas").value,
observaciones:
$("sObs").value
};
let senseOptions;
if (selectedPhotos.s) {
const form =
new FormData();
form.append(
"foto",
selectedPhotos.s
);
Object.keys(
body
).forEach(
function(key) {
form.append(
key,
body[key] == null
? ""
: String(
body[key]
)
);
}
);
senseOptions = {
method:
"POST",
body:
form
};
} else {
senseOptions = {
method:
"POST",
headers: {
"Content-Type":
"application/json"
},
body:
JSON.stringify(
body
)
};
}
const data =
await send(
"/flora/sense",
senseOptions
);
const health =
number(
data.salud,
5,
100
);
$("srNombre").textContent =
text(
data.nombre,
"Planta sin nombre"
);
$("srEstado").textContent =
text(
data.estado,
"Estado no determinado"
) +
" — " +
health +
"%";
$("srBar").style.width =
health + "%";
$("srPrincipal").textContent =
text(
data.necesidadPrincipal,
"No determinada."
);
fillList(
"srNec",
data.necesidades,
"Sin necesidades determinadas."
);
fillList(
"srSen",
data.senales,
"Sin señales adicionales."
);
fillList(
"srCon",
data.consejos,
"Sin consejos adicionales."
);
$("srPri").textContent =
text(
data.prioridad,
"baja"
).toUpperCase();
$("srExp").textContent =
text(
data.explicacion,
"Sin explicación adicional."
);
$("srAdvertencia").textContent =
text(
data.advertencia,
"Resultado orientativo basado únicamente en los datos introducidos."
);
$("sResult").style.display =
"block";
$("sStatus").textContent =
"✅ Análisis terminado.";
} catch (error) {
showError(
"s",
error.message
);
} finally {
setBusy(
button,
false,
"Analizar datos",
"Analizando..."
);
}
}
);
}());