Jose Salazar Claude Opus 5 commited on
Commit
2ab057f
·
1 Parent(s): a50e2a1

Retirar el stack legacy PHP/Apache

Browse files

js/*.js y api/*.php ya no se cargaban: index.html apunta al bundle TS y el
Dockerfile no los copia a la imagen. Eran código muerto (2528 líneas).

.htaccess se retira también: era config sólo-Apache y el runtime nuevo es
uvicorn. Además protegía secretos por FilesMatch, algo en lo que el plan de
seguridad decidió explícitamente no confiar.

El código sigue disponible en el tag v1.0-legacy-php y en la rama
legacy/php-apache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (14) hide show
  1. .htaccess +0 -32
  2. api/auth.php +0 -120
  3. api/conexion.php +0 -47
  4. api/hf_proxy.php +0 -116
  5. api/papers_proxy.php +0 -113
  6. api/setup.php +0 -37
  7. js/analisis.js +0 -817
  8. js/auth.js +0 -249
  9. js/ia.js +0 -289
  10. js/main.js +0 -205
  11. js/papers.js +0 -286
  12. js/pdf-parser.js +0 -477
  13. js/tooltip.js +0 -59
  14. js/ui.js +0 -479
.htaccess DELETED
@@ -1,32 +0,0 @@
1
- # Deny access to sensitive files
2
- <FilesMatch "^(\.env|setup\.php)$">
3
- Require all denied
4
- </FilesMatch>
5
-
6
- # Gzip compression
7
- <IfModule mod_deflate.c>
8
- AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript application/json font/ttf font/woff font/woff2
9
- </IfModule>
10
-
11
- <IfModule mod_headers.c>
12
- # JS and HTML — always revalidate so code changes are visible immediately.
13
- # The script tag ?v= param busts the cache on intentional deploys.
14
- <FilesMatch "\.(js|html)$">
15
- Header set Cache-Control "no-cache, must-revalidate"
16
- </FilesMatch>
17
-
18
- # JSON data files — same: revalidate on every request
19
- <FilesMatch "\.json$">
20
- Header set Cache-Control "no-cache, must-revalidate"
21
- </FilesMatch>
22
-
23
- # CSS — revalidate (style changes need to show immediately too)
24
- <FilesMatch "\.css$">
25
- Header set Cache-Control "no-cache, must-revalidate"
26
- </FilesMatch>
27
-
28
- # Fonts — truly immutable; safe to cache for 1 year
29
- <FilesMatch "\.(ttf|woff|woff2)$">
30
- Header set Cache-Control "public, max-age=31536000, immutable"
31
- </FilesMatch>
32
- </IfModule>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/auth.php DELETED
@@ -1,120 +0,0 @@
1
- <?php
2
-
3
- session_start();
4
- header('Content-Type: application/json; charset=utf-8');
5
- header('Access-Control-Allow-Origin: *');
6
- header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
7
- header('Access-Control-Allow-Headers: Content-Type');
8
-
9
- if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
10
-
11
- // Verificar estado de sesión (GET)
12
- if ($_SERVER['REQUEST_METHOD'] === 'GET') {
13
- echo json_encode([
14
- 'autenticado' => isset($_SESSION['morphos_usuario']),
15
- 'nombre' => $_SESSION['morphos_nombre'] ?? null,
16
- ]);
17
- exit;
18
- }
19
-
20
- if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
21
- http_response_code(405);
22
- echo json_encode(['error' => 'Método no permitido.']);
23
- exit;
24
- }
25
-
26
- require __DIR__ . '/conexion.php';
27
-
28
- if (!$conexion) {
29
- http_response_code(503);
30
- echo json_encode(['error' => 'Error de conexión con la base de datos.']);
31
- exit;
32
- }
33
-
34
- $cuerpo = json_decode(file_get_contents('php://input'), true) ?? [];
35
- $accion = $cuerpo['accion'] ?? '';
36
-
37
- switch ($accion) {
38
-
39
- case 'login':
40
- $email = trim($cuerpo['email'] ?? '');
41
- $password = $cuerpo['password'] ?? '';
42
-
43
- if (!$email || !$password) {
44
- http_response_code(422);
45
- echo json_encode(['error' => 'Email y contraseña son requeridos.']);
46
- exit;
47
- }
48
-
49
- $stmt = $conexion->prepare("SELECT id, nombre, email, password FROM usuarios WHERE email = :email LIMIT 1");
50
- $stmt->bindParam(':email', $email);
51
- $stmt->execute();
52
- $usuario = $stmt->fetch(PDO::FETCH_ASSOC);
53
-
54
- if ($usuario && password_verify($password, $usuario['password'])) {
55
- $_SESSION['morphos_usuario'] = $usuario['email'];
56
- $_SESSION['morphos_nombre'] = $usuario['nombre'];
57
- echo json_encode(['ok' => true, 'nombre' => $usuario['nombre']]);
58
- } else {
59
- http_response_code(401);
60
- echo json_encode(['error' => 'Email o contraseña incorrectos.']);
61
- }
62
- break;
63
-
64
- case 'registro':
65
- $nombre = trim($cuerpo['nombre'] ?? '');
66
- $apellido = trim($cuerpo['apellido'] ?? '');
67
- $email = trim($cuerpo['email'] ?? '');
68
- $password = $cuerpo['password'] ?? '';
69
-
70
- if (!$nombre || !$apellido || !$email || !$password) {
71
- http_response_code(422);
72
- echo json_encode(['error' => 'Todos los campos son requeridos.']);
73
- exit;
74
- }
75
-
76
- if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
77
- http_response_code(422);
78
- echo json_encode(['error' => 'El email no es válido.']);
79
- exit;
80
- }
81
-
82
- if (strlen($password) < 6) {
83
- http_response_code(422);
84
- echo json_encode(['error' => 'La contraseña debe tener al menos 6 caracteres.']);
85
- exit;
86
- }
87
-
88
- $stmt = $conexion->prepare("SELECT id FROM usuarios WHERE email = :email LIMIT 1");
89
- $stmt->bindParam(':email', $email);
90
- $stmt->execute();
91
- if ($stmt->fetch()) {
92
- http_response_code(409);
93
- echo json_encode(['error' => 'Ya existe una cuenta con ese email.']);
94
- exit;
95
- }
96
-
97
- $hash = password_hash($password, PASSWORD_DEFAULT);
98
- $stmt = $conexion->prepare(
99
- "INSERT INTO usuarios (nombre, apellido, email, password) VALUES (:nombre, :apellido, :email, :password)"
100
- );
101
- $stmt->bindParam(':nombre', $nombre);
102
- $stmt->bindParam(':apellido', $apellido);
103
- $stmt->bindParam(':email', $email);
104
- $stmt->bindParam(':password', $hash);
105
- $stmt->execute();
106
-
107
- $_SESSION['morphos_usuario'] = $email;
108
- $_SESSION['morphos_nombre'] = $nombre;
109
- echo json_encode(['ok' => true, 'nombre' => $nombre]);
110
- break;
111
-
112
- case 'logout':
113
- session_destroy();
114
- echo json_encode(['ok' => true]);
115
- break;
116
-
117
- default:
118
- http_response_code(400);
119
- echo json_encode(['error' => 'Acción no válida.']);
120
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/conexion.php DELETED
@@ -1,47 +0,0 @@
1
- <?php
2
-
3
- // Primero intenta conectar con MySQL (XAMPP), fallback a SQLite (Docker / HF Spaces)
4
- $dbHost = '127.0.0.1';
5
- $dbUsuario = 'root';
6
- $dbClave = '';
7
- $dbNombre = 'morphos_db';
8
- $dbPort = 3306;
9
- $dbPath = __DIR__ . '/../data/morphos.db';
10
-
11
- if (file_exists(__DIR__ . '/.env')) {
12
- foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
13
- if (str_starts_with($line, 'DB_PORT=')) $dbPort = (int) trim(substr($line, 8));
14
- }
15
- }
16
-
17
- $conexion = null;
18
-
19
- $useSqlite = getenv('DB_FORCE_SQLITE') === '1';
20
-
21
- if (!$useSqlite) {
22
- try {
23
- $conexion = new PDO("mysql:host=$dbHost;port=$dbPort;dbname=$dbNombre;charset=utf8mb4", $dbUsuario, $dbClave);
24
- $conexion->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
25
- } catch (PDOException $e) {
26
- $conexion = null;
27
- }
28
- }
29
-
30
- // Fallback de SQLite
31
- if (!$conexion) {
32
- try {
33
- $conexion = new PDO("sqlite:$dbPath");
34
- $conexion->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
35
-
36
- $conexion->exec("CREATE TABLE IF NOT EXISTS usuarios (
37
- id INTEGER PRIMARY KEY AUTOINCREMENT,
38
- nombre TEXT NOT NULL,
39
- apellido TEXT NOT NULL,
40
- email TEXT NOT NULL UNIQUE,
41
- password TEXT NOT NULL,
42
- creado_en DATETIME DEFAULT CURRENT_TIMESTAMP
43
- )");
44
- } catch (PDOException $e) {
45
- $conexion = null;
46
- }
47
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/hf_proxy.php DELETED
@@ -1,116 +0,0 @@
1
- <?php
2
- header('Content-Type: application/json; charset=utf-8');
3
- header('Access-Control-Allow-Origin: *');
4
- header('Access-Control-Allow-Methods: POST, OPTIONS');
5
- header('Access-Control-Allow-Headers: Content-Type');
6
-
7
- if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
8
- if ($_SERVER['REQUEST_METHOD'] !== 'POST') { http_response_code(405); exit; }
9
-
10
- $hfKey = $_ENV['HF_API_KEY'] ?? $_SERVER['HF_API_KEY'] ?? getenv('HF_API_KEY') ?? '';
11
-
12
- // Fallback a archivo .env si la variable de entorno no esta definida
13
- if (!$hfKey && file_exists(__DIR__ . '/.env')) {
14
- foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
15
- if (str_starts_with($line, 'HF_API_KEY=')) { $hfKey = trim(substr($line, 11)); break; }
16
- }
17
- }
18
-
19
- if (!$hfKey) { http_response_code(503); echo json_encode(['error' => 'Servicio no configurado.']); exit; }
20
-
21
- $body = json_decode(file_get_contents('php://input'), true);
22
- $SPACE = 'https://blackmistcode-morphos-medgemma.hf.space/gradio_api';
23
- $auth = ['Content-Type: application/json', "Authorization: Bearer $hfKey"];
24
-
25
- set_time_limit(120);
26
-
27
- function hf_get(string $url, array $headers, ?string $post = null): array {
28
- $ch = curl_init($url);
29
- curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 120]);
30
- if ($post !== null) { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $post); }
31
- return [curl_exec($ch), curl_getinfo($ch, CURLINFO_HTTP_CODE)];
32
- }
33
-
34
- function uploadImagen(string $space, string $hfKey, string $dataUrl): ?array {
35
- if (!preg_match('/^data:(image\/[\w+]+);base64,(.+)$/s', $dataUrl, $m)) return null;
36
- $mimeType = $m[1];
37
- $ext = explode('/', $mimeType)[1] ?? 'jpg';
38
- $binary = base64_decode($m[2]);
39
- if ($binary === false) return null;
40
-
41
- // Construye manualmente el cuerpo multipart para enviar la imagen al upload endpoint de Gradio
42
- $boundary = bin2hex(random_bytes(16));
43
- $body = "--$boundary\r\n"
44
- . "Content-Disposition: form-data; name=\"files\"; filename=\"image.$ext\"\r\n"
45
- . "Content-Type: $mimeType\r\n\r\n"
46
- . $binary
47
- . "\r\n--$boundary--\r\n";
48
-
49
- $ch = curl_init("$space/upload");
50
- curl_setopt_array($ch, [
51
- CURLOPT_RETURNTRANSFER => true,
52
- CURLOPT_POST => true,
53
- CURLOPT_POSTFIELDS => $body,
54
- CURLOPT_HTTPHEADER => [
55
- "Authorization: Bearer $hfKey",
56
- "Content-Type: multipart/form-data; boundary=$boundary",
57
- ],
58
- CURLOPT_TIMEOUT => 60,
59
- ]);
60
- $result = curl_exec($ch);
61
- $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
62
- curl_close($ch);
63
-
64
- // Si el upload falla, devuelve la imagen inline para que el modelo la procese igualmente
65
- if ($code >= 400 || !$result) {
66
- return ['url' => $dataUrl, 'orig_name' => "image.$ext", 'mime_type' => $mimeType];
67
- }
68
-
69
- $files = json_decode($result, true);
70
- $path = is_array($files) && isset($files[0]) ? $files[0] : null;
71
- if (!$path) {
72
- return ['url' => $dataUrl, 'orig_name' => "image.$ext", 'mime_type' => $mimeType];
73
- }
74
-
75
- return [
76
- 'path' => $path,
77
- 'url' => "$space/file=" . $path,
78
- 'orig_name' => "image.$ext",
79
- 'mime_type' => $mimeType,
80
- ];
81
- }
82
-
83
- $rawImages = array_slice(array_values($body['images'] ?? []), 0, 4);
84
- $data = [];
85
- foreach ($rawImages as $img) {
86
- $data[] = $img ? uploadImagen($SPACE, $hfKey, $img) : null;
87
- }
88
- while (count($data) < 4) $data[] = null;
89
- $data[] = $body['prompt'] ?? '';
90
-
91
- [$submitBody, $code] = hf_get("$SPACE/call/analyze", $auth, json_encode(['data' => $data]));
92
-
93
- if ($code >= 400) { http_response_code(502); echo json_encode(['error' => "Error Space: HTTP $code"]); exit; }
94
-
95
- $eventId = json_decode($submitBody, true)['event_id'] ?? null;
96
- if (!$eventId) { http_response_code(502); echo json_encode(['error' => 'No se obtuvo event_id.']); exit; }
97
-
98
- [$stream] = hf_get("$SPACE/call/analyze/$eventId", ["Authorization: Bearer $hfKey"]);
99
-
100
- $result = $error = null; $lastEvent = '';
101
- // Parsea el stream SSE de Gradio buscando el evento 'complete' o 'process_completed'
102
- foreach (explode("\n", $stream) as $raw) {
103
- $line = rtrim($raw, "\r");
104
- if (str_starts_with($line, 'event:')) $lastEvent = trim(substr($line, 6));
105
- elseif (str_starts_with($line, 'data:')) {
106
- $parsed = json_decode(trim(substr($line, 5)), true);
107
- if (in_array($lastEvent, ['complete', 'process_completed']))
108
- $result = is_array($parsed) ? $parsed[0] : ($parsed['output'] ?? $parsed);
109
- elseif ($lastEvent === 'error')
110
- $error = $parsed['error'] ?? 'Error del modelo.';
111
- }
112
- }
113
-
114
- if ($error) { http_response_code(503); echo json_encode(['error' => $error]); }
115
- elseif ($result !== null) { echo json_encode(['text' => $result]); }
116
- else { http_response_code(502); echo json_encode(['error' => 'Sin respuesta del modelo.']); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/papers_proxy.php DELETED
@@ -1,113 +0,0 @@
1
- <?php
2
- header('Content-Type: application/json');
3
- header('Access-Control-Allow-Origin: *');
4
- header('Access-Control-Allow-Methods: GET, OPTIONS');
5
- header('Access-Control-Allow-Headers: Content-Type');
6
-
7
- if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
8
-
9
- $consulta = trim($_GET['query'] ?? '');
10
- if ($consulta === '') {
11
- http_response_code(400);
12
- echo json_encode(['error' => 'query requerido']);
13
- exit;
14
- }
15
-
16
- $dirCache = sys_get_temp_dir() . '/morphos_papers_cache';
17
- if (!is_dir($dirCache)) mkdir($dirCache, 0700, true);
18
-
19
- function leerCache(string $clave, int $ttl): string|false {
20
- global $dirCache;
21
- $archivo = $dirCache . '/' . md5($clave) . '.json';
22
- if (file_exists($archivo) && (time() - filemtime($archivo)) < $ttl) {
23
- return file_get_contents($archivo);
24
- }
25
- return false;
26
- }
27
-
28
- function escribirCache(string $clave, string $contenido): void {
29
- global $dirCache;
30
- file_put_contents($dirCache . '/' . md5($clave) . '.json', $contenido);
31
- }
32
-
33
- $claveCache = 'pm:' . $consulta;
34
- $cached = leerCache($claveCache, 1800);
35
- if ($cached) { echo $cached; exit; }
36
-
37
- function fetchHttp(string $url, array $cabeceras, int $timeout = 15): array {
38
- $ctx = stream_context_create(['http' => [
39
- 'method' => 'GET',
40
- 'header' => implode("\r\n", $cabeceras),
41
- 'timeout' => $timeout,
42
- 'ignore_errors' => true,
43
- ]]);
44
- $body = @file_get_contents($url, false, $ctx);
45
- $codigo = 0;
46
- foreach ($http_response_header ?? [] as $h) {
47
- if (preg_match('#HTTP/\S+\s+(\d+)#', $h, $m)) $codigo = (int)$m[1];
48
- }
49
- return ['body' => $body, 'codigo' => $codigo];
50
- }
51
-
52
- $cabeceras = ['User-Agent: Morphos/1.0 (mailto:ceo@equipamed.net)', 'Accept: application/json'];
53
-
54
- $urlBusqueda = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi'
55
- . '?db=pubmed&retmode=json&retmax=100&term=' . urlencode($consulta);
56
-
57
- $resp = fetchHttp($urlBusqueda, $cabeceras);
58
- if ($resp['body'] === false || $resp['codigo'] >= 400) {
59
- http_response_code(502);
60
- echo json_encode(['error' => 'No se pudo contactar PubMed.']);
61
- exit;
62
- }
63
-
64
- $busqueda = json_decode($resp['body'], true);
65
- $ids = $busqueda['esearchresult']['idlist'] ?? [];
66
-
67
- if (empty($ids)) {
68
- $salida = json_encode(['total' => 0, 'data' => []]);
69
- escribirCache($claveCache, $salida);
70
- echo $salida;
71
- exit;
72
- }
73
-
74
- $urlResumen = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi'
75
- . '?db=pubmed&retmode=json&id=' . implode(',', $ids);
76
-
77
- $resp2 = fetchHttp($urlResumen, $cabeceras);
78
- if ($resp2['body'] === false || $resp2['codigo'] >= 400) {
79
- http_response_code(502);
80
- echo json_encode(['error' => 'No se pudo obtener los resultados de PubMed.']);
81
- exit;
82
- }
83
-
84
- $resumen = json_decode($resp2['body'], true);
85
- $resultado = $resumen['result'] ?? [];
86
- $uids = $resultado['uids'] ?? $ids;
87
-
88
- $papers = [];
89
- foreach ($uids as $uid) {
90
- $p = $resultado[$uid] ?? null;
91
- if (!$p) continue;
92
-
93
- $anio = '';
94
- if (!empty($p['pubdate'])) { preg_match('/\d{4}/', $p['pubdate'], $m); $anio = $m[0] ?? ''; }
95
-
96
- $doi = '';
97
- foreach ($p['articleids'] ?? [] as $aid) {
98
- if ($aid['idtype'] === 'doi') { $doi = $aid['value']; break; }
99
- }
100
-
101
- $papers[] = [
102
- 'pmid' => $uid,
103
- 'title' => $p['title'] ?? 'Sin título',
104
- 'authors' => array_map(fn($a) => ['name' => $a['name']], $p['authors'] ?? []),
105
- 'year' => $anio,
106
- 'doi' => $doi,
107
- 'journal' => $p['source'] ?? '',
108
- ];
109
- }
110
-
111
- $salida = json_encode(['total' => count($papers), 'data' => $papers]);
112
- escribirCache($claveCache, $salida);
113
- echo $salida;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/setup.php DELETED
@@ -1,37 +0,0 @@
1
- <?php
2
-
3
- header('Content-Type: text/html; charset=utf-8');
4
-
5
- $dbHost = '127.0.0.1';
6
- $dbUsuario = 'root';
7
- $dbClave = '';
8
- $dbNombre = 'morphos_db';
9
- $dbPort = 3306;
10
-
11
- foreach (file(__DIR__ . '/.env', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
12
- if (str_starts_with($line, 'DB_PORT=')) $dbPort = (int) trim(substr($line, 8));
13
- }
14
-
15
- try {
16
- $conexion = new PDO("mysql:host=$dbHost;port=$dbPort;charset=utf8mb4", $dbUsuario, $dbClave);
17
- $conexion->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
18
-
19
- $conexion->exec("CREATE DATABASE IF NOT EXISTS `$dbNombre`");
20
- $conexion->exec("USE `$dbNombre`");
21
- $conexion->exec("
22
- CREATE TABLE IF NOT EXISTS usuarios (
23
- id INT AUTO_INCREMENT PRIMARY KEY,
24
- nombre VARCHAR(100) NOT NULL,
25
- apellido VARCHAR(100) NOT NULL,
26
- email VARCHAR(150) NOT NULL UNIQUE,
27
- password VARCHAR(255) NOT NULL,
28
- creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
29
- )
30
- ");
31
-
32
- echo "<p style='font-family:sans-serif;color:green'>✓ Base de datos <strong>$dbNombre</strong> y tabla <strong>usuarios</strong> listas.</p>";
33
- echo "<p style='font-family:sans-serif'><a href='../index.html'>← Volver a Morphos</a></p>";
34
-
35
- } catch (PDOException $e) {
36
- echo "<p style='font-family:sans-serif;color:red'>Error: " . htmlspecialchars($e->getMessage()) . "</p>";
37
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/analisis.js DELETED
@@ -1,817 +0,0 @@
1
-
2
- // Gravedad
3
- // La desviación se mide en múltiplos del ancho del rango de referencia.
4
- // Ej: rango WBC 6-17 (ancho = 11). WBC = 28 → desviación = 11/11 = 1.0 → moderado.
5
-
6
- const UMBRALES_GRAVEDAD = { leve: 0.5, moderado: 1.5 };
7
-
8
- const clasificarGravedad = (valor, ref) => {
9
- // Mide cuantos anchos de rango de referencia se desvia el valor
10
- const rango = ref.superior - ref.inferior;
11
- const desviacion = valor > ref.superior
12
- ? (valor - ref.superior) / rango
13
- : (ref.inferior - valor) / rango;
14
-
15
- if (desviacion <= UMBRALES_GRAVEDAD.leve) return 'leve';
16
- if (desviacion <= UMBRALES_GRAVEDAD.moderado) return 'moderado';
17
- return 'grave';
18
- };
19
-
20
- // Edad
21
-
22
- const categorizarEdad = (edadMeses, especie) => {
23
- if (edadMeses === null) return 'adulto';
24
-
25
- if (especie === 'canino') {
26
- if (edadMeses < 12) return 'cachorro';
27
- if (edadMeses < 84) return 'adulto';
28
- if (edadMeses < 120) return 'senior';
29
- return 'geriatrico';
30
- }
31
-
32
- // felino
33
- if (edadMeses < 12) return 'cachorro';
34
- if (edadMeses < 120) return 'adulto';
35
- return 'senior';
36
- };
37
-
38
-
39
- // Ajustes por edad
40
-
41
- const AJUSTES_EDAD = {
42
- canino: {
43
- cachorro: { fal: { superior: 3.0 }, wbc: { superior: 1.25 } },
44
- adulto: {},
45
- senior: { bun: { superior: 1.15 }, creat: { superior: 1.15 } },
46
- geriatrico: { bun: { superior: 1.25 }, creat: { superior: 1.25 }, fal: { superior: 1.40 } }
47
- },
48
- felino: {
49
- cachorro: { fal: { superior: 2.0 }, wbc: { superior: 1.20 } },
50
- adulto: {},
51
- senior: { bun: { superior: 1.20 }, creat: { superior: 1.20 } }
52
- }
53
- };
54
-
55
- // Ajustes por raza
56
-
57
- const AJUSTES_RAZA = {
58
- canino: [
59
- {
60
- razas: ['galgo', 'greyhound', 'whippet', 'lebrel'],
61
- ajustes: {
62
- rbc: { inferior: 1.15, superior: 1.15 },
63
- hgb: { inferior: 1.12, superior: 1.12 },
64
- hct: { inferior: 1.12, superior: 1.12 },
65
- plt: { inferior: 0.75, superior: 0.75 }
66
- }
67
- },
68
- {
69
- razas: ['shiba', 'akita'],
70
- ajustes: {
71
- rbc: { inferior: 1.10, superior: 1.10 },
72
- hct: { inferior: 1.08, superior: 1.08 },
73
- hgb: { inferior: 1.08, superior: 1.08 }
74
- }
75
- }
76
- ]
77
- };
78
-
79
-
80
- // Ajustes por sexo
81
-
82
- const AJUSTES_SEXO = {
83
- felino: {
84
- Macho: { creat: { superior: 1.15 } }
85
- }
86
- };
87
-
88
- const obtenerAjustesRaza = (raza, especie) => {
89
- const razaNorm = raza?.toLowerCase().trim() ?? '';
90
- const grupos = AJUSTES_RAZA[especie] ?? [];
91
- return grupos.find(g => g.razas.some(r => razaNorm.includes(r)))?.ajustes ?? {};
92
- };
93
-
94
- // Ajuste de referencias
95
-
96
- const ajustarReferencias = (refsEspecie, paciente) => {
97
- const catEdad = categorizarEdad(paciente.edadMeses, paciente.especie);
98
- const ajEdad = AJUSTES_EDAD[paciente.especie]?.[catEdad] ?? {};
99
- const ajRaza = obtenerAjustesRaza(paciente.raza, paciente.especie);
100
- const ajSexo = AJUSTES_SEXO[paciente.especie]?.[paciente.sexo] ?? {};
101
-
102
- // Multiplica los limites inferiores y superiores por los factores de edad, raza y sexo
103
- return Object.entries(refsEspecie).reduce((acc, [clave, ref]) => {
104
- const factorEdad = ajEdad[clave] ?? {};
105
- const factorRaza = ajRaza[clave] ?? {};
106
- const factorSexo = ajSexo[clave] ?? {};
107
-
108
- acc[clave] = {
109
- ...ref,
110
- inferior: ref.inferior * (factorEdad.inferior ?? 1) * (factorRaza.inferior ?? 1) * (factorSexo.inferior ?? 1),
111
- superior: ref.superior * (factorEdad.superior ?? 1) * (factorRaza.superior ?? 1) * (factorSexo.superior ?? 1)
112
- };
113
- return acc;
114
- }, {});
115
- };
116
-
117
- // Detección de patrones clínicos
118
-
119
- const detectarPatrones = (hallazgos, especie, alt) => {
120
- const mapa = hallazgos.reduce((acc, h) => { acc[h.clave] = h; return acc; }, {});
121
-
122
- const esAlto = (clave) => mapa[clave]?.direccion === 'alto';
123
- const esBajo = (clave) => mapa[clave]?.direccion === 'bajo';
124
- const presente = (clave) => clave in mapa;
125
- const valor = (clave) => mapa[clave]?.valor ?? null;
126
-
127
- const gravedadDe = (...claves) => {
128
- const clave = claves.find(c => mapa[c]);
129
- return mapa[clave]?.gravedad ?? 'leve';
130
- };
131
-
132
- const patrones = [];
133
- const agregar = (patron) => patrones.push(patron);
134
-
135
- // Serie roja
136
-
137
- if (esBajo('hct') || esBajo('hgb') || esBajo('rbc')) {
138
- // Clasifica el tipo de anemia segun el VCM para sugerir la etiologia mas probable
139
- const tipoPorVcm = !presente('vcm') ? '' :
140
- esBajo('vcm') ? 'microcítica' :
141
- esAlto('vcm') ? 'macrocítica' : 'normocítica';
142
-
143
- const claveEtiologia = esBajo('vcm') ? 'ferropenia' :
144
- esAlto('vcm') ? 'macrocitica' :
145
- tipoPorVcm === 'normocítica' ? 'normocitica' : null;
146
- const etiologia = claveEtiologia ? alt.anemia.etiologias?.[claveEtiologia] ?? '' : '';
147
-
148
- agregar({
149
- nombre: `${alt.anemia.nombre}${tipoPorVcm ? ` ${tipoPorVcm}` : ''}`,
150
- descripcion: [alt.anemia.prefijo, etiologia].filter(Boolean).join(' '),
151
- gravedad: gravedadDe('hct', 'hgb', 'rbc'),
152
- parametros: ['hct', 'hgb', 'rbc', 'vcm'].filter(presente)
153
- });
154
- }
155
-
156
- if (esAlto('hct') || esAlto('rbc')) agregar({
157
- nombre: alt.eritrocitosis.nombre,
158
- descripcion: alt.eritrocitosis.descripcion,
159
- gravedad: gravedadDe('hct', 'rbc'),
160
- parametros: ['hct', 'rbc', 'hgb'].filter(presente)
161
- });
162
-
163
- // Serie blanca
164
-
165
- if (esAlto('wbc')) {
166
- // Diferencia leucocitosis neutrofilica de linfocitica; si no hay diferencial, informa generico
167
- const neutrofilia = esAlto('neutro');
168
- const linfocitosis = esAlto('linfo');
169
-
170
- if (neutrofilia) agregar({
171
- nombre: alt.leucocitosis_neutrofilica.nombre,
172
- descripcion: alt.leucocitosis_neutrofilica.descripcion,
173
- gravedad: gravedadDe('wbc', 'neutro'),
174
- parametros: ['wbc', 'neutro'].filter(presente)
175
- });
176
-
177
- if (linfocitosis) agregar({
178
- nombre: alt.leucocitosis_linfocitica.nombre,
179
- descripcion: alt.leucocitosis_linfocitica.descripcion,
180
- gravedad: gravedadDe('wbc', 'linfo'),
181
- parametros: ['wbc', 'linfo'].filter(presente)
182
- });
183
-
184
- if (!neutrofilia && !linfocitosis) agregar({
185
- nombre: alt.leucocitosis.nombre,
186
- descripcion: alt.leucocitosis.descripcion,
187
- gravedad: gravedadDe('wbc'),
188
- parametros: ['wbc']
189
- });
190
- }
191
-
192
- if (esBajo('wbc')) agregar({
193
- nombre: alt.leucopenia.nombre,
194
- descripcion: alt.leucopenia.descripcion,
195
- gravedad: gravedadDe('wbc'),
196
- parametros: ['wbc']
197
- });
198
-
199
- if (esBajo('neutro')) agregar({
200
- nombre: alt.neutropenia.nombre,
201
- descripcion: alt.neutropenia.descripcion,
202
- gravedad: gravedadDe('neutro'),
203
- parametros: ['neutro']
204
- });
205
-
206
- if (esBajo('linfo')) agregar({
207
- nombre: alt.linfopenia.nombre,
208
- descripcion: alt.linfopenia.descripcion,
209
- gravedad: gravedadDe('linfo'),
210
- parametros: ['linfo']
211
- });
212
-
213
- if (esAlto('eosino')) agregar({
214
- nombre: alt.eosinofilia.nombre,
215
- descripcion: alt.eosinofilia.descripcion,
216
- gravedad: gravedadDe('eosino'),
217
- parametros: ['eosino']
218
- });
219
-
220
-
221
- // Plaquetas
222
-
223
- if (esBajo('plt')) agregar({
224
- nombre: alt.trombocitopenia.nombre,
225
- descripcion: alt.trombocitopenia.descripcion,
226
- gravedad: gravedadDe('plt'),
227
- parametros: ['plt']
228
- });
229
-
230
- if (esAlto('plt')) agregar({
231
- nombre: alt.trombocitosis.nombre,
232
- descripcion: alt.trombocitosis.descripcion,
233
- gravedad: gravedadDe('plt'),
234
- parametros: ['plt']
235
- });
236
-
237
-
238
- // Hígado
239
-
240
- if (esAlto('alt') && esAlto('ast')) agregar({
241
- nombre: alt.dano_hepatocelular.nombre,
242
- descripcion: alt.dano_hepatocelular.descripcion,
243
- gravedad: gravedadDe('alt', 'ast'),
244
- parametros: ['alt', 'ast'].filter(presente)
245
- });
246
- else if (esAlto('alt')) agregar({
247
- nombre: alt.alt_aislada.nombre,
248
- descripcion: alt.alt_aislada.descripcion,
249
- gravedad: gravedadDe('alt'),
250
- parametros: ['alt']
251
- });
252
-
253
- if (esAlto('fal')) agregar({
254
- nombre: alt.patron_colestasico.nombre,
255
- descripcion: alt.patron_colestasico.descripcion[especie] ?? alt.patron_colestasico.descripcion.canino,
256
- gravedad: gravedadDe('fal'),
257
- parametros: ['fal']
258
- });
259
-
260
- if (esAlto('bili')) agregar({
261
- nombre: alt.hiperbilirrubinemia.nombre,
262
- descripcion: alt.hiperbilirrubinemia.descripcion,
263
- gravedad: gravedadDe('bili'),
264
- parametros: ['bili']
265
- });
266
-
267
- // Riñón
268
-
269
- if (esAlto('bun') && esAlto('creat')) agregar({
270
- nombre: alt.azotemia.nombre,
271
- descripcion: alt.azotemia.descripcion,
272
- gravedad: gravedadDe('creat', 'bun'),
273
- parametros: ['bun', 'creat'].filter(presente)
274
- });
275
- else if (esAlto('bun')) agregar({
276
- nombre: alt.hiperuremia_bun.nombre,
277
- descripcion: alt.hiperuremia_bun.descripcion,
278
- gravedad: gravedadDe('bun'),
279
- parametros: ['bun']
280
- });
281
- else if (esAlto('creat')) agregar({
282
- nombre: alt.creatinina_aislada.nombre,
283
- descripcion: alt.creatinina_aislada.descripcion,
284
- gravedad: gravedadDe('creat'),
285
- parametros: ['creat']
286
- });
287
-
288
- if (esBajo('bun')) agregar({
289
- nombre: alt.bun_disminuido.nombre,
290
- descripcion: alt.bun_disminuido.descripcion,
291
- gravedad: gravedadDe('bun'),
292
- parametros: ['bun']
293
- });
294
-
295
- // Glucosa
296
-
297
- if (esAlto('gluc')) agregar({
298
- nombre: alt.hiperglucemia.nombre,
299
- descripcion: alt.hiperglucemia.descripcion[especie] ?? alt.hiperglucemia.descripcion.canino,
300
- gravedad: gravedadDe('gluc'),
301
- parametros: ['gluc']
302
- });
303
-
304
- if (esBajo('gluc')) agregar({
305
- nombre: alt.hipoglucemia.nombre,
306
- descripcion: alt.hipoglucemia.descripcion,
307
- gravedad: gravedadDe('gluc'),
308
- parametros: ['gluc']
309
- });
310
-
311
- // Proteínas
312
-
313
- if (esAlto('prot')) agregar({
314
- nombre: alt.hiperproteinemia.nombre,
315
- descripcion: alt.hiperproteinemia.descripcion,
316
- gravedad: gravedadDe('prot'),
317
- parametros: ['prot']
318
- });
319
-
320
- if (esBajo('alb')) {
321
- const hipoproteinemia = esBajo('prot');
322
- const claveAlteracion = hipoproteinemia ? 'hipoproteinemia_hipoalbuminemia' : 'hipoalbuminemia';
323
- agregar({
324
- nombre: alt[claveAlteracion].nombre,
325
- descripcion: alt[claveAlteracion].descripcion,
326
- gravedad: gravedadDe('alb'),
327
- parametros: ['alb', ...(hipoproteinemia ? ['prot'] : [])].filter(presente)
328
- });
329
- }
330
-
331
- // Electrolitos
332
-
333
- const valSodio = valor('sodio');
334
- const valPotasio = valor('potasio');
335
-
336
- // Ratio Na/K < 27 es sugestivo de hipoadrenocorticismo; la gravedad aumenta a menor ratio
337
- if (valSodio !== null && valPotasio !== null && valPotasio > 0) {
338
- const ratioNaK = valSodio / valPotasio;
339
- if (ratioNaK < 27) agregar({
340
- nombre: alt.ratio_nak.nombre,
341
- descripcion: alt.ratio_nak.descripcion.replace('{ratio}', ratioNaK.toFixed(1)),
342
- gravedad: ratioNaK < 20 ? 'grave' : ratioNaK < 24 ? 'moderado' : 'leve',
343
- parametros: ['sodio', 'potasio'].filter(presente)
344
- });
345
- }
346
-
347
- if (esAlto('sodio')) agregar({
348
- nombre: alt.hipernatremia.nombre,
349
- descripcion: alt.hipernatremia.descripcion,
350
- gravedad: gravedadDe('sodio'),
351
- parametros: ['sodio']
352
- });
353
-
354
- if (esBajo('sodio')) agregar({
355
- nombre: alt.hiponatremia.nombre,
356
- descripcion: alt.hiponatremia.descripcion,
357
- gravedad: gravedadDe('sodio'),
358
- parametros: ['sodio']
359
- });
360
-
361
- if (esAlto('calc')) agregar({
362
- nombre: alt.hipercalcemia.nombre,
363
- descripcion: alt.hipercalcemia.descripcion,
364
- gravedad: gravedadDe('calc'),
365
- parametros: ['calc']
366
- });
367
-
368
- if (esBajo('calc')) agregar({
369
- nombre: alt.hipocalcemia.nombre,
370
- descripcion: alt.hipocalcemia.descripcion,
371
- gravedad: gravedadDe('calc'),
372
- parametros: ['calc']
373
- });
374
-
375
- if (esBajo('potasio')) agregar({
376
- nombre: alt.hipopotasemia.nombre,
377
- descripcion: alt.hipopotasemia.descripcion,
378
- gravedad: gravedadDe('potasio'),
379
- parametros: ['potasio']
380
- });
381
-
382
- if (esAlto('potasio')) agregar({
383
- nombre: alt.hiperpotasemia.nombre,
384
- descripcion: alt.hiperpotasemia.descripcion,
385
- gravedad: gravedadDe('potasio'),
386
- parametros: ['potasio']
387
- });
388
-
389
- if (esAlto('fosf')) agregar({
390
- nombre: alt.hiperfosforemia.nombre,
391
- descripcion: alt.hiperfosforemia.descripcion,
392
- gravedad: gravedadDe('fosf'),
393
- parametros: ['fosf']
394
- });
395
-
396
- // Urianálisis
397
-
398
- const valUsg = valor('usg');
399
- if (valUsg !== null && valUsg < 1.008) agregar({
400
- nombre: alt.hiposthenuria.nombre,
401
- descripcion: alt.hiposthenuria.descripcion,
402
- gravedad: valUsg < 1.005 ? 'grave' : 'moderado',
403
- parametros: ['usg']
404
- });
405
- else if (valUsg !== null && valUsg < 1.013) agregar({
406
- nombre: alt.isosthenuria.nombre,
407
- descripcion: alt.isosthenuria.descripcion,
408
- gravedad: 'leve',
409
- parametros: ['usg']
410
- });
411
-
412
- // Tiroides
413
-
414
- if (especie === 'canino' && esBajo('t4_total')) agregar({
415
- nombre: alt.hipotiroidismo.nombre,
416
- descripcion: alt.hipotiroidismo.descripcion.canino,
417
- gravedad: gravedadDe('t4_total'),
418
- parametros: ['t4_total'].filter(presente)
419
- });
420
-
421
- if (esAlto('t4_total')) agregar({
422
- nombre: alt.hipertiroidismo.nombre,
423
- descripcion: alt.hipertiroidismo.descripcion[especie] ?? alt.hipertiroidismo.descripcion.felino,
424
- gravedad: gravedadDe('t4_total'),
425
- parametros: ['t4_total'].filter(presente)
426
- });
427
-
428
- // Suprarrenal / Cortisol
429
-
430
- if (esAlto('cortisol_acth')) agregar({
431
- nombre: alt.hiperadrenocorticismo.nombre,
432
- descripcion: alt.hiperadrenocorticismo.descripcion[especie] ?? alt.hiperadrenocorticismo.descripcion.canino,
433
- gravedad: gravedadDe('cortisol_acth'),
434
- parametros: ['cortisol_acth', ...(presente('cortisol_bas') ? ['cortisol_bas'] : [])]
435
- });
436
-
437
- if (esBajo('cortisol_acth')) agregar({
438
- nombre: alt.hipoadrenocorticismo_cortisol.nombre,
439
- descripcion: alt.hipoadrenocorticismo_cortisol.descripcion,
440
- gravedad: gravedadDe('cortisol_acth'),
441
- parametros: ['cortisol_acth', ...(presente('cortisol_bas') ? ['cortisol_bas'] : [])]
442
- });
443
-
444
- if (esBajo('cortisol_bas') && !presente('cortisol_acth')) agregar({
445
- nombre: alt.cortisol_basal_bajo.nombre,
446
- descripcion: alt.cortisol_basal_bajo.descripcion,
447
- gravedad: 'moderado',
448
- parametros: ['cortisol_bas']
449
- });
450
-
451
- // Insulina
452
-
453
- if (esBajo('insulina') && esAlto('gluc')) agregar({
454
- nombre: alt.deficit_insulina.nombre,
455
- descripcion: alt.deficit_insulina.descripcion,
456
- gravedad: 'moderado',
457
- parametros: ['insulina', 'gluc'].filter(presente)
458
- });
459
-
460
- // Páncreas exocrino (PLI)
461
-
462
- if (esAlto('pli')) agregar({
463
- nombre: alt.pancreatitis.nombre,
464
- descripcion: alt.pancreatitis.descripcion[especie] ?? alt.pancreatitis.descripcion.canino,
465
- gravedad: gravedadDe('pli'),
466
- parametros: ['pli', ...(presente('lipasa') ? ['lipasa'] : []), ...(presente('amylasa') ? ['amylasa'] : [])].filter(presente)
467
- });
468
-
469
- if (esAlto('amylasa') && !presente('pli')) agregar({
470
- nombre: alt.hiperamylasemia.nombre,
471
- descripcion: alt.hiperamylasemia.descripcion,
472
- gravedad: gravedadDe('amylasa'),
473
- parametros: ['amylasa']
474
- });
475
-
476
- // Tiroides — TSH
477
-
478
- if (esAlto('tsh')) agregar({
479
- nombre: alt.tsh_elevado.nombre,
480
- descripcion: alt.tsh_elevado.descripcion[especie] ?? alt.tsh_elevado.descripcion.canino,
481
- gravedad: gravedadDe('tsh'),
482
- parametros: ['tsh', ...(presente('t4_total') ? ['t4_total'] : []), ...(presente('t4_libre') ? ['t4_libre'] : [])].filter(presente)
483
- });
484
-
485
- if (esBajo('tsh')) agregar({
486
- nombre: alt.tsh_suprimido.nombre,
487
- descripcion: alt.tsh_suprimido.descripcion[especie] ?? alt.tsh_suprimido.descripcion.canino,
488
- gravedad: gravedadDe('tsh'),
489
- parametros: ['tsh', ...(presente('t4_total') ? ['t4_total'] : [])].filter(presente)
490
- });
491
-
492
- if (esBajo('t4_libre') && !presente('tsh')) agregar({
493
- nombre: alt.t4_libre_baja.nombre,
494
- descripcion: alt.t4_libre_baja.descripcion[especie] ?? alt.t4_libre_baja.descripcion.canino,
495
- gravedad: gravedadDe('t4_libre'),
496
- parametros: ['t4_libre', ...(presente('t4_total') ? ['t4_total'] : [])].filter(presente)
497
- });
498
-
499
- // Biomarcadores cardíacos
500
-
501
- if (esAlto('ctni')) agregar({
502
- nombre: alt.dano_miocardico.nombre,
503
- descripcion: alt.dano_miocardico.descripcion,
504
- gravedad: gravedadDe('ctni'),
505
- parametros: ['ctni', ...(presente('nt_probnp') ? ['nt_probnp'] : [])].filter(presente)
506
- });
507
-
508
- if (esAlto('nt_probnp')) agregar({
509
- nombre: alt.cardiopatia_bnp.nombre,
510
- descripcion: alt.cardiopatia_bnp.descripcion[especie] ?? alt.cardiopatia_bnp.descripcion.canino,
511
- gravedad: gravedadDe('nt_probnp'),
512
- parametros: ['nt_probnp', ...(presente('ctni') ? ['ctni'] : [])].filter(presente)
513
- });
514
-
515
- // Proteínas de fase aguda
516
-
517
- if (esAlto('crp') || esAlto('saa')) agregar({
518
- nombre: alt.inflamacion_aguda.nombre,
519
- descripcion: alt.inflamacion_aguda.descripcion[especie] ?? alt.inflamacion_aguda.descripcion.canino,
520
- gravedad: gravedadDe('crp', 'saa'),
521
- parametros: ['crp', 'saa'].filter(presente)
522
- });
523
-
524
- // Progesterona
525
-
526
- if (esAlto('progesterona')) agregar({
527
- nombre: alt.progesterona_elevada.nombre,
528
- descripcion: alt.progesterona_elevada.descripcion[especie] ?? alt.progesterona_elevada.descripcion.canino,
529
- gravedad: gravedadDe('progesterona'),
530
- parametros: ['progesterona']
531
- });
532
-
533
- // Magnesio
534
-
535
- if (esBajo('magnesio')) agregar({
536
- nombre: alt.hipomagnesemia.nombre,
537
- descripcion: alt.hipomagnesemia.descripcion,
538
- gravedad: gravedadDe('magnesio'),
539
- parametros: ['magnesio']
540
- });
541
-
542
- if (esAlto('magnesio')) agregar({
543
- nombre: alt.hipermagnesemia.nombre,
544
- descripcion: alt.hipermagnesemia.descripcion,
545
- gravedad: gravedadDe('magnesio'),
546
- parametros: ['magnesio']
547
- });
548
-
549
- // Hierro
550
-
551
- if (esBajo('hierro')) agregar({
552
- nombre: alt.ferropenia_hierro.nombre,
553
- descripcion: alt.ferropenia_hierro.descripcion,
554
- gravedad: gravedadDe('hierro'),
555
- parametros: ['hierro']
556
- });
557
-
558
- // Ácido úrico
559
-
560
- if (esAlto('ac_urico')) agregar({
561
- nombre: alt.ac_urico_elevado.nombre,
562
- descripcion: alt.ac_urico_elevado.descripcion,
563
- gravedad: gravedadDe('ac_urico'),
564
- parametros: ['ac_urico']
565
- });
566
-
567
- // LDH
568
-
569
- if (esAlto('ldh')) agregar({
570
- nombre: alt.ldh_elevada.nombre,
571
- descripcion: alt.ldh_elevada.descripcion,
572
- gravedad: gravedadDe('ldh'),
573
- parametros: ['ldh']
574
- });
575
-
576
- // Monitorización de fármacos (TDM)
577
-
578
- if (esBajo('fenobarbital')) agregar({
579
- nombre: alt.fenobarbital_subterapeutico.nombre,
580
- descripcion: alt.fenobarbital_subterapeutico.descripcion,
581
- gravedad: gravedadDe('fenobarbital'),
582
- parametros: ['fenobarbital']
583
- });
584
-
585
- if (esAlto('fenobarbital')) agregar({
586
- nombre: alt.fenobarbital_toxico.nombre,
587
- descripcion: alt.fenobarbital_toxico.descripcion,
588
- gravedad: gravedadDe('fenobarbital'),
589
- parametros: ['fenobarbital']
590
- });
591
-
592
- if (esBajo('ciclosporina')) agregar({
593
- nombre: alt.ciclosporina_subterapeutica.nombre,
594
- descripcion: alt.ciclosporina_subterapeutica.descripcion,
595
- gravedad: gravedadDe('ciclosporina'),
596
- parametros: ['ciclosporina']
597
- });
598
-
599
- if (esAlto('ciclosporina')) agregar({
600
- nombre: alt.ciclosporina_toxica.nombre,
601
- descripcion: alt.ciclosporina_toxica.descripcion,
602
- gravedad: gravedadDe('ciclosporina'),
603
- parametros: ['ciclosporina']
604
- });
605
-
606
- // Coagulación
607
-
608
- if (esAlto('pt') && !esAlto('aptt')) agregar({
609
- nombre: alt.coagulopatia_extrinseca.nombre,
610
- descripcion: alt.coagulopatia_extrinseca.descripcion,
611
- gravedad: gravedadDe('pt'),
612
- parametros: ['pt']
613
- });
614
-
615
- if (esAlto('aptt') && !esAlto('pt')) agregar({
616
- nombre: alt.coagulopatia_intrinseca.nombre,
617
- descripcion: alt.coagulopatia_intrinseca.descripcion,
618
- gravedad: gravedadDe('aptt'),
619
- parametros: ['aptt']
620
- });
621
-
622
- if (esAlto('pt') && esAlto('aptt')) agregar({
623
- nombre: alt.coagulopatia_mixta.nombre,
624
- descripcion: alt.coagulopatia_mixta.descripcion,
625
- gravedad: gravedadDe('pt', 'aptt', 'act'),
626
- parametros: ['pt', 'aptt', ...(presente('act') ? ['act'] : [])].filter(presente)
627
- });
628
-
629
- if ((esAlto('ddimeros') || esAlto('fdp')) && esBajo('fibrinogeno')) agregar({
630
- nombre: alt.cid.nombre,
631
- descripcion: alt.cid.descripcion,
632
- gravedad: 'grave',
633
- parametros: ['ddimeros', 'fdp', 'fibrinogeno', 'plt'].filter(presente)
634
- });
635
-
636
- if (esAlto('fibrinogeno') && !esAlto('ddimeros') && !esAlto('fdp')) agregar({
637
- nombre: alt.hiperfibrinogenemia.nombre,
638
- descripcion: alt.hiperfibrinogenemia.descripcion,
639
- gravedad: gravedadDe('fibrinogeno'),
640
- parametros: ['fibrinogeno']
641
- });
642
-
643
- if (esBajo('fibrinogeno') && !esAlto('ddimeros') && !esAlto('fdp')) agregar({
644
- nombre: alt.hipofibrinogenemia.nombre,
645
- descripcion: alt.hipofibrinogenemia.descripcion,
646
- gravedad: gravedadDe('fibrinogeno'),
647
- parametros: ['fibrinogeno']
648
- });
649
-
650
- if (esBajo('vwf')) agregar({
651
- nombre: alt.deficit_vwf.nombre,
652
- descripcion: alt.deficit_vwf.descripcion,
653
- gravedad: gravedadDe('vwf'),
654
- parametros: ['vwf', ...(presente('aptt') ? ['aptt'] : [])].filter(presente)
655
- });
656
-
657
- if (esBajo('antitrombina')) agregar({
658
- nombre: alt.antitrombina_baja.nombre,
659
- descripcion: alt.antitrombina_baja.descripcion,
660
- gravedad: gravedadDe('antitrombina'),
661
- parametros: ['antitrombina']
662
- });
663
-
664
- // Urianálisis — sedimento / UPC
665
-
666
- if (esAlto('rbc_uri')) agregar({
667
- nombre: alt.hematuria_uri.nombre,
668
- descripcion: alt.hematuria_uri.descripcion,
669
- gravedad: gravedadDe('rbc_uri'),
670
- parametros: ['rbc_uri']
671
- });
672
-
673
- if (esAlto('wbc_uri')) agregar({
674
- nombre: alt.piuria.nombre,
675
- descripcion: alt.piuria.descripcion,
676
- gravedad: gravedadDe('wbc_uri'),
677
- parametros: ['wbc_uri']
678
- });
679
-
680
- if (esAlto('upc')) agregar({
681
- nombre: alt.proteinuria_upc.nombre,
682
- descripcion: alt.proteinuria_upc.descripcion,
683
- gravedad: gravedadDe('upc'),
684
- parametros: ['upc']
685
- });
686
-
687
- // Gasometría — ácido-base
688
-
689
- if (presente('ph_sangre')) {
690
- const phBajo = esBajo('ph_sangre');
691
- const phAlto = esAlto('ph_sangre');
692
- const hipercarbia = esAlto('pco2');
693
- const hipocarbia = esBajo('pco2');
694
- const componenteAcidMet = esBajo('hco3') || esBajo('exceso_base');
695
- const componenteAlcalMet = esAlto('hco3') || esAlto('exceso_base');
696
-
697
- if (phBajo) {
698
- if (hipercarbia && componenteAcidMet) {
699
- agregar({
700
- nombre: alt.acidosis_respiratoria.nombre + ' + ' + alt.acidosis_metabolica.nombre,
701
- descripcion: alt.acidosis_metabolica.descripcion,
702
- gravedad: 'grave',
703
- parametros: ['ph_sangre', 'pco2', 'hco3', 'exceso_base'].filter(presente)
704
- });
705
- } else if (hipercarbia) {
706
- agregar({
707
- nombre: alt.acidosis_respiratoria.nombre,
708
- descripcion: alt.acidosis_respiratoria.descripcion,
709
- gravedad: gravedadDe('ph_sangre', 'pco2'),
710
- parametros: ['ph_sangre', 'pco2'].filter(presente)
711
- });
712
- } else if (componenteAcidMet) {
713
- agregar({
714
- nombre: alt.acidosis_metabolica.nombre,
715
- descripcion: alt.acidosis_metabolica.descripcion,
716
- gravedad: gravedadDe('ph_sangre', 'hco3', 'exceso_base'),
717
- parametros: ['ph_sangre', 'hco3', 'exceso_base', 'anion_gap'].filter(presente)
718
- });
719
- }
720
- }
721
-
722
- if (phAlto) {
723
- if (hipocarbia && componenteAlcalMet) {
724
- agregar({
725
- nombre: alt.alcalosis_respiratoria.nombre + ' + ' + alt.alcalosis_metabolica.nombre,
726
- descripcion: alt.alcalosis_metabolica.descripcion,
727
- gravedad: 'grave',
728
- parametros: ['ph_sangre', 'pco2', 'hco3', 'exceso_base'].filter(presente)
729
- });
730
- } else if (hipocarbia) {
731
- agregar({
732
- nombre: alt.alcalosis_respiratoria.nombre,
733
- descripcion: alt.alcalosis_respiratoria.descripcion,
734
- gravedad: gravedadDe('ph_sangre', 'pco2'),
735
- parametros: ['ph_sangre', 'pco2'].filter(presente)
736
- });
737
- } else if (componenteAlcalMet) {
738
- agregar({
739
- nombre: alt.alcalosis_metabolica.nombre,
740
- descripcion: alt.alcalosis_metabolica.descripcion,
741
- gravedad: gravedadDe('ph_sangre', 'hco3', 'exceso_base'),
742
- parametros: ['ph_sangre', 'hco3', 'exceso_base'].filter(presente)
743
- });
744
- }
745
- }
746
- }
747
-
748
- if (esBajo('po2')) agregar({
749
- nombre: alt.hipoxemia.nombre,
750
- descripcion: alt.hipoxemia.descripcion,
751
- gravedad: gravedadDe('po2', 'so2'),
752
- parametros: ['po2', ...(presente('so2') ? ['so2'] : [])].filter(presente)
753
- });
754
-
755
- if (esAlto('lactato')) agregar({
756
- nombre: alt.hiperlactatemia.nombre,
757
- descripcion: alt.hiperlactatemia.descripcion,
758
- gravedad: gravedadDe('lactato'),
759
- parametros: ['lactato']
760
- });
761
-
762
- if (esBajo('ca_ion')) agregar({
763
- nombre: alt.ca_ionizado_bajo.nombre,
764
- descripcion: alt.ca_ionizado_bajo.descripcion,
765
- gravedad: gravedadDe('ca_ion'),
766
- parametros: ['ca_ion']
767
- });
768
-
769
- if (esAlto('ca_ion')) agregar({
770
- nombre: alt.ca_ionizado_alto.nombre,
771
- descripcion: alt.ca_ionizado_alto.descripcion,
772
- gravedad: gravedadDe('ca_ion'),
773
- parametros: ['ca_ion']
774
- });
775
-
776
- if (esAlto('anion_gap')) agregar({
777
- nombre: alt.anion_gap_elevado.nombre,
778
- descripcion: alt.anion_gap_elevado.descripcion,
779
- gravedad: gravedadDe('anion_gap'),
780
- parametros: ['anion_gap', ...(presente('lactato') ? ['lactato'] : [])].filter(presente)
781
- });
782
-
783
- return patrones;
784
- };
785
-
786
- // Exportación principal
787
-
788
- export const analizarResultados = (valoresInput, paciente, referencias, alteraciones) => {
789
- const refsEspecie = referencias[paciente.especie];
790
- if (!refsEspecie) return { hallazgos: [], patrones: [] };
791
-
792
- // Ajusta los rangos segun edad, raza y sexo antes de comparar
793
- const refsAjustadas = ajustarReferencias(refsEspecie, paciente);
794
- const hallazgos = [];
795
-
796
- for (const [clave, ref] of Object.entries(refsAjustadas)) {
797
- const crudo = valoresInput[clave];
798
- if (crudo === null || crudo === undefined || crudo === '') continue;
799
-
800
- const valorNum = parseFloat(crudo);
801
- if (isNaN(valorNum)) continue;
802
-
803
- if (valorNum > ref.superior) {
804
- hallazgos.push({
805
- clave, nombre: ref.nombre, valor: valorNum, unidad: ref.unidad,
806
- direccion: 'alto', gravedad: clasificarGravedad(valorNum, ref)
807
- });
808
- } else if (valorNum < ref.inferior) {
809
- hallazgos.push({
810
- clave, nombre: ref.nombre, valor: valorNum, unidad: ref.unidad,
811
- direccion: 'bajo', gravedad: clasificarGravedad(valorNum, ref)
812
- });
813
- }
814
- }
815
-
816
- return { hallazgos, patrones: detectarPatrones(hallazgos, paciente.especie, alteraciones) };
817
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/auth.js DELETED
@@ -1,249 +0,0 @@
1
- let estadoAuth = null;
2
- let accionPendiente = null;
3
-
4
- // Verificación de sesión
5
-
6
- export async function verificarAuth() {
7
- if (estadoAuth !== null) return estadoAuth;
8
-
9
- try {
10
- const resp = await fetch('api/auth.php');
11
- const datos = await resp.json();
12
- estadoAuth = datos.autenticado;
13
- if (estadoAuth) actualizarBtnUsuario(datos.nombre);
14
- } catch {
15
- estadoAuth = false;
16
- }
17
- return estadoAuth;
18
- }
19
-
20
- // Modal
21
-
22
- const modal = document.getElementById('modal-auth');
23
- const overlay = document.getElementById('modal-auth-overlay');
24
- const btnCerrar = document.getElementById('modal-auth-cerrar');
25
- const tabLogin = document.getElementById('auth-tab-login');
26
- const tabRegistro = document.getElementById('auth-tab-registro');
27
- const panelLogin = document.getElementById('auth-panel-login');
28
- const panelRegistro = document.getElementById('auth-panel-registro');
29
- const formLogin = document.getElementById('form-login');
30
- const formRegistro = document.getElementById('form-registro');
31
- const errorLogin = document.getElementById('auth-error-login');
32
- const errorRegistro = document.getElementById('auth-error-registro');
33
-
34
- function abrirModal() {
35
- modal.hidden = false;
36
- requestAnimationFrame(() => {
37
- modal.classList.add('visible');
38
- overlay.classList.add('activo');
39
- });
40
- formLogin.reset();
41
- formRegistro.reset();
42
- [formLogin, formRegistro].forEach(f =>
43
- f.querySelectorAll('input').forEach(limpiarCampo)
44
- );
45
- errorLogin.textContent = '';
46
- errorRegistro.textContent = '';
47
- activarTab('login');
48
- }
49
-
50
- function cerrarModal() {
51
- modal.classList.remove('visible');
52
- overlay.classList.remove('activo');
53
- modal.addEventListener('transitionend', () => { modal.hidden = true; }, { once: true });
54
- accionPendiente = null;
55
- }
56
-
57
- function activarTab(cual) {
58
- const esLogin = cual === 'login';
59
- tabLogin.classList.toggle('activo', esLogin);
60
- tabRegistro.classList.toggle('activo', !esLogin);
61
- panelLogin.hidden = !esLogin;
62
- panelRegistro.hidden = esLogin;
63
- }
64
-
65
- export function abrirModalAuth(callbackExito) {
66
- accionPendiente = callbackExito ?? null;
67
- abrirModal();
68
- }
69
-
70
- // Botón de usuario en header
71
-
72
- const btnUsuario = document.getElementById('btn-usuario');
73
-
74
- const SVG_LOGIN = `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="currentColor" aria-hidden="true"><path d="M480-120v-80h280v-560H480v-80h280q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H480Zm-80-160-55-58 102-102H120v-80h327L345-622l55-58 200 200-200 200Z"/></svg>`;
75
- const SVG_LOGOUT = `<svg xmlns="http://www.w3.org/2000/svg" height="20px" viewBox="0 -960 960 960" width="20px" fill="currentColor" aria-hidden="true"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h280v80H200Zm440-160-55-58 102-102H360v-80h327L585-622l55-58 200 200-200 200Z"/></svg>`;
76
-
77
- function actualizarBtnUsuario(nombre) {
78
- if (!btnUsuario) return;
79
- btnUsuario.innerHTML = SVG_LOGOUT;
80
- btnUsuario.append(' ', nombre ?? 'Usuario');
81
- btnUsuario.dataset.tooltip = 'Cerrar sesión';
82
- }
83
-
84
- function resetearBtnUsuario() {
85
- if (!btnUsuario) return;
86
- btnUsuario.innerHTML = `${SVG_LOGIN} Login`;
87
- btnUsuario.dataset.tooltip = 'Iniciar sesión';
88
- }
89
-
90
- // Validación en tiempo real
91
-
92
- function esEmailValido(v) {
93
- return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
94
- }
95
-
96
- function marcarCampo(input, valido) {
97
- input.classList.toggle('campo-valido', valido);
98
- input.classList.toggle('campo-invalido', !valido);
99
- }
100
-
101
- function limpiarCampo(input) {
102
- input.classList.remove('campo-valido', 'campo-invalido');
103
- }
104
-
105
- function activarValidacionCampo(input, reglaDeFalso) {
106
- // Solo marca despues del primer blur para no agobiar al usuario mientras escribe
107
- let tocado = false;
108
- input.addEventListener('blur', () => { tocado = true; marcarCampo(input, !reglaDeFalso()); });
109
- input.addEventListener('input', () => { if (tocado) marcarCampo(input, !reglaDeFalso()); });
110
- }
111
-
112
- function inicializarValidacionLogin() {
113
- const email = formLogin.querySelector('[name="email"]');
114
- const password = formLogin.querySelector('[name="password"]');
115
- activarValidacionCampo(email, () => !esEmailValido(email.value));
116
- activarValidacionCampo(password, () => password.value.length < 1);
117
- }
118
-
119
- function inicializarValidacionRegistro() {
120
- const nombre = formRegistro.querySelector('[name="nombre"]');
121
- const apellido = formRegistro.querySelector('[name="apellido"]');
122
- const email = formRegistro.querySelector('[name="email"]');
123
- const password = formRegistro.querySelector('[name="password"]');
124
- const password2 = formRegistro.querySelector('[name="password2"]');
125
-
126
- activarValidacionCampo(nombre, () => nombre.value.trim().length < 1);
127
- activarValidacionCampo(apellido, () => apellido.value.trim().length < 1);
128
- activarValidacionCampo(email, () => !esEmailValido(email.value));
129
- activarValidacionCampo(password, () => password.value.length < 6);
130
-
131
- // password2 depende del valor de password, se re-evalua en ambos campos para dar feedback inmediato
132
- let tocadoP2 = false;
133
- const validarP2 = () => password.value === password2.value && password2.value.length > 0;
134
- password2.addEventListener('blur', () => { tocadoP2 = true; marcarCampo(password2, validarP2()); });
135
- password2.addEventListener('input', () => { if (tocadoP2) marcarCampo(password2, validarP2()); });
136
- password.addEventListener('input', () => { if (tocadoP2) marcarCampo(password2, validarP2()); });
137
- }
138
-
139
- inicializarValidacionLogin();
140
- inicializarValidacionRegistro();
141
-
142
- // Manejo de formularios
143
-
144
- async function enviarFormAuth(accion, campos) {
145
- try {
146
- const resp = await fetch('api/auth.php', {
147
- method: 'POST',
148
- headers: { 'Content-Type': 'application/json' },
149
- body: JSON.stringify({ accion, ...campos }),
150
- });
151
- return await resp.json();
152
- } catch {
153
- return { error: 'Error de conexión. Verifica tu red.' };
154
- }
155
- }
156
-
157
- formLogin.addEventListener('submit', async e => {
158
- e.preventDefault();
159
- errorLogin.textContent = '';
160
- const btn = formLogin.querySelector('button[type="submit"]');
161
- btn.disabled = true;
162
- btn.textContent = 'Ingresando…';
163
-
164
- const datos = await enviarFormAuth('login', {
165
- email: formLogin.querySelector('[name="email"]').value,
166
- password: formLogin.querySelector('[name="password"]').value,
167
- });
168
-
169
- btn.disabled = false;
170
- btn.textContent = 'Ingresar';
171
-
172
- if (datos.error) { errorLogin.textContent = datos.error; return; }
173
-
174
- estadoAuth = true;
175
- actualizarBtnUsuario(datos.nombre);
176
- cerrarModal();
177
- // Ejecuta la accion que el usuario intento hacer antes de loguearse (ej. analisis IA)
178
- accionPendiente?.();
179
- accionPendiente = null;
180
- });
181
-
182
- formRegistro.addEventListener('submit', async e => {
183
- e.preventDefault();
184
- errorRegistro.textContent = '';
185
- const btn = formRegistro.querySelector('button[type="submit"]');
186
- btn.disabled = true;
187
- btn.textContent = 'Registrando…';
188
-
189
- const password = formRegistro.querySelector('[name="password"]').value;
190
- const password2 = formRegistro.querySelector('[name="password2"]').value;
191
-
192
- if (!formRegistro.querySelector('[name="aviso-legal"]').checked) {
193
- errorRegistro.textContent = 'Debes aceptar el aviso antes de crear una cuenta.';
194
- btn.disabled = false;
195
- btn.textContent = 'Crear cuenta';
196
- return;
197
- }
198
-
199
- if (password !== password2) {
200
- errorRegistro.textContent = 'Las contraseñas no coinciden.';
201
- btn.disabled = false;
202
- btn.textContent = 'Crear cuenta';
203
- return;
204
- }
205
-
206
- const datos = await enviarFormAuth('registro', {
207
- nombre: formRegistro.querySelector('[name="nombre"]').value,
208
- apellido: formRegistro.querySelector('[name="apellido"]').value,
209
- email: formRegistro.querySelector('[name="email"]').value,
210
- password,
211
- });
212
-
213
- btn.disabled = false;
214
- btn.textContent = 'Crear cuenta';
215
-
216
- if (datos.error) { errorRegistro.textContent = datos.error; return; }
217
-
218
- estadoAuth = true;
219
- actualizarBtnUsuario(datos.nombre);
220
- cerrarModal();
221
- // Ejecuta la accion que el usuario intento hacer antes de registrarse (ej. analisis IA)
222
- accionPendiente?.();
223
- accionPendiente = null;
224
- });
225
-
226
- // Eventos de UI
227
-
228
- tabLogin.addEventListener('click', () => activarTab('login'));
229
- tabRegistro.addEventListener('click', () => activarTab('registro'));
230
- btnCerrar.addEventListener('click', cerrarModal);
231
- overlay.addEventListener('click', cerrarModal);
232
- document.addEventListener('keydown', e => { if (e.key === 'Escape' && !modal.hidden) cerrarModal(); });
233
-
234
- btnUsuario?.addEventListener('click', async () => {
235
- const autenticado = await verificarAuth();
236
- if (!autenticado) {
237
- abrirModal();
238
- } else {
239
- await fetch('api/auth.php', {
240
- method: 'POST',
241
- headers: { 'Content-Type': 'application/json' },
242
- body: JSON.stringify({ accion: 'logout' }),
243
- });
244
- estadoAuth = false;
245
- resetearBtnUsuario();
246
- }
247
- });
248
-
249
- verificarAuth();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/ia.js DELETED
@@ -1,289 +0,0 @@
1
- import { imagenesDataUrl, capturasMicroscopio } from './ui.js';
2
-
3
- const BACKEND_KEY = 'mx-ia-backend';
4
- const OLLAMA_URL_KEY = 'mx-ia-ollama-url';
5
- const OLLAMA_MOD_KEY = 'mx-ia-ollama-model';
6
-
7
- export function inicializarConfigBackend() {
8
- const radioLocal = document.getElementById('ia-backend-local');
9
- const radioHF = document.getElementById('ia-backend-hf');
10
- const urlInput = document.getElementById('ia-ollama-url');
11
- const modelInput = document.getElementById('ia-ollama-model');
12
- const camposOllama = document.getElementById('ia-ollama-fields');
13
-
14
- const backendGuardado = localStorage.getItem(BACKEND_KEY) ?? 'hf';
15
- if (backendGuardado === 'local') radioLocal.checked = true;
16
- else radioHF.checked = true;
17
-
18
- const urlGuardada = localStorage.getItem(OLLAMA_URL_KEY);
19
- let modeloGuardado = localStorage.getItem(OLLAMA_MOD_KEY);
20
- if (modeloGuardado === 'medgemma:latest') {
21
- modeloGuardado = 'medgemma1.5:latest';
22
- localStorage.setItem(OLLAMA_MOD_KEY, modeloGuardado);
23
- }
24
- if (urlGuardada) urlInput.value = urlGuardada;
25
- if (modeloGuardado) modelInput.value = modeloGuardado;
26
-
27
- function aplicarBackend(val) {
28
- camposOllama.hidden = val !== 'local';
29
- }
30
- aplicarBackend(backendGuardado);
31
-
32
- [radioLocal, radioHF].forEach(r => r.addEventListener('change', () => {
33
- const val = document.querySelector('input[name="ia-backend"]:checked').value;
34
- localStorage.setItem(BACKEND_KEY, val);
35
- aplicarBackend(val);
36
- }));
37
-
38
- urlInput.addEventListener('input', () => localStorage.setItem(OLLAMA_URL_KEY, urlInput.value.trim()));
39
- modelInput.addEventListener('input', () => localStorage.setItem(OLLAMA_MOD_KEY, modelInput.value.trim()));
40
- }
41
-
42
- // Prompt
43
-
44
- function construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
45
- const paciente = obtenerDatosPaciente();
46
- const valores = obtenerValoresFormulario();
47
- const { hallazgos, patrones } = getUltimoAnalisis();
48
- const signosText = document.getElementById('signos-clinicos').value.trim();
49
- const refEspecie = paciente.especie ? (getReferencias()[paciente.especie] || {}) : {};
50
-
51
- const totalImagenes = imagenesDataUrl.filter(Boolean).length + capturasMicroscopio.length;
52
- const hayImagenes = totalImagenes > 0;
53
-
54
- const lineasValores = Object.entries(valores).map(([clave, valor]) => {
55
- const ref = refEspecie[clave];
56
- const nombre = ref?.nombre || clave;
57
- const unidad = ref?.unidad || '';
58
- const rango = ref ? ` [ref: ${ref.inferior}-${ref.superior}]` : '';
59
- const h = hallazgos.find(h => h.clave === clave);
60
- const flag = h ? ` ← ${h.direccion === 'alto' ? 'ELEVADO' : 'BAJO'} (${h.gravedad})` : '';
61
- return ` ${nombre}: ${valor} ${unidad}${rango}${flag}`;
62
- }).join('\n') || 'Sin valores ingresados';
63
-
64
- const lineasPatrones = patrones.length > 0
65
- ? patrones.map(p => ` - ${p.nombre}: ${p.descripcion}`).join('\n')
66
- : ' Ninguno detectado';
67
-
68
- const edadTexto = paciente.edadMeses != null
69
- ? (paciente.edadMeses < 24 ? `${Math.round(paciente.edadMeses)} meses` : `${(paciente.edadMeses / 12).toFixed(1)} años`)
70
- : 'desconocida';
71
-
72
- if (hayImagenes) {
73
- const lineasHallazgos = hallazgos.length > 0
74
- ? hallazgos.map(h => ` ${h.nombre}: ${h.valor} ${h.unidad || ''} (${h.direccion} · ${h.gravedad})`).join('\n')
75
- : ' Todos los valores normales';
76
-
77
- // Prompt enfocado en citologia cuando hay imagenes adjuntas
78
- return `Eres médico veterinario especialista en patología clínica.
79
-
80
- Paciente: ${paciente.especie || 'desconocido'}, ${paciente.raza || 'raza desconocida'}, ${edadTexto}, ${paciente.sexo || 'sexo desconocido'}
81
-
82
- Hallazgos de laboratorio:
83
- ${lineasHallazgos}
84
- ${signosText ? `\nSignos clínicos: ${signosText}` : ''}
85
-
86
- DATOS ADJUNTOS: ${totalImagenes} imagen${totalImagenes > 1 ? 'es' : ''} de citología.
87
-
88
- ¿Qué observas en las imágenes? Describe la morfología celular, identifica lesiones, patrones anormales, hemoparásitos (Anaplasma, Babesia, Ehrlichia, Hepatozoon, Piroplasma, Mycoplasma) e inclusiones citoplasmáticas. Luego integra con los datos de laboratorio. Responde en español.`;
89
- }
90
-
91
- const lineasHallazgos = hallazgos.length > 0
92
- ? hallazgos.map(h => ` ${h.nombre}: ${h.valor} ${h.unidad || ''} (${h.direccion} · ${h.gravedad})`).join('\n')
93
- : ' Todos los valores dentro de rangos normales';
94
-
95
- return `Responde en español.
96
-
97
- Eres médico veterinario especialista en patología clínica.
98
-
99
- Paciente: ${paciente.especie || 'desconocido'}, raza: ${paciente.raza || 'NE'}, edad: ${edadTexto}, sexo: ${paciente.sexo || 'NE'}
100
-
101
- Hallazgos de laboratorio:
102
- ${lineasHallazgos}
103
- ${signosText ? `\nSignos clínicos: ${signosText}` : ''}
104
-
105
- Proporciona una interpretación clínica breve (6-8 oraciones) destacando los hallazgos más significativos y las recomendaciones diagnósticas inmediatas.`;
106
- }
107
-
108
- function limpiarRespuesta(text) {
109
- // Elimina tokens especiales del modelo medGemma y otros artefactos de generacion
110
- if (text.includes('<start_of_turn>model')) {
111
- text = text.split('<start_of_turn>model').pop();
112
- }
113
- if (text.includes('<end_of_turn>')) {
114
- text = text.slice(0, text.indexOf('<end_of_turn>'));
115
- }
116
- if (text.includes('<unused95>')) {
117
- // Formato normal: <unused94>pensamiento<unused95>respuesta
118
- text = text.split('<unused95>').pop();
119
- } else if (text.includes('<unused94>')) {
120
- // El modelo agoto tokens en el razonamiento; muestra el pensamiento como respuesta
121
- text = text.split('<unused94>').slice(1).join('').trim();
122
- }
123
- text = text.replace(/<unused\d+>/g, '');
124
- text = text.replace(/<start_of_turn>\w+\n?/g, '');
125
-
126
- // Quitar prefijos de rol que el modelo a veces antepone
127
- text = text.replace(/^\d+\s+(medical assistant|assistant|model)\s*/i, '');
128
-
129
- // Quitar bloques de razonamiento / thinking process
130
- text = text.replace(/^thought\s*\n?/i, '');
131
-
132
- // Detectar si el modelo solo genero razonamiento en ingles sin respuesta clinica
133
- const tieneRazonamiento = /Here'?s a thinking process|Understand the Role|Analyze the Request|Review the Lab Results|Synthesize Findings|Formulate Clinical Interpretation/i.test(text);
134
- const tieneEspanol = /[áéíóúñÁÉÍÓÚÑ]{2,}/.test(text) || /\b(paciente|hallazgos|interpretación|recomendaciones|análisis|resultados|clínica|diagnóstico|evaluación|hepatopatía|nefropatía|anemia|leucocitosis|neutrofilia|linfopenia|hiperglucemia|hipoglucemia|pancreatitis|hepatitis|cirrosis|insuficiencia)\b/i.test(text);
135
- if (tieneRazonamiento && !tieneEspanol) {
136
- return 'El modelo generó un proceso de razonamiento interno en lugar de una interpretación clínica. Esto suele deberse a que el modelo está configurado en modo "pensamiento". Intenta nuevamente o contacta al administrador del espacio para desactivar el modo de razonamiento.';
137
- }
138
-
139
- // Si hay razonamiento mezclado con español, intentar extraer solo la respuesta
140
- if (tieneRazonamiento) {
141
- // Buscar la primera linea que parezca español clinico
142
- const lineas = text.split('\n');
143
- let inicioRespuesta = -1;
144
- for (let i = 0; i < lineas.length; i++) {
145
- const linea = lineas[i].trim();
146
- if (linea.length > 20 && /[áéíóúñÁÉÍÓÚÑ]/.test(linea) && !/\*\*[^*]+\*\*/.test(linea) && !/^\d+\./.test(linea) && !/Here'?s a thinking process/i.test(linea)) {
147
- inicioRespuesta = i;
148
- break;
149
- }
150
- }
151
- if (inicioRespuesta > 0) {
152
- text = lineas.slice(inicioRespuesta).join('\n');
153
- }
154
- }
155
-
156
- // Quitar LaTeX
157
- text = text.replace(/\$\\boxed\{[^}]*\}\$/g, '');
158
- text = text.replace(/\\begin\{[^}]+\}[\s\S]*?\\end\{[^}]+\}/g, '');
159
- text = text.replace(/\\[a-zA-Z]+(\{[^}]*\})?/g, '');
160
- text = text.replace(/\$[^$]*\$/g, '');
161
-
162
- // Colapsar lineas vacias multiples
163
- text = text.replace(/\n{3,}/g, '\n\n').trim();
164
-
165
- // Cortar al primer parrafo que se repite (loop del modelo)
166
- const parrafos = text.split(/\n\n+/);
167
- const vistos = new Set();
168
- const sinRepetidos = [];
169
- for (const p of parrafos) {
170
- const clave = p.trim().slice(0, 80);
171
- if (vistos.has(clave)) break;
172
- vistos.add(clave);
173
- sinRepetidos.push(p);
174
- }
175
- text = sinRepetidos.join('\n\n');
176
-
177
- return text.trim() || 'Sin respuesta del modelo.';
178
- }
179
-
180
- // Llamado a IA
181
-
182
- export async function llamarIA(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
183
- const salidaEl = document.getElementById('salida-ia');
184
- const backend = document.querySelector('input[name="ia-backend"]:checked')?.value ?? 'hf';
185
-
186
- salidaEl.textContent = 'Consultando al modelo de I.A…';
187
- salidaEl.classList.add('cargando');
188
-
189
- try {
190
- if (backend === 'local') {
191
- await _llamarOllama(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
192
- } else {
193
- await _llamarSpace(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
194
- }
195
- } finally {
196
- salidaEl.classList.remove('cargando');
197
- }
198
- }
199
-
200
- // Ollama
201
-
202
- async function _llamarOllama(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
203
- const urlBase = (document.getElementById('ia-ollama-url')?.value ?? 'http://localhost:11434').replace(/\/$/, '');
204
- const model = document.getElementById('ia-ollama-model')?.value?.trim() || 'medgemma1.5:latest';
205
- const prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
206
- const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio];
207
-
208
- // Construye el payload compatible con OpenAI vision: imagenes primero, luego el texto
209
- const contenido = [];
210
- for (const img of imagenes) {
211
- if (typeof img === 'string' && img.startsWith('data:image/'))
212
- contenido.push({ type: 'image_url', image_url: { url: img } });
213
- }
214
- contenido.push({ type: 'text', text: prompt });
215
-
216
- try {
217
- const res = await fetch(`${urlBase}/v1/chat/completions`, {
218
- method: 'POST',
219
- headers: { 'Content-Type': 'application/json' },
220
- body: JSON.stringify({
221
- model,
222
- messages: [{ role: 'user', content: contenido.length === 1 ? prompt : contenido }],
223
- max_tokens: imagenes.length > 0 ? 1200 : 600,
224
- stream: false,
225
- think: false,
226
- }),
227
- });
228
-
229
- let data;
230
- try { data = await res.json(); } catch {
231
- salidaEl.textContent = `Error del servidor Ollama (HTTP ${res.status}). Verifica que esté ejecutándose.`;
232
- return;
233
- }
234
-
235
- if (!res.ok) {
236
- salidaEl.textContent = `Error Ollama: ${data?.error?.message ?? data?.error ?? `HTTP ${res.status}`}`;
237
- } else {
238
- salidaEl.textContent = limpiarRespuesta(data?.choices?.[0]?.message?.content ?? 'Sin respuesta del modelo.');
239
- }
240
- } catch {
241
- salidaEl.textContent = `No se pudo conectar con Ollama en ${urlBase}. Verifica que esté ejecutándose con "ollama serve".`;
242
- }
243
- }
244
-
245
- // Morphos AI Space
246
-
247
- async function _llamarSpace(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
248
- let prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
249
-
250
- // El modelo medGemma espera el token <unused95> al inicio del prompt para modo respuesta directa
251
- if (!prompt.includes('<unused95>')) {
252
- prompt = '<unused95>' + prompt;
253
- }
254
-
255
- // Filtra y limita a 4 imagenes por restriccion del backend de HuggingFace
256
- const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio]
257
- .filter(img => typeof img === 'string' && /^data:image\/(jpeg|png|gif|webp);base64,/.test(img))
258
- .slice(0, 4);
259
-
260
- console.log('=== MORPHOS AI REQUEST ===');
261
- console.log('Images count:', imagenes.length);
262
- console.log('Prompt length:', prompt.length);
263
- console.log('Prompt preview:', prompt.substring(0, 200) + '...');
264
- console.log('==========================');
265
-
266
- try {
267
- const res = await fetch('api/hf_proxy.php', {
268
- method: 'POST',
269
- headers: { 'Content-Type': 'application/json' },
270
- body: JSON.stringify({ images: imagenes, prompt }),
271
- });
272
-
273
- const data = await res.json();
274
-
275
- console.log('=== MORPHOS AI RESPONSE ===');
276
- console.log('Status:', res.status);
277
- console.log('Raw text preview:', (data.text ?? 'NO TEXT').substring(0, 300));
278
- console.log('===========================');
279
-
280
- if (!res.ok) {
281
- salidaEl.textContent = `Error: ${data?.error ?? `HTTP ${res.status}`}`;
282
- } else {
283
- salidaEl.textContent = limpiarRespuesta(data.text ?? 'Sin respuesta del modelo.');
284
- }
285
- } catch (e) {
286
- console.error('Network error:', e);
287
- salidaEl.textContent = `Error de red: ${e.message}`;
288
- }
289
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/main.js DELETED
@@ -1,205 +0,0 @@
1
- import './tooltip.js';
2
- import { analizarResultados } from './analisis.js';
3
- import { colapsarPatrones, inicializarSincMob } from './ui.js';
4
- import { llamarIA, inicializarConfigBackend } from './ia.js';
5
- import { inicializarParserPdf } from './pdf-parser.js';
6
- import { verificarAuth, abrirModalAuth } from './auth.js';
7
- import { abrirModalPapers, inicializarModalPapers } from './papers.js';
8
-
9
- // Tema oscuro/claro
10
-
11
- const temaGuardado = localStorage.getItem('mx-theme');
12
- const temaPreferido = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
13
- document.documentElement.dataset.theme = temaGuardado || temaPreferido;
14
-
15
- const btnTema = document.getElementById('btn-tema');
16
- if (btnTema) {
17
- btnTema.addEventListener('click', () => {
18
- const siguienteTema = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
19
- document.documentElement.dataset.theme = siguienteTema;
20
- localStorage.setItem('mx-theme', siguienteTema);
21
- });
22
- }
23
-
24
- // Data
25
-
26
- let referencias = [];
27
- let alteraciones = {};
28
- let ultimoAnalisis = { hallazgos: [], patrones: [] };
29
-
30
- const cargarReferencias = async () => {
31
- try {
32
- const response = await fetch('data/valores_referencia.json');
33
- if (!response.ok) throw new Error(`Error HTTP: ${response.status}`);
34
- referencias = await response.json();
35
- } catch (error) {
36
- console.error('Error cargando valores de referencia:', error);
37
- }
38
- };
39
-
40
- const cargarAlteraciones = async () => {
41
- try {
42
- const response = await fetch('data/alteraciones.json');
43
- if (!response.ok) throw new Error(`Error HTTP: ${response.status}`);
44
- alteraciones = await response.json();
45
- } catch (error) {
46
- console.error('Error cargando alteraciones:', error);
47
- }
48
- };
49
-
50
- cargarReferencias();
51
- cargarAlteraciones();
52
-
53
- // Colección de datos de formulario
54
-
55
- const obtenerDatosPaciente = () => {
56
- const especieCruda = document.getElementById('pt-especie').value;
57
- const valorEdad = document.getElementById('pt-edad').value;
58
- const edadUnidad = document.getElementById('pt-edad-unidad').value;
59
- // Normaliza la edad siempre a meses para que analisis.js pueda aplicar ajustes por cachorro/adulto/senior
60
- const edadMeses = valorEdad === '' ? null
61
- : edadUnidad === 'meses' ? parseFloat(valorEdad)
62
- : parseFloat(valorEdad) * 12;
63
- return {
64
- especie: especieCruda === 'Canino' ? 'canino' : especieCruda === 'Felino' ? 'felino' : null,
65
- raza: document.getElementById('pt-raza').value,
66
- edadMeses,
67
- sexo: document.getElementById('pt-sexo').value
68
- };
69
- };
70
-
71
- const obtenerValoresFormulario = () => {
72
- const valores = {};
73
- document.querySelectorAll('input[type="number"]').forEach(input => {
74
- if (input.name && input.value !== '') valores[input.name] = parseFloat(input.value);
75
- });
76
- return valores;
77
- };
78
-
79
- // Renderizado
80
-
81
- const ETIQUETA_GRAVEDAD = { leve: 'Leve', moderado: 'Moderado', grave: 'Grave' };
82
-
83
- document.querySelectorAll('.fila-campo input[type="number"]').forEach(input => {
84
- const span = document.createElement('span');
85
- span.className = 'estado-campo';
86
- input.before(span);
87
- });
88
-
89
- const actualizarClasesInputs = (hallazgos) => {
90
- document.querySelectorAll('input[type="number"]').forEach(input => {
91
- input.classList.remove('alto', 'bajo');
92
- const span = input.previousElementSibling;
93
- if (span?.classList.contains('estado-campo')) {
94
- span.textContent = '';
95
- span.className = 'estado-campo';
96
- }
97
- });
98
- hallazgos.forEach(h => {
99
- const input = document.querySelector(`input[name="${h.clave}"]`);
100
- if (!input) return;
101
- input.classList.add(h.direccion);
102
- const span = input.previousElementSibling;
103
- if (span?.classList.contains('estado-campo')) {
104
- span.textContent = `${h.direccion === 'alto' ? 'Alto' : 'Bajo'} · ${ETIQUETA_GRAVEDAD[h.gravedad]}`;
105
- span.className = `estado-campo estado-campo--${h.direccion}`;
106
- }
107
- });
108
- };
109
-
110
- const renderizarPatrones = (patrones) => {
111
- const contenedor = document.getElementById('patrones-lista');
112
- if (!contenedor) return;
113
-
114
- contenedor.innerHTML = patrones.length === 0
115
- ? '<p class="sin-hallazgos">Sin patrones detectados.</p>'
116
- : patrones.map(p => `
117
- <div class="elemento-patron gravedad-${p.gravedad}">
118
- <div class="titulo-patron">${p.nombre}</div>
119
- <div class="cuerpo-patron">${p.descripcion}</div>
120
- </div>`).join('');
121
- };
122
-
123
- // Evaluación
124
-
125
- const evaluar = () => {
126
- const paciente = obtenerDatosPaciente();
127
-
128
- // Si no hay especie o aun no cargaron las referencias, limpia la UI para evitar falsos positivos
129
- if (!paciente.especie || !referencias[paciente.especie]) {
130
- actualizarClasesInputs([]);
131
- renderizarPatrones([]);
132
- return;
133
- }
134
-
135
- const valores = obtenerValoresFormulario();
136
- // Delega en analisis.js la comparacion contra rangos de referencia y la deteccion de patrones clinicos
137
- const { hallazgos, patrones } = analizarResultados(valores, paciente, referencias, alteraciones);
138
- ultimoAnalisis = { hallazgos, patrones };
139
-
140
- actualizarClasesInputs(hallazgos);
141
- renderizarPatrones(patrones);
142
- };
143
-
144
- // Eventos
145
-
146
- document.addEventListener('input', e => {
147
- if (e.target.type !== 'number') return;
148
- // Impide valores por debajo del mínimo del campo; permite negativos cuando min lo indica
149
- const minPermitido = e.target.min !== '' ? parseFloat(e.target.min) : 0;
150
- if (parseFloat(e.target.value) < minPermitido) e.target.value = minPermitido;
151
- if (e.target.value.replace('.', '').length > 4) e.target.value = e.target.value.slice(0, 4);
152
- e.target.classList.toggle('max-chars', e.target.value.replace('.', '').length >= 4);
153
- evaluar();
154
- });
155
-
156
- document.getElementById('pt-especie').addEventListener('change', evaluar);
157
- document.getElementById('pt-raza').addEventListener('input', evaluar);
158
- document.getElementById('pt-edad').addEventListener('input', evaluar);
159
- document.getElementById('pt-edad-unidad').addEventListener('change', evaluar);
160
- document.getElementById('pt-sexo').addEventListener('change', evaluar);
161
-
162
- inicializarSincMob(evaluar);
163
- inicializarConfigBackend();
164
- inicializarParserPdf(evaluar);
165
- inicializarModalPapers();
166
-
167
- document.addEventListener('click', e => {
168
- const btn = e.target.closest('.btn-limpiar-panel');
169
- if (!btn) return;
170
- const panel = document.getElementById(`panel-${btn.dataset.panel}`);
171
- if (!panel) return;
172
- // Recorre todos los campos editables del panel y los resetea, incluyendo indicadores visuales de estado
173
- panel.querySelectorAll('input[type="number"], input[type="text"], input[type="url"], select').forEach(el => {
174
- if (el.tagName === 'SELECT') {
175
- el.selectedIndex = 0;
176
- } else {
177
- el.value = '';
178
- el.classList.remove('alto', 'bajo', 'max-chars');
179
- const span = el.previousElementSibling;
180
- if (span?.classList.contains('estado-campo')) {
181
- span.textContent = '';
182
- span.className = 'estado-campo';
183
- }
184
- }
185
- });
186
- evaluar();
187
- });
188
-
189
- document.querySelector('.boton-analizar').addEventListener('click', async () => {
190
- // Si el usuario no esta logueado, abre el modal de auth y encola la llamada a IA como callback
191
- const autenticado = await verificarAuth();
192
- if (!autenticado) {
193
- abrirModalAuth(() => {
194
- colapsarPatrones(true);
195
- llamarIA(obtenerDatosPaciente, obtenerValoresFormulario, () => ultimoAnalisis, () => referencias);
196
- });
197
- return;
198
- }
199
- colapsarPatrones(true);
200
- llamarIA(obtenerDatosPaciente, obtenerValoresFormulario, () => ultimoAnalisis, () => referencias);
201
- });
202
-
203
- document.querySelector('.boton-papers').addEventListener('click', () => {
204
- abrirModalPapers(ultimoAnalisis.patrones);
205
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/papers.js DELETED
@@ -1,286 +0,0 @@
1
- const PROXY_URL = 'api/papers_proxy.php';
2
- const POR_PAGINA = 10;
3
-
4
- let todosLosPapers = [];
5
- let paginaActual = 0;
6
- let consultaActual = '';
7
-
8
-
9
- //Esto nos permite hacer búsquedas en la API de PubMed, sólo recibe texto en inglés
10
-
11
- const TERMINOS_EN = {
12
- 'Anemia': 'anemia',
13
- 'Eritrocitosis': 'erythrocytosis polycythemia',
14
- 'Leucocitosis': 'leukocytosis',
15
- 'Leucocitosis neutrofílica': 'neutrophilic leukocytosis',
16
- 'Leucocitosis linfocítica': 'lymphocytic leukocytosis',
17
- 'Leucopenia': 'leukopenia',
18
- 'Eosinofilia': 'eosinophilia',
19
- 'Neutropenia': 'neutropenia',
20
- 'Linfopenia': 'lymphopenia',
21
- 'Trombocitopenia': 'thrombocytopenia',
22
- 'Trombocitosis': 'thrombocytosis',
23
- 'Daño hepatocelular': 'hepatocellular damage liver injury',
24
- 'Elevación de ALT aislada': 'ALT elevation liver',
25
- 'Patrón colestásico': 'cholestasis',
26
- 'Hiperbilirrubinemia': 'hyperbilirubinemia jaundice',
27
- 'Azotemia': 'azotemia renal failure',
28
- 'Hiperuremia aislada (BUN)': 'elevated BUN prerenal azotemia',
29
- 'BUN disminuido': 'low BUN hepatic failure',
30
- 'Creatinina elevada (BUN normal)': 'elevated creatinine kidney',
31
- 'Hiperglucemia': 'hyperglycemia diabetes mellitus',
32
- 'Hipoglucemia': 'hypoglycemia',
33
- 'Hiperproteinemia': 'hyperproteinemia',
34
- 'Hipoproteinemia / Hipoalbuminemia': 'hypoproteinemia hypoalbuminemia',
35
- 'Hipoalbuminemia': 'hypoalbuminemia',
36
- 'Hipercalcemia': 'hypercalcemia',
37
- 'Hipocalcemia': 'hypocalcemia',
38
- 'Hipernatremia': 'hypernatremia',
39
- 'Hiponatremia': 'hyponatremia',
40
- 'Hiperpotasemia': 'hyperkalemia',
41
- 'Hipopotasemia': 'hypokalemia',
42
- 'Hiperfosforemia': 'hyperphosphatemia',
43
- 'Hipotiroidismo': 'hypothyroidism',
44
- 'Hipertiroidismo': 'hyperthyroidism',
45
- 'Hiperadrenocorticismo (Cushing)': 'hyperadrenocorticism Cushing',
46
- 'Hipoadrenocorticismo (Addison)': 'hypoadrenocorticism Addison',
47
- 'Ratio Na:K reducido — sospecha de hipoadrenocorticismo': 'hypoadrenocorticism sodium potassium ratio',
48
- 'Cortisol basal bajo — posible hipoadrenocorticismo': 'low basal cortisol hypoadrenocorticism',
49
- 'Hiposthenuria': 'hyposthenuria urine specific gravity',
50
- 'Isosthenuria': 'isosthenuria urine concentration renal',
51
- 'Posible déficit de insulina': 'insulin deficiency hyperglycemia diabetes mellitus',
52
- };
53
-
54
- const traducirPatron = (nombre) => {
55
- for (const [es, en] of Object.entries(TERMINOS_EN)) {
56
- if (nombre.startsWith(es)) return en;
57
- }
58
- return nombre.replace(/[áéíóúñ]/g, c => ({á:'a',é:'e',í:'i',ó:'o',ú:'u',ñ:'n'})[c] || c);
59
- };
60
-
61
- const construirQuery = (patrones) => {
62
- if (!patrones || patrones.length === 0) return 'veterinary clinical laboratory diagnosis canine feline';
63
- // Limita a 3 terminos para mantener la query enfocada y evitar resultados irrelevantes
64
- const terminos = [...new Set(patrones.map(p => traducirPatron(p.nombre)))].slice(0, 3);
65
- return `${terminos.join(' ')} canine OR feline veterinary`;
66
- };
67
-
68
- const buscarPapers = async (query) => {
69
- const respuesta = await fetch(`${PROXY_URL}?query=${encodeURIComponent(query)}`);
70
- if (!respuesta.ok) {
71
- const detalle = await respuesta.json().catch(() => ({}));
72
- throw new Error(detalle.error || `Error ${respuesta.status}`);
73
- }
74
- const datos = await respuesta.json();
75
- return datos.data || [];
76
- };
77
-
78
- const renderizarTarjetaPaper = (paper) => {
79
- const titulo = paper.title || 'Sin título';
80
- const autores = paper.authors?.slice(0, 3).map(a => a.name).join(', ') || 'Autores desconocidos';
81
- const masAutores = (paper.authors?.length || 0) > 3 ? ' et al.' : '';
82
- const anio = paper.year || '—';
83
- const revista = paper.journal || '';
84
-
85
- const urlDoi = paper.doi ? `https://doi.org/${paper.doi}` : null;
86
- const urlPubmed = paper.pmid ? `https://pubmed.ncbi.nlm.nih.gov/${paper.pmid}/` : null;
87
- const urlPrincipal = urlDoi || urlPubmed;
88
-
89
- const articulo = document.createElement('article');
90
- articulo.className = 'paper-tarjeta';
91
-
92
- const meta = document.createElement('div');
93
- meta.className = 'paper-meta';
94
-
95
- const spanAnio = document.createElement('span');
96
- spanAnio.className = 'paper-anio';
97
- spanAnio.textContent = anio;
98
- meta.append(spanAnio);
99
-
100
- if (revista) {
101
- const spanRevista = document.createElement('span');
102
- spanRevista.className = 'paper-revista';
103
- spanRevista.textContent = revista;
104
- meta.append(spanRevista);
105
- }
106
-
107
- articulo.append(meta);
108
-
109
- const h3 = document.createElement('h3');
110
- h3.className = 'paper-titulo';
111
-
112
- if (urlPrincipal) {
113
- const link = document.createElement('a');
114
- link.href = urlPrincipal;
115
- link.target = '_blank';
116
- link.rel = 'noopener noreferrer';
117
- link.textContent = titulo;
118
- h3.append(link);
119
- } else {
120
- h3.textContent = titulo;
121
- }
122
-
123
- articulo.append(h3);
124
-
125
- const pAutores = document.createElement('p');
126
- pAutores.className = 'paper-autores';
127
- pAutores.textContent = autores + masAutores;
128
- articulo.append(pAutores);
129
-
130
- return articulo;
131
- };
132
-
133
- const renderizarPaginacion = () => {
134
- const totalPaginas = Math.ceil(todosLosPapers.length / POR_PAGINA);
135
- const contenedor = document.getElementById('papers-paginacion');
136
- if (!contenedor) return;
137
-
138
- if (totalPaginas <= 1) {
139
- contenedor.innerHTML = '';
140
- return;
141
- }
142
-
143
- // Ventana deslizante de maximo 5 botones centrada en la pagina actual
144
- const inicio = Math.max(0, paginaActual - 2);
145
- const fin = Math.min(totalPaginas, inicio + 5);
146
-
147
- let html = `<button class="papers-pag-btn" data-pagina="${paginaActual - 1}" ${paginaActual === 0 ? 'disabled' : ''} aria-label="Página anterior"><img src="assets/icons/anterior.svg" alt="" aria-hidden="true" width="16" height="16"></button>`;
148
- for (let i = inicio; i < fin; i++) {
149
- html += `<button class="papers-pag-btn ${i === paginaActual ? 'activo' : ''}" data-pagina="${i}">${i + 1}</button>`;
150
- }
151
- html += `<button class="papers-pag-btn" data-pagina="${paginaActual + 1}" ${paginaActual >= totalPaginas - 1 ? 'disabled' : ''} aria-label="Página siguiente"><img src="assets/icons/siguiente.svg" alt="" aria-hidden="true" width="16" height="16"></button>`;
152
-
153
- contenedor.innerHTML = html;
154
- };
155
-
156
- const renderizarPaginaActual = () => {
157
- const lista = document.getElementById('papers-lista');
158
- if (!lista) return;
159
-
160
- const inicio = paginaActual * POR_PAGINA;
161
- const pagina = todosLosPapers.slice(inicio, inicio + POR_PAGINA);
162
-
163
- lista.replaceChildren();
164
-
165
- if (pagina.length === 0) {
166
- const vacio = document.createElement('p');
167
- vacio.className = 'papers-vacio';
168
- vacio.textContent = 'No se encontraron artículos para esta búsqueda.';
169
- lista.append(vacio);
170
- } else {
171
- lista.append(...pagina.map(renderizarTarjetaPaper));
172
- }
173
-
174
- renderizarPaginacion();
175
- lista.scrollTop = 0;
176
- };
177
-
178
- const irAPagina = (numeroPagina) => {
179
- const totalPaginas = Math.ceil(todosLosPapers.length / POR_PAGINA);
180
- if (numeroPagina < 0 || numeroPagina >= totalPaginas) return;
181
- paginaActual = numeroPagina;
182
- renderizarPaginaActual();
183
- };
184
-
185
- const mostrarEstadoCarga = () => {
186
- const lista = document.getElementById('papers-lista');
187
- if (lista) lista.innerHTML = '<p class="papers-cargando">Buscando artículos científicos…</p>';
188
- const paginacion = document.getElementById('papers-paginacion');
189
- if (paginacion) paginacion.innerHTML = '';
190
- };
191
-
192
- const mostrarError = (mensaje) => {
193
- const lista = document.getElementById('papers-lista');
194
- if (!lista) return;
195
- const p = document.createElement('p');
196
- p.className = 'papers-error';
197
- p.textContent = mensaje;
198
- lista.replaceChildren(p);
199
- };
200
-
201
- export const abrirModalPapers = async (patrones) => {
202
- const modal = document.getElementById('modal-papers');
203
- const overlay = document.getElementById('modal-papers-overlay');
204
- if (!modal || !overlay) return;
205
-
206
- const nuevaConsulta = construirQuery(patrones);
207
-
208
- modal.removeAttribute('hidden');
209
- overlay.classList.add('activo');
210
- document.body.style.overflow = 'hidden';
211
- requestAnimationFrame(() => modal.classList.add('visible'));
212
-
213
- const etiquetaConsulta = document.getElementById('papers-consulta');
214
- if (etiquetaConsulta) etiquetaConsulta.textContent = `"${nuevaConsulta}"`;
215
-
216
- // Reutiliza resultados si la consulta no cambio desde la ultima vez
217
- if (nuevaConsulta === consultaActual && todosLosPapers.length > 0) {
218
- renderizarPaginaActual();
219
- return;
220
- }
221
-
222
- consultaActual = nuevaConsulta;
223
- todosLosPapers = [];
224
- paginaActual = 0;
225
- mostrarEstadoCarga();
226
-
227
- try {
228
- todosLosPapers = await buscarPapers(nuevaConsulta);
229
- renderizarPaginaActual();
230
- } catch (error) {
231
- mostrarError(error.message || 'No se pudo conectar con PubMed. Intenta de nuevo más tarde.');
232
- console.error('Error buscando papers:', error);
233
- }
234
- };
235
-
236
- const cerrarModalPapers = () => {
237
- const modal = document.getElementById('modal-papers');
238
- const overlay = document.getElementById('modal-papers-overlay');
239
- if (!modal || !overlay) return;
240
-
241
- modal.classList.remove('visible');
242
- overlay.classList.remove('activo');
243
- document.body.style.overflow = '';
244
- setTimeout(() => modal.setAttribute('hidden', ''), 250);
245
- };
246
-
247
- export const inicializarModalPapers = () => {
248
- document.getElementById('modal-papers-cerrar')?.addEventListener('click', cerrarModalPapers);
249
- document.getElementById('modal-papers-overlay')?.addEventListener('click', cerrarModalPapers);
250
-
251
- document.addEventListener('keydown', (e) => {
252
- if (e.key === 'Escape') {
253
- const modal = document.getElementById('modal-papers');
254
- if (modal && !modal.hidden) cerrarModalPapers();
255
- }
256
- });
257
-
258
- document.getElementById('papers-paginacion')?.addEventListener('click', (e) => {
259
- const btn = e.target.closest('.papers-pag-btn');
260
- if (!btn || btn.disabled) return;
261
- irAPagina(parseInt(btn.dataset.pagina, 10));
262
- });
263
-
264
- document.getElementById('papers-busqueda-form')?.addEventListener('submit', async (e) => {
265
- e.preventDefault();
266
- const input = document.getElementById('papers-busqueda-input');
267
- const termino = input?.value.trim();
268
- if (!termino) return;
269
-
270
- const etiquetaConsulta = document.getElementById('papers-consulta');
271
- if (etiquetaConsulta) etiquetaConsulta.textContent = `"${termino}"`;
272
-
273
- consultaActual = termino;
274
- todosLosPapers = [];
275
- paginaActual = 0;
276
- mostrarEstadoCarga();
277
-
278
- try {
279
- todosLosPapers = await buscarPapers(termino);
280
- renderizarPaginaActual();
281
- } catch (error) {
282
- mostrarError(error.message || 'No se pudo conectar con PubMed. Intenta de nuevo más tarde.');
283
- console.error('Error buscando papers:', error);
284
- }
285
- });
286
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/pdf-parser.js DELETED
@@ -1,477 +0,0 @@
1
- // Extracción de texto de PDF y parseo de valores de laboratorio en el cliente (ningún dato sale del navegador)
2
-
3
- const PDFJS_WORKER = 'assets/lib/pdfjs/pdf.worker.min.js';
4
-
5
- // Cada entrada: nombre de campo (coincide con input[name="…"]) → regex + claveConv opcional
6
- // claveConv → sobreescribe la clave de conversión (permite reglas distintas para el mismo campo)
7
- const DEFS_ANALITOS = [
8
- // Hematología: Serie Roja
9
- { campo: 'rbc', re: /\b(?:eritrocit\w*|gl[oó]bulos?\s+rojos?|r\.?b\.?c\.?|eri)\b/i },
10
- { campo: 'hgb', re: /\b(?:hemoglobin[ao]?\w*|hgb|hb)\b(?!a\d)/i },
11
- { campo: 'hct', re: /\b(?:hematocrit[oo]?\w*|hct|pcv)\b/i },
12
- { campo: 'vcm', re: /\b(?:v\.?c\.?m\.?|m\.?c\.?v\.?|vol(?:umen)?\s+corp\w*)\b/i },
13
- // CHCM debe ir antes que HCM para evitar que MCH coincida con MCHC al usar lookahead negativo
14
- { campo: 'chcm', re: /\b(?:c\.?h\.?c\.?m\.?|m\.?c\.?h\.?c\.?|concentr\w+\s+hem\w+\s+corp\w*)\b/i },
15
- { campo: 'hcm', re: /\b(?:h\.?c\.?m\.?|m\.?c\.?h\.?)(?![cC]\.?)\b/i },
16
- { campo: 'rdw', re: /\b(?:r\.?d\.?w\.?(?:-cv)?|anch\w+\s+distrib\w+)\b/i },
17
- { campo: 'reti', re: /\b(?:reti\w*\s*%|ret\.?\s*%|ret[eé])\b/i },
18
- { campo: 'reti_abs', re: /\b(?:reti\w*\s*#|ret\.?\s*#)\b/i },
19
- { campo: 'nrbc', re: /\b(?:n\.?r\.?b\.?c\.?|eritrocit\w+\s+nucle\w+|nucleat\w+\s+r\.?b\.?c\.?|nrbc)\b/i },
20
-
21
- // Hematología: Serie Blanca
22
- { campo: 'wbc', re: /\b(?:leucocit\w*|w\.?b\.?c\.?|white\s+blood\s+cell|leu)\b/i },
23
- { campo: 'neutro_abs', re: /\bgran?#/i },
24
- // gran?(?!#) y similares: evitan coincidir con abreviaturas "GRA#"/"LYM#"/"EOS#"
25
- // cuando se busca la forma porcentual — éstas aparecen antes en informes
26
- // que listan conteos absolutos en una página y porcentajes en otra
27
- { campo: 'neutro', re: /\b(?:neutr[oó]fil\w*|neut\b|neu\b|gran?(?!#))\b/i },
28
- { campo: 'linfo_abs', re: /\blymp?#/i },
29
- { campo: 'linfo', re: /\b(?:linf[oa]cit\w*|lymph\w*|linf\b|lym(?!#))\b/i },
30
- { campo: 'mono_abs', re: /\bmon\w*#/i },
31
- { campo: 'mono', re: /\b(?:monocit\w*|mono\b|mon(?!#))\b/i },
32
- { campo: 'eosino_abs', re: /\beos\w*#/i },
33
- { campo: 'eosino', re: /\b(?:eosino\w*|eos(?!#))\b/i },
34
- { campo: 'baso_abs', re: /\bbas\w*#/i },
35
- { campo: 'baso', re: /\b(?:bas[oó]fil\w*|bas(?!#))\b/i },
36
-
37
- // Hematología: Plaquetas
38
- { campo: 'plt', re: /\b(?:plaqueta\w*|platelet\w*|plt\b|trc\b)\b/i },
39
- { campo: 'mpv', re: /\b(?:m\.?p\.?v\.?|vol(?:umen)?\s+plaquetario\s+medio)\b/i },
40
- { campo: 'pct', re: /\b(?:p\.?c\.?t\.?\b|plaquetocrit\w*)\b/i },
41
-
42
- // Bioquímica: Enzimas Hepáticas
43
- { campo: 'alt', re: /\b(?:alt\b|gpt\b|alanin[ao]?\s+amino\w*)\b/i },
44
- { campo: 'ast', re: /\b(?:ast\b|got\b|aspart\w*)\b/i },
45
- { campo: 'fal', re: /\b(?:fal\b|alp\b|fosfatasa\s+alcalin\w*|alkaline\s+phosph\w*)\b/i },
46
- { campo: 'ggt', re: /\b(?:g\.?g\.?t\.?|gamma\s*glutamil\w*|gama\s*glutamil\w*)\b/i },
47
-
48
- // Bioquímica: Función Hepática
49
- { campo: 'bili', re: /\b(?:bilirrub\w*\s+total|total\s+bilirubin\w*|tbil\b)\b/i },
50
- { campo: 'bili', re: /\b(?:bilirrub\w*|bilirubin\w*|bili\b)\b/i }, // alternativa si no hubo coincidencia previa
51
- { campo: 'bili_dir', re: /\b(?:bilirrub\w*\s+direct\w*|direct\w*\s+bilirubin\w*|bili\s*dir\b)\b/i },
52
- { campo: 'acidos_bil', re: /\b(?:[aá]cid\w*\s+biliares?|bile\s+acids?|ácidos?\s+bil\w*)\b/i },
53
-
54
- // Bioquímica: Función Renal
55
- { campo: 'bun', claveConv: 'bun', re: /\b(?:bun\b|nitr[oó]geno\s+ureico)\b/i },
56
- { campo: 'bun', claveConv: 'urea', re: /\burea\b/i },
57
- { campo: 'creat', re: /\b(?:creatinin[ao]?\w*|crea\b)\b/i },
58
- { campo: 'sdma', re: /\b(?:sdma\b|dimetilargin\w*|symmetric\s+dime\w*)\b/i },
59
-
60
- // Bioquímica: Metabolitos
61
- { campo: 'gluc', re: /\b(?:gluco(?:sa|se)\b|glucemia\b|glu\b)\b/i },
62
- { campo: 'prot', re: /\b(?:prote[íi]nas?\s+totales?|prot\s+total|tp)\b/i },
63
- { campo: 'alb', re: /\b(?:alb[úu]min[ao]?\w*|alb\b)\b/i },
64
- { campo: 'glob', re: /\b(?:globulin\w*|glob\b)\b/i },
65
- { campo: 'fosf', re: /\b(?:f[oó]sforo\b|phosph\w*|phos\b)\b/i },
66
- { campo: 'calc', re: /\b(?:calcio\b|calcium\b|ca\b)\b/i },
67
- { campo: 'fruc', re: /\b(?:fructosamina\b|fructosamine\b|fruc\b)\b/i },
68
-
69
- // Bioquímica: Electrolitos
70
- { campo: 'sodio', re: /\b(?:sodio\b|sodium\b)\b/i },
71
- { campo: 'potasio', re: /\b(?:potasio\b|potassium\b)\b/i },
72
- { campo: 'cloro', re: /\b(?:clor[ou]\w*|chloride\w*)\b/i },
73
- { campo: 'tco2', re: /\b(?:tco2\b|t\.?co\.?2\b|bicarbonat\w*|co2\s+total)\b/i },
74
-
75
- // Bioquímica: Lípidos
76
- { campo: 'colest', re: /\b(?:colesterol\b|cholesterol\b|chol\b)\b/i },
77
- { campo: 'trigli', re: /\b(?:triglicérid\w*|triglic[eé]rid\w*|trig\b)\b/i },
78
-
79
- // Bioquímica: Enzimas
80
- { campo: 'lipasa', re: /\b(?:lipas[ae]\b|lipa\b)\b/i },
81
- { campo: 'ck', re: /\b(?:c\.?k\.?\b|creatina?\s+kinas[ae]|creatine\s+kinas[ae])\b/i },
82
-
83
- // Perfil Endocrino
84
- { campo: 'cortisol_bas', re: /\b(?:cortisol\s+bas[ae]?l?)\b/i },
85
- { campo: 'cortisol_acth', re: /\b(?:cortisol\s+(?:post[-\s]?acth|post)\b)/i },
86
- { campo: 't4_total', re: /\b(?:t4\s+total|t4\s+libre|tiroxin\w*|thyroxin\w*)\b/i },
87
- { campo: 'insulina', re: /\b(?:insulin[ao]?\w*)\b/i },
88
-
89
- // Urianálisis
90
- { campo: 'usg', re: /\b(?:usg\b|densidad\s+(?:urin|orin)\w*|gravedad\s+esp\w*)\b/i },
91
- { campo: 'ph', re: /\b(?:ph\s+(?:urin|orin)\w*|ph\s+orina)\b/i },
92
- ];
93
-
94
- // Campos select semicuantitativos
95
- const DEFS_SEMICUANTITATIVOS = [
96
- { campo: 'uri-prot', re: /\b(?:prote[íi]nas?\s*(?:en\s*orina?|urin\w*)?|proteinuria)\b/i },
97
- { campo: 'uri-gluc', re: /\b(?:glucosuria\b|glucosa\s+(?:en\s*)?orina\w*)\b/i },
98
- ];
99
-
100
- // Reglas de conversión de unidades por campo (o claveConv).
101
- // Cada regla: { re → se evalúa contra los ~50 caracteres tras el valor, factor → número o fn(v)=>v }
102
- // Gana la primera coincidencia. Sin coincidencia = valor usado tal cual (se asume unidad nativa de la app).
103
- //
104
- // Unidades de referencia de la app:
105
- // rbc x10⁶/μL | hgb g/dL | hct % | vcm fL | chcm g/dL | hcm pg | rdw %
106
- // wbc x10³/μL | plt x10³/μL | mpv fL | pct %
107
- // reti % | reti_abs x10³/μL | nrbc /100WBC
108
- // alt/ast/fal/ggt/lipasa/ck U/L
109
- // bun mg/dL | creat mg/dL | sdma μg/dL
110
- // gluc mg/dL | prot/alb/glob g/dL | bili/bili_dir mg/dL | fosf mg/dL | calc mg/dL
111
- // fruc μmol/L | acidos_bil μmol/L
112
- // sodio/potasio/cloro/tco2 mEq/L | colest/trigli mg/dL
113
- // cortisol μg/dL | t4_total nmol/L | insulina μIU/mL
114
- const CONVERSIONES_UNIDADES = {
115
- hgb: [
116
- { re: /\bg\/L\b/i, factor: v => v / 10 },
117
- { re: /\bmmol\/L\b/i, factor: v => v * 1.6113 },
118
- ],
119
- hct: [
120
- { re: /\bL\/L\b/i, factor: v => v < 1.5 ? v * 100 : v },
121
- ],
122
- chcm: [
123
- { re: /\bg\/L\b/i, factor: v => v / 10 },
124
- { re: /\bmmol\/L\b/i, factor: v => v * 0.6206 },
125
- ],
126
- pct: [
127
- { re: /\bL\/L\b/i, factor: v => v < 1.5 ? v * 100 : v },
128
- ],
129
- wbc: [
130
- // "/μL" sin prefijo ×10³ = conteo absoluto → dividir entre 1000
131
- { re: /^[\s]*\/[μuµ]?[Ll]\b/, factor: v => v > 100 ? v / 1000 : v },
132
- ],
133
- plt: [
134
- { re: /^[\s]*\/[μuµ]?[Ll]\b/, factor: v => v > 1000 ? v / 1000 : v },
135
- ],
136
- // claveConv "bun": etiquetado como BUN o nitrógeno ureico → ya es fracción nitrogenada
137
- bun: [
138
- { re: /\bmmol\/L\b/i, factor: v => v * 2.8 },
139
- ],
140
- // claveConv "urea": etiquetado como "Urea" → puede ser urea total, no fracción nitrogenada
141
- urea: [
142
- { re: /\bmmol\/L\b/i, factor: v => v * 2.8 },
143
- { re: /\bmg\/dL\b/i, factor: v => v * 0.467 }, // urea total → BUN
144
- ],
145
- creat: [
146
- { re: /\b[μuµ]mol\/L\b/i, factor: v => v / 88.4 },
147
- ],
148
- sdma: [
149
- { re: /\bnmol\/L\b/i, factor: v => v / 5.899 },
150
- { re: /\b[μuµ]g\/L\b/i, factor: v => v / 10 },
151
- ],
152
- gluc: [
153
- { re: /\bmmol\/L\b/i, factor: v => v * 18.016 },
154
- ],
155
- prot: [
156
- { re: /\bg\/L\b/i, factor: v => v / 10 },
157
- ],
158
- alb: [
159
- { re: /\bg\/L\b/i, factor: v => v / 10 },
160
- ],
161
- glob: [
162
- { re: /\bg\/L\b/i, factor: v => v / 10 },
163
- ],
164
- bili: [
165
- { re: /\b[μuµ]mol\/L\b/i, factor: v => v / 17.1 },
166
- ],
167
- bili_dir: [
168
- { re: /\b[μuµ]mol\/L\b/i, factor: v => v / 17.1 },
169
- ],
170
- fosf: [
171
- { re: /\bmmol\/L\b/i, factor: v => v * 3.097 },
172
- ],
173
- calc: [
174
- { re: /\bmmol\/L\b/i, factor: v => v * 4.008 },
175
- { re: /\bm[Ee]q\/L\b/, factor: v => v * 2.004 },
176
- ],
177
- colest: [
178
- { re: /\bmmol\/L\b/i, factor: v => v * 38.67 },
179
- ],
180
- trigli: [
181
- { re: /\bmmol\/L\b/i, factor: v => v * 88.57 },
182
- ],
183
- cortisol_bas: [
184
- { re: /\bnmol\/L\b/i, factor: v => v / 27.59 },
185
- ],
186
- cortisol_acth: [
187
- { re: /\bnmol\/L\b/i, factor: v => v / 27.59 },
188
- ],
189
- t4_total: [
190
- // Unidad de la app: nmol/L
191
- { re: /\b[μuµ]g\/dL\b/i, factor: v => v * 12.87 },
192
- { re: /\bng\/dL\b/i, factor: v => v * 0.01287 },
193
- { re: /\bng\/mL\b/i, factor: v => v * 0.1287 },
194
- ],
195
- insulina: [
196
- { re: /\bpmol\/L\b/i, factor: v => v / 6.945 },
197
- ],
198
- };
199
-
200
- function aplicarConversion(campo, claveConv, value, cadenaUnidad) {
201
- const key = claveConv || campo;
202
- const reglas = CONVERSIONES_UNIDADES[key];
203
- if (!reglas) return value;
204
- // Aplica la primera regla cuya regex coincida con la cadena de unidad detectada
205
- for (const regla of reglas) {
206
- if (regla.re.test(cadenaUnidad)) {
207
- const f = regla.factor;
208
- const convertido = typeof f === 'function' ? f(value) : value * f;
209
- return Math.round(convertido * 10000) / 10000;
210
- }
211
- }
212
- return value;
213
- }
214
-
215
- // Retorna { num, unit } donde unit es la cadena de ~50 caracteres tras el valor numerico.
216
- // Las reglas de conversion evaluan su regex contra esta cadena para decidir el factor.
217
- function extraerValorYUnidad(contexto) {
218
- const m = contexto.match(/[<>≤≥]?\s*(\d+(?:[.,]\d+)?)([\s\S]*)/);
219
- if (!m) return { num: null, unit: '' };
220
- const v = parseFloat(m[1].replace(',', '.'));
221
- if (!isFinite(v) || v <= 0) return { num: null, unit: '' };
222
- return { num: v, unit: m[2].slice(0, 50) };
223
- }
224
-
225
- function parsearSemiCuantitativo(text) {
226
- const t = text.toLowerCase();
227
- if (/negati|nég|neg\b|ausente|absent|no\s+detect/.test(t)) return 'neg';
228
- if (/\+{3}/.test(t)) return '+++';
229
- if (/\+{2}/.test(t)) return '++';
230
- if (/\+/.test(t)) return '+';
231
- if (/traz|trace/.test(t)) return '+';
232
- return null;
233
- }
234
-
235
- function parsearTextoLab(textoCrudo) {
236
- const resultados = {};
237
-
238
- for (const def of DEFS_ANALITOS) {
239
- if (resultados[def.campo] !== undefined) continue;
240
- const match = def.re.exec(textoCrudo);
241
- if (!match) continue;
242
- const contexto = textoCrudo.slice(match.index + match[0].length, match.index + match[0].length + 150);
243
- const { num, unit } = extraerValorYUnidad(contexto);
244
- if (num === null) continue;
245
- resultados[def.campo] = aplicarConversion(def.campo, def.claveConv, num, unit);
246
- }
247
-
248
- // Derivar % desde conteos absolutos si el % no se encontro directamente y se conoce el WBC
249
- if (resultados.wbc && resultados.wbc > 0) {
250
- for (const f of ['neutro', 'linfo', 'mono', 'eosino', 'baso']) {
251
- if (resultados[f] === undefined && resultados[`${f}_abs`] !== undefined) {
252
- const pct = Math.round((resultados[`${f}_abs`] / resultados.wbc) * 100);
253
- if (pct >= 0 && pct <= 100) resultados[f] = pct;
254
- }
255
- }
256
- }
257
-
258
- // Derivar % de reticulocitos desde el conteo absoluto y RBC si no se encontro directamente
259
- // reti_abs (x10³/uL) / (rbc (x10⁶/uL) * 10) = reti%
260
- if (resultados.rbc && resultados.rbc > 0 && resultados.reti === undefined && resultados.reti_abs !== undefined) {
261
- const pct = resultados.reti_abs / (resultados.rbc * 10);
262
- if (pct >= 0 && pct <= 20) resultados.reti = Math.round(pct * 100) / 100;
263
- }
264
-
265
- for (const def of DEFS_SEMICUANTITATIVOS) {
266
- if (resultados[def.campo] !== undefined) continue;
267
- const match = def.re.exec(textoCrudo);
268
- if (!match) continue;
269
- const contexto = textoCrudo.slice(match.index, match.index + 80);
270
- const val = parsearSemiCuantitativo(contexto);
271
- if (val) resultados[def.campo] = val;
272
- }
273
-
274
- return resultados;
275
- }
276
-
277
- // Detección de información del paciente
278
-
279
- const RAZAS_CANINO = [
280
- 'labrador', 'golden retriever', 'golden', 'pastor alemán', 'pastor aleman', 'pastor',
281
- 'poodle', 'caniche', 'beagle', 'bulldog', 'dachshund', 'salchicha', 'teckel',
282
- 'husky', 'chihuahu', 'maltés', 'maltes', 'yorkshire', 'terrier', 'doberman',
283
- 'rottweiler', 'boxer', 'bóxer', 'schnauzer', 'cocker', 'spaniel',
284
- 'border collie', 'border', 'dálmata', 'dalmatian', 'pitbull', 'pit bull',
285
- 'american staffordshire', 'samoyedo', 'akita', 'shiba', 'galgo', 'greyhound',
286
- 'whippet', 'bichón', 'bichon', 'weimaraner', 'setter', 'pointer', 'vizsla',
287
- 'basset', 'mastín', 'mastin', 'mastiff', 'bullmastiff', 'dogo', 'cane corso',
288
- 'pomerania', 'pomeran', 'pequinés', 'pekinese', 'chow chow', 'shar pei',
289
- 'gran danés', 'great dane', 'san bernardo', 'saint bernard', 'bernese',
290
- 'spitz', 'pinscher', 'shih tzu', 'lhasa', 'basenji', 'rhodesian',
291
- ];
292
-
293
- const RAZAS_FELINO = [
294
- 'persa', 'persian', 'siamés', 'siames', 'siamese', 'bengala', 'bengal',
295
- 'maine coon', 'ragdoll', 'abisinio', 'abyssinian', 'birmano', 'burmese',
296
- 'angora', 'sphynx', 'esfinge', 'scottish fold', 'scottish', 'munchkin',
297
- 'tonkinés', 'cornish rex', 'devon rex', 'noruego', 'norwegian',
298
- 'british shorthair', 'british', 'russian blue', 'azul ruso', 'ocicat',
299
- 'exótico', 'exotic shorthair', 'ragamuffin', 'balinés', 'balinese',
300
- ];
301
-
302
- // Palabras clave de etiqueta que marcan el inicio de un nuevo campo (para detener la captura de raza)
303
- const SIGUIENTE_ETIQUETA = /\b(?:edad|age|sexo|sex|g[eé]nero|gender|especie|species|dueño|owner|propietario|doctor|vet|fecha|date|n[uú]m|caso|case|id|muestra|sample|peso|weight)\b/i;
304
-
305
- function inferEspecie(raza) {
306
- const r = raza.toLowerCase();
307
- if (RAZAS_CANINO.some(b => r.includes(b))) return 'Canino';
308
- if (RAZAS_FELINO.some(b => r.includes(b))) return 'Felino';
309
- return null;
310
- }
311
-
312
- function parsearTextoPaciente(textoCrudo) {
313
- const p = {};
314
-
315
- // Especie: tolera variaciones como "Canino", "Dog", "Felino", "Cat"
316
- const coincEsp = textoCrudo.match(/\b(?:especies?|species|tipo(?:\s+de)?\s+animal)\s*:?\s{0,4}([A-Za-záéíóúÁÉÍÓÚñÑ]{3,20})/i);
317
- if (coincEsp) {
318
- const v = coincEsp[1].toLowerCase();
319
- if (/can[io]|perro|dog/.test(v)) p.especie = 'Canino';
320
- else if (/fel[io]|gat[ao]|cat/.test(v)) p.especie = 'Felino';
321
- }
322
-
323
- // Raza: corta en la siguiente etiqueta o doble espacio para evitar absorber campos adyacentes en tablas
324
- const coincRaza = textoCrudo.match(/\b(?:raza|breed|race|cruce)\s*:?\s{0,4}([^\n\r;:]{2,60})/i);
325
- if (coincRaza) {
326
- const crudo = coincRaza[1];
327
- const indiceParo = crudo.search(SIGUIENTE_ETIQUETA);
328
- const limpiado = (indiceParo > 0 ? crudo.slice(0, indiceParo) : crudo)
329
- .split(/\s{2,}/)[0]
330
- .trim();
331
- if (limpiado.length >= 2) p.raza = limpiado.length > 40 ? limpiado.slice(0, 40).trim() : limpiado;
332
- }
333
-
334
- // Si no encontro especie pero si raza, infiere la especie a partir de listas de razas conocidas
335
- if (!p.especie && p.raza) p.especie = inferEspecie(p.raza);
336
-
337
- // Sexo: soporta abreviaturas (M, F, H) y variantes como "Esterilizada"
338
- const coincSex = textoCrudo.match(/\b(?:sexo|sex[ou]?|g[eé]nero|gender)\s*:?\s{0,4}([^\n\r;:]{1,30})/i);
339
- if (coincSex) {
340
- const v = coincSex[1].trim();
341
- if (/\b(?:macho|male|castrado|neutered)\b/i.test(v) || /^m\.?\s*$/i.test(v)) p.sexo = 'Macho';
342
- else if (/\b(?:hembra|female|esterilizada?|spayed)\b/i.test(v) || /^[fh]\.?\s*$/i.test(v)) p.sexo = 'Hembra';
343
- }
344
-
345
- // Edad: extrae numero y unidad, normalizando comas decimales
346
- const coincEdad = textoCrudo.match(/\b(?:edad|age)\s*:?\s{0,4}(\d+(?:[.,]\d+)?)\s*(a[ñn]os?|years?|yr?s?|meses?|months?)\b/i);
347
- if (coincEdad) {
348
- p.edad = parseFloat(coincEdad[1].replace(',', '.'));
349
- p.edadUnidad = /^m/i.test(coincEdad[2]) ? 'meses' : 'anyos';
350
- }
351
-
352
- return p;
353
- }
354
-
355
- function aplicarPacienteAFormulario(patient) {
356
- const MAPA = [
357
- { id: 'pt-especie', mobId: 'mob-pt-especie', key: 'especie', evt: 'change' },
358
- { id: 'pt-raza', mobId: 'mob-pt-raza', key: 'raza', evt: 'input' },
359
- { id: 'pt-edad', mobId: 'mob-pt-edad', key: 'edad', evt: 'input' },
360
- { id: 'pt-edad-unidad', mobId: 'mob-pt-edad-unidad', key: 'edadUnidad', evt: 'change' },
361
- { id: 'pt-sexo', mobId: 'mob-pt-sexo', key: 'sexo', evt: 'change' },
362
- ];
363
- let contador = 0;
364
- for (const { id, mobId, key, evt } of MAPA) {
365
- const val = patient[key];
366
- if (val === undefined) continue;
367
- const el = document.getElementById(id);
368
- const mob = document.getElementById(mobId);
369
- if (!el) continue;
370
- const valorCadena = String(val);
371
- if (el.tagName === 'SELECT') {
372
- const opcion = [...el.options].find(o => o.value === valorCadena || o.text === valorCadena);
373
- if (!opcion) continue;
374
- el.value = opcion.value;
375
- if (mob) mob.value = opcion.value;
376
- } else {
377
- el.value = valorCadena;
378
- if (mob) mob.value = valorCadena;
379
- }
380
- el.dispatchEvent(new Event(evt, { bubbles: true }));
381
- contador++;
382
- }
383
- return contador;
384
- }
385
-
386
- async function cargarPdfJs() {
387
- if (window.pdfjsLib) return window.pdfjsLib;
388
- await new Promise((resolve, reject) => {
389
- const script = document.createElement('script');
390
- script.src = 'assets/lib/pdfjs/pdf.min.js';
391
- script.onload = resolve;
392
- script.onerror = reject;
393
- document.head.appendChild(script);
394
- });
395
- return window.pdfjsLib;
396
- }
397
-
398
- async function extraerTextoPdf(file) {
399
- const pdfjs = await cargarPdfJs();
400
- if (!pdfjs) throw new Error('PDF.js no cargado');
401
- pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER;
402
-
403
- const buf = await file.arrayBuffer();
404
- const pdf = await pdfjs.getDocument({ data: buf }).promise;
405
- const paginas = [];
406
- for (let p = 1; p <= pdf.numPages; p++) {
407
- const page = await pdf.getPage(p);
408
- const content = await page.getTextContent();
409
- paginas.push(content.items.map(i => i.str + (i.hasEOL ? '\n' : ' ')).join(''));
410
- }
411
- return paginas.join('\n');
412
- }
413
-
414
- function aplicarAFormulario(resultados, evaluar) {
415
- let contador = 0;
416
- for (const [campo, value] of Object.entries(resultados)) {
417
- const el = document.querySelector(`[name="${campo}"]`);
418
- if (!el) continue;
419
- if (el.tagName === 'SELECT') {
420
- if ([...el.options].some(o => o.value === value)) {
421
- el.value = value;
422
- contador++;
423
- }
424
- } else {
425
- el.value = value;
426
- contador++;
427
- }
428
- }
429
- if (contador > 0) evaluar();
430
- return contador;
431
- }
432
-
433
- function mostrarToast(mensaje, error = false) {
434
- let el = document.getElementById('pdf-toast');
435
- if (!el) {
436
- el = document.createElement('div');
437
- el.id = 'pdf-toast';
438
- document.body.appendChild(el);
439
- }
440
- el.textContent = mensaje;
441
- el.className = 'pdf-toast' + (error ? ' pdf-toast--error' : '');
442
- el.classList.add('pdf-toast--show');
443
- clearTimeout(el._t);
444
- el._t = setTimeout(() => el.classList.remove('pdf-toast--show'), 3500);
445
- }
446
-
447
- export function inicializarParserPdf(evaluar) {
448
- document.querySelectorAll('.btn-importar-pdf').forEach(btn => {
449
- btn.addEventListener('click', e => {
450
- e.stopPropagation();
451
- document.getElementById(`pdf-input-${btn.dataset.panel}`)?.click();
452
- });
453
- });
454
-
455
- document.querySelectorAll('.pdf-input').forEach(input => {
456
- input.addEventListener('change', async () => {
457
- const file = input.files[0];
458
- if (!file) return;
459
- input.value = '';
460
- try {
461
- const textoCrudo = await extraerTextoPdf(file);
462
- const resultados = parsearTextoLab(textoCrudo);
463
- const contadorLab = aplicarAFormulario(resultados, evaluar);
464
- const patient = parsearTextoPaciente(textoCrudo);
465
- const contadorPac = aplicarPacienteAFormulario(patient);
466
- const partes = [];
467
- if (contadorLab > 0) partes.push(`${contadorLab} valor${contadorLab !== 1 ? 'es' : ''}`);
468
- if (contadorPac > 0) partes.push('datos del paciente');
469
- mostrarToast(partes.length > 0
470
- ? `${partes.join(' y ')} importados del PDF.`
471
- : 'No se encontraron datos reconocibles en el PDF.', partes.length === 0);
472
- } catch {
473
- mostrarToast('Error al leer el PDF. ¿Es un PDF con texto (no escaneado)?', true);
474
- }
475
- });
476
- });
477
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/tooltip.js DELETED
@@ -1,59 +0,0 @@
1
- const burbuja = document.createElement('div');
2
- burbuja.id = 'tooltip-global';
3
- document.body.appendChild(burbuja);
4
-
5
- const MARGEN = 8;
6
-
7
- const posicionar = (el) => {
8
- const rect = el.getBoundingClientRect();
9
- const enFooter = el.closest('.nav-inferior') !== null;
10
-
11
- burbuja.style.left = '';
12
- burbuja.style.right = '';
13
-
14
- let top, left;
15
-
16
- if (enFooter) {
17
- top = rect.top - burbuja.offsetHeight - MARGEN;
18
- } else {
19
- top = rect.bottom + MARGEN;
20
- }
21
-
22
- left = rect.left + rect.width / 2 - burbuja.offsetWidth / 2;
23
-
24
- const margenLateral = 6;
25
- if (left < margenLateral) left = margenLateral;
26
- if (left + burbuja.offsetWidth > window.innerWidth - margenLateral) {
27
- left = window.innerWidth - burbuja.offsetWidth - margenLateral;
28
- }
29
-
30
- burbuja.style.top = `${top + window.scrollY}px`;
31
- burbuja.style.left = `${left}px`;
32
- };
33
-
34
- let temporizador;
35
-
36
- document.addEventListener('mouseover', (e) => {
37
- const el = e.target.closest('[data-tooltip]');
38
- if (!el) return;
39
-
40
- clearTimeout(temporizador);
41
- temporizador = setTimeout(() => {
42
- burbuja.textContent = el.dataset.tooltip;
43
- burbuja.classList.add('visible');
44
- posicionar(el);
45
- }, 400);
46
- });
47
-
48
- document.addEventListener('mouseout', (e) => {
49
- const el = e.target.closest('[data-tooltip]');
50
- if (!el) return;
51
-
52
- clearTimeout(temporizador);
53
- burbuja.classList.remove('visible');
54
- });
55
-
56
- document.addEventListener('click', () => {
57
- clearTimeout(temporizador);
58
- burbuja.classList.remove('visible');
59
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
js/ui.js DELETED
@@ -1,479 +0,0 @@
1
- // Navegación-Pestañas
2
-
3
- const tabs = document.querySelectorAll('.tab-nav');
4
- const paneles = document.querySelectorAll('main > .panel, .col3-wrapper > .panel');
5
- const examenesSubtabsBar = document.getElementById('examenes-subtabs-bar');
6
-
7
- const EXAMENES_SUBTAB_PANELS = new Set(['panel-hema', 'panel-bioquim', 'panel-uri', 'panel-endo', 'panel-coag', 'panel-gas']);
8
- let panelExamenActivo = 'panel-hema';
9
- let panelActivo = 'panel-flujo';
10
-
11
- const SWIPE_ORDER = ['panel-flujo', 'panel-paciente', 'panel-hema', 'panel-bioquim', 'panel-uri', 'panel-endo', 'panel-coag', 'panel-gas', 'panel-imagenes', 'panel-resultados'];
12
-
13
- export function activarTab(targetId) {
14
- const esSubpanelExamenes = EXAMENES_SUBTAB_PANELS.has(targetId);
15
- const esTabExamenes = targetId === 'examenes';
16
- const mostrarExamenes = esTabExamenes || esSubpanelExamenes;
17
-
18
- // Si se clickea la tab generica "Examenes", muestra el ultimo subtab activo; si es un subtab, lo guarda
19
- let idPanelActual;
20
- if (esTabExamenes) {
21
- idPanelActual = panelExamenActivo;
22
- } else if (esSubpanelExamenes) {
23
- panelExamenActivo = targetId;
24
- idPanelActual = targetId;
25
- } else {
26
- idPanelActual = targetId;
27
- }
28
-
29
- tabs.forEach(tab => {
30
- const estaActivo = mostrarExamenes
31
- ? tab.dataset.target === 'examenes'
32
- : tab.dataset.target === targetId;
33
- tab.classList.toggle('activo', estaActivo);
34
- tab.setAttribute('aria-current', estaActivo ? 'true' : 'false');
35
- });
36
-
37
- if (examenesSubtabsBar) examenesSubtabsBar.hidden = !mostrarExamenes;
38
-
39
- if (mostrarExamenes) {
40
- document.querySelectorAll('.tab-examenes').forEach(btn => {
41
- btn.classList.toggle('activo', btn.dataset.subtabTarget === panelExamenActivo);
42
- });
43
- }
44
-
45
- paneles.forEach(panel => {
46
- panel.classList.toggle('activo', panel.id === idPanelActual);
47
- });
48
-
49
- panelActivo = idPanelActual;
50
- if (targetId === 'panel-paciente') sincronizarPacienteMob();
51
- }
52
-
53
- tabs.forEach(tab => {
54
- tab.addEventListener('click', () => activarTab(tab.dataset.target));
55
- });
56
-
57
- document.querySelectorAll('.tab-examenes').forEach(btn => {
58
- btn.addEventListener('click', () => activarTab(btn.dataset.subtabTarget));
59
- });
60
-
61
- // Swipe para navegar entre secciones
62
-
63
- let inicioSwipeX = 0;
64
- let inicioSwipeY = 0;
65
-
66
- document.querySelector('main').addEventListener('touchstart', e => {
67
- inicioSwipeX = e.touches[0].clientX;
68
- inicioSwipeY = e.touches[0].clientY;
69
- }, { passive: true });
70
-
71
- document.querySelector('main').addEventListener('touchend', e => {
72
- const dx = e.changedTouches[0].clientX - inicioSwipeX;
73
- const dy = e.changedTouches[0].clientY - inicioSwipeY;
74
- // Ignora gestos cortos o verticales para no interferir con scroll
75
- if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return;
76
- const indice = SWIPE_ORDER.indexOf(panelActivo);
77
- const siguiente = dx < 0 ? SWIPE_ORDER[indice + 1] : SWIPE_ORDER[indice - 1];
78
- if (siguiente) activarTab(siguiente);
79
- }, { passive: true });
80
-
81
- // Sincronizacón de datos de pacientes en mobile
82
-
83
- const MAPA_MOB_CANON = {
84
- 'mob-pt-especie': 'pt-especie',
85
- 'mob-pt-raza': 'pt-raza',
86
- 'mob-pt-edad': 'pt-edad',
87
- 'mob-pt-edad-unidad': 'pt-edad-unidad',
88
- 'mob-pt-sexo': 'pt-sexo'
89
- };
90
-
91
- function sincronizarPacienteMob() {
92
- Object.entries(MAPA_MOB_CANON).forEach(([mobId, canonId]) => {
93
- const mobEl = document.getElementById(mobId);
94
- const canonEl = document.getElementById(canonId);
95
- if (mobEl && canonEl) mobEl.value = canonEl.value;
96
- });
97
- }
98
-
99
- export function inicializarSincMob(evaluar) {
100
- Object.entries(MAPA_MOB_CANON).forEach(([mobId, canonId]) => {
101
- const mobEl = document.getElementById(mobId);
102
- if (!mobEl) return;
103
- const tipoEvento = mobEl.tagName === 'SELECT' ? 'change' : 'input';
104
- mobEl.addEventListener(tipoEvento, () => {
105
- const canonEl = document.getElementById(canonId);
106
- if (canonEl) canonEl.value = mobEl.value;
107
- evaluar();
108
- });
109
- });
110
- }
111
-
112
- // Filas de grid
113
-
114
- const panelFlujo = document.getElementById('panel-flujo');
115
- const btnColapsar = document.getElementById('btn-colapsar-flujo');
116
- const mainEl = document.querySelector('main');
117
-
118
- let filaColapsada = '';
119
- let filaExpandida = '';
120
-
121
- const esGridEscritorio = () => window.innerWidth > 1100;
122
-
123
- function inicializarFilasGrid() {
124
- if (!esGridEscritorio()) return;
125
- mainEl.style.gridTemplateRows = '1fr auto auto auto';
126
-
127
- // Mide el panel de flujo expandido y colapsado para animar grid-template-rows con precision
128
- const alturaPanel = panelFlujo.getBoundingClientRect().height;
129
- const alturaEncabezado = panelFlujo.querySelector('.panel-cabecera').getBoundingClientRect().height;
130
- if (alturaPanel > 0) filaExpandida = `${alturaPanel}px`;
131
- if (alturaEncabezado > 0) filaColapsada = `${alturaEncabezado}px`;
132
-
133
- mainEl.style.gridTemplateRows = `1fr auto auto ${filaExpandida || 'auto'}`;
134
- }
135
-
136
- function establecerFilasGrid(colapsado, animar) {
137
- if (!esGridEscritorio()) return;
138
- if (!animar) mainEl.style.transition = 'none';
139
- mainEl.style.gridTemplateRows = colapsado
140
- ? `1fr auto auto ${filaColapsada}`
141
- : `1fr auto auto ${filaExpandida}`;
142
- if (!animar) {
143
- mainEl.offsetHeight;
144
- mainEl.style.transition = '';
145
- }
146
- }
147
-
148
- inicializarFilasGrid();
149
-
150
- const inicioColapsado = localStorage.getItem('mx-flujo-collapsed') === '1';
151
- if (inicioColapsado) {
152
- panelFlujo.classList.add('collapsed');
153
- btnColapsar.setAttribute('aria-expanded', 'false');
154
- establecerFilasGrid(true, false);
155
- }
156
-
157
- btnColapsar.addEventListener('click', () => {
158
- const colapsado = panelFlujo.classList.toggle('collapsed');
159
- btnColapsar.setAttribute('aria-expanded', String(!colapsado));
160
- establecerFilasGrid(colapsado, true);
161
- localStorage.setItem('mx-flujo-collapsed', colapsado ? '1' : '0');
162
- if (!colapsado) {
163
- ['panel-endo', 'panel-uri', 'panel-coag', 'panel-gas'].forEach(id => {
164
- const sp = document.getElementById(id);
165
- if (sp) establecerSubpanelColapsado(sp, true);
166
- });
167
- }
168
- });
169
-
170
- window.addEventListener('resize', () => {
171
- if (esGridEscritorio()) {
172
- if (!panelFlujo.classList.contains('collapsed')) inicializarFilasGrid();
173
- } else {
174
- document.querySelectorAll('.subpanel-anim').forEach(animEl => {
175
- animEl.style.height = '';
176
- animEl.style.transition = '';
177
- });
178
- document.getElementById('subpanel-citologia')
179
- ?.querySelector('.subpanel-anim')
180
- ?.style.setProperty('height', '');
181
- }
182
- });
183
-
184
- // Paneles colapsables
185
-
186
- function establecerSubpanelColapsado(subpanel, debeColapsar) {
187
- if (!esGridEscritorio()) return;
188
- const animEl = subpanel.querySelector('.subpanel-anim');
189
- const btn = subpanel.querySelector('.btn-colapsar-subpanel');
190
- const esRelleno = subpanel.id === 'subpanel-citologia';
191
- if (debeColapsar === subpanel.classList.contains('collapsed')) return;
192
- subpanel.classList.toggle('collapsed', debeColapsar);
193
- if (btn) btn.setAttribute('aria-expanded', String(!debeColapsar));
194
- // Forzar reflujo antes de cambiar height permite que CSS transition anime correctamente
195
- if (debeColapsar) {
196
- animEl.style.height = `${animEl.offsetHeight}px`;
197
- animEl.offsetHeight;
198
- animEl.style.height = '0px';
199
- } else {
200
- animEl.style.height = `${animEl.scrollHeight}px`;
201
- if (esRelleno) {
202
- animEl.addEventListener('transitionend', () => {
203
- animEl.style.height = '';
204
- }, { once: true });
205
- }
206
- }
207
- localStorage.setItem(`mx-${subpanel.id}-collapsed`, debeColapsar ? '1' : '0');
208
- }
209
-
210
- const GRUPOS_VINCULADOS = [
211
- ['panel-endo', 'panel-uri'],
212
- ['panel-coag', 'panel-gas'],
213
- ];
214
-
215
- const PANELES_COLAPSADOS_POR_DEFECTO = new Set(['panel-uri', 'panel-endo', 'panel-coag', 'panel-gas']);
216
-
217
- document.querySelectorAll('.btn-colapsar-subpanel').forEach(btn => {
218
- const subpanel = btn.closest('.subpanel');
219
- const animEl = subpanel.querySelector('.subpanel-anim');
220
- const claveAlmacenamiento = `mx-${subpanel.id}-collapsed`;
221
- const esRelleno = subpanel.id === 'subpanel-citologia';
222
-
223
- if (esGridEscritorio()) {
224
- if (!esRelleno) {
225
- animEl.style.transition = 'none';
226
- animEl.style.height = `${animEl.scrollHeight}px`;
227
- }
228
-
229
- const valorGuardado = localStorage.getItem(claveAlmacenamiento);
230
- const debeColapsar = valorGuardado !== null
231
- ? valorGuardado === '1'
232
- : PANELES_COLAPSADOS_POR_DEFECTO.has(subpanel.id);
233
-
234
- if (debeColapsar) {
235
- subpanel.classList.add('collapsed');
236
- btn.setAttribute('aria-expanded', 'false');
237
- if (esRelleno) animEl.style.transition = 'none';
238
- animEl.style.height = '0px';
239
- if (esRelleno) { animEl.offsetHeight; animEl.style.transition = ''; }
240
- }
241
-
242
- if (!esRelleno) { animEl.offsetHeight; animEl.style.transition = ''; }
243
- }
244
-
245
- btn.addEventListener('click', () => {
246
- if (!esGridEscritorio()) return;
247
- const colapsado = subpanel.classList.toggle('collapsed');
248
- btn.setAttribute('aria-expanded', String(!colapsado));
249
- if (colapsado) {
250
- animEl.style.height = `${animEl.offsetHeight}px`;
251
- animEl.offsetHeight;
252
- animEl.style.height = '0px';
253
- } else {
254
- animEl.style.height = `${animEl.scrollHeight}px`;
255
- if (esRelleno) {
256
- animEl.addEventListener('transitionend', () => {
257
- animEl.style.height = '';
258
- }, { once: true });
259
- }
260
- }
261
- localStorage.setItem(claveAlmacenamiento, colapsado ? '1' : '0');
262
-
263
- const grupo = GRUPOS_VINCULADOS.find(g => g.includes(subpanel.id));
264
- if (grupo) {
265
- grupo.forEach(id => {
266
- if (id !== subpanel.id) {
267
- const asociado = document.getElementById(id);
268
- if (asociado) establecerSubpanelColapsado(asociado, colapsado);
269
- }
270
- });
271
- }
272
- });
273
- });
274
-
275
- // Patrones de paneles colapsables
276
-
277
- const btnColapsarPatrones = document.getElementById('btn-colapsar-patrones');
278
- const patronesAnim = document.getElementById('patrones-anim');
279
-
280
- export function colapsarPatrones(debeColapsar) {
281
- const estaExpandido = btnColapsarPatrones.getAttribute('aria-expanded') === 'true';
282
- const colapsado = debeColapsar ?? estaExpandido;
283
-
284
- if (colapsado && estaExpandido) {
285
- patronesAnim.style.height = `${patronesAnim.scrollHeight}px`;
286
- patronesAnim.offsetHeight;
287
- patronesAnim.style.height = '0px';
288
- btnColapsarPatrones.setAttribute('aria-expanded', 'false');
289
- } else if (!colapsado && !estaExpandido) {
290
- patronesAnim.style.height = `${patronesAnim.scrollHeight}px`;
291
- patronesAnim.addEventListener('transitionend', () => {
292
- if (btnColapsarPatrones.getAttribute('aria-expanded') === 'true') {
293
- patronesAnim.style.height = '';
294
- }
295
- }, { once: true });
296
- btnColapsarPatrones.setAttribute('aria-expanded', 'true');
297
- }
298
- }
299
-
300
- btnColapsarPatrones.addEventListener('click', () => colapsarPatrones());
301
-
302
- // Imágenes
303
-
304
- export const imagenesDataUrl = [null, null];
305
- export const capturasMicroscopio = [];
306
-
307
- const MAX_CAPTURAS_MICRO = 4;
308
-
309
- document.querySelectorAll('.zona-imagen').forEach(zona => {
310
- const indice = parseInt(zona.dataset.zona);
311
- const input = zona.querySelector('.input-zona');
312
- const vacia = zona.querySelector('.zona-vacia');
313
- const btnQuitar = zona.querySelector('.btn-quitar-zona');
314
- const vistaPrevia = document.createElement('img');
315
- vistaPrevia.className = 'zona-img-preview';
316
- vistaPrevia.alt = `Citología ${indice + 1}`;
317
- vistaPrevia.hidden = true;
318
- btnQuitar.before(vistaPrevia);
319
-
320
- zona.addEventListener('click', e => {
321
- if (btnQuitar.contains(e.target)) return;
322
- input.click();
323
- });
324
-
325
- input.addEventListener('change', () => {
326
- const file = input.files[0];
327
- if (!file) return;
328
- const reader = new FileReader();
329
- reader.onload = ev => {
330
- const img = new Image();
331
- img.onload = () => {
332
- // Reduce la imagen a max 1024px en su lado mayor para no saturar la memoria ni la API
333
- const MAX_PIXELES = 1024;
334
- const scale = Math.min(MAX_PIXELES / img.width, MAX_PIXELES / img.height, 1);
335
- const canvas = document.createElement('canvas');
336
- canvas.width = Math.round(img.width * scale);
337
- canvas.height = Math.round(img.height * scale);
338
- canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
339
- const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
340
- imagenesDataUrl[indice] = dataUrl;
341
- vistaPrevia.src = dataUrl;
342
- vistaPrevia.hidden = false;
343
- btnQuitar.hidden = false;
344
- vacia.hidden = true;
345
- zona.classList.add('con-imagen');
346
- };
347
- img.src = ev.target.result;
348
- };
349
- reader.readAsDataURL(file);
350
- });
351
-
352
- btnQuitar.addEventListener('click', e => {
353
- e.stopPropagation();
354
- imagenesDataUrl[indice] = null;
355
- vistaPrevia.src = '';
356
- vistaPrevia.hidden = true;
357
- btnQuitar.hidden = true;
358
- vacia.hidden = false;
359
- zona.classList.remove('con-imagen');
360
- input.value = '';
361
- });
362
- });
363
-
364
- // Captura de microscopio
365
-
366
- (function () {
367
- const zona = document.querySelector('.zona-microscopio');
368
- if (!zona) return;
369
-
370
- const micVacia = zona.querySelector('.micro-vacia');
371
- const video = zona.querySelector('.micro-video');
372
- const controles = zona.querySelector('.micro-controles');
373
- const btnGaleria = zona.querySelector('.micro-btn-galeria');
374
- const badge = zona.querySelector('.micro-badge');
375
- const btnCapturar = zona.querySelector('.micro-btn-capturar');
376
- const btnCerrar = zona.querySelector('.micro-btn-cerrar');
377
- const galeriaEl = zona.querySelector('.micro-galeria');
378
-
379
- let stream = null;
380
- let galeriaEsVisible = false;
381
-
382
- function detenerStream() {
383
- if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
384
- }
385
-
386
- function actualizarInsignia() {
387
- const n = capturasMicroscopio.length;
388
- badge.textContent = n;
389
- badge.hidden = n === 0;
390
- btnCapturar.disabled = n >= MAX_CAPTURAS_MICRO;
391
- }
392
-
393
- function renderizarGaleria() {
394
- if (capturasMicroscopio.length === 0) {
395
- galeriaEl.innerHTML = '<span class="micro-galeria-vacia">Sin capturas</span>';
396
- return;
397
- }
398
- galeriaEl.innerHTML = capturasMicroscopio.map((src, i) => `
399
- <div class="micro-thumb">
400
- <img src="${src}" alt="Captura ${i + 1}">
401
- <button class="micro-thumb-quitar" type="button" data-capture-idx="${i}" aria-label="Eliminar captura ${i + 1}">
402
- <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" aria-hidden="true">
403
- <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
404
- </svg>
405
- </button>
406
- </div>`).join('');
407
-
408
- galeriaEl.querySelectorAll('.micro-thumb-quitar').forEach(btn => {
409
- btn.addEventListener('click', e => {
410
- e.stopPropagation();
411
- const i = parseInt(btn.dataset.captureIdx);
412
- capturasMicroscopio.splice(i, 1);
413
- actualizarInsignia();
414
- renderizarGaleria();
415
- if (capturasMicroscopio.length === 0 && galeriaEsVisible) alternarGaleria();
416
- });
417
- });
418
- }
419
-
420
- function alternarGaleria() {
421
- galeriaEsVisible = !galeriaEsVisible;
422
- galeriaEl.hidden = !galeriaEsVisible;
423
- if (galeriaEsVisible) renderizarGaleria();
424
- btnGaleria.style.color = galeriaEsVisible ? 'var(--accent)' : '';
425
- }
426
-
427
- async function abrirCamara() {
428
- try {
429
- // Preferencia por camara trasera (microscopio o movil apuntando a la muestra)
430
- stream = await navigator.mediaDevices.getUserMedia({
431
- video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 } }
432
- });
433
- video.srcObject = stream;
434
- video.hidden = false;
435
- micVacia.hidden = true;
436
- controles.hidden = false;
437
- actualizarInsignia();
438
- } catch {
439
- // Permiso denegado o camara no disponible; no se requiere fallback
440
- }
441
- }
442
-
443
- zona.addEventListener('click', e => {
444
- if (controles.contains(e.target) || galeriaEl.contains(e.target)) return;
445
- if (!stream) abrirCamara();
446
- });
447
-
448
- btnGaleria.addEventListener('click', e => {
449
- e.stopPropagation();
450
- alternarGaleria();
451
- });
452
-
453
- btnCapturar.addEventListener('click', e => {
454
- e.stopPropagation();
455
- if (capturasMicroscopio.length >= MAX_CAPTURAS_MICRO) return;
456
- const canvas = document.createElement('canvas');
457
- // Escala el fotograma de video para mantener un tamano razonable antes de enviarlo al modelo
458
- const MAX_PIXELES = 1024;
459
- const scale = Math.min(MAX_PIXELES / video.videoWidth, MAX_PIXELES / video.videoHeight, 1);
460
- canvas.width = Math.round(video.videoWidth * scale);
461
- canvas.height = Math.round(video.videoHeight * scale);
462
- canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
463
- capturasMicroscopio.push(canvas.toDataURL('image/jpeg', 0.85));
464
- actualizarInsignia();
465
- if (galeriaEsVisible) renderizarGaleria();
466
- });
467
-
468
- btnCerrar.addEventListener('click', e => {
469
- e.stopPropagation();
470
- detenerStream();
471
- video.hidden = true;
472
- video.srcObject = null;
473
- controles.hidden = true;
474
- galeriaEl.hidden = true;
475
- galeriaEsVisible = false;
476
- micVacia.hidden = false;
477
- });
478
- })();
479
-