diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..855b9b3 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,43 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true, node: true }, + globals: { + process: 'readonly', + tf: 'readonly' + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true + } + }, + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + 'react/prop-types': 'off', + 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-console': 'warn', + 'prefer-const': 'error', + 'no-var': 'error', + 'react/jsx-key': 'error', + 'react-hooks/exhaustive-deps': 'warn', + 'react/no-unescaped-entities': 'off', + 'react/display-name': 'off' + }, + settings: { + react: { + version: 'detect', + }, + }, +} diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 65cf716..9c67ba4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,40 +2,62 @@ name: Deploy to GitHub Pages on: push: - branches: - - main + branches: [ main ] + pull_request: + branches: [ main ] +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + jobs: - build-and-deploy: + # Build job + build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '18' - + cache: 'npm' + - name: Install dependencies - run: npm install - - - name: Build + run: npm ci + + - name: Lint code + run: npm run lint + + - name: Build for production run: npm run build - + - name: Setup Pages uses: actions/configure-pages@v5 - + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: './dist' - + path: ./dist + + # Deployment job + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' + steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 3c3629e..5ddd652 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,101 @@ -node_modules +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Vite build output +dist +dist-ssr +*.local + +# Rollup build output +build/ + +# Webpack build output +/dist/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS generated files +Thumbs.db +.DS_Store? +ehthumbs.db +Icon? + +# Temporary files +tmp/ +temp/ + +# IDE files +*.swp +*.swo +*~ + +# Storybook build outputs +storybook-static diff --git a/index.html b/index.html index ebbf157..721d72d 100644 --- a/index.html +++ b/index.html @@ -3,10 +3,48 @@ - Explorador de Espacio Latente de Caracteres + + + Explorador de Espacio Latente - Autoencoder Interactivo + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json index dfce4a7..7a2e2af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "eslint-plugin-react-refresh": "^0.4.3", "postcss": "^8.4.27", "tailwindcss": "^3.3.3", + "terser": "^5.43.1", "vite": "^7.0.6" } }, @@ -939,6 +940,16 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.10.tgz", + "integrity": "sha512-0pPkgz9dY+bijgistcTTJ5mR+ocqRXLuhXHYdzoMmmoJ2C9S46RCm2GMUbatPEUK9Yjy26IrAy8D/M00lLkv+Q==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", @@ -1867,6 +1878,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4988,6 +5005,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4997,6 +5023,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -5326,6 +5362,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/terser": { + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index b6aa6cb..561fb36 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "vite build", "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "lint:fix": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0 --fix", + "preview": "vite preview", + "analyze": "npm run build && npx vite-bundle-analyzer dist/stats.html" }, "dependencies": { "@tensorflow/tfjs": "^4.11.0", @@ -25,6 +27,7 @@ "eslint-plugin-react-refresh": "^0.4.3", "postcss": "^8.4.27", "tailwindcss": "^3.3.3", + "terser": "^5.43.1", "vite": "^7.0.6" } } diff --git a/postcss.config.js b/postcss.config.js index 75c78c1..2aa7205 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,5 +1,3 @@ -import postcss from 'postcss'; - export default { plugins: { tailwindcss: {}, diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..304e886 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,4 @@ + + + A + diff --git a/src/App.jsx b/src/App.jsx index ed96412..b4a9791 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,11 +1,14 @@ -import React, { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, Suspense, lazy } from 'react'; import Spinner from './components/Spinner'; import Header from './components/Header'; import LatentSpacePlot from './components/LatentSpacePlot'; import GeneratedCharacter from './components/GeneratedCharacter'; import ControlPanel from './components/ControlPanel'; -import TheorySection from './components/TheorySection'; import { useAutoencoder } from './hooks/useAutoencoder'; +import { throttle } from './utils/performance'; + +// Lazy loading para componentes pesados +const TheorySection = lazy(() => import('./components/TheorySection')); function App() { const { @@ -27,19 +30,24 @@ function App() { const [showScrollButton, setShowScrollButton] = useState(false); const [isFooterVisible, setIsFooterVisible] = useState(false); - const handleScroll = () => { + // Función throttled para manejar el scroll + const handleScroll = throttle(() => { if (mainSectionRef.current) { const { bottom } = mainSectionRef.current.getBoundingClientRect(); setShowScrollButton(bottom < 0); } - }; + }, 100); useEffect(() => { + // Observer para el footer con mejores opciones const observer = new IntersectionObserver( ([entry]) => { setIsFooterVisible(entry.isIntersecting); }, - { threshold: 0.1 } + { + threshold: 0.1, + rootMargin: '50px' // Detectar antes de que sea totalmente visible + } ); const currentFooterRef = footerRef.current; @@ -47,7 +55,8 @@ function App() { observer.observe(currentFooterRef); } - window.addEventListener('scroll', handleScroll); + // Listener optimizado para scroll + window.addEventListener('scroll', handleScroll, { passive: true }); return () => { if (currentFooterRef) { @@ -55,26 +64,39 @@ function App() { } window.removeEventListener('scroll', handleScroll); }; - }, []); + }, [handleScroll]); const scrollToMainSection = () => { mainSectionRef.current?.scrollIntoView({ behavior: 'smooth', + block: 'start' }); }; + // Renderizar loading spinner hasta que todo esté completamente listo + if (isLoading || !isAppReady) { + return ; + } + return ( <> - {isLoading && } -
+
+
-
-

Espacio Latente de Referencia

