Merge pull request #15 from AlexisLopez-Dev/feature/frontend-v2
Feature: Editor de cartas, gráficas difusas y gráfica resultado
This commit is contained in:
Generated
+58
@@ -12,6 +12,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwindcss": "^4.2.2"
|
"tailwindcss": "^4.2.2"
|
||||||
},
|
},
|
||||||
@@ -1529,6 +1530,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3093,6 +3107,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.2.tgz",
|
||||||
|
"integrity": "sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.2.tgz",
|
||||||
|
"integrity": "sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.13.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/recharts": {
|
"node_modules/recharts": {
|
||||||
"version": "3.8.0",
|
"version": "3.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||||
@@ -3209,6 +3261,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwindcss": "^4.2.2"
|
"tailwindcss": "^4.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import BasicMode from './pages/BasicMode';
|
import { AppRouter } from './routers/AppRouter';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 p-8 font-sans text-slate-800 flex flex-col items-center">
|
<AppRouter />
|
||||||
<BasicMode/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
export default function AddLevelButton({ handleAddLevel }) {
|
export default function AddLevelButton({ handleAddLevel }) {
|
||||||
return (
|
return (
|
||||||
<div className="w-72 mt-6">
|
<div className="flex flex-col items-center mx-2 my-2">
|
||||||
<button
|
<button onClick={handleAddLevel} className="w-40 h-52 border-4 border-dashed border-slate-300 rounded-2xl flex flex-col items-center justify-center gap-2 text-slate-400 font-semibold hover:bg-blue-50 hover:border-blue-400 hover:text-blue-500 transition-all active:scale-[0.98] group">
|
||||||
onClick={handleAddLevel}
|
<span className="text-4xl font-light leading-none group-hover:scale-110 transition-transform">+</span>
|
||||||
className="w-full h-24 border-4 border-dashed border-slate-300 rounded-xl flex flex-col items-center justify-center gap-1 text-slate-400 font-semibold hover:bg-blue-50 hover:border-blue-400 hover:text-blue-500 transition-all active:scale-[0.98]"
|
<span className="text-xs uppercase tracking-widest font-bold text-center px-4">Añadir Carta</span>
|
||||||
>
|
|
||||||
<span className="text-3xl leading-none">+</span>
|
|
||||||
<span className="text-sm">Añadir Carta</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<div className="h-6 mt-2"></div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,36 +1,55 @@
|
|||||||
export default function BlankCardsCounter({ index, blankCardsCount, handleBlankCardChange }) {
|
export default function BlankCardsCounter({ index, blankCardsCount, handleBlankCardChange }) {
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center my-2 w-full">
|
|
||||||
<div className="w-0.5 h-6 bg-slate-300"></div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 bg-white px-4 py-2 rounded-full shadow-sm border border-slate-200 z-10">
|
const maxCardsPerRow = 7;
|
||||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Blancas:</span>
|
const rows = [];
|
||||||
|
for (let i = 0; i < blankCardsCount; i += maxCardsPerRow) {
|
||||||
|
rows.push(Array.from({ length: Math.min(maxCardsPerRow, blankCardsCount - i) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center mx-1 my-2 z-10 w-32">
|
||||||
|
|
||||||
|
<div className="relative w-full h-52 flex flex-col items-center justify-center shrink-0">
|
||||||
|
|
||||||
|
{/* Línea conectora horizontal */}
|
||||||
|
<div className="absolute w-[calc(100%+2rem)] h-1 bg-slate-200 top-[50%] -translate-y-1/2 z-0 rounded"></div>
|
||||||
|
|
||||||
|
{/* Botones - y + */}
|
||||||
|
<div className="flex items-center gap-1 bg-white px-2 py-1.5 rounded-full shadow-sm border border-slate-200 z-10 relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBlankCardChange(index, -1)}
|
onClick={() => handleBlankCardChange(index, -1)}
|
||||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
|
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
|
||||||
>-</button>
|
>-</button>
|
||||||
<span className="text-base font-bold w-6 text-center text-blue-600">
|
|
||||||
{blankCardsCount}
|
<div className="flex flex-col items-center leading-none min-w-[3rem]">
|
||||||
</span>
|
<span className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1">Blancas</span>
|
||||||
<button
|
<span className="text-base font-black text-blue-600">{blankCardsCount}</span>
|
||||||
onClick={() => handleBlankCardChange(index, 1)}
|
|
||||||
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-100 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
|
|
||||||
>+</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleBlankCardChange(index, 1)}
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded-full bg-slate-50 hover:bg-slate-200 text-slate-600 font-bold transition-colors"
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cartas blancas */}
|
||||||
{blankCardsCount > 0 && (
|
{blankCardsCount > 0 && (
|
||||||
<div className="flex flex-col items-center -space-y-4 mt-3 mb-1">
|
<div className="flex flex-col items-center gap-y-3 w-full justify-center -mt-16 relative z-0">
|
||||||
{Array.from({ length: blankCardsCount }).map((_, i) => (
|
{rows.map((row, rowIndex) => (
|
||||||
|
<div key={rowIndex} className="flex flex-row items-center justify-center -space-x-4">
|
||||||
|
{row.map((_, colIndex) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={`${rowIndex}-${colIndex}`}
|
||||||
className="w-16 h-8 bg-blue-50 border-2 border-blue-200 rounded shadow-sm opacity-80"
|
className="w-8 h-12 bg-white border-2 border-dashed border-slate-300 rounded shadow-sm opacity-90 transition-all hover:-translate-y-1"
|
||||||
style={{ zIndex: i }}
|
style={{ zIndex: colIndex }}
|
||||||
></div>
|
></div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-0.5 h-6 bg-slate-300"></div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,33 +1,18 @@
|
|||||||
export default function CardEditor({ index, level, handleLevelChange, handleRemoveLevel, totalLevels }) {
|
|
||||||
|
export default function CardEditor({ index, level, handleLevelChange, handleRemoveLevel, totalLevels, error }) {
|
||||||
return (
|
return (
|
||||||
<div className="relative w-72 h-44 bg-white border-2 border-slate-200 rounded-xl shadow-[0_8px_30px_rgb(0,0,0,0.08)] flex flex-col items-center justify-center transition-transform hover:-translate-y-1 hover:shadow-[0_12px_40px_rgb(0,0,0,0.12)] group">
|
<div className="flex flex-col items-center mx-2 my-2">
|
||||||
|
<div className={`relative w-40 h-52 bg-white border-2 rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.08)] flex flex-col items-center justify-center transition-transform hover:-translate-y-2 hover:shadow-[0_12px_40px_rgb(0,0,0,0.12)] group ${
|
||||||
{/* Botón Eliminar */}
|
error ? 'border-red-400 shadow-red-100' : 'border-slate-200'
|
||||||
{totalLevels > 2 && (
|
}`}>
|
||||||
<button
|
{totalLevels > 3 && (
|
||||||
onClick={() => handleRemoveLevel(index)}
|
<button onClick={() => handleRemoveLevel(index)} className="absolute -top-3 -right-3 w-8 h-8 bg-white text-slate-400 rounded-full border border-slate-200 flex items-center justify-center font-bold hover:bg-red-500 hover:text-white hover:border-red-500 transition-colors z-10 opacity-0 group-hover:opacity-100 shadow-sm" title="Eliminar carta">×</button>
|
||||||
className="absolute -top-3 -right-3 w-8 h-8 bg-white text-slate-400 rounded-full border border-slate-200 flex items-center justify-center font-bold hover:bg-red-500 hover:text-white hover:border-red-500 transition-colors z-10 opacity-0 group-hover:opacity-100 shadow-sm"
|
|
||||||
title="Eliminar carta"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
|
<span className="absolute top-3 left-4 text-sm font-black text-slate-300">{index + 1}</span>
|
||||||
{/* Detalles tipo naipe */}
|
<span className="absolute bottom-3 right-4 text-sm font-black text-slate-300 rotate-180">{index + 1}</span>
|
||||||
<span className="absolute top-4 left-4 text-sm font-black text-slate-300">
|
<input type="text" placeholder="Etiqueta..." value={level} onChange={(e) => handleLevelChange(index, e.target.value)} className={`w-10/12 text-center text-lg font-bold text-slate-700 bg-transparent border-b-2 border-dashed outline-none pb-1 ${error ? 'border-red-300 focus:border-red-500 placeholder:text-red-200' : 'border-slate-300 focus:border-blue-500'}`} />
|
||||||
{index + 1}
|
</div>
|
||||||
</span>
|
<div className="h-6 mt-2">{error && <p className="text-red-500 text-xs font-semibold animate-pulse">Escribe una etiqueta</p>}</div>
|
||||||
<span className="absolute bottom-4 right-4 text-sm font-black text-slate-300 rotate-180">
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Escribe aquí..."
|
|
||||||
value={level}
|
|
||||||
onChange={(e) => handleLevelChange(index, e.target.value)}
|
|
||||||
className="w-4/5 text-center text-2xl font-bold text-slate-700 bg-transparent border-b-2 border-dashed border-slate-300 focus:border-blue-500 outline-none pb-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,16 +1,29 @@
|
|||||||
export default function CriterionInput({ criterionName, setCriterionName }) {
|
export default function CriterionInput({ criterionName, setCriterionName, error }) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-2xl bg-white p-6 rounded-2xl shadow-sm border border-slate-200 mb-12">
|
<div className="flex flex-row items-center justify-center gap-3 w-full z-30 relative mb-2">
|
||||||
<label className="block text-sm font-bold text-slate-400 uppercase tracking-widest mb-2 text-center">
|
<label className="text-sm font-bold text-slate-600 uppercase tracking-wide whitespace-nowrap">
|
||||||
Nombre del Criterio
|
Nombre del Criterio:
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<div className="relative w-72">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ej. Calidad del aceite..."
|
placeholder="Ej: Calidad del código"
|
||||||
value={criterionName}
|
value={criterionName}
|
||||||
onChange={(e) => setCriterionName(e.target.value)}
|
onChange={(e) => setCriterionName(e.target.value)}
|
||||||
className="w-full text-3xl font-bold p-2 text-center text-slate-700 border-b-2 border-transparent hover:border-slate-200 focus:border-blue-500 outline-none transition-colors"
|
className={`w-full px-4 py-1.5 rounded-lg border-2 font-bold outline-none transition-all ${
|
||||||
|
error
|
||||||
|
? 'border-red-400 focus:border-red-500 bg-red-50 text-red-700 placeholder:text-red-300'
|
||||||
|
: 'border-slate-200 focus:border-blue-400 bg-slate-50 text-slate-800'
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<span className="absolute top-1/2 -right-24 -translate-y-1/2 text-xs font-bold text-red-500 animate-pulse whitespace-nowrap">
|
||||||
|
* Obligatorio
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import CriterionInput from '../CriterionInput';
|
||||||
|
import CardEditor from '../CardEditor';
|
||||||
|
import BlankCardsCounter from '../BlankCardsCounter';
|
||||||
|
import AddLevelButton from '../AddLevelButton';
|
||||||
|
|
||||||
|
export default function Step1BaseScale({
|
||||||
|
criterionName, handleCriterionChange,
|
||||||
|
levels, handleLevelChange, handleAddLevel, handleRemoveLevel,
|
||||||
|
blankCards, handleBlankCardChange,
|
||||||
|
errors, handleGenerateBaseScale, isLoading
|
||||||
|
}) {
|
||||||
|
const [isZoomActive, setIsZoomActive] = useState(true);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const tableRef = useRef(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ container: 1000, table: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateMeasurements = () => {
|
||||||
|
if (containerRef.current && tableRef.current) {
|
||||||
|
setDimensions({
|
||||||
|
container: containerRef.current.offsetWidth,
|
||||||
|
table: tableRef.current.scrollWidth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(updateMeasurements, 50);
|
||||||
|
window.addEventListener('resize', updateMeasurements);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.removeEventListener('resize', updateMeasurements);
|
||||||
|
};
|
||||||
|
}, [levels, blankCards]);
|
||||||
|
|
||||||
|
const needsZoom = dimensions.table > dimensions.container;
|
||||||
|
const dynamicScale = needsZoom ? (dimensions.container / dimensions.table) * 0.95 : 1;
|
||||||
|
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 mb-6 flex flex-col items-center animate-fade-in relative overflow-visible">
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center w-full mb-4 border-b pb-3 relative z-30">
|
||||||
|
<h2 className="text-xl font-bold text-slate-800">
|
||||||
|
Paso 1: Escala de Referencia (Mesa)
|
||||||
|
</h2>
|
||||||
|
{needsZoom && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (containerRef.current) containerRef.current.scrollLeft = 0;
|
||||||
|
setIsZoomActive(!isZoomActive);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg font-bold transition-all shadow-sm border text-sm ${isZoomActive ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-white border-slate-200 text-slate-600'}`}
|
||||||
|
>
|
||||||
|
<span>{isZoomActive ? '🔍' : '🖼️'}</span>
|
||||||
|
{isZoomActive ? 'Ver de cerca (Scroll)' : 'Ajustar mesa'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CriterionInput criterionName={criterionName} setCriterionName={handleCriterionChange} error={errors.criterion} />
|
||||||
|
|
||||||
|
<div ref={containerRef} className={`w-full mt-2 transition-all relative ${!isZoomActive && needsZoom ? 'overflow-x-auto flex justify-start pb-8 pt-4 px-4' : 'overflow-hidden flex justify-center pb-8 pt-4'}`}>
|
||||||
|
<div className={`flex flex-row items-start min-w-max transition-transform duration-500 ease-out px-4 origin-top`} style={{ transform: `scale(${currentScale})`, marginBottom: isZoomActive && currentScale < 1 ? `-${(1 - currentScale) * 300}px` : '0px' }}>
|
||||||
|
|
||||||
|
<div ref={tableRef} className="flex flex-row items-start relative px-10 overflow-visible">
|
||||||
|
{levels.map((level, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<div className="flex flex-col items-center mx-2 my-2 relative z-20">
|
||||||
|
<CardEditor index={index} level={level} handleLevelChange={handleLevelChange} handleRemoveLevel={handleRemoveLevel} totalLevels={levels.length} error={errors.levels[index]} canRemove={levels.length > 3} />
|
||||||
|
</div>
|
||||||
|
{index < levels.length - 1 && (
|
||||||
|
<BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
<div className="mx-1 my-2 h-52 flex items-center justify-center">
|
||||||
|
<div className="w-10 h-1 bg-slate-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<AddLevelButton handleAddLevel={handleAddLevel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-lg mt-2 pt-6 border-t border-slate-200 flex flex-col items-center z-20 relative bg-white">
|
||||||
|
<button onClick={handleGenerateBaseScale} disabled={isLoading} className={`w-full py-3 text-white text-lg font-bold rounded-xl shadow-md transition-all active:scale-[0.98] ${isLoading ? 'bg-slate-400 cursor-not-allowed' : 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700'}`}>
|
||||||
|
{isLoading ? 'Calculando...' : 'Generar Gráfica Continua'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Chart from '../membershipFunction/Chart';
|
||||||
|
import Controls from '../membershipFunction/Controls';
|
||||||
|
import { CHART_COLORS } from '../../config';
|
||||||
|
|
||||||
|
export default function Step2FuzzyModeling({
|
||||||
|
baseScale,
|
||||||
|
mfDefinitions,
|
||||||
|
selectedTerm,
|
||||||
|
setSelectedTerm,
|
||||||
|
updateCurrentMf,
|
||||||
|
handleFinalSubmit,
|
||||||
|
onBack,
|
||||||
|
subscales,
|
||||||
|
onOpenSubscale
|
||||||
|
}) {
|
||||||
|
const scaleKeys = Object.keys(baseScale);
|
||||||
|
|
||||||
|
const selectedColor = CHART_COLORS[scaleKeys.indexOf(selectedTerm) % CHART_COLORS.length] || '#2563eb';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 animate-fade-in relative overflow-visible">
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-6 border-b pb-3">
|
||||||
|
<h2 className="text-xl font-bold text-slate-800">Paso 2: Modelar Conceptos Difusos</h2>
|
||||||
|
<button onClick={onBack} className="text-slate-500 hover:text-blue-600 text-sm font-semibold underline">← Volver a las cartas</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mb-6">
|
||||||
|
{scaleKeys.map((name, index) => {
|
||||||
|
const isSelected = selectedTerm === name;
|
||||||
|
const color = CHART_COLORS[index % CHART_COLORS.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => setSelectedTerm(name)}
|
||||||
|
style={isSelected ? { backgroundColor: color, borderColor: color, color: '#fff' } : { borderColor: color, color: '#475569' }}
|
||||||
|
className={`px-5 py-2 rounded-lg font-bold border-2 transition-all duration-300 flex flex-col items-center shadow-sm hover:shadow-md ${isSelected ? 'transform scale-105' : 'bg-white opacity-80 hover:opacity-100'}`}
|
||||||
|
>
|
||||||
|
<span>{name}</span>
|
||||||
|
<span className="text-[10px] font-normal opacity-80">(X: {baseScale[name].toFixed(2)})</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Chart
|
||||||
|
baseScale={baseScale}
|
||||||
|
mfDefinitions={mfDefinitions}
|
||||||
|
selectedTerm={selectedTerm}
|
||||||
|
colors={CHART_COLORS}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controls
|
||||||
|
selectedTerm={selectedTerm}
|
||||||
|
currentMf={mfDefinitions[selectedTerm]}
|
||||||
|
selectedColor={selectedColor}
|
||||||
|
baseScale={baseScale}
|
||||||
|
mfDefinitions={mfDefinitions}
|
||||||
|
updateCurrentMf={updateCurrentMf}
|
||||||
|
subscales={subscales}
|
||||||
|
onOpenSubscale={onOpenSubscale}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="w-full mt-8 flex justify-center">
|
||||||
|
<button onClick={handleFinalSubmit} className="px-10 py-3 bg-slate-900 text-white text-lg font-bold rounded-xl shadow-md hover:bg-slate-800 transition-colors">
|
||||||
|
Guardar Todo el Espectro Difuso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import { CHART_COLORS } from '../../config';
|
||||||
|
|
||||||
|
const Step3FinalGraph = ({ data }) => {
|
||||||
|
const sortedResults = useMemo(() => {
|
||||||
|
if (!data || !data.results) return [];
|
||||||
|
|
||||||
|
const withPermanentColors = data.results.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
color: CHART_COLORS[index % CHART_COLORS.length]
|
||||||
|
}));
|
||||||
|
|
||||||
|
return withPermanentColors.sort((a, b) => {
|
||||||
|
const coreA = Array.isArray(a.core) ? Number(a.core[0]) : 0;
|
||||||
|
const coreB = Array.isArray(b.core) ? Number(b.core[0]) : 0;
|
||||||
|
return coreA - coreB;
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (!data || !data.results) {
|
||||||
|
return <p className="text-center mt-10 text-slate-500">Cargando gráfico final...</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-[550px] mt-2 bg-white p-6 rounded-2xl shadow-sm border border-slate-200 flex flex-col">
|
||||||
|
<h3 className="text-2xl font-bold mb-4 text-center text-slate-800">Espectro Difuso Final</h3>
|
||||||
|
|
||||||
|
{/* Gráfica */}
|
||||||
|
<div className="flex-1 w-full min-h-[400px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.5} vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="x"
|
||||||
|
type="number"
|
||||||
|
domain={[0, 1]}
|
||||||
|
tickCount={11}
|
||||||
|
tick={{ fill: '#475569', fontWeight: 600, fontSize: 14 }}
|
||||||
|
allowDuplicatedCategory={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0, 1]}
|
||||||
|
tickCount={6}
|
||||||
|
tick={{ fill: '#475569', fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value, name) => [Number(value).toFixed(3), name]}
|
||||||
|
labelFormatter={(label) => `X: ${Number(label).toFixed(3)}`}
|
||||||
|
contentStyle={{ borderRadius: '12px', border: 'none', boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sortedResults.map((item) => {
|
||||||
|
const lineData = [...(item.left_nodes || []), ...(item.right_nodes || [])].map(node => ({
|
||||||
|
x: Number(node[0]),
|
||||||
|
y: Number(node[1])
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={item.term}
|
||||||
|
data={lineData}
|
||||||
|
type="linear"
|
||||||
|
dataKey="y"
|
||||||
|
name={item.term.toUpperCase()}
|
||||||
|
stroke={item.color}
|
||||||
|
strokeWidth={4}
|
||||||
|
dot={{ r: 5, strokeWidth: 2, fill: '#fff' }}
|
||||||
|
activeDot={{ r: 8 }}
|
||||||
|
isAnimationActive={true}
|
||||||
|
animationDuration={1500}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leyenda */}
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-8 gap-y-3 mt-6 pb-2">
|
||||||
|
{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 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium uppercase tracking-wide"
|
||||||
|
style={{ color: item.color }}
|
||||||
|
>
|
||||||
|
{item.term}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Step3FinalGraph;
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import BlankCardsCounter from '../BlankCardsCounter';
|
||||||
|
|
||||||
|
export default function SubscaleModal({ onClose, onSave, targetInfo }) {
|
||||||
|
|
||||||
|
const [cardsCount, setCardsCount] = useState(targetInfo?.initialData?.cardsCount || 2);
|
||||||
|
const [blankCards, setBlankCards] = useState(targetInfo?.initialData?.blankCards || [0]);
|
||||||
|
|
||||||
|
const handleAddCard = () => {
|
||||||
|
setCardsCount(prev => prev + 1);
|
||||||
|
setBlankCards([...blankCards, 0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveCard = () => {
|
||||||
|
if (cardsCount <= 2) return;
|
||||||
|
setCardsCount(prev => prev - 1);
|
||||||
|
setBlankCards(blankCards.slice(0, -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlankCardChange = (index, delta) => {
|
||||||
|
const newBlanks = [...blankCards];
|
||||||
|
if (newBlanks[index] + delta >= 0) {
|
||||||
|
newBlanks[index] += delta;
|
||||||
|
setBlankCards(newBlanks);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSave(targetInfo.term, targetInfo.side, { cardsCount, blankCards });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
onSave(targetInfo.term, targetInfo.side, null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/40 backdrop-blur-sm animate-fade-in">
|
||||||
|
<div className="bg-white w-full max-w-5xl p-8 rounded-3xl shadow-2xl mx-4 flex flex-col">
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center mb-6 border-b pb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800">Diseñar Subescala</h2>
|
||||||
|
<p className="text-slate-500 font-medium">
|
||||||
|
Ajustando pendiente <span className="text-blue-600 font-bold">{targetInfo.side === 'left' ? 'Izquierda (Ascendente)' : 'Derecha (Descendente)'}</span> del término <span className="text-blue-600 font-bold">"{targetInfo.term}"</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="w-10 h-10 bg-slate-100 hover:bg-slate-200 text-slate-600 rounded-full font-bold transition-colors">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tablero */}
|
||||||
|
<div className="w-full py-10 overflow-x-auto flex justify-start px-4">
|
||||||
|
<div className="flex flex-row items-start min-w-max relative">
|
||||||
|
{Array.from({ length: cardsCount }).map((_, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<div className="flex flex-col items-center mx-2 my-2 relative z-20">
|
||||||
|
<div className="relative w-32 h-40 bg-slate-50 border-2 border-slate-300 rounded-2xl shadow-sm flex flex-col items-center justify-center group">
|
||||||
|
{cardsCount > 2 && index === cardsCount - 1 && (
|
||||||
|
<button onClick={handleRemoveCard} className="absolute -top-3 -right-3 w-8 h-8 bg-white text-slate-400 rounded-full border border-slate-200 flex items-center justify-center font-bold hover:bg-red-500 hover:text-white transition-colors z-10 shadow-sm opacity-0 group-hover:opacity-100">×</button>
|
||||||
|
)}
|
||||||
|
<span className="text-4xl font-black text-slate-200">{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{index < cardsCount - 1 && (
|
||||||
|
<BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mx-2 my-2 h-40 flex items-center">
|
||||||
|
<button onClick={handleAddCard} className="w-32 h-40 border-4 border-dashed border-slate-300 rounded-2xl flex flex-col items-center justify-center text-slate-400 font-bold hover:bg-blue-50 hover:border-blue-400 hover:text-blue-500 transition-colors">
|
||||||
|
<span className="text-3xl">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botones */}
|
||||||
|
<div className="mt-8 flex justify-between items-center border-t pt-6">
|
||||||
|
<button onClick={handleDelete} className="px-6 py-3 rounded-xl font-bold text-red-500 hover:bg-red-50 transition-colors">
|
||||||
|
Borrar Subescala
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button onClick={onClose} className="px-6 py-3 rounded-xl font-bold text-slate-600 hover:bg-slate-100 transition-colors">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSave} className="px-8 py-3 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl shadow-md transition-colors">
|
||||||
|
Guardar Subescala
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function MainLayout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 font-sans text-slate-900">
|
||||||
|
|
||||||
|
{/* Cabecera */}
|
||||||
|
<header className="bg-white border-b border-slate-200 sticky top-0 z-50 shadow-sm">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 h-14 flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center shadow-inner">
|
||||||
|
<span className="text-white font-black text-xl leading-none">DoC</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-800">
|
||||||
|
Deck of Cards
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Contenido principal */}
|
||||||
|
<main className="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ComposedChart, Line, XAxis, YAxis, CartesianGrid, ReferenceArea, ReferenceLine, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
|
|
||||||
|
export default function Chart({ baseScale, mfDefinitions, selectedTerm, colors }) {
|
||||||
|
const scaleKeys = Object.keys(baseScale);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full bg-slate-50/50 rounded-2xl border border-slate-200 p-2 mb-6">
|
||||||
|
<ResponsiveContainer width="99%" height={320}>
|
||||||
|
<ComposedChart margin={{ top: 20, right: 30, left: 10, bottom: 10 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
|
<XAxis type="number" dataKey="x" domain={[0, 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: 12 }} />
|
||||||
|
<YAxis domain={[0, 1]} tick={{ fill: '#475569', fontSize: 12 }} />
|
||||||
|
<Tooltip formatter={(value) => typeof value === 'number' ? value.toFixed(2) : value} />
|
||||||
|
|
||||||
|
{scaleKeys.map((name, index) => {
|
||||||
|
const val = baseScale[name];
|
||||||
|
const mf = mfDefinitions[name];
|
||||||
|
if (!mf) return null;
|
||||||
|
const color = colors[index % colors.length];
|
||||||
|
const isSelected = selectedTerm === name;
|
||||||
|
const trapezeData = [ { x: mf.supportStart, y: 0 }, { x: mf.coreStart, y: 1 }, { x: mf.coreEnd, y: 1 }, { x: mf.supportEnd, y: 0 } ];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`mf-${name}`}>
|
||||||
|
<ReferenceLine x={val} stroke={color} strokeDasharray="4 4" strokeWidth={isSelected ? 2 : 1} label={{ position: 'top', value: name, fill: color, fontWeight: isSelected ? '900' : 'normal', fontSize: 12 }} />
|
||||||
|
<ReferenceArea x1={mf.supportStart} x2={mf.supportEnd} fill={color} fillOpacity={isSelected ? 0.3 : 0.05} />
|
||||||
|
<ReferenceArea x1={mf.coreStart} x2={mf.coreEnd} fill={color} fillOpacity={isSelected ? 0.6 : 0.15} />
|
||||||
|
<Line data={trapezeData} dataKey="y" type="linear" stroke={color} strokeWidth={isSelected ? 4 : 2} dot={isSelected ? { r: 5, fill: color, stroke: '#fff', strokeWidth: 2 } : false} activeDot={false} isAnimationActive={false} />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
export default function Controls({
|
||||||
|
selectedTerm, currentMf, selectedColor, baseScale, mfDefinitions, updateCurrentMf,
|
||||||
|
subscales, onOpenSubscale
|
||||||
|
}) {
|
||||||
|
if (!selectedTerm || !currentMf) return null;
|
||||||
|
|
||||||
|
const scaleKeys = Object.keys(baseScale);
|
||||||
|
const selectedIndex = scaleKeys.indexOf(selectedTerm);
|
||||||
|
|
||||||
|
let absoluteMin = 0, absoluteMax = 1;
|
||||||
|
if (selectedIndex > 0) absoluteMin = mfDefinitions[scaleKeys[selectedIndex - 1]].coreEnd;
|
||||||
|
if (selectedIndex < scaleKeys.length - 1) absoluteMax = mfDefinitions[scaleKeys[selectedIndex + 1]].coreStart;
|
||||||
|
|
||||||
|
const leftSubscale = subscales?.[selectedTerm]?.left;
|
||||||
|
const rightSubscale = subscales?.[selectedTerm]?.right;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-md relative overflow-hidden">
|
||||||
|
<div className="absolute top-0 left-0 w-full h-1.5" style={{ backgroundColor: selectedColor }}></div>
|
||||||
|
<h3 className="text-xl font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||||||
|
Ajustando: <span style={{ color: selectedColor }}>"{selectedTerm}"</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
{/* Lado izquierdo (Pendiente ascendente) */}
|
||||||
|
<div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100">
|
||||||
|
<div>
|
||||||
|
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
|
||||||
|
<span>Inicio del Soporte (Punto inferior)</span><span style={{ color: selectedColor }}>{currentMf.supportStart.toFixed(3)}</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.supportStart} onChange={(e) => updateCurrentMf('supportStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
|
||||||
|
<span>Inicio del Núcleo (Punto superior)</span><span style={{ color: selectedColor }}>{currentMf.coreStart.toFixed(3)}</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.coreStart} onChange={(e) => updateCurrentMf('coreStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón subescala izquierda */}
|
||||||
|
<div className="pt-2 border-t border-slate-200 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenSubscale(selectedTerm, 'left', leftSubscale)}
|
||||||
|
className={`text-sm font-bold px-4 py-2 rounded-lg transition-all border ${leftSubscale ? 'bg-blue-50 text-blue-700 border-blue-200' : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{leftSubscale ? `✎ Subescala (Cartas: ${leftSubscale.cardsCount})` : '+ Añadir Subescala'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado derecho (Pendiente descendente) */}
|
||||||
|
<div className="space-y-4 bg-slate-50 p-4 rounded-xl border border-slate-100">
|
||||||
|
<div>
|
||||||
|
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
|
||||||
|
<span>Fin del Núcleo (Punto superior)</span><span style={{ color: selectedColor }}>{currentMf.coreEnd.toFixed(3)}</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.coreEnd} onChange={(e) => updateCurrentMf('coreEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="flex justify-between text-xs font-bold text-slate-600 mb-1">
|
||||||
|
<span>Fin del Soporte (Punto inferior)</span><span style={{ color: selectedColor }}>{currentMf.supportEnd.toFixed(3)}</span>
|
||||||
|
</label>
|
||||||
|
<input type="range" min={absoluteMin} max={absoluteMax} step="0.001" value={currentMf.supportEnd} onChange={(e) => updateCurrentMf('supportEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botón subescala derecha */}
|
||||||
|
<div className="pt-2 border-t border-slate-200 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenSubscale(selectedTerm, 'right', rightSubscale)}
|
||||||
|
className={`text-sm font-bold px-4 py-2 rounded-lg transition-all border ${rightSubscale ? 'bg-blue-50 text-blue-700 border-blue-200' : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'}`}
|
||||||
|
>
|
||||||
|
{rightSubscale ? `✎ Subescala (Cartas: ${rightSubscale.cardsCount})` : '+ Añadir Subescala'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1 +1,6 @@
|
|||||||
export const API_BASE_URL = import.meta.env.VITE_API_URL;
|
export const API_BASE_URL = import.meta.env.VITE_API_URL;
|
||||||
|
|
||||||
|
export const CHART_COLORS = [
|
||||||
|
'#ef4444', '#f59e0b', '#10b981', '#3b82f6',
|
||||||
|
'#d946ef', '#06b6d4', '#8b5cf6', '#f43f5e', '#6366f1'
|
||||||
|
];
|
||||||
@@ -1 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import Axios from 'axios';
|
import Axios from 'axios';
|
||||||
import { API_BASE_URL } from '../config';
|
import { API_BASE_URL } from '../config';
|
||||||
|
|
||||||
const axios = Axios.create({
|
const api = Axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
@@ -10,4 +10,4 @@ const axios = Axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
export default axios;
|
export default api;
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import CriterionInput from '../components/CriterionInput';
|
||||||
|
import CardEditor from '../components/CardEditor';
|
||||||
|
import BlankCardsCounter from '../components/BlankCardsCounter';
|
||||||
|
import AddLevelButton from '../components/AddLevelButton';
|
||||||
|
import Chart from '../components/membershipFunction/Chart';
|
||||||
|
import Controls from '../components/membershipFunction/Controls';
|
||||||
|
import { calculateValueFunction } from '../services/docService';
|
||||||
|
|
||||||
|
const COLORS = ['#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#d946ef', '#06b6d4', '#8b5cf6', '#f43f5e', '#6366f1'];
|
||||||
|
|
||||||
|
export default function AdvancedMode() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [criterionName, setCriterionName] = useState('');
|
||||||
|
const [levels, setLevels] = useState(['', '', '']);
|
||||||
|
const [blankCards, setBlankCards] = useState([0, 0]);
|
||||||
|
const [errors, setErrors] = useState({ criterion: false, levels: [] });
|
||||||
|
|
||||||
|
const [isZoomActive, setIsZoomActive] = useState(true);
|
||||||
|
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
const tableRef = useRef(null);
|
||||||
|
const [dimensions, setDimensions] = useState({ container: 1000, table: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const updateMeasurements = () => {
|
||||||
|
if (containerRef.current && tableRef.current) {
|
||||||
|
setDimensions({
|
||||||
|
container: containerRef.current.offsetWidth,
|
||||||
|
table: tableRef.current.scrollWidth
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(updateMeasurements, 50);
|
||||||
|
window.addEventListener('resize', updateMeasurements);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
window.removeEventListener('resize', updateMeasurements);
|
||||||
|
};
|
||||||
|
}, [levels, blankCards, step]);
|
||||||
|
|
||||||
|
// Estados Fase 2 (Franjas)
|
||||||
|
const [baseScale, setBaseScale] = useState({});
|
||||||
|
const [selectedTerm, setSelectedTerm] = useState(null);
|
||||||
|
const [mfDefinitions, setMfDefinitions] = useState({});
|
||||||
|
|
||||||
|
// Manejadores de Escala
|
||||||
|
const handleCriterionChange = (val) => { setCriterionName(val); if (errors.criterion) setErrors({ ...errors, criterion: false }); };
|
||||||
|
const handleLevelChange = (index, newValue) => { const newLevels = [...levels]; newLevels[index] = newValue; setLevels(newLevels); if (errors.levels[index]) setErrors({ ...errors, levels: errors.levels.map((e, i) => i === index ? false : e) }); };
|
||||||
|
const handleAddLevel = () => { setLevels([...levels, '']); setBlankCards([...blankCards, 0]); setErrors({ ...errors, levels: [...errors.levels, false] }); };
|
||||||
|
const handleRemoveLevel = (indexToRemove) => { if (levels.length <= 3) return; setLevels(levels.filter((_, i) => i !== indexToRemove)); setBlankCards(blankCards.filter((_, i) => i !== (indexToRemove === 0 ? 0 : indexToRemove - 1))); setErrors({ ...errors, levels: errors.levels.filter((_, i) => i !== indexToRemove) }); };
|
||||||
|
const handleBlankCardChange = (index, delta) => { const newCards = [...blankCards]; if (newCards[index] + delta >= 0) { newCards[index] += delta; setBlankCards(newCards); } };
|
||||||
|
|
||||||
|
const handleGenerateBaseScale = async () => {
|
||||||
|
const newErrors = { criterion: !criterionName.trim(), levels: levels.map(l => !l.trim()) };
|
||||||
|
if (newErrors.criterion || newErrors.levels.includes(true)) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return alert("Por favor, rellena todos los campos.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const payloadBase = { criterion_name: criterionName.trim(), levels: levels.map(l => l.trim()), blank_cards: blankCards, references: { "0": 0, [(levels.length - 1).toString()]: 1 } };
|
||||||
|
const baseResult = await calculateValueFunction(payloadBase);
|
||||||
|
|
||||||
|
setBaseScale(baseResult.values);
|
||||||
|
const initialMfs = {};
|
||||||
|
Object.entries(baseResult.values).forEach(([name, value]) => { initialMfs[name] = { supportStart: value, coreStart: value, coreEnd: value, supportEnd: value }; });
|
||||||
|
|
||||||
|
setMfDefinitions(initialMfs);
|
||||||
|
setSelectedTerm(Object.keys(baseResult.values)[0]);
|
||||||
|
setStep(2);
|
||||||
|
} catch (error) { alert("Error: " + error); } finally { setIsLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCurrentMf = (field, value) => {
|
||||||
|
if (!selectedTerm) return;
|
||||||
|
let numValue = parseFloat(value);
|
||||||
|
|
||||||
|
setMfDefinitions(prev => {
|
||||||
|
const scaleKeys = Object.keys(baseScale);
|
||||||
|
const selectedIndex = scaleKeys.indexOf(selectedTerm);
|
||||||
|
let prevCoreEnd = 0, prevSupportEnd = 0, nextCoreStart = 1, nextSupportStart = 1;
|
||||||
|
|
||||||
|
if (selectedIndex > 0) {
|
||||||
|
prevCoreEnd = prev[scaleKeys[selectedIndex - 1]].coreEnd;
|
||||||
|
prevSupportEnd = prev[scaleKeys[selectedIndex - 1]].supportEnd;
|
||||||
|
}
|
||||||
|
if (selectedIndex < scaleKeys.length - 1) {
|
||||||
|
nextCoreStart = prev[scaleKeys[selectedIndex + 1]].coreStart;
|
||||||
|
nextSupportStart = prev[scaleKeys[selectedIndex + 1]].supportStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = baseScale[selectedTerm];
|
||||||
|
|
||||||
|
if (field === 'supportStart' && numValue < prevCoreEnd) numValue = prevCoreEnd;
|
||||||
|
if (field === 'coreStart' && numValue < prevSupportEnd) numValue = prevSupportEnd;
|
||||||
|
if (field === 'coreEnd' && numValue > nextSupportStart) numValue = nextSupportStart;
|
||||||
|
if (field === 'supportEnd' && numValue > nextCoreStart) numValue = nextCoreStart;
|
||||||
|
|
||||||
|
if ((field === 'supportStart' || field === 'coreStart') && numValue > anchor) numValue = anchor;
|
||||||
|
if ((field === 'supportEnd' || field === 'coreEnd') && numValue < anchor) numValue = anchor;
|
||||||
|
|
||||||
|
const current = { ...prev[selectedTerm], [field]: numValue };
|
||||||
|
|
||||||
|
if (field === 'supportStart') {
|
||||||
|
if (current.supportStart > current.coreStart) current.coreStart = current.supportStart;
|
||||||
|
if (current.coreStart > current.coreEnd) current.coreEnd = current.coreStart;
|
||||||
|
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
|
||||||
|
} else if (field === 'coreStart') {
|
||||||
|
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
|
||||||
|
if (current.coreStart > current.coreEnd) current.coreEnd = current.coreStart;
|
||||||
|
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
|
||||||
|
} else if (field === 'coreEnd') {
|
||||||
|
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
|
||||||
|
if (current.coreEnd < current.coreStart) current.coreStart = current.coreEnd;
|
||||||
|
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
|
||||||
|
} else if (field === 'supportEnd') {
|
||||||
|
if (current.supportEnd < current.coreEnd) current.coreEnd = current.supportEnd;
|
||||||
|
if (current.coreEnd < current.coreStart) current.coreStart = current.coreEnd;
|
||||||
|
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, [selectedTerm]: current };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalSubmit = () => {
|
||||||
|
console.log("PAYLOAD DOC-MF:", { base_scale: baseScale, membership_functions: mfDefinitions });
|
||||||
|
alert("¡Mira la consola! JSON preparado.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const scaleKeys = Object.keys(baseScale);
|
||||||
|
const selectedColor = COLORS[scaleKeys.indexOf(selectedTerm) % COLORS.length] || '#2563eb';
|
||||||
|
|
||||||
|
const needsZoom = dimensions.table > dimensions.container;
|
||||||
|
const dynamicScale = needsZoom ? (dimensions.container / dimensions.table) * 0.95 : 1;
|
||||||
|
const currentScale = isZoomActive && needsZoom ? dynamicScale : 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col items-center">
|
||||||
|
|
||||||
|
{/* PASO 1 */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 mb-6 flex flex-col items-center animate-fade-in relative overflow-visible">
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center w-full mb-4 border-b pb-3 relative z-30">
|
||||||
|
<h2 className="text-xl font-bold text-slate-800">
|
||||||
|
Paso 1: Establecer escala
|
||||||
|
</h2>
|
||||||
|
{needsZoom && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (containerRef.current) containerRef.current.scrollLeft = 0;
|
||||||
|
setIsZoomActive(!isZoomActive);
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg font-bold transition-all shadow-sm border text-sm ${isZoomActive ? 'bg-blue-50 border-blue-200 text-blue-700' : 'bg-white border-slate-200 text-slate-600'}`}
|
||||||
|
>
|
||||||
|
<span>{isZoomActive ? '🔍' : '🖼️'}</span>
|
||||||
|
{isZoomActive ? 'Ver de cerca (Scroll)' : 'Ajustar mesa'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CriterionInput criterionName={criterionName} setCriterionName={handleCriterionChange} error={errors.criterion} />
|
||||||
|
|
||||||
|
<div ref={containerRef} className={`w-full mt-2 transition-all relative ${!isZoomActive && needsZoom ? 'overflow-x-auto flex justify-start pb-8 pt-4 px-4' : 'overflow-hidden flex justify-center pb-8 pt-4'}`}>
|
||||||
|
<div className={`flex flex-row items-start min-w-max transition-transform duration-500 ease-out px-4 origin-top`} style={{ transform: `scale(${currentScale})`, marginBottom: isZoomActive && currentScale < 1 ? `-${(1 - currentScale) * 300}px` : '0px' }}>
|
||||||
|
|
||||||
|
<div ref={tableRef} className="flex flex-row items-start relative px-10 overflow-visible">
|
||||||
|
|
||||||
|
{levels.map((level, index) => (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<div className="flex flex-col items-center mx-2 my-2 relative z-20">
|
||||||
|
<CardEditor index={index} level={level} handleLevelChange={handleLevelChange} handleRemoveLevel={handleRemoveLevel} totalLevels={levels.length} error={errors.levels[index]} canRemove={levels.length > 3} />
|
||||||
|
</div>
|
||||||
|
{index < levels.length - 1 && (
|
||||||
|
<BlankCardsCounter index={index} blankCardsCount={blankCards[index]} handleBlankCardChange={handleBlankCardChange} />
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mx-1 my-2 h-52 flex items-center justify-center">
|
||||||
|
<div className="w-10 h-1 bg-slate-200 rounded"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AddLevelButton handleAddLevel={handleAddLevel} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full max-w-lg mt-2 pt-6 border-t border-slate-200 flex flex-col items-center z-20 relative bg-white">
|
||||||
|
<button onClick={handleGenerateBaseScale} disabled={isLoading} className={`w-full py-3 text-white text-lg font-bold rounded-xl shadow-md transition-all active:scale-[0.98] ${isLoading ? 'bg-slate-400 cursor-not-allowed' : 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700'}`}>
|
||||||
|
{isLoading ? 'Calculando...' : 'Generar Gráfica Continua'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PASO 2 */}
|
||||||
|
{step === 2 && (
|
||||||
|
<div className="w-full bg-white p-6 rounded-2xl shadow-sm border border-slate-200 animate-fade-in relative overflow-visible">
|
||||||
|
<div className="flex justify-between items-center mb-6 border-b pb-3">
|
||||||
|
<h2 className="text-xl font-bold text-slate-800">Paso 2: Modelar Conceptos Difusos</h2>
|
||||||
|
<button onClick={() => setStep(1)} className="text-slate-500 hover:text-blue-600 text-sm font-semibold underline">← Volver a las cartas</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap justify-center gap-3 mb-6">
|
||||||
|
{scaleKeys.map((name, index) => {
|
||||||
|
const color = COLORS[index % COLORS.length];
|
||||||
|
const isSelected = selectedTerm === name;
|
||||||
|
return (
|
||||||
|
<button key={name} onClick={() => setSelectedTerm(name)} style={isSelected ? { backgroundColor: color, borderColor: color, color: '#fff' } : { borderColor: color, color: '#475569' }} className={`px-5 py-2 rounded-lg font-bold border-2 transition-all duration-300 flex flex-col items-center shadow-sm hover:shadow-md ${isSelected ? 'transform scale-105' : 'bg-white opacity-80 hover:opacity-100'}`}>
|
||||||
|
<span>{name}</span><span className="text-[10px] font-normal opacity-80">(X: {baseScale[name].toFixed(2)})</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Chart baseScale={baseScale} mfDefinitions={mfDefinitions} selectedTerm={selectedTerm} colors={COLORS} />
|
||||||
|
|
||||||
|
<Controls selectedTerm={selectedTerm} currentMf={mfDefinitions[selectedTerm]} selectedColor={selectedColor} baseScale={baseScale} mfDefinitions={mfDefinitions} updateCurrentMf={updateCurrentMf} />
|
||||||
|
|
||||||
|
<div className="w-full mt-8 flex justify-center">
|
||||||
|
<button onClick={handleFinalSubmit} className="px-10 py-3 bg-slate-900 text-white text-lg font-bold rounded-xl shadow-md hover:bg-black hover:shadow-lg transition-all">
|
||||||
|
Guardar Todo el Espectro Difuso
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,23 +14,37 @@ export default function BasicMode() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState({ criterion: false, levels: [] });
|
||||||
|
|
||||||
const handleCalculate = async () => {
|
const handleCalculate = async () => {
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
const newErrors = { criterion: false, levels: Array(levels.length).fill(false) };
|
||||||
|
|
||||||
|
if (!criterionName.trim()) {
|
||||||
|
newErrors.criterion = true;
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
levels.forEach((level, idx) => {
|
||||||
|
if (!level.trim()) {
|
||||||
|
newErrors.levels[idx] = true;
|
||||||
|
hasError = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
|
||||||
|
if (hasError) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
|
|
||||||
const firstIndex = "0";
|
|
||||||
const lastIndex = (levels.length - 1).toString();
|
|
||||||
|
|
||||||
const currentReferences = {
|
|
||||||
[firstIndex]: 0,
|
|
||||||
[lastIndex]: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
criterion_name: criterionName || "Criterio sin nombre",
|
criterion_name: criterionName.trim(),
|
||||||
levels: levels,
|
levels: levels.map(l => l.trim()),
|
||||||
blank_cards: blankCards,
|
blank_cards: blankCards,
|
||||||
references: currentReferences
|
references: { "0": 0, [(levels.length - 1).toString()]: 1 }
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -43,24 +57,40 @@ export default function BasicMode() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCriterionChange = (val) => {
|
||||||
|
setCriterionName(val);
|
||||||
|
if (errors.criterion) setErrors({ ...errors, criterion: false });
|
||||||
|
};
|
||||||
|
|
||||||
const handleLevelChange = (index, newValue) => {
|
const handleLevelChange = (index, newValue) => {
|
||||||
const newLevels = [...levels];
|
const newLevels = [...levels];
|
||||||
newLevels[index] = newValue;
|
newLevels[index] = newValue;
|
||||||
setLevels(newLevels);
|
setLevels(newLevels);
|
||||||
|
|
||||||
|
if (errors.levels[index]) {
|
||||||
|
const newErrLevels = [...errors.levels];
|
||||||
|
newErrLevels[index] = false;
|
||||||
|
setErrors({ ...errors, levels: newErrLevels });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLevel = () => {
|
const handleAddLevel = () => {
|
||||||
setLevels([...levels, '']);
|
setLevels([...levels, '']);
|
||||||
setBlankCards([...blankCards, 0]);
|
setBlankCards([...blankCards, 0]);
|
||||||
|
setErrors({ ...errors, levels: [...errors.levels, false] });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveLevel = (indexToRemove) => {
|
const handleRemoveLevel = (indexToRemove) => {
|
||||||
if (levels.length <= 2) return;
|
if (levels.length <= 3) return;
|
||||||
const newLevels = levels.filter((_, index) => index !== indexToRemove);
|
const newLevels = levels.filter((_, index) => index !== indexToRemove);
|
||||||
const blankIndexToRemove = indexToRemove === 0 ? 0 : indexToRemove - 1;
|
const blankIndexToRemove = indexToRemove === 0 ? 0 : indexToRemove - 1;
|
||||||
const newBlankCards = blankCards.filter((_, index) => index !== blankIndexToRemove);
|
const newBlankCards = blankCards.filter((_, index) => index !== blankIndexToRemove);
|
||||||
|
|
||||||
|
const newErrLevels = errors.levels.filter((_, index) => index !== indexToRemove);
|
||||||
|
|
||||||
setLevels(newLevels);
|
setLevels(newLevels);
|
||||||
setBlankCards(newBlankCards);
|
setBlankCards(newBlankCards);
|
||||||
|
setErrors({ ...errors, levels: newErrLevels });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlankCardChange = (index, delta) => {
|
const handleBlankCardChange = (index, delta) => {
|
||||||
@@ -77,7 +107,8 @@ export default function BasicMode() {
|
|||||||
|
|
||||||
<CriterionInput
|
<CriterionInput
|
||||||
criterionName={criterionName}
|
criterionName={criterionName}
|
||||||
setCriterionName={setCriterionName}
|
setCriterionName={handleCriterionChange}
|
||||||
|
error={errors.criterion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="w-full max-w-lg flex flex-col items-center">
|
<div className="w-full max-w-lg flex flex-col items-center">
|
||||||
@@ -90,6 +121,7 @@ export default function BasicMode() {
|
|||||||
handleLevelChange={handleLevelChange}
|
handleLevelChange={handleLevelChange}
|
||||||
handleRemoveLevel={handleRemoveLevel}
|
handleRemoveLevel={handleRemoveLevel}
|
||||||
totalLevels={levels.length}
|
totalLevels={levels.length}
|
||||||
|
error={errors.levels[index]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{index < levels.length - 1 && (
|
{index < levels.length - 1 && (
|
||||||
@@ -120,5 +152,5 @@ export default function BasicMode() {
|
|||||||
<ValueFunctionChart result={result} />
|
<ValueFunctionChart result={result} />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Step1BaseScale from '../components/editor/Step1BaseScale';
|
||||||
|
import Step2FuzzyModeling from '../components/editor/Step2FuzzyModeling';
|
||||||
|
import SubscaleModal from '../components/editor/SubscaleModal';
|
||||||
|
import { calculateValueFunction, buildFuzzyGraph } from '../services/docService';
|
||||||
|
import Step3FinalGraph from '../components/editor/Step3FinalGraph';
|
||||||
|
|
||||||
|
export default function DocEditor() {
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// ESTADOS: FASE 1
|
||||||
|
const [criterionName, setCriterionName] = useState('');
|
||||||
|
const [levels, setLevels] = useState(['', '', '']);
|
||||||
|
const [blankCards, setBlankCards] = useState([0, 0]);
|
||||||
|
const [errors, setErrors] = useState({ criterion: false, levels: [] });
|
||||||
|
|
||||||
|
// ESTADOS: FASE 2
|
||||||
|
const [baseScale, setBaseScale] = useState({});
|
||||||
|
const [selectedTerm, setSelectedTerm] = useState(null);
|
||||||
|
const [mfDefinitions, setMfDefinitions] = useState({});
|
||||||
|
const [subscales, setSubscales] = useState({});
|
||||||
|
const [modalTarget, setModalTarget] = useState(null);
|
||||||
|
|
||||||
|
// ESTADO: FASE 3
|
||||||
|
const [finalResult, setFinalResult] = useState(null);
|
||||||
|
|
||||||
|
// MANEJADORES: FASE 1
|
||||||
|
const handleCriterionChange = (val) => { setCriterionName(val); if (errors.criterion) setErrors({ ...errors, criterion: false }); };
|
||||||
|
const handleLevelChange = (index, newValue) => { const newLevels = [...levels]; newLevels[index] = newValue; setLevels(newLevels); if (errors.levels[index]) setErrors({ ...errors, levels: errors.levels.map((e, i) => i === index ? false : e) }); };
|
||||||
|
const handleAddLevel = () => { setLevels([...levels, '']); setBlankCards([...blankCards, 0]); setErrors({ ...errors, levels: [...errors.levels, false] }); };
|
||||||
|
const handleRemoveLevel = (indexToRemove) => { if (levels.length <= 3) return; setLevels(levels.filter((_, i) => i !== indexToRemove)); setBlankCards(blankCards.filter((_, i) => i !== (indexToRemove === 0 ? 0 : indexToRemove - 1))); setErrors({ ...errors, levels: errors.levels.filter((_, i) => i !== indexToRemove) }); };
|
||||||
|
const handleBlankCardChange = (index, delta) => { const newCards = [...blankCards]; if (newCards[index] + delta >= 0) { newCards[index] += delta; setBlankCards(newCards); } };
|
||||||
|
|
||||||
|
const handleGenerateBaseScale = async () => {
|
||||||
|
const newErrors = { criterion: !criterionName.trim(), levels: levels.map(l => !l.trim()) };
|
||||||
|
if (newErrors.criterion || newErrors.levels.includes(true)) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return alert("Por favor, rellena todos los campos.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const payloadBase = { criterion_name: criterionName.trim(), levels: levels.map(l => l.trim()), blank_cards: blankCards, references: { "0": 0, [(levels.length - 1).toString()]: 1 } };
|
||||||
|
const baseResult = await calculateValueFunction(payloadBase);
|
||||||
|
setBaseScale(baseResult.values);
|
||||||
|
const initialMfs = {};
|
||||||
|
Object.entries(baseResult.values).forEach(([name, value]) => { initialMfs[name] = { supportStart: value, coreStart: value, coreEnd: value, supportEnd: value }; });
|
||||||
|
setMfDefinitions(initialMfs);
|
||||||
|
setSelectedTerm(Object.keys(baseResult.values)[0]);
|
||||||
|
setStep(2);
|
||||||
|
} catch (error) { alert("Error: " + error); } finally { setIsLoading(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// MANEJADORES: FASE 2
|
||||||
|
const updateCurrentMf = (field, value) => {
|
||||||
|
if (!selectedTerm) return;
|
||||||
|
let numValue = parseFloat(value);
|
||||||
|
setMfDefinitions(prev => {
|
||||||
|
const scaleKeys = Object.keys(baseScale);
|
||||||
|
const selectedIndex = scaleKeys.indexOf(selectedTerm);
|
||||||
|
let prevCoreEnd = 0, prevSupportEnd = 0, nextCoreStart = 1, nextSupportStart = 1;
|
||||||
|
|
||||||
|
if (selectedIndex > 0) {
|
||||||
|
prevCoreEnd = prev[scaleKeys[selectedIndex - 1]].coreEnd;
|
||||||
|
prevSupportEnd = prev[scaleKeys[selectedIndex - 1]].supportEnd;
|
||||||
|
}
|
||||||
|
if (selectedIndex < scaleKeys.length - 1) {
|
||||||
|
nextCoreStart = prev[scaleKeys[selectedIndex + 1]].coreStart;
|
||||||
|
nextSupportStart = prev[scaleKeys[selectedIndex + 1]].supportStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = baseScale[selectedTerm];
|
||||||
|
|
||||||
|
if (field === 'supportStart' && numValue < prevCoreEnd) numValue = prevCoreEnd;
|
||||||
|
if (field === 'coreStart' && numValue < prevSupportEnd) numValue = prevSupportEnd;
|
||||||
|
if (field === 'coreEnd' && numValue > nextSupportStart) numValue = nextSupportStart;
|
||||||
|
if (field === 'supportEnd' && numValue > nextCoreStart) numValue = nextCoreStart;
|
||||||
|
if ((field === 'supportStart' || field === 'coreStart') && numValue > anchor) numValue = anchor;
|
||||||
|
if ((field === 'supportEnd' || field === 'coreEnd') && numValue < anchor) numValue = anchor;
|
||||||
|
|
||||||
|
const current = { ...prev[selectedTerm], [field]: numValue };
|
||||||
|
|
||||||
|
if (field === 'supportStart') {
|
||||||
|
if (current.supportStart > current.coreStart) current.coreStart = current.supportStart;
|
||||||
|
if (current.coreStart > current.coreEnd) current.coreEnd = current.coreStart;
|
||||||
|
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
|
||||||
|
} else if (field === 'coreStart') {
|
||||||
|
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
|
||||||
|
if (current.coreStart > current.coreEnd) current.coreEnd = current.coreStart;
|
||||||
|
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
|
||||||
|
} else if (field === 'coreEnd') {
|
||||||
|
if (current.coreEnd > current.supportEnd) current.supportEnd = current.coreEnd;
|
||||||
|
if (current.coreEnd < current.coreStart) current.coreStart = current.coreEnd;
|
||||||
|
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
|
||||||
|
} else if (field === 'supportEnd') {
|
||||||
|
if (current.supportEnd < current.coreEnd) current.coreEnd = current.supportEnd;
|
||||||
|
if (current.coreEnd < current.coreStart) current.coreStart = current.coreEnd;
|
||||||
|
if (current.coreStart < current.supportStart) current.supportStart = current.coreStart;
|
||||||
|
}
|
||||||
|
return { ...prev, [selectedTerm]: current };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenSubscale = (term, side, initialData) => {
|
||||||
|
setModalTarget({ term, side, initialData });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSubscale = (term, side, data) => {
|
||||||
|
setSubscales(prev => ({
|
||||||
|
...prev,
|
||||||
|
[term]: {
|
||||||
|
...prev[term],
|
||||||
|
[side]: data
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
setModalTarget(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Petición para el endpoint "build"
|
||||||
|
const handleFinalSubmit = async () => {
|
||||||
|
const scaleKeys = Object.keys(baseScale);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
levels: scaleKeys.map(term => {
|
||||||
|
const mf = mfDefinitions[term];
|
||||||
|
const sub = subscales[term] || {};
|
||||||
|
|
||||||
|
const c_start = Number(mf.coreStart.toFixed(4));
|
||||||
|
const c_end = Number(mf.coreEnd.toFixed(4));
|
||||||
|
|
||||||
|
const s_start = Math.min(Number(mf.supportStart.toFixed(4)), c_start);
|
||||||
|
const s_end = Math.max(Number(mf.supportEnd.toFixed(4)), c_end);
|
||||||
|
|
||||||
|
const leftCount = sub.left ? sub.left.cardsCount : 2;
|
||||||
|
const left_nodes_x = Array.from({ length: leftCount }).map((_, i) =>
|
||||||
|
Number((s_start + (c_start - s_start) * (i / (leftCount - 1))).toFixed(4))
|
||||||
|
);
|
||||||
|
|
||||||
|
const rightCount = sub.right ? sub.right.cardsCount : 2;
|
||||||
|
const right_nodes_x = Array.from({ length: rightCount }).map((_, i) =>
|
||||||
|
Number((c_end + (s_end - c_end) * (i / (rightCount - 1))).toFixed(4))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
term: term,
|
||||||
|
core: [ c_start, c_end ],
|
||||||
|
support: [ s_start, s_end ],
|
||||||
|
left_nodes_x: left_nodes_x,
|
||||||
|
left_blank_cards: sub.left ? sub.left.blankCards : [0],
|
||||||
|
right_nodes_x: right_nodes_x,
|
||||||
|
right_blank_cards: sub.right ? sub.right.blankCards : [0]
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await buildFuzzyGraph(payload);
|
||||||
|
console.log("RESPUESTA DEL BACKEND:", result);
|
||||||
|
|
||||||
|
setFinalResult(result);
|
||||||
|
setStep(3);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
alert("Error del servidor: \n" + JSON.stringify(error, null, 2));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col items-center">
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<Step1BaseScale
|
||||||
|
criterionName={criterionName} handleCriterionChange={handleCriterionChange}
|
||||||
|
levels={levels} handleLevelChange={handleLevelChange}
|
||||||
|
handleAddLevel={handleAddLevel} handleRemoveLevel={handleRemoveLevel}
|
||||||
|
blankCards={blankCards} handleBlankCardChange={handleBlankCardChange}
|
||||||
|
errors={errors} handleGenerateBaseScale={handleGenerateBaseScale} isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && (
|
||||||
|
<Step2FuzzyModeling
|
||||||
|
baseScale={baseScale} mfDefinitions={mfDefinitions}
|
||||||
|
selectedTerm={selectedTerm} setSelectedTerm={setSelectedTerm}
|
||||||
|
updateCurrentMf={updateCurrentMf} handleFinalSubmit={handleFinalSubmit}
|
||||||
|
onBack={() => setStep(1)}
|
||||||
|
subscales={subscales}
|
||||||
|
onOpenSubscale={handleOpenSubscale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 3 && finalResult && (
|
||||||
|
<div className="flex flex-col gap-6 w-full">
|
||||||
|
<Step3FinalGraph data={finalResult} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => console.log("Lógica para guardar")}
|
||||||
|
className="mt-4 px-8 py-3 bg-blue-600 text-white font-bold rounded-xl shadow-md hover:bg-blue-700 w-fit self-end transition-all"
|
||||||
|
>
|
||||||
|
Finalizar y Guardar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modalTarget && (
|
||||||
|
<SubscaleModal
|
||||||
|
key={`${modalTarget.term}-${modalTarget.side}`}
|
||||||
|
onClose={() => setModalTarget(null)}
|
||||||
|
onSave={handleSaveSubscale}
|
||||||
|
targetInfo={modalTarget}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import MainLayout from '../components/layout/MainLayout';
|
||||||
|
import DocEditor from '../pages/DocEditor';
|
||||||
|
|
||||||
|
export function AppRouter() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<MainLayout />}>
|
||||||
|
<Route index element={<DocEditor />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,23 @@
|
|||||||
import axios from '../lib/axios';
|
import api from '../lib/api';
|
||||||
|
|
||||||
export const calculateValueFunction = async (payload) => {
|
export const calculateValueFunction = async (payload) => {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/criteria/doc/value-function', payload);
|
const response = await api.post('/criteria/doc/value-function', payload);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error en calculateValueFunction:", error);
|
console.error('Error calculating value function:', error);
|
||||||
throw error;
|
throw error.response?.data?.detail || error.message;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const buildFuzzyGraph = async (payload) => {
|
||||||
|
try {
|
||||||
|
const response = await api.post('/criteria/doc-mf/build', payload);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error building fuzzy graph:', error);
|
||||||
|
throw error.response?.data?.detail || error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user