Protocolo Antropométrico Profesional

ANÁLISIS DE
COMPOSICIÓN CORPORAL

Sistema KERR · 5 Componentes · Ajuste 100% Exacto al Peso Real

📏 Pliegues Cutáneos
📐 Perímetros
🦴 Diámetros Óseos
📊 Gráfico Corporal
📄 Exportar PDF
📁 Historial
🪪
Identificación
Datos administrativos del paciente y la evaluación
Perfil deportivo y antecedentes
sesiones
h/sem
años
Consentimiento informado
🏷️
Sube tu logo profesional (aparecerá en el PDF)
🔋
Energía e Hidratación
Metabolismo basal, requerimiento calórico, hidratación funcional
%
Hidratación funcional
Selecciona el color más cercano a la orina del paciente
g/mL
<1.020 normal · 1.020-1.030 deshidratación leve · >1.030 severa
Signos vitales y bioquímica opcional
mmHg
mmHg
bpm
mg/dL
%
mg/dL
mg/dL
mg/dL
mg/dL
👤
Datos del Paciente
Información biológica y fotografía de referencia
📷
Arrastra o selecciona foto
Foto del paciente
años
kg
cm
cm
📐
Mediciones Antropométricas
Pliegues · Perímetros · Diámetros · Protocolos KERR + ISAK
Pliegues cutáneos
mm
mm
mm
mm
mm
mm
mm
mm
mm
mm
Perímetros
cm
cm
cm
cm
cm
cm
cm
cm
cm
cm
cm
cm
cm
cm
Diámetros óseos
cm
cm
cm
cm
cm
cm
cm
cm
Longitudes segmentarias ISAK COMPLETO
cm
cm
cm
cm
cm
cm
cm
cm

🔬 Control de Calidad ISAK · Error Técnico de Medición (TEM) Umbrales: Pliegues ≤5% · Perímetros ≤1% · Diámetros/Longitudes ≤0.5%

📊
Diagnóstico Final
Composición corporal · Protocolo KERR · Ajuste exacto al peso real
Indicadores Clave
Peso Real
Suma Final
Ajuste Aplicado
* Los valores han sido ajustados proporcionalmente para coincidir al 100% con el peso de báscula
Composición por Componente
kg total
${t('sec.bfFormulas')}
${bfList.map(f => ` `).join('')}
FórmulaSitios% GrasoMasa grasa
${escapeHtml(f.name)} ${f.ref?'★ referencia':''} ${escapeHtml(f.sites)} ${f.val.toFixed(1)}% ${f.grams.toFixed(2)} kg
Promedio (excl. ref): ${(bfList.filter(f=>!f.ref).reduce((a,b)=>a+b.val,0)/bfList.filter(f=>!f.ref).length).toFixed(1)}% · Rango: ${Math.min(...bfList.map(f=>f.val)).toFixed(1)}% – ${Math.max(...bfList.map(f=>f.val)).toFixed(1)}%
${t('sec.athletes')}
Tu somatotipo (${somato.endo.toFixed(1)}–${somato.meso.toFixed(1)}–${somato.ecto.toFixed(1)}) comparado con valores élite por deporte (Carter & Heath):
${closest.map((a,i) => `
${a.icon}
${escapeHtml(a.sport)}
${a.somat[0].toFixed(1)}–${a.somat[1].toFixed(1)}–${a.somat[2].toFixed(1)}
SAD: ${a.distance.toFixed(2)} ${i===0?'· 🥇 más cercano':''}
`).join('')}
🦵 Masa Muscular Esquelética (Lee et al. 2000) y Datos Derivados
${leeResult ? `
SMM (Lee)
${leeResult.smm.toFixed(1)}
${leeResult.smmPct.toFixed(1)}% kg
SMI
${leeResult.smi.toFixed(2)}
${escapeHtml(leeResult.classification.label)}
` : `
SMM (Lee)
Faltan brazo, muslo, pierna o pliegues
`}
Superficie corporal
${sa.toFixed(2)}
m² (DuBois)
Sum 7 pliegues
${sum7.toFixed(0)}
mm (JP)
🧬 Modelos de Composición Corporal
${fourC ? ` ` : ''}
ModeloCompartimentokg%
2C
${escapeHtml(twoC?.formula||'')}
🔥 Masa grasa${twoC?twoC.fatMass.toFixed(2):'—'}${twoC?twoC.fatPct.toFixed(1)+'%':'—'}
💪 Masa libre de grasa${twoC?twoC.ffMass.toFixed(2):'—'}${twoC?twoC.ffPct.toFixed(1)+'%':'—'}
4C
${escapeHtml(fourC.formula)}
💧 Agua corporal${fourC.water.toFixed(2)}${fourC.waterPct.toFixed(1)}%
🔥 Masa grasa${fourC.fat.toFixed(2)}${fourC.fatPct.toFixed(1)}%
🦴 Mineral óseo${fourC.bone.toFixed(2)}${fourC.bonePct.toFixed(1)}%
🧪 Proteína + residual${fourC.protein.toFixed(2)}${fourC.proteinPct.toFixed(1)}%
5C (KERR)
Kerr 1991
💪 Muscular${final.Muscular.toFixed(2)}${(final.Muscular/peso*100).toFixed(1)}%
🔥 Adipose${final.Adiposa.toFixed(2)}${(final.Adiposa/peso*100).toFixed(1)}%
🦴 Ósea${final.Osea.toFixed(2)}${(final.Osea/peso*100).toFixed(1)}%
🛡️ Piel${final.Piel.toFixed(2)}${(final.Piel/peso*100).toFixed(1)}%
🧩 Residual${final.Residual.toFixed(2)}${(final.Residual/peso*100).toFixed(1)}%
${(bp.pas || bp.glicemia || bp.colesterolT) ? `
❤️ Signos Vitales y Bioquímica
${bp.pas ? `
Presión arterial
${bp.pas}/${bp.pad||'—'}
${bp.pas>=140||bp.pad>=90?'Hipertensión':bp.pas>=130||bp.pad>=85?'Normal alta':'Óptima'} mmHg
` : ''} ${bp.fc ? `
FC reposo
${bp.fc}
bpm
` : ''} ${bp.glicemia ? `
Glicemia
${bp.glicemia}
${bp.glicemia>=126?'Diabetes':bp.glicemia>=100?'Prediabetes':'Normal'} mg/dL
` : ''} ${bp.hba1c ? `
HbA1c
${bp.hba1c}
${bp.hba1c>=6.5?'Diabetes':bp.hba1c>=5.7?'Prediabetes':'Normal'} %
` : ''} ${bp.colesterolT ? `
Colesterol total
${bp.colesterolT}
${bp.colesterolT>=240?'Alto':bp.colesterolT>=200?'Limítrofe':'Deseable'} mg/dL
` : ''} ${bp.hdl ? `
HDL
${bp.hdl}
mg/dL
` : ''} ${bp.ldl ? `
LDL
${bp.ldl}
mg/dL
` : ''} ${bp.trigliceridos ? `
Triglicéridos
${bp.trigliceridos}
mg/dL
` : ''}
` : ''}
${t('sec.alerts')}
${alerts.map(a => `
${a.icon}
${escapeHtml(a.title)}
${escapeHtml(a.text)}
`).join('')}
📋 Conclusiones Profesionales
${conclusions.map(c => `

${escapeHtml(c)}

`).join('')}

⚖️ Este informe es de naturaleza antropométrica y no constituye diagnóstico médico. Las conclusiones son orientativas y deben ser interpretadas por el profesional tratante en el contexto clínico individual.

${referrals.length ? `
🏥 Recomendaciones de Derivación Profesional
${referrals.map(r => `
${r.urgency==='priority'?'⚠️':'📨'}
${escapeHtml(r.to)} ${r.urgency==='priority'?'· PRIORITARIO':'· RECOMENDADO'}
${escapeHtml(r.reason)}
`).join('')}
` : `
✅ Sin necesidad de derivación profesional adicional según los datos evaluados.
`} `; document.getElementById('advanced-results').innerHTML = html; // Activate indices first tab + animate counters again (for the new bento) setTimeout(() => { setIdxTab('imo'); animateAllCounters(); }, 50); } // Indices tab switcher function setIdxTab(key) { if (!window._indices6 || !window._indices6[key]) return; document.querySelectorAll('.idx-tab').forEach(el => el.classList.toggle('active', el.dataset.idx === key)); const i = window._indices6[key]; const panel = document.getElementById('idx-panel'); if (!panel) return; // Re-trigger pop animation panel.classList.remove('pop'); void panel.offsetWidth; panel.classList.add('pop'); panel.innerHTML = `
${escapeHtml(i.label)} · ${key.toUpperCase()}
${i.val.toFixed(2)}
${escapeHtml(i.classify)}
${escapeHtml(i.interp)}
📊 ${escapeHtml(i.ref)}
`; } // ════════════════════════════════════════════════════════════ // CLASSIFICATIONS // ════════════════════════════════════════════════════════════ function clasificarGrasa(pct, sexo) { const m = sexo === 'masculino'; if (pct < (m ? 6 : 14)) return [t('norm.essential'),'bg-amber']; if (pct < (m ? 14 : 21)) return [t('norm.athletic'),'bg-blue']; if (pct < (m ? 18 : 25)) return [t('norm.fitness'),'bg-green']; if (pct < (m ? 25 : 32)) return [t('norm.acceptable'),'bg-amber']; return [t('norm.obese'),'bg-red']; } function calcSomatotype(pctAdip, pctMusc, sexo) { const highFat = sexo === 'masculino' ? pctAdip > 22 : pctAdip > 28; const highMusc = pctMusc > 46; const lowFat = sexo === 'masculino' ? pctAdip < 11 : pctAdip < 18; if (highFat && !highMusc) return { icon:'🟠', name:t('soma.endo'), desc:t('soma.endoDesc') }; if (highMusc && !highFat) return { icon:'🔵', name:t('soma.meso'), desc:t('soma.mesoDesc') }; if (lowFat && !highMusc) return { icon:'🟡', name:t('soma.ecto'), desc:t('soma.ectoDesc') }; return { icon:'🟢', name:t('soma.inter'), desc:t('soma.interDesc') }; } // ════════════════════════════════════════════════════════════ // ISAK — Heath-Carter Somatotype (3 components) // ════════════════════════════════════════════════════════════ function heathCarter(M) { // M is the measurements object: triceps, subescapular, supraespinal, // abdominal, pliegueMuslo, plieguePierna, brazoFlex, pierna, humero, // femur, peso, talla const stature = M.talla; const corr = 170.18 / stature; // Endomorphy const X = (M.triceps + M.subescapular + M.supraespinal) * corr; const endo = -0.7182 + 0.1451*X - 0.00068*X*X + 0.0000014*X*X*X; // Mesomorphy const cArm = M.brazoFlex - (M.triceps / 10); const cCalf = M.pierna - (M.plieguePierna / 10); const meso = 0.858*M.humero + 0.601*M.femur + 0.188*cArm + 0.161*cCalf - 0.131*stature + 4.5; // Ectomorphy via HWR const hwr = stature / Math.pow(M.peso, 1/3); let ecto; if (hwr >= 40.75) ecto = 0.732 * hwr - 28.58; else if (hwr >= 38.25) ecto = 0.463 * hwr - 17.63; else ecto = 0.1; return { endo: Math.max(0.1, endo), meso: Math.max(0.1, meso), ecto: Math.max(0.1, ecto), hwr }; } function somatoInterpretation(comp, value) { // comp: 'endo'|'meso'|'ecto', value: number const lvl = value < 2.5 ? 'low' : value < 5 ? 'mid' : value < 7 ? 'high' : 'veryhigh'; const map = { endo: { low: 'Low relative fatness; little subcutaneous fat.', mid: 'Moderate relative fatness; average subcutaneous fat.', high: 'High relative fatness; thick subcutaneous fat; increased storage of fat in abdomen.', veryhigh: 'Extremely high relative fatness; very thick subcutaneous fat.' }, meso: { low: 'Low relative musculo-skeletal development; thin muscles; narrow skeleton.', mid: 'Moderate musculo-skeletal development; average muscle bulk and bone size.', high: 'High relative musculo-skeletal development; bulky muscles; wide skeleton and joints.', veryhigh: 'Extremely high relative musculo-skeletal development; very bulky muscles; very wide skeleton and joints.' }, ecto: { low: 'Low relative linearity; great bulk per unit of height; round like a ball; relatively bulky limbs.', mid: 'Moderate relative linearity; balanced ratio between height and bulk.', high: 'High relative linearity; long thin limbs; lean trunk; little subcutaneous tissue.', veryhigh: 'Extremely linear; long fragile limbs; very low bulk per unit of height.' } }; return map[comp][lvl]; } // ════════════════════════════════════════════════════════════ // ISAK — Durnin-Womersley body fat (1974) // ════════════════════════════════════════════════════════════ function durninWomersley(M, sexo, edad) { // Sum of 4: triceps + biceps + subscapular + suprailiac (iliac crest) const sum4 = M.triceps + M.biceps + M.subescapular + M.crestaIliaca; if (sum4 <= 0) return { density: 0, fatPct: 0, fatMass: 0, ffMass: M.peso }; const log = Math.log10(sum4); // Coefficients by age & sex (Durnin & Womersley 1974, Br J Nutr) const COEF = { masculino: [ { max: 19, c: 1.1620, m: 0.0630 }, { max: 29, c: 1.1631, m: 0.0632 }, { max: 39, c: 1.1422, m: 0.0544 }, { max: 49, c: 1.1620, m: 0.0700 }, { max: 200, c: 1.1715, m: 0.0779 } ], femenino: [ { max: 19, c: 1.1549, m: 0.0678 }, { max: 29, c: 1.1599, m: 0.0717 }, { max: 39, c: 1.1423, m: 0.0632 }, { max: 49, c: 1.1333, m: 0.0612 }, { max: 200, c: 1.1339, m: 0.0645 } ] }; const tbl = COEF[sexo] || COEF.masculino; const row = tbl.find(r => edad <= r.max) || tbl[tbl.length-1]; const density = row.c - row.m * log; const fatPct = (4.95 / density - 4.5) * 100; const fatMass = (fatPct / 100) * M.peso; return { density, fatPct, fatMass, ffMass: M.peso - fatMass }; } // ════════════════════════════════════════════════════════════ // ISAK — Health, proportionality and body comp indices // ════════════════════════════════════════════════════════════ function healthIndices(M, sexo) { const peso = M.peso, talla = M.talla; const cintura = M.cintura, cadera = M.cadera; const imc = peso / Math.pow(talla/100, 2); const whr = cadera ? cintura / cadera : 0; const whtr = talla ? cintura / talla : 0; // Conicity index (Valdez 1991): waist(m) / (0.109 × √(weight/height(m))) const ci = (cintura/100) / (0.109 * Math.sqrt(peso / (talla/100))); // Fat distribution index = limbs / trunk (skinfolds) const limbs = M.triceps + M.pliegueMuslo + M.plieguePierna; const trunk = M.subescapular + M.supraespinal + M.abdominal; const fdi = trunk > 0 ? limbs / trunk : 0; // BMI classification let bmiCls; if (imc < 18.5) bmiCls = { txt: 'Underweight', light: 'amber' }; else if (imc < 25) bmiCls = { txt: 'Normal', light: 'green' }; else if (imc < 30) bmiCls = { txt: 'Overweight', light: 'amber' }; else if (imc < 35) bmiCls = { txt: 'Grade 1 Obesity', light: 'red' }; else if (imc < 40) bmiCls = { txt: 'Grade 2 Obesity', light: 'red' }; else bmiCls = { txt: 'Grade 3 Obesity', light: 'red' }; // WHR classification (sex-specific) const whrLimit = sexo === 'masculino' ? 1.00 : 0.85; const whrCls = whr === 0 ? null : { txt: whr < whrLimit ? 'Low risk of developing chronic diseases' : 'High risk of developing chronic diseases', light: whr < whrLimit ? 'green' : 'red' }; // WHtR const whtrCls = whtr === 0 ? null : { txt: whtr < 0.5 ? 'Low risk of developing chronic diseases' : 'High risk of developing chronic diseases', light: whtr < 0.5 ? 'green' : 'red' }; // Conicity const ciLimit = sexo === 'masculino' ? 1.25 : 1.18; const ciCls = !cintura ? null : { txt: ci < ciLimit ? 'Low cardiovascular risk' : 'High cardiovascular risk', light: ci < ciLimit ? 'green' : 'amber' }; return { imc, whr, whtr, ci, fdi, limbs, trunk, limbsPct: (limbs+trunk)>0 ? (limbs/(limbs+trunk))*100 : 0, trunkPct: (limbs+trunk)>0 ? (trunk/(limbs+trunk))*100 : 0, bmiCls, whrCls, whtrCls, ciCls, whrLimit, ciLimit }; } function bodyCompIndices(tissues, M, sexo) { const ami = tissues.muscle ? tissues.adipose / tissues.muscle : 0; const mbi = tissues.bone ? tissues.muscle / tissues.bone : 0; // AMI classification let amiCls; if (ami < 0.4) amiCls = 'Low'; else if (ami < 0.7) amiCls = 'Normal'; else if (ami < 1.0) amiCls = 'High'; else amiCls = 'Very high'; // MBI classification (rough) let mbiCls; if (mbi < 3.5) mbiCls = 'Low'; else if (mbi < 5.5) mbiCls = 'Normal'; else mbiCls = 'High'; return { ami, amiCls, mbi, mbiCls }; } function proportionalityIndices(M, sexo) { const cormic = M.estaturaSentado && M.talla ? (M.estaturaSentado / M.talla) * 100 : 0; const acIliac = M.biacromial ? (M.biiliocrestal / M.biacromial) * 100 : 0; let cormicCls; if (cormic < 51) cormicCls = 'Long-legged trunk'; else if (cormic < 53) cormicCls = 'Proportionate trunk at shoulders and hips'; else cormicCls = 'Short-legged trunk'; // Acromio-iliac classification (sex-specific approximate) let aiCls; const lo = sexo === 'masculino' ? 70 : 73; const hi = sexo === 'masculino' ? 75 : 78; if (acIliac < lo) aiCls = 'Hips proportionally narrower than shoulders'; else if (acIliac < hi) aiCls = 'Proportionate at shoulders and hips'; else aiCls = 'Hips proportionally wider than shoulders'; return { cormic, cormicCls, acIliac, aiCls }; } // ════════════════════════════════════════════════════════════ // ADVANCED MODULES — energetics, multi-formula BF, goals, athletes // ════════════════════════════════════════════════════════════ // BMR / TDEE — 8 fórmulas (paridad con 5componentes) function energyExpenditure(M, sexo, edad, ffMass) { const W = M.peso, H = M.talla, A = edad, m = sexo==='masculino'; // 1. Mifflin-St Jeor (1990) const mifflin = m ? 10*W + 6.25*H - 5*A + 5 : 10*W + 6.25*H - 5*A - 161; // 2. Harris-Benedict revisado (Roza 1984) const harris = m ? 88.362 + 13.397*W + 4.799*H - 5.677*A : 447.593 + 9.247*W + 3.098*H - 4.330*A; // 3. Katch-McArdle (usa FFM, ideal para atletas) const katch = ffMass > 0 ? 370 + 21.6 * ffMass : null; // 4. Cunningham (usa FFM, también muy usada en atletas) const cunningham = ffMass > 0 ? 500 + 22 * ffMass : null; // 5. Oxford / Henry (2005) — por rangos etarios let oxford; if (m) { if (A < 18) oxford = 16.25*W + 137.2*H/100 + 515.5; else if (A < 30) oxford = 14.4*W + 313*H/100 + 113; else if (A < 60) oxford = 11.4*W + 541*H/100 - 137; else oxford = 11.4*W + 541*H/100 - 256; } else { if (A < 18) oxford = 8.365*W + 465*H/100 + 200; else if (A < 30) oxford = 10.4*W + 615*H/100 - 282; else if (A < 60) oxford = 8.18*W + 502*H/100 - 11.6; else oxford = 8.52*W + 421*H/100 + 10.7; } // 6. FAO/OMS (1985) let faoOms; if (m) { if (A < 18) faoOms = 17.5*W + 651; else if (A < 30) faoOms = 15.3*W + 679; else if (A < 60) faoOms = 11.6*W + 879; else faoOms = 13.5*W + 487; } else { if (A < 18) faoOms = 12.2*W + 746; else if (A < 30) faoOms = 14.7*W + 496; else if (A < 60) faoOms = 8.7*W + 829; else faoOms = 10.5*W + 596; } // 7. Valencia (Carrasco 2002) — población chilena let valencia; if (m) { if (A < 30) valencia = 15.3*W + 679; else if (A < 60) valencia = 11.6*W + 879; else valencia = 13.5*W + 487; } else { if (A < 30) valencia = 14.7*W + 496; else if (A < 60) valencia = 8.7*W + 829; else valencia = 10.5*W + 596; } // 8. Owen (1986/1987) const owen = m ? 879 + 10.2 * W : 795 + 7.18 * W; const list = [ { name: 'Mifflin-St Jeor', val: mifflin, color:'#0090CC' }, { name: 'Harris-Benedict', val: harris, color:'#7c3aed' }, { name: 'Katch-McArdle', val: katch, color:'#16a34a' }, { name: 'Cunningham', val: cunningham, color:'#f97316' }, { name: 'Oxford / Henry', val: oxford, color:'#dc2626' }, { name: 'FAO/OMS', val: faoOms, color:'#0e7490' }, { name: 'Valencia (Chile)', val: valencia, color:'#be185d' }, { name: 'Owen', val: owen, color:'#a16207' } ]; const vals = list.map(x => x.val).filter(v => v != null && isFinite(v) && v > 0); const avg = vals.length ? vals.reduce((a,b)=>a+b,0) / vals.length : 0; return { list, avg, bmrMifflin: mifflin, // backwards-compat aliases bmrHarris: harris, bmrCunningham: cunningham, bmrAvg: avg }; } // ════════════════════════════════════════════════════════════ // Total Body Water (TBW) — 5 fórmulas // ════════════════════════════════════════════════════════════ function multiTbwFormulas(M, sexo, edad) { if (!M.peso || !M.talla) return null; const W = M.peso, H = M.talla, A = edad, m = sexo==='masculino'; // Watson (1980) const watson = m ? 2.447 - 0.09516*A + 0.1074*H + 0.3362*W : -2.097 + 0.1069*H + 0.2466*W; // Hume-Weyers (1971) const hume = m ? 0.194786*H + 0.296785*W - 14.012934 : 0.34454*H + 0.183809*W - 35.270121; // Chertow (1997) — diálisis general const chertow = m ? -28.3497 + 0.1844*H + 0.3232*W - 0.0681*A + 0.0006 * (W*W) : -26.3933 + 0.2862*W + 0.1715*H - 0.0378*A + 0.0007 * (W*W); // Chertow (1997) - diabetes adjustment const chertowDb = chertow - 1.07; // Promedio 3 fórmulas (Watson + Hume + Chertow) const mean3 = (watson + hume + chertow) / 3; const list = [ { name: 'Watson (1980)', val: watson, color:'#a16207' }, { name: 'Hume-Weyers (1971)', val: hume, color:'#f97316' }, { name: 'Chertow (1997)', val: chertow, color:'#16a34a' }, { name: 'Chertow (diabetes)', val: chertowDb, color:'#eab308' }, { name: 'Media 3 fórmulas', val: mean3, color:'#0090CC' } ]; return { list, pctList: list.map(x => ({ ...x, pct: (x.val / W) * 100 })), avgL: mean3, avgPct: (mean3 / W) * 100, // OMS recommended water intake: 35 mL/kg base, athletes 40-45 intakeOMS_ml: Math.round(W * 35), intakeAthlete_ml: Math.round(W * 45) }; } // ════════════════════════════════════════════════════════════ // Body Composition Indices (IMO, ICA, ISA, IMG, IMM, IAM) // Paridad con 5componentes // ════════════════════════════════════════════════════════════ function bodyCompositionIndices6(M, tissues, dwResult, leeResult) { const W = M.peso, H = M.talla; const htM = H / 100; const sum6 = M.triceps + M.subescapular + M.supraespinal + M.abdominal + M.pliegueMuslo + M.plieguePierna; // IMO - Índice Músculo-Óseo (kg musc / kg ósea) const imo = tissues.bone ? tissues.muscle / tissues.bone : 0; // ICA - Índice Cintura-Altura (cintura/talla en cm) const ica = H && M.cintura ? M.cintura / H : 0; // ISA - Índice Sumatoria/Altura (6 pliegues en mm / talla en cm × 100) const isa = H ? (sum6 / H) * 100 : 0; // IMG - Índice de Masa Grasa (kg grasa / m²) const fatMass = dwResult ? dwResult.fatMass : tissues.adipose; const img = htM ? fatMass / (htM*htM) : 0; // IMM - Índice de Masa Muscular esquelética (Lee SMM o KERR muscular / m²) const muscleKg = leeResult ? leeResult.smm : tissues.muscle; const imm = htM ? muscleKg / (htM*htM) : 0; // IAM - Índice Adipose-Muscular (kg grasa / kg músculo) const iam = tissues.muscle ? tissues.adipose / tissues.muscle : 0; return { imo: { val: imo, label: 'Índice Músculo-Óseo (IMO)', interp: 'kg de masa muscular por cada kg de masa ósea. Indica cuánto músculo soporta el esqueleto.', classify: imo < 3.8 ? 'Bajo' : imo < 4.9 ? 'Normal' : 'Alto', ref: 'ARGOREF: bajo <3.8 · normal 3.8-4.9 · alto >4.9' }, ica: { val: ica, label: 'Índice Cintura-Altura (ICA)', interp: 'Riesgo cardiometabólico abdominal. Valor único para ambos sexos.', classify: ica < 0.5 ? 'Saludable' : ica < 0.6 ? 'Sobrepeso/riesgo' : 'Riesgo elevado', ref: 'Saludable <0.50 · Riesgo 0.50-0.60 · Elevado >0.60' }, isa: { val: isa, label: 'Índice Sumatoria-Altura (ISA)', interp: 'Suma de 6 pliegues normalizada por talla. Indicador de adiposidad relativa.', classify: isa < 50 ? 'Bajo' : isa < 100 ? 'Normal' : 'Alto', ref: 'Bajo <50 · Normal 50-100 · Alto >100' }, img: { val: img, label: 'Índice de Masa Grasa (IMG)', interp: 'kg de grasa por m². Análogo al IMC pero específico para grasa.', classify: img < 4 ? 'Bajo' : img < 8 ? 'Normal' : img < 12 ? 'Sobrepeso' : 'Obesidad', ref: 'Bajo <4 · Normal 4-8 · Sobrepeso 8-12 · Obesidad >12' }, imm: { val: imm, label: 'Índice de Masa Muscular (IMM)', interp: 'kg de músculo esquelético por m² (Lee). Detecta sarcopenia.', classify: imm < 6.76 ? 'Bajo / sarcopenia' : imm < 8.5 ? 'Normal' : 'Alto', ref: 'Sarcopenia <6.76 · Normal 6.76-8.5 · Alto >8.5 (mujeres; ajustar +30% en hombres)' }, iam: { val: iam, label: 'Índice Adipose-Muscular (IAM)', interp: 'kg grasa que cada kg de músculo debe transportar. Menor = mejor eficiencia.', classify: iam < 0.4 ? 'Bajo' : iam < 0.7 ? 'Normal' : iam < 1.0 ? 'Alto' : 'Muy alto', ref: 'Bajo <0.4 · Normal 0.4-0.7 · Alto 0.7-1.0 · Muy alto >1.0' } }; } function tdeeAndMacros(bmr, activityFactor, goalPct, weight, goalType) { const tdee = bmr * activityFactor; const target = tdee * (1 + goalPct/100); // Macro split by goal let pPct, cPct, fPct; if (goalPct < -10) { pPct=40; cPct=35; fPct=25; } // aggressive loss else if (goalPct < 0) { pPct=35; cPct=40; fPct=25; } // moderate loss else if (goalPct === 0) { pPct=25; cPct=50; fPct=25; } // maintenance else { pPct=30; cPct=50; fPct=20; } // gain const protG = (target * pPct/100) / 4; const carbG = (target * cPct/100) / 4; const fatG = (target * fPct/100) / 9; return { tdee, target, protein: { pct: pPct, kcal: target*pPct/100, grams: protG, gPerKg: protG/weight }, carbs: { pct: cPct, kcal: target*cPct/100, grams: carbG, gPerKg: carbG/weight }, fat: { pct: fPct, kcal: target*fPct/100, grams: fatG, gPerKg: fatG/weight }, waterMl: Math.round(weight * 35) // base hydration target }; } // ════════════════════════════════════════════════════════════ // Lee et al. (2000) — Skeletal Muscle Mass (SMM) via anthropometry // Validated vs MRI in non-obese adults. Uses ethnicity adjustment. // ════════════════════════════════════════════════════════════ function leeSMM(M, sexo, edad, etnia) { if (!M.peso || !M.talla || !M.brazo || !M.musloG || !M.pierna) return null; const PI = Math.PI; const cArm = M.brazo - (PI * M.triceps / 10); const cThigh = M.musloG - (PI * M.pliegueMuslo / 10); const cCalf = M.pierna - (PI * M.plieguePierna / 10); const htM = M.talla / 100; const sex = sexo === 'masculino' ? 1 : 0; let race = 0; if (etnia === 'asian') race = -2.0; else if (etnia === 'african') race = 1.1; // SM (kg) = Ht × (0.00744·CAG² + 0.00088·CTG² + 0.00441·CCG²) + 2.4·Sex − 0.048·Age + Race + 7.8 const smm = htM * (0.00744 * cArm * cArm + 0.00088 * cThigh * cThigh + 0.00441 * cCalf * cCalf) + 2.4 * sex - 0.048 * edad + race + 7.8; // Sanity bounds if (!isFinite(smm) || smm <= 0 || smm > 100) return null; return { smm, smmPct: (smm / M.peso) * 100, smi: smm / (htM * htM), // Skeletal Muscle Mass Index (Janssen 2002) components: { cArm, cThigh, cCalf }, classification: classifySMI(smm / (htM * htM), sexo) }; } function classifySMI(smi, sexo) { // Janssen (2002) cutoffs for sarcopenia const m = sexo === 'masculino'; if (smi >= (m ? 10.76 : 6.76)) return { label: 'Normal', light: 'green' }; if (smi >= (m ? 8.51 : 5.76)) return { label: 'Sarcopenia clase I', light: 'amber' }; return { label: 'Sarcopenia clase II', light: 'red' }; } // ════════════════════════════════════════════════════════════ // Body Surface Area (DuBois 1916) // ════════════════════════════════════════════════════════════ function bodySurfaceArea(W, H) { if (!W || !H) return 0; return 0.007184 * Math.pow(W, 0.425) * Math.pow(H, 0.725); } // ════════════════════════════════════════════════════════════ // Sum of 7 skinfolds (Jackson-Pollock) // ════════════════════════════════════════════════════════════ function sum7Skinfolds(M) { // Classic JP7: chest, axillary, triceps, subscapular, abdominal, suprailiac, thigh // We don't have chest skinfold — use the sum of available 7 closest sites return M.triceps + M.subescapular + M.supraespinal + M.abdominal + M.pliegueMuslo + (M.axilarMedial || M.plieguePierna) + (M.crestaIliaca || M.biceps); } // ════════════════════════════════════════════════════════════ // 2-Component model (Siri / Brozek from body density) // ════════════════════════════════════════════════════════════ function twoComponentModel(M, dwResult) { if (!dwResult) return null; const fatMass = dwResult.fatMass; const ffMass = M.peso - fatMass; return { fatMass, ffMass, fatPct: (fatMass / M.peso) * 100, ffPct: (ffMass / M.peso) * 100, density: dwResult.density, formula: 'Siri 1961 / Durnin-Womersley 1974' }; } // ════════════════════════════════════════════════════════════ // 4-Component model (simplified: water + bone mineral + fat + residual) // TBW via Watson 1980 equation // ════════════════════════════════════════════════════════════ function fourComponentModel(M, sexo, edad, dwResult, tissues) { if (!dwResult || !tissues) return null; const W = M.peso, H = M.talla; // Watson TBW (L) let tbw; if (sexo === 'masculino') tbw = 2.447 - 0.09516*edad + 0.1074*H + 0.3362*W; else tbw = -2.097 + 0.1069*H + 0.2466*W; // Bone mineral ≈ 50% of osseous tissue (Kerr) const bmc = tissues.bone * 0.5; // Fat from DW const fat = dwResult.fatMass; // Protein (residual) = weight − fat − water − bone mineral const protein = W - fat - tbw - bmc; return { fat, water: tbw, bone: bmc, protein, fatPct: (fat/W)*100, waterPct: (tbw/W)*100, bonePct: (bmc/W)*100, proteinPct: (protein/W)*100, formula: 'Watson 1980 + Kerr 1991 + DW 1974' }; } // ════════════════════════════════════════════════════════════ // IMC DIAL GAUGE (semicircular, color-zone SVG) // ════════════════════════════════════════════════════════════ function imcDialSvg(imc) { // Map imc 10..50 → angle -90..+90 deg (semicircle) const min = 10, max = 50; const v = Math.max(min, Math.min(max, imc)); const angle = (-90 + ((v - min) / (max - min)) * 180); // degrees const rad = angle * Math.PI / 180; // Needle tip at radius 75 from center (100,100) const cx = 100, cy = 100, r = 75; const nx = cx + r * Math.cos(rad - Math.PI/2); const ny = cy + r * Math.sin(rad - Math.PI/2); // Helper: polar arc segment const arc = (startV, endV, color) => { const a1 = (-90 + ((startV - min) / (max - min)) * 180) * Math.PI / 180; const a2 = (-90 + ((endV - min) / (max - min)) * 180) * Math.PI / 180; const x1 = cx + 85 * Math.cos(a1 - Math.PI/2); const y1 = cy + 85 * Math.sin(a1 - Math.PI/2); const x2 = cx + 85 * Math.cos(a2 - Math.PI/2); const y2 = cy + 85 * Math.sin(a2 - Math.PI/2); const large = (endV - startV) / (max - min) * 180 > 180 ? 1 : 0; return ``; }; // Zones (BMI ISO) const zones = arc(10, 18.5, '#3b82f6') // bajo peso (azul) + arc(18.5, 25, '#16a34a') // normal (verde) + arc(25, 30, '#eab308') // sobrepeso (amarillo) + arc(30, 35, '#f97316') // obesidad 1 (naranja) + arc(35, 40, '#dc2626') // obesidad 2 (rojo) + arc(40, 50, '#7f1d1d'); // obesidad 3 (rojo oscuro) // Labels at zone centers const zoneLabel = (v, txt) => { const a = (-90 + ((v - min) / (max - min)) * 180) * Math.PI / 180; const lx = cx + 102 * Math.cos(a - Math.PI/2); const ly = cy + 102 * Math.sin(a - Math.PI/2); return `${txt}`; }; return ` ${zones} ${zoneLabel(14, '<18.5')} ${zoneLabel(21.5, '18.5-25')} ${zoneLabel(27.5, '25-30')} ${zoneLabel(32.5, '30-35')} ${zoneLabel(37.5, '35-40')} ${zoneLabel(45, '>40')} ${imc.toFixed(1)} IMC kg/m² `; } // ════════════════════════════════════════════════════════════ // ANIMATED COUNTER (smooth count-up effect for dopamine UI) // ════════════════════════════════════════════════════════════ function animateCounter(el, target, opts = {}) { if (!el) return; const duration = opts.duration || 1200; const decimals = opts.decimals != null ? opts.decimals : 0; const prefix = opts.prefix || ''; const suffix = opts.suffix || ''; const startV = 0; const startT = performance.now(); function tick(now) { const elapsed = now - startT; const progress = Math.min(elapsed / duration, 1); // ease-out cubic const eased = 1 - Math.pow(1 - progress, 3); const v = startV + (target - startV) * eased; el.textContent = prefix + v.toFixed(decimals) + suffix; if (progress < 1) requestAnimationFrame(tick); else el.textContent = prefix + target.toFixed(decimals) + suffix; } requestAnimationFrame(tick); } function animateAllCounters() { document.querySelectorAll('[data-counter]').forEach(el => { const target = parseFloat(el.dataset.counter); if (isNaN(target)) return; const decimals = parseInt(el.dataset.decimals || '0'); const prefix = el.dataset.prefix || ''; const suffix = el.dataset.suffix || ''; animateCounter(el, target, { decimals, prefix, suffix }); }); } // Multiple body fat formulas function multipleBfFormulas(M, sexo, edad, dwResult) { const W = M.peso, T = M.talla; const sum6 = M.triceps + M.subescapular + M.supraespinal + M.abdominal + M.pliegueMuslo + M.plieguePierna; const sum8 = sum6 + M.biceps + M.crestaIliaca; const m = sexo === 'masculino'; // Yuhasz (2 sites? actually 6-skinfold: tri+subscap+suprasp+abd+thigh+calf) const yuhasz = m ? (sum6 * 0.097 + 3.64) : (sum6 * 0.143 + 4.56); // Faulkner (4 skinfolds: triceps + subscap + suprailiac + abdominal) const sum4F = M.triceps + M.subescapular + M.crestaIliaca + M.abdominal; const faulkner = sum4F * 0.153 + 5.783; // Carter (modified Yuhasz, 6 skinfolds with stretch correction) const corr = 170.18 / T; const carter = m ? (sum6 * corr) * 0.1051 + 2.585 : (sum6 * corr) * 0.1545 + 3.580; // Jackson-Pollock 7-site (chest, abdomen, thigh, triceps, subscap, suprailiac, axilla approx) // We don't have chest/axilla. Use 3-site instead: // Men: chest, abdomen, thigh — we use abdominal + pliegueMuslo + supraespinal (proxy) // Women: triceps, suprailiac, thigh const sum3JP = m ? (M.abdominal + M.pliegueMuslo + M.supraespinal) : (M.triceps + M.crestaIliaca + M.pliegueMuslo); const dJP = m ? 1.10938 - 0.0008267*sum3JP + 0.0000016*sum3JP*sum3JP - 0.0002574*edad : 1.0994921 - 0.0009929*sum3JP + 0.0000023*sum3JP*sum3JP - 0.0001392*edad; const jacksonPollock = (4.95/dJP - 4.5) * 100; // Slaughter (ages 8-18, triceps + calf) const slaughter = edad >= 8 && edad <= 18 ? (m ? 0.735*(M.triceps+M.plieguePierna)+1.0 : 0.610*(M.triceps+M.plieguePierna)+5.1) : null; // Rocha (Brazilian, kerr-tissue derived) — using kerr adipose // Fallback: use durnin-womersley as canonical const dw = dwResult ? dwResult.fatPct : 0; return [ { name: 'Durnin-Womersley (1974)', sites: '4 pliegues', val: dw, grams: (dw/100)*W, ref: true }, { name: 'Yuhasz (1974)', sites: '6 pliegues', val: yuhasz, grams: (yuhasz/100)*W }, { name: 'Faulkner (1968)', sites: '4 pliegues', val: faulkner, grams: (faulkner/100)*W }, { name: 'Carter (1980)', sites: '6 pliegues', val: carter, grams: (carter/100)*W }, { name: 'Jackson-Pollock (1978)', sites: '3 pliegues', val: jacksonPollock,grams: (jacksonPollock/100)*W }, slaughter != null ? { name: 'Slaughter (1988) [niños/adolescentes]', sites: 'tríceps+pierna', val: slaughter, grams: (slaughter/100)*W } : null ].filter(Boolean); } // Hydration analysis function hydrationStatus(urineColor, density, weight) { let level = 'unknown', light = 'amber', txt = 'Sin datos suficientes'; let recommendations = []; let score = 0; if (urineColor > 0) { if (urineColor <= 3) { level='Bien hidratado'; light='green'; score+=2; } else if (urineColor <= 5) { level='Hidratación leve'; light='amber'; score+=0; } else { level='Deshidratación'; light='red'; score-=2; } txt = `Color de orina ${urineColor}/8 (Armstrong) → ${level}`; } if (density > 0) { let dLevel; if (density < 1.020) { dLevel='Normal'; light = light==='red'?'red':'green'; score+=1; } else if (density < 1.030) { dLevel='Deshidratación leve'; light = light==='red'?'red':'amber'; score-=1; } else { dLevel='Deshidratación severa'; light='red'; score-=2; } txt += `${urineColor>0?' · ':''}Densidad ${density.toFixed(3)} g/mL → ${dLevel}`; } // Recommendations const baseMl = Math.round(weight * 35); recommendations.push(`💧 Ingesta basal recomendada: ${baseMl} mL/día (35 mL/kg)`); if (score < 0) recommendations.push(`⚠️ Aumentar ingesta a ${Math.round(weight*45)} mL/día las próximas 24-48h`); if (urineColor >= 6 || density >= 1.030) recommendations.push(`🥤 Considerar bebida con electrolitos (Na 0.5-1.0 g/L)`); if (urineColor === 0 && !density) { recommendations = [`💧 Ingesta basal recomendada: ${baseMl} mL/día (35 mL/kg)`, `📊 Para evaluar hidratación funcional: registra color de orina o densidad`]; light = 'info'; level = 'Sin medición'; txt = '—'; } return { level, light, txt, recommendations, score }; } // Athlete somatotype reference (Carter & Heath data) const ATHLETE_REFS = [ // [endo, meso, ecto] elite means { sport: 'Fútbol', icon: '⚽', somat: [2.5, 5.0, 2.5] }, { sport: 'Sprinter 100m', icon: '🏃', somat: [2.0, 5.0, 3.0] }, { sport: 'Maratón', icon: '🏃‍♂️', somat: [1.5, 4.0, 3.5] }, { sport: 'Boxeo pesado', icon: '🥊', somat: [3.0, 6.5, 1.5] }, { sport: 'Físicoculturismo',icon:'💪', somat: [2.0, 7.5, 1.0] }, { sport: 'Natación', icon: '🏊', somat: [2.0, 5.0, 2.5] }, { sport: 'Tenis', icon: '🎾', somat: [2.5, 5.0, 2.5] }, { sport: 'Básquetbol', icon: '🏀', somat: [2.5, 4.5, 3.0] }, { sport: 'Rugby forward', icon: '🏉', somat: [3.5, 6.0, 1.5] }, { sport: 'Voleibol', icon: '🏐', somat: [2.5, 4.5, 3.0] }, { sport: 'Crossfit', icon: '🏋️', somat: [2.5, 6.5, 2.0] }, { sport: 'Ciclismo', icon: '🚴', somat: [2.0, 4.5, 3.0] }, { sport: 'Halterofilia', icon: '🏋️‍♂️', somat: [3.0, 7.0, 1.0] }, { sport: 'Gimnasia', icon: '🤸', somat: [1.5, 5.5, 2.5] } ]; function athleteDistance(somatA, somatB) { // Somatotype distance (SAD - Somatotype Attitudinal Distance) return Math.sqrt( Math.pow(somatA[0]-somatB[0], 2) + Math.pow(somatA[1]-somatB[1], 2) + Math.pow(somatA[2]-somatB[2], 2) ); } function findClosestAthletes(somato) { const me = [somato.endo, somato.meso, somato.ecto]; return ATHLETE_REFS .map(a => ({ ...a, distance: athleteDistance(me, a.somat) })) .sort((a,b) => a.distance - b.distance); } // Goal projection function goalProjection(M, sexo, currentBfPct, targetBfPct, tdee, targetCalories) { const W = M.peso; const ffm = W * (1 - currentBfPct/100); // Target weight assuming FFM remains constant const targetWeight = ffm / (1 - targetBfPct/100); const deltaWeight = targetWeight - W; // Caloric deficit/surplus const dailyDelta = targetCalories - tdee; // 1 kg fat ≈ 7700 kcal const weeksToGoal = dailyDelta !== 0 ? Math.abs(deltaWeight) / (Math.abs(dailyDelta) * 7 / 7700) : 0; const direction = deltaWeight < 0 ? 'loss' : 'gain'; // Sustainable rate check const weeklyKg = (Math.abs(dailyDelta) * 7) / 7700; let sustainability = 'optimal'; if (weeklyKg > 1.0) sustainability = 'aggressive'; else if (weeklyKg < 0.25) sustainability = 'slow'; return { currentWeight: W, ffm, targetWeight, deltaWeight, dailyDelta, weeksToGoal, direction, weeklyKg, sustainability, targetDate: weeksToGoal > 0 ? new Date(Date.now() + weeksToGoal*7*86400000) : null }; } // Risk alerts function generateAlerts(M, sexo, edad, hi, dwPct, tissues, dist, somato) { const alerts = []; // BMI risks if (hi.imc < 18.5) alerts.push({ level:'warning', icon:'⚠️', title:'IMC bajo', text:`IMC ${hi.imc.toFixed(1)} indica bajo peso. Evaluar ingesta calórica y descartar patologías.` }); if (hi.imc >= 30) alerts.push({ level:'critical', icon:'🚨', title:'Obesidad', text:`IMC ${hi.imc.toFixed(1)} indica obesidad ${hi.imc>=40?'grado 3':hi.imc>=35?'grado 2':'grado 1'}. Riesgo cardiometabólico aumentado.` }); // WHtR if (hi.whtr >= 0.5) alerts.push({ level:'warning', icon:'📏', title:'Cintura/Talla elevada', text:`WHtR ${hi.whtr.toFixed(2)} ≥ 0.5: mayor riesgo metabólico. Recomendable reducir grasa abdominal.` }); // WHR if (hi.whr >= hi.whrLimit) alerts.push({ level:'warning', icon:'📐', title:'Distribución androide', text:`WHR ${hi.whr.toFixed(2)} sobre el límite (${hi.whrLimit}). Acumulación abdominal.` }); // Fat % extremes const m = sexo === 'masculino'; const minHealthyBf = m ? 6 : 14; const obeseBf = m ? 25 : 32; if (dwPct < minHealthyBf) alerts.push({ level:'critical', icon:'🚨', title:'% graso esencial', text:`% graso ${dwPct.toFixed(1)}% bajo el mínimo saludable (${minHealthyBf}%). Riesgo hormonal/inmune.` }); if (dwPct > obeseBf) alerts.push({ level:'critical', icon:'🚨', title:'% graso elevado', text:`% graso ${dwPct.toFixed(1)}% en rango de obesidad. Plan de reducción recomendado.` }); // Muscle mass low const musclePct = (tissues.muscle / M.peso) * 100; if (musclePct < 30) alerts.push({ level:'warning', icon:'💪', title:'Masa muscular baja', text:`${musclePct.toFixed(1)}% de masa muscular bajo el promedio. Considerar entrenamiento de fuerza.` }); // Age-specific if (edad >= 65 && musclePct < 35) alerts.push({ level:'warning', icon:'👴', title:'Sarcopenia potencial', text:`Adulto mayor con masa muscular reducida. Evaluar fuerza y movilidad.` }); // Bone tissue const bonePct = (tissues.bone / M.peso) * 100; if (bonePct < 8) alerts.push({ level:'info', icon:'🦴', title:'Masa ósea baja', text:`${bonePct.toFixed(1)}% de masa ósea. Asegurar calcio, vitamina D y carga mecánica.` }); // Distribution if (dist.fatTopPct > 40) alerts.push({ level:'info', icon:'📊', title:'Adiposidad superior', text:`${dist.fatTopPct.toFixed(1)}% de la grasa en zona superior.` }); if (dist.fatMidPct > 40) alerts.push({ level:'warning', icon:'📊', title:'Adiposidad central', text:`${dist.fatMidPct.toFixed(1)}% de la grasa en zona central — mayor riesgo metabólico.` }); // Somatotype if (somato.endo > 6) alerts.push({ level:'info', icon:'🟠', title:'Endomorfismo dominante', text:`Componente endo ${somato.endo.toFixed(1)}. Alta tendencia adiposa.` }); // Positive if (alerts.length === 0) alerts.push({ level:'success', icon:'✅', title:'Sin alertas', text:'Todos los indicadores antropométricos están dentro de rangos saludables.' }); return alerts; } // ════════════════════════════════════════════════════════════ // CONCLUSIONS + MEDICAL REFERRAL ENGINE // ════════════════════════════════════════════════════════════ function generateConclusions(M, patient, sexo, edad, hi, dwPct, tissues, dist, somato, leeResult, bp) { const conclusions = []; const referrals = []; const m = sexo === 'masculino'; // Build narrative conclusions const nombre = patient.nombre || 'El/la paciente'; let dominantSoma = 'intermedio'; const maxComp = Math.max(somato.endo, somato.meso, somato.ecto); if (maxComp === somato.endo) dominantSoma = 'endomórfico'; else if (maxComp === somato.meso) dominantSoma = 'mesomórfico'; else dominantSoma = 'ectomórfico'; conclusions.push(`${nombre} presenta un IMC de ${hi.imc.toFixed(1)} kg/m² (${hi.bmiCls.txt}), con un porcentaje de masa grasa estimado en ${dwPct.toFixed(1)}% (Durnin-Womersley). El perfil somatotípico es ${dominantSoma} (${somato.endo.toFixed(1)}–${somato.meso.toFixed(1)}–${somato.ecto.toFixed(1)}).`); conclusions.push(`La composición corporal por fraccionamiento KERR muestra ${(tissues.muscle/M.peso*100).toFixed(1)}% de masa muscular, ${(tissues.adipose/M.peso*100).toFixed(1)}% de masa adiposa y ${(tissues.bone/M.peso*100).toFixed(1)}% de masa ósea.`); if (leeResult) { conclusions.push(`La masa muscular esquelética por ecuación de Lee et al. (2000) es de ${leeResult.smm.toFixed(1)} kg (índice SMI: ${leeResult.smi.toFixed(2)} kg/m² — ${leeResult.classification.label}).`); } // Distribution if (dist.fatMidPct > 40) { conclusions.push(`La distribución adiposa es predominantemente central (${dist.fatMidPct.toFixed(1)}%), lo cual está asociado con mayor riesgo cardiometabólico.`); } else if (dist.fatTopPct > 40) { conclusions.push(`La distribución adiposa es predominantemente superior (${dist.fatTopPct.toFixed(1)}%).`); } else { conclusions.push(`La distribución adiposa es proporcionada entre las regiones evaluadas.`); } // Health risks summary const risks = []; if (hi.whtr >= 0.5) risks.push('cintura/talla elevada'); if (hi.imc >= 30) risks.push('obesidad'); if (hi.imc < 18.5) risks.push('bajo peso'); if (bp && bp.pas >= 140) risks.push('hipertensión sistólica'); if (bp && bp.pad >= 90) risks.push('hipertensión diastólica'); if (bp && bp.glicemia >= 126) risks.push('hiperglicemia (criterio diagnóstico glicemia ayunas)'); if (bp && bp.hba1c >= 6.5) risks.push('HbA1c elevada'); if (bp && bp.colesterolT >= 240) risks.push('hipercolesterolemia'); if (bp && bp.trigliceridos >= 200) risks.push('hipertrigliceridemia'); if (risks.length) { conclusions.push(`Marcadores de riesgo detectados: ${risks.join(', ')}.`); } else { conclusions.push(`No se detectaron marcadores de riesgo elevado en los parámetros evaluados.`); } // ─── REFERRALS ─── if (hi.imc >= 30 || dwPct > (m ? 25 : 32)) { referrals.push({ to: '🥗 Nutricionista', reason: 'Plan de intervención nutricional para reducción de masa grasa.', urgency: 'recommended' }); } if (hi.imc < 18.5 || dwPct < (m ? 6 : 14)) { referrals.push({ to: '🥗 Nutricionista + 👨‍⚕️ Médico', reason: 'Bajo peso o % graso esencial bajo — evaluar etiología y riesgo nutricional/hormonal.', urgency: 'priority' }); } if (bp && (bp.pas >= 140 || bp.pad >= 90)) { referrals.push({ to: '👨‍⚕️ Médico (cardiología / medicina interna)', reason: `Presión arterial elevada (${bp.pas}/${bp.pad} mmHg). Evaluar diagnóstico y manejo.`, urgency: 'priority' }); } if (bp && (bp.glicemia >= 126 || bp.hba1c >= 6.5)) { referrals.push({ to: '👨‍⚕️ Médico (endocrinología / diabetología)', reason: 'Marcadores compatibles con disglicemia. Confirmar diagnóstico.', urgency: 'priority' }); } if (bp && (bp.colesterolT >= 240 || bp.ldl >= 160 || bp.trigliceridos >= 200)) { referrals.push({ to: '👨‍⚕️ Médico (cardiología) + 🥗 Nutricionista', reason: 'Dislipidemia. Evaluar riesgo cardiovascular global.', urgency: 'recommended' }); } if (leeResult && leeResult.classification.label.startsWith('Sarcopenia')) { referrals.push({ to: '🏋️ Kinesiólogo / Preparador físico', reason: 'Masa muscular reducida — programa de entrenamiento de fuerza supervisado.', urgency: 'recommended' }); } if (patient.lesiones && patient.lesiones.trim()) { referrals.push({ to: '🏥 Kinesiólogo / Médico deportólogo', reason: 'Antecedentes de lesiones declarados — evaluar rehabilitación y reincorporación deportiva segura.', urgency: 'recommended' }); } if (edad >= 65 && (tissues.muscle / M.peso * 100) < 35) { referrals.push({ to: '👨‍⚕️ Médico geriatra + 🏋️ Kinesiólogo', reason: 'Adulto mayor con masa muscular reducida — evaluar fuerza, marcha y prevención de caídas.', urgency: 'priority' }); } return { conclusions, referrals }; } function muscleAdiposeDistribution(M) { const PI = Math.PI; // Fat distribution from sum of 6 skinfolds const top = M.triceps + M.subescapular; const mid = M.supraespinal + M.abdominal; const low = M.pliegueMuslo + M.plieguePierna; const sum6 = top + mid + low; const sum8 = sum6 + M.biceps + M.crestaIliaca; // Corrected girths (Kerr / De Rose convention: π × skinfold/10, RELAXED arm) const cArm = M.brazo - (PI * M.triceps / 10); const cThigh = M.musloG - (PI * M.pliegueMuslo / 10); const cCalf = M.pierna - (PI * M.plieguePierna/ 10); const cTorax = M.torax - (PI * M.subescapular / 10); const sumG = cArm + cThigh + cCalf; return { sum6, sum8, top, mid, low, fatTopPct: sum6 ? (top/sum6)*100 : 0, fatMidPct: sum6 ? (mid/sum6)*100 : 0, fatLowPct: sum6 ? (low/sum6)*100 : 0, cArm, cThigh, cCalf, cTorax, muscArmPct: sumG ? (cArm/sumG)*100 : 0, muscThighPct: sumG ? (cThigh/sumG)*100 : 0, muscCalfPct: sumG ? (cCalf/sumG)*100 : 0 }; } // ════════════════════════════════════════════════════════════ // STORAGE / HISTORY // ════════════════════════════════════════════════════════════ function loadHistory() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } function saveHistory(arr) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(arr)); refreshHistCount(); return true; } catch (e) { toast('No se pudo guardar (almacenamiento lleno)', 'error'); return false; } } function refreshHistCount() { document.getElementById('hist-count').textContent = loadHistory().length; } function guardarHistorial() { if (!lastResult) { toast('Primero realiza un cálculo', 'warn'); return; } const hist = loadHistory(); const record = { id: Date.now(), savedAt: new Date().toISOString(), patient: lastResult.patient, measurements: lastResult.measurements, final: lastResult.final, photo: lastResult.photo }; hist.unshift(record); if (saveHistory(hist)) toast('Evaluación guardada en historial', 'success'); } function deleteRecord(id) { if (!confirm('¿Eliminar esta evaluación del historial? Esta acción no se puede deshacer.')) return; const hist = loadHistory().filter(r => r.id !== id); selectedIds.delete(id); saveHistory(hist); renderHistoryList(); toast('Evaluación eliminada', 'success'); } function loadRecord(id) { const rec = loadHistory().find(r => r.id === id); if (!rec) return; // Fill identification document.getElementById('nombre').value = rec.patient.nombre || ''; document.getElementById('documento').value = rec.patient.documento || ''; document.getElementById('telefono').value = rec.patient.telefono || ''; document.getElementById('evaluador').value = rec.patient.evaluador || ''; document.getElementById('fechaEval').value = rec.patient.fecha || todayIso(); document.getElementById('objetivo').value = rec.patient.objetivo || ''; document.getElementById('deporte').value = rec.patient.deporte || ''; document.getElementById('nivelActividad').value = rec.patient.nivelActividad || ''; document.getElementById('acreditacion').value = rec.patient.acreditacion || ''; document.getElementById('evalNum').value = rec.patient.evalNum || ''; document.getElementById('notas').value = rec.patient.notas || ''; if (rec.patient.actividadFisica) document.getElementById('actividadFisica').value = rec.patient.actividadFisica; if (rec.patient.metaCalorica) document.getElementById('metaCalorica').value = rec.patient.metaCalorica; if (rec.patient.objetivoGrasaPct) document.getElementById('objetivoGrasaPct').value = rec.patient.objetivoGrasaPct; if (rec.patient.urineColor) setUrine(parseInt(rec.patient.urineColor)); if (rec.patient.urineDensity) document.getElementById('urineDensity').value = rec.patient.urineDensity; // Sprint 2 fields ['fechaNacimiento','pais','etnia','etapaDeportiva','posicion','frecuenciaSem','horasSem','historialAnos','lesiones', 'pasMmhg','padMmhg','fcReposo','glicemia','hba1c','colesterolT','hdl','ldl','trigliceridos'].forEach(k => { const el = document.getElementById(k); if (el && rec.patient[k] != null && rec.patient[k] !== '') el.value = rec.patient[k]; }); if (rec.patient.consent) { const chk = document.getElementById('consentChk'); if (chk) { chk.checked = true; onConsentCheck(); } } // Patient setSexo(rec.patient.sexo || 'masculino'); document.getElementById('edad').value = rec.patient.edad ?? ''; document.getElementById('peso').value = rec.patient.peso ?? ''; document.getElementById('talla').value = rec.patient.talla ?? ''; document.getElementById('estatuSentado').value = rec.patient.sentado ?? ''; // Measurements Object.keys(RANGES).forEach(k => { const el = document.getElementById(k); if (el) el.value = rec.measurements?.[k] ?? ''; }); // Photo if (rec.photo) { currentPhoto = rec.photo; const img = document.getElementById('preview-img'); img.src = rec.photo; img.style.display = 'block'; document.getElementById('foto-placeholder').style.display = 'none'; document.getElementById('foto-upload').classList.add('has-img'); } else { clearPhoto(); } closeHistory(); toast('Evaluación cargada – pulsa "Iniciar Análisis" para recalcular', 'success'); setTimeout(() => calcular(), 250); } // ── HISTORY MODAL ── function openHistory() { selectedIds.clear(); renderHistoryList(); document.getElementById('modal-history').classList.add('open'); document.body.style.overflow = 'hidden'; } function closeHistory() { document.getElementById('modal-history').classList.remove('open'); document.body.style.overflow = ''; } function renderHistoryList() { const container = document.getElementById('history-content'); const hist = loadHistory(); const q = (document.getElementById('hist-search').value || '').toLowerCase().trim(); const filtered = q ? hist.filter(r => (r.patient.nombre || '').toLowerCase().includes(q) || (r.patient.documento || '').toLowerCase().includes(q)) : hist; if (filtered.length === 0) { container.innerHTML = `
${hist.length === 0 ? '📭' : '🔍'}
${hist.length === 0 ? 'Aún no has guardado evaluaciones' : 'Sin resultados para tu búsqueda'}
`; updateCompareBtn(); return; } // Evolution chart: if filtering by name and >=2 records of same patient renderEvolutionChart(filtered, q); let html = '
'; filtered.forEach(r => { const peso = r.patient.peso ?? '—'; const totalMusc = r.final?.Muscular ? r.final.Muscular.toFixed(1) + ' kg' : '—'; const totalAdip = r.final?.Adiposa ? r.final.Adiposa.toFixed(1) + ' kg' : '—'; const checked = selectedIds.has(r.id) ? 'checked' : ''; const selectedCls = selectedIds.has(r.id) ? 'selected' : ''; html += `
${escapeHtml(r.patient.nombre || '— Sin nombre —')}
📅 ${escapeHtml(fmtDate(r.patient.fecha))} ⚖️ ${escapeHtml(String(peso))} kg 💪 ${escapeHtml(totalMusc)} 🔥 ${escapeHtml(totalAdip)} ${r.patient.documento ? `🪪 ${escapeHtml(r.patient.documento)}` : ''}
`; }); html += '
'; container.innerHTML = html; updateCompareBtn(); } // Evolution chart for filtered patient let evolutionChartInst = null; function renderEvolutionChart(records, query) { const block = document.getElementById('evolution-block'); const title = document.getElementById('evolution-title'); if (!block) return; // Need at least 2 records of the same person // Group by name const groups = {}; records.forEach(r => { const k = (r.patient.nombre || '').trim().toLowerCase(); if (!k) return; (groups[k] = groups[k] || []).push(r); }); // Pick the largest group (or the only one when filtered) const top = Object.values(groups).sort((a,b)=>b.length-a.length)[0]; if (!top || top.length < 2) { block.classList.add('hidden'); if (evolutionChartInst) { evolutionChartInst.destroy(); evolutionChartInst = null; } return; } block.classList.remove('hidden'); // Sort ascending by date top.sort((a,b)=> new Date(a.patient.fecha) - new Date(b.patient.fecha)); title.textContent = `📈 Evolución – ${top[0].patient.nombre} (${top.length} evaluaciones)`; const labels = top.map(r => fmtDate(r.patient.fecha)); const wd = top.map(r => r.patient.peso); const fp = top.map(r => r.final && r.patient.peso ? +(r.final.Adiposa / r.patient.peso * 100).toFixed(2) : null); const mp = top.map(r => r.final && r.patient.peso ? +(r.final.Muscular / r.patient.peso * 100).toFixed(2) : null); if (evolutionChartInst) evolutionChartInst.destroy(); const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const txtColor = isDark ? '#e8eef5' : '#1E2D3D'; evolutionChartInst = new Chart(document.getElementById('evolution-canvas'), { type: 'line', data: { labels, datasets: [ { label:'Peso (kg)', data: wd, yAxisID:'y', borderColor:'#0C3547', backgroundColor:'rgba(12,53,71,.1)', tension:.3, pointRadius:5 }, { label:'% Grasa', data: fp, yAxisID:'y1', borderColor:'#f97316', backgroundColor:'rgba(249,115,22,.1)', tension:.3, pointRadius:5 }, { label:'% Muscular', data: mp, yAxisID:'y1', borderColor:'#00AEEF', backgroundColor:'rgba(0,174,239,.1)', tension:.3, pointRadius:5 } ] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position:'bottom', labels:{ color:txtColor, font:{size:11} } } }, scales: { x: { ticks: { color: txtColor } }, y: { type:'linear', position:'left', title:{display:true,text:'Peso (kg)', color: txtColor}, ticks:{color:txtColor} }, y1: { type:'linear', position:'right', title:{display:true,text:'%', color: txtColor}, ticks:{color:txtColor}, grid:{drawOnChartArea:false} } } } }); } function toggleSelect(id) { if (selectedIds.has(id)) selectedIds.delete(id); else { if (selectedIds.size >= 2) { // Replace oldest selection const first = selectedIds.values().next().value; selectedIds.delete(first); } selectedIds.add(id); } renderHistoryList(); } function updateCompareBtn() { const btn = document.getElementById('btn-compare'); const n = selectedIds.size; btn.textContent = `⚖ Comparar (${n}/2)`; btn.disabled = n !== 2; document.getElementById('sel-info').textContent = n === 0 ? 'Selecciona 2 para comparar' : n === 1 ? 'Selecciona 1 más' : '✅ Listo para comparar'; } // ════════════════════════════════════════════════════════════ // COMPARISON // ════════════════════════════════════════════════════════════ function openComparison() { if (selectedIds.size !== 2) return; const ids = [...selectedIds]; const hist = loadHistory(); const recs = ids.map(id => hist.find(r => r.id === id)).filter(Boolean); if (recs.length !== 2) return; // Sort by date ascending so delta = newer - older recs.sort((a,b) => new Date(a.patient.fecha) - new Date(b.patient.fecha)); renderComparison(recs[0], recs[1]); document.getElementById('modal-compare').classList.add('open'); } function closeComparison() { document.getElementById('modal-compare').classList.remove('open'); } function deltaStr(newer, older, unit='', decimals=2) { const d = newer - older; const sign = d > 0 ? '+' : ''; const cls = Math.abs(d) < 0.01 ? 'delta-same' : (d > 0 ? 'delta-up' : 'delta-down'); const arrow = Math.abs(d) < 0.01 ? '→' : (d > 0 ? '↑' : '↓'); return `${arrow} ${sign}${d.toFixed(decimals)}${unit}`; } function renderComparison(a, b) { // a = older, b = newer const pesoA = a.patient.peso, pesoB = b.patient.peso; const imcA = pesoA / Math.pow(a.patient.talla/100, 2); const imcB = pesoB / Math.pow(b.patient.talla/100, 2); const compsA = a.final, compsB = b.final; const compNames = ['Muscular','Adiposa','Osea','Piel','Residual']; const diasEntre = Math.round((new Date(b.patient.fecha) - new Date(a.patient.fecha)) / 86400000); let html = `

