{"id":316,"date":"2025-08-23T17:04:44","date_gmt":"2025-08-23T17:04:44","guid":{"rendered":"https:\/\/tomchan.hk\/?page_id=316"},"modified":"2025-09-11T13:55:31","modified_gmt":"2025-09-11T13:55:31","slug":"growth-chart","status":"publish","type":"page","link":"https:\/\/tomchan.hk\/?page_id=316","title":{"rendered":"OPD Tools"},"content":{"rendered":"<!-- OPD Tool \u2013 Growth, Development & BP (clean build, with ASCII + QR linebreak fixes) -->\r\n<!-- LIMIT CHARTS TO <= 2 PER ROW & ZOOM MODAL -->\r\n<style>\r\n\/* --- Mobile overflow fixes for charts --- *\/\r\n\r\n\/* Make the whole widget clip any accidental horizontal overflow *\/\r\n.hkga{ overflow-x:hidden !important; }\r\n\r\n\/* Ensure grid never pushes wider than its container *\/\r\n#chartsSection, #chartsGrid{ max-width:100% !important; overflow:hidden !important; }\r\n\r\n\/* Let grid items shrink below their intrinsic min-content width *\/\r\n#chartsGrid > div{ min-width:0 !important; }\r\n\r\n\/* Make the Chart.js canvas strictly obey the container size *\/\r\n#chartsGrid canvas,\r\n#modalChartCanvas{\r\n  display:block !important;\r\n  width:100% !important;\r\n  height:100% !important;\r\n}\r\n\r\n\/* Extra safety on very narrow phones *\/\r\n@media (max-width: 480px){\r\n  #chartsGrid{\r\n    grid-template-columns: 1fr !important; \/* force single column *\/\r\n    gap: 10px !important;\r\n  }\r\n}\r\n\r\n\/* Box-sizing safety so padding\/borders don\u2019t expand widths *\/\r\n.hkga *, .hkga *::before, .hkga *::after{ box-sizing:border-box !important; }\r\n\r\n\r\n\r\n\/* Gender-tinted headings (force override with !important) *\/\r\n.hkga h2{\r\n  margin-top:0 !important;\r\n  background:transparent !important;\r\n  padding:4px 8px !important;\r\n  border-radius:6px !important;\r\n  transition:background-color .3s !important;\r\n}\r\n.hkga h3{\r\n  border-left:4px solid transparent !important;\r\n  padding-left:6px !important;\r\n  transition:border-color .3s !important;\r\n}\r\n\/* Girl theme *\/\r\n.hkga.girl h2{ background:#f4759c !important; color:#fff !important; }\r\n.hkga.girl h3{ border-left-color:#f4759c !important; }\r\n\/* Boy theme *\/\r\n.hkga.boy h2{ background:#1483b3 !important; color:#fff !important; }\r\n.hkga.boy h3{ border-left-color:#1483b3 !important; }\r\n\r\n\/* Charts grid *\/\r\n#chartsGrid {\r\n  display: grid;\r\n  grid-template-columns: repeat(2, minmax(260px, 1fr));\r\n  gap: 12px;\r\n}\r\n@media (max-width: 640px) {\r\n  #chartsGrid { grid-template-columns: 1fr; }\r\n}\r\n\r\n\/* Zoom button in each chart panel *\/\r\n.zoomBtn {\r\n  position: absolute;\r\n  top: 6px;\r\n  right: 6px;\r\n  border: 1px solid #d1d5db;\r\n  background: #ffffffcc;\r\n  border-radius: 8px;\r\n  padding: 2px 6px;\r\n  font-size: 18px;\r\n  line-height: 1;\r\n  cursor: pointer;\r\n}\r\n.zoomBtn:hover { background:#fff; }\r\n\r\n\/* Modal *\/\r\n#chartModal {\r\n  display: none;\r\n  position: fixed;\r\n  inset: 0;\r\n  z-index: 99999;\r\n  background: rgba(0,0,0,.5);\r\n  align-items: center;\r\n  justify-content: center;\r\n  padding: 16px;\r\n}\r\n#chartModalContent {\r\n  background: #fff;\r\n  border-radius: 12px;\r\n  box-shadow: 0 10px 30px rgba(0,0,0,.25);\r\n  height: 80vh;\r\n  max-height: 90vh;\r\n  aspect-ratio: 2 \/ 3;\r\n  width: auto;\r\n  box-sizing: border-box;\r\n  position: relative;\r\n  padding: 12px;\r\n  display: flex;\r\n  flex-direction: column;\r\n}\r\n#chartModalHeader {\r\n  display: flex;\r\n  align-items: center;\r\n  justify-content: space-between;\r\n  margin-bottom: 8px;\r\n}\r\n#chartModalTitle { margin: 0; font-size: 1rem; }\r\n#chartModalClose {\r\n  border: 1px solid #d1d5db;\r\n  background: #f9fafb;\r\n  border-radius: 8px;\r\n  padding: 4px 8px;\r\n  cursor: pointer;\r\n}\r\n#chartModalBody { flex: 1; position: relative; }\r\n#modalChartCanvas {\r\n  position: absolute;\r\n  inset: 12px;\r\n  width: calc(100% - 24px);\r\n  height: calc(100% - 24px);\r\n}\r\n\r\n\/* Section jump buttons *\/\r\n.sectionJump {\r\n  display: none; \/* shown when results are available *\/\r\n  margin: 0 0 10px 0;\r\n  gap: 8px;\r\n  flex-wrap: wrap;\r\n}\r\n.sectionJump button {\r\n  border: 1px solid #d1d5db;\r\n  background: #f9fafb;\r\n  border-radius: 8px;\r\n  padding: 6px 10px;\r\n  cursor: pointer;\r\n  font-size: .9rem;\r\n}\r\n.sectionJump button:hover { background:#fff; }\r\n\r\n\/* Smooth scrolling for jump + top button *\/\r\nhtml { scroll-behavior: smooth; }\r\n\r\n\/* Scroll-to-top floating button *\/\r\n#scrollTopBtn {\r\n  position: fixed;\r\n  bottom: 24px;\r\n  right: 24px;\r\n  z-index: 100000;\r\n  background: #111;\r\n  color: #fff;\r\n  border: none;\r\n  border-radius: 50%;\r\n  width: 44px;\r\n  height: 44px;\r\n  font-size: 20px;\r\n  cursor: pointer;\r\n  display: none; \/* hidden until scroll *\/\r\n  align-items: center;\r\n  justify-content: center;\r\n}\r\n#scrollTopBtn:hover { background: #333; }\r\n<\/style>\r\n\r\n<div id=\"hk-growth-app\" class=\"hkga\" style=\"font-family:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; max-width:960px; margin:16px auto; padding:16px; border:1px solid #e5e7eb; border-radius:12px; background:#fff;\">\r\n  <h1 style=\"margin:0 0 12px 0;\">OPD Tool \u2013 Growth \u2022 Development \u2022 BP<\/h1>\r\n  <p style=\"margin:0 0 16px 0; font-size:.95rem;\">\r\n    Hong Kong 2020 growth references (Height, Weight, BMI, HC), developmental milestones (0\u20136y), and pediatric BP percentiles (1mo\u201315y). Adult BP rules apply at \u226516y.\r\n  <\/p>\r\n\r\n  <!-- Inputs -->\r\n  <form id=\"hkga-form\" onsubmit=\"return false;\" style=\"display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin-bottom:12px;\">\r\n    <label>Sex\r\n      <select id=\"sex\" required style=\"width:100%; padding:6px;\">\r\n        <option value=\"girl\">Girl<\/option>\r\n        <option value=\"boy\">Boy<\/option>\r\n      <\/select>\r\n    <\/label>\r\n\r\n    <label>Age \u2013 Years\r\n      <select id=\"ageYears\" required style=\"width:100%; padding:6px;\"><\/select>\r\n    <\/label>\r\n\r\n    <label>Age \u2013 Months\r\n      <select id=\"ageMonths\" required style=\"width:100%; padding:6px;\"><\/select>\r\n    <\/label>\r\n\r\n    <label>Height (cm)\r\n      <input id=\"heightCm\" type=\"number\" step=\"0.1\" min=\"20\" max=\"220\" placeholder=\"e.g., 110.5\" style=\"width:100%; padding:6px;\">\r\n    <\/label>\r\n\r\n    <label>Weight (kg)\r\n      <input id=\"weightKg\" type=\"number\" step=\"0.1\" min=\"0.5\" max=\"200\" placeholder=\"e.g., 18.2\" style=\"width:100%; padding:6px;\">\r\n    <\/label>\r\n\r\n    <label>Head Circumference (cm)\r\n      <input id=\"hcCm\" type=\"number\" step=\"0.1\" min=\"20\" max=\"65\" placeholder=\"e.g., 48.3\" style=\"width:100%; padding:6px;\">\r\n    <\/label>\r\n\r\n    <!-- BP inputs placed immediately after H\/W\/HC -->\r\n    <label>Systolic BP (SBP, mmHg)\r\n      <input id=\"bpSbp\" type=\"number\" step=\"1\" min=\"40\" max=\"250\" placeholder=\"e.g., 110\" style=\"width:100%; padding:6px;\">\r\n    <\/label>\r\n    <label>Diastolic BP (DBP, mmHg)\r\n      <input id=\"bpDbp\" type=\"number\" step=\"1\" min=\"20\" max=\"150\" placeholder=\"e.g., 70\" style=\"width:100%; padding:6px;\">\r\n    <\/label>\r\n\r\n    <div style=\"grid-column:1\/-1; display:flex; gap:8px; flex-wrap:wrap;\">\r\n      <button id=\"calcBtn\" type=\"button\" style=\"padding:8px 12px; border:1px solid #333; border-radius:6px; background:#111; color:#fff; cursor:pointer;\">Calculate<\/button>\r\n      <button id=\"resetBtn\" type=\"button\" style=\"padding:8px 12px; border:1px solid #999; border-radius:6px; background:#f5f5f5; color:#000; cursor:pointer;\">Reset<\/button>\r\n      <span id=\"status\" aria-live=\"polite\" style=\"margin-left:8px; font-size:.9rem; color:#666;\"><\/span>\r\n    <\/div>\r\n  <\/form>\r\n\r\n  <!-- Growth results -->\r\n  <div id=\"results\" style=\"display:none;\">\r\n    <!-- Quick jump buttons -->\r\n    <div id=\"sectionJump\" class=\"sectionJump\">\r\n      <button data-target=\"#chartsSection\" style=\"color:#000;\">Growth Charts<\/button>\r\n      <button data-target=\"#bpSection\" style=\"color:#000;\">Pediatric \/ Adult Blood Pressure<\/button>\r\n      <button data-target=\"#development\" style=\"color:#000;\">Developmental Milestones<\/button>\r\n      <button data-target=\"#summarySection\" style=\"color:#000;\">Plain Summary (for notes \/ QR)<\/button>\r\n    <\/div>\r\n\r\n    <h2 style=\"margin:12px 0 6px 0;\">Growth Results<\/h2>\r\n    <div id=\"ageMeta\" style=\"font-size:.9rem; color:#555; margin-bottom:8px;\"><\/div>\r\n    <div id=\"centileTable\" style=\"margin-top:10px; overflow-x:auto;\"><\/div>\r\n\r\n    <div style=\"display:grid; grid-template-columns:repeat(auto-fit,minmax(240px,1fr)); gap:12px;\">\r\n      <div class=\"card\" style=\"border:1px solid #ddd; border-radius:8px; padding:10px;\">\r\n        <h4 style=\"margin:0 0 6px 0;\">Height<\/h4>\r\n        <div id=\"heightOut\" style=\"white-space:pre-wrap;\"><\/div>\r\n      <\/div>\r\n      <div class=\"card\" style=\"border:1px solid #ddd; border-radius:8px; padding:10px;\">\r\n        <h4 style=\"margin:0 0 6px 0;\">Weight<\/h4>\r\n        <div id=\"weightOut\" style=\"white-space:pre-wrap;\"><\/div>\r\n      <\/div>\r\n      <div class=\"card\" style=\"border:1px solid #ddd; border-radius:8px; padding:10px;\">\r\n        <h4 style=\"margin:0 0 6px 0;\">BMI<\/h4>\r\n        <div id=\"bmiOut\" style=\"white-space:pre-wrap;\"><\/div>\r\n      <\/div>\r\n      <div class=\"card\" style=\"border:1px solid #ddd; border-radius:8px; padding:10px;\">\r\n        <h4 style=\"margin:0 0 6px 0;\">Head Circumference<\/h4>\r\n        <div id=\"hcOut\" style=\"white-space:pre-wrap;\"><\/div>\r\n      <\/div>\r\n    <\/div>\r\n    <p style=\"margin-top:10px; font-size:.85rem; color:#666;\">\r\n      \u201cat\u201d = within 10% of the distance to the nearest adjacent centile line. \r\n      Example: if 50th = 10 and 75th = 15 (gap 5), \u201cat 50th\u201d is 9.5\u201310.5.\r\n    <\/p>\r\n  <\/div>\r\n\r\n  <!-- Charts -->\r\n  <div id=\"chartsSection\" style=\"display:none; margin-top:20px;\">\r\n    <h2>Growth Charts<\/h2>\r\n    <div id=\"chartsGrid\">\r\n      <div style=\"position:relative; width:100%; aspect-ratio: 2 \/ 3;\">\r\n        <button class=\"zoomBtn\" data-metric=\"height\" title=\"Zoom chart\" style=\"color:#000;\"><i class=\"fa-regular fa-magnifying-glass\"><\/i><\/button>\r\n        <canvas id=\"chartHeight\" style=\"width:100%; height:100%;\"><\/canvas>\r\n      <\/div>\r\n      <div style=\"position:relative; width:100%; aspect-ratio: 2 \/ 3;\">\r\n        <button class=\"zoomBtn\" data-metric=\"weight\" title=\"Zoom chart\" style=\"color:#000;\"><i class=\"fa-regular fa-magnifying-glass\"><\/i><\/button>\r\n        <canvas id=\"chartWeight\" style=\"width:100%; height:100%;\"><\/canvas>\r\n      <\/div>\r\n\r\n      <div style=\"position:relative; width:100%; aspect-ratio: 2 \/ 3;\">\r\n        <button class=\"zoomBtn\" data-metric=\"bmi\" title=\"Zoom chart\" style=\"color:#000;\"><i class=\"fa-regular fa-magnifying-glass\"><\/i><\/button>\r\n        <canvas id=\"chartBMI\" style=\"width:100%; height:100%;\"><\/canvas>\r\n      <\/div>\r\n      <div style=\"position:relative; width:100%; aspect-ratio: 2 \/ 3;\">\r\n        <button class=\"zoomBtn\" data-metric=\"hc\" title=\"Zoom chart\" style=\"color:#000;\"><i class=\"fa-regular fa-magnifying-glass\"><\/i><\/button>\r\n        <canvas id=\"chartHC\" style=\"width:100%; height:100%;\"><\/canvas>\r\n      <\/div>\r\n    <\/div>\r\n  <\/div>\r\n\r\n  <!-- Modal for full-size growth chart -->\r\n  <div id=\"chartModal\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"chartModalTitle\">\r\n    <div id=\"chartModalContent\">\r\n      <div id=\"chartModalHeader\">\r\n        <h3 id=\"chartModalTitle\">Chart<\/h3>\r\n        <button id=\"chartModalClose\" type=\"button\" style=\"color:#000;\">Close \u00d7<\/button>\r\n      <\/div>\r\n      <div id=\"chartModalBody\">\r\n        <canvas id=\"modalChartCanvas\"><\/canvas>\r\n      <\/div>\r\n    <\/div>\r\n  <\/div>\r\n\r\n  <!-- BP outputs -->\r\n  <div id=\"bpSection\" style=\"margin-top:20px;\">\r\n    <h2>Pediatric \/ Adult Blood Pressure<\/h2>\r\n    <div class=\"card\" style=\"border:1px solid #ddd; border-radius:8px; padding:10px; margin-top:10px;\">\r\n      <h4 style=\"margin:0 0 6px 0;\">BP Summary<\/h4>\r\n      <div id=\"bpSummary\" style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; color:#000;\">\u2014<\/div>\r\n      <div id=\"bpHeightWarn\" style=\"display:none; background:#fff7ed; border:1px solid #fdba74; color:#9a3412; padding:.6rem .75rem; border-radius:10px; font-size:.92rem; margin-top:.6rem;\"><\/div>\r\n    <\/div>\r\n\r\n    <div class=\"card\" style=\"border:1px solid #ddd; border-radius:8px; padding:10px; margin-top:10px;\">\r\n      <h4 style=\"margin:0 0 6px 0;\">BP Percentile Table<\/h4>\r\n      <div style=\"font-size:.9rem; color:#6b7280; margin-bottom:.25rem;\">Shown for selected sex\/age and nearest printed height if entered. If no height (\u22651y), age-based percentiles shown when available. 99th displayed only when provided in the tables. (Hidden when adult rules are used.)<\/div>\r\n      <table id=\"bpPctTable\" style=\"width:100%; border-collapse:collapse;\">\r\n        <thead>\r\n          <tr>\r\n            <th style=\"border:1px solid #e5e7eb; padding:.5rem; background:#f9fafb;\">Percentile<\/th>\r\n            <th style=\"border:1px solid #e5e7eb; padding:.5rem; background:#f9fafb;\">SBP<\/th>\r\n            <th style=\"border:1px solid #e5e7eb; padding:.5rem; background:#f9fafb;\">DBP<\/th>\r\n          <\/tr>\r\n        <\/thead>\r\n        <tbody><\/tbody>\r\n      <\/table>\r\n    <\/div>\r\n\r\n    <div class=\"card\" style=\"border:1px solid #ddd; border-radius:8px; padding:10px; margin-top:10px;\">\r\n      <h4 style=\"margin:0 0 6px 0;\">BP Category<\/h4>\r\n      <div id=\"bpCategory\" style=\"font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;\">\u2014<\/div>\r\n    <\/div>\r\n\r\n    <!-- Reference -->\r\n    <div class=\"card\" style=\"border:1px solid #ddd; border-radius:8px; padding:10px; margin-top:10px;\">\r\n      <h4 style=\"margin:0 0 6px 0;\">BP Classification (Reference)<\/h4>\r\n      <table style=\"width:100%; border-collapse:collapse; font-size:.95rem;\">\r\n        <thead>\r\n          <tr style=\"background:#f9fafb;\">\r\n            <th style=\"border:1px solid #e5e7eb; padding:.5rem;\">Description<\/th>\r\n            <th style=\"border:1px solid #e5e7eb; padding:.5rem;\">&lt; 16 years old<\/th>\r\n            <th style=\"border:1px solid #e5e7eb; padding:.5rem;\">\u2265 16 years old<\/th>\r\n          <\/tr>\r\n        <\/thead>\r\n        <tbody>\r\n          <tr><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">Hypotension<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">&lt; 5th centile<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">&lt; 90\/50<\/td><\/tr>\r\n          <tr><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">High normal BP<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">90\u201395th centile<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">130\u2013139 \/ 85\u201389<\/td><\/tr>\r\n          <tr><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">Hypertension Grade 1<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">95th to 99th + 5 mmHg<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">140\u2013159 \/ 90\u201399<\/td><\/tr>\r\n          <tr><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">Hypertension Grade 2<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">&gt; 99th + 5 mmHg<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">160\u2013179 \/ 100\u2013109<\/td><\/tr>\r\n          <tr><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">Hypertension Grade 3<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">\u2013<\/td><td style=\"border:1px solid #e5e7eb; padding:.5rem;\">\u2265 180 \/ 110<\/td><\/tr>\r\n        <\/tbody>\r\n      <\/table>\r\n    <\/div>\r\n  <\/div>\r\n\r\n  <!-- Development -->\r\n  <div id=\"development\" style=\"margin-top:20px; display:none;\">\r\n    <h2>Developmental Milestones<\/h2>\r\n    <div id=\"devContent\" style=\"white-space:pre-wrap;\"><\/div>\r\n  <\/div>\r\n\r\n  <!-- Plain summary + QR -->\r\n  <div id=\"summarySection\" style=\"margin-top:20px; display:none;\">\r\n    <h2>Plain Summary (for notes \/ QR)<\/h2>\r\n    <pre id=\"plainSummary\" style=\"background:#f7f7f7; padding:10px; border:1px solid #ddd; border-radius:8px; white-space:pre-wrap; color:#000;\"><\/pre>\r\n    <div id=\"qrcode\" style=\"margin-top:10px;\"><\/div>\r\n    <button id=\"downloadQR\" type=\"button\" style=\"margin-top:10px; padding:6px 10px; border:1px solid #333; border-radius:6px; background:#111; color:#fff; cursor:pointer; display:none;\">Download QR<\/button>\r\n  <\/div>\r\n<\/div>\r\n\r\n<!-- Floating back-to-top button -->\r\n<button id=\"scrollTopBtn\" title=\"Back to top\">\u2191<\/button>\r\n\r\n<script>\r\n(() => {\r\n  const DATA_URL = \"https:\/\/tomchan.hk\/tomwebapp\/growthchart.json\";\r\n  const DEV_URL  = \"https:\/\/tomchan.hk\/tomwebapp\/development.json\";\r\n  const BP_URL   = \"https:\/\/tomchan.hk\/tomwebapp\/bp_tables.json\";\r\n\r\n  const wrapper = document.getElementById('hk-growth-app');\r\n\r\n  \/\/ Shared UI\r\n  const yearsEl = document.getElementById('ageYears');\r\n  const monthsEl = document.getElementById('ageMonths');\r\n  const sexEl = document.getElementById('sex');\r\n  const hEl = document.getElementById('heightCm');\r\n  const wEl = document.getElementById('weightKg');\r\n  const hcEl = document.getElementById('hcCm');\r\n  const bpSbpEl = document.getElementById('bpSbp');\r\n  const bpDbpEl = document.getElementById('bpDbp');\r\n  const statusEl = document.getElementById('status');\r\n\r\n  const results = document.getElementById('results');\r\n  const ageMeta = document.getElementById('ageMeta');\r\n  const heightOut = document.getElementById('heightOut');\r\n  const weightOut = document.getElementById('weightOut');\r\n  const bmiOut = document.getElementById('bmiOut');\r\n  const hcOut = document.getElementById('hcOut');\r\n  const centileTableDiv = document.getElementById('centileTable');\r\n\r\n  const devSection = document.getElementById('development');\r\n  const devContent = document.getElementById('devContent');\r\n\r\n  const bpSummaryEl = document.getElementById('bpSummary');\r\n  const bpWarnEl = document.getElementById('bpHeightWarn');\r\n  const bpPctBody = document.querySelector('#bpPctTable tbody');\r\n  const bpCategoryEl = document.getElementById('bpCategory');\r\n\r\n  const summarySection = document.getElementById('summarySection');\r\n  const plainSummary = document.getElementById('plainSummary');\r\n  const qrcodeDiv = document.getElementById('qrcode');\r\n  const downloadQRBtn = document.getElementById('downloadQR');\r\n\r\n  const chartsSection = document.getElementById('chartsSection');\r\n\r\n  \/\/ Modal elements\r\n  const chartModal = document.getElementById('chartModal');\r\n  const chartModalClose = document.getElementById('chartModalClose');\r\n  const chartModalTitle = document.getElementById('chartModalTitle');\r\n  const modalCanvas = document.getElementById('modalChartCanvas');\r\n\r\n  \/\/ Populate dropdowns\r\n  for (let y=0; y<=18; y++) yearsEl.add(new Option(y,y));\r\n  for (let m=0; m<=11; m++) monthsEl.add(new Option(m,m));\r\n\r\n  \/\/ Helpers\r\n  const to1 = x => (Math.round(x*10)\/10).toFixed(1);\r\n  const parseCent = k => parseFloat(k.replace('cent',''));\r\n  const sortedCentKeys = obj => Object.keys(obj).filter(k=>k.startsWith('cent')).sort((a,b)=>parseCent(a)-parseCent(b));\r\n  const centileLabel = k => {\r\n    const n = parseCent(k);\r\n    if (n === 0.4) return \"0.4th centile\";\r\n    if (n === 2) return \"2nd centile\";\r\n    if (n === 9) return \"9th centile\";\r\n    if (n === 25) return \"25th centile\";\r\n    if (n === 50) return \"50th centile\";\r\n    if (n === 75) return \"75th centile\";\r\n    if (n === 91) return \"91st centile\";\r\n    if (n === 98) return \"98th centile\";\r\n    if (n === 99.6) return \"99.6th centile\";\r\n    return `${n}th centile`;\r\n  };\r\n\r\n  \/* NEW: ASCII inequality + QR line-break normalization *\/\r\n  function asciiIneq(str){\r\n    return String(str).replaceAll('\u2265','>=').replaceAll('\u2264','=<');\r\n  }\r\n  function normalizeQRLinebreaks(str){\r\n    \/\/ unify, then convert to CRLF for better scanner compatibility\r\n    const unified = String(str).replace(\/\\r\\n|\\r|\\n\/g, '\\n').replace(\/\\n{3,}\/g, '\\n\\n');\r\n    return unified.replace(\/\\n\/g, '\\r\\n');\r\n  }\r\n\r\n  let GROWTH=null, DEV=null, BP=null;\r\n\r\n  async function loadJSON(url){\r\n    const r = await fetch(url, {cache:'no-cache'});\r\n    if(!r.ok) throw new Error('load failed: '+url);\r\n    return r.json();\r\n  }\r\n\r\n  async function loadData(){\r\n    statusEl.textContent = \"Loading data\u2026\";\r\n    try {\r\n      [GROWTH, DEV, BP] = await Promise.all([\r\n        loadJSON(DATA_URL),\r\n        loadJSON(DEV_URL),\r\n        loadJSON(BP_URL)\r\n      ]);\r\n      statusEl.textContent = \"\";\r\n      runAll();\r\n      initLiveUpdate();\r\n      initZoomButtons();\r\n      initSectionJump();\r\n    } catch(e){\r\n      console.error(e);\r\n      statusEl.textContent = \"Error loading one or more data files.\";\r\n    }\r\n  }\r\n\r\n  const getAgeMonths = ()=> (parseInt(yearsEl.value||\"0\",10)*12) + parseInt(monthsEl.value||\"0\",10);\r\n\r\n  function interpolateRow(rows, ageM){\r\n    let lower = rows[0], upper = rows.at(-1);\r\n    for (let i=0;i<rows.length-1;i++){\r\n      if (rows[i].age_months <= ageM && ageM <= rows[i+1].age_months){ lower = rows[i]; upper = rows[i+1]; break; }\r\n    }\r\n    const t = (ageM - lower.age_months) \/ (upper.age_months - lower.age_months || 1);\r\n    const out = {};\r\n    for (const k in lower) if (k.startsWith(\"cent\")) out[k] = lower[k] + (upper[k] - lower[k]) * t;\r\n    return out;\r\n  }\r\n\r\n  function classify(val, centRow){\r\n    const keys = sortedCentKeys(centRow);\r\n    const pairs = keys.map(k => ({k, v: centRow[k]}));\r\n    for (let i = 0; i < pairs.length; i++){\r\n      const cur = pairs[i].v;\r\n      const gapDown = (i > 0) ? (cur - pairs[i-1].v) : Infinity;\r\n      const gapUp   = (i < pairs.length-1) ? (pairs[i+1].v - cur) : Infinity;\r\n      const nearestGap = Math.min(gapDown, gapUp);\r\n      const tol = (nearestGap === Infinity) ? 0 : nearestGap * 0.10;\r\n      if (Math.abs(val - cur) <= tol) return { detail: `at ${centileLabel(pairs[i].k)}` };\r\n    }\r\n    if (val < pairs[0].v) return { detail:`below ${centileLabel(pairs[0].k)}` };\r\n    if (val > pairs.at(-1).v) return { detail:`above ${centileLabel(pairs.at(-1).k)}` };\r\n    for (let i = 0; i < pairs.length - 1; i++){\r\n      const a = pairs[i], b = pairs[i+1];\r\n      if (val > a.v && val < b.v) return { detail:`between ${centileLabel(a.k)} and ${centileLabel(b.k)}` };\r\n    }\r\n    return { detail:\"\u2014\" };\r\n  }\r\n\r\n  function buildCentileTable(ageM){\r\n    const sex = sexEl.value;\r\n    const metrics = [\"height\",\"weight\",\"bmi\",\"hc\"];\r\n    const labels  = {height:\"Height (cm)\", weight:\"Weight (kg)\", bmi:\"BMI (kg\/m\u00b2)\", hc:\"HC (cm)\"};\r\n    const centiles = [\"cent0.4\",\"cent2\",\"cent9\",\"cent25\",\"cent50\",\"cent75\",\"cent91\",\"cent98\",\"cent99.6\"];\r\n    let html = `<h4 style=\"margin-top:0\">Reference Centiles at ${Math.floor(ageM\/12)}y ${ageM%12}m<\/h4>`;\r\n    html += `<table border=\"1\" cellpadding=\"4\" cellspacing=\"0\" style=\"border-collapse:collapse; font-size:.9rem;\"><thead><tr><th>Metric<\/th>`;\r\n    for (const c of centiles) html += `<th>${c.replace(\"cent\",\"\")}th<\/th>`;\r\n    html += `<\/tr><\/thead><tbody>`;\r\n    for (const metric of metrics){\r\n      const rows = GROWTH?.data?.[sex]?.[metric];\r\n      if (!rows) continue;\r\n      const r = interpolateRow(rows, ageM);\r\n      html += `<tr><td>${labels[metric]}<\/td>`;\r\n      for (const c of centiles) html += `<td>${to1(r[c])}<\/td>`;\r\n      html += `<\/tr>`;\r\n    }\r\n    html += `<\/tbody><\/table>`;\r\n    return html;\r\n  }\r\n\r\n  \/* ------------------------- BP helpers ------------------------- *\/\r\n  function parsePair(pair){ if(!pair) return {sbp:null,dbp:null}; const [s,d]=pair.split('\/').map(x=>parseInt(x,10)); return {sbp:s,dbp:d}; }\r\n  function nearestIndex(arr, value){ if(!arr||!arr.length||value==null) return {idx:-1,val:null}; let bi=0,bd=Math.abs(arr[0]-value); for(let i=1;i<arr.length;i++){const d=Math.abs(arr[i]-value); if(d<bd){bd=d;bi=i;}} return {idx:bi,val:arr[bi]}; }\r\n\r\n  function bpGetPercentiles(sexChar, years, months, height){\r\n    const pack = (sexChar==='M') ? BP.boys : BP.girls;\r\n    if(years >= 16) return {mode:'adult', table:[], warning:'', usedHeight:null};\r\n    if(years===0){\r\n      const valid=[1,2,6]; let used=months;\r\n      if(!valid.includes(months)){ const near=nearestIndex(valid,months); used=valid[near.idx]; }\r\n      const table=[];\r\n      for(const pct of ['5th','50th','90th','95th']){\r\n        const pair = pack.infants?.[pct]?.[used+'mo'] || pack.infants?.[pct]?.[used];\r\n        if(pair) table.push({pct, pair:parsePair(pair)});\r\n      }\r\n      return {mode:'infant', usedMonth:used, table, warning:''};\r\n    }\r\n    const ageBlock = pack.ages?.[String(years)];\r\n    if(!ageBlock) return {mode:'age', error:'No BP data for this age.', table:[], warning:''};\r\n\r\n    const heights = ageBlock.heights||[];\r\n    const near = (height!=null && height!==\"\") ? nearestIndex(heights, parseFloat(height)) : {idx:-1,val:null};\r\n\r\n    const rows=[];\r\n    function pushRow(label, src){\r\n      let pair=null;\r\n      if(near.idx>=0 && src?.by_height && src.by_height.length===heights.length){\r\n        pair = src.by_height[near.idx];\r\n      } else if (src?.by_age){\r\n        pair = src.by_age;\r\n      }\r\n      if(!pair) return;\r\n      rows.push({pct:label, pair:parsePair(pair)});\r\n    }\r\n    pushRow('5th',  ageBlock.centiles['5th']);\r\n    pushRow('50th', ageBlock.centiles['50th']);\r\n    pushRow('90th', ageBlock.centiles['90th']);\r\n    pushRow('95th', ageBlock.centiles['95th']);\r\n    if(ageBlock.centiles['99th']){\r\n      let have=false;\r\n      if(near.idx>=0 && ageBlock.centiles['99th'].by_height && ageBlock.centiles['99th'].by_height.length===heights.length){\r\n        have = !!ageBlock.centiles['99th'].by_height[near.idx];\r\n      } else if (ageBlock.centiles['99th'].by_age){\r\n        have = true;\r\n      }\r\n      if(have) pushRow('99th', ageBlock.centiles['99th']);\r\n    }\r\n\r\n    let warning = '';\r\n    if(near.idx>=0){\r\n      const minH = Math.min(...heights), maxH = Math.max(...heights);\r\n      if (parseFloat(height) < minH || parseFloat(height) > maxH){\r\n        warning = `Outside reference height range (${minH}\u2013${maxH} cm). Using nearest ${near.val} cm.`;\r\n      } else if (Math.abs(near.val - parseFloat(height)) > 0.4){\r\n        warning = `Using nearest height ${near.val} cm.`;\r\n      }\r\n    } else if(height==null || height===''){\r\n      warning = 'No height entered: using age-based thresholds (if available).';\r\n    }\r\n\r\n    return {mode:'age', usedHeight:near.val, warning, table:rows};\r\n  }\r\n\r\n  function add5pair(p){ return {sbp:p.sbp+5, dbp:p.dbp+5}; }\r\n\r\n  function bpPercentileLabelSingle(val, map){\r\n    if(val==null || isNaN(val)) return '--';\r\n    const p5=map['5th'], p50=map['50th'], p90=map['90th'], p95=map['95th'], p99=map['99th'];\r\n    if(p5!=null && val < p5) return '<5th';\r\n    if(p50!=null && val < p50) return '5th\u2013<50th';\r\n    if(p90!=null && val < p90) return '50th\u2013<90th';\r\n    if(p95!=null && val < p95) return '90th\u2013<95th';\r\n    if(p99!=null && val <= p99) return '95th\u2013\u226499th';\r\n    if(p99!=null && val > p99) return '>99th';\r\n    if(p95!=null && val >= p95) return '\u226595th';\r\n    return '\u2014';\r\n  }\r\n\r\n  function bpCategorize(ageYears, sbp, dbp, rows){\r\n    if(sbp==null || dbp==null || isNaN(sbp) || isNaN(dbp)) return {text:'\u2014', detail:''};\r\n    if(ageYears >= 16){\r\n      if (sbp < 90 || dbp < 50) return {text:'Hypotension (adult)', detail:'< 90\/50 mmHg (either)'}; \r\n      if ((sbp >=130 && sbp <=139) || (dbp >=85 && dbp <=89)) return {text:'High normal (adult)', detail:'130\u2013139 \/ 85\u201389 (either)'};\r\n      if ((sbp >=140 && sbp <=159) || (dbp >=90 && dbp <=99)) return {text:'Hypertension Grade 1 (adult)', detail:'140\u2013159 \/ 90\u201399 (either)'};\r\n      if ((sbp >=160 && sbp <=179) || (dbp >=100 && dbp <=109)) return {text:'Hypertension Grade 2 (adult)', detail:'160\u2013179 \/ 100\u2013109 (either)'};\r\n      if (sbp >=180 || dbp >=110) return {text:'Hypertension Grade 3 (adult)', detail:'\u2265 180 \/ 110 (either)'};\r\n      return {text:'Normal (adult)', detail:'Below high-normal thresholds and \u2265 90\/50'};\r\n    }\r\n\r\n    const map={}; for(const r of rows){ map[r.pct]=r.pair; }\r\n    const p5 = map['5th'], p90=map['90th'], p95=map['95th'], p99=map['99th'];\r\n    const p99p5 = p99 ? add5pair(p99) : null;\r\n\r\n    if(p5 && (sbp < p5.sbp || dbp < p5.dbp)) return {text:'Hypotension (<5th centile)', detail:'Either SBP or DBP < 5th'};\r\n\r\n    if(p90 && p95){\r\n      const normal = (sbp < p90.sbp && dbp < p90.dbp);\r\n      if(normal) return {text:'Normal (<90th centile)', detail:'Both SBP and DBP < 90th'};\r\n      const highN = ((sbp >= p90.sbp || dbp >= p90.dbp) && (sbp < p95.sbp && dbp < p95.dbp));\r\n      if(highN) return {text:'High normal (90th\u2013<95th)', detail:'Either \u226590th and both <95th'};\r\n    }\r\n\r\n    if(p95){\r\n      const atLeast95 = (sbp >= p95.sbp || dbp >= p95.dbp);\r\n      if(atLeast95){\r\n        if(p99p5 && (sbp > p99p5.sbp || dbp > p99p5.dbp)){\r\n          return {text:'Hypertension Grade 2 (>99th + 5 mmHg)', detail:'Either SBP or DBP > 99th + 5'};\r\n        }\r\n        return {text:'Hypertension Grade 1 (\u226595th to \u226499th + 5)', detail:'Either \u226595th, not exceeding 99th + 5'};\r\n      }\r\n    }\r\n    return {text:'\u2014', detail:''};\r\n  }\r\n\r\n  function renderBpTable(rows, isAdult){\r\n    bpPctBody.innerHTML='';\r\n    const tableWrap = document.getElementById('bpPctTable').parentElement;\r\n    if(isAdult){ tableWrap.style.display='none'; return; }\r\n    tableWrap.style.display='';\r\n    const order=['5th','50th','90th','95th','99th'];\r\n    const by=Object.fromEntries(rows.map(r=>[r.pct,r.pair]));\r\n    for(const label of order){\r\n      if(!by[label]) continue;\r\n      const tr=document.createElement('tr');\r\n      tr.innerHTML = `<td style=\"border:1px solid #e5e7eb; padding:.5rem; text-align:center;\">${label}<\/td>\r\n                      <td style=\"border:1px solid #e5e7eb; padding:.5rem; text-align:center;\">${by[label].sbp ?? '\u2014'}<\/td>\r\n                      <td style=\"border:1px solid #e5e7eb; padding:.5rem; text-align:center;\">${by[label].dbp ?? '\u2014'}<\/td>`;\r\n      bpPctBody.appendChild(tr);\r\n    }\r\n  }\r\n\r\n  function computeBP(ageY, ageM){\r\n    const sexChar = (sexEl.value==='boy') ? 'M' : 'F';\r\n    const height = hEl.value==='' ? null : parseFloat(hEl.value);\r\n    const sbp = bpSbpEl.value==='' ? null : parseInt(bpSbpEl.value,10);\r\n    const dbp = bpDbpEl.value==='' ? null : parseInt(bpDbpEl.value,10);\r\n\r\n    const res = bpGetPercentiles(sexChar, ageY, ageM, height);\r\n\r\n    let s = `Sex: ${sexChar==='M'?'Male':'Female'} | Age: ${ageY}y ${ageM}m`;\r\n    if(res.mode==='infant'){\r\n      if([1,2,6].includes(res.usedMonth) && res.usedMonth!==ageM) s += ` (nearest available: ${res.usedMonth}m)`;\r\n    } else if(res.mode==='age' && res.usedHeight){ s += ` | Height used: ${res.usedHeight} cm`; }\r\n    bpSummaryEl.textContent = s;\r\n\r\n    if(res.warning){ bpWarnEl.style.display='block'; bpWarnEl.textContent = res.warning; }\r\n    else { bpWarnEl.style.display='none'; bpWarnEl.textContent=''; }\r\n    renderBpTable(res.table, res.mode==='adult');\r\n\r\n    const cat = bpCategorize(ageY, sbp, dbp, res.table);\r\n    bpCategoryEl.innerHTML = (cat.text==='\u2014') ? '\u2014' : `<strong>${cat.text}<\/strong>${cat.detail?`<div style=\"font-size:.9rem; color:#6b7280; margin-top:.25rem\">${cat.detail}<\/div>`:''}`;\r\n\r\n    let sbpPct='--', dbpPct='--';\r\n    if(res.mode==='adult'){\r\n      sbpPct = 'adult rule';\r\n      dbpPct = 'adult rule';\r\n    } else {\r\n      const by = Object.fromEntries(res.table.map(r=>[r.pct, r.pair]));\r\n      const sbpMap={}, dbpMap={};\r\n      for (const k in by){ if(!by[k]) continue; sbpMap[k]=by[k].sbp; dbpMap[k]=by[k].dbp; }\r\n      sbpPct = (sbp!=null && !isNaN(sbp)) ? bpPercentileLabelSingle(sbp, sbpMap) : '--';\r\n      dbpPct = (dbp!=null && !isNaN(dbp)) ? bpPercentileLabelSingle(dbp, dbpMap) : '--';\r\n    }\r\n\r\n    const bpLine = `BP ${ (sbp==null||isNaN(sbp))?'--':sbp }\/${ (dbp==null||isNaN(dbp))?'--':dbp } mmHg (${cat.text}${(sbpPct!=='--'||dbpPct!=='--')?`; SBP ${sbpPct}, DBP ${dbpPct}`:''})`;\r\n    return {bpLine, sbpPct, dbpPct};\r\n  }\r\n\r\n  \/\/ QR code loader + renderer\r\n  function ensureQRCodeLib() {\r\n    return new Promise((resolve, reject) => {\r\n      if (window.QRCode && window.QRCode.CorrectLevel) return resolve();\r\n      const s = document.createElement('script');\r\n      s.src = \"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/qrcodejs\/1.0.0\/qrcode.min.js\";\r\n      s.async = true;\r\n      s.onload = () => resolve();\r\n      s.onerror = () => reject(new Error(\"QR lib load failed\"));\r\n      document.head.appendChild(s);\r\n    });\r\n  }\r\n  async function renderQRCode(text){\r\n    qrcodeDiv.innerHTML = \"\";\r\n    try {\r\n      await ensureQRCodeLib();\r\n      const holder = document.createElement('div');\r\n      qrcodeDiv.appendChild(holder);\r\n      new QRCode(holder, { text, width:220, height:220, correctLevel: QRCode.CorrectLevel.M });\r\n      setTimeout(() => {\r\n        let canvas = holder.querySelector('canvas'); let img = holder.querySelector('img');\r\n        if (canvas && img) img.remove();\r\n        if (canvas) setupQRDownload(canvas);\r\n        else if (img && img.src){\r\n          const c = document.createElement('canvas'); c.width=220; c.height=220;\r\n          const ctx=c.getContext('2d'); const tmp=new Image(); tmp.crossOrigin='anonymous';\r\n          tmp.onload=()=>{ ctx.drawImage(tmp,0,0,220,220); setupQRDownload(c); }; tmp.src=img.src;\r\n        }\r\n      }, 50);\r\n    } catch(e) {\r\n      const img=document.createElement('img'); img.alt='QR'; img.width=220; img.height=220;\r\n      \/\/ Safe URL encoding preserves CRLF as %0D%0A\r\n      img.src=\"https:\/\/api.qrserver.com\/v1\/create-qr-code\/?size=220x220&data=\"+encodeURIComponent(text);\r\n      qrcodeDiv.appendChild(img);\r\n      downloadQRBtn.style.display=\"inline-block\";\r\n      downloadQRBtn.onclick=()=>{ const a=document.createElement('a'); a.download='summary_qr.png'; a.href=img.src; a.click(); };\r\n    }\r\n  }\r\n  function setupQRDownload(canvas){\r\n    downloadQRBtn.style.display = \"inline-block\";\r\n    downloadQRBtn.onclick = () => {\r\n      const a = document.createElement('a');\r\n      a.download = 'summary_qr.png';\r\n      a.href = canvas.toDataURL('image\/png');\r\n      a.click();\r\n    };\r\n  }\r\n\r\n  \/\/ Chart.js (lazy load)\r\n  let ChartLibReady = false;\r\n  function whenChartReady(){\r\n    return new Promise((resolve,reject)=>{\r\n      if(ChartLibReady) return resolve();\r\n      const s=document.createElement('script');\r\n      s.src=\"https:\/\/cdn.jsdelivr.net\/npm\/chart.js@4.4.1\/dist\/chart.umd.min.js\";\r\n      s.async=true;\r\n      s.onload=()=>{ ChartLibReady=true; resolve(); };\r\n      s.onerror=()=>reject(new Error(\"Chart.js load failed\"));\r\n      document.head.appendChild(s);\r\n    });\r\n  }\r\n\r\n  \/\/ Growth charts (inline)\r\n  let charts = {};\r\n  async function buildGrowthCharts(ageM){\r\n    try { await whenChartReady(); } catch(e){ console.warn(e); return; }\r\n    if(!GROWTH) return;\r\n\r\n    chartsSection.style.display = 'block';\r\n    const sex = sexEl.value;\r\n    const centKeys = [\"cent0.4\",\"cent2\",\"cent9\",\"cent25\",\"cent50\",\"cent75\",\"cent91\",\"cent98\",\"cent99.6\"];\r\n    const metrics = [\r\n      {key:'weight', label:'Weight (kg)', canvas:'chartWeight'},\r\n      {key:'height', label:'Height (cm)', canvas:'chartHeight'},\r\n      {key:'bmi',    label:'BMI (kg\/m\u00b2)', canvas:'chartBMI'},\r\n      {key:'hc',     label:'Head Circumference (cm)', canvas:'chartHC'},\r\n    ];\r\n\r\n    const sexData = GROWTH.data?.[sex];\r\n    if(!sexData) return;\r\n\r\n    const useYears = ageM >= 24;\r\n    const xLabel   = useYears ? \"Age (years)\" : \"Age (months)\";\r\n    const xMin     = 0;\r\n    const xMax     = useYears ? 18 : 24;\r\n    const stepSize = useYears ? 1 : 3;\r\n\r\n    for (const m of metrics){\r\n      const rows = sexData[m.key];\r\n      if(!rows || !rows.length) continue;\r\n\r\n      const ages = rows.map(r=>r.age_months);\r\n\r\n      const datasets = centKeys.map(ck => ({\r\n        label: ck.replace('cent','') + 'th',\r\n        data: ages.map(a => {\r\n          const r = interpolateRow(rows, a);\r\n          if(!r || r[ck]==null) return null;\r\n          return { x: useYears ? a\/12 : a, y: r[ck] };\r\n        }).filter(Boolean),\r\n        fill: false,\r\n        pointRadius: 0,\r\n        borderWidth: 1\r\n      }));\r\n\r\n      let patientVal=null;\r\n      if(m.key==='weight'){ const W=parseFloat(wEl.value); if(!isNaN(W)) patientVal=W; }\r\n      else if(m.key==='height'){ const H=parseFloat(hEl.value); if(!isNaN(H)) patientVal=H; }\r\n      else if(m.key==='bmi'){ const H=parseFloat(hEl.value),W=parseFloat(wEl.value); if(!isNaN(H)&&!isNaN(W)) patientVal=W\/Math.pow(H\/100,2); }\r\n      else if(m.key==='hc'){ const HC=parseFloat(hcEl.value); if(!isNaN(HC)) patientVal=HC; }\r\n\r\n      const patientDs = {\r\n        label:'Patient',\r\n        type:'scatter',\r\n        data:(patientVal!=null)?[{ x: useYears ? ageM\/12 : ageM, y: patientVal }]:[],\r\n        pointRadius:4,\r\n        borderWidth:0\r\n      };\r\n\r\n      if(charts[m.key]){ charts[m.key].destroy(); charts[m.key]=null; }\r\n      const ctx=document.getElementById(m.canvas).getContext('2d');\r\n\r\n      charts[m.key]=new Chart(ctx,{\r\n        type:'line',\r\n        data:{ datasets:[...datasets, patientDs] },\r\n        options:{\r\n          responsive:true,\r\n          maintainAspectRatio:false,\r\n          parsing:false,\r\n          normalized:true,\r\n          scales:{\r\n            x:{ type:'linear', title:{display:true,text:xLabel}, min:xMin, max:xMax, ticks:{ stepSize:stepSize } },\r\n            y:{ title:{display:true,text:m.label} }\r\n          },\r\n          plugins:{ legend:{ position:'bottom', labels:{boxWidth:16} }, tooltip:{ mode:'nearest', intersect:false } },\r\n          elements:{ line:{ tension:0 } }\r\n        }\r\n      });\r\n    }\r\n  }\r\n\r\n  \/\/ Modal (full-size) chart\r\n  let modalChart = null;\r\n  function metricLabel(key){\r\n    if (key==='weight') return 'Weight (kg)';\r\n    if (key==='height') return 'Height (cm)';\r\n    if (key==='bmi')    return 'BMI (kg\/m\u00b2)';\r\n    if (key==='hc')     return 'Head Circumference (cm)';\r\n    return key;\r\n  }\r\n  function getPatientValueForMetric(key){\r\n    const H=parseFloat(hEl.value), W=parseFloat(wEl.value), HC=parseFloat(hcEl.value);\r\n    if (key==='weight') return isNaN(W)?null:W;\r\n    if (key==='height') return isNaN(H)?null:H;\r\n    if (key==='bmi') { if (isNaN(H)||isNaN(W)) return null; return W\/Math.pow(H\/100,2); }\r\n    if (key==='hc') return isNaN(HC)?null:HC;\r\n    return null;\r\n  }\r\n  function buildMetricDatasets(sexData, metricKey, ageM){\r\n    const centKeys = [\"cent0.4\",\"cent2\",\"cent9\",\"cent25\",\"cent50\",\"cent75\",\"cent91\",\"cent98\",\"cent99.6\"];\r\n    const rows = sexData[metricKey];\r\n    if(!rows || !rows.length) return {datasets:[], xMin:0, xMax:24, xLabel:\"Age (months)\"};\r\n    const ages = rows.map(r=>r.age_months);\r\n    const useYears = ageM >= 24;\r\n    const xLabel   = useYears ? \"Age (years)\" : \"Age (months)\";\r\n    const xMin     = 0;\r\n    const xMax     = useYears ? 18 : 24;\r\n\r\n    const datasets = centKeys.map(ck => ({\r\n      label: ck.replace('cent','') + 'th',\r\n      data: ages.map(a => {\r\n        const r = interpolateRow(rows, a);\r\n        if(!r || r[ck]==null) return null;\r\n        return { x: useYears ? a\/12 : a, y: r[ck] };\r\n      }).filter(Boolean),\r\n      fill: false,\r\n      pointRadius: 0,\r\n      borderWidth: 1\r\n    }));\r\n\r\n    const pv = getPatientValueForMetric(metricKey);\r\n    const patientDs = {\r\n      label: 'Patient',\r\n      type: 'scatter',\r\n      data: (pv!=null) ? [{ x: useYears ? ageM\/12 : ageM, y: pv }] : [],\r\n      pointRadius: 5,\r\n      borderWidth: 0\r\n    };\r\n    return {datasets:[...datasets, patientDs], xMin, xMax, xLabel};\r\n  }\r\n  async function openChartModal(metricKey){\r\n    try { await whenChartReady(); } catch(e){ console.warn(e); return; }\r\n    if(!GROWTH) return;\r\n    const ageM = getAgeMonths();\r\n    const sex = sexEl.value;\r\n    const sexData = GROWTH.data?.[sex];\r\n    if(!sexData) return;\r\n\r\n    chartModalTitle.textContent = `Growth Chart \u2013 ${metricLabel(metricKey)}`;\r\n    if (modalChart){ modalChart.destroy(); modalChart = null; }\r\n    const {datasets, xMin, xMax, xLabel} = buildMetricDatasets(sexData, metricKey, ageM);\r\n\r\n    const ctx = modalCanvas.getContext('2d');\r\n    modalChart = new Chart(ctx, {\r\n      type:'line',\r\n      data:{ datasets },\r\n      options:{\r\n        responsive:true,\r\n        maintainAspectRatio:false,\r\n        parsing:false,\r\n        normalized:true,\r\n        scales:{\r\n          x:{ type:'linear', title:{display:true,text:xLabel}, min:xMin, max:xMax, ticks:{ stepSize: (xLabel.includes('years') ? 1 : 3) } },\r\n          y:{ title:{display:true,text:metricLabel(metricKey)} }\r\n        },\r\n        plugins:{ legend:{ position:'bottom', labels:{boxWidth:16} }, tooltip:{ mode:'nearest', intersect:false } },\r\n        elements:{ line:{ tension:0 } }\r\n      }\r\n    });\r\n\r\n    chartModal.style.display = 'flex';\r\n    document.body.style.overflow = 'hidden';\r\n  }\r\n  function closeChartModal(){\r\n    chartModal.style.display = 'none';\r\n    if (modalChart){ modalChart.destroy(); modalChart = null; document.body.style.overflow = ''; }\r\n  }\r\n  function initZoomButtons(){\r\n    document.querySelectorAll('.zoomBtn').forEach(btn=>{\r\n      btn.addEventListener('click', (e)=>{\r\n        const key = e.currentTarget.getAttribute('data-metric');\r\n        openChartModal(key);\r\n      });\r\n    });\r\n    chartModalClose.addEventListener('click', closeChartModal);\r\n    chartModal.addEventListener('click', (e)=>{ if(e.target === chartModal) closeChartModal(); });\r\n    document.addEventListener('keydown', (e)=>{ if(e.key==='Escape' && chartModal.style.display==='flex') closeChartModal(); });\r\n  }\r\n\r\n  \/\/ Section jump buttons\r\n  function initSectionJump(){\r\n    const bar = document.getElementById('sectionJump');\r\n    if (!bar) return;\r\n    bar.querySelectorAll('button[data-target]').forEach(btn=>{\r\n      btn.addEventListener('click', ()=>{\r\n        const sel = btn.getAttribute('data-target');\r\n        const el = document.querySelector(sel);\r\n        if (!el) return;\r\n        if (el.style && el.style.display === 'none') el.style.display = 'block';\r\n        const y = el.getBoundingClientRect().top + window.scrollY - 12;\r\n        window.scrollTo({ top: y, behavior: 'smooth' });\r\n      });\r\n    });\r\n  }\r\n\r\n  \/\/ Build plain summary\r\n  function buildPlainSummary(ageM, heightCls, weightCls, bmiCls, hcCls, nearest, bpLine){\r\n    const H  = parseFloat(hEl.value), W = parseFloat(wEl.value), HC = parseFloat(hcEl.value);\r\n    const bmi = (!isNaN(H) && !isNaN(W)) ? (W\/Math.pow(H\/100,2)) : NaN;\r\n\r\n    const bwLine = `BW ${isNaN(W) ? \"--\" : to1(W)} kg (${weightCls ? weightCls.detail : \"--\"})`;\r\n    const bhLine = `BH ${isNaN(H) ? \"--\" : to1(H)} cm (${heightCls ? heightCls.detail : \"--\"})`;\r\n    const bmiLine= `BMI ${isNaN(bmi)? \"--\" : to1(bmi)} (${bmiCls ? bmiCls.detail : \"--\"})`;\r\n    const hcLine = `HC ${isNaN(HC)? \"--\" : to1(HC)} cm (${hcCls ? hcCls.detail : \"--\"})`;\r\n\r\n    let dev = \"Developmental milestone of the age\\nGM: \\nFM: \\nVC: \\nVE: \\nSocial:\";\r\n    if (nearest){\r\n      dev = `Developmental milestone of the age\r\nGM: ${nearest.GM.join(\"; \")}\r\nFM: ${nearest.FM.join(\"; \")}\r\nVC: ${nearest.SpeechComprehension.join(\"; \")}\r\nVE: ${nearest.SpeechExpression.join(\"; \")}\r\nSocial: ${nearest.Social.join(\"; \")}`;\r\n    }\r\n    return `${bwLine}\r\n${bhLine}\r\n${bmiLine}\r\n${hcLine}\r\n${bpLine}\r\n\r\n${dev}`;\r\n  }\r\n\r\n  function showDevelopment(ageM){\r\n    if (!DEV || ageM >= 72){ devSection.style.display = \"none\"; return {nearest:null}; }\r\n    const all = DEV.milestones;\r\n    const nearest = all.reduce((a,b)=>Math.abs(b.age_months-ageM)<Math.abs(a.age_months-ageM)?b:a);\r\n    const idx = all.indexOf(nearest);\r\n    const prev = all[idx-1] || null, next = all[idx+1] || null;\r\n    function render(m){ if(!m) return \"\"; return `Age ${m.age_months} months:\\n- GM: ${m.GM.join(\"; \")}\\n- FM: ${m.FM.join(\"; \")}\\n- Speech (expression): ${m.SpeechExpression.join(\"; \")}\\n- Speech (comprehension): ${m.SpeechComprehension.join(\"; \")}\\n- Social: ${m.Social.join(\"; \")}\\nRed Flags: ${m.RedFlags.join(\"; \")}\\n\\n`; }\r\n    devContent.textContent = (render(prev)+render(nearest)+render(next)).trim();\r\n    devSection.style.display = \"block\";\r\n    return {nearest};\r\n  }\r\n\r\n  \/\/ Debounce + listeners\r\n  function debounce(fn, delay=250){ let t=null; return (...args) => { clearTimeout(t); t = setTimeout(()=>fn(...args), delay); }; }\r\n  const runAllDebounced = debounce(()=>{ if(GROWTH) runAll(); }, 200);\r\n\r\n  function initLiveUpdate(){\r\n    [sexEl, yearsEl, monthsEl].forEach(el => el.addEventListener('change', runAllDebounced));\r\n    [hEl, wEl, hcEl, bpSbpEl, bpDbpEl].forEach(el => el.addEventListener('input', runAllDebounced));\r\n  }\r\n\r\n  \/\/ Main compute\r\n  function runAll(){\r\n    if(!GROWTH){ statusEl.textContent = \"Growth data not loaded.\"; return; }\r\n\r\n    const ageM = getAgeMonths();\r\n    const sex = sexEl.value;\r\n    const sexData = GROWTH.data?.[sex];\r\n\r\n    wrapper.classList.remove('boy','girl');\r\n    wrapper.classList.add(sex);   \/\/ sex is \"boy\" or \"girl\"\r\n\r\n    if (!sexData){ statusEl.textContent = \"Missing growth data for sex.\"; return; }\r\n\r\n    const H = parseFloat(hEl.value), W = parseFloat(wEl.value), HC = parseFloat(hcEl.value);\r\n    heightOut.textContent = weightOut.textContent = bmiOut.textContent = hcOut.textContent = \"\u2014\";\r\n\r\n    const rowH = sexData.height ? interpolateRow(sexData.height, ageM) : null;\r\n    const rowW = sexData.weight ? interpolateRow(sexData.weight, ageM) : null;\r\n    const rowB = sexData.bmi    ? interpolateRow(sexData.bmi,    ageM) : null;\r\n    const rowHC= sexData.hc     ? interpolateRow(sexData.hc,     ageM) : null;\r\n\r\n    let heightCls=null, weightCls=null, bmiCls=null, hcCls=null;\r\n\r\n    if (!isNaN(H) && rowH){ heightCls = classify(H, rowH); heightOut.textContent = `Input: ${to1(H)} cm\\nResult: ${heightCls.detail}`; }\r\n    else heightOut.textContent = \"Provide height.\";\r\n\r\n    if (!isNaN(W) && rowW){ weightCls = classify(W, rowW); weightOut.textContent = `Input: ${to1(W)} kg\\nResult: ${weightCls.detail}`; }\r\n    else weightOut.textContent = \"Provide weight.\";\r\n\r\n    if (!isNaN(H) && !isNaN(W) && rowB){\r\n      const bmi = W \/ Math.pow(H\/100, 2);\r\n      bmiCls = classify(bmi, rowB);\r\n      bmiOut.textContent = `Computed BMI: ${to1(bmi)} kg\/m\u00b2\\nResult: ${bmiCls.detail}`;\r\n    } else bmiOut.textContent = \"Provide height + weight.\";\r\n\r\n    if (!isNaN(HC) && rowHC){ hcCls = classify(HC, rowHC); hcOut.textContent = `Input: ${to1(HC)} cm\\nResult: ${hcCls.detail}`; }\r\n    else hcOut.textContent = \"Provide HC.\";\r\n\r\n    ageMeta.textContent = `Age used: ${Math.floor(ageM\/12)}y ${ageM%12}m (${ageM} months)`;\r\n    centileTableDiv.innerHTML = buildCentileTable(ageM);\r\n    results.style.display = \"block\";\r\n\r\n    \/\/ show jump bar once results exist\r\n    const bar = document.getElementById('sectionJump');\r\n    if (bar) bar.style.display = 'flex';\r\n\r\n    \/\/ Development\r\n    const {nearest} = showDevelopment(ageM);\r\n\r\n    \/\/ BP\r\n    const ageY = parseInt(yearsEl.value||'0',10);\r\n    const {bpLine} = computeBP(ageY, parseInt(monthsEl.value||'0',10));\r\n\r\n    \/\/ Summary + QR\r\n    const summaryText = buildPlainSummary(ageM, heightCls, weightCls, bmiCls, hcCls, nearest, bpLine);\r\n\r\n    \/\/ Screen textbox: ASCII operators only\r\n    const normalizedSummary = asciiIneq(summaryText);\r\n    summarySection.style.display = \"block\";\r\n    plainSummary.textContent = normalizedSummary;\r\n\r\n    \/\/ QR payload: ASCII operators + CRLF line endings\r\n    const qrPayload = normalizeQRLinebreaks(normalizedSummary);\r\n    renderQRCode(qrPayload);\r\n\r\n    \/\/ Charts\r\n    buildGrowthCharts(ageM);\r\n  }\r\n\r\n  \/\/ Buttons\r\n  document.getElementById('calcBtn').addEventListener('click', runAll);\r\n  document.getElementById('resetBtn').addEventListener('click', () => {\r\n    document.getElementById('hkga-form').reset();\r\n    results.style.display = \"none\";\r\n    document.getElementById('sectionJump').style.display = \"none\";\r\n    devSection.style.display = \"none\";\r\n    summarySection.style.display = \"none\";\r\n    chartsSection.style.display = \"none\";\r\n    qrcodeDiv.innerHTML = \"\";\r\n    downloadQRBtn.style.display = \"none\";\r\n    statusEl.textContent = \"\";\r\n    \/\/ BP outputs\r\n    bpSummaryEl.textContent = \"\u2014\";\r\n    bpWarnEl.style.display=\"none\"; bpWarnEl.textContent=\"\";\r\n    bpPctBody.innerHTML = \"\";\r\n    bpCategoryEl.textContent = \"\u2014\";\r\n    \/\/ Destroy charts if any\r\n    Object.keys(charts).forEach(k => { if(charts[k]){ charts[k].destroy(); charts[k]=null; } });\r\n    \/\/ Close modal if open\r\n    if (chartModal.style.display==='flex') { closeChartModal(); }\r\n    runAllDebounced();\r\n  });\r\n\r\n  \/\/ Scroll-to-top button behavior\r\n  const scrollTopBtn = document.getElementById(\"scrollTopBtn\");\r\n  window.addEventListener(\"scroll\", () => {\r\n    if (document.documentElement.scrollTop > 200 || document.body.scrollTop > 200) {\r\n      scrollTopBtn.style.display = \"flex\";\r\n    } else {\r\n      scrollTopBtn.style.display = \"none\";\r\n    }\r\n  });\r\n  scrollTopBtn.addEventListener(\"click\", () => {\r\n    window.scrollTo({ top: 0, behavior: \"smooth\" });\r\n  });\r\n\r\n  \/\/ Boot\r\n  loadData();\r\n})();\r\n<\/script>\r\n\n\n\n\n<div class=\"wp-block-buttons is-content-justification-center is-layout-flex wp-container-core-buttons-is-layout-c83fbfdc wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button is-style-outline is-style-outline--1\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/tomchan.hk\/?page_id=339\">PT &amp; ET Threshold<\/a><\/div>\n<\/div>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"page-full.php","meta":{"footnotes":""},"tags":[],"class_list":["post-316","page","type-page","status-publish","article"],"_links":{"self":[{"href":"https:\/\/tomchan.hk\/index.php?rest_route=\/wp\/v2\/pages\/316","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tomchan.hk\/index.php?rest_route=\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/tomchan.hk\/index.php?rest_route=\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/tomchan.hk\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tomchan.hk\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=316"}],"version-history":[{"count":13,"href":"https:\/\/tomchan.hk\/index.php?rest_route=\/wp\/v2\/pages\/316\/revisions"}],"predecessor-version":[{"id":558,"href":"https:\/\/tomchan.hk\/index.php?rest_route=\/wp\/v2\/pages\/316\/revisions\/558"}],"wp:attachment":[{"href":"https:\/\/tomchan.hk\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=316"}],"wp:term":[{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tomchan.hk\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=316"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}