1// DOM controller for the Oral Multi-Dose Simulator. 2// Owns all UI wiring, rendering, and the plot; delegates every numeric step to 3// the pure engine. Theme is handled by shared.js ThemeManager. 4 5import { PRESETS } from './presets.js'; 6import { 7 kaFromBucket, solveKaFromTmax, propagate, propagateSaturable, buildSchedule, buildBaselineSchedule, 8 simulate, computeIntervalAUC, findIntervalExtrema, 9 computeMetrics, computePercentileBands, runMonteCarlo, mulberry32 10} from './oral-pk-engine.js'; 11 12function getTheme() { return document.documentElement.getAttribute('data-theme') || 'dark'; } 13 14// ============================================ 15// STATE 16// ============================================ 17let currentDoseForm = "IR"; 18let currentParamMode = "A"; 19let currentElimMode = "firstorder"; 20let scheduleEvents = []; 21let simulationData = null; 22 23// ============================================ 24// TIME FORMATTING HELPER 25// ============================================ 26function formatTime(hours) { 27 if (hours === null || hours === undefined || !isFinite(hours)) { 28 return '-'; 29 } 30 31 if (hours < 72) { 32 return hours.toFixed(1) + ' hr'; 33 } else { 34 const days = hours / 24; 35 if (days < 7) { 36 return hours.toFixed(0) + ' hr (' + days.toFixed(1) + ' days)'; 37 } else { 38 const weeks = days / 7; 39 return days.toFixed(1) + ' days (' + weeks.toFixed(1) + ' wk)'; 40 } 41 } 42} 43 44function formatTimeShort(hours) { 45 if (hours === null || hours === undefined || !isFinite(hours)) { 46 return '-'; 47 } 48 49 if (hours < 72) { 50 return hours.toFixed(1) + ' hr'; 51 } else { 52 const days = hours / 24; 53 return days.toFixed(1) + ' d'; 54 } 55} 56 57// ============================================ 58// UI HELPERS 59// ============================================ 60function setDoseForm(form) { 61 currentDoseForm = form; 62 document.querySelectorAll('.doseform-container .mode-card').forEach(c => c.classList.remove('active')); 63 document.getElementById('card-' + form).classList.add('active'); 64 65 document.getElementById('delayedOptions').style.display = (form === 'Delayed') ? 'block' : 'none'; 66 document.getElementById('erOptions').style.display = (form === 'ER') ? 'block' : 'none'; 67} 68 69function setParamMode(mode) { 70 currentParamMode = mode; 71 document.querySelectorAll('.mode-container .mode-card').forEach(c => c.classList.remove('active')); 72 document.getElementById('card-mode' + mode).classList.add('active'); 73 74 document.getElementById('modeAInputs').style.display = (mode === 'A') ? 'block' : 'none'; 75 document.getElementById('modeBInputs').style.display = (mode === 'B') ? 'block' : 'none'; 76 document.getElementById('modeCInputs').style.display = (mode === 'C') ? 'block' : 'none'; 77} 78 79function setElimMode(mode) { 80 currentElimMode = mode; 81 document.getElementById('elimMode').value = mode; 82 document.getElementById('mmInputs').style.display = (mode === 'mm') ? 'block' : 'none'; 83 document.getElementById('zeroInputs').style.display = (mode === 'zero') ? 'block' : 'none'; 84} 85 86function toggleCollapsible(id, header) { 87 const content = document.getElementById(id); 88 const isOpen = content.classList.toggle('open'); 89 header.classList.toggle('open', isOpen); 90 header.setAttribute('aria-expanded', String(isOpen)); 91} 92 93function toggleDropdown(id, header) { 94 const content = document.getElementById(id); 95 const isOpen = content.classList.toggle('open'); 96 if (header) header.setAttribute('aria-expanded', String(isOpen)); 97} 98 99// ============================================ 100// PRESET LOADING 101// ============================================ 102function loadPreset() { 103 const presetKey = document.getElementById('presetSelect').value; 104 const banner = document.getElementById('nonOralBanner'); 105 const presetInfo = document.getElementById('presetInfo'); 106 107 if (presetKey === 'custom') { 108 banner.classList.remove('visible'); 109 presetInfo.innerHTML = ''; 110 return; 111 } 112 113 const preset = PRESETS[presetKey]; 114 if (!preset) return; 115 116 // Show/hide non-oral banner (with a preset-specific message) 117 if (!preset.isOral) { 118 banner.textContent = preset.bannerText || 'NOT ORAL - shown for educational contrast only'; 119 banner.classList.add('visible'); 120 } else { 121 banner.classList.remove('visible'); 122 } 123 124 // Populate fields 125 document.getElementById('dose').value = preset.dose; 126 document.getElementById('tau').value = preset.tau; 127 document.getElementById('numDoses').value = preset.numDoses || 3; 128 document.getElementById('halfLife').value = preset.halfLife; 129 document.getElementById('bioavailability').value = preset.bioavailability; 130 document.getElementById('tLag').value = preset.tLag || 0; 131 if (preset.tRel) document.getElementById('tRel').value = preset.tRel; 132 133 // Set dose form 134 setDoseForm(preset.doseForm); 135 136 // Set param mode 137 setParamMode(preset.paramMode); 138 139 // Set elimination kinetics (first-order unless the preset is saturable) 140 const elimMode = preset.elimMode || 'firstorder'; 141 setElimMode(elimMode); 142 if (elimMode === 'mm') { 143 if (preset.Vmax) document.getElementById('vmaxInput').value = preset.Vmax; 144 if (preset.Km) document.getElementById('kmInput').value = preset.Km; 145 } else if (elimMode === 'zero') { 146 if (preset.k0) document.getElementById('k0Input').value = preset.k0; 147 } 148 149 // Mode-specific values 150 if (preset.paramMode === 'B' && preset.tmax) { 151 document.getElementById('tmax').value = preset.tmax; 152 } 153 if (preset.paramMode === 'C' && preset.ka) { 154 document.getElementById('kaInput').value = preset.ka; 155 } 156 157 // Update uncertainty ranges if available 158 if (preset.halfLifeRange) { 159 document.getElementById('halfLifeMin').value = preset.halfLifeRange[0]; 160 document.getElementById('halfLifeMax').value = preset.halfLifeRange[1]; 161 } 162 163 // Build info text 164 let infoHtml = preset.notes; 165 if (preset.refs && preset.refs.length > 0) { 166 infoHtml += ' ';
167 preset.refs.forEach(ref => { 168 infoHtml += `<a href="${ref.url}" target="_blank" rel="noopener" class="preset-ref">[${ref.label}]</a> `; 169 }); 170 } 171 if (preset.analyteNote) { 172 infoHtml = `<strong>${preset.analyteNote}.</strong> ` + infoHtml; 173 } 174 if (preset.healthWarning) { 175 infoHtml += `<br><span style="color: var(--accent-error);">â ï¸ ${preset.healthWarning}</span>`; 176 } 177 presetInfo.innerHTML = infoHtml; 178} 179 180// ============================================ 181// EVENT EDITOR 182// ============================================ 183function addEvent() { 184 const doseNum = parseInt(document.getElementById('eventDoseNum').value); 185 const eventType = document.getElementById('eventType').value; 186 const shift = parseFloat(document.getElementById('eventShift').value) || 0; 187 const customDose = parseFloat(document.getElementById('eventCustomDose').value) || 0; 188 189 if (doseNum < 1) return; 190 191 // Check for duplicate 192 const exists = scheduleEvents.find(e => e.doseNum === doseNum); 193 if (exists) { 194 showError(`Dose #${doseNum} already has a disruption. Remove it first.`); 195 return; 196 } 197 clearError(); 198 199 const event = { doseNum, eventType, shift }; 200 if (eventType === 'custom') { 201 event.customDose = customDose; 202 } 203 204 scheduleEvents.push(event); 205 renderEventList(); 206} 207 208function removeEvent(index) { 209 scheduleEvents.splice(index, 1); 210 renderEventList(); 211} 212 213function renderEventList() { 214 const list = document.getElementById('eventList'); 215 if (scheduleEvents.length === 0) { 216 list.innerHTML = '<div style="padding: 12px; color: var(--text-muted); font-size: 0.75rem; text-align: center;">No disruptions added</div>'; 217 return; 218 } 219 220 list.innerHTML = scheduleEvents.map((e, i) => { 221 let desc = `Dose #${e.doseNum}: `; 222 switch (e.eventType) { 223 case 'missed': desc += 'MISSED'; break; 224 case 'late': desc += `Late by ${e.shift}h`; break; 225 case 'early': desc += `Early by ${e.shift}h`; break; 226 case 'double': desc += 'DOUBLED'; break; 227 case 'custom': desc += `${e.customDose}mg`; break; 228 } 229 return ` 230 <div class="event-item"> 231 <span><span class="event-type ${e.eventType}">${e.eventType.toUpperCase()}</span> ${desc}</span> 232 <button class="event-remove" data-remove="${i}" aria-label="Remove disruption">â</button> 233 </div> 234 `; 235 }).join(''); 236} 237 238function toggleEventInputs() { 239 const eventType = document.getElementById('eventType').value; 240 const shiftGroup = document.getElementById('eventShiftGroup'); 241 const customDoseGroup = document.getElementById('eventCustomDoseGroup'); 242 243 if (eventType === 'custom') { 244 shiftGroup.style.display = 'none'; 245 customDoseGroup.style.display = 'block'; 246 } else if (eventType === 'late' || eventType === 'early') { 247 shiftGroup.style.display = 'block'; 248 customDoseGroup.style.display = 'none'; 249 } else { 250 // missed or double - hide both optional inputs 251 shiftGroup.style.display = 'none'; 252 customDoseGroup.style.display = 'none'; 253 } 254} 255 256// ============================================ 257// UNCERTAINTY TOGGLE 258// ============================================ 259document.getElementById('uncertaintyEnabled').addEventListener('change', function() { 260 document.getElementById('uncertaintyInputs').style.display = this.checked ? 'block' : 'none'; 261}); 262 263// ============================================ 264// REAL TIME AXIS TOGGLE 265// ============================================ 266document.getElementById('showRealTime').addEventListener('change', function() { 267 document.getElementById('realTimeOptions').style.display = this.checked ? 'block' : 'none'; 268 // Re-render plot if we have data 269 if (simulationData) { 270 const Vd = parseFloat(document.getElementById('vd').value) || null; 271 renderPlot(simulationData.result, simulationData.mcBands || null, Vd); 272 } 273}); 274 275function initializeDateTimeInput() { 276 const now = new Date(); 277 // Format as YYYY-MM-DDTHH:MM for datetime-local input 278 const year = now.getFullYear();
279 const month = String(now.getMonth() + 1).padStart(2, '0'); 280 const day = String(now.getDate()).padStart(2, '0'); 281 const hours = String(now.getHours()).padStart(2, '0'); 282 const minutes = String(now.getMinutes()).padStart(2, '0'); 283 document.getElementById('firstDoseTime').value = `${year}-${month}-${day}T${hours}:${minutes}`; 284} 285 286function getFirstDoseDate() { 287 const input = document.getElementById('firstDoseTime').value; 288 if (!input) return null; 289 return new Date(input); 290} 291 292function formatRealTime(hoursFromStart) { 293 const firstDose = getFirstDoseDate(); 294 if (!firstDose) return null; 295 296 const msOffset = hoursFromStart * 60 * 60 * 1000; 297 const realTime = new Date(firstDose.getTime() + msOffset); 298 299 // Format nicely 300 const options = { 301 weekday: 'short', 302 month: 'short', 303 day: 'numeric', 304 hour: 'numeric', 305 minute: '2-digit', 306 hour12: true 307 }; 308 return realTime.toLocaleDateString('en-US', options); 309} 310 311function formatRealTimeShort(hoursFromStart) { 312 const firstDose = getFirstDoseDate(); 313 if (!firstDose) return null; 314 315 const msOffset = hoursFromStart * 60 * 60 * 1000; 316 const realTime = new Date(firstDose.getTime() + msOffset); 317
318 const month = String(realTime.getMonth() + 1).padStart(2, '0'); 319 const day = String(realTime.getDate()).padStart(2, '0'); 320 const hours = realTime.getHours(); 321 const minutes = String(realTime.getMinutes()).padStart(2, '0'); 322 const ampm = hours >= 12 ? 'PM' : 'AM'; 323 const h12 = hours % 12 || 12; 324 325 // Return object with date and time on separate lines 326 return { 327 date: `${month}/${day}`, 328 time: `${h12}:${minutes}${ampm}` 329 }; 330} 331 332 333// ============================================ 334// PLOTTING 335// ============================================ 336let plotChart = null; 337 338function renderPlot(simResult, mcBands = null, Vd = null) { 339 const canvas = document.getElementById('pkPlot'); 340 const ctx = canvas.getContext('2d'); 341 342 // Set canvas size 343 // const container = canvas.parentElement; 344 // canvas.width = container.clientWidth; 345 // canvas.height = container.clientHeight; 346 //set canvas internal size to match its displayed size (CSS pixels) 347 canvas.width = canvas.clientWidth; 348 canvas.height = canvas.clientHeight; 349 350 351 const { times, Ac_values } = simResult; 352 353 // Determine if showing concentration or amount 354 const showConc = Vd && Vd > 0; 355 const yValues = showConc ? Ac_values.map(a => a / Vd) : Ac_values; 356 const yLabel = showConc ? 'Model Concentration (mg/L)' : 'Amount Ac(t) (mg)'; 357 358 // Find data range 359 let yMax = Math.max(...yValues) * 1.1; 360 let yMin = 0; 361 362 if (mcBands) { 363 const band90 = showConc ? mcBands.bands[90].map(v => v / Vd) : mcBands.bands[90]; 364 yMax = Math.max(yMax, Math.max(...band90) * 1.1); 365 } 366 367 const xMax = times[times.length - 1]; 368 const showDays = xMax > 72; // Show days if duration > 72 hours 369 const showRealTimeForPadding = document.getElementById('showRealTime').checked; 370 371 // Mobile detection based on canvas width 372 const isMobile = canvas.width < 500; 373 const isNarrow = canvas.width < 700; 374 375 // Responsive font sizes 376 const fontSize = { 377 axis: isMobile ? 9 : 12, 378 secondary: isMobile ? 8 : 10, 379 realTime: isMobile ? 7 : 9 380 }; 381 382 // Calculate bottom padding based on what we're showing 383 // let bottomPadding = 50; 384 // if (showDays) bottomPadding = 65; 385 // if (showRealTimeForPadding) bottomPadding += 18; 386 387 // Plotting area 388 // const padding = { top: 30, right: 30, bottom: bottomPadding, left: 70 }; 389 390 let bottomPadding = isMobile ? 40 : 50; 391 if (showDays) bottomPadding = isMobile ? 50 : 65; 392 if (showRealTimeForPadding) bottomPadding += isMobile ? 24 : 32; 393 394 const padding = { top: 30, right: isMobile ? 15 : 30, bottom: bottomPadding, left: isMobile ? 50 : 70 }; 395 const plotWidth = canvas.width - padding.left - padding.right; 396 const plotHeight = canvas.height - padding.top - padding.bottom; 397 398 // Scale functions 399 const scaleX = (t) => padding.left + (t / xMax) * plotWidth; 400 const scaleY = (y) => padding.top + plotHeight - (y / yMax) * plotHeight; 401 402 // Clear 403 ctx.clearRect(0, 0, canvas.width, canvas.height); 404 405 // Get theme colors 406 const isDark = getTheme() === 'dark'; 407 const gridColor = isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; 408 const textColor = isDark ? '#a1a1aa' : '#475569'; 409 const textMutedColor = isDark ? '#71717a' : '#94a3b8'; 410 const lineColor = '#10b981'; 411 const bandColor = 'rgba(16, 185, 129, 0.2)'; 412 413 // Draw grid 414 ctx.strokeStyle = gridColor; 415 ctx.lineWidth = 1; 416 417 // Y grid 418 const yTicks = 5; 419 for (let i = 0; i <= yTicks; i++) { 420 const y = padding.top + (i / yTicks) * plotHeight; 421 ctx.beginPath();
422 ctx.moveTo(padding.left, y); 423 ctx.lineTo(canvas.width - padding.right, y); 424 ctx.stroke(); 425 } 426 427 // X grid - adaptive tick spacing 428 let xTicks, xTickInterval; 429 const maxXTicks = isMobile ? 5 : (isNarrow ? 7 : 10); 430 if (showDays) { 431 // For long durations, use day-based ticks 432 const totalDays = xMax / 24; 433 if (totalDays <= 14) { 434 xTickInterval = isMobile ? 48 : 24; // Every day or every 2 days on mobile 435 } else if (totalDays <= 60) { 436 xTickInterval = isMobile ? 24 * 14 : 24 * 7; // Every 2 weeks or 1 week 437 } else { 438 xTickInterval = 24 * 7; // Every week 439 } 440 xTicks = Math.min(maxXTicks, Math.ceil(xMax / xTickInterval)); 441 } else { 442 xTicks = Math.min(maxXTicks, Math.ceil(xMax / 6)); 443 xTickInterval = xMax / xTicks; 444 } 445 446 for (let i = 0; i <= xTicks; i++) { 447 const t = showDays ? i * xTickInterval : (i / xTicks) * xMax; 448 if (t > xMax) break; 449 const x = scaleX(t); 450 ctx.beginPath(); 451 ctx.moveTo(x, padding.top); 452 ctx.lineTo(x, canvas.height - padding.bottom); 453 ctx.stroke(); 454 } 455 456 // Draw uncertainty band if present 457 if (mcBands) { 458 const band10 = showConc ? mcBands.bands[10].map(v => v / Vd) : mcBands.bands[10]; 459 const band90 = showConc ? mcBands.bands[90].map(v => v / Vd) : mcBands.bands[90]; 460 461 ctx.fillStyle = bandColor; 462 ctx.beginPath(); 463 ctx.moveTo(scaleX(mcBands.times[0]), scaleY(band10[0])); 464 465 // Upper edge 466 for (let i = 0; i < mcBands.times.length; i++) { 467 ctx.lineTo(scaleX(mcBands.times[i]), scaleY(band90[i])); 468 } 469 470 // Lower edge (reverse) 471 for (let i = mcBands.times.length - 1; i >= 0; i--) { 472 ctx.lineTo(scaleX(mcBands.times[i]), scaleY(band10[i])); 473 } 474 475 ctx.closePath(); 476 ctx.fill(); 477 } 478 479 // Draw main curve 480 ctx.strokeStyle = lineColor; 481 ctx.lineWidth = 2; 482 ctx.beginPath(); 483 ctx.moveTo(scaleX(times[0]), scaleY(yValues[0])); 484 485 for (let i = 1; i < times.length; i++) { 486 ctx.lineTo(scaleX(times[i]), scaleY(yValues[i])); 487 } 488 ctx.stroke(); 489 490 // Draw axes labels 491 ctx.fillStyle = textColor; 492 ctx.font = `${fontSize.axis}px "JetBrains Mono", monospace`; 493 ctx.textAlign = 'center'; 494 495 // X axis labels 496 const showRealTime = document.getElementById('showRealTime').checked; 497 498 for (let i = 0; i <= xTicks; i++) { 499 const t = showDays ? i * xTickInterval : (i / xTicks) * xMax; 500 if (t > xMax) break; 501 const x = scaleX(t); 502 503 if (showDays) { 504 // Show hours on first line 505 ctx.fillStyle = textColor; 506 ctx.font = `${fontSize.axis}px "JetBrains Mono", monospace`; 507 ctx.fillText(t.toFixed(0) + 'h', x, canvas.height - padding.bottom + (isMobile ? 12 : 18)); 508 // Show days on second line 509 ctx.fillStyle = textMutedColor; 510 ctx.font = `${fontSize.secondary}px "JetBrains Mono", monospace`; 511 ctx.fillText('(' + (t/24).toFixed(1) + 'd)', x, canvas.height - padding.bottom + (isMobile ? 22 : 32)); 512 ctx.font = `${fontSize.axis}px "JetBrains Mono", monospace`; 513 } else { 514 ctx.fillStyle = textColor; 515 ctx.fillText(t.toFixed(1), x, canvas.height - padding.bottom + (isMobile ? 12 : 18)); 516 } 517 518 // Show real time if enabled (date and time on separate lines) 519 if (showRealTime) { 520 const realTimeData = formatRealTimeShort(t); 521 if (realTimeData) { 522 ctx.fillStyle = isDark ? '#06b6d4' : '#0891b2'; // accent-info color 523 ctx.font = `${fontSize.realTime}px "JetBrains Mono", monospace`; 524 const yOffsetDate = showDays ? (isMobile ? 32 : 46) : (isMobile ? 22 : 32); 525 const yOffsetTime = yOffsetDate + (isMobile ? 9 : 11); // Line spacing 526 ctx.fillText(realTimeData.date, x, canvas.height - padding.bottom + yOffsetDate); 527 ctx.fillText(realTimeData.time, x, canvas.height - padding.bottom + yOffsetTime); 528 ctx.font = `${fontSize.axis}px "JetBrains Mono", monospace`; 529 } 530 } 531 } 532 533 // X axis title 534 ctx.fillStyle = textColor; 535 ctx.font = `${fontSize.axis}px "JetBrains Mono", monospace`; 536 let xAxisLabel = showDays ? 'Time (hours / days)' : 'Time (hours)'; 537 if (showRealTime) { 538 xAxisLabel += isMobile ? '' : ' (real time in blue)'; 539 } 540 ctx.fillText(xAxisLabel, canvas.width / 2, canvas.height - 6); 541 542 // Y axis labels 543 ctx.textAlign = 'right'; 544 for (let i = 0; i <= yTicks; i++) {
545 const val = yMax * (1 - i / yTicks); 546 const y = padding.top + (i / yTicks) * plotHeight; 547 ctx.fillText(val.toFixed(isMobile ? 0 : 1), padding.left - (isMobile ? 5 : 10), y + 4); 548 } 549 550 // Y axis title 551 ctx.save(); 552 ctx.translate(isMobile ? 10 : 15, canvas.height / 2); 553 ctx.rotate(-Math.PI / 2); 554 ctx.textAlign = 'center'; 555 ctx.font = `${fontSize.axis}px "JetBrains Mono", monospace`; 556 ctx.fillText(isMobile ? (showConc ? 'Conc (mg/L)' : 'Amount (mg)') : yLabel, 0, 0); 557 ctx.restore(); 558 559 // Update legend 560 const legend = document.getElementById('plotLegend'); 561 if (showConc) { 562 legend.innerHTML = ` 563 <div class="legend-item"> 564 <div class="legend-color" style="background: ${lineColor};"></div> 565 <span>C(t): Model Concentration (mg/L)</span> 566 </div> 567 ${mcBands ? ` 568 <div class="legend-item"> 569 <div class="legend-color" style="background: ${bandColor}; height: 10px;"></div> 570 <span>10-90% Uncertainty Band</span> 571 </div> 572 ` : ''} 573 `; 574 } else { 575 legend.innerHTML = ` 576 <div class="legend-item"> 577 <div class="legend-color" style="background: ${lineColor};"></div> 578 <span>A<sub>c</sub>(t): Amount (mg)</span> 579 </div> 580 ${mcBands ? ` 581 <div class="legend-item"> 582 <div class="legend-color" style="background: ${bandColor}; height: 10px;"></div> 583 <span>10-90% Uncertainty Band</span> 584 </div> 585 ` : ''} 586 `; 587 } 588 589 // Store plot state for hover interactions 590 currentPlotState = { 591 times, 592 yValues, 593 mcBands, 594 Vd, 595 showConc, 596 xMax, 597 yMax, 598 padding, 599 plotWidth, 600 plotHeight, 601 showDays 602 }; 603 604 // Save the rendered plot for efficient hover interactions 605 saveCanvasState(); 606} 607 608// ============================================ 609// PLOT HOVER INTERACTION 610// ============================================ 611let currentPlotState = null; 612let isHovering = false; 613let savedPlotImage = null; 614 615function setupPlotHover() { 616 const canvas = document.getElementById('pkPlot'); 617 const tooltip = document.getElementById('plotTooltip'); 618 const container = canvas.parentElement; 619 620 canvas.addEventListener('mousemove', handlePlotHover); 621 canvas.addEventListener('mouseleave', handlePlotLeave); 622 canvas.addEventListener('mouseenter', () => { isHovering = true; }); 623} 624 625function saveCanvasState() { 626 const canvas = document.getElementById('pkPlot'); 627 const ctx = canvas.getContext('2d'); 628 savedPlotImage = ctx.getImageData(0, 0, canvas.width, canvas.height); 629} 630 631function restoreCanvasState() { 632 if (!savedPlotImage) return; 633 const canvas = document.getElementById('pkPlot'); 634 const ctx = canvas.getContext('2d'); 635 ctx.putImageData(savedPlotImage, 0, 0); 636} 637 638function handlePlotHover(e) { 639 if (!currentPlotState) return; 640 641 const canvas = document.getElementById('pkPlot'); 642 const tooltip = document.getElementById('plotTooltip'); 643 const rect = canvas.getBoundingClientRect(); 644 645 // const mouseX = e.clientX - rect.left; 646 // const mouseY = e.clientY - rect.top; 647 //map CSS pixels -> canvas internal pixels (prevents hover offset) 648 const scaleX = canvas.width / rect.width; 649 const scaleY = canvas.height / rect.height; 650 651 const mouseX = (e.clientX - rect.left) * scaleX; 652 const mouseY = (e.clientY - rect.top) * scaleY; 653 654 const { times, yValues, mcBands, Vd, showConc, xMax, yMax, padding, plotWidth, plotHeight } = currentPlotState; 655 656 // Check if mouse is in plot area 657 if (mouseX < padding.left || mouseX > padding.left + plotWidth || 658 mouseY < padding.top || mouseY > padding.top + plotHeight) { 659 tooltip.classList.remove('visible'); 660 restoreCanvasState(); 661 return; 662 } 663 664 // Convert mouse position to time 665 const t = ((mouseX - padding.left) / plotWidth) * xMax; 666 667 // Find nearest data point using binary search for efficiency 668 let nearestIdx = 0; 669 let lo = 0, hi = times.length - 1; 670 while (lo < hi) { 671 const mid = Math.floor((lo + hi) / 2); 672 if (times[mid] < t) { 673 lo = mid + 1; 674 } else { 675 hi = mid; 676 } 677 } 678 // Check if lo or lo-1 is closer 679 if (lo > 0 && Math.abs(times[lo-1] - t) < Math.abs(times[lo] - t)) { 680 nearestIdx = lo - 1; 681 } else { 682 nearestIdx = lo; 683 } 684 685 const time = times[nearestIdx]; 686 const value = yValues[nearestIdx]; 687 688 // Update tooltip content 689 const showRealTime = document.getElementById('showRealTime').
689checked; 690 691 // Time display 692 let timeStr = formatTimeShort(time); 693 document.getElementById('tooltipTime').textContent = timeStr; 694 695 // Amount/concentration display 696 const label = showConc ? 'Conc:' : 'Amount:'; 697 const unit = showConc ? ' mg/L' : ' mg'; 698 document.getElementById('tooltipAmountLabel').textContent = label; 699 document.getElementById('tooltipAmount').textContent = value.toFixed(2) + unit; 700 701 // Uncertainty range if available 702 const rangeEl = document.getElementById('tooltipRange'); 703 if (mcBands && mcBands.bands) { 704 const band10 = showConc ? mcBands.bands[10][nearestIdx] / Vd : mcBands.bands[10][nearestIdx]; 705 const band90 = showConc ? mcBands.bands[90][nearestIdx] / Vd : mcBands.bands[90][nearestIdx]; 706 document.getElementById('tooltipRangeValues').textContent = 707 band10.toFixed(2) + ' to ' + band90.toFixed(2) + unit; 708 rangeEl.style.display = 'block'; 709 } else { 710 rangeEl.style.display = 'none'; 711 } 712 713 // Real time display 714 const datetimeEl = document.getElementById('tooltipDatetime'); 715 if (showRealTime) { 716 const realTimeStr = formatRealTime(time); 717 if (realTimeStr) { 718 document.getElementById('tooltipDatetimeValue').textContent = realTimeStr; 719 datetimeEl.style.display = 'block'; 720 } else { 721 datetimeEl.style.display = 'none'; 722 } 723 } else { 724 datetimeEl.style.display = 'none'; 725 } 726 727 // Position tooltip - shift based on which half of chart we're in 728 const container = canvas.parentElement; 729 const chartCenterX = padding.left + plotWidth / 2; 730 const isRightHalf = mouseX > chartCenterX; 731 732 const tooltipWidth = 200; 733 const tooltipHeight = 100; 734 735 // Scale mouse position back to CSS pixels for tooltip positioning 736 const cssMouseX = mouseX / scaleX; 737 const cssMouseY = mouseY / scaleY; 738 739 let tooltipX, tooltipY; 740 741 // On right half, show tooltip to the left of cursor; on left half, show to the right 742 if (isRightHalf) { 743 tooltipX = cssMouseX - tooltipWidth - 15; 744 } else { 745 tooltipX = cssMouseX + 15; 746 } 747 748 tooltipY = cssMouseY - 10; 749 750 // Keep tooltip in vertical bounds 751 if (tooltipY + tooltipHeight > container.clientHeight - 10) { 752 tooltipY = container.clientHeight - tooltipHeight - 10; 753 } 754 if (tooltipY < 10) tooltipY = 10; 755 756 // Final horizontal bounds check (fallback) 757 if (tooltipX < 10) tooltipX = 10; 758 if (tooltipX + tooltipWidth > container.clientWidth - 10) { 759 tooltipX = container.clientWidth - tooltipWidth - 10; 760 } 761 762 tooltip.style.left = tooltipX + 'px'; 763 tooltip.style.top = tooltipY + 'px'; 764 tooltip.classList.add('visible'); 765 766 // Draw crosshair (restore base plot first) 767 restoreCanvasState(); 768 drawCrosshair(nearestIdx); 769} 770 771function handlePlotLeave() { 772 const tooltip = document.getElementById('plotTooltip'); 773 tooltip.classList.remove('visible'); 774 isHovering = false; 775 776 // Restore plot without crosshair 777 restoreCanvasState(); 778} 779 780function drawCrosshair(highlightIdx) { 781 if (!currentPlotState || highlightIdx === null) return; 782 783 const canvas = document.getElementById('pkPlot'); 784 const ctx = canvas.getContext('2d'); 785 786 const { times, yValues, xMax, yMax, padding, plotWidth, plotHeight } = currentPlotState; 787 788 // Get theme colors 789 const isDark = getTheme() === 'dark'; 790 const crosshairColor = isDark ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.2)'; 791 const dotColor = '#10b981'; 792 793 // Scale functions 794 const scaleX = (t) => padding.left + (t / xMax) * plotWidth; 795 const scaleY = (y) => padding.top + plotHeight - (y / yMax) * plotHeight; 796 797 const x = scaleX(times[highlightIdx]); 798 const y = scaleY(yValues[highlightIdx]); 799 800 // Draw vertical crosshair line 801 ctx.strokeStyle = crosshairColor; 802 ctx.lineWidth = 1; 803 ctx.setLineDash([4, 4]); 804 ctx.beginPath(); 805 ctx.moveTo(x, padding.top); 806 ctx.lineTo(x, padding.top + plotHeight); 807 ctx.stroke(); 808 809 // Draw horizontal line to y-axis 810 ctx.beginPath();
811 ctx.moveTo(padding.left, y); 812 ctx.lineTo(x, y); 813 ctx.stroke(); 814 ctx.setLineDash([]); 815 816 // Draw highlight dot 817 ctx.fillStyle = dotColor; 818 ctx.beginPath(); 819 ctx.arc(x, y, 5, 0, Math.PI * 2); 820 ctx.fill(); 821 822 // Draw white border on dot 823 ctx.strokeStyle = isDark ? '#fafafa' : '#0f172a'; 824 ctx.lineWidth = 2; 825 ctx.beginPath(); 826 ctx.arc(x, y, 5, 0, Math.PI * 2); 827 ctx.stroke(); 828} 829 830// ============================================ 831// MAIN SIMULATION RUNNER 832// ============================================ 833function runSimulation() { 834 try { 835 // Gather inputs 836 const dose = parseFloat(document.getElementById('dose').value); 837 const tau = parseFloat(document.getElementById('tau').value); 838 const numDoses = parseInt(document.getElementById('numDoses').value); 839 const halfLife = parseFloat(document.getElementById('halfLife').value); 840 const F = parseFloat(document.getElementById('bioavailability').value); 841 const Vd = parseFloat(document.getElementById('vd').value) || null; 842 const tLag = parseFloat(document.getElementById('tLag').value) || 0; 843 const tRel = parseFloat(document.getElementById('tRel').value) || 8; 844 const extendElimination = document.getElementById('extendElimination').checked; 845 846 // Elimination kinetics (first-order default; saturable modes use RK4) 847 const elimMode = currentElimMode; 848 let Vmax = 0, Km = 0; 849 if (elimMode === 'mm') { 850 Vmax = parseFloat(document.getElementById('vmaxInput').value); 851 Km = parseFloat(document.getElementById('kmInput').value); 852 } else if (elimMode === 'zero') { 853 Vmax = parseFloat(document.getElementById('k0Input').value); // zero-order = Km -> 0 854 Km = 0; 855 } 856 857 // Validate 858 clearError(); 859 if (!dose || dose <= 0) { showError('Dose must be > 0'); return; } 860 if (!tau || tau <= 0) { showError('Interval tau must be > 0'); return; } 861 if (!numDoses || numDoses < 1) { showError('Number of doses must be >= 1'); return; } 862 if (!halfLife || halfLife <= 0) { showError('Half-life must be > 0'); return; } 863 if (F < 0 || F > 1) { showError('Bioavailability must be 0-1'); return; } 864 if (elimMode === 'mm' && (!(Vmax > 0) || !(Km > 0))) { showError('Michaelis-Menten needs Vmax > 0 and Km > 0.'); return; } 865 if (elimMode === 'zero' && !(Vmax > 0)) { showError('Zero-order needs k0 > 0.'); return; } 866 867 // Compute ke (elimination rate; only used by the first-order model) 868 const ke = Math.LN2 / halfLife; 869 870 // Compute ka based on mode 871 let ka; 872 if (currentParamMode === 'A') { 873 const bucket = parseFloat(document.getElementById('absorptionBucket').value); 874 ka = kaFromBucket(bucket); 875 } else if (currentParamMode === 'B') { 876 let tmax = parseFloat(document.getElementById('tmax').value); 877 const tmaxBasis = document.getElementById('tmaxBasis').value; 878 879 // Adjust Tmax for lag if measuring from ingestion 880 if (tmaxBasis === 'ingestion' && currentDoseForm === 'Delayed') { 881 tmax = tmax - tLag; 882 } 883 884 if (tmax <= 0) { 885 showError('Tmax (after lag adjustment) must be > 0. Reduce lag time or increase Tmax.'); 886 return; 887 } 888 889 ka = solveKaFromTmax(ke, tmax); 890 if (!ka) { 891 showError('Could not solve for ka. Tmax may be too large or inconsistent with half-life. Try reducing Tmax or switching to Mode A/C.'); 892 return; 893 } 894 } else { 895 ka = parseFloat(document.getElementById('kaInput').value); 896 } 897 898 if (!ka || ka <= 0) { 899 showError('ka must be > 0.'); 900 return; 901 } 902 if (elimMode === 'firstorder' && ka <= ke) { 903 showError('ka must be > ke for valid 1-compartment extravascular model.'); 904 return; 905 } 906 907 // Calculate elimination time milestones 908 const t5hl = 5 * halfLife; // ~97% eliminated 909 const t7hl = 7 * halfLife; // ~99% eliminated 910 911 // Simulation duration 912 let duration = parseFloat(document.getElementById('simDuration').value); 913 if (!duration || duration <= 0) { 914 duration = numDoses * tau + tau; // Extra interval after last dose 915 } 916 917 // If extend elimination is checked, extend to 7 half-lives after last dose 918 if (extendElimination) { 919 const lastDoseTime = (numDoses - 1) * tau; 920 const extendedDuration = lastDoseTime + t7hl; 921 duration = Math.max(duration, extendedDuration); 922 } 923 924 // Build schedule 925 const schedule = buildSchedule(numDoses, tau, scheduleEvents, dose, currentDoseForm, tLag, tRel); 926 927 // Build baseline (no disruptions) for comparison 928 const baselineSchedule = buildBaselineSchedule(numDoses, tau, dose, currentDoseForm, tLag, tRel); 929 930 const samplesPlot = 240; // smooth plot, base was 60 931 const samplesMetrics = 1200; // very accurate AUC/metrics, base used to be 60 932 933 const simParams = { 934 ka, ke, F, 935 schedule, 936 duration,
937 samplesPerHour: samplesPlot, 938 elimMode, Vmax, Km 939 }; 940 941 // new 942 const simParamsMetrics = { 943 ka, ke, F, schedule, duration, 944 samplesPerHour: samplesMetrics, 945 elimMode, Vmax, Km 946 }; 947 948 // Run main simulation 949 // const result = simulate(simParams); 950 951 // Run baseline if there are disruptions 952 // let baseline = null; 953 // if (scheduleEvents.length > 0) { 954 // baseline = simulate({ ...simParams, schedule: baselineSchedule }); 955 // } 956 957 // run two sims (plot vs metrics) 958 const resultPlot = simulate(simParams); 959 const resultMetrics = simulate(simParamsMetrics); 960 961 // Keep existing downstream code working (anything that expects `result`) 962 const result = resultPlot; 963 964 //baseline for metrics (only if disruptions exist) 965 let baselineMetrics = null; 966 if (scheduleEvents.length > 0) { 967 baselineMetrics = simulate({ ...simParamsMetrics, schedule: baselineSchedule }); 968 } 969 970 // Monte Carlo if enabled 971 let mcBands = null; 972 if (document.getElementById('uncertaintyEnabled').checked) { 973 const nSamples = parseInt(document.getElementById('mcSamples').value) || 300; 974 const mcOpts = { 975 halfLifeMin: parseFloat(document.getElementById('halfLifeMin').value), 976 halfLifeMax: parseFloat(document.getElementById('halfLifeMax').value), 977 paramMode: currentParamMode, 978 tmax: parseFloat(document.getElementById('tmax').value), 979 tmaxBasis: document.getElementById('tmaxBasis').value, 980 doseForm: currentDoseForm, 981 tLag, 982 }; 983 const mcResults = runMonteCarlo(simParams, Math.min(nSamples, 2000), mcOpts); 984 mcBands = computePercentileBands(mcResults); 985 } 986 987 // Compute metrics 988 // const metrics = computeMetrics(result, { tau, numDoses }, baseline); 989 990 // Render plot 991 // renderPlot(result, mcBands, Vd); 992 993 // Compute metrics using the high-resolution result 994 const metrics = computeMetrics(resultMetrics, { tau, numDoses }, baselineMetrics, scheduleEvents); 995 996 // Render plot using the plot-resolution result 997 renderPlot(resultPlot, mcBands, Vd); 998 999 1000 // Update metrics display with proper time formatting 1001 document.getElementById('metricAmax').innerHTML = metrics.Amax.toFixed(2) + '<span class="metric-unit">mg</span>'; 1002 document.getElementById('metricAmin').innerHTML = metrics.Amin.toFixed(2) + '<span class="metric-unit">mg</span>'; 1003 document.getElementById('metricTmax').innerHTML = formatTimeShort(metrics.Tmax); 1004 document.getElementById('metricAccum').innerHTML = metrics.accumRatio.toFixed(2); 1005 document.getElementById('metricRecovery').innerHTML = metrics.recoveryTime !== null 1006 ? formatTime(metrics.recoveryTime) 1007 : '-'; 1008 document.getElementById('metricSteady').innerHTML = metrics.steadyState ? 'Yes' : 'No'; 1009 1010 // Update elimination time metrics. Half-life is only constant under 1011 // first-order kinetics; saturable modes have no fixed half-life. 1012 if (elimMode === 'firstorder') { 1013 document.getElementById('metricT5hl').innerHTML = formatTime(t5hl); 1014 document.getElementById('metricT7hl').innerHTML = formatTime(t7hl); 1015 } else { 1016 document.getElementById('metricT5hl').innerHTML = 'n/a'; 1017 document.getElementById('metricT7hl').innerHTML = 'n/a'; 1018 } 1019 1020 // Update derived parameters 1021 const keLine = document.getElementById('derivedKeLine'); 1022 const satLine = document.getElementById('derivedSatLine'); 1023 document.getElementById('derivedKa').textContent = ka.toFixed(4); 1024 document.getElementById('derivedF').textContent = F.toFixed(2); 1025 if (elimMode === 'firstorder') { 1026 document.getElementById('derivedKe').textContent = ke.toFixed(4); 1027 keLine.style.display = 'block'; 1028 satLine.style.display = 'none'; 1029 } else { 1030 keLine.style.display = 'none'; 1031 satLine.style.display = 'block'; 1032 document.getElementById('derivedSat').innerHTML = (elimMode === 'mm') 1033 ? `Vmax = ${Vmax} mg/h, Km = ${Km}
1033 mg (Michaelis-Menten)` 1034 : `k0 = ${Vmax} mg/h (zero-order)`; 1035 } 1036 1037 if (Vd) { 1038 document.getElementById('derivedVdLine').style.display = 'block'; 1039 document.getElementById('derivedVd').textContent = Vd.toFixed(1); 1040 } else { 1041 document.getElementById('derivedVdLine').style.display = 'none'; 1042 } 1043 1044 // Store for potential export and hover interactions 1045 simulationData = { result, metrics, params: simParams, halfLife, t5hl, t7hl, mcBands }; 1046 1047 } catch (err) { 1048 console.error('Simulation error:', err); 1049 showError('Simulation error: ' + err.message); 1050 } 1051} 1052 1053// ============================================ 1054// INITIALIZATION 1055// ============================================ 1056document.addEventListener('DOMContentLoaded', () => { 1057 // Theme is initialized by shared.js ThemeManager. 1058 wireEvents(); 1059 initializeDateTimeInput(); 1060 setupPlotHover(); 1061 loadPreset(); // Load default preset (coffee) 1062 1063 // Initial simulation 1064 setTimeout(() => { 1065 runSimulation(); 1066 }, 100); 1067}); 1068 1069// Handle window resize 1070window.addEventListener('resize', () => { 1071 if (simulationData) { 1072 const Vd = parseFloat(document.getElementById('vd').value) || null; 1073 renderPlot(simulationData.result, simulationData.mcBands || null, Vd); 1074 } 1075}); 1076 1077// ============================================ 1078// EVENT WIRING (replaces inline on* handlers) 1079// ============================================ 1080function wireEvents() { 1081 document.getElementById('presetSelect').addEventListener('change', loadPreset); 1082 1083 document.querySelectorAll('.doseform-container .mode-card').forEach(card => { 1084 card.addEventListener('click', () => setDoseForm(card.querySelector('input').value)); 1085 }); 1086 document.querySelectorAll('.mode-container .mode-card').forEach(card => { 1087 card.addEventListener('click', () => setParamMode(card.querySelector('input').value)); 1088 }); 1089 document.getElementById('elimMode').addEventListener('change', (e) => setElimMode(e.target.value)); 1090 1091 const onActivate = (header, fn) => { 1092 header.addEventListener('click', fn); 1093 header.addEventListener('keydown', (e) => { 1094 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn(); } 1095 }); 1096 }; 1097 document.querySelectorAll('[data-collapsible]').forEach(header => { 1098 onActivate(header, () => toggleCollapsible(header.dataset.collapsible, header)); 1099 }); 1100 document.querySelectorAll('[data-dropdown]').forEach(header => { 1101 onActivate(header, () => toggleDropdown(header.dataset.dropdown, header)); 1102 }); 1103 1104 document.getElementById('eventType').addEventListener('change', toggleEventInputs); 1105 document.getElementById('addEventBtn').addEventListener('click', addEvent); 1106 document.getElementById('runBtn').addEventListener('click', runSimulation); 1107 1108 // Delegated remove-disruption clicks (buttons are re-rendered dynamically). 1109 document.getElementById('eventList').addEventListener('click', (e) => { 1110 const btn = e.target.closest('[data-remove]'); 1111 if (btn) removeEvent(parseInt(btn.dataset.remove, 10)); 1112 }); 1113} 1114 1115// ============================================ 1116// INLINE ERROR DISPLAY (replaces alert()) 1117// ============================================ 1118function showError(message) { 1119 const el = document.getElementById('simError'); 1120 if (!el) return; 1121 el.textContent = message; 1122 el.style.display = 'block'; 1123} 1124 1125function clearError() { 1126 const el = document.getElementById('simError'); 1127 if (!el) return; 1128 el.textContent = ''; 1129 el.style.display = 'none'; 1130} 1131 1132// Expose the pure engine for tests and programmatic use. 1133window.OralMultiDose = { 1134 PRESETS, 1135 kaFromBucket, solveKaFromTmax, propagate, propagateSaturable, buildSchedule, buildBaselineSchedule, 1136 simulate, computeIntervalAUC, findIntervalExtrema, 1137 computeMetrics, computePercentileBands, runMonteCarlo, mulberry32, 1138};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.