📅 ${escapeHtml(fmtDate(a.patient.fecha))}

${escapeHtml(a.patient.nombre || '—')} · ${pesoA} kg

📅 ${escapeHtml(fmtDate(b.patient.fecha))}

${escapeHtml(b.patient.nombre || '—')} · ${pesoB} kg ${diasEntre>0?'· +'+diasEntre+' días':''}
Indicadores Globales
IndicadorAnteriorActualΔ
Peso${pesoA.toFixed(1)} kg${pesoB.toFixed(1)} kg${deltaStr(pesoB, pesoA, ' kg', 2)}
IMC${imcA.toFixed(1)}${imcB.toFixed(1)}${deltaStr(imcB, imcA, '', 2)}
% Grasa ${(compsA.Adiposa/pesoA*100).toFixed(1)}% ${(compsB.Adiposa/pesoB*100).toFixed(1)}% ${deltaStr(compsB.Adiposa/pesoB*100, compsA.Adiposa/pesoA*100, '%', 2)}
% Muscular ${(compsA.Muscular/pesoA*100).toFixed(1)}% ${(compsB.Muscular/pesoB*100).toFixed(1)}% ${deltaStr(compsB.Muscular/pesoB*100, compsA.Muscular/pesoA*100, '%', 2)}
Componentes (kg)
${compNames.map(c => ` `).join('')}
ComponenteAnteriorActualΔ kg
${ICONS[c]} ${c} ${compsA[c].toFixed(2)} kg ${compsB[c].toFixed(2)} kg ${deltaStr(compsB[c], compsA[c], ' kg', 2)}
`; document.getElementById('compare-content').innerHTML = html; } // ════════════════════════════════════════════════════════════ // CSV EXPORT // ════════════════════════════════════════════════════════════ function buildCsvHeader() { const measFields = Object.keys(RANGES); return [ 'fecha_guardado','fecha_evaluacion','nombre','documento','telefono','evaluador','objetivo', 'sexo','edad','peso','talla','torso_sentado','imc', 'masa_muscular_kg','masa_muscular_pct', 'masa_adiposa_kg','masa_adiposa_pct', 'masa_osea_kg','masa_osea_pct', 'masa_piel_kg','masa_piel_pct', 'masa_residual_kg','masa_residual_pct', ...measFields ]; } function recordToCsvRow(rec) { const p = rec.patient, f = rec.final, m = rec.measurements || {}; const peso = p.peso || 0; const imc = peso && p.talla ? (peso / Math.pow(p.talla/100,2)).toFixed(2) : ''; const pct = v => peso ? ((v/peso)*100).toFixed(2) : ''; return [ rec.savedAt || '', p.fecha || '', p.nombre || '', p.documento || '', p.telefono || '', p.evaluador || '', p.objetivo || '', p.sexo || '', p.edad ?? '', p.peso ?? '', p.talla ?? '', p.sentado ?? '', imc, f.Muscular?.toFixed(2) ?? '', pct(f.Muscular), f.Adiposa?.toFixed(2) ?? '', pct(f.Adiposa), f.Osea?.toFixed(2) ?? '', pct(f.Osea), f.Piel?.toFixed(2) ?? '', pct(f.Piel), f.Residual?.toFixed(2) ?? '', pct(f.Residual), ...Object.keys(RANGES).map(k => m[k] ?? '') ]; } function csvEscape(v) { const s = String(v ?? ''); if (/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g,'""') + '"'; return s; } function downloadCsv(filename, header, rows) { const sep = ';'; const lines = [header.join(sep), ...rows.map(r => r.map(csvEscape).join(sep))]; const blob = new Blob(['' + lines.join('\r\n')], { type:'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 200); } function exportAllCSV() { const hist = loadHistory(); if (hist.length === 0) { toast('Historial vacío', 'warn'); return; } downloadCsv(`ANTROPY_historial_${todayIso()}.csv`, buildCsvHeader(), hist.map(recordToCsvRow)); toast(`${hist.length} registro(s) exportados`, 'success'); } function exportCurrentCSV() { if (!lastResult) { toast('Primero realiza un cálculo', 'warn'); return; } const rec = { savedAt: new Date().toISOString(), patient: lastResult.patient, measurements: lastResult.measurements, final: lastResult.final }; const safeName = (lastResult.patient.nombre || 'paciente').replace(/[^a-z0-9]+/gi,'_').toLowerCase(); downloadCsv(`ANTROPY_${safeName}_${todayIso()}.csv`, buildCsvHeader(), [recordToCsvRow(rec)]); toast('CSV descargado', 'success'); } // ════════════════════════════════════════════════════════════ // ISAK REPORT BUILDER // ════════════════════════════════════════════════════════════ function isakFoot(pageNum, totalPages, evaluador) { return `
Actual Previous Phantom
Made by: ${escapeHtml(evaluador || '—')}
E-mail: felipe.munoz1983@gmail.com
Page ${pageNum} of ${totalPages}
`; } function isakLogo() { return `
K
`; } // Z-bar visual (HTML/CSS only) function isakZbar(z) { if (!isFinite(z)) return ''; const pct = zToPct(z); const out = Math.abs(z) > 3.5; const ticks = [-3,-2,-1,1,2,3].map(t => `
`).join(''); return `
${ticks}
`; } // Single measurement row function isakRow(label, value, decimals, key, stature) { const v = (value == null || isNaN(value)) ? '' : Number(value).toFixed(decimals); let z = '', zStr = ''; if (key && stature && value > 0 && PHANTOM[key]) { z = phantomZ(value, stature, key); zStr = z.toFixed(2); } return ` ${label} ${v} ${z !== '' ? isakZbar(z) : ''} ${zStr} `; } // Build patient info header table function isakPatientHeader(p) { const dias = '0'; return `
Name:${escapeHtml(p.nombre || '—')} Evaluated by:${escapeHtml(p.evaluador || '—')}
Age:${p.edad || '—'} Accredited Instructor:${escapeHtml(p.acreditacion || '—')}
Gender:${p.sexo === 'masculino' ? 'Male' : 'Female'} Evaluation nº:${escapeHtml(p.evalNum || '—')}
Sport:${escapeHtml(p.deporte || '—')} Evaluation:${escapeHtml(fmtDateIso(p.fecha))}
Level Activity:${escapeHtml(p.nivelActividad || '—')} Days last measurement:${dias}
`; } function fmtDateIso(iso) { if (!iso) return '—'; const parts = iso.split('-'); if (parts.length !== 3) return iso; return `${parts[2]}-${parts[1]}-${parts[0]}`; } // PAGE 1: Basic + Skinfolds + Girths function isakPage1(p, M, totalPages) { const t = M.talla; return `
${isakLogo()} ${isakPatientHeader(p)}
Basic measurementsk
${isakRow('Body Mass (kg)', M.peso, 1, 'pesoCorporal', t)} ${isakRow('Stretch Stature (cm)', M.talla, 1, 'estatura', t)} ${isakRow('Sitting Height (cm)', M.estaturaSentado, 1, 'estaturaSentado', t)}
ResultsZ Score
Skinfoldsk
${isakRow('Triceps (mm)', M.triceps, 1, 'triceps', t)} ${isakRow('Subscapular skinfold (mm)', M.subescapular, 1, 'subescapular', t)} ${isakRow('Biceps (mm)', M.biceps, 1, 'biceps', t)} ${isakRow('Iliac Crest (mm)', M.crestaIliaca, 1, 'crestaIliaca', t)} ${isakRow('Supraspinal (mm)', M.supraespinal, 1, 'supraespinal', t)} ${isakRow('Abdominal (mm)', M.abdominal, 1, 'abdominal', t)} ${isakRow('Thigh (mm)', M.pliegueMuslo, 1, 'pliegueMuslo', t)} ${isakRow('Calf (mm)', M.plieguePierna, 1, 'plieguePierna', t)} ${M.axilarMedial > 0 ? isakRow('Mid-axillary (mm)', M.axilarMedial, 1, 'axilarMedial', t) : ''}
ResultsZ Score
Girthsk
${isakRow('Head (cm)', M.cabeza, 1, 'cabeza', t)} ${M.cuello > 0 ? isakRow('Neck (cm)', M.cuello, 1, 'cuello', t) : ''} ${isakRow('Arm Relaxed (cm)', M.brazo, 1, 'brazo', t)} ${isakRow('Arm Flexed And Tensed (cm)', M.brazoFlex, 1, 'brazoFlex', t)} ${isakRow('Forearm (cm)', M.antebrazo, 1, 'antebrazo', t)} ${M.munecaPer > 0 ? isakRow('Wrist (cm)', M.munecaPer, 1, 'munecaPer', t) : ''} ${isakRow('Chest (cm)', M.torax, 1, 'torax', t)} ${isakRow('Waist (cm)', M.cintura, 1, 'cintura', t)} ${isakRow('Hips (cm)', M.cadera, 1, 'cadera', t)} ${M.musloSup > 0 ? isakRow('Upper Thigh (cm)', M.musloSup, 1, 'musloSup', t) : ''} ${isakRow('Thigh Middle (cm)', M.musloG, 1, 'musloG', t)} ${isakRow('Calf (cm)', M.pierna, 1, 'pierna', t)} ${M.tobillo > 0 ? isakRow('Ankle (cm)', M.tobillo, 1, 'tobillo', t) : ''}
ResultsZ Score
${isakFoot(1, totalPages, p.evaluador)}
`; } // PAGE 2: Breadths + Body composition + 2 charts function isakPage2(p, M, totalPages, dw, tissues) { const t = M.talla; const totalEst = tissues.adipose + tissues.muscle + tissues.bone + tissues.skin + tissues.residual; const dif = totalEst - M.peso; const difPct = M.peso ? (dif / M.peso) * 100 : 0; return `
${isakLogo()}
Breadthsk
${isakRow('Biacromial (cm)', M.biacromial, 1, 'biacromial', t)} ${isakRow('Biiliocristal (cm)', M.biiliocrestal, 1, 'biiliocrestal', t)} ${isakRow('Transverse chest (cm)', M.toraxTrans, 1, 'toraxTrans', t)} ${isakRow('Antero-posterior chest depth (cm)', M.toraxAP, 1, 'toraxAP', t)} ${isakRow('Humerus (cm)', M.humero, 1, 'humero', t)} ${isakRow('Femur (cm)', M.femur, 1, 'femur', t)} ${M.munecaDiam > 0 ? isakRow('Wrist breadth (cm)', M.munecaDiam, 1, 'munecaDiam', t) : ''}
ResultsZ Score
${(M.acromialRadial > 0 || M.radialEstil > 0 || M.mano > 0 || M.ileoespinal > 0 || M.trocanterea > 0 || M.tibialLat > 0 || M.pie > 0) ? `
Segmental lengthsk
${M.acromialRadial > 0 ? isakRow('Acromial-radial length (cm)', M.acromialRadial, 1, 'acromialRadial', t) : ''} ${M.radialEstil > 0 ? isakRow('Radial-stylion length (cm)', M.radialEstil, 1, 'radialEstil', t) : ''} ${M.mano > 0 ? isakRow('Hand length (cm)', M.mano, 1, 'mano', t) : ''} ${M.ileoespinal > 0 ? isakRow('Iliospinale height (cm)', M.ileoespinal, 1, 'ileoespinal', t) : ''} ${M.trocanterea > 0 ? isakRow('Trochanterion height (cm)', M.trocanterea, 1, 'trocanterea', t) : ''} ${M.tibialLat > 0 ? isakRow('Tibial-laterale height (cm)', M.tibialLat, 1, 'tibialLat', t) : ''} ${M.pie > 0 ? isakRow('Foot length (cm)', M.pie, 1, 'pie', t) : ''}
ResultsZ Score
` : ''}
Body compositionk
${isakRow('Fat mass (kg) (Durnin-Womersley, 1974)', dw.fatMass, 2, 'fatmass', t)}
Molecular fractionationResultsZ Score
Fat free mass (kg)${dw.ffMass.toFixed(2)}
${isakRow('Adipose tissue (Kerr, 1991) (kg)', tissues.adipose, 2, 'adipose', t)} ${isakRow('Muscle tissue (Kerr, 1991) (kg)', tissues.muscle, 2, 'muscle', t)} ${isakRow('Bone tissue (Kerr, 1991) (kg)', tissues.bone, 2, 'bone', t)} ${isakRow('Skin tissue (Kerr, 1991) (kg)', tissues.skin, 2, 'skin', t)} ${isakRow('Residual tissue (Kerr, 1991) (kg)',tissues.residual, 2, 'residual', t)}
Tissue fractionationResultsZ Score
Estimated total tissue${totalEst.toFixed(2)}
Kg dif estructured-brute tissue${dif.toFixed(2)}
% dif structured-brute tissue${difPct.toFixed(2)}

