const STORAGE_KEY = 'tccc360-v1';
const ACCENTS = [
{ value: 'op',     color: '#3f9e6a', label: 'Operational Green' },
{ value: 'ranger', color: '#4f7a5b', label: 'Ranger Green' },
{ value: 'olive',  color: '#6e6f3a', label: 'Olive Drab' },
{ value: 'sage',   color: '#869169', label: 'Foliage Sage' },
{ value: 'steel',  color: '#5d7e92', label: 'Steel Blue' },
{ value: 'coyote', color: '#8a6a4a', label: 'Coyote Brown' },
{ value: 'sand',   color: '#b3a06b', label: 'Desert Sand' },
{ value: 'slate',  color: '#727f87', label: 'Slate Grey' },
{ value: 'rust',   color: '#9e5a45', label: 'Brick Rust' },
{ value: 'crit',   color: '#b8443a', label: 'Critical Red' },
];
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "tactical",
"accent": "op",
"density": "regular",
"sectionStyle": "tactical",
"showSparklines": true,
"showGrid": true,
"timeMode": "both",
"glassPanels": true,
"glassOpacity": 56,
"bgCondition": true,
"webglField": true,
"soundOn": false,
"soundMode": "lubdub",
"soundVolume": 55,
"collapsibleTiles": true,
"dripGlow": true
}/*EDITMODE-END*/;
const DEFAULT_STATE = {
rosterNum: '',
opName: 'ENDURING SHIELD',
medic: 'SGT R. PHAM',
name: 'DOE, J.',
last4: '4521',
gender: 'M',
date: '',
time: '',
service: 'USA',
unit: '',
allergies: 'NKDA',
bloodType: 'A+',
evac: 'urgent',
triage: '',
weight: 90,
mechanism: ['GSW', 'Blast'],
acuteFindings: [],
findingPos: {},
injuries: [
{ view:'front', x: 70, y: 130, type:'gsw' },
{ view:'front', x: 132, y: 280, type:'gsw' },
{ view:'back',  x: 110, y: 160, type:'gsw' },
],
burnedRegions: [],
tourniquets: {
rArm: { active: false, type: '', time: '', position: '' },
lArm: { active: false, type: '', time: '', position: '' },
rLeg: { active: true,  type: 'CAT', time: '1342', position: 'Proximal' },
lLeg: { active: false, type: '', time: '', position: '' },
},
vitals: [
{ time: '1338', hr: '128', sbp: '92',  dbp: '60', rr: '22', spo2: '94', pain: '8' },
{ time: '1345', hr: '118', sbp: '98',  dbp: '64', rr: '20', spo2: '96', pain: '6' },
{ time: '1352', hr: '110', sbp: '104', dbp: '68', rr: '18', spo2: '97', pain: '4' },
],
draftVitals: { hr: '110', sbp: '104', dbp: '68', rr: '18', spo2: '97', pain: '4' },
avpu: 'V',
interventions: [
'massive:Tourniquet applied',
'massive:TXA 2g IV',
'circ:IV established',
'head:Hypothermia prevention kit',
],
medsGiven: [
{ id: 1, time: '1343', name: 'Fentanyl OTFC', dose: '800 mcg' },
{ id: 2, time: '1346', name: 'TXA',           dose: '2 g IV' },
{ id: 3, time: '1350', name: 'Ketamine IV',   dose: '0.2 mg/kg' },
],
fluidsGiven: [
{ id: 1, time: '1348', type: 'Whole Blood', vol: '500' },
],
notes: '1340: PT received, GSW R thigh + L flank. CAT applied prox R thigh; bleeding controlled. IV L AC 18g. TXA 2g over 10 min. Permissive hypotension target SBP 90+. Pt warming with hypo kit.',
};
const MECHANISMS = ['Artillery', 'Blast', 'Blunt', 'Burn', 'Drone', 'Fall', 'Grenade', 'GSW', 'IED', 'Landmine', 'MVC', 'RPG', 'Other'];
const ACUTE_FINDINGS = [
'Penetrating Head', 'TBI / Closed Head', 'Maxillofacial', 'Ocular / Globe', 'Airway Burn', 'Neck Hematoma',
'Tension Pneumo', 'Open Chest', 'Sucking Chest', 'Flail Chest', 'Hemothorax', 'Cardiac Tamponade', 'Blast Lung', 'Impalement',
'Evisceration', 'Open Abdomen', 'Pelvic Fracture', 'GU Trauma',
'Amputation', 'Mangled Extremity', 'Open Fracture', 'Arterial Bleed', 'Junctional Hemorrhage', 'Crush', 'Compartment Syndrome', 'Avulsion', 'Degloving',
'Spinal Injury',
];
function sanitizeState(s) {
if (Array.isArray(s.injuries)) {
const seen = new Set();
const kept = [];
for (const i of s.injuries) {
if (i && i.type === 'burn' && Array.isArray(i.points)) {
const n = i.points.length;
const cx = Math.round(i.points.reduce((a, p) => a + p.x, 0) / n / 3);
const cy = Math.round(i.points.reduce((a, p) => a + p.y, 0) / n / 3);
const sig = i.view + ':' + n + ':' + cx + ':' + cy;
if (seen.has(sig)) continue;
seen.add(sig);
}
kept.push(i);
}
const burns = kept.filter(i => i && i.type === 'burn' && i.points);
if (burns.length > 24) {
const drop = new Set(burns.slice(0, burns.length - 24));
s.injuries = kept.filter(i => !drop.has(i));
} else {
s.injuries = kept;
}
}
return s;
}
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return sanitizeState({ ...DEFAULT_STATE, ...JSON.parse(raw) });
} catch (e) {}
return DEFAULT_STATE;
}
function App() {
const [state, setState] = React.useState(loadState);
const [tab, setTab] = React.useState('treatments');
const [now, setNow] = React.useState(new Date());
const [focus, setFocus] = React.useState(false);
const [peek, setPeek] = React.useState(null); // null | 'center'
const [findingsOpen, setFindingsOpen] = React.useState(() => {
try { return localStorage.getItem('tccc360-findings-open') !== '0'; } catch (e) { return true; }
});
const [exportOpen, setExportOpen] = React.useState(false);
const [tickerSpeed, setTickerSpeed] = React.useState(() => {
try { const v = parseInt(localStorage.getItem('tccc360-ticker-speed'), 10); return v > 0 ? v : 38; } catch (e) { return 38; }
});
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
React.useEffect(() => {
const root = document.documentElement;
root.setAttribute('data-theme', t.theme || 'tactical');
root.setAttribute('data-accent', t.accent || 'op');
root.setAttribute('data-density', t.density || 'regular');
root.setAttribute('data-section-style', t.sectionStyle || 'tactical');
root.style.setProperty('--grid-op', t.showGrid && t.theme !== 'paper' ? 0.18 : 0);
root.style.setProperty('--tk-accent', 'var(--accent)');
}, [t.theme, t.accent, t.density, t.sectionStyle, t.showGrid]);
React.useEffect(() => {
document.documentElement.setAttribute('data-collapsible', t.collapsibleTiles === false ? '0' : '1');
}, [t.collapsibleTiles]);
React.useEffect(() => {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } catch (e) {}
}, [state]);
React.useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id);
}, []);
const set = (k) => (v) => setState(prev => ({ ...prev, [k]: typeof v === 'function' ? v(prev[k]) : v }));
const toggleMechanism = (m) => {
setState(prev => ({
...prev,
mechanism: prev.mechanism.includes(m)
? prev.mechanism.filter(x => x !== m)
: [...prev.mechanism, m]
}));
};
const toggleFinding = (f) => {
setState(prev => {
const list = prev.acuteFindings || [];
return {
...prev,
acuteFindings: list.includes(f) ? list.filter(x => x !== f) : [...list, f]
};
});
};
const setEvac = (level) => setState(prev => ({ ...prev, evac: prev.evac === level ? '' : level }));
const z = (n) => String(n).padStart(2, '0');
const zuluTime = `${z(now.getUTCHours())}${z(now.getUTCMinutes())}${z(now.getUTCSeconds())}Z`;
const localTime = `${z(now.getHours())}:${z(now.getMinutes())}:${z(now.getSeconds())}`;
const dateStr = now.toISOString().slice(0,10).replace(/-/g,'') + 'L';
const activeTQs = Object.values(state.tourniquets).filter(t => t.active).length;
const lastVital = state.vitals[state.vitals.length - 1] || {};
React.useEffect(() => {
document.body.classList.toggle('glass', t.glassPanels !== false);
}, [t.glassPanels]);
React.useEffect(() => {
document.body.style.setProperty('--glass-alpha', String((t.glassOpacity ?? 56) / 100));
}, [t.glassOpacity]);
React.useEffect(() => {
const c = document.getElementById('webgl-bg');
if (c) c.style.display = (t.webglField === false) ? 'none' : 'block';
}, [t.webglField]);
React.useEffect(() => { window.HeartAudio && window.HeartAudio.setEnabled(!!t.soundOn); }, [t.soundOn]);
React.useEffect(() => { window.HeartAudio && window.HeartAudio.setMode(t.soundMode || 'lubdub'); }, [t.soundMode]);
React.useEffect(() => { window.HeartAudio && window.HeartAudio.setVolume((t.soundVolume ?? 55) / 100); }, [t.soundVolume]);
React.useEffect(() => {
if (!window.HeartAudio || !t.soundOn) return;
const hr = +lastVital.hr;
if (!hr) return;
const fire = () => { if (!window.__SPM_WAVE_BEAT) window.HeartAudio.beat(); };
fire();
const id = setInterval(fire, 60000 / hr);
return () => clearInterval(id);
}, [t.soundOn, lastVital.hr]);
React.useEffect(() => {
if (!window.PFC_BG) return;
const hr = +lastVital.hr, sbp = +lastVital.sbp, spo2 = +lastVital.spo2;
const level = (t.bgCondition === false) ? 'stable'
: ((sbp && sbp < 90) || (spo2 && spo2 < 90) || (hr && (hr > 130 || hr < 40))) ? 'critical'
: ((sbp && sbp < 100) || (spo2 && spo2 < 94) || (hr && (hr > 110 || hr < 50))) ? 'urgent'
: 'stable';
window.PFC_BG.setCondition(level, { hr: hr || 0 });
}, [lastVital.hr, lastVital.sbp, lastVital.spo2, t.bgCondition]);
return (
<div className={'app' + (focus ? ' focus-mode' : '') + (focus && peek ? ' peek-' + peek : '')}>
{/* ===== TOP BAR ===== */}
<div className="topbar">
<div className="brand"><span className="dot"></span><b>TCCC</b> · 360 CASUALTY VIEW · DD-1380</div>
<div className="session">
<span>OP <input className="session-input" value={state.opName} onChange={e=>set('opName')(e.target.value)} style={{width: `${Math.max(8, (state.opName||'').length)}ch`}}/></span>
<span>MEDIC <input className="session-input" value={state.medic} onChange={e=>set('medic')(e.target.value)} style={{width: `${Math.max(8, (state.medic||'').length)}ch`}}/></span>
<span>DTG <b>{
t.timeMode === 'local' ? `${dateStr.slice(0,8)} ${localTime}`
: t.timeMode === 'zulu' ? `${dateStr} ${zuluTime}`
: `${dateStr} ${zuluTime}`
}</b></span>
<span>LOC <b>34.5°N 69.2°E</b></span>
</div>
</div>
{/* ===== ROSTER HEADER ===== */}
<div className="roster">
<div className="cell">
<div className="lbl">Name · Last4</div>
<div className="val lg" style={{display:'flex', alignItems:'baseline', gap:'4px'}}>
<input value={state.name} onChange={e=>set('name')(e.target.value)} style={{width:'180px'}}/>
<span className="small">/</span>
<input value={state.last4} onChange={e=>set('last4')(e.target.value)} maxLength="4" style={{width:'84px'}} className="last4-input"/>
</div>
</div>
<div className="cell">
<div className="lbl">Roster #</div>
<div className="val">
<input value={state.rosterNum} onChange={e=>set('rosterNum')(e.target.value)} placeholder="HO0332"/>
</div>
<div className="lbl" style={{marginTop:'6px'}}>Service · Unit</div>
<div className="val mono" style={{fontSize:'12px'}}>
<input value={state.service} onChange={e=>set('service')(e.target.value)} style={{width:'48px'}}/>
<span className="muted"> · </span>
<input value={state.unit} onChange={e=>set('unit')(e.target.value)} placeholder="A/2-75" style={{width:'90px'}}/>
</div>
</div>
<div className="cell">
<div className="lbl">Sex · Wt</div>
<div className="val">
<span style={{display:'inline-flex', gap:'4px'}}>
{['M','F'].map(g => (
<button key={g} onClick={()=>set('gender')(g)} style={{
background: state.gender === g ? 'var(--op)' : 'transparent',
border: '1px solid ' + (state.gender === g ? 'var(--op)' : 'var(--line-2)'),
color: state.gender === g ? '#0c1410' : 'var(--fg-1)',
fontFamily: 'var(--mono)', fontSize: '15px', fontWeight: 600, width: '32px', height: '30px',
borderRadius: '2px', cursor: 'pointer'
}}>{g}</button>
))}
</span>
</div>
<div className="lbl" style={{marginTop:'6px'}}>Weight</div>
<div className="val mono" style={{fontSize:'18px'}}>
<input type="number" value={state.weight} onChange={e=>set('weight')(Number(e.target.value)||0)} style={{width:'70px'}}/> kg
</div>
</div>
<div className="cell">
<div className="lbl">Blood Type</div>
<div style={{display:'flex', alignItems:'center', gap:'8px', marginTop:'2px'}}>
<span className="blood-chip">{state.bloodType}</span>
</div>
<div className="lbl" style={{marginTop:'6px'}}>Allergies</div>
<div className="val mono" style={{fontSize:'12px', color: state.allergies==='NKDA'?'var(--op)':'var(--amber)'}}>
<input value={state.allergies} onChange={e=>set('allergies')(e.target.value)}/>
</div>
</div>
<div className="cell">
<div className="lbl">EVAC Priority</div>
<div className="evac">
<button className={state.evac==='urgent'?'active urgent':''} onClick={()=>setEvac('urgent')}>Urgent</button>
<button className={state.evac==='priority'?'active priority':''} onClick={()=>setEvac('priority')}>Priority</button>
<button className={state.evac==='routine'?'active routine':''} onClick={()=>setEvac('routine')}>Routine</button>
</div>
<div className="lbl" style={{marginTop:'8px'}}>Triage</div>
<div className="triage">
{[
{k:'Immediate',c:'var(--crit)'},
{k:'Delayed',c:'var(--amber)'},
{k:'Minimal',c:'var(--op)'},
{k:'Expectant',c:'#555'}
].map(opt => {
const isActive = state.triage === opt.k.toLowerCase();
return (
<button
key={opt.k}
className={'tri-btn ' + (isActive ? 'active' : '')}
style={{
'--tri-c': opt.c,
color: isActive ? '#fff' : opt.c,
background: isActive ? opt.c : 'transparent',
borderColor: opt.c
}}
onClick={()=>set('triage')(isActive ? '' : opt.k.toLowerCase())}
title={opt.k}
>{opt.k.slice(0,3)}</button>
);
})}
</div>
<button
className={'dd-launch' + (t.dripGlow !== false ? ' glow' : '')}
onClick={() => window.dispatchEvent(new CustomEvent('tccc-drip-toggle'))}
title="Open the drip & dosage calculator"
>
<span className="dd-launch-ic">💧</span>
<span className="dd-launch-tx">DRIP / DOSE</span>
</button>
</div>
</div>
{/* ===== SUB-HEADER: Acute Findings (left) + MARCH-PAWS ticker (right) ===== */}
<div className="subhead">
<div className={'findings-bar' + (findingsOpen ? '' : ' closed')}>
<div className="findings-bar-h" onClick={() => { const v = !findingsOpen; setFindingsOpen(v); try { localStorage.setItem('tccc360-findings-open', v ? '1' : '0'); } catch (e) {} }}>
<span className="tick"></span>
<b>ACUTE FINDINGS</b>
<span className="findings-count">
{state.acuteFindings && state.acuteFindings.length
? `${state.acuteFindings.length} flagged · drag tags on the body to reposition`
: 'tap to flag · drag tags on the body to reposition'}
</span>
<span className="findings-chev" aria-hidden="true">{findingsOpen ? '▾' : '▸'}</span>
</div>
{findingsOpen && (
<div className="findings-chips">
{ACUTE_FINDINGS.map(f => (
<div key={f}
className={'fchip ' + ((state.acuteFindings||[]).includes(f) ? 'active' : '')}
onClick={()=>toggleFinding(f)}>
{f}
</div>
))}
</div>
)}
</div>
<MarchPawsTicker speed={tickerSpeed} setSpeed={(v) => { setTickerSpeed(v); try { localStorage.setItem('tccc360-ticker-speed', String(v)); } catch (e) {} }} />
</div>
{/* ===== MAIN GRID ===== */}
<div className="main">
{focus && (
<button className="focus-rail focus-rail-center" onClick={() => setPeek(p => p === 'center' ? null : 'center')}
title="Show signs, timeline & notes">
<span>SIGNS · TIMELINE · NOTES</span>
</button>
)}
{/* ----- LEFT COLUMN: Body Diagram + Mechanism ----- */}
<div className="col col-left">
<div className="scrollable">
<div className="panel">
<div className="panel-h">
<span className="tick"></span><b>INJURY MAP</b>
<span className="right">click body to mark · {state.injuries.length} marks · {activeTQs} TQ</span>
</div>
<BodyDiagram
injuries={state.injuries}
setInjuries={set('injuries')}
tourniquets={state.tourniquets}
setTourniquets={set('tourniquets')}
burnedRegions={state.burnedRegions || []}
setBurnedRegions={set('burnedRegions')}
weightKg={state.weight}
acuteFindings={state.acuteFindings || []}
findingPos={state.findingPos || {}}
onMoveFinding={(f, view, x, y) => setState(prev => ({ ...prev, findingPos: { ...(prev.findingPos || {}), [f]: { view, x, y } } }))}
/>
</div>
<div className="panel panel-mech">
<div className="panel-h"><span className="tick"></span><b>MECHANISM OF INJURY</b></div>
<div className="panel-body">
<div className="chips">
{MECHANISMS.map(m => (
<div key={m}
className={'chip ' + (state.mechanism.includes(m) ? 'active' : '')}
onClick={()=>toggleMechanism(m)}>
{m}
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* ----- CENTER COLUMN: Vitals + Notes ----- */}
<div className="col col-center">
<div className="scrollable">
<div className="panel">
<div className="panel-h">
<span className="tick"></span><b>SIGNS &amp; SYMPTOMS · LIVE</b>
<span className="right">click any tile or cell to edit · latest: {lastVital.time || '—'}</span>
</div>
<VitalsPanel
vitals={state.vitals}
setVitals={set('vitals')}
draft={state.draftVitals || { hr:'', sbp:'', dbp:'', rr:'', spo2:'', pain:'' }}
setDraft={set('draftVitals')}
avpu={state.avpu}
setAVPU={set('avpu')}
showSparklines={t.showSparklines}
/>
</div>
<div className="panel">
<div className="panel-h"><span className="tick"></span><b>TQ &amp; MED · TIMELINE</b></div>
<Timeline state={state} setState={setState} />
</div>
<div className="panel">
<div className="panel-h"><span className="tick"></span><b>FIELD NOTES · MIST</b>
<span className="right">auto-fills as data is entered</span>
</div>
<MistPanel state={state} setNotes={set('notes')} notes={state.notes}/>
</div>
</div>
</div>
{/* ----- RIGHT COLUMN: Tabs ----- */}
<div className="col col-right">
<div className="tabs">
{[
['treatments', 'Tx'],
['meds', 'Meds'],
['drip', 'Drip'],
['blood', 'Blood'],
['ref', 'Ref']
].map(([k, l]) => (
<button key={k} className={tab===k ? 'active':''} onClick={()=>setTab(k)}>{l}</button>
))}
</div>
{tab === 'treatments' && <TreatmentsTab interventions={state.interventions} setInterventions={set('interventions')} />}
{tab === 'meds'       && <MedsTab medsGiven={state.medsGiven} setMedsGiven={set('medsGiven')} weight={state.weight} />}
{tab === 'drip'       && <DripCalcTab patientKgInit={state.weight} />}
{tab === 'blood'      && <BloodTab bloodType={state.bloodType} setBloodType={set('bloodType')} fluidsGiven={state.fluidsGiven} setFluidsGiven={set('fluidsGiven')} />}
{tab === 'ref'        && <RefTab />}
</div>
</div>
{/* ===== STATUS BAR ===== */}
<div className="statusbar">
<div className="seg"><span>●</span><b style={{color:'var(--op)'}}>SECURE</b></div>
<div className="seg">PT <b>{state.name}</b></div>
<div className="seg">EVAC <b style={{
color: state.evac==='urgent'?'var(--crit)':state.evac==='priority'?'var(--amber)':'var(--op)'
}}>{state.evac.toUpperCase() || '—'}</b></div>
<div className="seg">TQ <b>{activeTQs}/4</b></div>
<div className="seg">VITALS <b>{state.vitals.length} rec</b></div>
<div className="seg">MEDS <b>{state.medsGiven.length}</b></div>
<div style={{marginLeft:'auto', display:'flex', alignItems:'center', gap:'6px'}}>
<button
className={'tweaks-btn focus-btn' + (focus ? ' on' : '')}
onClick={() => { setFocus(f => !f); setPeek(null); }}
title="Focus mode — body diagram + Tx/Meds/Blood/Ref center stage"
>
<span className="tweaks-btn-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 5.5V2.5h3M14 5.5V2.5h-3M2 10.5v3h3M14 10.5v3h-3"/>
</svg>
</span>
<span>{focus ? 'EXIT FOCUS' : 'FOCUS'}</span>
</button>
<div className="export-wrap">
<button
className="tweaks-btn print-btn"
onClick={() => setExportOpen(o => !o)}
title="Export casualty card (PDF / HTML / PNG / JPEG)"
>
<span className="tweaks-btn-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M4.5 6V2.5h7V6"/>
<rect x="2.5" y="6" width="11" height="5" rx="1"/>
<path d="M4.5 10.5h7V14h-7z"/>
</svg>
</span>
<span>EXPORT</span>
</button>
{exportOpen && (
<div className="export-menu">
<button onClick={() => { setExportOpen(false); exportCasualtyCard('pdf'); }}>PDF · Print</button>
<button onClick={() => { setExportOpen(false); exportCasualtyCard('html'); }}>HTML file</button>
<button onClick={() => { setExportOpen(false); exportCasualtyCard('png'); }}>PNG image</button>
<button onClick={() => { setExportOpen(false); exportCasualtyCard('jpeg'); }}>JPEG image</button>
</div>
)}
</div>
<button
className="tweaks-btn"
onClick={() => window.postMessage({ type: '__activate_edit_mode' }, '*')}
title="Open Tweaks panel"
>
<span className="tweaks-btn-icon" aria-hidden="true">
<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round">
<line x1="3" y1="4" x2="13" y2="4"/>
<line x1="3" y1="8" x2="13" y2="8"/>
<line x1="3" y1="12" x2="13" y2="12"/>
<circle cx="6" cy="4" r="1.6" fill="var(--bg-0)"/>
<circle cx="10" cy="8" r="1.6" fill="var(--bg-0)"/>
<circle cx="5" cy="12" r="1.6" fill="var(--bg-0)"/>
</svg>
</span>
<span>TWEAKS</span>
</button>
</div>
<div className="seg">
{(t.timeMode === 'local' || t.timeMode === 'both') && <><b>{localTime}</b> LOC</>}
{t.timeMode === 'both' && ' · '}
{(t.timeMode === 'zulu' || t.timeMode === 'both') && <><b>{zuluTime}</b></>}
</div>
<div className="seg">
<button onClick={() => {
if (confirm('Reset all casualty data? This cannot be undone.')) {
localStorage.removeItem(STORAGE_KEY);
setState({ ...DEFAULT_STATE, name:'', last4:'', mechanism:[], injuries:[], vitals:[], medsGiven:[], interventions:[], fluidsGiven:[], notes:'', tourniquets:{rArm:{active:false,type:'',time:''},lArm:{active:false,type:'',time:''},rLeg:{active:false,type:'',time:''},lLeg:{active:false,type:'',time:''}} });
}
}} style={{background:'transparent', border:'none', color:'var(--fg-3)', fontFamily:'var(--mono)', fontSize:'10px', letterSpacing:'0.15em', cursor:'pointer', textTransform:'uppercase'}}>
⨯ NEW CASUALTY
</button>
</div>
</div>
<TweaksPanel title="Tweaks" headerExtra={
<div className="twk-swatches" role="radiogroup" aria-label="Color scheme">
{ACCENTS.map(a => (
<button key={a.value} type="button" className="twk-accsw" role="radio"
aria-checked={t.accent === a.value} data-on={t.accent === a.value ? '1' : '0'}
aria-label={a.label} title={a.label}
style={{ background: a.color }}
onClick={() => setTweak('accent', a.value)} />
))}
</div>
}>
<TweakSection label="Theme" />
<TweakSelect label="Mode" value={t.theme}
options={[
{ value: 'tactical', label: 'Tactical (night)' },
{ value: 'day',      label: 'Day mode' },
{ value: 'paper',    label: 'DD1380 Paper' },
]}
onChange={(v) => setTweak('theme', v)} />
<TweakSelect label="Accent" value={t.accent}
options={ACCENTS.map(a => ({ value: a.value, label: a.label }))}
onChange={(v) => setTweak('accent', v)} />
<TweakRadio label="Section labels" value={t.sectionStyle}
options={[
{ value: 'tactical', label: 'Mono caps' },
{ value: 'dd1380',   label: 'Italic' },
]}
onChange={(v) => setTweak('sectionStyle', v)} />
<TweakSection label="Layout" />
<TweakRadio label="Density" value={t.density}
options={['compact', 'regular', 'roomy']}
onChange={(v) => setTweak('density', v)} />
<TweakToggle label="Grid backdrop" value={t.showGrid}
onChange={(v) => setTweak('showGrid', v)} />
<TweakToggle label="Vital sparklines" value={t.showSparklines}
onChange={(v) => setTweak('showSparklines', v)} />
<TweakToggle label="Collapsible tiles" value={t.collapsibleTiles !== false}
onChange={(v) => setTweak('collapsibleTiles', v)} />
<TweakToggle label="Drip/Dose glow" value={t.dripGlow !== false}
onChange={(v) => setTweak('dripGlow', v)} />
<TweakSection label="Time" />
<TweakRadio label="Display" value={t.timeMode}
options={[
{ value: 'local', label: 'Local' },
{ value: 'zulu',  label: 'Zulu' },
{ value: 'both',  label: 'Both' },
]}
onChange={(v) => setTweak('timeMode', v)} />
<TweakSection label="Background · WebGL" />
<TweakToggle label="WebGL field" value={t.webglField !== false}
onChange={(v) => setTweak('webglField', v)} />
<TweakToggle label="Glass panels" value={t.glassPanels !== false}
onChange={(v) => setTweak('glassPanels', v)} />
<TweakSlider label="Glass opacity" value={t.glassOpacity ?? 56}
min={30} max={95} unit=" %"
onChange={(v) => setTweak('glassOpacity', v)} />
<TweakToggle label="Tint to patient acuity" value={t.bgCondition !== false}
onChange={(v) => setTweak('bgCondition', v)} />
<TweakSection label="Heart Sound" />
<TweakToggle label="Sound" value={!!t.soundOn}
onChange={(v) => {
setTweak('soundOn', v);
if (window.HeartAudio) { window.HeartAudio.setEnabled(v); if (v) window.HeartAudio.beat(); }
}} />
<TweakRadio label="Mode" value={t.soundMode || 'lubdub'}
options={[
{ value: 'lubdub', label: 'Lub-Dub' },
{ value: 'beep',   label: 'Beep' },
]}
onChange={(v) => { setTweak('soundMode', v); if (window.HeartAudio) { window.HeartAudio.setMode(v); if (t.soundOn) window.HeartAudio.beat(); } }} />
<TweakSlider label="Volume" value={t.soundVolume ?? 55}
min={0} max={100} unit=" %"
onChange={(v) => setTweak('soundVolume', v)} />
</TweaksPanel>
{/* Separate drip & dosage calculator box (floating, with teaching mode) */}
<window.DripDosageBox weight={state.weight} hideFab={true} />
{/* Print-only casualty card (hidden on screen, revealed by @media print) */}
<PrintSheet state={state} now={now} />
</div>
);
}
function Timeline({ state, setState }) {
const events = [];
state.medsGiven.forEach(m => events.push({
t: m.time, k: 'med', label: m.name, sub: m.dose,
onClear: () => setState(p => ({ ...p, medsGiven: p.medsGiven.filter(x => x.id !== m.id) }))
}));
state.fluidsGiven.forEach(m => events.push({
t: m.time, k: 'fluid', label: m.type, sub: m.vol ? m.vol + ' mL' : '',
onClear: () => setState(p => ({ ...p, fluidsGiven: p.fluidsGiven.filter(x => x.id !== m.id) }))
}));
state.vitals.forEach((v, idx) => events.push({
t: v.time, k: 'vital',
label: `HR ${v.hr} · BP ${v.sbp}/${v.dbp}`,
sub: `RR ${v.rr} SpO₂ ${v.spo2}`,
onClear: () => setState(p => ({ ...p, vitals: p.vitals.filter((_, i) => i !== idx) }))
}));
Object.entries(state.tourniquets).forEach(([k, tq]) => {
if (tq.active && tq.time) {
const side = { rArm:'R ARM', lArm:'L ARM', rLeg:'R LEG', lLeg:'L LEG' }[k];
events.push({
t: tq.time, k: 'tq', label: `TQ ${side}`, sub: [tq.type, tq.position].filter(Boolean).join(' · ') || 'applied',
onClear: () => setState(p => ({
...p,
tourniquets: { ...p.tourniquets, [k]: { active: false, type: '', time: '', position: '' } }
}))
});
}
});
events.sort((a, b) => (a.t || '').localeCompare(b.t || ''));
if (events.length === 0) {
return <div style={{padding:'14px', fontFamily:'var(--mono)', fontSize:'11px', color:'var(--fg-3)'}}>No events yet.</div>;
}
const times = events.map(e => parseTime(e.t)).filter(n => !isNaN(n));
const t0 = Math.min(...times);
const t1 = Math.max(...times);
const span = Math.max(1, t1 - t0);
return (
<div style={{padding:'4px 14px 14px'}}>
<div style={{position:'relative', height: events.length * 26 + 30}}>
{/* axis */}
<div style={{position:'absolute', left:'72px', right:'24px', top:14, height:1, background:'var(--line-2)'}}></div>
{/* tick labels */}
<div style={{position:'absolute', left:'72px', right:'24px', top:0, display:'flex', justifyContent:'space-between', fontFamily:'var(--mono)', fontSize:'9px', color:'var(--fg-3)'}}>
<span>{events[0]?.t || ''}</span>
<span>{events[events.length-1]?.t || ''}</span>
</div>
{events.map((e, i) => {
const tx = parseTime(e.t);
const pct = isNaN(tx) ? 0 : ((tx - t0) / span) * 100;
const color = e.k === 'tq' ? 'var(--op)'
: e.k === 'med' ? 'var(--amber)'
: e.k === 'fluid' ? 'var(--info)'
: 'var(--fg-2)';
return (
<div key={i} className="tl-row" style={{
position:'absolute', top: 26 + i*22, left:0, right:0,
display:'grid', gridTemplateColumns:'56px 1fr 22px',
alignItems:'center', gap:'8px',
fontFamily:'var(--mono)', fontSize:'11px'
}}>
<span style={{color:'var(--fg-2)', textAlign:'right'}}>{e.t || '----'}</span>
<span style={{position:'relative', display:'block', height:'14px'}}>
<span style={{
position:'absolute', top:'3px',
width:8, height:8, background: color, borderRadius: e.k==='tq'?'1px':'50%',
left: `calc(${pct}% - 4px)`,
boxShadow:`0 0 6px ${color}`
}}></span>
<span style={{position:'absolute', left:0, top:'-2px', color:'var(--fg-0)', whiteSpace:'nowrap'}}>
{e.label} <span style={{color:'var(--fg-2)'}}>· {e.sub}</span>
</span>
</span>
<button className="tl-clear" onClick={e.onClear} title="Clear this event">×</button>
</div>
);
})}
</div>
</div>
);
}
function parseTime(t) {
if (!t) return NaN;
const s = String(t).replace(':','');
const h = parseInt(s.slice(0,2));
const m = parseInt(s.slice(2,4));
if (isNaN(h) || isNaN(m)) return NaN;
return h * 60 + m;
}
function buildMIST(state) {
const M = state.mechanism && state.mechanism.length
? state.mechanism.join(' · ')
: '—';
const counts = { gsw: 0, burn: 0, frac: 0, shrap: 0 };
(state.injuries || []).forEach(i => { counts[i.type] = (counts[i.type] || 0) + 1; });
const front = (state.injuries || []).filter(i => i.view === 'front').length;
const back  = (state.injuries || []).filter(i => i.view === 'back').length;
const injParts = [];
if (counts.gsw)   injParts.push(`${counts.gsw}× GSW`);
if (counts.burn || (state.burnedRegions && state.burnedRegions.length)) {
const burnTbsa = (typeof estimateBurnTBSA === 'function') ? estimateBurnTBSA(state.injuries, state.burnedRegions) : 0;
if (burnTbsa > 0) injParts.push(`Burn ~${burnTbsa.toFixed(1)}% TBSA`);
else if (counts.burn) injParts.push(`${counts.burn}× Burn`);
}
if (counts.frac)  injParts.push(`${counts.frac}× Frac`);
if (counts.shrap) injParts.push(`${counts.shrap}× Shrap`);
if (front || back) injParts.push(`(ant ${front}/post ${back})`);
const tqActive = Object.entries(state.tourniquets || {})
.filter(([_,t]) => t.active)
.map(([k,t]) => {
const side = { rArm:'R arm', lArm:'L arm', rLeg:'R leg', lLeg:'L leg' }[k];
return `TQ ${side}${t.type ? ' '+t.type : ''}${t.position ? ' ('+t.position+')' : ''}${t.time ? ' @'+t.time : ''}`;
});
if (tqActive.length) injParts.push(...tqActive);
if (state.acuteFindings && state.acuteFindings.length) {
injParts.unshift(state.acuteFindings.join(' · '));
}
const I = injParts.length ? injParts.join(', ') : '—';
const draft = state.draftVitals || {};
const draftHasData = !!(draft.hr || draft.sbp || draft.dbp || draft.rr || draft.spo2 || draft.pain);
const v = draftHasData
? { ...draft, time: '' }
: (state.vitals || [])[state.vitals.length - 1];
let S = '—';
if (v) {
const bits = [];
if (v.hr)  bits.push(`HR ${v.hr}`);
if (v.sbp || v.dbp) bits.push(`BP ${v.sbp||'?'}/${v.dbp||'?'}`);
if (v.rr)  bits.push(`RR ${v.rr}`);
if (v.spo2)bits.push(`SpO₂ ${v.spo2}`);
if (v.pain)bits.push(`Pain ${v.pain}/10`);
if (state.avpu) bits.push(`AVPU ${state.avpu}`);
S = (v.time ? `@${v.time} ` : '') + bits.join(' · ');
} else if (state.avpu) {
S = `AVPU ${state.avpu}`;
}
const tx = [];
(state.interventions || []).forEach(s => {
const [, label] = s.split(':');
if (label) tx.push(label);
});
(state.medsGiven || []).forEach(m => {
tx.push(`${m.name} ${m.dose}${m.time ? ' @'+m.time : ''}`);
});
(state.fluidsGiven || []).forEach(f => {
tx.push(`${f.type}${f.vol ? ' '+f.vol+'mL' : ''}${f.time ? ' @'+f.time : ''}`);
});
const T = tx.length ? tx.join('; ') : '—';
return { M, I, S, T };
}
function MistPanel({ state, notes, setNotes }) {
const mist = buildMIST(state);
const fullText = `M: ${mist.M}\nI: ${mist.I}\nS: ${mist.S}\nT: ${mist.T}`;
const copyToNotes = () => {
const sep = notes && notes.trim() ? '\n\n' : '';
setNotes((notes || '') + sep + `--- MIST @ ${nowHHMM()} ---\n` + fullText);
};
return (
<div style={{padding:'8px 14px 14px'}}>
<div className="mist-grid">
{[
['M', 'Mechanism',  mist.M],
['I', 'Injuries',   mist.I],
['S', 'Signs',      mist.S],
['T', 'Treatments', mist.T],
].map(([k, label, val]) => (
<div className="mist-row" key={k}>
<span className="mist-k"><b>{k}</b><span className="mist-lbl">{label}</span></span>
<span className="mist-v">{val}</span>
</div>
))}
</div>
<div style={{display:'flex', gap:'6px', marginTop:'10px', alignItems:'center'}}>
<button className="mist-btn" onClick={copyToNotes} title="Snapshot current MIST to narrative notes">
⇣ APPEND TO NOTES
</button>
<span style={{flex:1}}></span>
<span className="muted mono tiny" style={{letterSpacing:'0.1em'}}>NARRATIVE</span>
</div>
<textarea
className="notes"
value={notes}
onChange={e=>setNotes(e.target.value)}
placeholder="Free-text narrative. Click APPEND TO NOTES to stamp current MIST + time."
style={{marginTop:'4px'}}
/>
</div>
);
}
function PrintSheet({ state, now }) {
const mist = buildMIST(state);
const w = Number(state.weight) || 0;
const tbsa = (typeof estimateBurnTBSA === 'function') ? estimateBurnTBSA(state.injuries, state.burnedRegions) : 0;
const pk = (tbsa > 0 && w && typeof parkland === 'function') ? parkland(tbsa, w) : null;
let r10rate = 0;
if (tbsa > 0) { const t = Math.round(tbsa); r10rate = t * 10 + (w > 80 ? 100 * Math.floor((w - 80) / 10) : 0); }
const z = (n) => String(n).padStart(2, '0');
const dtg = `${now.toISOString().slice(0,10).replace(/-/g,'')} ${z(now.getUTCHours())}${z(now.getUTCMinutes())}Z`;
const activeTQs = Object.entries(state.tourniquets || {}).filter(([,tq]) => tq.active);
const sideName = { rArm:'R Arm', lArm:'L Arm', rLeg:'R Leg', lLeg:'L Leg' };
const intervBy = {};
(state.interventions || []).forEach(s => { const [k, l] = s.split(':'); if (l) { (intervBy[k] = intervBy[k] || []).push(l); } });
const catName = { massive:'Massive Hemorrhage', airway:'Airway', resp:'Respiration', circ:'Circulation', head:'Head/Hypothermia', pain:'Pain', abx:'Antibiotics', wounds:'Wounds', splint:'Splinting' };
const Row = ({ k, v }) => (<div className="ps-kv"><span className="ps-k">{k}</span><span className="ps-v">{v || '—'}</span></div>);
return (
<div className="print-sheet" aria-hidden="true">
<div className="ps-head">
<div className="ps-title"><b>TCCC 360°</b> — TACTICAL CASUALTY CARD · DD-1380</div>
<div className="ps-dtg">DTG {dtg}</div>
</div>
<div className="ps-grid">
<Row k="Name · Last4" v={`${state.name || '—'} / ${state.last4 || '—'}`} />
<Row k="Roster #" v={state.rosterNum} />
<Row k="Service · Unit" v={`${state.service || '—'} · ${state.unit || '—'}`} />
<Row k="Sex · Weight" v={`${state.gender || '—'} · ${w || '—'} kg`} />
<Row k="Blood Type" v={state.bloodType} />
<Row k="Allergies" v={state.allergies} />
<Row k="EVAC" v={(state.evac || '—').toUpperCase()} />
<Row k="Triage" v={(state.triage || '—').toUpperCase()} />
</div>
<div className="ps-sec">MIST</div>
<div className="ps-mist">
<Row k="M — Mechanism" v={mist.M} />
<Row k="I — Injuries" v={mist.I} />
<Row k="S — Signs" v={mist.S} />
<Row k="T — Treatments" v={mist.T} />
</div>
<div className="ps-sec">Injury Map</div>
<div className="ps-figure">
<window.CasualtyFigure injuries={state.injuries} tourniquets={state.tourniquets}
burnedRegions={state.burnedRegions} acuteFindings={state.acuteFindings} findingPos={state.findingPos} />
</div>
{(state.acuteFindings && state.acuteFindings.length > 0) && (
<>
<div className="ps-sec">Acute Findings</div>
<div className="ps-line">{state.acuteFindings.join(' · ')}</div>
</>
)}
{activeTQs.length > 0 && (
<>
<div className="ps-sec">Tourniquets</div>
<table className="ps-table">
<thead><tr><th>Site</th><th>Type</th><th>Position</th><th>Time</th></tr></thead>
<tbody>
{activeTQs.map(([k, tq]) => (
<tr key={k}><td>{sideName[k]}</td><td>{tq.type || '—'}</td><td>{tq.position || '—'}</td><td>{tq.time || '—'}</td></tr>
))}
</tbody>
</table>
</>
)}
{tbsa > 0 && (
<>
<div className="ps-sec">Burn Resuscitation</div>
<div className="ps-line"><b>{tbsa.toFixed(1)}% TBSA</b>{pk && <> · Parkland {pk.total.toLocaleString()} mL/24h (first 8h {pk.rateFirst8} mL/hr · next 16h {pk.rateNext16} mL/hr)</>}{r10rate > 0 && <> · Rule of 10s {r10rate.toLocaleString()} mL/hr LR</>}</div>
</>
)}
<div className="ps-sec">Vitals</div>
<table className="ps-table">
<thead><tr><th>Time</th><th>HR</th><th>BP</th><th>RR</th><th>SpO₂</th><th>Pain</th></tr></thead>
<tbody>
{(state.vitals || []).map((v, i) => (
<tr key={i}><td>{v.time}</td><td>{v.hr}</td><td>{v.sbp}/{v.dbp}</td><td>{v.rr}</td><td>{v.spo2}</td><td>{v.pain}</td></tr>
))}
{(!state.vitals || state.vitals.length === 0) && <tr><td colSpan="6">—</td></tr>}
</tbody>
</table>
<div className="ps-line ps-sub">AVPU {state.avpu || '—'}</div>
{(state.medsGiven && state.medsGiven.length > 0) && (
<>
<div className="ps-sec">Medications</div>
<table className="ps-table">
<thead><tr><th>Time</th><th>Drug</th><th>Dose</th></tr></thead>
<tbody>{state.medsGiven.map(m => <tr key={m.id}><td>{m.time}</td><td>{m.name}</td><td>{m.dose}</td></tr>)}</tbody>
</table>
</>
)}
{(state.fluidsGiven && state.fluidsGiven.length > 0) && (
<>
<div className="ps-sec">Fluids / Blood</div>
<table className="ps-table">
<thead><tr><th>Time</th><th>Product</th><th>Vol (mL)</th></tr></thead>
<tbody>{state.fluidsGiven.map(f => <tr key={f.id}><td>{f.time}</td><td>{f.type}</td><td>{f.vol || '—'}</td></tr>)}</tbody>
</table>
</>
)}
{Object.keys(intervBy).length > 0 && (
<>
<div className="ps-sec">Treatments / Interventions</div>
{Object.entries(intervBy).map(([k, list]) => (
<div className="ps-kv" key={k}><span className="ps-k">{catName[k] || k}</span><span className="ps-v">{list.join(' · ')}</span></div>
))}
</>
)}
{state.notes && state.notes.trim() && (
<>
<div className="ps-sec">Narrative</div>
<div className="ps-notes">{state.notes}</div>
</>
)}
<div className="ps-foot">OP {state.opName || '—'} · MEDIC {state.medic || '—'} · Generated {dtg} · TCCC 360° — not a substitute for the official DD Form 1380.</div>
</div>
);
}
(function setupCollapsibles() {
const KEY = 'tccc360-collapsed';
const load = () => { try { return new Set(JSON.parse(localStorage.getItem(KEY)) || []); } catch (e) { return new Set(); } };
const save = () => { try { localStorage.setItem(KEY, JSON.stringify([...collapsed])); } catch (e) {} };
let collapsed = load();
const labelOf = (panel) => {
const b = panel.querySelector(':scope > .panel-h b');
return b ? b.textContent.trim() : '';
};
const apply = () => {
document.querySelectorAll('.panel').forEach(panel => {
const head = panel.querySelector(':scope > .panel-h');
if (!head) return;
const lbl = labelOf(panel);
const want = !!(lbl && collapsed.has(lbl));
const has = panel.getAttribute('data-collapsed') === '1';
if (want && !has) panel.setAttribute('data-collapsed', '1');
else if (!want && has) panel.removeAttribute('data-collapsed');
});
};
document.addEventListener('click', (e) => {
if (document.documentElement.getAttribute('data-collapsible') === '0') return;
const head = e.target.closest('.panel-h');
if (!head) return;
const panel = head.closest('.panel');
if (!panel || head.parentElement !== panel) return;
if (e.target.closest('input,select,textarea,button,a,label')) return;
const lbl = labelOf(panel);
if (!lbl) return;
if (collapsed.has(lbl)) collapsed.delete(lbl); else collapsed.add(lbl);
save();
apply();
});
const start = () => {
const root = document.getElementById('root');
if (root) new MutationObserver(() => apply()).observe(root, { childList: true, subtree: true });
apply();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(start, 30));
else setTimeout(start, 30);
})();
const MARCH_PAWS_TIPS = [
['M', 'Massive Hemorrhage', 'Limb bleeding → CAT high-and-tight over the clothing; tighten until bleeding stops and the distal pulse is gone.'],
['M', 'Massive Hemorrhage', 'Junctional / not-TQ-able → pack the wound with hemostatic gauze + 3 min of firm direct pressure.'],
['M', 'Massive Hemorrhage', 'Give TXA 2 g IV/IO as early as possible — ideally within 3 h of injury.'],
['M', 'Massive Hemorrhage', 'Reassess every tourniquet; convert to a pressure dressing once it is tactically safe and bleeding allows.'],
['A', 'Airway', 'Conscious with airway threat → let the casualty take the position of comfort / sit up and lean forward.'],
['A', 'Airway', 'Unconscious → chin-lift / jaw-thrust, insert an NPA, then recovery position.'],
['A', 'Airway', 'Obstruction not relieved → perform a surgical cricothyroidotomy.'],
['R', 'Respiration', 'Open / penetrating chest wound → apply a vented occlusive chest seal; burp or replace it if tension develops.'],
['R', 'Respiration', 'Suspected tension pneumothorax → needle-decompress at the 5th ICS anterior axillary line (or 2nd ICS mid-clavicular).'],
['R', 'Respiration', 'Monitor with pulse-ox and capnography (EMMA); give oxygen if available and saturations are low.'],
['C', 'Circulation', 'Establish IV access; if it fails, move to IO. Reassess all bleeding control.'],
['C', 'Circulation', 'Hemorrhagic shock → whole blood preferred (then plasma / PRBC). Avoid crystalloid when blood is available.'],
['C', 'Circulation', 'Permissive hypotension: target a palpable radial pulse / SBP 80–90 mmHg — unless there is a TBI.'],
['C', 'Circulation', 'Give calcium with massive transfusion to counter citrate-induced hypocalcaemia.'],
['H', 'Hypothermia / Head', 'Prevent hypothermia early — Ready-Heat + HPMK, insulate from the ground, keep the casualty dry.'],
['H', 'Hypothermia / Head', 'TBI → keep SBP ≥ 110 and SpO₂ ≥ 90, avoid hypoventilation; elevate the head 30° if no spinal concern.'],
['P', 'Pain', 'Mild–moderate, still able to fight → Combat Wound Pill Pack (acetaminophen + meloxicam).'],
['P', 'Pain', 'Moderate–severe, not in shock → OTFC 800 mcg fentanyl; reassess every 15 min, watch respirations.'],
['P', 'Pain', 'In shock / respiratory distress → ketamine at analgesic dosing; titrate and protect the airway.'],
['A', 'Antibiotics', 'All open combat wounds → moxifloxacin 400 mg PO if able to tolerate oral meds.'],
['A', 'Antibiotics', 'Unable to take PO or in shock → ertapenem 1 g IV/IO. Give within 3 h of wounding.'],
['W', 'Wounds', 'Dress all wounds; find and treat exit wounds; cover eye injuries with a rigid shield (no pressure).'],
['W', 'Wounds', 'Re-check for bleeding under dressings; estimate burn TBSA, cover burns, and prevent hypothermia.'],
['S', 'Splinting', 'Immobilize suspected fractures; recheck distal pulse, motor and sensation before and after splinting.'],
['S', 'Splinting', 'Suspected pelvic fracture → apply a pelvic binder centered over the greater trochanters.'],
['✓', 'Document', 'Record every finding and intervention on the DD-1380 / TCCC card and keep it with the casualty.'],
];
function MarchPawsTicker({ speed, setSpeed }) {
const [detached, setDetached] = React.useState(false);
const [pos, setPos] = React.useState(null); // null = centered
const drag = React.useRef(null);
const phaseColor = { M:'var(--crit)', A:'var(--amber)', R:'var(--info)', C:'var(--crit)', H:'var(--op)', P:'var(--amber)', W:'var(--info)', S:'var(--op)', '✓':'var(--fg-2)' };
const renderList = (kp) => MARCH_PAWS_TIPS.map((t, i) => (
<div className="mp-line" key={kp + i}>
<span className="mp-badge" style={{ background: phaseColor[t[0]] || 'var(--fg-3)' }}>{t[0]}</span>
<span className="mp-txt"><b>{t[1]}</b> — {t[2]}</span>
</div>
));
const SpeedCtl = () => (
<span className="mp-speed">
<span className="mp-speed-lbl">SPEED</span>
<input type="range" min="10" max="80" step="1" value={90 - speed}
onChange={(e) => setSpeed(90 - Number(e.target.value))} title="Scroll speed" />
</span>
);
const onHeadDown = (e) => {
if (e.target.closest('button') || e.target.closest('input')) return;
const box = e.currentTarget.parentElement;
const r = box.getBoundingClientRect();
drag.current = { dx: e.clientX - r.left, dy: e.clientY - r.top, w: r.width, h: r.height };
setPos({ x: r.left, y: r.top });
e.currentTarget.setPointerCapture && e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
};
const onHeadMove = (e) => {
if (!drag.current) return;
const x = Math.max(6, Math.min(window.innerWidth - drag.current.w - 6, e.clientX - drag.current.dx));
const y = Math.max(6, Math.min(window.innerHeight - 60, e.clientY - drag.current.dy));
setPos({ x, y });
};
const onHeadUp = (e) => { if (drag.current) { drag.current = null; e.currentTarget.releasePointerCapture && e.currentTarget.releasePointerCapture(e.pointerId); } };
const panelStyle = pos ? { left: pos.x + 'px', top: pos.y + 'px', transform: 'none' } : undefined;
return (
<div className="mp-ticker">
<div className="mp-h">
<span className="mp-tick"></span><b>TCCC · MARCH-PAWS</b>
<span className="mp-sub">best practice</span>
<SpeedCtl />
<button className="mp-pop" onClick={() => setDetached(d => !d)} title="Open in a draggable focus panel">⤢</button>
</div>
{!detached ? (
<div className="mp-view">
<div className="mp-track" style={{ animationDuration: speed + 's' }}>
{renderList('a-')}
{renderList('b-')}
</div>
</div>
) : (
<div className="mp-docked">
<span>Best practices opened in focus panel</span>
<button onClick={() => setDetached(false)}>⤡ Dock back</button>
</div>
)}
{detached && ReactDOM.createPortal(
<>
<div className="mp-scrim" onClick={() => setDetached(false)} />
<div className={'mp-panel' + (drag.current ? ' dragging' : '')} style={panelStyle} role="dialog" aria-label="MARCH-PAWS best practices">
<div className="mp-panel-h" onPointerDown={onHeadDown} onPointerMove={onHeadMove} onPointerUp={onHeadUp} onPointerCancel={onHeadUp} title="Drag to move">
<span className="dd-grip" aria-hidden="true">⠿</span>
<span className="mp-panel-title"><span className="mp-tick"></span>TCCC · MARCH-PAWS · BEST PRACTICE</span>
<SpeedCtl />
<button className="mp-panel-x" onClick={() => setDetached(false)} aria-label="Close">×</button>
</div>
<div className="mp-view big">
<div className="mp-track" style={{ animationDuration: speed + 's' }}>
{renderList('p1-')}
{renderList('p2-')}
</div>
</div>
</div>
</>,
document.body
)}
</div>
);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click();
setTimeout(() => { try { document.body.removeChild(a); } catch (e) {} URL.revokeObjectURL(url); }, 1500);
}
const EXPORT_PROPS = ['box-sizing','display','position','top','left','right','bottom','width','height','min-width','max-width','min-height','margin-top','margin-right','margin-bottom','margin-left','padding-top','padding-right','padding-bottom','padding-left','border-top','border-right','border-bottom','border-left','border-radius','border-collapse','color','background-color','font-family','font-size','font-weight','font-style','line-height','letter-spacing','text-transform','text-align','text-anchor','white-space','flex','flex-direction','align-items','justify-content','gap','grid-template-columns','opacity','fill','stroke','stroke-width','stroke-dasharray','vertical-align','overflow','object-fit'];
function inlineComputed(src, dst) {
const cs = window.getComputedStyle(src);
let str = 'animation:none;transition:none;';
for (const p of EXPORT_PROPS) { const v = cs.getPropertyValue(p); if (v) str += p + ':' + v + ';'; }
dst.setAttribute('style', str);
const sc = src.children, dc = dst.children;
for (let i = 0; i < sc.length; i++) if (dc[i]) inlineComputed(sc[i], dc[i]);
}
function exportFilename() {
const d = new Date(); const z = (n) => String(n).padStart(2, '0');
return `TCCC360-casualty-${d.getFullYear()}${z(d.getMonth()+1)}${z(d.getDate())}-${z(d.getHours())}${z(d.getMinutes())}`;
}
function exportCasualtyCard(fmt) {
if (fmt === 'pdf') { window.print(); return; }
const src = document.querySelector('.print-sheet');
if (!src) return;
const fname = exportFilename();
if (fmt === 'html') {
let css = [...document.querySelectorAll('style')].map(s => s.textContent).join('\n');
css = css.replace(/@font-face\s*\{[^}]*\}/g, ''); // drop the heavy embedded fonts; fall back to system mono
const doc = '<!doctype html><html lang="en"><head><meta charset="utf-8">'
+ '<meta name="viewport" content="width=device-width, initial-scale=1"><title>' + fname + '</title>'
+ '<style>' + css + '</style>'
+ '<style>html,body{background:#fff;margin:0}.print-sheet{display:block !important;position:static;max-width:820px;margin:24px auto;padding:24px;font-family:ui-monospace,Menlo,Consolas,monospace}</style>'
+ '</head><body>' + src.outerHTML + '</body></html>';
downloadBlob(new Blob([doc], { type: 'text/html' }), fname + '.html');
return;
}
const holder = document.createElement('div');
holder.style.cssText = 'position:fixed;left:-10000px;top:0;background:#fff;z-index:-1;';
const clone = src.cloneNode(true);
clone.style.display = 'block'; clone.style.position = 'static';
clone.style.maxWidth = 'none'; clone.style.width = '780px'; clone.style.padding = '24px'; clone.style.background = '#fff';
holder.appendChild(clone);
document.body.appendChild(holder);
const cleanup = () => { try { document.body.removeChild(holder); } catch (e) {} };
requestAnimationFrame(() => requestAnimationFrame(() => {
const w = Math.ceil(clone.scrollWidth || 780);
const h = Math.ceil(clone.scrollHeight || 1000);
inlineComputed(clone, clone);
let xhtml;
try { xhtml = new XMLSerializer().serializeToString(clone); }
catch (e) { cleanup(); alert('Export failed to serialize. Use PDF or HTML.'); return; }
if (!/xmlns=/.test(xhtml.slice(0, 120))) xhtml = xhtml.replace('<div', '<div xmlns="http://www.w3.org/1999/xhtml"');
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><foreignObject x="0" y="0" width="${w}" height="${h}">${xhtml}</foreignObject></svg>`;
const scale = 2;
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = w * scale; canvas.height = h * scale;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(scale, scale); ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
if (blob) downloadBlob(blob, fname + (fmt === 'jpeg' ? '.jpg' : '.png'));
else alert('Image export not supported in this browser — use PDF or HTML.');
cleanup();
}, fmt === 'jpeg' ? 'image/jpeg' : 'image/png', 0.92);
};
img.onerror = () => { cleanup(); alert('Image export not supported in this browser — use PDF or HTML.'); };
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}));
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);