// Organización tipo app (28/sep): tarjetas ilustradas, vista previa al mantener // presionado y barra de abajo en el celular. Es el mismo diseño que la app de // Android (paquete «paquete_web_iconos_organizacion» del usuario). // // Los 141 dibujos viven en assets/iconos/ y su nombre sale directo de los datos // (ver orgMapa): así funcionan igual aunque se agregue un diagnóstico, siempre // que su clase ya tenga dibujo. Se verificó contra el manifiesto del paquete: los // 27 dominios y las 104 clases coinciden, nombre por nombre. const { useState: useOrgS, useEffect: useOrgE, useRef: useOrgR } = React; // Sube este número si se redibuja algún ícono: el .htaccess los guarda 7 días. const ORG_ICONOS_V = '20260928'; const orgIconoUrl = (slot) => 'assets/iconos/' + slot + '.webp?v=' + ORG_ICONOS_V; const ORG_PREFIJO = { dx: 'dx', ob: 'no', iv: 'ni' }; const ORG_UNIDAD = { dx: ['diagnóstico', 'diagnósticos'], ob: ['objetivo', 'objetivos'], iv: ['intervención', 'intervenciones'] }; const orgContar = (n, kind) => n + ' ' + ORG_UNIDAD[kind][n === 1 ? 0 : 1]; // Intervenciones se organiza en «campos»; los otros dos libros, en «dominios». const orgNivel1 = (kind, plural) => (kind === 'iv' ? (plural ? 'campos' : 'Campo') : (plural ? 'dominios' : 'Dominio')); // La clase de un elemento: diagnósticos por clase_num, objetivos por // clase_codigo, e intervenciones (sin código en los datos) por su nombre. function orgClaseDe(kind, it) { if (kind === 'dx') return String(it.clase_num); if (kind === 'ob') return String(it.clase_codigo); return it.clase || ''; } // Dominios y clases de un libro, sacados de los datos (no de una lista fija). // Los dibujos de intervenciones se numeran por orden de aparición de la clase // dentro de su campo: es la misma regla con la que se nombraron (28/sep). const orgCacheMapa = {}; function orgMapa(kind) { if (orgCacheMapa[kind]) return orgCacheMapa[kind]; const src = kind === 'dx' ? DATA.dxs : kind === 'ob' ? DATA.objs : DATA.ints; const pre = ORG_PREFIJO[kind]; const nombreDom = new Map((src.dominios || []).map(d => [Number(d.num), d.name])); const doms = new Map(); for (const it of src.items) { const dn = Number(it.dominio_num); if (!doms.has(dn)) { const nombre = String(nombreDom.get(dn) || it.dominio || '').replace(/^(Dominio|Campo)\s*\d+:?\s*/i, ''); doms.set(dn, { num: dn, nombre, slot: 'dom-' + pre + '-' + dn, clases: new Map(), total: 0 }); } const d = doms.get(dn); d.total++; const k = orgClaseDe(kind, it); if (!d.clases.has(k)) { const codigo = kind === 'iv' ? String(d.clases.size + 1) : k; d.clases.set(k, { k, codigo, etiqueta: kind === 'iv' ? null : 'Clase ' + k, nombre: String(it.clase || '').replace(/^Clase\s*[\w\d]+:?\s*/i, ''), slot: 'cls-' + pre + '-' + dn + '-' + codigo, items: [], }); } d.clases.get(k).items.push(it); } const porCodigo = (a, b) => String(a.codigo).localeCompare(String(b.codigo), 'es', { numeric: true }); const ordenClase = kind === 'dx' ? (a, b) => Number(a.k) - Number(b.k) : kind === 'ob' // A…Z y luego AA, BB…, como el libro. ? (a, b) => (a.k.length - b.k.length) || a.k.localeCompare(b.k) : () => 0; // intervenciones: orden de aparición const lista = [...doms.values()].sort((a, b) => a.num - b.num).map(d => ({ ...d, clases: [...d.clases.values()].sort(ordenClase).map(c => ({ ...c, items: c.items.slice().sort(porCodigo) })), })); orgCacheMapa[kind] = lista; return lista; } // ─── Vista previa: mantener presionado (táctil) o dejar el cursor encima ──── // En el celular se abre a los 380 ms y se cierra al soltar, SIN entrar. Con // mouse se abre cuando el cursor, después de moverse, se queda 400 ms quieto // sobre la tarjeta; se cierra al salir o al desplazar la página; el clic entra. // Última posición del mouse. Al entrar a un nivel o al desplazar la página, el // navegador avisa que el cursor pasó a la tarjeta que quedó debajo aunque nadie // lo haya movido: sin esta comprobación la vista previa se abría sola (28/sep). const orgMouse = { x: -1, y: -1 }; function useOrgPrevia() { const [vista, setVista] = useOrgS(null); const [tactil, setTactil] = useOrgS(true); const reloj = useOrgR(null); const disparada = useOrgR(false); const abierta = useOrgR(null); // tarjeta cuya vista previa está abierta (mouse) useOrgE(() => () => clearTimeout(reloj.current), []); // Con el mouse, al desplazar la página la tarjeta ya no está bajo el cursor. useOrgE(() => { if (!vista || tactil) return; const cerrar = () => { abierta.current = null; setVista(null); }; window.addEventListener('scroll', cerrar, { passive: true }); return () => window.removeEventListener('scroll', cerrar); }, [vista, tactil]); const parar = () => clearTimeout(reloj.current); const gestos = (armar, entrar) => ({ onPointerDown: (e) => { if (e.pointerType === 'mouse') return; parar(); disparada.current = false; reloj.current = setTimeout(() => { disparada.current = true; setTactil(true); try { if (navigator.vibrate) navigator.vibrate(10); } catch (err) {} setVista(armar()); }, 380); }, onPointerUp: (e) => { if (e.pointerType === 'mouse') return; parar(); if (disparada.current) setVista(null); }, onPointerCancel: () => { parar(); setVista(null); }, onPointerMove: (e) => { if (e.pointerType !== 'mouse') return; const movio = e.clientX !== orgMouse.x || e.clientY !== orgMouse.y; orgMouse.x = e.clientX; orgMouse.y = e.clientY; if (!movio || abierta.current === e.currentTarget) return; // Cada movimiento reinicia la espera: se abre al quedarse quieto. parar(); const el = e.currentTarget; reloj.current = setTimeout(() => { abierta.current = el; setTactil(false); setVista({ ...armar(), rect: el.getBoundingClientRect() }); }, 400); }, onPointerLeave: () => { parar(); abierta.current = null; setVista(null); }, onContextMenu: (e) => e.preventDefault(), onClick: () => { // Soltar después de asomarse no cuenta como toque: solo cierra. if (disparada.current) { disparada.current = false; return; } parar(); abierta.current = null; setVista(null); entrar(); }, }); return { vista, tactil, gestos }; } // Con el dedo: tarjeta centrada sobre la pantalla oscurecida (como la app). // Con el mouse: tarjetita junto a la tarjeta señalada, sin oscurecer nada, para // que detenerse a leer la rejilla no tape la pantalla entera. function orgPosicionCursor(r) { const W = 360, M = 12, vw = window.innerWidth, vh = window.innerHeight; let left = r.right + M; if (left + W > vw - M) left = r.left - M - W; if (left < M) left = Math.max(M, Math.min(vw - W - M, r.left + r.width / 2 - W / 2)); const top = Math.max(M, r.top); return (vh - top >= 300) ? { left, top, width: W, maxHeight: vh - top - M } : { left, bottom: Math.max(M, vh - r.bottom), width: W, maxHeight: Math.max(160, r.bottom - M) }; } function OrgPrevia({ vista, kind, tactil }) { if (!vista) return null; const tono = kind === 'dx' ? 'nanda' : kind === 'ob' ? 'noc' : 'nic'; const junto = !tactil && vista.rect; // Portal al : .fade-in deja un transform en la sección y un «fixed» // dentro de ella dejaría de cubrir la pantalla. return ReactDOM.createPortal( , document.body ); } // ─── Tarjeta ilustrada (dominio, clase o herramienta) ─────────────────────── function OrgTile({ slot, nombre, sub, tono, on, gestos }) { return ( ); } // ─── Mapa de taxonomías: rejillas de dominios y de clases ─────────────────── // Lo usa DxSection (AppView.jsx). Aquí solo van las rejillas y la cabecera de // nivel; la lista de elementos y la ficha siguen siendo las de siempre. function OrgRejillaDominios({ kind, mapa, onEntrar }) { const { vista, tactil, gestos } = useOrgPrevia(); const tono = kind === 'dx' ? 'nanda' : kind === 'ob' ? 'noc' : 'nic'; const libro = kind === 'dx' ? 'Diagnósticos' : kind === 'ob' ? 'Objetivos' : 'Intervenciones'; return (
{mapa.length} {orgNivel1(kind, true)} · {libro} Toca un {kind === 'iv' ? 'campo' : 'dominio'} para ver sus clases · mantén presionado para asomarte Haz clic en un {kind === 'iv' ? 'campo' : 'dominio'} para ver sus clases · deja el cursor encima para asomarte
{mapa.map(d => ( ({ eyebrow: orgNivel1(kind) + ' ' + d.num + ' · ' + d.clases.length + (d.clases.length === 1 ? ' clase' : ' clases'), titulo: d.nombre, filas: d.clases.map(c => ({ code: c.etiqueta || String(c.items.length), name: c.nombre + (c.etiqueta ? ' (' + c.items.length + ')' : '') })), }), () => onEntrar(d.num) )}/> ))}
); } function OrgRejillaClases({ kind, dominio, onEntrar }) { const { vista, tactil, gestos } = useOrgPrevia(); const tono = kind === 'dx' ? 'nanda' : kind === 'ob' ? 'noc' : 'nic'; const etiquetaDom = orgNivel1(kind) + ' ' + dominio.num; const getLabel = (it) => it.etiqueta || it.titulo || it.nombre; return (
{dominio.clases.length} {dominio.clases.length === 1 ? 'clase' : 'clases'} · {etiquetaDom}
{dominio.clases.map(c => ( ({ eyebrow: [etiquetaDom, c.etiqueta].filter(Boolean).join(' · '), titulo: c.nombre, filas: c.items.map(it => ({ code: it.codigo, name: getLabel(it) })), }), () => onEntrar(c.k) )}/> ))}
Toca para ver la lista · mantén presionado para asomarte Haz clic para ver la lista · deja el cursor encima para asomarte
); } // Cabecera de nivel: «‹ volver» + tarjeta con el dibujo del dominio o la clase. function OrgCabeceraNivel({ volverTexto, onVolver, slot, tono, eyebrow, nombre }) { return (
{eyebrow} {nombre}
); } // ─── Selector de libro: Diagnósticos · Objetivos · Intervenciones ─────────── // Una pastilla del color del libro se desliza al elegido (29/sep, pedido del // usuario: «íconos y mejor dinámica, minimalista pero no genérico»). Es un // tablist: con el teclado, ← → Inicio Fin cambian de libro. const ORG_LIBROS = [ { kind: 'dx', nombre: 'Diagnósticos', sigla: 'NANDA-I', tono: 'nanda', // Lupa con pulso: buscar signos y síntomas. d: ['M3.75 10.5a6.75 6.75 0 1 0 13.5 0a6.75 6.75 0 1 0-13.5 0', 'm20.25 20.25-4.9-4.9', 'M6.6 10.5h1.8l1.1-2.3 1.9 4.6 1.1-2.3h1.9'] }, { kind: 'ob', nombre: 'Objetivos', sigla: 'NOC', tono: 'noc', // Diana con bandera: el resultado que se busca. d: ['M12 13V2l8 4-8 4', 'M20.56 10.22a9 9 0 1 1-12.55-5.29', 'M8 10a5 5 0 1 0 8.9 2.02'] }, { kind: 'iv', nombre: 'Intervenciones', sigla: 'NIC', tono: 'nic', // Mano con corazón: el cuidado que se da. d: ['M11 14h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 16', 'm7 20 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9', 'm2 15 6 6', 'M19.5 8.5c.7-.7 1.5-1.6 1.5-2.7A2.73 2.73 0 0 0 16 4a2.78 2.78 0 0 0-5 1.8c0 1.2.8 2 1.5 2.8L16 12Z'] }, ]; function SelectorLibro({ kind, conteos, onElegir }) { const idx = Math.max(0, ORG_LIBROS.findIndex(l => l.kind === kind)); const botones = useOrgR([]); const teclas = (e) => { const n = e.key === 'ArrowRight' ? (idx + 1) % 3 : e.key === 'ArrowLeft' ? (idx + 2) % 3 : e.key === 'Home' ? 0 : e.key === 'End' ? 2 : -1; if (n < 0) return; e.preventDefault(); onElegir(ORG_LIBROS[n].kind); if (botones.current[n]) botones.current[n].focus(); }; return (
); } // ─── Herramientas: las 10 tarjetas, en el orden del manifiesto ────────────── // «calc» es el id interno de la calculadora en Tools.jsx (el que también usa // Cynamoncita para abrir una calculadora desde el chat). const ORG_HERRAMIENTAS = [ // Nombre pedido por el usuario el 29/sep (antes «Datos del paciente»). { id: 'paciente', nombre: 'Cálculos totales de enfermería', sub: 'Se comparten con todas' }, { id: 'perd-ped', nombre: 'Pérdidas insensibles', sub: 'Pediátrico · neonatal', calc: 'ped' }, { id: 'perd-adu', nombre: 'Pérdidas insensibles', sub: 'Adulto · por temperatura', calc: 'adult' }, { id: 'goteo', nombre: 'Factor de goteo', sub: 'Gotas/min · ml/h', calc: 'gotas' }, { id: 'imc', nombre: 'Índice de masa corporal', sub: 'OMS · peso ideal', calc: 'imc' }, { id: 'choque', nombre: 'Índice de choque', sub: 'FC ÷ TA sistólica', calc: 'choque' }, { id: 'pam', nombre: 'Presión arterial media', sub: 'Meta ≥ 65 mmHg', calc: 'pam' }, { id: 'liquidos', nombre: 'Tabla de líquidos', sub: '15 filas de referencia', calc: 'tabla' }, { id: 'mi-calc', nombre: 'Mi calculadora', sub: 'Tus propias fórmulas' }, { id: 'escalas', nombre: 'Escalas', sub: '6 escalas clínicas' }, ]; function OrgRejillaHerramientas({ elegida, onElegir }) { return (
{ORG_HERRAMIENTAS.map(h => ( onElegir(h.id) }}/> ))}
); } // ─── Barra de abajo (solo en el celular; ver organizacion.css) ────────────── const ORG_TABS = [ { id: 'auto', label: 'Cynamoncita', d: ['M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z'] }, { id: 'dx', label: 'Mapa', d: ['M9 3 3 6v15l6-3 6 3 6-3V3l-6 3z', 'M9 3v15', 'M15 6v15'] }, { id: 'tools', label: 'Herramientas', d: ['M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2-2z'] }, // Escalas: pestaña propia desde el 11/oct (pedido del usuario; Android tiene 4). { id: 'escalas', label: 'Escalas', d: ['M3.3 19a10 10 0 1 1 17.4 0', 'M12 15l4-5', 'M6.5 12.5h1', 'M16.5 12.5h1', 'M12 7v1'] }, { id: 'casos', label: 'Casos', d: ['M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z', 'M4 10h16', 'M9 5V3h6v2'] }, ]; function BarraAbajo({ tab, onElegir }) { const idx = ORG_TABS.findIndex(t => t.id === tab); // La clase en es la que, en el celular, deja sitio abajo y sube los // avisos flotantes para que la barra no los tape. useOrgE(() => { document.body.classList.add('con-barra-abajo'); return () => document.body.classList.remove('con-barra-abajo'); }, []); return ReactDOM.createPortal( , document.body ); } Object.assign(window, { orgMapa, orgClaseDe, orgIconoUrl, orgContar, orgNivel1, OrgRejillaDominios, OrgRejillaClases, OrgCabeceraNivel, OrgRejillaHerramientas, ORG_HERRAMIENTAS, BarraAbajo, SelectorLibro, });