;(function () {
const { useState, useEffect, useRef, useCallback, useContext, createContext } = React;
let _ctx = null;
function getAudioCtx() {
if (!_ctx) { try { _ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { _ctx = null; } }
return _ctx;
}
function playBeep(ctx, t, vol) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(1000, t);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.18 * vol, t + 0.004);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.07);
o.connect(g).connect(ctx.destination); o.start(t); o.stop(t + 0.09);
}
function playThump(ctx, t, { startF, endF, dur, gain, noiseGain }, vol) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine';
o.frequency.setValueAtTime(startF, t);
o.frequency.exponentialRampToValueAtTime(endF, t + dur);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain * vol, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g).connect(ctx.destination); o.start(t); o.stop(t + dur + 0.02);
if (noiseGain) {
const n = Math.floor(ctx.sampleRate * dur), buf = ctx.createBuffer(1, n, ctx.sampleRate), d = buf.getChannelData(0);
let last = 0;
for (let i = 0; i < n; i++) { const w = Math.random() * 2 - 1; last = (last + 0.02 * w) / 1.02; d[i] = last * 2.5; }
const src = ctx.createBufferSource(); src.buffer = buf;
const bp = ctx.createBiquadFilter(); bp.type = 'lowpass'; bp.frequency.value = 220;
const ng = ctx.createGain();
ng.gain.setValueAtTime(0.0001, t);
ng.gain.exponentialRampToValueAtTime(noiseGain * vol, t + 0.005);
ng.gain.exponentialRampToValueAtTime(0.0001, t + dur);
src.connect(bp).connect(ng).connect(ctx.destination); src.start(t); src.stop(t + dur);
}
}
const playLub = (c, t, v) => playThump(c, t, { startF: 85, endF: 42, dur: 0.13, gain: 0.32, noiseGain: 0.10 }, v);
const playDub = (c, t, v) => playThump(c, t, { startF: 105, endF: 55, dur: 0.10, gain: 0.24, noiseGain: 0.07 }, v);
function startScheduler({ hr, mode, vol, onBeat }) {
const ctx = getAudioCtx();
if (!ctx || mode === 'off' || !hr) return () => {};
if (ctx.state === 'suspended') ctx.resume().catch(() => {});
const beatS = 60 / hr, dubOffset = Math.min(0.30, beatS * 0.32);
let nextTime = ctx.currentTime + 0.08;
const tick = () => {
const lookahead = ctx.currentTime + 0.25;
while (nextTime < lookahead) {
if (mode === 'beep') playBeep(ctx, nextTime, vol);
else if (mode === 'lubdub') { playLub(ctx, nextTime, vol); playDub(ctx, nextTime + dubOffset, vol); }
const delay = Math.max(0, (nextTime - ctx.currentTime) * 1000);
setTimeout(() => onBeat && onBeat(), delay);
nextTime += beatS;
}
};
tick();
const h = setInterval(tick, 60);
return () => clearInterval(h);
}
function useStripSound({ hr, mode, vol, setBeating, onActivate }) {
useEffect(() => startScheduler({
hr, mode, vol,
onBeat: () => { setBeating(true); setTimeout(() => setBeating(false), 90); },
}), [hr, mode, vol]);
useEffect(() => {
if (mode === 'off') return;
const ctx = getAudioCtx();
if (ctx && ctx.state === 'suspended') ctx.resume().then(() => onActivate && onActivate()).catch(() => {});
else if (ctx && ctx.state === 'running') onActivate && onActivate();
}, [mode]);
}
function Seg({ value, onChange, options, ariaLabel }) {
return (
<div className="seg-group" role="group" aria-label={ariaLabel}>
{options.map(([k, lbl]) => (
<button key={k} className={value === k ? 'on' : ''} onClick={() => onChange(k)} aria-pressed={value === k}>{lbl}</button>
))}
</div>
);
}
function SoundPicker({ value, onChange }) {
return (
<label><span>SOUND</span>
<Seg value={value} onChange={onChange} ariaLabel="Heart sound" options={[['off', 'Off'], ['beep', 'Beep'], ['lubdub', 'Lub-Dub']]} />
</label>
);
}
const LEAD_ORDER = window.PQRST_LEAD_ORDER;
const LEADS_INFO = window.PQRST_LEADS;
const GRID_3x4 = [['I', 'aVR', 'V1', 'V4'], ['II', 'aVL', 'V2', 'V5'], ['III', 'aVF', 'V3', 'V6']];
function LeadRail({ value, onChange }) {
return (
<div className="lead-rail" role="tablist" aria-label="ECG lead">
{LEAD_ORDER.map(L => (
<button key={L} role="tab" aria-selected={L === value} className={'lead-chip' + (L === value ? ' on' : '')}
onClick={() => onChange(L)} title={LEADS_INFO[L]?.view || L}>{L}</button>
))}
</div>
);
}
function TwelveLead({ hr, color, cycles, layout, rhythmStrip }) {
const rows = layout === '6x2'
? [['I', 'V1'], ['II', 'V2'], ['III', 'V3'], ['aVR', 'V4'], ['aVL', 'V5'], ['aVF', 'V6']]
: GRID_3x4;
const cellCycles = Math.max(1, Math.round(cycles * (layout === '6x2' ? 1 : 0.5)));
return (
<div className={'twelve ' + (layout === '6x2' ? 'l62' : 'l34')}>
{rows.map((row, ri) => row.map(L => (
<div className="cell" key={L} style={{ gridRow: ri + 1 }}>
<span className="cell-lbl">{L}</span>
<PqrstWave hr={hr} color={color} height={layout === '6x2' ? 58 : 72} visibleCycles={cellCycles} lead={L} interactive={false} />
</div>
)))}
{rhythmStrip && (
<div className="cell rhythm">
<span className="cell-lbl">II <i>rhythm</i></span>
<PqrstWave hr={hr} color={color} height={96} visibleCycles={cycles * 2} lead="II" interactive={false} />
</div>
)}
</div>
);
}
function Strip({ title, defaultHr, color, height = 150, cycles = 4, soundKey, hrMin = 35, hrMax = 180, defaultLead = 'II' }) {
const S = useContext(window.SettingsCtx);
const ls = (k, d) => { try { return localStorage.getItem('pqrst.' + k + '.' + soundKey) || d; } catch (e) { return d; } };
const [hr, setHr] = useState(() => Math.max(hrMin, Math.min(hrMax, defaultHr)));
const [lead, setLead] = useState(() => ls('lead', defaultLead));
const [sound, setSound] = useState(() => ls('sound', 'off'));
const [view, setView] = useState(() => ls('view', 'single'));
const [beating, setBeating] = useState(false);
useEffect(() => { try { localStorage.setItem('pqrst.sound.' + soundKey, sound); } catch (e) {} }, [sound]);
useEffect(() => { try { localStorage.setItem('pqrst.lead.' + soundKey, lead); } catch (e) {} }, [lead]);
useEffect(() => { try { localStorage.setItem('pqrst.view.' + soundKey, view); } catch (e) {} }, [view]);
useStripSound({
hr, mode: S.mute ? 'off' : sound, vol: S.volume / 100, setBeating,
onActivate: () => window.dispatchEvent(new CustomEvent('pqrst-audio-active')),
});
const rate = hr >= 100 ? '#ef5350' : hr <= 55 ? '#5ecae0' : color;
const silent = S.mute || sound === 'off';
const leadView = LEADS_INFO[lead]?.view;
const speedMul = S.speed === '50' ? 0.5 : 1;
const cyc = Math.max(1, Math.round(cycles * speedMul));
const durs = window.PQRST_COMPUTE ? window.PQRST_COMPUTE(hr) : null;
const stamp = new Date().toLocaleString();
const cardRef = useRef(null);
return (
<div className="card" data-key={soundKey} ref={cardRef}>
<div className="card-head">
<span>{title}</span>
<span className="lead-info">
{view === 'single' ? <><b>LEAD {lead}</b>{leadView ? ' · ' + leadView : ''}</> : <b>12-LEAD · {S.layout === '6x2' ? '6×2' : '3×4'}</b>}
</span>
<div className="head-tools">
<Seg value={view} onChange={setView} ariaLabel="View" options={[['single', 'Single'], ['12', '12-Lead']]} />
<button className="icon-btn" title="Print this strip" onClick={() => window.pqrstPrint(soundKey)}>
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.4"><path d="M4 6V2.5h8V6M3 6h10a1 1 0 0 1 1 1v4h-2.5M2 11V7a1 1 0 0 1 1-1M4.5 9.5h7v4h-7z" /></svg>
</button>
<span className={'ph' + (beating ? ' beating' : '')} style={{ color: silent ? 'var(--page-ink-faint)' : rate }}>
<span className="pip"></span>
{silent ? 'silent' : sound === 'beep' ? 'beep' : 'lub · dub'}
</span>
</div>
</div>
<div className="print-meta">
<span><b>{title}</b></span>
<span>{view === 'single' ? 'Lead ' + lead : '12-lead ' + (S.layout === '6x2' ? '6×2' : '3×4')}</span>
<span>HR {hr} bpm</span>
{durs && <><span>RR {Math.round(durs.RR)} ms</span><span>PR {Math.round(durs.PR)} ms</span><span>QRS {Math.round(durs.QRS)} ms</span><span>QT {Math.round(durs.QT)} ms</span></>}
<span>{S.speed} mm/s · 10 mm/mV</span>
<span>{stamp}</span>
</div>
<div className={'trace-row' + (S.heart ? ' with-heart' : '')}>
<div className="trace-main">
{view === 'single' ? (
<>
<LeadRail value={lead} onChange={setLead} />
<PqrstWave hr={hr} color={rate} height={height} visibleCycles={cyc} lead={lead} />
</>
) : (
<TwelveLead hr={hr} color={rate} cycles={cyc} layout={S.layout} rhythmStrip={S.rhythmStrip} />
)}
</div>
{S.heart && <HeartWindow hostRef={cardRef} hr={hr} color={rate} lead={view === 'single' ? lead : null} />}
</div>
<div className="controls">
<label><span>HEART RATE</span>
<input type="range" min={hrMin} max={hrMax} step="1" value={hr} onChange={e => setHr(+e.target.value)} />
<span className="val">{hr} bpm</span>
<span className="range-hint">{hrMin}-{hrMax} bpm</span>
</label>
<SoundPicker value={sound} onChange={setSound} />
</div>
</div>
);
}
function AudioBanner() {
const S = useContext(window.SettingsCtx);
const [need, setNeed] = useState(false);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const check = () => {
const ctx = getAudioCtx();
const anySound = ['ns', 'tachy', 'brady'].some(k => { try { const v = localStorage.getItem('pqrst.sound.' + k); return v && v !== 'off'; } catch (e) { return false; } });
setNeed(!!(ctx && ctx.state !== 'running' && anySound && !S.mute));
};
check();
const t = setInterval(check, 600);
window.addEventListener('pqrst-audio-active', check);
return () => { clearInterval(t); window.removeEventListener('pqrst-audio-active', check); };
}, [S.mute]);
if (dismissed || !need) return null;
return (
<div className="audio-banner">
<span>Sound is queued — click activate to unmute</span>
<button onClick={() => { const ctx = getAudioCtx(); if (ctx) ctx.resume().then(() => setDismissed(true)); else setDismissed(true); }}>Activate</button>
<button className="dismiss" onClick={() => setDismissed(true)}>×</button>
</div>
);
}
function Demo() {
return (
<>
<Strip soundKey="ns" title="Normal sinus rhythm" defaultHr={80} color="#6fd99a" height={170} hrMin={60} hrMax={100} defaultLead="II" />
<Strip soundKey="tachy" title="Sinus tachycardia" defaultHr={140} color="#f4b04a" height={150} hrMin={100} hrMax={180} defaultLead="V4" />
<Strip soundKey="brady" title="Sinus bradycardia" defaultHr={45} color="#5ecae0" height={150} cycles={3} hrMin={35} hrMax={59} defaultLead="aVF" />
<AudioBanner />
</>
);
}
Object.assign(window, { Demo, getAudioCtx, Seg });
window.Demo = Demo;
})();