Molecular fractionation

Current

Tissue fractionation

Current
${isakFoot(2, totalPages, p.evaluador)}
`; } // SVG body silhouette with labels function isakBodySvg(side) { // Simplified human silhouette return ` `; } // PAGE 3: Distribution + Body comp indices function isakPage3(p, M, totalPages, dist, bci) { return `
${isakLogo()}
Fat mass
Muscle tissue
Muscular adipose distributionk
Fat mass
${isakBodySvg('fat')}
Top: ${dist.fatTopPct.toFixed(2)}%
Central: ${dist.fatMidPct.toFixed(2)}%
Lower: ${dist.fatLowPct.toFixed(2)}%
Arm: ${dist.muscArmPct.toFixed(2)}%
Thigh: ${dist.muscThighPct.toFixed(2)}%
Calf: ${dist.muscCalfPct.toFixed(2)}%
Muscle tissue
${isakBodySvg('muscle')}
Body composition indicesk
Adipose muscle index${bci.ami.toFixed(2)}
Classification: ${bci.amiCls}
Expresses how many kg. of adipose tissue has to be carried by each kg. of muscle tissue. The lower this number, the more efficient the displacement.
Muscle Bone index${bci.mbi.toFixed(2)}
Classification: ${bci.mbiCls}
Expresses how many kg. of muscle tissue are in the body relative to the bone tissue.
${isakFoot(3, totalPages, p.evaluador)}
`; } // PAGE 4: Adiposity + Muscularity function isakPage4(p, M, totalPages, dist) { const t = M.talla; const corr = 170.18 / t; const zArm = ((dist.cArm * corr) - 22.05) / 1.91; const zTorax = ((dist.cTorax * corr) - 82.46) / 4.86; const zThigh = ((dist.cThigh * corr) - 49.64) / 3.30; const zCalf = ((dist.cCalf * corr) - 30.22) / 1.97; const armDiff = (M.brazoFlex || 0) - (M.brazo || 0); return `
${isakLogo()}
Adiposityk
Results
Sum of 6 skinfolds (mm)${dist.sum6.toFixed(1)}
Sum of 8 skinfolds (mm)${dist.sum8.toFixed(1)}
Muscularityk
Results
Corrected arm (cm)${dist.cArm.toFixed(2)}
Corrected torax (cm)${dist.cTorax.toFixed(2)}
Corrected thigh (cm)${dist.cThigh.toFixed(2)}
Corrected calf (cm)${dist.cCalf.toFixed(2)}
Z corrected arm${zArm.toFixed(2)}
Z corrected torax${zTorax.toFixed(2)}
Z corrected thigh${zThigh.toFixed(2)}
Z corrected calf${zCalf.toFixed(2)}
Difference between flexed-tensed arm and relaxed arm (cm)${armDiff.toFixed(1)}
${isakFoot(4, totalPages, p.evaluador)}
`; } // Somatochart SVG (Heath-Carter triangular plot) function isakSomatochart(somato) { const X = somato.ecto - somato.endo; const Y = 2*somato.meso - (somato.endo + somato.ecto); // Map data range X[-8,8] Y[-8,14] to SVG 360x320 viewBox const xMin=-9, xMax=9, yMin=-9, yMax=15; const w = 360, h = 320; const sx = v => ((v - xMin)/(xMax - xMin)) * w; const sy = v => h - ((v - yMin)/(yMax - yMin)) * h; const px = sx(X), py = sy(Y); // Triangle vertices const pEndo = [sx(-8), sy(-7)]; const pMeso = [sx(0), sy(13)]; const pEcto = [sx(8), sy(-7)]; const center = [sx(0), sy(0)]; return ` ${[-8,-6,-4,-2,0,2,4,6,8].map(v => ``).join('')} ${[-8,-6,-4,-2,0,2,4,6,8,10,12,14].map(v => ``).join('')} Mesomorphy Endomorphy Ectomorphy Current `; } // PAGE 5: Bone proportionality + Somatotype function isakPage5(p, M, totalPages, prop, somato) { return `
${isakLogo()}
Bone proportionality indices and ratiosk
Cormic index${prop.cormic.toFixed(2)}
Current classification: ${prop.cormicCls}
Acromio-iliac index${prop.acIliac.toFixed(2)}
Current classification: ${prop.aiCls}
Somatotypek
Endomorphy${somato.endo.toFixed(2)}
Interpretation: ${somatoInterpretation('endo', somato.endo)}
Mesomorphy${somato.meso.toFixed(2)}
Interpretation: ${somatoInterpretation('meso', somato.meso)}
Ectomorphy${somato.ecto.toFixed(2)}
Interpretation: ${somatoInterpretation('ecto', somato.ecto)}
${isakSomatochart(somato)} ${isakFoot(5, totalPages, p.evaluador)}
`; } // PAGE 6: Health indexes function isakPage6(p, M, totalPages, hi) { const row = (name, value, range, light, interp) => `
${name}
Interpretation: ${interp}
${value} Healthy range: ${range}
`; return `
${isakLogo()}
Health indexesk
${hi.whrCls ? row('Waist Hip Ratio', hi.whr.toFixed(2), '<'+hi.whrLimit.toFixed(2), hi.whrCls.light, hi.whrCls.txt) : ''} ${hi.whtrCls ? row('Waist-to-height ratio', hi.whtr.toFixed(2), '<0.50', hi.whtrCls.light, hi.whtrCls.txt) : ''} ${hi.ciCls ? row('Conicity index', hi.ci.toFixed(2), '<'+hi.ciLimit.toFixed(2), hi.ciCls.light, hi.ciCls.txt) : ''} ${row('BMI (kg/m²)', hi.imc.toFixed(1), '18.5 – 24.9', hi.bmiCls.light, hi.bmiCls.txt)}
Fat distribution index${hi.fdi.toFixed(2)}
A lower value implies greater accumulation of fat in the trunk, which is associated with an increased risk of metabolic disease.
Body fat distribution
${isakFoot(6, totalPages, p.evaluador)}
`; } // MAIN BUILDER function buildIsakReport(p, M, dw, tissues, dist, bci, prop, somato, hi) { const total = 6; return [ isakPage1(p, M, total), isakPage2(p, M, total, dw, tissues), isakPage3(p, M, total, dist, bci), isakPage4(p, M, total, dist), isakPage5(p, M, total, prop, somato), isakPage6(p, M, total, hi) ].join(''); } // Render Chart.js charts inside the report after HTML insertion function renderIsakCharts(dw, tissues, dist, M, hi) { const chartOpts = { responsive: false, maintainAspectRatio: false, animation: false, plugins: { legend: { position: 'bottom', labels: { font: { size: 9 } } } } }; // Pie 1: Molecular fractionation const c1 = document.getElementById('isak-pie-mol'); if (c1) new Chart(c1, { type: 'pie', data: { labels: ['Fat mass', 'Fat free mass'], datasets: [{ data: [dw.fatMass, dw.ffMass], backgroundColor: ['#fef9c3','#a4543e'], borderColor:'#fff', borderWidth:1 }] }, options: chartOpts }); // Pie 2: Tissue fractionation const c2 = document.getElementById('isak-pie-tis'); if (c2) new Chart(c2, { type: 'pie', data: { labels: ['Adipose tissue','Muscle tissue','Bone tissue','Skin tissue','Residual tissue'], datasets: [{ data: [tissues.adipose, tissues.muscle, tissues.bone, tissues.skin, tissues.residual], backgroundColor: ['#facc15','#dc2626','#94a3b8','#f9a8d4','#86efac'], borderColor:'#fff', borderWidth:1 }] }, options: chartOpts }); // Bar mass: fat mass vs fat-free mass const c3 = document.getElementById('isak-bar-mass'); if (c3) new Chart(c3, { type: 'bar', data: { labels: ['Current'], datasets: [ { label:'Fat mass', data:[dw.fatMass.toFixed(1)], backgroundColor:'#fef3c7' }, { label:'Fat free mass', data:[dw.ffMass.toFixed(1)], backgroundColor:'#a4543e' } ] }, options: { ...chartOpts, scales: { y: { beginAtZero:true, max: M.peso*1.05 } } } }); // Bar tissue: adipose vs muscle const c4 = document.getElementById('isak-bar-tis'); if (c4) new Chart(c4, { type: 'bar', data: { labels: ['Current'], datasets: [ { label:'Adipose tissue', data:[tissues.adipose.toFixed(1)], backgroundColor:'#facc15' }, { label:'Muscle tissue', data:[tissues.muscle.toFixed(1)], backgroundColor:'#dc2626' } ] }, options: { ...chartOpts, scales: { y: { beginAtZero:true, max: M.peso*1.05 } } } }); // Skinfold line chart const c5 = document.getElementById('isak-line-skin'); if (c5) new Chart(c5, { type: 'line', data: { labels: ['Triceps','Subscapular','Biceps','Iliac Crest','Supraspinal','Abdominal','Thigh','Calf'], datasets: [{ label: 'Current', data: [M.triceps, M.subescapular, M.biceps, M.crestaIliaca, M.supraespinal, M.abdominal, M.pliegueMuslo, M.plieguePierna], borderColor: '#16a34a', backgroundColor: 'rgba(22,163,74,.1)', tension: 0.2, pointRadius: 4, pointBackgroundColor:'#16a34a' }] }, options: { ...chartOpts, scales: { y: { beginAtZero:true, title:{display:true, text:'Individual skinfolds (mm)'} } } } }); // Sum 8 bar const c6 = document.getElementById('isak-bar-sum8'); if (c6) new Chart(c6, { type: 'bar', data: { labels:['Current'], datasets:[{ label:'Sum of 8 skinfolds', data:[dist.sum8.toFixed(1)], backgroundColor:'#16a34a' }] }, options: { ...chartOpts, scales: { y: { beginAtZero:true, title:{display:true, text:'mm'} } } } }); // Corrected girths bar const c7 = document.getElementById('isak-bar-corr'); if (c7) new Chart(c7, { type: 'bar', data: { labels: ['Arm','Torax','Thigh','Calf'], datasets: [{ label:'Corrected girths (cm)', data:[dist.cArm.toFixed(1), dist.cTorax.toFixed(1), dist.cThigh.toFixed(1), dist.cCalf.toFixed(1)], backgroundColor: '#16a34a' }] }, options: { ...chartOpts, scales: { y: { beginAtZero:true, title:{display:true, text:'cm'} } } } }); // Body fat distribution pie const c8 = document.getElementById('isak-pie-fat'); if (c8) new Chart(c8, { type: 'pie', data: { labels:['Limbs','Trunk'], datasets:[{ data:[hi.limbsPct.toFixed(1), hi.trunkPct.toFixed(1)], backgroundColor:['#86efac','#fdba74'], borderColor:'#fff', borderWidth:1 }] }, options: chartOpts }); } // ════════════════════════════════════════════════════════════ // PDF // ════════════════════════════════════════════════════════════ async function descargarPDF() { if (!lastResult) { toast('Primero realiza un cálculo', 'warn'); return; } document.body.classList.add('summary-printing'); await new Promise(r => setTimeout(r, 80)); const safeName = (lastResult.patient.nombre || 'paciente').replace(/[^a-z0-9]+/gi,'_').toLowerCase(); const target = document.getElementById('results-section'); try { await html2pdf().set({ margin: [8, 8, 8, 8], filename: `ANTROPY_${safeName}_${todayIso()}.pdf`, image: { type: 'jpeg', quality: 0.97 }, html2canvas: { scale: 2, useCORS: true, scrollY: 0, backgroundColor: '#ffffff', logging: false }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' } }).from(target).save(); toast('PDF descargado', 'success'); } catch (e) { console.error(e); toast('Error al generar PDF', 'error'); } finally { document.body.classList.remove('summary-printing'); } } // ════════════════════════════════════════════════════════════ // ISAK PDF EXPORT (multi-page A4) // ════════════════════════════════════════════════════════════ async function exportIsakPDF() { if (!lastResult) { toast('Primero realiza un cálculo', 'warn'); return; } // Build patient + measurements bundle from current state const patient = { ...lastResult.patient, deporte: _v('deporte'), nivelActividad:_v('nivelActividad'), acreditacion: _v('acreditacion'), evalNum: _v('evalNum') }; const M = { ...lastResult.measurements, peso: lastResult.patient.peso, talla: lastResult.patient.talla, estaturaSentado: lastResult.patient.sentado }; // Validate minimum data for ISAK calcs if (!M.talla || !M.peso) { toast('Faltan peso o talla', 'error'); return; } // Tissues from KERR (use raw, not factor-adjusted, for ISAK report) const PI = Math.PI; const C_sa = patient.edad > 0 && patient.edad < 12 ? 70.691 : patient.sexo==='masculino' ? 68.308 : 73.704; const SA = C_sa * Math.pow(M.peso, 0.425) * Math.pow(M.talla, 0.725); const skin = (SA * ((patient.sexo==='masculino' ? 2.07 : 1.96) / 10) * 1.05) / 1000; const sumP = M.triceps + M.subescapular + M.supraespinal + M.abdominal + M.pliegueMuslo + M.plieguePierna; const ZG = ((sumP * (170.18/M.talla)) - 116.41) / 34.79; const adipose = ((ZG * 5.85) + 25.6) / Math.pow(170.18/M.talla, 3); const ZCab = (M.cabeza - 56.0) / 1.44; const sumH = M.biacromial + M.biiliocrestal + 2*M.humero + 2*M.femur; const ZCue = ((sumH * (170.18/M.talla)) - 98.88) / 5.33; const bone = ((ZCab * 0.18) + 1.20) + (((ZCue * 1.34) + 6.70) / Math.pow(170.18/M.talla, 3)); const brC = M.brazo - (PI * (M.triceps / 10)); const muC = M.musloG - (PI * (M.pliegueMuslo / 10)); const piC = M.pierna - (PI * (M.plieguePierna/ 10)); const toC = M.torax - (PI * (M.subescapular / 10)); const sumM = brC + M.antebrazo + muC + piC + toC; const ZM = ((sumM * (170.18/M.talla)) - 207.21) / 13.74; const muscle = ((ZM * 5.4) + 24.5) / Math.pow(170.18/M.talla, 3); const ciC = M.cintura - (PI * (M.abdominal / 10)); const sumR = M.toraxAP + M.toraxTrans + ciC; const sentado = M.estaturaSentado; const ZR = sentado > 0 ? ((sumR * (89.92/sentado)) - 109.35) / 7.08 : 0; const residual = sentado > 0 ? ((ZR * 1.24) + 6.10) / Math.pow(89.92/sentado, 3) : 0; const tissues = { adipose, muscle, bone, skin, residual }; // Other ISAK calcs const dw = durninWomersley(M, patient.sexo, patient.edad); const dist = muscleAdiposeDistribution(M); const bci = bodyCompIndices(tissues, M, patient.sexo); const prop = proportionalityIndices(M, patient.sexo); const somato = heathCarter(M); const hi = healthIndices(M, patient.sexo); // Insert report HTML and make it visible (rest of page hidden via class) const host = document.getElementById('isak-report-host'); host.innerHTML = buildIsakReport(patient, M, dw, tissues, dist, bci, prop, somato, hi); document.body.classList.add('isak-printing'); window.scrollTo(0, 0); // Render charts (canvases now have real dimensions) try { renderIsakCharts(dw, tissues, dist, M, hi); } catch (e) { console.warn('Chart render warning:', e); } // Wait for layout, fonts and chart paint await new Promise(r => setTimeout(r, 350)); // Generate PDF const safeName = (patient.nombre || 'paciente').replace(/[^a-z0-9]+/gi,'_').toLowerCase(); toast('Generando informe ISAK… esto puede tardar unos segundos', 'info', 3500); try { await html2pdf().set({ margin: 0, filename: `ANTROPY_ISAK_${safeName}_${todayIso()}.pdf`, image: { type: 'jpeg', quality: 0.95 }, html2canvas: { scale: 2, useCORS: true, scrollY: 0, backgroundColor: '#ffffff', logging: false, windowWidth: 794 }, jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, pagebreak: { mode: ['css', 'legacy'] } }).from(host).save(); toast('Informe ISAK descargado', 'success'); } catch (e) { console.error(e); toast('Error al generar el informe ISAK: ' + (e.message || e), 'error', 4500); } finally { document.body.classList.remove('isak-printing'); host.innerHTML = ''; } } // ════════════════════════════════════════════════════════════ // WHATSAPP // ════════════════════════════════════════════════════════════ function enviarWSP() { if (!lastResult) { toast('Primero realiza un cálculo', 'warn'); return; } const { final: d, pesoReal: p, talla, sexo, edad, patient } = lastResult; const imc = p / Math.pow(talla / 100, 2); const pctA = (d.Adiposa / p * 100); const pctM = (d.Muscular / p * 100); const soma = calcSomatotype(pctA, pctM, sexo); let t = `🏋️ *ANTROPY · La antropometría completa*\n_by SDEPORTIVA_\n\n`; if (patient.nombre) t += `👤 *${patient.nombre}*\n`; t += `${sexo === 'masculino' ? 'Masculino' : 'Femenino'} · ${edad} años · ${fmtDate(patient.fecha)}\n`; t += `⚖️ Peso: ${p} kg · Talla: ${talla} cm · IMC: ${imc.toFixed(1)}\n`; t += `${soma.icon} Somatotipo: *${soma.name}*\n\n`; t += `📊 *Composición Corporal:*\n`; t += `💪 Muscular: ${d.Muscular.toFixed(2)} kg · ${pctM.toFixed(1)}%\n`; t += `🔥 Adiposa: ${d.Adiposa.toFixed(2)} kg · ${pctA.toFixed(1)}%\n`; t += `🦴 Ósea: ${d.Osea.toFixed(2)} kg · ${(d.Osea/p*100).toFixed(1)}%\n`; t += `🛡️ Piel: ${d.Piel.toFixed(2)} kg · ${(d.Piel/p*100).toFixed(1)}%\n`; t += `🧩 Residual: ${d.Residual.toFixed(2)} kg · ${(d.Residual/p*100).toFixed(1)}%\n\n`; t += `✅ *Total: ${p.toFixed(2)} kg · Exactitud 100%*\n`; if (patient.evaluador) t += `_Evaluador: ${patient.evaluador}_\n`; t += `_Motor KERR 5C · ANTROPY by SDEPORTIVA_`; // Clean phone: keep only digits const rawPhone = patient.telefono || ''; const phone = rawPhone.replace(/\D/g, ''); const url = phone ? `https://wa.me/${phone}?text=${encodeURIComponent(t)}` : `https://wa.me/?text=${encodeURIComponent(t)}`; window.open(url, '_blank', 'noopener'); } // ════════════════════════════════════════════════════════════ // SHARE / PATIENT-VIEW / PWA // ════════════════════════════════════════════════════════════ let deferredInstallPrompt = null; window.addEventListener('beforeinstallprompt', e => { e.preventDefault(); deferredInstallPrompt = e; }); // Pack patient share payload (compressed) into URL function buildSharePayload() { if (!lastResult) return null; const adv = lastResult.advanced || {}; const payload = { v: 1, lang: currentLang, p: { n: lastResult.patient.nombre, e: lastResult.patient.evaluador, d: lastResult.patient.fecha, s: lastResult.patient.sexo, a: lastResult.patient.edad, w: lastResult.patient.peso, h: lastResult.patient.talla, sp: lastResult.patient.deporte, ob: lastResult.patient.objetivo, no: lastResult.patient.notas }, f: { M: +lastResult.final.Muscular.toFixed(2), A: +lastResult.final.Adiposa.toFixed(2), O: +lastResult.final.Osea.toFixed(2), P: +lastResult.final.Piel.toFixed(2), R: +lastResult.final.Residual.toFixed(2) }, bf: adv.dwResult ? +adv.dwResult.fatPct.toFixed(1) : 0, en: adv.energy ? Math.round(adv.energy.bmrMifflin) : 0, td: adv.macros ? Math.round(adv.macros.tdee) : 0, tg: adv.macros ? Math.round(adv.macros.target) : 0, so: adv.somato ? [+adv.somato.endo.toFixed(1), +adv.somato.meso.toFixed(1), +adv.somato.ecto.toFixed(1)] : null, mac: adv.macros ? { p: Math.round(adv.macros.protein.grams), c: Math.round(adv.macros.carbs.grams), f: Math.round(adv.macros.fat.grams) } : null, hyd: adv.hyd ? { l: adv.hyd.level, t: adv.hyd.txt, r: adv.hyd.recommendations } : null, // Photo & logo are omitted from the URL to keep QR scannable. // Patient view shows initials + brand text instead. ph: null, lg: null }; try { const str = JSON.stringify(payload); const compressed = LZString.compressToEncodedURIComponent(str); return compressed; } catch (e) { console.warn('share encode err', e); return null; } } function buildShareUrl() { const data = buildSharePayload(); if (!data) return null; const base = location.origin + location.pathname; return base + '?p=' + data; } async function openShareModal() { if (!lastResult) { toast(t('t.calcFirst'), 'warn'); return; } const url = buildShareUrl(); if (!url) { toast('No se pudo generar el enlace', 'error'); return; } const modal = document.getElementById('modal-share'); const content = document.getElementById('share-content'); const phone = (lastResult.patient.telefono || '').replace(/\D/g,''); const wspText = encodeURIComponent( (currentLang==='en' ? 'Hi! Here is your anthropometric evaluation: ' : '¡Hola! Aquí están los resultados de tu evaluación antropométrica: ') + url ); const wspLink = phone ? `https://wa.me/${phone}?text=${wspText}` : `https://wa.me/?text=${wspText}`; content.innerHTML = `
${t('t.shareTitle')}
${t('pv.qr')}
${currentLang==='en' ? 'The patient just opens this link on their phone to see their results in a beautiful read-only view. They can install it as an app from their browser.' : 'El paciente solo abre este enlace en su teléfono para ver sus resultados en una vista bonita de solo lectura. Puede instalarlo como app desde su navegador.'}
`; modal.classList.add('open'); document.body.style.overflow = 'hidden'; // Render QR const wrap = document.getElementById('qr-wrap'); if (window.QRCode) { QRCode.toCanvas(url, { width: 220, margin: 1, color: { dark: '#0C3547', light: '#ffffff' } }, (err, canvas) => { if (err) { wrap.textContent = 'QR error'; return; } wrap.innerHTML = ''; wrap.appendChild(canvas); }); } else { // Fallback: external QR API const img = document.createElement('img'); img.src = `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(url)}`; img.width = 220; img.height = 220; wrap.appendChild(img); } } function closeShareModal() { document.getElementById('modal-share').classList.remove('open'); document.body.style.overflow = ''; } function copyShareLink() { const inp = document.getElementById('share-url'); if (!inp) return; inp.select(); inp.setSelectionRange(0, 99999); try { navigator.clipboard.writeText(inp.value); toast(t('t.linkCopied'), 'success'); } catch { document.execCommand('copy'); toast(t('t.linkCopied'), 'success'); } } // ── PATIENT VIEW (renders if URL has ?p=...) ── function tryRenderPatientView() { const params = new URLSearchParams(location.search); const data = params.get('p'); if (!data) return false; let payload; try { const json = LZString.decompressFromEncodedURIComponent(data); payload = JSON.parse(json); } catch (e) { console.warn('decode err', e); return false; } if (!payload || !payload.p) return false; // Apply language from payload if (payload.lang) setLang(payload.lang); const p = payload.p, f = payload.f, mac = payload.mac, hyd = payload.hyd, so = payload.so; const peso = p.w, talla = p.h; const imc = peso && talla ? (peso / Math.pow(talla/100,2)) : 0; const COLORS_PV = { M: '#00AEEF', A: '#f97316', O: '#7c3aed', P: '#f59e0b', R: '#94a3b8' }; const NAMES_PV = currentLang==='en' ? { M:'Muscle', A:'Fat', O:'Bone', P:'Skin', R:'Residual' } : { M:'Muscular', A:'Adiposa', O:'Ósea', P:'Piel', R:'Residual' }; const ICONS_PV = { M:'💪', A:'🔥', O:'🦴', P:'🛡️', R:'🧩' }; const sumF = f.M+f.A+f.O+f.P+f.R; const photoBlock = payload.ph ? `` : `
${(p.n||'?').split(/\s+/).slice(0,2).map(s=>s[0]||'').join('').toUpperCase()}
`; const logoBlock = payload.lg ? `` : `
ANTROPY · SDEPORTIVA
`; const fatPct = peso ? (f.A/peso*100).toFixed(1) : '—'; const muscPct = peso ? (f.M/peso*100).toFixed(1) : '—'; const compRows = ['M','A','O','P','R'].map(k => { const kg = f[k], pct = peso ? (kg/peso*100) : 0; return `
${ICONS_PV[k]} ${NAMES_PV[k]}
${kg.toFixed(2)} kg
`; }).join(''); document.body.classList.add('patient-mode'); const host = document.getElementById('patient-view-host'); host.style.display = 'block'; host.innerHTML = `
${logoBlock}

