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:
parent
bf62d15ad6
commit
2913b09abd
5 changed files with 80 additions and 22 deletions
BIN
public/audio/cardiac/functional-murmur.wav
Normal file
BIN
public/audio/cardiac/functional-murmur.wav
Normal file
Binary file not shown.
BIN
public/audio/cardiac/infant-normal.ogg
Normal file
BIN
public/audio/cardiac/infant-normal.ogg
Normal file
Binary file not shown.
BIN
public/audio/cardiac/pvc.ogg
Normal file
BIN
public/audio/cardiac/pvc.ogg
Normal file
Binary file not shown.
BIN
public/audio/cardiac/stills-murmur.ogg
Normal file
BIN
public/audio/cardiac/stills-murmur.ogg
Normal file
Binary file not shown.
|
|
@ -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 S1–S2 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 (120–160 bpm range)',
|
||||
features: 'Same S1–S2 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 3–7 y (this recording is a toddler)',
|
||||
features: 'Low-frequency vibratory / musical systolic, grade 2–3/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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue