feat(pe-guide): native audio controls + cardiac sounds library + mobile layout

Three user-flagged issues fixed together:

1. Mixed real/synth audio was labelled only "synthesised samples" — misleading
   since wheeze, stridor, fine+coarse crackles are real Wikimedia recordings.
   Now each sound card has a REAL or SYNTH badge and the library header
   reads: "real recordings where available; synthesised approximations
   labelled SYNTH".

2. No murmur sounds in the CV section. Added a Cardiac sounds library
   between the APTM diagram and the innocent-murmur panel:
     - Normal heart sounds (S1, S2) — 61 bpm reference
     - Ventricular septal defect (VSD) — harsh holosystolic at LLSB
     - Mitral valve prolapse (MVP) — mid-systolic click + late systolic
   All 3 are real recordings from Wikimedia Commons.

3. No pause / stop / duration controls. Replaced the synth-only play
   button with a native <audio controls> element for every real
   recording — gives play, pause, seek, elapsed/total time, volume
   for free on desktop AND mobile (browser-native, accessibility-
   compliant, consistent with platform conventions). Synth sounds
   (rhonchi, pleural rub, grunting, normal vesicular) keep the one-
   shot play button since they\'re Web-Audio-API generated and don\'t
   support seeking.

Mobile layout:
- The APTM diagram + legend was hard-coded 2-col (minmax(280px,1fr) 1fr)
  which could overflow narrow screens. Switched to repeat(auto-fit,
  minmax(280px,1fr)) — stacks to 1-col below ~580 px viewport.
- Refactored sound-library + APTM render into helpers (renderSoundCard,
  renderSoundsLibrary, TWO_COL_GRID constant) to reduce duplication.
This commit is contained in:
Daniel 2026-04-22 21:10:34 +02:00
parent 7d860c5287
commit 9bbfb4ce83

View file