${t('pv.title')}

${t('pv.evaluator')}: ${escapeHtml(p.e||'—')} · ${t('pv.date')}: ${escapeHtml(fmtDate(p.d))}
${photoBlock}
${escapeHtml(p.n || t('pat.notSet'))}
${p.s==='masculino'?t('lbl.male'):t('lbl.female')} · ${p.a||'—'} ${t('unit.years')} ${p.sp?` · 🏅 ${escapeHtml(p.sp)}`:''} ${p.ob?` · 🎯 ${escapeHtml(p.ob)}`:''}
${t('s.bmi')}
${imc.toFixed(1)}
${currentLang==='en'?'Weight':'Peso'}
${peso}
${t('unit.kg')}
${t('s.fat')}
${fatPct}%
${f.A.toFixed(1)} kg
${t('s.muscle')}
${muscPct}%
${f.M.toFixed(1)} kg

${t('pv.composition')}

${compRows}
${so ? `
Somatotipo
${so[0].toFixed(1)} – ${so[1].toFixed(1)} – ${so[2].toFixed(1)}
Endo · Meso · Ecto
` : ''}
${mac ? `

🔋 ${t('pv.nutrition')}

${t('s.bmr')}
${payload.en}
${t('unit.kcalDay')}
${t('s.tdee')}
${payload.td}
${t('unit.kcalDay')}
${t('s.target')}
${payload.tg}
${t('unit.kcalDay')}
${t('s.water')}
${(peso*35/1000).toFixed(1)}
${t('unit.lDay')}
${mac.p}g
${t('m.protein')}
${mac.c}g
${t('m.carbs')}
${mac.f}g
${t('m.fat')}
` : ''} ${hyd && hyd.r ? `

💧 ${currentLang==='en'?'Hydration':'Hidratación'}

${escapeHtml(hyd.l||'')}
${hyd.r.map(r=>`
${escapeHtml(r)}
`).join('')}
` : ''} ${p.no ? `

📝 ${currentLang==='en'?'Notes from your evaluator':'Notas de tu evaluador'}

${escapeHtml(p.no)}
` : ''}
${t('pv.thanks')}
ANTROPY · by SDEPORTIVA
`; // Show install button when prompt is available setTimeout(() => { if (deferredInstallPrompt) { const btn = document.getElementById('pv-install-btn'); if (btn) btn.hidden = false; } }, 800); return true; } function toggleLangPV() { setLang(currentLang === 'es' ? 'en' : 'es'); // Re-render patient view document.getElementById('patient-view-host').innerHTML = ''; document.body.classList.remove('patient-mode'); tryRenderPatientView(); } async function pwaInstall() { if (!deferredInstallPrompt) { toast(currentLang==='en' ? 'Use your browser menu: Add to Home Screen' : 'Usa el menú del navegador: Agregar a inicio', 'info', 4000); return; } deferredInstallPrompt.prompt(); const choice = await deferredInstallPrompt.userChoice; if (choice.outcome === 'accepted') toast(currentLang==='en' ? 'App installed!' : '¡App instalada!', 'success'); deferredInstallPrompt = null; document.getElementById('pv-install-btn')?.setAttribute('hidden', ''); } // ════════════════════════════════════════════════════════════ // DEMO DATA — Lucas Nervi (verification against ISAKMetry) // ════════════════════════════════════════════════════════════ function loadDemoLucas() { const D = { nombre:'Lucas Nervi', documento:'', telefono:'', evaluador:'FELIPE MUÑOZ ZAMBRANO', fechaEval:'2026-03-06', objetivo:'Rendimiento deportivo', deporte:'', nivelActividad:'Competitivo', acreditacion:'ISAK Level 2', evalNum:'2/2', edad:24, peso:120.8, talla:186.5, estatuSentado:96.5, triceps:17.0, subescapular:23.0, supraespinal:13.0, abdominal:22.0, pliegueMuslo:23.0, plieguePierna:12.0, biceps:5.0, crestaIliaca:40.0, cabeza:58.8, brazo:38.2, brazoFlex:41.0, antebrazo:33.2, torax:119.0, cintura:103.2, cadera:125.2, musloG:71.2, pierna:44.2, biacromial:43.9, biiliocrestal:34.2, humero:7.8, femur:10.8, toraxAP:23.7, toraxTrans:35.1 }; Object.entries(D).forEach(([k,v]) => { const el = document.getElementById(k); if (el) el.value = v; }); setSexo('masculino'); // Clear validation flags document.querySelectorAll('.field.warn,.field.error').forEach(f => { f.classList.remove('warn','error'); const m = f.querySelector('.field-msg'); if (m) m.textContent = ''; }); toast(t('t.demoLoaded'), 'success', 3000); } // ════════════════════════════════════════════════════════════ // GLOBAL UI EVENTS // ════════════════════════════════════════════════════════════ // Close modals on Escape / overlay click document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeHistory(); closeComparison(); } }); ['modal-history','modal-compare','modal-share'].forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); }); }); // ════════════════════════════════════════════════════════════ // SPRINT 3 — SETTINGS, BACKUP, TEMPLATES, ROLES, AUDIT, BIA, XLSX // ════════════════════════════════════════════════════════════ const SETTINGS_KEY = 'kerr5c_settings_v1'; const ROLE_KEY = 'kerr5c_role_v1'; const TEMPLATE_KEY = 'kerr5c_template_v1'; const AUDIT_KEY = 'kerr5c_audit_v1'; // ── Role system ── const ROLES = { admin: { name: 'Administrador', desc: 'Acceso completo: medir, guardar, exportar, configurar, eliminar', ico: '👑' }, evaluator:{ name: 'Evaluador', desc: 'Puede medir y guardar, ver historial. No accede a configuración.', ico: '🥼' }, readonly: { name: 'Solo lectura', desc: 'Ver historial e informes. No puede modificar nada.', ico: '👁️' } }; let currentRole = 'admin'; function setRole(r) { if (!ROLES[r]) r = 'admin'; currentRole = r; document.body.classList.remove('role-admin','role-evaluator','role-readonly'); document.body.classList.add('role-' + r); try { localStorage.setItem(ROLE_KEY, r); } catch {} toast(`Rol cambiado: ${ROLES[r].ico} ${ROLES[r].name}`, 'success', 2500); } // ── Report templates ── const TEMPLATES = { full: { name: 'Completo', ico: '📑', desc: 'Todas las secciones (default)', sections: ['summary','soma','ctrl','chart','goal','energy','hyd','bf','athlete','lee','models','health','alerts','conclusions','referrals'] }, athlete: { name: 'Atleta', ico: '🏆', desc: 'Composición, somatotipo, atleta ref, macros', sections: ['summary','soma','ctrl','chart','goal','energy','bf','athlete','lee','alerts','conclusions'] }, clinical: { name: 'Clínico', ico: '🏥', desc: 'Mediciones, salud, alertas, derivación', sections: ['summary','soma','ctrl','chart','health','alerts','conclusions','referrals'] }, geriatric: { name: 'Geriátrico', ico: '👴', desc: 'Sarcopenia (Lee SMI), riesgos', sections: ['summary','ctrl','lee','health','alerts','conclusions','referrals'] }, pediatric: { name: 'Pediátrico', ico: '🧒', desc: 'Niños/adolescentes con Slaughter', sections: ['summary','soma','ctrl','chart','bf','alerts','conclusions'] }, esthetic: { name: 'Estética', ico: '✨', desc: 'Composición + objetivo + macros', sections: ['summary','chart','goal','energy','bf','conclusions'] } }; let currentTemplate = 'full'; function setTemplate(t) { if (!TEMPLATES[t]) t = 'full'; currentTemplate = t; try { localStorage.setItem(TEMPLATE_KEY, t); } catch {} if (lastResult) renderResults(lastResult); } // ── Audit log ── function loadAudit() { try { return JSON.parse(localStorage.getItem(AUDIT_KEY) || '[]'); } catch { return []; } } function appendAudit(action, details) { const audit = loadAudit(); audit.unshift({ ts: new Date().toISOString(), role: currentRole, who: _v('evaluador') || ROLES[currentRole].name, action, details }); // Cap at 500 entries if (audit.length > 500) audit.length = 500; try { localStorage.setItem(AUDIT_KEY, JSON.stringify(audit)); } catch {} } // ── Settings modal ── let currentSettingsTab = 'backup'; function openSettings() { document.getElementById('modal-settings').classList.add('open'); document.body.style.overflow = 'hidden'; setSettingsTab(currentSettingsTab); } function closeSettings() { document.getElementById('modal-settings').classList.remove('open'); document.body.style.overflow = ''; } function setSettingsTab(tab) { currentSettingsTab = tab; document.querySelectorAll('.settings-tab').forEach(el => { el.classList.toggle('active', el.dataset.tab === tab); }); const c = document.getElementById('settings-tab-content'); if (tab === 'backup') c.innerHTML = renderBackupTab(); else if (tab === 'templates') c.innerHTML = renderTemplatesTab(); else if (tab === 'roles') c.innerHTML = renderRolesTab(); else if (tab === 'audit') c.innerHTML = renderAuditTab(); } // ─── BACKUP / RESTORE ─── function renderBackupTab() { const hist = loadHistory(); const audit = loadAudit(); return `

