PageSourceSearch

https://tools.jesse-anderson.net/js/oral_multidose/oral-multidose.js

js jesse-anderson.net collected 2026-10-09 22:08:06 UTC 42,795 bytes, 1,138 lines download raw bytes

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.