@ -249,41 +249,60 @@
// ────────────────────────────────────────────────────────────
// Each entry maps to a key in window.RespSounds (respiratorySounds.js).
// Displayed as a dedicated card at the top of the Respiratory system.
// src = real recording path; if absent, the player falls back to synthesis
var RESP_SOUNDS = [
{ key: 'normal', title: 'Normal vesicular breath sounds',
{ key: 'normal', src: null, title: 'Normal vesicular breath sounds',
where: 'Peripheral lung fields',
features: 'Soft, rustling. Inspiration louder and longer than expiration.',
clinical: 'Baseline — deviation elsewhere is what you listen for.' },
{ key: 'wheeze', title: 'Wheeze',
{ key: 'wheeze', src: '/audio/respiratory/wheeze.ogg', title: 'Wheeze',
where: 'Diffuse in asthma; localised in foreign body',
features: 'Continuous, high-pitched, musical. Usually expiratory; biphasic if severe.',
clinical: 'Lower-airway narrowing — asthma, bronchiolitis, foreign body, bronchomalacia. Silent chest in severe asthma is an ominous sign.' },
{ key: 'stridor', title: 'Stridor',
{ key: 'stridor', src: '/audio/respiratory/stridor.ogg', title: 'Stridor',
where: 'Louder over neck than chest — upper airway',
features: 'Continuous, high-pitched, harsh. Classically inspiratory (extrathoracic obstruction); biphasic if fixed.',
clinical: 'Croup, epiglottitis, foreign body, laryngomalacia (infant). Distinguish from wheeze by auscultating the neck — stridor is loudest there.' },
{ key: 'finecrackles', title: 'Fine (end-inspiratory) crackles',
{ key: 'finecrackles', src: '/audio/respiratory/crackles-fine.ogg', title: 'Fine (end-inspiratory) crackles',
where: 'Bibasilar in pulmonary edema/fibrosis; focal in pneumonia',
features: 'Discontinuous, brief, high-pitched. "Velcro" quality. Late inspiratory, do NOT clear with cough.',
clinical: 'Alveolar opening — pulmonary fibrosis, pulmonary edema, early pneumonia, atelectasis.' },
{ key: 'coarsecrackles', title: 'Coarse crackles',
{ key: 'coarsecrackles', src: '/audio/respiratory/crackles-coarse.ogg', title: 'Coarse crackles',
where: 'Lower lobes; either side',
features: 'Discontinuous, longer and louder than fine crackles. Lower-pitched. Can be early or late inspiratory; often clear partly with cough.',
clinical: 'Secretions in larger airways — bronchitis, later pneumonia, bronchiectasis, aspiration.' },
{ key: 'rhonchi', title: 'Rhonchi',
{ key: 'rhonchi', src: null, title: 'Rhonchi',
where: 'Central or anywhere with airway secretions',
features: 'Continuous, low-pitched, snore-like. Typically expiratory. Clear or change with cough.',
clinical: 'Large-airway secretions — bronchitis, pneumonia with large-airway involvement, cystic fibrosis, bronchiectasis.' },
{ key: 'pleuralrub', title: 'Pleural friction rub',
{ key: 'pleuralrub', src: null, title: 'Pleural friction rub',
where: 'Focal, often lateral or posterior lower chest',
features: 'Grating, creaky — "leather on leather". Biphasic (heard in inspiration and expiration). Does NOT clear with cough.',
clinical: 'Pleural inflammation — pleuritis, pulmonary embolism, pneumonia with pleural involvement, viral pleurisy.' },
{ key: 'grunting', title: 'Expiratory grunting',
{ key: 'grunting', src: null, title: 'Expiratory grunting',
where: 'Audible without stethoscope in infants',
features: 'Short, low-pitched sound at the end of each expiration. Physiologic PEEP against a partially closed glottis.',
clinical: 'Sign of significant respiratory distress in an infant — RDS, pneumonia, sepsis, congenital heart disease with heart failure.' }
];
// ────────────────────────────────────────────────────────────
// CARDIAC SOUNDS LIBRARY — real recordings from Wikimedia Commons
// ────────────────────────────────────────────────────────────
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',
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: '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.',
clinical: 'Most common congenital heart defect. Small VSD: loud murmur, usually asymptomatic, may close spontaneously. Large VSD: softer murmur (less pressure gradient) but signs of heart failure, pulmonary hypertension.' },
{ 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.' }
];
// ────────────────────────────────────────────────────────────
// DATA
// ────────────────────────────────────────────────────────────
@ -1356,6 +1375,46 @@
document.getElementById('pe-generate-btn').addEventListener('click', generate);
}
// Responsive two-column grid helper: side-by-side on wide screens, stacked on narrow
var TWO_COL_GRID = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;align-items:start;';
// Render a single sound card with native <audio controls> (play/pause/seek
// free on all platforms incl. mobile). Falls back to a synth play button
// when the sound has no real recording.
function renderSoundCard(s) {
var isReal = !!s.src;
var badge = isReal
? '<span style="font-size:10px;font-weight:600;padding:2px 6px;border-radius:4px;background:#dcfce7;color:#166534;letter-spacing:0.3px;">REAL</span>'
: '<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>';
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;">';
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);flex:1;min-width:0;">' + esc(s.title) + '</div>';
html += ' ' + badge;
html += ' </div>';
if (s.where) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Where:</strong> ' + esc(s.where) + '</div>';
if (s.rate) html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:3px;"><strong>Rate:</strong> ' + esc(s.rate) + '</div>';
if (s.features) html += ' <div style="font-size:12px;color:var(--g700);line-height:1.5;margin-bottom:3px;"><strong>Features:</strong> ' + esc(s.features) + '</div>';
if (s.clinical) html += ' <div style="font-size:11px;color:var(--g600);line-height:1.5;margin-bottom:8px;"><strong>Clinical:</strong> ' + esc(s.clinical) + '</div>';
html += ' <div style="margin-top:6px;">' + player + '</div>';
html += '</div>';
return html;
}
function renderSoundsLibrary(title, sounds, accent, accentTint, icon) {
var html = '';
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 ' + icon + '"></i> ' + esc(title) + ' <span style="font-weight:400;font-size:11px;color:var(--g500);margin-left:6px;">real recordings where available; synthesised approximations labelled SYNTH</span></h3></div>';
html += ' <div style="padding:10px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
sounds.forEach(function (s) { html += renderSoundCard(s); });
html += ' </div>';
html += '</div>';
return html;
}
function renderSystem() {
var content = document.getElementById('pe-content');
var group = PE_DATA[currentAgeGroup];
@ -1384,7 +1443,7 @@
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;display:grid;grid-template-columns:minmax(280px,1fr) 1fr;gap:16px;align-items:start;">';
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;">';
APTM_LEGEND.forEach(function (p) {
@ -1403,6 +1462,16 @@
html += ' </div>';
html += ' </div>';
// ─ Cardiac sounds library ─
html += ' <div style="border-top:1px solid var(--g200);padding:14px;">';
html += ' <div style="font-weight:600;font-size:13px;color:' + accent + ';margin-bottom:10px;"><i class="fas fa-volume-high"></i> Cardiac sounds library</div>';
html += ' <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
CARDIAC_SOUNDS.forEach(function (s) {
html += renderSoundCard(s);
});
html += ' </div>';
html += ' </div>';
// ─ Innocent murmur reference panel ─
html += ' <div style="border-top:1px solid var(--g200);padding:14px;background:#f0fdf4;">';
html += ' <div style="font-weight:600;font-size:13px;color:#047857;margin-bottom:8px;"><i class="fas fa-leaf"></i> Classic innocent murmurs (benign, no workup needed)</div>';
@ -1423,22 +1492,7 @@
// ─ Respiratory sounds library (only for resp system) ─
if (currentSystem === 'resp') {
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-volume-high"></i> Respiratory sounds library <span style="font-weight:400;font-size:11px;color:var(--g500);margin-left:6px;">synthesised teaching samples — listen for the pattern</span></h3></div>';
html += ' <div style="padding:10px 14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;">';
RESP_SOUNDS.forEach(function (s) {
html += '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px;background:#fff;">';
html += ' <div style="display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:4px;">';
html += ' <div style="font-weight:600;font-size:13px;color:var(--g800);">' + esc(s.title) + '</div>';
html += ' <button type="button" class="btn-sm btn-primary resp-sound-play" data-resp-sound="' + esc(s.key) + '" title="Play sample" style="padding:4px 10px;"><i class="fas fa-play"></i></button>';
html += ' </div>';
html += ' <div style="font-size:11px;color:var(--g500);margin-bottom:4px;"><strong>Where:</strong> ' + esc(s.where) + '</div>';
html += ' <div style="font-size:12px;color:var(--g700);line-height:1.5;margin-bottom:4px;"><strong>Features:</strong> ' + esc(s.features) + '</div>';
html += ' <div style="font-size:11px;color:var(--g600);line-height:1.5;"><strong>Clinical:</strong> ' + esc(s.clinical) + '</div>';
html += '</div>';
});
html += ' </div>';
html += '</div>';
html += renderSoundsLibrary('Respiratory sounds library', RESP_SOUNDS, accent, accentTint, 'fa-lungs');
}
// ─ Grading scales reference (collapsible) ─