📥 Exportar respaldo completo (JSON)

Descarga un único archivo JSON con todo tu historial (${hist.length} evaluaciones), logo de marca, auditoría (${audit.length} eventos) y preferencias. Úsalo para hacer respaldo, mover a otro dispositivo o compartir con un colega.

📤 Restaurar desde respaldo

Carga un archivo de respaldo previamente exportado. ⚠️ Reemplaza todo tu historial actual — exporta primero si tienes datos importantes.

⚠️ Borrado seguro

Elimina TODOS los datos locales: historial, logo, auditoría, preferencias. Acción irreversible.
`; } function exportBackup() { const data = { appVersion: 'ANTROPY v3 (Sprint 3)', exportedAt: new Date().toISOString(), history: loadHistory(), audit: loadAudit(), logo: currentLogo, lang: currentLang, role: currentRole, template: currentTemplate }; const blob = new Blob([JSON.stringify(data, null, 2)], { type:'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `ANTROPY_backup_${todayIso()}.json`; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 200); appendAudit('backup_export', `${data.history.length} eval, ${data.audit.length} eventos`); toast('Respaldo descargado', 'success'); } function importBackup(event) { const file = event.target.files?.[0]; if (!file) return; if (!confirm('Esta acción REEMPLAZA tu historial actual. ¿Continuar?')) return; const r = new FileReader(); r.onload = ev => { try { const data = JSON.parse(ev.target.result); if (!data || !Array.isArray(data.history)) throw new Error('Formato inválido'); localStorage.setItem(STORAGE_KEY, JSON.stringify(data.history)); if (Array.isArray(data.audit)) localStorage.setItem(AUDIT_KEY, JSON.stringify(data.audit)); if (data.logo) localStorage.setItem(LOGO_KEY, data.logo); if (data.lang) localStorage.setItem(LANG_KEY, data.lang); if (data.role) localStorage.setItem(ROLE_KEY, data.role); if (data.template) localStorage.setItem(TEMPLATE_KEY, data.template); refreshHistCount(); appendAudit('backup_import', `${data.history.length} eval restauradas`); toast('Respaldo restaurado · recarga la página para aplicar todo', 'success', 4500); setSettingsTab('backup'); } catch (e) { toast('Error al leer el respaldo: ' + e.message, 'error'); } }; r.readAsText(file); event.target.value = ''; } function wipeAllData() { if (!confirm('⚠️ Esto borrará TODO: historial, logo, auditoría, preferencias. ¿Estás seguro?')) return; if (!confirm('Última confirmación. ¿Borrar TODOS los datos locales?')) return; [STORAGE_KEY, AUDIT_KEY, LOGO_KEY, LANG_KEY, THEME_KEY, ROLE_KEY, TEMPLATE_KEY, PROFILE_KEY, QC_KEY, SETTINGS_KEY].forEach(k => { try { localStorage.removeItem(k); } catch {} }); toast('Todos los datos borrados. Recargando…', 'warn', 1500); setTimeout(() => location.reload(), 1500); } // ─── TEMPLATES ─── function renderTemplatesTab() { return `

