feat: manual membership evaluator implemented reusing existing logic

This commit is contained in:
Alexis
2026-04-30 14:07:14 +02:00
parent 596d9f71b3
commit 47905d15c2
3 changed files with 141 additions and 21 deletions
@@ -2,6 +2,7 @@ import React, { useState, useEffect, memo } from 'react';
import { ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; import { ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { useGraphData } from './finalGraph/useGraphData'; import { useGraphData } from './finalGraph/useGraphData';
import { GraphTooltip } from './finalGraph/GraphTooltip'; import { GraphTooltip } from './finalGraph/GraphTooltip';
import { MembershipEvaluator } from './finalGraph/MembershipEvaluator';
const Step3FinalGraph = memo(({ data, criterionName }) => { const Step3FinalGraph = memo(({ data, criterionName }) => {
const { sortedResults, denseData } = useGraphData(data); const { sortedResults, denseData } = useGraphData(data);
@@ -19,15 +20,15 @@ const Step3FinalGraph = memo(({ data, criterionName }) => {
} }
return ( return (
<div className="w-full h-[550px] bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col final-graph-container relative"> <div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col final-graph-container relative">
<style>{`.final-graph-container svg * { clip-path: none !important; }`}</style> <style>{`.final-graph-container svg * { clip-path: none !important; }`}</style>
<h3 className="text-xl font-bold mb-4 text-center text-slate-800 uppercase"> <h3 className="text-xl font-bold mb-4 text-center text-slate-800 uppercase shrink-0">
{criterionName ? `Criterio: ${criterionName}` : 'Espectro Difuso Final'} {criterionName ? `Criterio: ${criterionName}` : 'Espectro Difuso Final'}
</h3> </h3>
<div className="flex-1 w-full min-h-[400px] relative"> <div className="w-full h-[400px] relative shrink-0">
{!isReady && ( {!isReady && (
<div className="absolute inset-0 flex flex-col items-center justify-center"> <div className="absolute inset-0 flex flex-col items-center justify-center">
<div className="w-8 h-8 border-4 border-slate-200 border-t-blue-500 rounded-full animate-spin mb-3"></div> <div className="w-8 h-8 border-4 border-slate-200 border-t-blue-500 rounded-full animate-spin mb-3"></div>
@@ -35,28 +36,25 @@ const Step3FinalGraph = memo(({ data, criterionName }) => {
</div> </div>
)} )}
{/* Gráfica */}
<div className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${isReady ? 'opacity-100' : 'opacity-0'}`}> <div className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${isReady ? 'opacity-100' : 'opacity-0'}`}>
{isReady && ( {isReady && (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart data={denseData} margin={{ top: 15, right: 50, left: 10, bottom: 10 }}> <ComposedChart data={denseData} margin={{ top: 15, right: 50, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} /> <CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} />
<XAxis <XAxis
dataKey="x" type="number" domain={[0, 1]} allowDataOverflow={true} dataKey="x" type="number" domain={[0, 1]} allowDataOverflow={true}
ticks={[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]} ticks={[0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1]}
tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }} tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }}
/> />
<YAxis <YAxis
domain={[0, 1]} tickCount={6} tickFormatter={(val) => Number(val.toFixed(2))} domain={[0, 1]} tickCount={6} tickFormatter={(val) => Number(val.toFixed(2))}
tick={{ fill: '#475569', fontSize: 14 }} tick={{ fill: '#475569', fontSize: 14 }}
/> />
<Tooltip
<Tooltip content={<GraphTooltip sortedResults={sortedResults} />}
content={<GraphTooltip sortedResults={sortedResults} />} cursor={{ stroke: '#cbd5e1', strokeWidth: 1, strokeDasharray: '5 5' }}
cursor={{ stroke: '#cbd5e1', strokeWidth: 1, strokeDasharray: '5 5' }}
isAnimationActive={false} isAnimationActive={false}
/> />
{sortedResults.map((item) => ( {sortedResults.map((item) => (
<React.Fragment key={item.term}> <React.Fragment key={item.term}>
{item.isType2 ? ( {item.isType2 ? (
@@ -76,8 +74,8 @@ const Step3FinalGraph = memo(({ data, criterionName }) => {
</div> </div>
</div> </div>
{/* Leyenda */} {/* Legend */}
<div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-6 pb-2 relative z-10"> <div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-5 relative z-10 shrink-0">
{sortedResults.map((item) => ( {sortedResults.map((item) => (
<div key={`legend-${item.term}`} className="flex items-center gap-2"> <div key={`legend-${item.term}`} className="flex items-center gap-2">
<span className="w-3.5 h-3.5 rounded-full shadow-sm" style={{ backgroundColor: item.color }} /> <span className="w-3.5 h-3.5 rounded-full shadow-sm" style={{ backgroundColor: item.color }} />
@@ -85,6 +83,13 @@ const Step3FinalGraph = memo(({ data, criterionName }) => {
</div> </div>
))} ))}
</div> </div>
{/* Evaluador Manual */}
{isReady && sortedResults.length > 0 && (
<div className="mt-4 pt-4 border-t border-slate-100 relative z-10">
<MembershipEvaluator sortedResults={sortedResults} />
</div>
)}
</div> </div>
); );
}); });
@@ -0,0 +1,115 @@
import { useState } from 'react';
import { interpolateY } from './useGraphData';
const evaluateMembership = (x, sortedResults) => {
if (!sortedResults || sortedResults.length === 0) return [];
return sortedResults.map(item => {
if (item.isType2) {
const lower = interpolateY(x, item.lowerNodes) ?? 0;
const upper = interpolateY(x, item.upperNodes) ?? 0;
return { term: item.term, color: item.color, isType2: true, lower, upper };
}
const y = interpolateY(x, item.nodes) ?? 0;
return { term: item.term, color: item.color, isType2: false, y };
});
};
const filterActive = (results) =>
results.filter(r => r.isType2 ? r.upper > 0 : r.y > 0);
const TermResult = ({ result }) => {
const uncertainty = result.isType2 ? Math.abs(result.upper - result.lower) : 0;
const isSimple = !result.isType2 || uncertainty < 0.001;
const displayY = result.isType2 ? result.upper : result.y;
return (
<div className="flex flex-col text-xs font-medium bg-slate-50 px-3 py-2 rounded-xl border border-slate-100 shrink-0">
<span className="uppercase font-black mb-1 tracking-wide leading-none" style={{ color: result.color }}>
{result.term}
</span>
{isSimple ? (
<span className="text-slate-700 flex justify-between gap-2">
Pertenencia: <b>{displayY.toFixed(3)}</b>
</span>
) : (
<>
<span className="text-slate-600 flex justify-between gap-2">
Inferior: <b>{result.lower.toFixed(3)}</b>
</span>
<span className="text-slate-600 flex justify-between gap-2 mt-0.5">
Superior: <b>{result.upper.toFixed(3)}</b>
</span>
<span className="text-slate-500 font-bold mt-1 pt-1 border-t border-slate-200 flex justify-between gap-2">
Incertidumbre: <span>{uncertainty.toFixed(3)}</span>
</span>
</>
)}
</div>
);
};
export const MembershipEvaluator = ({ sortedResults }) => {
const [inputValue, setInputValue] = useState('');
const handleChange = (e) => {
const raw = e.target.value;
if (raw === '') { setInputValue(''); return; }
const dotIdx = raw.indexOf('.');
if (dotIdx !== -1 && raw.length - dotIdx - 1 > 4) {
setInputValue(raw.slice(0, dotIdx + 5));
} else {
setInputValue(raw);
}
};
const trimmed = inputValue.trim();
const xNum = trimmed === '' ? null : parseFloat(trimmed);
const isValidNumber = xNum !== null && !isNaN(xNum) && isFinite(xNum);
const activeResults = isValidNumber
? filterActive(evaluateMembership(xNum, sortedResults))
: null;
return (
<div className="flex items-center gap-4 flex-wrap">
{/* Input group */}
<div className="flex items-center gap-2 shrink-0">
<label
htmlFor="eval-x-input"
className="text-xs font-bold uppercase tracking-wider text-slate-400 whitespace-nowrap"
>
Evaluar X:
</label>
<input
id="eval-x-input"
type="number"
step="0.0001"
value={inputValue}
onChange={handleChange}
placeholder="ej. 0.35"
className="w-28 px-2.5 py-1.5 border border-slate-300 rounded-lg text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-blue-400 transition-shadow placeholder:text-slate-400"
/>
</div>
{/* Results */}
<div className="flex flex-row flex-wrap gap-3 items-center">
{trimmed === '' && (
<span className="text-xs text-slate-400 italic">
Introduce un valor para obtener el grado de pertenencia.
</span>
)}
{trimmed !== '' && !isValidNumber && (
<span className="text-xs text-red-400 italic">Valor no válido.</span>
)}
{isValidNumber && activeResults.length === 0 && (
<span className="text-xs text-slate-400 italic">
Ningún término activo en X = {xNum.toFixed(4)}.
</span>
)}
{isValidNumber && activeResults.map(r => (
<TermResult key={r.term} result={r} />
))}
</div>
</div>
);
};
@@ -1,7 +1,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { CHART_COLORS } from '../../../config'; import { CHART_COLORS } from '../../../config';
const interpolateY = (x, nodes) => { export const interpolateY = (x, nodes) => {
if (!nodes || nodes.length === 0) return null; if (!nodes || nodes.length === 0) return null;
const EPSILON = 1e-5; const EPSILON = 1e-5;
const MICRO_STEP = 0.0001; const MICRO_STEP = 0.0001;