- {isLoading ? ( - - ) : latentCoords && latentSpaceBounds && latentData ? ( + {/* Sección del plot del espacio latente */} +
+

+ Espacio Latente de Referencia +

+ + {latentCoords && latentSpaceBounds && latentData ? ( ) : ( -
Error: No se pudo cargar el espacio latente.
+
+ Error: No se pudo cargar el espacio latente. +
)} -
-
+ + + {/* Sección de controles y carácter generado */} +
+
- + + {/* Lazy loading de la sección de teoría */} + +
Cargando contenido teórico...
+
+ }> + + + + {/* Footer */}
+ + {/* Botón de scroll optimizado */} diff --git a/src/components/ControlPanel.jsx b/src/components/ControlPanel.jsx index d9ef8de..e75f4f9 100644 --- a/src/components/ControlPanel.jsx +++ b/src/components/ControlPanel.jsx @@ -1,38 +1,134 @@ -import React from 'react'; +import { memo, useState, useEffect } from 'react'; +import { CONFIG } from '../config/constants'; +import { isValidBounds } from '../utils/validation'; +import { clamp } from '../utils/math'; -const ControlPanel = ({ latentCoords, latentSpaceBounds, onSliderChange, onReset, onCoordInputChange }) => { - const { xMin, xMax, yMin, yMax } = latentSpaceBounds || { xMin: -3, xMax: 3, yMin: -3, yMax: 3 }; +const ControlPanel = memo(({ latentCoords, latentSpaceBounds, onSliderChange, onReset, onCoordInputChange }) => { + // Estado local para los inputs de coordenadas + const [localCoords, setLocalCoords] = useState({ + x: latentCoords.x.toFixed(CONFIG.COORDINATE_PRECISION), + y: latentCoords.y.toFixed(CONFIG.COORDINATE_PRECISION) + }); + + // Sincronizar el estado local cuando cambien las coordenadas externas + useEffect(() => { + setLocalCoords({ + x: latentCoords.x.toFixed(CONFIG.COORDINATE_PRECISION), + y: latentCoords.y.toFixed(CONFIG.COORDINATE_PRECISION) + }); + }, [latentCoords.x, latentCoords.y]); + + // Valores por defecto seguros + const bounds = isValidBounds(latentSpaceBounds) + ? latentSpaceBounds + : { xMin: -3, xMax: 3, yMin: -3, yMax: 3 }; + + const { xMin, xMax, yMin, yMax } = bounds; + + // Formatear coordenadas con la precisión configurada + const formatCoord = (value) => value.toFixed(CONFIG.COORDINATE_PRECISION); + + // Manejar cambios en los inputs de coordenadas + const handleInputChange = (dim, value) => { + // Actualizar el estado local inmediatamente para permitir escritura + setLocalCoords(prev => ({ ...prev, [dim]: value })); + }; + + // Manejar cuando el usuario termina de editar (onBlur o Enter) + const handleInputCommit = (dim, value) => { + const parsedValue = parseFloat(value); + + if (!isNaN(parsedValue) && isValidBounds(latentSpaceBounds)) { + // Clamp del valor dentro de los límites + const { xMin, xMax, yMin, yMax } = latentSpaceBounds; + const clampedValue = dim === 'x' + ? clamp(parsedValue, xMin, xMax) + : clamp(parsedValue, yMin, yMax); + + // Actualizar las coordenadas reales + onCoordInputChange(dim, clampedValue); + + // Actualizar el estado local con el valor formateado + setLocalCoords(prev => ({ + ...prev, + [dim]: clampedValue.toFixed(CONFIG.COORDINATE_PRECISION) + })); + } else { + // Si el valor no es válido, revertir al valor anterior + setLocalCoords(prev => ({ + ...prev, + [dim]: latentCoords[dim].toFixed(CONFIG.COORDINATE_PRECISION) + })); + } + }; + + // Manejar Enter en los inputs + const handleKeyPress = (e, dim, value) => { + if (e.key === 'Enter') { + handleInputCommit(dim, value); + e.target.blur(); // Quitar el foco para activar onBlur también + } + }; return (
-

Ajuste Fino

+

+ Ajuste Fino +

+ {/* Inputs de coordenadas directas */}
- + onCoordInputChange('x', e.target.value)} - className="w-24 p-2 text-center border border-gray-300 rounded-md font-mono" + value={localCoords.x} + onChange={(e) => handleInputChange('x', e.target.value)} + onBlur={(e) => handleInputCommit('x', e.target.value)} + onKeyPress={(e) => handleKeyPress(e, 'x', e.target.value)} + className="w-24 p-2 text-center border border-gray-300 rounded-md font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder={`${xMin.toFixed(1)} a ${xMax.toFixed(1)}`} + aria-label="Coordenada X del espacio latente" + title={`Ingrese un valor entre ${xMin.toFixed(2)} y ${xMax.toFixed(2)}`} />
- + onCoordInputChange('y', e.target.value)} - className="w-24 p-2 text-center border border-gray-300 rounded-md font-mono" + value={localCoords.y} + onChange={(e) => handleInputChange('y', e.target.value)} + onBlur={(e) => handleInputCommit('y', e.target.value)} + onKeyPress={(e) => handleKeyPress(e, 'y', e.target.value)} + className="w-24 p-2 text-center border border-gray-300 rounded-md font-mono focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder={`${yMin.toFixed(1)} a ${yMax.toFixed(1)}`} + aria-label="Coordenada Y del espacio latente" + title={`Ingrese un valor entre ${yMin.toFixed(2)} y ${yMax.toFixed(2)}`} />
+ {/* Sliders para ajuste fino */}
- + onSliderChange('x', e.target.value)} - className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + onChange={(e) => onSliderChange('x', e.target.value)} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50" + aria-label="Slider para coordenada X" />
+
- + onSliderChange('y', e.target.value)} - className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer" + onChange={(e) => onSliderChange('y', e.target.value)} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50" + aria-label="Slider para coordenada Y" />
-
); -}; +}); + +ControlPanel.displayName = 'ControlPanel'; export default ControlPanel; diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..aa44062 --- /dev/null +++ b/src/components/ErrorBoundary.jsx @@ -0,0 +1,85 @@ +import React from 'react'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null, errorInfo: null }; + } + + static getDerivedStateFromError(_error) { + // Actualiza el estado para mostrar la UI de error + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + // Registra el error + // console.error('ErrorBoundary caught an error:', error, errorInfo); + + this.setState({ + error: error, + errorInfo: errorInfo + }); + + // Aquí podrías enviar el error a un servicio de logging + // this.reportError(error, errorInfo); + } + + render() { + if (this.state.hasError) { + // UI de fallback personalizada + return ( +
+
+
+ + + +
+ +

+ ¡Oops! Algo salió mal +

+ +

+ La aplicación encontró un error inesperado. Por favor, intenta recargar la página. +

+ +
+ + + +
+ + {process.env.NODE_ENV === 'development' && this.state.error && ( +
+ + Detalles del error (desarrollo) + +
+
Error:
+
{this.state.error && this.state.error.toString()}
+
Stack trace:
+
{this.state.errorInfo.componentStack}
+
+
+ )} +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/GeneratedCharacter.jsx b/src/components/GeneratedCharacter.jsx index 0bb7c2f..2922312 100644 --- a/src/components/GeneratedCharacter.jsx +++ b/src/components/GeneratedCharacter.jsx @@ -1,6 +1,6 @@ -import React from 'react'; +import { forwardRef } from 'react'; -const GeneratedCharacter = React.forwardRef((props, ref) => { +const GeneratedCharacter = forwardRef((props, ref) => { return (

Carácter Generado

@@ -9,4 +9,6 @@ const GeneratedCharacter = React.forwardRef((props, ref) => { ); }); +GeneratedCharacter.displayName = 'GeneratedCharacter'; + export default GeneratedCharacter; diff --git a/src/components/Header.jsx b/src/components/Header.jsx index b14f22f..b12789b 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,5 +1,3 @@ -import React from 'react'; - const Header = () => { return (
diff --git a/src/components/LatentSpacePlot.jsx b/src/components/LatentSpacePlot.jsx index 89f5558..4e0f5da 100644 --- a/src/components/LatentSpacePlot.jsx +++ b/src/components/LatentSpacePlot.jsx @@ -1,134 +1,189 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useRef, useCallback } from 'react'; +import { CONFIG } from '../config/constants'; +import { isValidLatentCoords, isValidBounds, isValidLatentData } from '../utils/validation'; +import { worldToPixel } from '../utils/math'; +import { throttle } from '../utils/performance'; -const PLOT_MARGIN = 40; - -function drawGridAndAxes(ctx, width, height, bounds) { - if (!bounds) return; +/** + * Dibuja la grilla y los ejes del gráfico + */ +const drawGridAndAxes = (ctx, width, height, bounds) => { + if (!isValidBounds(bounds)) return; + const { xMin, xMax, yMin, yMax } = bounds; - ctx.strokeStyle = '#e0e0e0'; + const effectiveWidth = width - 2 * CONFIG.PLOT_MARGIN - 2 * CONFIG.PLOT_OFFSET; + const effectiveHeight = height - 2 * CONFIG.PLOT_MARGIN - 2 * CONFIG.PLOT_OFFSET; + const xOffset = CONFIG.PLOT_MARGIN + CONFIG.PLOT_OFFSET; + const yOffset = CONFIG.PLOT_MARGIN + CONFIG.PLOT_OFFSET; + + // Configurar estilo de grilla + ctx.strokeStyle = CONFIG.COLORS.GRID; ctx.lineWidth = 0.5; - const numGridLines = 10; - const effectiveWidth = width - 2 * PLOT_MARGIN - 20; - const effectiveHeight = height - 2 * PLOT_MARGIN - 20; - const xScale = effectiveWidth / (xMax - xMin); - const yScale = effectiveHeight / (yMax - yMin); - const xOffset = PLOT_MARGIN + 10; - const yOffset = PLOT_MARGIN + 10; - - for (let i = 0; i <= numGridLines; i++) { - const xVal = xMin + (i / numGridLines) * (xMax - xMin); - const xPixel = xOffset + (xVal - xMin) * xScale; + + // Dibujar líneas de grilla + for (let i = 0; i <= CONFIG.PLOT_GRID_LINES; i++) { + const xVal = xMin + (i / CONFIG.PLOT_GRID_LINES) * (xMax - xMin); + const xPixel = xOffset + (xVal - xMin) * (effectiveWidth / (xMax - xMin)); + ctx.beginPath(); ctx.moveTo(xPixel, yOffset); ctx.lineTo(xPixel, height - yOffset); ctx.stroke(); } - for (let i = 0; i <= numGridLines; i++) { - const yVal = yMin + (i / numGridLines) * (yMax - yMin); - const yPixel = height - yOffset - (yVal - yMin) * yScale; + + for (let i = 0; i <= CONFIG.PLOT_GRID_LINES; i++) { + const yVal = yMin + (i / CONFIG.PLOT_GRID_LINES) * (yMax - yMin); + const yPixel = height - yOffset - (yVal - yMin) * (effectiveHeight / (yMax - yMin)); + ctx.beginPath(); ctx.moveTo(xOffset, yPixel); ctx.lineTo(width - xOffset, yPixel); ctx.stroke(); } - ctx.strokeStyle = '#666'; + + // Dibujar ejes principales + ctx.strokeStyle = CONFIG.COLORS.AXES; ctx.lineWidth = 1; + + // Eje X ctx.beginPath(); ctx.moveTo(xOffset, height - yOffset); ctx.lineTo(width - xOffset, height - yOffset); ctx.stroke(); + + // Eje Y ctx.beginPath(); ctx.moveTo(xOffset, yOffset); ctx.lineTo(xOffset, height - yOffset); ctx.stroke(); - ctx.fillStyle = '#333'; - ctx.font = '10px Arial'; + + // Etiquetas de los ejes + ctx.fillStyle = CONFIG.COLORS.TEXT; + ctx.font = CONFIG.FONTS.AXIS_LABEL; + + // Etiquetas del eje X ctx.textAlign = 'center'; ctx.textBaseline = 'top'; const xStep = (xMax - xMin) / 5; for (let i = 0; i <= 5; i++) { const val = xMin + i * xStep; - const xPixel = xOffset + (val - xMin) * xScale; - ctx.fillText(val.toFixed(1), xPixel, height - yOffset + 5); + const xPixel = xOffset + (val - xMin) * (effectiveWidth / (xMax - xMin)); + ctx.fillText(val.toFixed(CONFIG.DISPLAY_PRECISION), xPixel, height - yOffset + 5); } + + // Etiquetas del eje Y ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; const yStep = (yMax - yMin) / 5; for (let i = 0; i <= 5; i++) { const val = yMin + i * yStep; - const yPixel = height - yOffset - (val - yMin) * yScale; - ctx.fillText(val.toFixed(1), xOffset - 5, yPixel); + const yPixel = height - yOffset - (val - yMin) * (effectiveHeight / (yMax - yMin)); + ctx.fillText(val.toFixed(CONFIG.DISPLAY_PRECISION), xOffset - 5, yPixel); } -} +}; const LatentSpacePlot = React.forwardRef((props, ref) => { const { latentCoords, latentSpaceBounds, latentData } = props; + const animationFrameRef = useRef(); - useEffect(() => { - function draw() { - if ( - ref.current && - latentCoords && - typeof latentCoords.x === 'number' && - typeof latentCoords.y === 'number' && - latentSpaceBounds && - latentData - ) { - const canvas = ref.current; - const parent = canvas.parentElement; - const visualWidth = parent.clientWidth; - const visualHeight = visualWidth; - canvas.width = visualWidth; - canvas.height = visualHeight; - const ctx = canvas.getContext('2d'); - ctx.clearRect(0, 0, visualWidth, visualHeight); - drawGridAndAxes(ctx, visualWidth, visualHeight, latentSpaceBounds); - const { xMin, xMax, yMin, yMax } = latentSpaceBounds; - const effectiveWidth = visualWidth - 2 * PLOT_MARGIN - 20; - const effectiveHeight = visualHeight - 2 * PLOT_MARGIN - 20; - const xOffset = PLOT_MARGIN + 10; - const yOffset = PLOT_MARGIN + 10; - const xScale = effectiveWidth / (xMax - xMin); - const yScale = effectiveHeight / (yMax - yMin); - - // Dibujar puntos y etiquetas - latentData.forEach(({ x, y, label }) => { - const xPixel = xOffset + (x - xMin) * xScale; - const yPixel = visualHeight - yOffset - (y - yMin) * yScale; - ctx.beginPath(); - ctx.arc(xPixel, yPixel, 3, 0, 2 * Math.PI); - ctx.fillStyle = 'blue'; - ctx.fill(); - ctx.font = '12px Arial'; - ctx.fillStyle = 'black'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(label, xPixel, yPixel - 5); - }); - - // Dibujar marcador (cruz) en la misma escala - const markerX = xOffset + (latentCoords.x - xMin) * xScale; - const markerY = visualHeight - yOffset - (latentCoords.y - yMin) * yScale; - ctx.beginPath(); - ctx.moveTo(markerX - 6, markerY); - ctx.lineTo(markerX + 6, markerY); - ctx.moveTo(markerX, markerY - 6); - ctx.lineTo(markerX, markerY + 6); - ctx.strokeStyle = 'red'; - ctx.lineWidth = 2; - ctx.stroke(); - } + const draw = useCallback(() => { + if ( + !ref.current || + !isValidLatentCoords(latentCoords) || + !isValidBounds(latentSpaceBounds) || + !isValidLatentData(latentData) + ) { + return; } + + const canvas = ref.current; + const parent = canvas.parentElement; + const visualWidth = parent.clientWidth; + const visualHeight = visualWidth; // Mantener aspecto cuadrado + + // Actualizar dimensiones del canvas + canvas.width = visualWidth; + canvas.height = visualHeight; + + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, visualWidth, visualHeight); + + // Dibujar grilla y ejes + drawGridAndAxes(ctx, visualWidth, visualHeight, latentSpaceBounds); + + const canvasInfo = { + width: visualWidth, + height: visualHeight, + xOffset: CONFIG.PLOT_MARGIN + CONFIG.PLOT_OFFSET, + yOffset: CONFIG.PLOT_MARGIN + CONFIG.PLOT_OFFSET + }; + + // Dibujar puntos de datos y etiquetas + latentData.forEach(({ x, y, label }) => { + const pixelCoords = worldToPixel({ x, y }, latentSpaceBounds, canvasInfo); + + // Dibujar punto + ctx.beginPath(); + ctx.arc(pixelCoords.x, pixelCoords.y, CONFIG.PLOT_POINT_SIZE, 0, 2 * Math.PI); + ctx.fillStyle = CONFIG.COLORS.POINT; + ctx.fill(); + ctx.strokeStyle = CONFIG.COLORS.PRIMARY_HOVER; + ctx.lineWidth = 1; + ctx.stroke(); + + // Dibujar etiqueta + ctx.font = CONFIG.FONTS.POINT_LABEL; + ctx.fillStyle = CONFIG.COLORS.TEXT; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.fillText(label, pixelCoords.x, pixelCoords.y - CONFIG.PLOT_POINT_SIZE - 2); + }); + + // Dibujar marcador actual (cruz roja) + const markerPixels = worldToPixel(latentCoords, latentSpaceBounds, canvasInfo); + ctx.beginPath(); + ctx.moveTo(markerPixels.x - 6, markerPixels.y); + ctx.lineTo(markerPixels.x + 6, markerPixels.y); + ctx.moveTo(markerPixels.x, markerPixels.y - 6); + ctx.lineTo(markerPixels.x, markerPixels.y + 6); + ctx.strokeStyle = CONFIG.COLORS.MARKER; + ctx.lineWidth = 2; + ctx.stroke(); + }, [latentCoords, latentSpaceBounds, latentData, ref]); + + // Versión throttled del dibujo para resize + const throttledDraw = useCallback(throttle(() => { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + animationFrameRef.current = requestAnimationFrame(draw); + }, 16), [draw]); // ~60fps + + useEffect(() => { + // Dibujar inmediatamente cuando cambien las dependencias draw(); - window.addEventListener('resize', draw); + + // Configurar listener para resize + window.addEventListener('resize', throttledDraw); + return () => { - window.removeEventListener('resize', draw); + window.removeEventListener('resize', throttledDraw); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } }; - }, [latentCoords, latentSpaceBounds, latentData, ref]); + }, [draw, throttledDraw]); return ( - + ); }); +LatentSpacePlot.displayName = 'LatentSpacePlot'; + export default LatentSpacePlot; diff --git a/src/components/Spinner.jsx b/src/components/Spinner.jsx index 20292e7..4294ea8 100644 --- a/src/components/Spinner.jsx +++ b/src/components/Spinner.jsx @@ -1,5 +1,3 @@ -import React from 'react'; - const Spinner = () => { return (
diff --git a/src/components/TheorySection.jsx b/src/components/TheorySection.jsx index 76ad986..b1c3a5b 100644 --- a/src/components/TheorySection.jsx +++ b/src/components/TheorySection.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import redNeuronalBasica from '../assets/red_neuronal_basica.png'; import operacionConvolucion from '../assets/operacion_convolucion.png'; import autoencoder from '../assets/autoencoder.png'; diff --git a/src/config/constants.js b/src/config/constants.js new file mode 100644 index 0000000..da1cde4 --- /dev/null +++ b/src/config/constants.js @@ -0,0 +1,63 @@ +// Constantes de configuración de la aplicación +export const CONFIG = { + // Modelo y datos + MODEL_PATH: 'tfjs_decoder_model_20250724_135134/model.json', + LATENT_DATA_PATH: 'latent_space_data_20250724_130013.json', + + // Configuración TensorFlow.js + TENSORFLOW: { + BACKEND_PREFERENCES: ['webgl', 'webgpu', 'cpu'], + INITIALIZATION_TIMEOUT: 5000, + }, + + // Dimensiones del canvas + IMAGE_SIZE: 64, + + // Configuración del plot + PLOT_POINT_SIZE: 5, + PLOT_MARGIN: 40, + PLOT_GRID_LINES: 10, + PLOT_OFFSET: 10, + + // Factores de padding y escala + PADDING_FACTOR: 0.05, + SLIDER_PADDING: 5, + + // Configuración de UI + TRANSITION_DELAY: 50, + + // Precisión numérica + COORDINATE_PRECISION: 2, + DISPLAY_PRECISION: 1, + + // Configuración de estilos + COLORS: { + PRIMARY: '#007bff', + PRIMARY_HOVER: '#0056b3', + GRID: '#e0e0e0', + AXES: '#666', + TEXT: '#333', + MARKER: 'red', + POINT: 'blue' + }, + + // Configuración de fuentes + FONTS: { + AXIS_LABEL: '10px Arial', + POINT_LABEL: '12px Arial' + } +}; + +// Tipos de datos para mejor tipado +export const DATA_TYPES = { + LATENT_COORDS: 'latentCoords', + LATENT_BOUNDS: 'latentBounds', + LATENT_DATA: 'latentData' +}; + +// Estados de la aplicación +export const APP_STATES = { + LOADING: 'loading', + READY: 'ready', + ERROR: 'error' +}; diff --git a/src/hooks/useAutoencoder.js b/src/hooks/useAutoencoder.js index 3db5b1b..99a34fb 100644 --- a/src/hooks/useAutoencoder.js +++ b/src/hooks/useAutoencoder.js @@ -1,11 +1,10 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import * as tf from '@tensorflow/tfjs'; - -const MODEL_PATH = 'tfjs_decoder_model_20250724_135134/model.json'; -const LATENT_DATA_PATH = 'latent_space_data_20250724_130013.json'; -const IMAGE_SIZE = 64; -const PLOT_POINT_SIZE = 5; -const PLOT_MARGIN = 40; +import { CONFIG } from '../config/constants'; +import { isValidLatentCoords, isValidBounds } from '../utils/validation'; +import { clamp, pixelToWorld } from '../utils/math'; +import { cleanupTensors, throttle } from '../utils/performance'; +import { useErrorHandler } from './useErrorHandler'; export const useAutoencoder = () => { const [isLoading, setIsLoading] = useState(true); @@ -14,267 +13,239 @@ export const useAutoencoder = () => { const [latentData, setLatentData] = useState(null); const [latentSpaceBounds, setLatentSpaceBounds] = useState(null); const [latentCoords, setLatentCoords] = useState({ x: 0, y: 0 }); - + + const resourcesLoaded = useRef(false); const plotCanvasRef = useRef(null); const generatedCanvasRef = useRef(null); + const { reportError } = useErrorHandler(); + + // Función para calcular los límites del espacio latente + const calculateBounds = useCallback((plotData) => { + const xCoords = plotData.map(p => p.x); + const yCoords = plotData.map(p => p.y); + const xMin = Math.min(...xCoords); + const xMax = Math.max(...xCoords); + const yMin = Math.min(...yCoords); + const yMax = Math.max(...yCoords); + + const xRange = xMax - xMin; + const yRange = yMax - yMin; + + return { + xMin: xMin - xRange * CONFIG.PADDING_FACTOR - CONFIG.SLIDER_PADDING, + xMax: xMax + xRange * CONFIG.PADDING_FACTOR + CONFIG.SLIDER_PADDING, + yMin: yMin - yRange * CONFIG.PADDING_FACTOR - CONFIG.SLIDER_PADDING, + yMax: yMax + yRange * CONFIG.PADDING_FACTOR + CONFIG.SLIDER_PADDING, + }; + }, []); - // Carga del modelo y los datos + // Carga del modelo y los datos con manejo de errores mejorado useEffect(() => { + if (resourcesLoaded.current) return; // Evitar doble carga + const loadResources = async () => { try { - const model = await tf.loadGraphModel(MODEL_PATH); - setDecoderModel(model); - - const response = await fetch(LATENT_DATA_PATH); + // Intentar diferentes backends en orden de preferencia + let backendInitialized = false; + for (const backend of CONFIG.TENSORFLOW.BACKEND_PREFERENCES) { + try { + await tf.setBackend(backend); + await tf.ready(); + backendInitialized = true; + break; + } catch (error) { + // Solo mostrar warning si es el último backend que falla + if (backend === CONFIG.TENSORFLOW.BACKEND_PREFERENCES[CONFIG.TENSORFLOW.BACKEND_PREFERENCES.length - 1]) { + console.warn('Algunos backends de TensorFlow.js no están disponibles, usando fallback'); + } + continue; + } + } + + if (!backendInitialized) { + throw new Error('No se pudo inicializar ningún backend de TensorFlow.js'); + } + + // Cargar modelo y datos en paralelo + const [model, response] = await Promise.all([ + tf.loadGraphModel(CONFIG.MODEL_PATH), + fetch(CONFIG.LATENT_DATA_PATH) + ]); + + if (!response.ok) { + throw new Error(`Error al cargar datos: ${response.status} ${response.statusText}`); + } + const data = await response.json(); + // Validar estructura de datos + if (!data.latent_coords || !Array.isArray(data.latent_coords) || !data.labels) { + throw new Error('Estructura de datos inválida'); + } + const plotData = data.latent_coords.map((coord, index) => ({ x: coord[0], y: coord[1], label: data.labels[index], })); + + setDecoderModel(model); setLatentData(plotData); - - const xCoords = plotData.map(p => p.x); - const yCoords = plotData.map(p => p.y); - const xMin = Math.min(...xCoords); - const xMax = Math.max(...xCoords); - const yMin = Math.min(...yCoords); - const yMax = Math.max(...yCoords); - const paddingFactor = 0.05; - const xRange = xMax - xMin; - const yRange = yMax - yMin; - const sliderPadding = 5; - - setLatentSpaceBounds({ - xMin: xMin - xRange * paddingFactor - sliderPadding, - xMax: xMax + xRange * paddingFactor + sliderPadding, - yMin: yMin - yRange * paddingFactor - sliderPadding, - yMax: yMax + yRange * paddingFactor + sliderPadding, - }); + setLatentSpaceBounds(calculateBounds(plotData)); + resourcesLoaded.current = true; } catch (error) { - console.error("Error loading resources:", error); - // En caso de error, detenemos la carga para evitar un spinner infinito + console.error('Error al cargar recursos del autoencoder:', error); + reportError(error, 'Error al cargar recursos del autoencoder'); setIsLoading(false); } - // No ponemos setIsLoading(false) aquí para esperar al primer dibujado }; - loadResources(); - }, []); - - const drawGridAndAxes = useCallback((ctx, width, height, bounds) => { - if (!bounds) return; - const { xMin, xMax, yMin, yMax } = bounds; - - ctx.strokeStyle = '#e0e0e0'; - ctx.lineWidth = 0.5; - const numGridLines = 10; - - const effectiveWidth = width - 2 * PLOT_MARGIN - 20; - const effectiveHeight = height - 2 * PLOT_MARGIN - 20; - const xScale = effectiveWidth / (xMax - xMin); - const yScale = effectiveHeight / (yMax - yMin); - const xOffset = PLOT_MARGIN + 10; - const yOffset = PLOT_MARGIN + 10; - - for (let i = 0; i <= numGridLines; i++) { - const xVal = xMin + (i / numGridLines) * (xMax - xMin); - const xPixel = xOffset + (xVal - xMin) * xScale; - ctx.beginPath(); - ctx.moveTo(xPixel, yOffset); - ctx.lineTo(xPixel, height - yOffset); - ctx.stroke(); - } - for (let i = 0; i <= numGridLines; i++) { - const yVal = yMin + (i / numGridLines) * (yMax - yMin); - const yPixel = height - yOffset - (yVal - yMin) * yScale; - ctx.beginPath(); - ctx.moveTo(xOffset, yPixel); - ctx.lineTo(width - xOffset, yPixel); - ctx.stroke(); - } - - ctx.strokeStyle = '#666'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(xOffset, height - yOffset); - ctx.lineTo(width - xOffset, height - yOffset); - ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(xOffset, yOffset); - ctx.lineTo(xOffset, height - yOffset); - ctx.stroke(); - - ctx.fillStyle = '#333'; - ctx.font = '10px Arial'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - const xStep = (xMax - xMin) / 5; - for (let i = 0; i <= 5; i++) { - const val = xMin + i * xStep; - const xPixel = xOffset + (val - xMin) * xScale; - ctx.fillText(val.toFixed(1), xPixel, height - yOffset + 5); - } + loadResources(); + }, [calculateBounds, reportError]); - ctx.textAlign = 'right'; - ctx.textBaseline = 'middle'; - const yStep = (yMax - yMin) / 5; - for (let i = 0; i <= 5; i++) { - const val = yMin + i * yStep; - const yPixel = height - yOffset - (val - yMin) * yScale; - ctx.fillText(val.toFixed(1), xOffset - 5, yPixel); + // Función optimizada para generar letras con limpieza de memoria + const generateLetter = useCallback(async (latentVector) => { + if (!decoderModel || !generatedCanvasRef.current) { + return false; } - }, []); - - const drawLatentSpace = useCallback(() => { - const canvas = plotCanvasRef.current; - if (!canvas || !latentData || !latentSpaceBounds) return; - const ctx = canvas.getContext('2d'); - const visualWidth = canvas.clientWidth; - const visualHeight = visualWidth; - canvas.width = visualWidth; - canvas.height = visualHeight; - - ctx.clearRect(0, 0, visualWidth, visualHeight); - drawGridAndAxes(ctx, visualWidth, visualHeight, latentSpaceBounds); - - const { xMin, xMax, yMin, yMax } = latentSpaceBounds; - const effectiveWidth = visualWidth - 2 * PLOT_MARGIN - 20; - const effectiveHeight = visualHeight - 2 * PLOT_MARGIN - 20; - const xOffset = PLOT_MARGIN + 10; - const yOffset = PLOT_MARGIN + 10; - const xScale = effectiveWidth / (xMax - xMin); - const yScale = effectiveHeight / (yMax - yMin); - - latentData.forEach(point => { - const xPixel = xOffset + (point.x - xMin) * xScale; - const yPixel = visualHeight - yOffset - (point.y - yMin) * yScale; + let latentTensor, outputTensor, imageTensor, normalizedImageTensor; + + try { + // Verificar que TensorFlow.js esté listo + if (!tf.getBackend()) { + await tf.ready(); + } - ctx.beginPath(); - ctx.arc(xPixel, yPixel, PLOT_POINT_SIZE, 0, 2 * Math.PI); - ctx.fillStyle = '#007bff'; - ctx.fill(); - ctx.strokeStyle = '#0056b3'; - ctx.lineWidth = 1; - ctx.stroke(); + const canvas = generatedCanvasRef.current; + const ctx = canvas.getContext('2d'); + + // Crear tensores + latentTensor = tf.tensor2d([latentVector]); + outputTensor = decoderModel.predict(latentTensor); + imageTensor = outputTensor.squeeze(); + normalizedImageTensor = imageTensor.mul(255).round().clipByValue(0, 255).cast('int32'); + + // Obtener datos de imagen + const imageDataArray = await normalizedImageTensor.data(); + + // Crear datos RGBA + const rgbaData = new Uint8ClampedArray(CONFIG.IMAGE_SIZE * CONFIG.IMAGE_SIZE * 4); + for (let i = 0; i < CONFIG.IMAGE_SIZE * CONFIG.IMAGE_SIZE; i++) { + const pixelValue = imageDataArray[i]; + const baseIndex = i * 4; + rgbaData[baseIndex] = pixelValue; // R + rgbaData[baseIndex + 1] = pixelValue; // G + rgbaData[baseIndex + 2] = pixelValue; // B + rgbaData[baseIndex + 3] = 255; // A + } - ctx.font = '12px Arial'; - ctx.fillStyle = '#333'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - ctx.fillText(point.label, xPixel, yPixel - PLOT_POINT_SIZE - 2); - }); - }, [latentData, latentSpaceBounds, drawGridAndAxes]); + // Renderizar en canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + const imageDataObject = new ImageData(rgbaData, CONFIG.IMAGE_SIZE, CONFIG.IMAGE_SIZE); + ctx.putImageData(imageDataObject, 0, 0); - const generateLetter = useCallback(async (latentVector) => { - const canvas = generatedCanvasRef.current; - if (!decoderModel || !canvas) return; - const ctx = canvas.getContext('2d'); + return true; // Éxito - const latentTensor = tf.tensor2d([latentVector]); - const outputTensor = decoderModel.predict(latentTensor); - const imageTensor = outputTensor.squeeze(); - const normalizedImageTensor = imageTensor.mul(255).round().clipByValue(0, 255).cast('int32'); - const imageDataArray = await normalizedImageTensor.data(); - - const rgbaData = new Uint8ClampedArray(IMAGE_SIZE * IMAGE_SIZE * 4); - for (let i = 0; i < IMAGE_SIZE * IMAGE_SIZE; i++) { - const pixelValue = imageDataArray[i]; - rgbaData[i * 4 + 0] = pixelValue; - rgbaData[i * 4 + 1] = pixelValue; - rgbaData[i * 4 + 2] = pixelValue; - rgbaData[i * 4 + 3] = 255; + } catch (error) { + console.error('Error al generar letra:', error); + reportError(error, 'Error al generar letra'); + return false; // Error + } finally { + // Limpiar tensores para evitar memory leaks + cleanupTensors([latentTensor, outputTensor, imageTensor, normalizedImageTensor]); } + }, [decoderModel, reportError]); - ctx.clearRect(0, 0, canvas.width, canvas.height); - const imageDataObject = new ImageData(rgbaData, IMAGE_SIZE, IMAGE_SIZE); - ctx.putImageData(imageDataObject, 0, 0); - - tf.dispose([latentTensor, outputTensor, imageTensor, normalizedImageTensor]); - }, [decoderModel]); - - // Efecto para dibujar el espacio latente cuando los datos están listos + // Efecto para inicializar la aplicación cuando los recursos están listos useEffect(() => { - if (decoderModel && latentData) { - // drawLatentSpace(); // ELIMINADO: solo LatentSpacePlot.jsx debe dibujar - generateLetter([latentCoords.x, latentCoords.y]); + if (decoderModel && latentData && latentSpaceBounds && !isAppReady) { setIsLoading(false); - const timer = setTimeout(() => setIsAppReady(true), 50); // Pequeño delay para la transición - const handleResize = () => {/* nada, el canvas se redibuja por React */}; - window.addEventListener('resize', handleResize); - return () => { - clearTimeout(timer); - window.removeEventListener('resize', handleResize); + setIsAppReady(true); + } + }, [decoderModel, latentData, latentSpaceBounds, isAppReady]); + + // Efecto separado para generar la primera letra después de que la app esté lista + useEffect(() => { + if (isAppReady && decoderModel && generatedCanvasRef.current) { + const generateInitialLetter = async () => { + try { + await generateLetter([latentCoords.x, latentCoords.y]); + } catch (error) { + console.error('Error al generar primera letra:', error); + } }; + + // Pequeño delay para asegurar que el canvas esté completamente renderizado + const timer = setTimeout(generateInitialLetter, 100); + return () => clearTimeout(timer); } - }, [decoderModel, latentData, generateLetter]); + }, [isAppReady, decoderModel, generateLetter, latentCoords.x, latentCoords.y]); // Efecto para generar la letra cuando las coordenadas cambian useEffect(() => { - if (isAppReady) { + if (isAppReady && isValidLatentCoords(latentCoords)) { generateLetter([latentCoords.x, latentCoords.y]); } }, [latentCoords, generateLetter, isAppReady]); - const handlePlotClick = (event) => { + // Función throttled para manejar clics en el plot + const handlePlotClick = useCallback(throttle((event) => { const canvas = plotCanvasRef.current; - if (!canvas || !latentSpaceBounds) return; + if (!canvas || !isValidBounds(latentSpaceBounds)) return; const rect = canvas.getBoundingClientRect(); - // Usar el tamaño visual real del canvas - const visualWidth = rect.width; - const visualHeight = rect.height; - const xPixel = event.clientX - rect.left; - const yPixel = event.clientY - rect.top; - const { xMin, xMax, yMin, yMax } = latentSpaceBounds; + const pixelCoords = { + x: event.clientX - rect.left, + y: event.clientY - rect.top + }; - // Usar los mismos offsets y escalas que en el renderizado - const xOffset = PLOT_MARGIN + 10; - const yOffset = PLOT_MARGIN + 10; - const effectiveWidth = visualWidth - 2 * PLOT_MARGIN - 20; - const effectiveHeight = visualHeight - 2 * PLOT_MARGIN - 20; - const xScale = effectiveWidth / (xMax - xMin); - const yScale = effectiveHeight / (yMax - yMin); + const canvasInfo = { + width: rect.width, + height: rect.height, + xOffset: CONFIG.PLOT_MARGIN + CONFIG.PLOT_OFFSET, + yOffset: CONFIG.PLOT_MARGIN + CONFIG.PLOT_OFFSET + }; // Validar que el clic esté dentro del área del gráfico if ( - xPixel < xOffset || xPixel > (visualWidth - xOffset) || - yPixel < yOffset || yPixel > (visualHeight - yOffset) + pixelCoords.x < canvasInfo.xOffset || + pixelCoords.x > (canvasInfo.width - canvasInfo.xOffset) || + pixelCoords.y < canvasInfo.yOffset || + pixelCoords.y > (canvasInfo.height - canvasInfo.yOffset) ) { return; } - // Mapeo idéntico al renderizado - const latentX = xMin + (xPixel - xOffset) / xScale; - const latentY = yMin + ((visualHeight - yOffset - yPixel) / yScale); - - setLatentCoords({ x: latentX, y: latentY }); - }; - - const handleSliderChange = (dim, value) => { - setLatentCoords(prev => ({ ...prev, [dim]: parseFloat(value) })); - }; + const worldCoords = pixelToWorld(pixelCoords, latentSpaceBounds, canvasInfo); + setLatentCoords(worldCoords); + }, 50), [latentSpaceBounds]); - const handleCoordInputChange = (dim, value) => { - const parsedValue = parseFloat(value); - if (!isNaN(parsedValue)) { - const { xMin, xMax, yMin, yMax } = latentSpaceBounds; - let clampedValue = parsedValue; - if (dim === 'x') { - clampedValue = Math.max(xMin, Math.min(xMax, parsedValue)); - } else { - clampedValue = Math.max(yMin, Math.min(yMax, parsedValue)); - } - setLatentCoords(prev => ({ ...prev, [dim]: clampedValue })); + const handleSliderChange = useCallback((dim, value) => { + const numValue = parseFloat(value); + if (!isNaN(numValue)) { + setLatentCoords(prev => ({ ...prev, [dim]: numValue })); } - }; + }, []); - const handleReset = () => { + const handleCoordInputChange = useCallback((dim, value) => { + // El valor ya viene procesado desde el ControlPanel + setLatentCoords(prev => ({ ...prev, [dim]: value })); + }, []); + + const handleReset = useCallback(() => { setLatentCoords({ x: 0, y: 0 }); - }; + }, []); + + // Cleanup al desmontar el componente + useEffect(() => { + return () => { + if (decoderModel) { + decoderModel.dispose(); + } + }; + }, [decoderModel]); return { isLoading, diff --git a/src/hooks/useErrorHandler.js b/src/hooks/useErrorHandler.js new file mode 100644 index 0000000..2c85f64 --- /dev/null +++ b/src/hooks/useErrorHandler.js @@ -0,0 +1,59 @@ +import { useState, useCallback } from 'react'; + +/** + * Hook personalizado para manejo de errores + * @returns {Object} + */ +export const useErrorHandler = () => { + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const handleAsync = useCallback(async (asyncFunction, errorMessage = 'Ha ocurrido un error') => { + try { + setIsLoading(true); + setError(null); + const result = await asyncFunction(); + return result; + } catch (err) { + console.error(errorMessage, err); + setError({ + message: errorMessage, + originalError: err, + timestamp: new Date().toISOString() + }); + throw err; + } finally { + setIsLoading(false); + } + }, []); + + const clearError = useCallback(() => { + setError(null); + }, []); + + const reportError = useCallback((error, context = '') => { + const errorInfo = { + message: error.message || 'Error desconocido', + stack: error.stack, + context, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + url: window.location.href + }; + + console.error('Error reportado:', errorInfo); + + // Aquí podrías enviar el error a un servicio de logging como Sentry + // sendToErrorService(errorInfo); + + setError(errorInfo); + }, []); + + return { + error, + isLoading, + handleAsync, + clearError, + reportError + }; +}; diff --git a/src/main.jsx b/src/main.jsx index 54b39dd..bd74c6b 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,13 @@ import React from 'react' import ReactDOM from 'react-dom/client' import App from './App.jsx' +import ErrorBoundary from './components/ErrorBoundary.jsx' import './index.css' ReactDOM.createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/src/utils/math.js b/src/utils/math.js new file mode 100644 index 0000000..3ee0d17 --- /dev/null +++ b/src/utils/math.js @@ -0,0 +1,76 @@ +/** + * Utilidades matemáticas para el proyecto + */ + +/** + * Limita un valor entre un mínimo y máximo + * @param {number} value - Valor a limitar + * @param {number} min - Valor mínimo + * @param {number} max - Valor máximo + * @returns {number} + */ +export const clamp = (value, min, max) => { + return Math.min(Math.max(value, min), max); +}; + +/** + * Mapea un valor de un rango a otro + * @param {number} value - Valor a mapear + * @param {number} fromMin - Mínimo del rango origen + * @param {number} fromMax - Máximo del rango origen + * @param {number} toMin - Mínimo del rango destino + * @param {number} toMax - Máximo del rango destino + * @returns {number} + */ +export const mapRange = (value, fromMin, fromMax, toMin, toMax) => { + return toMin + ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin); +}; + +/** + * Calcula la escala para convertir coordenadas del mundo a píxeles + * @param {Object} bounds - Límites del mundo {xMin, xMax, yMin, yMax} + * @param {number} width - Ancho disponible en píxeles + * @param {number} height - Alto disponible en píxeles + * @returns {Object} - Escalas {xScale, yScale} + */ +export const calculateScale = (bounds, width, height) => { + const xScale = width / (bounds.xMax - bounds.xMin); + const yScale = height / (bounds.yMax - bounds.yMin); + return { xScale, yScale }; +}; + +/** + * Convierte coordenadas del mundo a coordenadas de píxeles + * @param {Object} worldCoords - Coordenadas del mundo {x, y} + * @param {Object} bounds - Límites del mundo + * @param {Object} canvasInfo - Información del canvas {width, height, xOffset, yOffset} + * @returns {Object} - Coordenadas en píxeles {x, y} + */ +export const worldToPixel = (worldCoords, bounds, canvasInfo) => { + const { width, height, xOffset, yOffset } = canvasInfo; + const effectiveWidth = width - 2 * xOffset; + const effectiveHeight = height - 2 * yOffset; + + const xPixel = xOffset + mapRange(worldCoords.x, bounds.xMin, bounds.xMax, 0, effectiveWidth); + const yPixel = height - yOffset - mapRange(worldCoords.y, bounds.yMin, bounds.yMax, 0, effectiveHeight); + + return { x: xPixel, y: yPixel }; +}; + +/** + * Convierte coordenadas de píxeles a coordenadas del mundo + * @param {Object} pixelCoords - Coordenadas en píxeles {x, y} + * @param {Object} bounds - Límites del mundo + * @param {Object} canvasInfo - Información del canvas {width, height, xOffset, yOffset} + * @returns {Object} - Coordenadas del mundo {x, y} + */ +export const pixelToWorld = (pixelCoords, bounds, canvasInfo) => { + const { width, height, xOffset, yOffset } = canvasInfo; + const effectiveWidth = width - 2 * xOffset; + const effectiveHeight = height - 2 * yOffset; + + const worldX = mapRange(pixelCoords.x - xOffset, 0, effectiveWidth, bounds.xMin, bounds.xMax); + const worldY = mapRange(height - yOffset - pixelCoords.y, 0, effectiveHeight, bounds.yMin, bounds.yMax); + + return { x: worldX, y: worldY }; +}; diff --git a/src/utils/performance.js b/src/utils/performance.js new file mode 100644 index 0000000..4a12fc9 --- /dev/null +++ b/src/utils/performance.js @@ -0,0 +1,82 @@ +/** + * Utilidades para manejo de performance y recursos + */ + +/** + * Debounce function para limitar la frecuencia de llamadas + * @param {Function} func - Función a ejecutar + * @param {number} delay - Retraso en milisegundos + * @returns {Function} + */ +export const debounce = (func, delay) => { + let timeoutId; + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => func.apply(null, args), delay); + }; +}; + +/** + * Throttle function para limitar la frecuencia de ejecución + * @param {Function} func - Función a ejecutar + * @param {number} limit - Límite en milisegundos + * @returns {Function} + */ +export const throttle = (func, limit) => { + let inThrottle; + return (...args) => { + if (!inThrottle) { + func.apply(null, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +}; + +/** + * Hook personalizado para medir el rendimiento + * @param {string} name - Nombre de la medición + * @returns {Object} + */ +export const usePerformance = (name) => { + const start = () => { + if (typeof performance !== 'undefined' && performance.mark) { + performance.mark(`${name}-start`); + } + }; + + const end = () => { + if (typeof performance !== 'undefined' && performance.mark && performance.measure) { + performance.mark(`${name}-end`); + performance.measure(name, `${name}-start`, `${name}-end`); + } + }; + + return { start, end }; +}; + +/** + * Limpia los recursos de TensorFlow.js + * @param {Array} tensors - Array de tensores a limpiar + */ +export const cleanupTensors = (tensors) => { + if (tensors && Array.isArray(tensors)) { + tensors.forEach(tensor => { + if (tensor && typeof tensor.dispose === 'function') { + tensor.dispose(); + } + }); + } +}; + +/** + * Verifica si hay memory leaks en TensorFlow.js + */ +export const checkMemoryLeaks = () => { + if (typeof window !== 'undefined' && window.tf && window.tf.memory) { + const memInfo = window.tf.memory(); + if (memInfo.numTensors > 100) { // Umbral configurable + console.warn('Posible memory leak detectado. Tensores en memoria:', memInfo.numTensors); + } + } +}; diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 0000000..0cbdae3 --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1,54 @@ +/** + * Utilidades para validación de datos + */ + +/** + * Valida si un valor es un número válido + * @param {any} value - Valor a validar + * @returns {boolean} + */ +export const isValidNumber = (value) => { + return typeof value === 'number' && !isNaN(value) && isFinite(value); +}; + +/** + * Valida si las coordenadas latentes son válidas + * @param {Object} coords - Coordenadas {x, y} + * @returns {boolean} + */ +export const isValidLatentCoords = (coords) => { + return coords && + isValidNumber(coords.x) && + isValidNumber(coords.y); +}; + +/** + * Valida si los límites del espacio latente son válidos + * @param {Object} bounds - Límites {xMin, xMax, yMin, yMax} + * @returns {boolean} + */ +export const isValidBounds = (bounds) => { + return bounds && + isValidNumber(bounds.xMin) && + isValidNumber(bounds.xMax) && + isValidNumber(bounds.yMin) && + isValidNumber(bounds.yMax) && + bounds.xMax > bounds.xMin && + bounds.yMax > bounds.yMin; +}; + +/** + * Valida si los datos latentes son válidos + * @param {Array} data - Array de datos latentes + * @returns {boolean} + */ +export const isValidLatentData = (data) => { + return Array.isArray(data) && + data.length > 0 && + data.every(point => + point && + isValidNumber(point.x) && + isValidNumber(point.y) && + typeof point.label === 'string' + ); +}; diff --git a/vite.config.js b/vite.config.js index 491ab1d..c5e7775 100644 --- a/vite.config.js +++ b/vite.config.js @@ -5,4 +5,24 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], base: '/cae/', + build: { + outDir: 'dist', + sourcemap: false, + minify: 'terser', + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom'], + tensorflow: ['@tensorflow/tfjs'] + } + } + } + }, + optimizeDeps: { + include: ['@tensorflow/tfjs'] + }, + server: { + port: 3000, + open: true + } })