📋 Plantilla de informe activa

Define qué secciones aparecen en los resultados y el PDF. Se aplica inmediatamente. Útil para adaptar el informe al tipo de paciente.
${Object.entries(TEMPLATES).map(([k, tpl]) => `
${tpl.ico}
${escapeHtml(tpl.name)}
${escapeHtml(tpl.desc)}
${tpl.sections.length} secciones
`).join('')}
`; } // ─── ROLES ─── function renderRolesTab() { return `

👥 Rol de usuario activo

Cambia el modo de operación de la app. Solo lectura es útil para mostrar resultados sin riesgo de edición accidental.
${Object.entries(ROLES).map(([k, r]) => `
${r.ico}
${escapeHtml(r.name)}
${escapeHtml(r.desc)}
${k===currentRole?'
':''}
`).join('')}
🔒 Nota: La protección por rol es a nivel UI (impide ediciones accidentales). Para seguridad real multi-usuario se requiere autenticación con backend.
`; } // ─── AUDIT ─── function renderAuditTab() { const audit = loadAudit(); if (audit.length === 0) { return `

📜 Auditoría

Aún no hay eventos registrados. Cada acción (guardar, eliminar, importar, exportar) queda registrada aquí con timestamp.
`; } return `

📜 Auditoría de cambios (${audit.length} eventos)

