feat(pe-guide): 7 cardiac sounds, APTM image full-width + tap-to-zoom, synth stop/progress

Three user-flagged fixes:

1. Cardiac sounds library expanded from 3 to 7 (from Wikimedia Commons
   heart-sounds + heart-murmurs subcategories). All real recordings:
   - Normal (61 bpm)                 [existing]
   - Infant heartbeat                [new — pediatric reference]
   - VSD                              [existing]
   - Mitral valve prolapse            [existing]
   - Still's murmur in a toddler      [new — classic innocent murmur]
   - Functional murmur (adult female) [new — benign flow murmur]
   - PVCs                             [new — arrhythmia]
   All with native <audio controls> (play/pause/seek/elapsed/total
   work on desktop AND mobile for free).

2. APTM diagram is now full-width on every device, no longer sharing
   row space with the legend on mobile:
   - Image always on its own row, max-width:420px, centered
   - Wrapped in an <a href target="_blank"> — tap/click opens the PNG
     full-size in a new tab where browser pinch-zoom works natively
   - "Tap to open full-size" hint line under the image
   - Legend below uses auto-fit minmax(min(100%,260px),1fr) so it stacks
     on narrow viewports without cramping

3. Synth sounds (rhonchi, pleural rub, grunting, normal vesicular — all
   Web-Audio-API generated, no native controls) now have:
   - Play button → kicks off playback and hides itself
   - Stop button appears, lets user terminate early
   - Progress bar animates over 3.2 s, resets at end
   - Only one synth sound plays at a time (clicking another stops the
     previous)
This commit is contained in:
Daniel 2026-04-22 21:50:53 +02:00
parent bf62d15ad6
commit 2913b09abd
5 changed files with 80 additions and 22 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -290,9 +290,13 @@
// ────────────────────────────────────────────────────────────
var CARDIAC_SOUNDS = [
{ key: 'normal', src: '/audio/cardiac/normal.ogg', title: 'Normal heart sounds (S1, S2)',
where: 'All four classic auscultation points', rate: '61 bpm in this sample',
where: 'All four classic auscultation points', rate: '~61 bpm reference',
features: '"lub-dub": S1 (closure of mitral + tricuspid) louder at apex; S2 (closure of aortic + pulmonic) louder at base. Physiologic S2 split on inspiration.',
clinical: 'Reference for rhythm, rate, and the normal S1S2 interval. Listen for what\'s changed — not just what\'s added.' },
{ key: 'infant-normal', src: '/audio/cardiac/infant-normal.ogg', title: 'Infant normal heart sounds',
where: 'Infant chest — rate will be higher than adult', rate: 'Pediatric reference (120160 bpm range)',
features: 'Same S1S2 pattern, faster rate. Short diastole makes murmurs easier to miss — careful auscultation needed.',
clinical: 'Reference for neonatal/infant rhythm. Any murmur in the first 72 h should prompt pre/postductal sat screening.' },
{ key: 'vsd', src: '/audio/cardiac/vsd.wav', title: 'Ventricular septal defect (VSD)',
where: 'Lower left sternal border (4th ICS)',
features: 'Harsh, blowing, holosystolic (pansystolic) murmur — plateau shape through all of systole. Often accompanied by a thrill if large.',
@ -300,7 +304,19 @@
{ key: 'mvp', src: '/audio/cardiac/mitral-prolapse.wav', title: 'Mitral valve prolapse (MVP) — click + late systolic murmur',
where: 'Apex (5th ICS, mid-clavicular line)',
features: 'Mid-systolic click followed by a late-systolic crescendo murmur. Timing of click changes with maneuvers: earlier with standing or Valsalva, later with squatting.',
clinical: 'Often benign, especially in thin young women. Features suggesting need for echo: thickened/redundant leaflets on exam, associated mitral regurgitation, symptoms (palpitations, chest pain), arrhythmias.' }
clinical: 'Often benign, especially in thin young women. Features suggesting need for echo: thickened/redundant leaflets, associated MR, symptoms (palpitations, chest pain), arrhythmias.' },
{ key: 'stills', src: '/audio/cardiac/stills-murmur.ogg', title: 'Still\'s murmur (innocent)',
where: 'LLSB, radiating to apex', rate: 'Classic age 37 y (this recording is a toddler)',
features: 'Low-frequency vibratory / musical systolic, grade 23/6, mid-systolic, "twanging-string" quality.',
clinical: 'The most common innocent murmur of childhood. Louder supine, softer or disappears on standing or Valsalva. Normal S2. No radiation to neck or back. No workup needed when classic.' },
{ key: 'functional', src: '/audio/cardiac/functional-murmur.wav', title: 'Functional (innocent) murmur — adult female',
where: 'Left sternal border, soft systolic',
features: 'Soft systolic murmur in a structurally normal heart — often from increased cardiac output, thin chest wall, anemia, hyperthyroidism, or pregnancy.',
clinical: 'Benign if it meets the 7 S criteria. Investigate if loud (≥3/6), holosystolic, diastolic, radiating, or with thrill / symptoms.' },
{ key: 'pvc', src: '/audio/cardiac/pvc.ogg', title: 'Premature ventricular contractions (PVCs)',
where: 'Apex; detect via irregular rhythm',
features: 'Irregular rhythm with a compensatory pause after the early beat. Pulse may be irregular on palpation.',
clinical: 'Common and usually benign in structurally normal hearts. Investigate if frequent (>10/min), symptomatic (syncope), or with exertional worsening. Workup: ECG, ambulatory monitor, consider echo.' }
];
// ────────────────────────────────────────────────────────────
@ -1388,7 +1404,13 @@
: '<span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;background:#e0e7ff;color:#3730a3;letter-spacing:0.3px;">SYNTH</span>';
var player = isReal
? '<audio controls preload="none" src="' + esc(s.src) + '" style="width:100%;height:36px;"></audio>'
: '<button type="button" class="btn-sm btn-primary resp-sound-play" data-resp-sound="' + esc(s.key) + '" title="Play synthesised sample" style="padding:6px 12px;"><i class="fas fa-play"></i> Play (~3s)</button>';
: '<div class="synth-sound" data-synth-key="' + esc(s.key) + '" style="display:flex;align-items:center;gap:8px;">' +
'<button type="button" class="btn-sm btn-primary synth-play-btn" title="Play synthesised ~3 s sample" style="padding:6px 10px;min-width:80px;"><i class="fas fa-play"></i> Play</button>' +
'<button type="button" class="btn-sm btn-ghost synth-stop-btn" title="Stop" style="padding:6px 10px;display:none;"><i class="fas fa-stop"></i></button>' +
'<div class="synth-progress" style="flex:1;height:6px;background:var(--g200);border-radius:3px;overflow:hidden;">' +
'<div class="synth-progress-bar" style="height:100%;width:0%;background:#0ea5e9;transition:width 50ms linear;"></div>' +
'</div>' +
'</div>';
var html = '';
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px;background:#fff;">';
html += ' <div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;flex-wrap:wrap;">';
@ -1443,18 +1465,27 @@
if (currentSystem === 'cv') {
html += '<div class="card" style="margin-bottom:14px;border:1px solid ' + accent + '33;">';
html += ' <div class="card-header" style="background:' + accentTint + ';"><h3 style="margin:0;font-size:14px;color:' + accent + ';"><i class="fas fa-stethoscope"></i> Auscultation landmarks — APTM + Erb\'s</h3></div>';
html += ' <div style="padding:14px;' + TWO_COL_GRID + '">';
html += ' <div><img src="/images/pe-guide/aptm.png" alt="APTM cardiac auscultation points diagram" style="max-width:100%;height:auto;display:block;margin:0 auto;border-radius:8px;"/></div>';
html += ' <div style="font-size:12px;line-height:1.55;">';
// Image ALWAYS on its own row (full width on every device, responsive
// max-width caps it so it isn't huge on desktop). Tap/click to open
// full-size in a new tab — native browser zoom works there. Legend
// below stacks on narrow screens via auto-fit grid with minmax.
html += ' <div style="padding:14px 16px 6px;">';
html += ' <a href="/images/pe-guide/aptm.png" target="_blank" rel="noopener" aria-label="Open APTM diagram full size in a new tab" style="display:block;text-align:center;cursor:zoom-in;">';
html += ' <img src="/images/pe-guide/aptm.png" alt="APTM cardiac auscultation points diagram" style="max-width:100%;width:100%;max-width:420px;height:auto;display:inline-block;border-radius:8px;border:1px solid var(--g200);"/>';
html += ' <div style="font-size:11px;color:var(--g500);margin-top:6px;"><i class="fas fa-up-right-from-square" style="margin-right:4px;"></i>Tap / click to open full-size in a new tab (pinch to zoom on mobile)</div>';
html += ' </a>';
html += ' </div>';
html += ' <div style="padding:4px 16px 14px;">';
html += ' <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:10px 16px;font-size:12px;line-height:1.55;">';
APTM_LEGEND.forEach(function (p) {
html += '<div style="display:flex;gap:10px;margin-bottom:10px;align-items:flex-start;">';
html += '<div style="display:flex;gap:10px;align-items:flex-start;">';
html += ' <div style="flex-shrink:0;width:24px;height:24px;border-radius:50%;background:' + p.color + ';color:#fff;font-weight:700;display:flex;align-items:center;justify-content:center;font-size:12px;">' + p.letter + '</div>';
html += ' <div style="flex:1;">';
html += ' <div style="flex:1;min-width:0;">';
html += ' <div style="font-weight:600;color:var(--g800);font-size:13px;">' + esc(p.title) + '</div>';
html += ' <div style="color:var(--g600);font-size:11px;margin-top:1px;">' + esc(p.location) + '</div>';
html += ' <div style="color:var(--g700);margin-top:3px;font-size:12px;"><strong>Listen for:</strong> ' + esc(p.listen) + '</div>';
if (p.innocent) {
html += ' <div style="color:#047857;margin-top:2px;font-size:11px;"><i class="fas fa-leaf" style="margin-right:3px;"></i><em>Innocent murmur(s):</em> ' + esc(p.innocent) + '</div>';
html += ' <div style="color:#047857;margin-top:2px;font-size:11px;"><i class="fas fa-leaf" style="margin-right:3px;"></i><em>Innocent:</em> ' + esc(p.innocent) + '</div>';
}
html += ' </div>';
html += '</div>';
@ -1587,19 +1618,46 @@
if (state[key]) state[key].note = inp.value;
});
});
// Respiratory sound-library play buttons
content.querySelectorAll('.resp-sound-play').forEach(function (btn) {
btn.addEventListener('click', function () {
var name = btn.dataset.respSound;
if (window.RespSounds && window.RespSounds.play) {
var ok = window.RespSounds.play(name);
if (!ok) showToast('Audio not available', 'error');
// Visual feedback
btn.innerHTML = '<i class="fas fa-volume-high"></i>';
setTimeout(function () { btn.innerHTML = '<i class="fas fa-play"></i>'; }, 3500);
} else {
showToast('Audio library not loaded', 'error');
}
// Synth sound play/stop with progress bar
content.querySelectorAll('.synth-sound').forEach(function (container) {
var key = container.dataset.synthKey;
var playBtn = container.querySelector('.synth-play-btn');
var stopBtn = container.querySelector('.synth-stop-btn');
var bar = container.querySelector('.synth-progress-bar');
var timer = null;
var startTime = 0;
var durationMs = 3200; // synth sounds are all ~3 s
function finish() {
if (timer) { clearInterval(timer); timer = null; }
playBtn.innerHTML = '<i class="fas fa-play"></i> Play';
playBtn.style.display = '';
stopBtn.style.display = 'none';
bar.style.width = '0%';
}
playBtn.addEventListener('click', function () {
if (!window.RespSounds || !window.RespSounds.play) { showToast('Audio library not loaded', 'error'); return; }
// Stop any other synth player first
content.querySelectorAll('.synth-sound').forEach(function (c) { if (c !== container) {
var b = c.querySelector('.synth-progress-bar'); if (b) b.style.width = '0%';
var pb = c.querySelector('.synth-play-btn'); if (pb) { pb.innerHTML = '<i class="fas fa-play"></i> Play'; pb.style.display = ''; }
var sb = c.querySelector('.synth-stop-btn'); if (sb) sb.style.display = 'none';
}});
window.RespSounds.stop();
var ok = window.RespSounds.play(key);
if (!ok) { showToast('Audio not available', 'error'); return; }
playBtn.style.display = 'none';
stopBtn.style.display = '';
startTime = Date.now();
bar.style.width = '0%';
timer = setInterval(function () {
var pct = Math.min(100, ((Date.now() - startTime) / durationMs) * 100);
bar.style.width = pct + '%';
if (pct >= 100) finish();
}, 50);
});
stopBtn.addEventListener('click', function () {
if (window.RespSounds && window.RespSounds.stop) window.RespSounds.stop();
finish();
});
});
}