feat: manual membership evaluator implemented reusing existing logic
This commit is contained in:
@@ -2,6 +2,7 @@ import React, { useState, useEffect, memo } from 'react';
|
||||
import { ComposedChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { useGraphData } from './finalGraph/useGraphData';
|
||||
import { GraphTooltip } from './finalGraph/GraphTooltip';
|
||||
import { MembershipEvaluator } from './finalGraph/MembershipEvaluator';
|
||||
|
||||
const Step3FinalGraph = memo(({ data, criterionName }) => {
|
||||
const { sortedResults, denseData } = useGraphData(data);
|
||||
@@ -19,15 +20,15 @@ const Step3FinalGraph = memo(({ data, criterionName }) => {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<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'}
|
||||
</h3>
|
||||
|
||||
<div className="flex-1 w-full min-h-[400px] relative">
|
||||
|
||||
|
||||
<div className="w-full h-[400px] relative shrink-0">
|
||||
|
||||
{!isReady && (
|
||||
<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>
|
||||
@@ -35,28 +36,25 @@ const Step3FinalGraph = memo(({ data, criterionName }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gráfica */}
|
||||
<div className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${isReady ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{isReady && (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={denseData} margin={{ top: 15, right: 50, left: 10, bottom: 10 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} />
|
||||
<XAxis
|
||||
<XAxis
|
||||
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]}
|
||||
tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }}
|
||||
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 }}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 1]} tickCount={6} tickFormatter={(val) => Number(val.toFixed(2))}
|
||||
tick={{ fill: '#475569', fontSize: 14 }}
|
||||
<YAxis
|
||||
domain={[0, 1]} tickCount={6} tickFormatter={(val) => Number(val.toFixed(2))}
|
||||
tick={{ fill: '#475569', fontSize: 14 }}
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
content={<GraphTooltip sortedResults={sortedResults} />}
|
||||
cursor={{ stroke: '#cbd5e1', strokeWidth: 1, strokeDasharray: '5 5' }}
|
||||
<Tooltip
|
||||
content={<GraphTooltip sortedResults={sortedResults} />}
|
||||
cursor={{ stroke: '#cbd5e1', strokeWidth: 1, strokeDasharray: '5 5' }}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
|
||||
{sortedResults.map((item) => (
|
||||
<React.Fragment key={item.term}>
|
||||
{item.isType2 ? (
|
||||
@@ -76,8 +74,8 @@ const Step3FinalGraph = memo(({ data, criterionName }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Leyenda */}
|
||||
<div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-6 pb-2 relative z-10">
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-5 relative z-10 shrink-0">
|
||||
{sortedResults.map((item) => (
|
||||
<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 }} />
|
||||
@@ -85,6 +83,13 @@ const Step3FinalGraph = memo(({ data, criterionName }) => {
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 { CHART_COLORS } from '../../../config';
|
||||
|
||||
const interpolateY = (x, nodes) => {
|
||||
export const interpolateY = (x, nodes) => {
|
||||
if (!nodes || nodes.length === 0) return null;
|
||||
const EPSILON = 1e-5;
|
||||
const MICRO_STEP = 0.0001;
|
||||
|
||||
Reference in New Issue
Block a user