Registro inmutable de todas las acciones realizadas. Útil para trazabilidad clínica y forense.
${audit.slice(0,200).map(a => `
${escapeHtml(a.ts.replace('T',' ').substring(0,19))}
${ROLES[a.role]?.ico||''} ${escapeHtml(a.who||'-')}
${escapeHtml(a.action)} ${a.details?`· ${escapeHtml(a.details)}`:''}
`).join('')}
${audit.length > 200 ? `
Mostrando primeros 200 de ${audit.length}
` : ''}
`; } function exportAudit() { const audit = loadAudit(); const header = ['timestamp','rol','quien','accion','detalles']; const rows = audit.map(a => [a.ts, a.role, a.who, a.action, a.details||'']); downloadCsv(`ANTROPY_auditoria_${todayIso()}.csv`, header, rows); toast('Auditoría exportada', 'success'); } function clearAudit() { if (!confirm('¿Limpiar el log de auditoría? Esta acción es irreversible.')) return; try { localStorage.removeItem(AUDIT_KEY); } catch {} appendAudit('audit_clear', 'Log limpiado por usuario'); setSettingsTab('audit'); toast('Auditoría limpiada', 'success'); } // ════════════════════════════════════════════════════════════ // BIA IMPORT (Tanita / InBody / Seca / generic CSV/JSON/key=value) // ════════════════════════════════════════════════════════════ let biaParsed = null; function openBiaImport() { document.getElementById('modal-bia').classList.add('open'); document.body.style.overflow = 'hidden'; } function closeBiaImport() { document.getElementById('modal-bia').classList.remove('open'); document.body.style.overflow = ''; document.getElementById('bia-input').value = ''; document.getElementById('bia-preview').innerHTML = ''; document.getElementById('bia-apply-btn').disabled = true; biaParsed = null; } // Field alias dictionary (BIA device exports → app field IDs) const BIA_ALIASES = { // weight peso: ['weight','peso','body_weight','wt','wt_kg','weight_kg','bodyweight'], talla: ['height','talla','stature','ht','ht_cm','height_cm'], edad: ['age','edad','years','age_years'], // body composition (BIA gives these directly) bia_fatPct: ['body_fat','body_fat_percent','bf_pct','fat_pct','fat_percent','grasa_pct','pbf','bodyfat'], bia_fatKg: ['fat_mass','fat_mass_kg','fmass','grasa_kg','fm','fat_kg'], bia_muscleKg: ['muscle_mass','muscle_mass_kg','smm','skeletal_muscle','musculo_kg','mm','muscle_kg'], bia_ffMass: ['fat_free_mass','ffm','ffm_kg','lean_mass','lbm','mlg'], bia_water: ['total_body_water','tbw','tbw_l','water','agua'], bia_bone: ['bone_mass','bone_mass_kg','bmc','hueso_kg'], bia_basal: ['bmr','basal','rmr','metabolismo_basal'], bia_visceral: ['visceral_fat','vf','visceral'], // perimetros if device gives them cintura: ['waist','cintura','waist_cm'], cadera: ['hip','hips','cadera','hip_cm'] }; function parseBiaPreview() { const raw = document.getElementById('bia-input').value.trim(); if (!raw) { toast('Pega los datos primero', 'warn'); return; } let kv = {}; let format = 'unknown'; // Try JSON try { const j = JSON.parse(raw); if (typeof j === 'object') { flattenObject(j, '', kv); format = 'JSON'; } } catch {} // Try CSV or key=value if (format === 'unknown') { const lines = raw.split(/[\r\n]+/).map(l => l.trim()).filter(Boolean); let isCsv = lines.some(l => l.includes(',')); let isKv = lines.some(l => /[=:]/.test(l)); if (isCsv) { format = 'CSV'; lines.forEach(line => { const parts = line.split(',').map(s => s.trim()); if (parts.length >= 2) { const k = normalizeKey(parts[0]); const v = parseValue(parts[1]); if (v != null) kv[k] = v; } }); } else if (isKv) { format = 'Key=Value'; lines.forEach(line => { const m = line.match(/^([^=:]+)\s*[=:]\s*(.+)$/); if (m) { const k = normalizeKey(m[1]); const v = parseValue(m[2]); if (v != null) kv[k] = v; } }); } } if (Object.keys(kv).length === 0) { document.getElementById('bia-preview').innerHTML = `
⚠️ No se detectaron datos válidos. Verifica el formato.
`; return; } // Map to app fields const mapping = {}; Object.entries(BIA_ALIASES).forEach(([appField, aliases]) => { for (const alias of aliases) { if (kv[alias] != null) { mapping[appField] = kv[alias]; break; } } }); biaParsed = { format, raw_kv: kv, mapping }; const mappingHtml = Object.entries(mapping).map(([k, v]) => `
${escapeHtml(k)}
${escapeHtml(String(Object.entries(kv).find(([a])=>BIA_ALIASES[k]?.includes(a))?.[0]||'-'))}
${escapeHtml(String(v))}
` ).join(''); const unmapped = Object.keys(kv).filter(k => !Object.values(BIA_ALIASES).some(aliases => aliases.includes(k)) ); document.getElementById('bia-preview').innerHTML = `
Formato detectado: ${format} ${Object.keys(mapping).length} de ${Object.keys(kv).length} campos mapeados
${Object.keys(mapping).length > 0 ? `
Campo app
Origen
Valor
${mappingHtml}
` : '
No se pudo mapear ningún campo conocido. Edita el formato e intenta de nuevo.
'} ${unmapped.length ? `
Campos no mapeados (${unmapped.length}): ${escapeHtml(unmapped.slice(0,8).join(', '))}${unmapped.length>8?'…':''}
` : ''}
`; document.getElementById('bia-apply-btn').disabled = Object.keys(mapping).length === 0; } function applyBiaData() { if (!biaParsed?.mapping) return; const m = biaParsed.mapping; let applied = 0; // Apply direct fields ['peso','talla','edad','cintura','cadera'].forEach(f => { if (m[f] != null) { const el = document.getElementById(f); if (el) { el.value = m[f]; validateField(f); applied++; } } }); // BIA-derived fields are stored separately (we don't override KERR calculations) // Store them in patient for use in conclusions/comparison if (!lastResult) lastResult = {}; lastResult.bia = {}; ['bia_fatPct','bia_fatKg','bia_muscleKg','bia_ffMass','bia_water','bia_bone','bia_basal','bia_visceral'].forEach(f => { if (m[f] != null) { lastResult.bia[f] = m[f]; applied++; } }); appendAudit('bia_import', `Formato: ${biaParsed.format}, ${applied} campos aplicados`); toast(`✅ ${applied} campos importados desde BIA · revisa el formulario`, 'success', 3500); closeBiaImport(); } function flattenObject(obj, prefix, out) { Object.entries(obj).forEach(([k, v]) => { const key = normalizeKey(prefix ? prefix + '_' + k : k); if (v && typeof v === 'object' && !Array.isArray(v)) { flattenObject(v, key, out); } else { const parsed = parseValue(v); if (parsed != null) out[key] = parsed; } }); } function normalizeKey(k) { return String(k).toLowerCase() .replace(/[áàäâ]/g,'a').replace(/[éèëê]/g,'e').replace(/[íìïî]/g,'i').replace(/[óòöô]/g,'o').replace(/[úùüû]/g,'u') .replace(/[^a-z0-9_]/g,'_').replace(/_+/g,'_').replace(/^_|_$/g,''); } function parseValue(v) { if (v == null) return null; if (typeof v === 'number') return v; let s = String(v).trim(); // Strip units s = s.replace(/\s*(kg|cm|mm|%|bpm|mmhg|mg\/dl|kcal|l|years?|años?|m²|m2|hr|years|year)$/i, ''); s = s.replace(/,/g, '.'); const n = parseFloat(s); return isNaN(n) ? null : n; } // ════════════════════════════════════════════════════════════ // XLSX EXPORT (SheetJS) // ════════════════════════════════════════════════════════════ function exportCurrentXLSX() { if (!lastResult) { toast(t('t.calcFirst'), 'warn'); return; } if (!window.XLSX) { toast('SheetJS no disponible (revisa conexión)', 'error'); return; } const rec = { savedAt: new Date().toISOString(), patient: lastResult.patient, measurements: lastResult.measurements, final: lastResult.final }; const safeName = (lastResult.patient.nombre || 'paciente').replace(/[^a-z0-9]+/gi,'_').toLowerCase(); const wb = XLSX.utils.book_new(); // Sheet 1: Resumen const summary = [ ['Campo','Valor'], ['Paciente', rec.patient.nombre||''], ['Documento', rec.patient.documento||''], ['Fecha evaluación', rec.patient.fecha||''], ['Evaluador', rec.patient.evaluador||''], ['Sexo', rec.patient.sexo==='masculino'?'Masculino':'Femenino'], ['Edad', rec.patient.edad||''], ['Peso (kg)', rec.patient.peso||''], ['Talla (cm)', rec.patient.talla||''], ['IMC', rec.patient.peso && rec.patient.talla ? (rec.patient.peso/Math.pow(rec.patient.talla/100,2)).toFixed(2) : ''], ['Deporte', rec.patient.deporte||''], ['Objetivo', rec.patient.objetivo||''], ['', ''], ['Composición KERR (kg)','Valor'], ['Muscular', rec.final.Muscular?.toFixed(2)], ['Adiposa', rec.final.Adiposa?.toFixed(2)], ['Ósea', rec.final.Osea?.toFixed(2)], ['Piel', rec.final.Piel?.toFixed(2)], ['Residual', rec.final.Residual?.toFixed(2)] ]; XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(summary), 'Resumen'); // Sheet 2: Mediciones const measRows = [['Medición','Valor','Unidad','Categoría']]; Object.entries(rec.measurements||{}).forEach(([k, v]) => { if (RANGES[k] && v) measRows.push([RANGES[k].label, v, RANGES[k].unit, RANGES[k].cat || '']); }); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(measRows), 'Mediciones'); // Sheet 3: CSV-like full export const csvHeader = buildCsvHeader(); const csvRow = recordToCsvRow(rec); XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([csvHeader, csvRow]), 'Datos completos'); XLSX.writeFile(wb, `ANTROPY_${safeName}_${todayIso()}.xlsx`); appendAudit('xlsx_export', `Evaluación de ${rec.patient.nombre||'-'}`); toast('XLSX descargado', 'success'); } // Hook into save/delete for audit const _origGuardar = guardarHistorial; guardarHistorial = function() { const before = loadHistory().length; _origGuardar.apply(this, arguments); const after = loadHistory().length; if (after > before) appendAudit('save', `Paciente: ${lastResult?.patient?.nombre||'-'} · fecha: ${lastResult?.patient?.fecha||'-'}`); }; const _origDelete = deleteRecord; deleteRecord = function(id) { const rec = loadHistory().find(r => r.id === id); _origDelete.apply(this, arguments); if (rec) appendAudit('delete', `Paciente: ${rec.patient.nombre||'-'} · fecha: ${rec.patient.fecha||'-'}`); }; // ════════════════════════════════════════════════════════════ // DASHBOARD "Mi Práctica" — aggregate stats with dopamine UI // ════════════════════════════════════════════════════════════ function openDashboard() { const hist = loadHistory(); const audit = loadAudit(); const html = buildDashboardHtml(hist, audit); document.getElementById('dashboard-content').innerHTML = html; document.getElementById('modal-dashboard').classList.add('open'); document.body.style.overflow = 'hidden'; // Animate counters after render setTimeout(() => animateAllCounters(), 100); setTimeout(() => renderDashboardCharts(hist), 250); } function closeDashboard() { document.getElementById('modal-dashboard').classList.remove('open'); document.body.style.overflow = ''; } function buildDashboardHtml(hist, audit) { // Aggregate stats const totalEval = hist.length; // Unique patients (by name) const uniqPat = new Set(hist.map(r => (r.patient.nombre||'').trim().toLowerCase()).filter(Boolean)); // Patients with >1 evaluation (recurrent) const counts = {}; hist.forEach(r => { const k = (r.patient.nombre||'').trim().toLowerCase(); if (k) counts[k] = (counts[k]||0) + 1; }); const recurrent = Object.values(counts).filter(n => n > 1).length; const recurrentPct = uniqPat.size ? Math.round((recurrent/uniqPat.size)*100) : 0; const consultsPerClient = uniqPat.size ? (totalEval / uniqPat.size).toFixed(1) : 0; // Gender split const male = hist.filter(r => r.patient.sexo==='masculino').length; const female = hist.filter(r => r.patient.sexo==='femenino').length; // Age groups const ageGroups = { '1-9':0, '10-13':0, '14-16':0, '17-19':0, '20-29':0, '30-40':0, '40+':0 }; hist.forEach(r => { const a = +r.patient.edad || 0; if (a <= 9) ageGroups['1-9']++; else if (a <= 13) ageGroups['10-13']++; else if (a <= 16) ageGroups['14-16']++; else if (a <= 19) ageGroups['17-19']++; else if (a <= 29) ageGroups['20-29']++; else if (a <= 40) ageGroups['30-40']++; else ageGroups['40+']++; }); // Evaluations per year const byYear = {}; hist.forEach(r => { const y = (r.patient.fecha || '').substring(0,4); if (y) byYear[y] = (byYear[y]||0) + 1; }); // Avg metrics const validW = hist.filter(r => r.patient.peso > 0); const avgWeight = validW.length ? validW.reduce((a,b)=>a+b.patient.peso,0) / validW.length : 0; const validBmi = hist.filter(r => r.patient.peso && r.patient.talla); const avgBmi = validBmi.length ? validBmi.reduce((a,b)=>a + b.patient.peso/Math.pow(b.patient.talla/100,2), 0) / validBmi.length : 0; // Top sports const sports = {}; hist.forEach(r => { const s = (r.patient.deporte||'').trim(); if (s) sports[s] = (sports[s]||0) + 1; }); const topSports = Object.entries(sports).sort((a,b)=>b[1]-a[1]).slice(0,5); if (totalEval === 0) { return `
📭

Tu práctica está vacía

Realiza tu primera evaluación y guárdala en el historial. Aquí verás estadísticas agregadas en tiempo real de toda tu práctica.

`; } return `
👥
Pacientes
0
únicos en historial
📋
Evaluaciones
0
totales realizadas
🔄
Recurrentes
0
${recurrent} pacientes con +2 eval
📈
Consultas / cliente
0
promedio

📅 Evaluaciones por año

Distribución temporal de tu actividad

👫 Pacientes por edad y sexo

${male} hombres · ${female} mujeres
⚖️
Peso promedio
0
de tus pacientes
📏
IMC promedio
0
kg/m² agregado
📜
Eventos auditoría
0
acciones registradas
${topSports.length ? `

🏆 Top deportes en tu práctica

Los más frecuentes entre tus pacientes
${topSports.map(([s, n], i) => `
${['🥇','🥈','🥉','4️⃣','5️⃣'][i]} ${escapeHtml(s)} ${n}
`).join('')}
` : ''}
📚
Biblioteca de Papers
${SEED_PAPERS.length + loadCustomPapers().length}
papers científicos disponibles
📄 Paper PDF 🎧 Podcast 🖼️ Infografía
Cada paper se presenta en 3 formatos para distintos estilos de aprendizaje: documento completo, podcast narrado para escuchar mientras entrenas, e infografía visual para captar la idea en segundos.
Abrir biblioteca →
`; } function renderDashboardCharts(hist) { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const txtColor = isDark ? '#e8eef5' : '#1E2D3D'; // Year chart const yc = document.getElementById('dash-year'); if (yc && window._dashByYear) { const yKeys = Object.keys(window._dashByYear).sort(); new Chart(yc, { type: 'bar', data: { labels: yKeys, datasets: [{ label: 'Evaluaciones', data: yKeys.map(k => window._dashByYear[k]), backgroundColor: yKeys.map((_,i) => `hsl(${(i*40+200)%360}, 70%, 55%)`), borderRadius: 8 }]}, options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false } }, scales:{ x:{ ticks:{color:txtColor} }, y:{ ticks:{color:txtColor}, beginAtZero:true } } } }); } // Age chart const ac = document.getElementById('dash-age'); if (ac && window._dashAgeGroups) { const labels = Object.keys(window._dashAgeGroups); new Chart(ac, { type: 'bar', data: { labels, datasets: [{ label: 'Pacientes', data: labels.map(k => window._dashAgeGroups[k]), backgroundColor: 'linear-gradient(45deg, #00AEEF, #7c3aed)', backgroundColor: ['#00AEEF','#0090CC','#7c3aed','#a855f7','#ec4899','#f97316','#16a34a'], borderRadius: 8 }]}, options: { responsive:true, maintainAspectRatio:false, plugins:{ legend:{ display:false } }, scales:{ x:{ ticks:{color:txtColor} }, y:{ ticks:{color:txtColor}, beginAtZero:true } } } }); } } // Add modal-dashboard to overlay click handlers const _dashOverlayEl = document.getElementById('modal-dashboard'); if (_dashOverlayEl) _dashOverlayEl.addEventListener('click', e => { if (e.target === _dashOverlayEl) _dashOverlayEl.classList.remove('open'); }); // ════════════════════════════════════════════════════════════ // PAPERS BIBLIOTECA — Paper + Podcast + Infografía // ════════════════════════════════════════════════════════════ const PAPERS_KEY = 'kerr5c_papers_custom_v1'; // Seed papers (clásicos de antropometría y cineantropometría) const SEED_PAPERS = [ { id: 'kerr-1988', title: 'An Anthropometric Method for Fractionation of Skin, Adipose, Bone, Muscle and Residual Tissue Masses', authors: 'Kerr DA', year: 1988, journal: 'PhD Thesis · Simon Fraser University', tags: ['KERR','5 componentes','composición corporal','tesis'], duration: '14:22', grad: ['#0C3547','#0090CC'], ico: '🔬', abstract: 'Tesis doctoral original que estableció el método KERR de fraccionamiento en 5 componentes. Validado contra disección de cadáveres del estudio belga de Bruselas. Base científica del motor de cálculo de ANTROPY.', pdfUrl: 'https://summit.sfu.ca/item/4655', audioUrl: '', infographicUrl: '' }, { id: 'carter-heath-1990', title: 'Somatotyping — Development and Applications', authors: 'Carter JEL, Heath BH', year: 1990, journal: 'Cambridge University Press', tags: ['somatotipo','Heath-Carter','antropometría'], duration: '12:08', grad: ['#7c3aed','#a855f7'], ico: '📐', abstract: 'Libro definitorio sobre somatotipo Heath-Carter. Define las 3 componentes (endomorfia/mesomorfia/ectomorfia) y su aplicación en ciencias del deporte. Referencia obligada para clasificación corporal.', pdfUrl: 'https://www.scribd.com/doc/110204321/Somatotyping-Carter-Heath', audioUrl: '', infographicUrl: '' }, { id: 'lee-2000', title: 'Total-body skeletal muscle mass: development and cross-validation of anthropometric prediction models', authors: 'Lee RC, Wang Z, Heo M, Ross R, Janssen I, Heymsfield SB', year: 2000, journal: 'Am J Clin Nutr · 72:796-803', tags: ['masa muscular','SMM','antropometría','validación'], duration: '8:45', grad: ['#16a34a','#15803d'], ico: '💪', abstract: 'Estudio que desarrolla y valida ecuaciones antropométricas para estimar masa muscular esquelética total, validadas contra resonancia magnética. Incluye ajuste por etnia. Implementada en ANTROPY.', pdfUrl: 'https://academic.oup.com/ajcn/article/72/3/796/4729537', audioUrl: '', infographicUrl: '' }, { id: 'durnin-womersley-1974', title: 'Body fat assessed from total body density and its estimation from skinfold thickness', authors: 'Durnin JVGA, Womersley J', year: 1974, journal: 'Br J Nutr · 32(1):77-97', tags: ['% graso','pliegues','densidad','clásico'], duration: '10:30', grad: ['#f97316','#c2410c'], ico: '🔥', abstract: 'Estudio clásico que estableció las ecuaciones para estimar densidad corporal y % graso a partir de 4 pliegues. Validado contra pesaje hidrostático. Una de las fórmulas más usadas globalmente.', pdfUrl: 'https://www.cambridge.org/core/journals/british-journal-of-nutrition/article/body-fat-assessed-from-total-body-density-and-its-estimation-from-skinfold-thickness/E1B0FED55B3D04F9028AB94D6FFE8E5D', audioUrl: '', infographicUrl: '' }, { id: 'norton-olds-1996', title: 'Anthropometrica — A textbook of body measurement for sports and health courses', authors: 'Norton K, Olds T (Eds.)', year: 1996, journal: 'UNSW Press', tags: ['ISAK','metodología','libro'], duration: '15:20', grad: ['#ec4899','#be185d'], ico: '📖', abstract: 'Libro de texto australiano fundamental para el aprendizaje de antropometría aplicada a deporte y salud. Cubre técnicas, protocolos ISAK, análisis y aplicaciones. Lectura obligada en cursos de cineantropometría.', pdfUrl: 'https://www.researchgate.net/publication/235796408_Anthropometrica', audioUrl: '', infographicUrl: '' }, { id: 'janssen-2002', title: 'Skeletal muscle cutpoints associated with elevated physical disability risk in older men and women', authors: 'Janssen I, Baumgartner RN, Ross R, Rosenberg IH, Roubenoff R', year: 2002, journal: 'Am J Epidemiol · 159(4):413-21', tags: ['sarcopenia','SMI','riesgo','adulto mayor'], duration: '7:50', grad: ['#dc2626','#991b1b'], ico: '👴', abstract: 'Estudio que estableció los puntos de corte del Skeletal Muscle Mass Index (SMI) para clasificar sarcopenia y predecir riesgo de discapacidad física en adultos mayores. Implementado en ANTROPY (clasificación de Lee SMM).', pdfUrl: 'https://academic.oup.com/aje/article/159/4/413/153090', audioUrl: '', infographicUrl: '' }, { id: 'isak-2019', title: 'International Standards for Anthropometric Assessment (ISAK)', authors: 'Stewart A, Marfell-Jones M, Olds T, de Ridder H', year: 2019, journal: 'ISAK · Murcia, Spain', tags: ['ISAK','estándares','protocolo','TEM'], duration: '18:10', grad: ['#0090CC','#0050A0'], ico: '📏', abstract: 'Manual oficial de ISAK con los estándares internacionales de evaluación antropométrica. Define puntos anatómicos, técnicas, instrumentos, control de calidad TEM y los perfiles restringido (15 sitios) y completo (38 sitios). Base normativa de ANTROPY.', pdfUrl: 'https://www.isak.global/Education/AccreditationManual', audioUrl: '', infographicUrl: '' }, { id: 'ross-wilson-1974', title: 'A stratagem for proportional growth assessment — The Phantom', authors: 'Ross WD, Wilson NC', year: 1974, journal: 'Acta Paediatrica Belgica · 28(suppl):169-182', tags: ['Phantom','Z-score','proporcionalidad'], duration: '11:00', grad: ['#14b8a6','#0f766e'], ico: '👻', abstract: 'Define el modelo Phantom de Ross & Wilson: un humano de referencia con valores P (media) y S (desviación estándar) para 30+ mediciones, que permite calcular Z-scores ajustados por estatura. Base de los Z-scores antropométricos en ANTROPY.', pdfUrl: 'https://www.researchgate.net/publication/247524817_A_strategem_for_proportional_growth_assessment', audioUrl: '', infographicUrl: '' } ]; let currentPapersFilter = 'all'; let currentPaperOpenId = null; let currentPaperTab = 'pdf'; function loadCustomPapers() { try { return JSON.parse(localStorage.getItem(PAPERS_KEY) || '[]'); } catch { return []; } } function saveCustomPapers(arr) { try { localStorage.setItem(PAPERS_KEY, JSON.stringify(arr)); } catch {} } function allPapers() { return [...SEED_PAPERS, ...loadCustomPapers()]; } function openPapers() { currentPaperOpenId = null; renderPapersList(); document.getElementById('modal-papers').classList.add('open'); document.body.style.overflow = 'hidden'; appendAudit('papers_open', 'Biblioteca abierta'); } function closePapers() { document.getElementById('modal-papers').classList.remove('open'); document.body.style.overflow = ''; } function renderPapersList() { const content = document.getElementById('papers-content'); const papers = allPapers(); // Collect unique tags const allTags = [...new Set(papers.flatMap(p => p.tags))]; const filtered = currentPapersFilter === 'all' ? papers : papers.filter(p => p.tags.includes(currentPapersFilter)); content.innerHTML = `
${allTags.map(tag => ` `).join('')}
${filtered.map((p, i) => `
${p.year}
${p.ico}
${p.pdfUrl ? `
📄
` : ''} ${p.audioUrl ? `
🎧
` : ''} ${p.infographicUrl ? `
🖼️
` : ''}
${escapeHtml(p.title)}
${escapeHtml(p.authors)}
${p.tags.slice(0,4).map(tag => `${escapeHtml(tag)}`).join('')}
${p.duration ? `
🎧 ${escapeHtml(p.duration)} podcast
` : ''}
`).join('')}
${filtered.length === 0 ? `
🔍

Sin resultados

No hay papers que coincidan con tu búsqueda.

` : ''} `; } function filterPapersSearch(q) { q = q.toLowerCase().trim(); const cards = document.querySelectorAll('.paper-card'); cards.forEach(c => { const txt = c.textContent.toLowerCase(); c.style.display = txt.includes(q) ? '' : 'none'; }); } function filterPapersTag(tag) { currentPapersFilter = tag; renderPapersList(); } function openPaper(id) { const paper = allPapers().find(p => p.id === id); if (!paper) return; currentPaperOpenId = id; currentPaperTab = paper.pdfUrl ? 'pdf' : paper.audioUrl ? 'audio' : 'info'; renderPaperViewer(paper); appendAudit('paper_view', `${paper.title.substring(0,60)}…`); } function renderPaperViewer(paper) { const content = document.getElementById('papers-content'); content.innerHTML = `

${escapeHtml(paper.title)}

${escapeHtml(paper.authors)} · ${escapeHtml(paper.journal)} (${paper.year})
${escapeHtml(paper.abstract)}
${paper.tags.map(tag => `${escapeHtml(tag)}`).join('')}
${renderPaperTabContent(paper, currentPaperTab)}
`; } function setPaperTab(tab) { currentPaperTab = tab; const paper = allPapers().find(p => p.id === currentPaperOpenId); if (!paper) return; document.querySelectorAll('.paper-viewer-tabs button').forEach(b => b.classList.remove('active')); document.querySelector(`.paper-viewer-tabs button:nth-child(${tab==='pdf'?1:tab==='audio'?2:3})`)?.classList.add('active'); const c = document.getElementById('paper-viewer-content'); c.style.opacity = '0'; setTimeout(() => { c.innerHTML = renderPaperTabContent(paper, tab); c.style.transition = 'opacity .3s'; c.style.opacity = '1'; }, 100); } function renderPaperTabContent(paper, tab) { if (tab === 'pdf') { if (paper.pdfUrl) { return `
🔗 Abrir paper en nueva pestaña
Si el PDF no carga en el iframe (por restricciones del editor), usa el botón "Abrir en nueva pestaña" ↑
`; } return `
📄

Paper no disponible

Este paper aún no tiene PDF cargado.

`; } if (tab === 'audio') { return `
${paper.ico}
${escapeHtml(paper.title)}
🎙️ Podcast educativo · Narrado · ${paper.duration||'—'}
${paper.audioUrl ? `
${Array.from({length: 60}, (_,i) => `
`).join('')}
` : `
🎧

Podcast próximamente

El podcast de este paper aún no está disponible. Como administrador, puedes editar el paper y agregar la URL del audio (cualquier servicio: SoundCloud, Spotify embed, MP3 directo).

`}
📌 Notas del podcast:
${escapeHtml(paper.abstract)}
`; } // info if (paper.infographicUrl) { return `
Infografía: ${escapeHtml(paper.title)}
⬇️ Descargar infografía
`; } return `
🖼️

Infografía próximamente

Como administrador, puedes generar una infografía resumen del paper en Canva, Piktochart o Figma y subirla aquí.

`; } function addAudioToPaper(id) { const url = prompt('Pega la URL del podcast (MP3, OGG, WAV, embed de SoundCloud/Spotify):'); if (!url) return; updatePaperField(id, 'audioUrl', url); openPaper(id); toast('Audio agregado', 'success'); } function addInfographicToPaper(id) { const url = prompt('Pega la URL de la infografía (PNG, JPG, SVG):'); if (!url) return; updatePaperField(id, 'infographicUrl', url); openPaper(id); toast('Infografía agregada', 'success'); } function updatePaperField(id, field, value) { // If seed paper, copy to custom storage let custom = loadCustomPapers(); let p = custom.find(p => p.id === id); if (!p) { const seed = SEED_PAPERS.find(p => p.id === id); if (seed) { p = { ...seed }; custom.push(p); } } if (p) { p[field] = value; saveCustomPapers(custom); appendAudit('paper_update', `${id} · ${field}`); } } function showAddPaperForm() { const container = document.getElementById('add-paper-form-container'); if (!container) return; if (container.innerHTML) { container.innerHTML = ''; return; } container.innerHTML = `

➕ Agregar paper personalizado

`; } function saveCustomPaper() { const get = id => document.getElementById(id)?.value.trim(); const title = get('np-title'); if (!title) { toast('Título requerido', 'warn'); return; } const tags = (get('np-tags') || 'personalizado').split(',').map(s => s.trim()).filter(Boolean); const paper = { id: 'custom-' + Date.now(), title, authors: get('np-authors') || '—', year: parseInt(get('np-year')) || new Date().getFullYear(), journal: get('np-journal') || '—', tags, duration: get('np-duration') || '', grad: ['#16a34a','#15803d'], ico: '📑', abstract: get('np-abstract') || '', pdfUrl: get('np-pdf') || '', audioUrl: get('np-audio') || '', infographicUrl: get('np-info') || '' }; const custom = loadCustomPapers(); custom.unshift(paper); saveCustomPapers(custom); appendAudit('paper_create', title.substring(0,60)); toast('Paper agregado a tu biblioteca', 'success'); renderPapersList(); } // Modal close handler const _papersOverlayEl = document.getElementById('modal-papers'); if (_papersOverlayEl) _papersOverlayEl.addEventListener('click', e => { if (e.target === _papersOverlayEl) _papersOverlayEl.classList.remove('open'); }); // Close settings modal on Esc / overlay click ['modal-settings','modal-bia','modal-dashboard'].forEach(id => { const el = document.getElementById(id); if (el) el.addEventListener('click', e => { if (e.target === el) el.classList.remove('open'); }); }); // ════════════════════════════════════════════════════════════ // INIT // ════════════════════════════════════════════════════════════ (function init() { // Language (must run before patient view) let savedLang = 'es'; try { savedLang = localStorage.getItem(LANG_KEY) || 'es'; } catch {} // Detect from browser if no saved if (!localStorage.getItem(LANG_KEY)) { const nav = (navigator.language || 'es').toLowerCase(); if (nav.startsWith('en')) savedLang = 'en'; } setLang(savedLang); // Patient view mode (URL has ?p=...) if (tryRenderPatientView()) return; // Theme let savedTheme = 'light'; try { savedTheme = localStorage.getItem(THEME_KEY) || 'light'; } catch {} applyTheme(savedTheme); // Default fecha = hoy const fechaInput = document.getElementById('fechaEval'); if (fechaInput && !fechaInput.value) fechaInput.value = todayIso(); // Live validation attachLiveValidation(); // History count badge refreshHistCount(); // Urine color picker initUrinePicker(); // ISAK profile (restringido / completo) try { const savedProfile = localStorage.getItem(PROFILE_KEY); if (savedProfile === 'full') toggleIsakProfile(); } catch {} // QC mode try { const savedQc = localStorage.getItem(QC_KEY); if (savedQc === '1') toggleQcMode(); } catch {} // Role + Template (Sprint 3) try { const savedRole = localStorage.getItem(ROLE_KEY) || 'admin'; document.body.classList.add('role-' + savedRole); currentRole = savedRole; } catch { document.body.classList.add('role-admin'); } try { const savedTpl = localStorage.getItem(TEMPLATE_KEY) || 'full'; currentTemplate = TEMPLATES[savedTpl] ? savedTpl : 'full'; } catch {} appendAudit('app_start', `Idioma: ${currentLang}, Rol: ${currentRole}`); // Restore saved logo try { const saved = localStorage.getItem(LOGO_KEY); if (saved) { currentLogo = saved; const img = document.getElementById('logo-preview'); if (img) { img.src = saved; img.style.display = 'block'; document.getElementById('logo-placeholder').style.display = 'none'; document.getElementById('logo-upload').classList.add('has-img'); } } } catch {} })();