diff --git a/.gitignore b/.gitignore index 0b222a3..45ef76a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ __pycache__/ *.py[cod] *$py.class -# Variables de entorno - +# Variables de entorno (solo ignoramos las locales/sobre-escrituras) +# Mantenemos .env y .env.example versionados para compartir la configuración +# de producción y el esqueleto de variables. Nunca subimos .env.local. +.env.local +.env.*.local # Configuraciones del editor .vscode/ diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..1f82712 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,21 @@ +# Backend environment variables (FastAPI) +# ---------------------------------------- +# Copia este archivo como `.env` para desarrollo local y rellena los valores. +# +# docker-compose levanta el backend con `env_file: backend/.env`. + +# Google OAuth (https://console.cloud.google.com/apis/credentials) +# IMPORTANTE: la REDIRECT_URI es la URL a la que Google devuelve al usuario, +# por tanto debe coincidir con la URL pública del backend tal como la ve el +# navegador. Usando docker-compose, el backend está expuesto en el host en +# el puerto 8070, así que: +# http://localhost:8070/api/auth/google/callback +# Si ejecutas el backend fuera de Docker en el puerto 8000, usa: +# http://localhost:8000/api/auth/google/callback +# Esta URI debe estar registrada también en la consola de Google Cloud. +GOOGLE_CLIENT_ID=tu-client-id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=tu-client-secret +GOOGLE_REDIRECT_URI=http://localhost:8070/api/auth/google/callback + +# Clave para firmar los JWT (usa algo largo y aleatorio en producción) +SECRET_KEY=cambia-esta-clave-en-produccion diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..3c23460 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,22 @@ +# Frontend environment variables (Vite) +# -------------------------------------- +# Copia este archivo como `.env.local` para desarrollo local. +# Vite carga automáticamente `.env.local` encima de `.env`, así que +# los valores aquí definidos sobrescribirán los de producción en dev. +# +# cp .env.example .env.local +# +# IMPORTANTE: Sólo las variables con prefijo VITE_ se exponen al cliente. + +# URL base de la API del backend (la ve el NAVEGADOR, no el contenedor). +# +# - Dev con docker-compose (recomendado): el backend está expuesto en el +# puerto 8070 del host (mapea 8070 -> 8000 del contenedor). +# VITE_API_URL=http://localhost:8070/api +# +# - Backend ejecutado fuera de Docker (uvicorn --port 8000): +# VITE_API_URL=http://localhost:8000/api +# +# - Producción (ya definido en .env): +# VITE_API_URL=http://sinbad2.ujaen.es:8070/api +VITE_API_URL=http://localhost:8070/api diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..efe0071 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -12,6 +12,11 @@ dist dist-ssr *.local +# Variables de entorno locales de Vite +# (.env y .env.example sí se versionan) +.env.local +.env.*.local + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/frontend/src/components/membershipFunction/Controls.jsx b/frontend/src/components/membershipFunction/Controls.jsx index 565cc84..252a6fb 100644 --- a/frontend/src/components/membershipFunction/Controls.jsx +++ b/frontend/src/components/membershipFunction/Controls.jsx @@ -1,4 +1,105 @@ -export default function Controls({ +import { useState } from 'react'; + +function SliderInput({ label, value, min, max, step, color, onChange, decimals = 3 }) { + const [draft, setDraft] = useState(null); + + const numericValue = Number(value); + const displayValue = draft !== null ? draft : numericValue.toFixed(decimals); + + const commitDraft = () => { + if (draft === null) return; + + const trimmed = draft.trim(); + if (trimmed === '' || trimmed === '-' || trimmed === '.' || trimmed === '-.') { + setDraft(null); + return; + } + + const parsed = parseFloat(trimmed); + if (Number.isNaN(parsed)) { + setDraft(null); + return; + } + + const clamped = Math.min(max, Math.max(min, parsed)); + onChange(clamped); + setDraft(null); + }; + + const handleNumberChange = (e) => { + const raw = e.target.value; + + if (raw === '') { + setDraft(''); + return; + } + + const num = e.target.valueAsNumber; + + if (Number.isNaN(num)) { + setDraft(raw); + return; + } + + if (num > max) { + setDraft(max.toFixed(decimals)); + onChange(max); + return; + } + + if (num < min) { + setDraft(raw); + return; + } + + setDraft(raw); + onChange(num); + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { + e.currentTarget.blur(); + } else if (e.key === 'Escape') { + setDraft(null); + e.currentTarget.blur(); + } + }; + + return ( +
+ +
+ onChange(e.target.value)} + className="flex-1 cursor-pointer h-1.5" + style={{ accentColor: color }} + /> + +
+
+ ); +} + +const MF_STEP = 0.001; +const snapToMfStep = (v) => Math.round(Number(v) / MF_STEP) * MF_STEP; + +export default function Controls({ selectedTerm, currentMf, selectedColor, baseScale, mfDefinitions, updateCurrentMf, subscales, onOpenSubscale }) { @@ -6,38 +107,50 @@ export default function Controls({ 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 prevTerm = selectedIndex > 0 ? mfDefinitions[scaleKeys[selectedIndex - 1]] : null; + const nextTerm = selectedIndex < scaleKeys.length - 1 ? mfDefinitions[scaleKeys[selectedIndex + 1]] : null; + + const anchor = snapToMfStep(baseScale[selectedTerm]); + + const bounds = { + supportStart: { min: snapToMfStep(prevTerm?.coreEnd ?? 0), max: anchor }, + coreStart: { min: snapToMfStep(prevTerm?.supportEnd ?? 0), max: anchor }, + coreEnd: { min: anchor, max: snapToMfStep(nextTerm?.supportStart ?? 1) }, + supportEnd: { min: anchor, max: snapToMfStep(nextTerm?.coreStart ?? 1) }, + }; const leftSubscale = subscales?.[selectedTerm]?.left; const rightSubscale = subscales?.[selectedTerm]?.right; + const commonProps = { step: 0.001, color: selectedColor }; + return (

Ajustando: "{selectedTerm}"

- +
-
- - updateCurrentMf('supportStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} /> -
-
- - updateCurrentMf('coreStart', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} /> -
- + updateCurrentMf('supportStart', v)} + /> + updateCurrentMf('coreStart', v)} + /> +
-
-
- - updateCurrentMf('supportEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor, opacity: 0.7 }} /> -
-
- - updateCurrentMf('coreEnd', e.target.value)} className="w-full cursor-pointer h-1.5" style={{ accentColor: selectedColor }} /> -
+ updateCurrentMf('supportEnd', v)} + /> + updateCurrentMf('coreEnd', v)} + />
-
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/DocEditor.jsx b/frontend/src/pages/DocEditor.jsx index 8e318ab..0e18b1b 100644 --- a/frontend/src/pages/DocEditor.jsx +++ b/frontend/src/pages/DocEditor.jsx @@ -5,6 +5,15 @@ import SubscaleModal from '../components/editor/SubscaleModal'; import { calculateValueFunction, buildFuzzyGraph, saveToHistory } from '../services/docService'; import Step3FinalGraph from '../components/editor/Step3FinalGraph'; +// Step de la rejilla numérica de los puntos de la función de pertenencia. +// El de Controls usa este mismo step (0.001), así que +// guardamos SIEMPRE en este grid: evita que valores 4-decimales del backend +// (p.ej. 0.3017) contaminen el estado, lo cual disparaba dos bugs en el UI: +// · stepMismatch en hover ("los valores válidos más aproximados son…") +// · flechas que no llegan al máximo (paraban en 0.9997 con max=1). +const MF_STEP = 0.001; +const snapToMfStep = (v) => Math.round(Number(v) / MF_STEP) * MF_STEP; + export default function DocEditor() { const [step, setStep] = useState(1); const [isLoading, setIsLoading] = useState(false); @@ -46,7 +55,10 @@ export default function DocEditor() { 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 }; }); + Object.entries(baseResult.values).forEach(([name, value]) => { + const v = snapToMfStep(value); + initialMfs[name] = { supportStart: v, coreStart: v, coreEnd: v, supportEnd: v }; + }); setMfDefinitions(initialMfs); setSelectedTerm(Object.keys(baseResult.values)[0]); setStep(2); @@ -56,22 +68,24 @@ export default function DocEditor() { // MANEJADORES: FASE 2 const updateCurrentMf = (field, value) => { if (!selectedTerm) return; - let numValue = parseFloat(value); + // Snap al grid de 3 decimales antes de hacer cualquier comparación, + // para que el estado nunca guarde más precisión que la que muestra el input. + let numValue = snapToMfStep(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; + prevCoreEnd = snapToMfStep(prev[scaleKeys[selectedIndex - 1]].coreEnd); + prevSupportEnd = snapToMfStep(prev[scaleKeys[selectedIndex - 1]].supportEnd); } if (selectedIndex < scaleKeys.length - 1) { - nextCoreStart = prev[scaleKeys[selectedIndex + 1]].coreStart; - nextSupportStart = prev[scaleKeys[selectedIndex + 1]].supportStart; + nextCoreStart = snapToMfStep(prev[scaleKeys[selectedIndex + 1]].coreStart); + nextSupportStart = snapToMfStep(prev[scaleKeys[selectedIndex + 1]].supportStart); } - const anchor = baseScale[selectedTerm]; + const anchor = snapToMfStep(baseScale[selectedTerm]); if (field === 'supportStart' && numValue < prevCoreEnd) numValue = prevCoreEnd; if (field === 'coreStart' && numValue < prevSupportEnd) numValue = prevSupportEnd; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index a119649..a7d012f 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { authService } from '../services/authService'; +import { API_BASE_URL } from '../config'; import { FiEye, FiEyeOff } from 'react-icons/fi'; export default function Login() { @@ -66,7 +67,7 @@ export default function Login() { }; const handleGoogleLogin = () => { - window.location.href = "http://localhost:8000/api/auth/google/login"; + window.location.href = `${API_BASE_URL}/auth/google/login`; }; return (