diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/App.jsx b/src/App.jsx index b6f2208..da9ff42 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,326 +1,31 @@ -import { useState, useEffect, useRef, useCallback } from "react"; import DripitoSim from "./DripitoSim"; +import { useDripMonitor } from "./hooks/useDripMonitor"; +import { formatElapsed, alarmLevel, devPct, getLines } from "./utils/displayHelpers"; +import DevicePanel from "./components/DevicePanel"; +import DropSimulatorPanel from "./components/DropSimulatorPanel"; +import LiveStatsPanel from "./components/LiveStatsPanel"; +import ScreenJumper from "./components/ScreenJumper"; +import SpecPanel from "./components/SpecPanel"; const LCD_FONT_URL = "https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap"; -// ─── Constants ──────────────────────────────────────────────────────────────── -const DRIP_SETS = [ - { label: "10 gtt/mL", gtt: 10, note: "Macro – rapid infusion" }, - { label: "15 gtt/mL", gtt: 15, note: "Macro – standard adult" }, - { label: "20 gtt/mL", gtt: 20, note: "Macro – standard adult" }, - { label: "60 gtt/mL", gtt: 60, note: "Micro – paediatric/neonate" }, -]; - -const FLOW_AVG_WINDOW = 5; -const NO_FLOW_DEMO = 8000; // ms — short for demo -const NO_FLOW_REAL = 60000; // ms — 60s real clinical - -const S = { - BOOT: "BOOT", - MEASURING: "MEASURING", - MAIN: "MAIN", - ARMED: "ARMED", - ALARM_WARN: "ALARM_WARN", - ALARM_HIGH: "ALARM_HIGH", - ALARM_NOFLOW: "ALARM_NOFLOW", - ALARM_LOWBAT: "ALARM_LOWBAT", -}; - -const INFO_MODES = ["INFUSED", "ELAPSED", "DROPS"]; - -// ─── LCD display ────────────────────────────────────────────────────────────── -function LCD({ lines, alarmLevel }) { - const padded = [...lines]; - while (padded.length < 4) padded.push(""); - const bg = alarmLevel === "HIGH" ? "#c8e87a" : alarmLevel === "WARN" ? "#d0e87a" : "#c8d87a"; - return ( -
- -
-
- {padded.slice(0,4).map((line, i) => ( -
- {(line + " ".repeat(16)).slice(0,16)} -
- ))} -
- ); -} - -// ─── Physical button ────────────────────────────────────────────────────────── -function Btn({ label, sublabel, color, bg, onClick, red }) { - const [pressed, setPressed] = useState(false); - return ( - - ); -} - -// ─── Main App ───────────────────────────────────────────────────────────────── export default function DripitoV2() { - // drip set - const [dripSetIdx, setDripSetIdx] = useState(2); - const dripSet = DRIP_SETS[dripSetIdx]; - const GTT_PER_ML = dripSet.gtt; - - // drop tracking (refs = no re-render on every drop like firmware would) - const intervalBuffer = useRef([]); // last N inter-drop dt values (ms) - const lastDropTime = useRef(null); - const noFlowTimer = useRef(null); - const startTime = useRef(null); - - // reactive state - const [dropCount, setDropCount] = useState(0); - const [flowMlh, setFlowMlh] = useState(0); // moving-avg - const [instFlowMlh, setInstFlowMlh] = useState(0); // single-interval - const [totalMl, setTotalMl] = useState(0); - const [elapsedMs, setElapsedMs] = useState(0); - - - // UI state - const [screen, setScreen] = useState(S.BOOT); - const [infoMode, setInfoMode] = useState(0); - const [armedFlow, setArmedFlow] = useState(null); - const [demoMode, setDemoMode] = useState(true); - const [blink, setBlink] = useState(true); - const [blinkFast, setBlinkFast] = useState(true); - - const bootDone = useRef(false); - const measDone = useRef(false); - - // local copies for use inside callbacks without stale closure issues - const armedFlowRef = useRef(null); - const gttRef = useRef(GTT_PER_ML); - const screenRef = useRef(S.BOOT); - armedFlowRef.current = armedFlow; - gttRef.current = GTT_PER_ML; - screenRef.current = screen; - - // blink tickers - useEffect(() => { - const t1 = setInterval(() => setBlink(b=>!b), 600); - const t2 = setInterval(() => setBlinkFast(b=>!b), 280); - return () => { clearInterval(t1); clearInterval(t2); }; - }, []); - - // elapsed timer - useEffect(() => { - const active = [S.MAIN,S.ARMED,S.ALARM_WARN,S.ALARM_HIGH,S.ALARM_NOFLOW]; - if (!active.includes(screen)) return; - const t = setInterval(() => { - if (startTime.current) setElapsedMs(Date.now() - startTime.current); - }, 500); - return () => clearInterval(t); - }, [screen]); - - // boot sequence - useEffect(() => { - if (screen === S.BOOT && !bootDone.current) { - bootDone.current = true; - setTimeout(() => { setScreen(S.MEASURING); measDone.current = false; }, 2500); - } - if (screen === S.MEASURING && !measDone.current) { - measDone.current = true; - startTime.current = Date.now(); - setTimeout(() => setScreen(S.MAIN), 4000); - } - }, [screen]); - - // no-flow watchdog - const resetNoFlowTimer = useCallback(() => { - if (noFlowTimer.current) clearTimeout(noFlowTimer.current); - const timeout = demoMode ? NO_FLOW_DEMO : NO_FLOW_REAL; - noFlowTimer.current = setTimeout(() => setScreen(S.ALARM_NOFLOW), timeout); - }, [demoMode]); - - useEffect(() => { - const monitoring = [S.MAIN,S.ARMED,S.ALARM_WARN,S.ALARM_HIGH]; - if (monitoring.includes(screen)) resetNoFlowTimer(); - return () => { if (noFlowTimer.current) clearTimeout(noFlowTimer.current); }; - }, [screen, resetNoFlowTimer]); - - // ── Core drop handler (mirrors EXTI ISR + flow calc in firmware) ────────── - const registerDrop = useCallback(() => { - const now = Date.now(); - const gtt = gttRef.current; - const armed = armedFlowRef.current; - const curScr = screenRef.current; - - // Only register drops in monitoring screens - const valid = [S.MAIN,S.ARMED,S.ALARM_WARN,S.ALARM_HIGH,S.ALARM_NOFLOW,S.MEASURING]; - if (!valid.includes(curScr)) return; - - // Reset no-flow watchdog - resetNoFlowTimer(); - - // Compute flow - let newInst = 0; - let newAvg = 0; - - if (lastDropTime.current !== null) { - const dt = now - lastDropTime.current; // ms between drops - newInst = (3600 * 1000) / (dt * gtt); // mL/h instantaneous - - intervalBuffer.current.push(dt); - if (intervalBuffer.current.length > FLOW_AVG_WINDOW) - intervalBuffer.current.shift(); - - const avgDt = intervalBuffer.current.reduce((a,b)=>a+b,0) / intervalBuffer.current.length; - newAvg = (3600 * 1000) / (avgDt * gtt); // mL/h moving avg - } - lastDropTime.current = now; + const monitor = useDripMonitor(); + const { + dripSetIdx, setDripSetIdx, dripSet, GTT_PER_ML, + dropCount, flowMlh, instFlowMlh, totalMl, elapsedMs, + screen, setScreen, infoMode, armedFlow, setArmedFlow, demoMode, setDemoMode, + blink, blinkFast, + registerDrop, handleMode, handleSet, handlePlus, handleMute, hardReset, + _bootDone, _measDone, + } = monitor; + + const { eH, eM, eS } = formatElapsed(elapsedMs); + const alLv = alarmLevel(screen); + const devP = devPct(armedFlow, flowMlh); + const lines = getLines({ screen, flowMlh, GTT_PER_ML, dropCount, armedFlow, infoMode, + totalMl, blink, blinkFast, eH, eM, eS, devP }); - // Update reactive state via functional updaters to avoid stale closures - setDropCount(prev => { - const n = prev + 1; - setTotalMl(n / gtt); - return n; - }); - setInstFlowMlh(Math.round(newInst * 10) / 10); - setFlowMlh(Math.round(newAvg * 10) / 10); - - // Alarm logic (only when armed and we have enough drops for avg) - if (armed !== null && intervalBuffer.current.length >= 1) { - const dev = Math.abs(newAvg - armed) / armed; - setScreen(prev => { - if (prev === S.ALARM_NOFLOW) return S.ARMED; - if (dev >= 0.25) return S.ALARM_HIGH; - if (dev >= 0.15) return S.ALARM_WARN; - return S.ARMED; - }); - } else if (curScr === S.ALARM_NOFLOW) { - setScreen(armedFlowRef.current ? S.ARMED : S.MAIN); - } - }, [resetNoFlowTimer]); - - // keyboard shortcut: SPACE = drop - useEffect(() => { - const handler = (e) => { - if (e.code === "Space") { e.preventDefault(); registerDrop(); } - }; - window.addEventListener("keydown", handler); - return () => window.removeEventListener("keydown", handler); - }, [registerDrop]); - - // recalculate flow when drip set changes - useEffect(() => { - if (intervalBuffer.current.length > 0) { - const avgDt = intervalBuffer.current.reduce((a,b)=>a+b,0) / intervalBuffer.current.length; - setFlowMlh(Math.round((3600*1000)/(avgDt*GTT_PER_ML)*10)/10); - } - setDropCount(prev => { setTotalMl(prev / GTT_PER_ML); return prev; }); - // eslint-disable-next-line - }, [GTT_PER_ML]); - - // ── Helpers ─────────────────────────────────────────────────────────────── - const eTotal = elapsedMs / 1000; - const eH = String(Math.floor(eTotal/3600)).padStart(2,"0"); - const eM = String(Math.floor((eTotal%3600)/60)).padStart(2,"0"); - const eS = String(Math.floor(eTotal%60)).padStart(2,"0"); - const batPct = 72; - - function alarmLevel() { - if (screen===S.ALARM_HIGH||screen===S.ALARM_NOFLOW) return "HIGH"; - if (screen===S.ALARM_WARN) return "WARN"; - return "NONE"; - } - const alLv = alarmLevel(); - - function devPct() { - if (!armedFlow||flowMlh===0) return 0; - return (flowMlh-armedFlow)/armedFlow*100; - } - const devP = devPct(); - - function infoRow() { - if (infoMode===0) return `Infsd:${totalMl.toFixed(1).padStart(5," ")} mL`; - if (infoMode===1) return `Time: ${eH}:${eM}:${eS}`; - return `Drops: ${String(dropCount).padStart(5," ")} `; - } - - function getLines() { - const flow = flowMlh>0 ? String(Math.round(flowMlh)).padStart(4," ") - : (screen===S.MEASURING ? "----" : " 0"); - const bat = `BAT:${batPct}%`; - const gttStr = String(GTT_PER_ML).padStart(2,"0"); - - switch(screen) { - case S.BOOT: return [" "," Dripito V2 "," ETHZ GHE ", blink?" ":" Loading... "]; - case S.MEASURING: return ["-- Measuring -- ",`Flow:${flow} mL/h`,`Drops: ${String(dropCount).padStart(5," ")} `, blink?" Please wait.. ":" Stabilising.. "]; - case S.MAIN: return [`Flow:${flow} mL/h`, infoRow(),`${bat} ${gttStr}gtt/mL`,"[SET]arm [MODE]+"]; - case S.ARMED: return [`Flow:${flow} mL/h`, infoRow(),`Tgt:${String(Math.round(armedFlow)).padStart(4," ")} ${bat} `,"[MODE]+ [MUTE] "]; - case S.ALARM_WARN: { - const sign=devP>=0?"+":"-"; const p=Math.abs(devP).toFixed(0).padStart(2," "); - return [blinkFast?"> RATE SHIFT < ":`Flow:${flow} mL/h`,`Dev:${sign}${p}% `,`Tgt:${String(Math.round(armedFlow)).padStart(4," ")} mL/h `,"[MUTE] silence "]; - } - case S.ALARM_HIGH: { - const sign=devP>=0?"+":"-"; const p=Math.abs(devP).toFixed(0).padStart(2," "); - return [blinkFast?"!! RATE ALARM !!":" ",`Flow:${flow} mL/h`,`Dev:${sign}${p}% !!!`,"[MUTE] silence "]; - } - case S.ALARM_NOFLOW: return [blinkFast?"!! NO FLOW !!!!":" ",`Last:${String(Math.round(armedFlow||flowMlh)).padStart(4," ")} mL/h`,`${totalMl.toFixed(1).padStart(5," ")} mL infused`,"[MUTE] silence "]; - case S.ALARM_LOWBAT: return [blinkFast?"!! LOW BATTERY !":"LOW BATTERY ",`BAT: ~${batPct}% left `,`${totalMl.toFixed(1).padStart(5," ")} mL infused`,"[MUTE] silence "]; - default: return ["","","",""]; - } - } - - // ── Button handlers ─────────────────────────────────────────────────────── - function handleMode() { - if ([S.MAIN,S.ARMED].includes(screen)) setInfoMode(m=>(m+1)%3); - else if ([S.ALARM_WARN,S.ALARM_HIGH,S.ALARM_NOFLOW,S.ALARM_LOWBAT].includes(screen)) - setScreen(armedFlow?S.ARMED:S.MAIN); - } - function handleSet() { - if ([S.MAIN,S.MEASURING,S.ARMED].includes(screen) && flowMlh>0) { - setArmedFlow(flowMlh); setScreen(S.ARMED); - } - } - function handlePlus() { - if ([S.ARMED,S.ALARM_WARN,S.ALARM_HIGH].includes(screen)) { setArmedFlow(null); setScreen(S.MAIN); } - } - function handleMute() { - if ([S.ALARM_WARN,S.ALARM_HIGH,S.ALARM_NOFLOW,S.ALARM_LOWBAT].includes(screen)) - setScreen(armedFlow?S.ARMED:S.MAIN); - } - - function hardReset() { - intervalBuffer.current=[]; lastDropTime.current=null; startTime.current=null; - if(noFlowTimer.current) clearTimeout(noFlowTimer.current); - setDropCount(0); setFlowMlh(0); setInstFlowMlh(0); setTotalMl(0); setElapsedMs(0); - setArmedFlow(null); bootDone.current=false; measDone.current=false; setScreen(S.BOOT); - } - - // ── Render ──────────────────────────────────────────────────────────────── return (
- {/* ── LEFT ── */}
- - {/* Device */} -
- {/* brand bar */} -
- DRIPITO - V2 PROTOTYPE -
- {/* LCD */} -
-
- -
-
- {/* Buttons */} -
- - - - -
-
↓ CLIP-ON · IV DRIP CHAMBER ↓
-
- - {/* Drop simulator panel */} -
-
- Drop Simulator -
- - {/* Big DROP button */} -
- -
- -
- Click or  - SPACE -
- - {/* Drip set selector */} -
-
- Drip Set -
-
- {DRIP_SETS.map((ds,i) => ( - - ))} -
-
- Active: {dripSet.label} — {dripSet.note} -
-
-
- - {/* Live stats */} -
-
- Live Computed Values -
-
- {[ - ["Avg flow", flowMlh>0?`${flowMlh.toFixed(1)} mL/h`:"—", "#1a4a1a"], - ["Inst flow", instFlowMlh>0?`${instFlowMlh.toFixed(1)} mL/h`:"—", "#1a4a1a"], - ["Total drops", String(dropCount), "#1a4a1a"], - ["Total vol.", `${totalMl.toFixed(2)} mL`, "#1a4a1a"], - ["Elapsed", `${eH}:${eM}:${eS}`, "#1a4a1a"], - ["Armed target",armedFlow?`${armedFlow.toFixed(1)} mL/h`:"—", armedFlow?"#1a6a1a":"#999"], - ["Deviation", armedFlow&&flowMlh>0?`${devP>=0?"+":""}${devP.toFixed(1)}%`:"—", - alLv==="HIGH"?"#cc2200":alLv==="WARN"?"#cc7700":"#2a8a2a"], - ["Drip factor", `${GTT_PER_ML} gtt/mL`, "#1a4a1a"], - ].map(([k,v,vc]) => ( - <> - {k} - {v} - - ))} -
-
- - -
-
- - {/* Screen jumps */} -
-
JUMP TO SCREEN
-
- {[ - {l:"Boot",s:S.BOOT},{l:"Measuring",s:S.MEASURING},{l:"Main",s:S.MAIN}, - {l:"Armed",s:S.ARMED},{l:"⚠ Warn",s:S.ALARM_WARN},{l:"🔴 Alarm",s:S.ALARM_HIGH}, - {l:"No Flow",s:S.ALARM_NOFLOW},{l:"Low Bat",s:S.ALARM_LOWBAT}, - ].map(({l,s}) => ( - - ))} -
-
+ + + +
{/* ── RIGHT: spec ── */} -
-
Design Specification
- - {/* Spec cards */} - {[ - { - title:"Drop → Flow Algorithm", color:"#1a6a1a", - rows:[ - ["Each drop", "Timestamp in ms (equivalent to EXTI interrupt on STM32)"], - ["Inst. flow", `dt = now − prev_ms → 3 600 000 ÷ (dt × ${GTT_PER_ML}) = mL/h`], - ["Avg. flow", `Moving average over last ${FLOW_AVG_WINDOW} inter-drop intervals`], - ["Total vol.", `drop_count ÷ ${GTT_PER_ML} gtt/mL = mL infused`], - ["No-flow WD", `${demoMode?"8 s (demo)":"60 s"} since last drop → ALARM_NOFLOW`], - ["Drip change", "Recalculates flow & volume instantly from existing buffer"], - ] - }, - { - title:"Button Map", color:"#1a4a9a", - rows:[ - ["MODE", "Cycle row-2: Infused / Elapsed / Drops · dismiss alarm → armed/main"], - ["SET", "Arm alarm to current avg flow · re-arm if already armed"], - ["+", "Disarm alarm (return to unmonitored main)"], - ["MUTE", "Silence active alarm · stay on armed screen"], - ["SPACE", "Keyboard shortcut: simulate a drop"], - ] - }, - { - title:"Alarm Thresholds (Paediatric)", color:"#b85a00", - rows:[ - ["±15% warn", "Intermittent beep · row-1 blinks · nurse adjusts clamp"], - ["±25% alarm", "Rapid continuous beep · full blink · urgent intervention"], - ["No flow", `No drops for ${demoMode?"8 s (demo)":"60 s"} → highest priority`], - ["Low battery", "Non-blocking, single beep every 60 s"], - ["Clinical ref", "IEC 60601-2-24 ±20% for pumps. ±15% warn tightened for paediatric (NICE CG174). Gravity sets show >85% obs outside ±10% (Atanda 2023)"], - ] - }, - ].map(sec => ( -
-
- {sec.title} -
- {sec.rows.map(([k,v],i) => ( -
- {k} - {v} -
- ))} -
- ))} - - {/* Alarm band visual */} -
-
- Alarm Bands -
- {(() => { - const c = armedFlow||(flowMlh>0?flowMlh:100); - const lo25= (c*0.75).toFixed(1); const lo15=(c*0.85).toFixed(1); - const hi15= (c*1.15).toFixed(1); const hi25=(c*1.25).toFixed(1); - return ( -
-
- {[ - {bg:"#ff4444",label:`ALARM\n<${lo25}`,flex:1}, - {bg:"#ffaa00",label:`WARN\n${lo25}–${lo15}`,flex:1}, - {bg:"#44aa44",label:`OK\n${lo15}–${hi15}`,flex:1.6,bold:true}, - {bg:"#ffaa00",label:`WARN\n${hi15}–${hi25}`,flex:1}, - {bg:"#ff4444",label:`ALARM\n>${hi25}`,flex:1}, - ].map((seg,i)=>( -
{seg.label}
- ))} -
-
- Target: {c.toFixed(1)} mL/h {armedFlow?"(armed)":"(arm to update)"} - {armedFlow&&flowMlh>0&&( - - · {flowMlh.toFixed(1)} mL/h ({devP>=0?"+":""}{devP.toFixed(1)}%) - → {alLv==="NONE"?"✓ OK":alLv==="WARN"?"⚠ WARN":"🔴 ALARM"} - - )} -
-
- {GTT_PER_ML} gtt/mL · avg over last {FLOW_AVG_WINDOW} intervals -
-
- ); - })()} -
- - {/* Drop rate reference table */} -
-
- Reference: drops/min for target flow -
-
- {/* header */} -
mL/h
- {DRIP_SETS.map(ds=>( -
{ds.gtt}gtt
- ))} - {[20,40,60,80,100,125,150,200].map(rate=>( - [ -
{rate}
, - ...DRIP_SETS.map(ds=>{ - const dpm=(rate*ds.gtt/60).toFixed(1); - const sel=dripSetIdx===DRIP_SETS.indexOf(ds); - return
{dpm}
; - }) - ] - ))} -
-
Highlighted column = active drip set.
-
-
+
- Clinical: ±15% warn · ±25% alarm · NICE CG174 · IEC 60601-2-24 · Atanda et al. PMC 2023 + Clinical: \u00b115% warn \u00b7 \u00b125% alarm \u00b7 NICE CG174 \u00b7 IEC 60601-2-24 \u00b7 Atanda et al. PMC 2023
{/* ── SIMULATION SECTION ─────────────────────────────────────────────── */}
- {/* Section divider */}
- Physics Simulation — Dual-Beam Sensor Module + Physics Simulation \u2014 Dual-Beam Sensor Module
- {/* Sim description blurb */}

The simulation below shows the Dripito sensor module (left) clipped onto a transparent drip chamber. - Two IR beams — TX\u2081/RX\u2081 and TX\u2082/RX\u2082 — detect each drop in real time. + Two IR beams \u2014 TX\u2081/RX\u2081 and TX\u2082/RX\u2082 \u2014 detect each drop in real time. Transit time \u0394t between the beams is used to estimate drop velocity and volume without nurse interaction.

- {/* Dark sim panel */} -
+
); -} \ No newline at end of file +} diff --git a/src/DripitoSim.jsx b/src/DripitoSim.jsx index 5c780bb..5769cc1 100644 --- a/src/DripitoSim.jsx +++ b/src/DripitoSim.jsx @@ -1,62 +1,12 @@ import { useState, useEffect, useRef, useCallback } from "react"; - -const SIM = { - nozzleY_mm: 0, - beam1Y_mm: 25, - beam2Y_mm: 35, - chamberBottom_mm: 60, - chamberWidth_mm: 22, - g: 9810, - beamGap_mm: 10, -}; - -const VIS = { - scale: 6.0, - chamberX: 260, - chamberTop: 50, - get nozzleY() { return this.chamberTop + SIM.nozzleY_mm * this.scale; }, - get beam1Y() { return this.chamberTop + SIM.beam1Y_mm * this.scale; }, - get beam2Y() { return this.chamberTop + SIM.beam2Y_mm * this.scale; }, - get chamberBot() { return this.chamberTop + SIM.chamberBottom_mm * this.scale; }, - get chamberLeft(){ return this.chamberX - (SIM.chamberWidth_mm / 2) * this.scale; }, - get chamberRight(){ return this.chamberX + (SIM.chamberWidth_mm / 2) * this.scale; }, -}; - -const TIME_SCALE = 0.15; - -function rayleighFreq(gamma_mNm, rho_kgm3, R_mm) { - const gamma = gamma_mNm * 1e-3; - const R = R_mm * 1e-3; - return (1 / (2 * Math.PI)) * Math.sqrt((8 * gamma) / (rho_kgm3 * R * R * R)); -} - -function sphereVolume(d_mm) { - return (Math.PI / 6) * d_mm * d_mm * d_mm; -} - -function roundRect(ctx, x, y, w, h, r) { - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.lineTo(x + w - r, y); - ctx.arcTo(x + w, y, x + w, y + r, r); - ctx.lineTo(x + w, y + h - r); - ctx.arcTo(x + w, y + h, x + w - r, y + h, r); - ctx.lineTo(x + r, y + h); - ctx.arcTo(x, y + h, x, y + h - r, r); - ctx.lineTo(x, y + r); - ctx.arcTo(x, y, x + r, y, r); - ctx.closePath(); -} - -const FLUIDS = { - nacl: { name: "NaCl 0.9%", gamma: 72.0, rho: 1005, color: "#a8d8ea", nozzleDia: 4.0 }, - ringer:{ name: "Ringer's Lactate", gamma: 71.5, rho: 1005, color: "#b8e6c8", nozzleDia: 4.0 }, - d5w: { name: "D5W (5% Dextrose)", gamma: 68.0, rho: 1020, color: "#f5e6a3", nozzleDia: 4.0 }, - d10w: { name: "D10W (10% Dextrose)", gamma: 64.0, rho: 1040, color: "#f0d080", nozzleDia: 4.0 }, -}; +import { SIM, VIS, TIME_SCALE, FLUIDS } from "./constants/simConstants"; +import { makeDrop, updateDropPhysics, calcBeamOcclusion } from "./sim/physics"; +import { tryMeasureDrop } from "./sim/measurement"; +import { drawChamberCanvas } from "./sim/drawChamber"; +import { drawScope } from "./sim/drawScope"; export default function DripitoSim() { - const canvasRef = useRef(null); + const canvasRef = useRef(null); const scopeCanvasRef = useRef(null); const animRef = useRef(null); @@ -78,6 +28,7 @@ export default function DripitoSim() { avgVolume: 0, avgDiameter: 0, phase: "calibrating", }); + // Reset simulation when fluid changes useEffect(() => { const s = sim.current; s.drops = []; s.time = 0; s.lastDropTime = -10; @@ -89,28 +40,13 @@ export default function DripitoSim() { setDisplay({ phase: "calibrating", dropCount: 0, avgVolume: 0, avgDiameter: 0, avgGttMl: 0, last: null, measurements: [] }); }, [fluidType]); - const makeDrop = useCallback(() => { - const f = FLUIDS[fluidType]; - const vol = (Math.PI * f.nozzleDia * (f.gamma * 1e-3) * 0.6) / ((f.rho * 1e-9) * SIM.g); - const v = vol * (1 + (Math.random() - 0.5) * 0.02); - const d = Math.pow((6 * v) / Math.PI, 1 / 3); - const oscF = rayleighFreq(f.gamma, f.rho, d / 2); - return { - y_mm: SIM.nozzleY_mm, vy: 0, - trueDia: d, trueVol: v, - oscFreq: oscF, oscPhase: Math.random() * Math.PI * 2, - oscAmp: 0.15, oscDecay: 2.5, - detachTime: sim.current.time, - b1Hit: false, b2Hit: false, - b1TimeFirst: 0, b2TimeFirst: 0, - b1OccStart: 0, b1OccEnd: 0, - b2OccStart: 0, b2OccEnd: 0, - measured: false, active: true, - }; + // Wrap makeDrop so it captures current fluidType without stale closure in tick + const makeDropForFluid = useCallback(() => { + return makeDrop(FLUIDS[fluidType], sim.current.time); }, [fluidType]); useEffect(() => { - const canvas = canvasRef.current; + const canvas = canvasRef.current; const scopeCanvas = scopeCanvasRef.current; if (!canvas || !scopeCanvas) return; @@ -131,90 +67,43 @@ export default function DripitoSim() { const s = sim.current; const simDt = frameDt * TIME_SCALE; s.time += simDt; - const fluid = FLUIDS[fluidType]; + const fluid = FLUIDS[fluidType]; const interval = 1 / dropRate; + // Spawn drop if (s.time - s.lastDropTime >= interval) { - s.drops.push(makeDrop()); + s.drops.push(makeDropForFluid()); s.lastDropTime = s.time; } + // Physics + beam values let b1Val = 1.0, b2Val = 1.0; for (const drop of s.drops) { if (!drop.active) continue; - drop.vy += SIM.g * simDt; - drop.y_mm += drop.vy * simDt; - - const ft = s.time - drop.detachTime; - const osc = drop.oscAmp * Math.sin(2 * Math.PI * drop.oscFreq * ft + drop.oscPhase) * Math.exp(-drop.oscDecay * ft); - const dH = drop.trueDia * (1 + osc); - const dV = drop.trueDia * (1 - osc * 0.5); - const rH = dH / 2, rV = dV / 2; - - for (let bi = 0; bi < 2; bi++) { - const beamY = bi === 0 ? SIM.beam1Y_mm : SIM.beam2Y_mm; - const dropTop = drop.y_mm - rV; - const dropBot = drop.y_mm + rV; - - if (dropTop < beamY + 0.3 && dropBot > beamY - 0.3) { - const dist = Math.abs(drop.y_mm - beamY); - const norm = Math.min(dist / rV, 1); - const chord = norm < 1 ? 2 * rH * Math.sqrt(1 - norm * norm) : 0; - const occ = Math.min(chord / SIM.chamberWidth_mm, 0.9); - - if (bi === 0) { - b1Val = Math.min(b1Val, 1 - occ); - if (!drop.b1Hit && occ > 0.02) { drop.b1Hit = true; drop.b1TimeFirst = s.time; drop.b1OccStart = s.time; } - if (drop.b1Hit && occ > 0.02) drop.b1OccEnd = s.time; - } else { - b2Val = Math.min(b2Val, 1 - occ); - if (!drop.b2Hit && occ > 0.02) { drop.b2Hit = true; drop.b2TimeFirst = s.time; drop.b2OccStart = s.time; } - if (drop.b2Hit && occ > 0.02) drop.b2OccEnd = s.time; - } - } - - if (bi === 1 && drop.b2Hit && !drop.measured && drop.y_mm - rV > SIM.beam2Y_mm + 2) { - if (drop.b1Hit && drop.b1OccEnd > drop.b1OccStart && drop.b2OccEnd > drop.b2OccStart) { - drop.measured = true; - const dt_transit = drop.b2TimeFirst - drop.b1TimeFirst; - if (dt_transit > 1e-6) { - const dh = SIM.beamGap_mm; - const v1 = (dh - 0.5 * SIM.g * dt_transit * dt_transit) / dt_transit; - const v2 = v1 + SIM.g * dt_transit; - const occ1 = drop.b1OccEnd - drop.b1OccStart; - const occ2 = drop.b2OccEnd - drop.b2OccStart; - const d1 = Math.abs(v1) * occ1; - const d2 = Math.abs(v2) * occ2; - const dAvg = (d1 + d2) / 2; - const vEst = sphereVolume(dAvg); - - s.dropCount++; - const meas = { - idx: s.dropCount, dt: dt_transit * 1000, - v1: v1 / 1000, v2: v2 / 1000, - occ1: occ1 * 1000, occ2: occ2 * 1000, - d1, d2, dAvg, vEst, - trueVol: drop.trueVol, trueDia: drop.trueDia, - err: ((vEst - drop.trueVol) / drop.trueVol) * 100, - }; - s.measurements.push(meas); - if (s.measurements.length > 30) s.measurements = s.measurements.slice(-30); - const recent = s.measurements.slice(-10); - s.avgVolume = recent.reduce((a, b) => a + b.vEst, 0) / recent.length; - s.avgDiameter = recent.reduce((a, b) => a + b.dAvg, 0) / recent.length; - if (s.dropCount >= 10) s.phase = "calibrated"; - - setDisplay({ - phase: s.phase, dropCount: s.dropCount, - avgVolume: s.avgVolume, avgDiameter: s.avgDiameter, - avgGttMl: s.avgVolume > 0 ? 1000 / s.avgVolume : 0, - last: meas, measurements: [...s.measurements], - }); - } - } - } + const shape = updateDropPhysics(drop, simDt, s.time); + const occ = calcBeamOcclusion(drop, s.time, shape); + b1Val = Math.min(b1Val, occ.b1Val); + b2Val = Math.min(b2Val, occ.b2Val); + + const meas = tryMeasureDrop(drop, s.time); + if (meas) { + s.dropCount++; + meas.idx = s.dropCount; + s.measurements.push(meas); + if (s.measurements.length > 30) s.measurements = s.measurements.slice(-30); + const recent = s.measurements.slice(-10); + s.avgVolume = recent.reduce((a, b) => a + b.vEst, 0) / recent.length; + s.avgDiameter = recent.reduce((a, b) => a + b.dAvg, 0) / recent.length; + if (s.dropCount >= 10) s.phase = "calibrated"; + + setDisplay({ + phase: s.phase, dropCount: s.dropCount, + avgVolume: s.avgVolume, avgDiameter: s.avgDiameter, + avgGttMl: s.avgVolume > 0 ? 1000 / s.avgVolume : 0, + last: meas, measurements: [...s.measurements], + }); } if (drop.y_mm > SIM.chamberBottom_mm + 10) drop.active = false; @@ -225,406 +114,15 @@ export default function DripitoSim() { s.scope2[s.scopeIdx % 1200] = b2Val; s.scopeIdx++; - // ─── Clear ──────────────────────────────────────────────────────────── - ctx.clearRect(0, 0, CW, CH); - ctx.fillStyle = "#080c11"; - ctx.fillRect(0, 0, CW, CH); - - const wL = VIS.chamberLeft - 8; - const wR = VIS.chamberRight + 8; - - // ─── SENSOR MODULE (holder, left of chamber) ────────────────────────── - const hLeft = 14; - const hRight = 150; - const hTop = VIS.chamberTop - 26; - const hBot = VIS.chamberBot + 26; - const hCx = (hLeft + hRight) / 2; - - // Housing body - roundRect(ctx, hLeft, hTop, hRight - hLeft, hBot - hTop, 10); - ctx.fillStyle = "#0d141e"; - ctx.fill(); - roundRect(ctx, hLeft, hTop, hRight - hLeft, hBot - hTop, 10); - ctx.strokeStyle = "#253848"; - ctx.lineWidth = 1.8; - ctx.stroke(); - - // PCB substrate - ctx.fillStyle = "#060e07"; - ctx.fillRect(hLeft + 7, hTop + 7, hRight - hLeft - 14, hBot - hTop - 14); - - // PCB horizontal traces - ctx.strokeStyle = "#0c1e0e"; - ctx.lineWidth = 0.5; - for (let y = hTop + 20; y < hBot - 6; y += 13) { - ctx.beginPath(); - ctx.moveTo(hLeft + 8, y); - ctx.lineTo(hRight - 8, y); - ctx.stroke(); - } - // PCB vias - ctx.fillStyle = "#1a3a1a"; - for (let y = hTop + 28; y < hBot - 20; y += 32) { - for (let x = hLeft + 16; x < hRight - 20; x += 18) { - ctx.beginPath(); - ctx.arc(x, y, 2, 0, Math.PI * 2); - ctx.fill(); - } - } - - // MCU chip - const chipX = hLeft + 10, chipY = hTop + 36; - ctx.fillStyle = "#0a0e12"; - ctx.fillRect(chipX, chipY, 36, 22); - ctx.strokeStyle = "#1e2e3e"; - ctx.lineWidth = 0.8; - ctx.strokeRect(chipX, chipY, 36, 22); - ctx.strokeStyle = "#2a4038"; - ctx.lineWidth = 0.8; - for (let i = 0; i < 5; i++) { - ctx.beginPath(); ctx.moveTo(chipX + 5 + i * 6, chipY - 2); - ctx.lineTo(chipX + 5 + i * 6, chipY); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(chipX + 5 + i * 6, chipY + 22); - ctx.lineTo(chipX + 5 + i * 6, chipY + 24); ctx.stroke(); - } - ctx.fillStyle = "#1a2a2a"; - ctx.font = "5.5px monospace"; - ctx.fillText("STM32", chipX + 3, chipY + 11); - ctx.fillText("G030", chipX + 5, chipY + 18); - - // Bottom connector block - ctx.fillStyle = "#161e2c"; - ctx.fillRect(hCx - 14, hBot - 9, 28, 14); - ctx.strokeStyle = "#263848"; - ctx.lineWidth = 1; - ctx.strokeRect(hCx - 14, hBot - 9, 28, 14); - // connector pins (gold) - for (let i = 0; i < 4; i++) { - ctx.fillStyle = "#8a9a6a"; - ctx.fillRect(hCx - 9 + i * 6, hBot + 3, 3, 9); - } - - // LED emitter modules on right edge of housing - for (let i = 0; i < 2; i++) { - const beamY = i === 0 ? VIS.beam1Y : VIS.beam2Y; - const val = i === 0 ? b1Val : b2Val; - const ledX = hRight - 10; - const baseCol = i === 0 ? [255, 60, 60] : [60, 150, 255]; - const txLabel = i === 0 ? "TX\u2081" : "TX\u2082"; - - // LED mount plate - roundRect(ctx, ledX - 18, beamY - 13, 28, 26, 4); - ctx.fillStyle = "#16202c"; - ctx.fill(); - ctx.strokeStyle = "#2a3a50"; - ctx.lineWidth = 1; - ctx.stroke(); - - // LED barrel housing - ctx.beginPath(); - ctx.arc(ledX + 4, beamY, 7, 0, Math.PI * 2); - ctx.fillStyle = "#18202a"; - ctx.fill(); - ctx.strokeStyle = "#303850"; - ctx.lineWidth = 1; - ctx.stroke(); - - // LED lens - const ledAlpha = 0.35 + val * 0.65; - ctx.beginPath(); - ctx.arc(ledX + 4, beamY, 4.5, 0, Math.PI * 2); - ctx.fillStyle = `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},${ledAlpha})`; - ctx.fill(); - - // Glow halo - if (val > 0.4) { - const halo = ctx.createRadialGradient(ledX + 4, beamY, 0, ledX + 4, beamY, 18); - halo.addColorStop(0, `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},${0.35 * val})`); - halo.addColorStop(1, `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},0)`); - ctx.beginPath(); - ctx.arc(ledX + 4, beamY, 18, 0, Math.PI * 2); - ctx.fillStyle = halo; - ctx.fill(); - } - - // TX label - ctx.fillStyle = i === 0 ? "#cc4444" : "#4477cc"; - ctx.font = "bold 8px monospace"; - ctx.textAlign = "center"; - ctx.fillText(txLabel, ledX - 10, beamY + 3); - ctx.textAlign = "left"; - } - - // Clip arms (C-clamp connecting housing to chamber left wall) - const clipTop = hTop + 16; - const clipBot = hBot - 16; - - // Dark outer stroke - ctx.strokeStyle = "#18242e"; - ctx.lineWidth = 8; - ctx.lineCap = "square"; - ctx.beginPath(); ctx.moveTo(hRight - 2, clipTop); ctx.lineTo(wL - 6, clipTop); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(wL - 6, clipTop); ctx.lineTo(wL - 6, clipTop + 24); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(hRight - 2, clipBot); ctx.lineTo(wL - 6, clipBot); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(wL - 6, clipBot); ctx.lineTo(wL - 6, clipBot - 24); ctx.stroke(); - - // Mid fill - ctx.strokeStyle = "#22344a"; - ctx.lineWidth = 5; - ctx.beginPath(); ctx.moveTo(hRight - 2, clipTop); ctx.lineTo(wL - 6, clipTop); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(wL - 6, clipTop); ctx.lineTo(wL - 6, clipTop + 24); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(hRight - 2, clipBot); ctx.lineTo(wL - 6, clipBot); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(wL - 6, clipBot); ctx.lineTo(wL - 6, clipBot - 24); ctx.stroke(); - - // Highlight shimmer - ctx.strokeStyle = "#2e4860"; - ctx.lineWidth = 2; - ctx.beginPath(); ctx.moveTo(hRight - 2, clipTop - 1); ctx.lineTo(wL - 6, clipTop - 1); ctx.stroke(); - ctx.beginPath(); ctx.moveTo(hRight - 2, clipBot + 1); ctx.lineTo(wL - 6, clipBot + 1); ctx.stroke(); - - ctx.lineCap = "butt"; - - // Cable out from bottom-left - ctx.strokeStyle = "#161e2a"; - ctx.lineWidth = 6; - ctx.beginPath(); - ctx.moveTo(hCx - 4, hBot + 10); - ctx.bezierCurveTo(hCx - 8, hBot + 40, hLeft + 4, hBot + 60, 0, hBot + 90); - ctx.stroke(); - // Cable sheath highlight - ctx.strokeStyle = "#1e2e3e"; - ctx.lineWidth = 3; - ctx.beginPath(); - ctx.moveTo(hCx - 4, hBot + 10); - ctx.bezierCurveTo(hCx - 8, hBot + 40, hLeft + 4, hBot + 60, 0, hBot + 90); - ctx.stroke(); - - // Module label at top - ctx.fillStyle = "#4a7a8a"; - ctx.font = "bold 9px monospace"; - ctx.textAlign = "center"; - ctx.fillText("DRIPITO MODULE", hCx, hTop - 12); - ctx.fillText("IR SENSOR", hCx, hTop - 2); - ctx.textAlign = "left"; - - // ─── DRIP CHAMBER ───────────────────────────────────────────────────── - ctx.strokeStyle = "#2a3a4a"; - ctx.lineWidth = 2.5; - ctx.beginPath(); - ctx.moveTo(wL, VIS.chamberTop - 5); ctx.lineTo(wL, VIS.chamberBot + 5); - ctx.moveTo(wR, VIS.chamberTop - 5); ctx.lineTo(wR, VIS.chamberBot + 5); - ctx.stroke(); - - ctx.fillStyle = fluid.color + "0a"; - ctx.fillRect(wL + 2, VIS.chamberTop, wR - wL - 4, VIS.chamberBot - VIS.chamberTop); - - // Nozzle - ctx.fillStyle = "#445566"; - const nw = 6; - ctx.fillRect(VIS.chamberX - nw / 2, VIS.chamberTop - 10, nw, VIS.nozzleY - VIS.chamberTop + 12); - - // Forming drop at nozzle - const tSinceDrop = s.time - s.lastDropTime; - const prog = Math.min(tSinceDrop / interval, 0.92); - if (prog > 0.08) { - const pr = prog * 2.2 * VIS.scale; - ctx.beginPath(); - ctx.ellipse(VIS.chamberX, VIS.nozzleY + pr * 0.7, pr * 0.65, pr, 0, 0, Math.PI * 2); - ctx.fillStyle = fluid.color + "bb"; - ctx.fill(); - ctx.beginPath(); - ctx.moveTo(VIS.chamberX - 2, VIS.nozzleY); - ctx.quadraticCurveTo(VIS.chamberX - pr * 0.4, VIS.nozzleY + pr * 0.35, VIS.chamberX - pr * 0.65, VIS.nozzleY + pr * 0.7); - ctx.moveTo(VIS.chamberX + 2, VIS.nozzleY); - ctx.quadraticCurveTo(VIS.chamberX + pr * 0.4, VIS.nozzleY + pr * 0.35, VIS.chamberX + pr * 0.65, VIS.nozzleY + pr * 0.7); - ctx.strokeStyle = fluid.color + "66"; - ctx.lineWidth = 1; - ctx.stroke(); - } - - // IR beams + RX side - for (let i = 0; i < 2; i++) { - const by = i === 0 ? VIS.beam1Y : VIS.beam2Y; - const val = i === 0 ? b1Val : b2Val; - const baseCol = i === 0 ? [255, 60, 60] : [60, 150, 255]; - - // Beam - ctx.beginPath(); - ctx.moveTo(wL - 1, by); ctx.lineTo(wR + 1, by); - ctx.strokeStyle = `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},${0.12 + val * 0.55})`; - ctx.lineWidth = val < 0.8 ? 3 : 2; - ctx.stroke(); - - if (val < 0.7) { - ctx.beginPath(); - ctx.moveTo(wL - 1, by); ctx.lineTo(wR + 1, by); - ctx.strokeStyle = `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},${(1 - val) * 0.3})`; - ctx.lineWidth = 6; - ctx.stroke(); - } - - // RX housing (right side) - ctx.fillStyle = i === 0 ? "#551818" : "#182855"; - ctx.fillRect(wR + 2, by - 6, 22, 12); - ctx.strokeStyle = i === 0 ? "#7a2a2a" : "#2a4a7a"; - ctx.lineWidth = 0.8; - ctx.strokeRect(wR + 2, by - 6, 22, 12); - - // RX photodiode - ctx.beginPath(); - ctx.arc(wR + 6, by, 3, 0, Math.PI * 2); - ctx.fillStyle = i === 0 ? "#2a0808" : "#081828"; - ctx.fill(); - - // RX label - ctx.fillStyle = i === 0 ? "#ff5555" : "#5599ff"; - ctx.font = "bold 9px monospace"; - ctx.fillText(i === 0 ? "RX\u2081" : "RX\u2082", wR + 28, by + 3); - } - - // Δh annotation - const midBy = (VIS.beam1Y + VIS.beam2Y) / 2; - ctx.strokeStyle = "#445566"; - ctx.lineWidth = 1; - ctx.setLineDash([2, 3]); - ctx.beginPath(); - ctx.moveTo(wR + 54, VIS.beam1Y); ctx.lineTo(wR + 66, VIS.beam1Y); - ctx.moveTo(wR + 54, VIS.beam2Y); ctx.lineTo(wR + 66, VIS.beam2Y); - ctx.moveTo(wR + 60, VIS.beam1Y + 4); ctx.lineTo(wR + 60, VIS.beam2Y - 4); - ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = "#88aacc"; - ctx.font = "bold 9px monospace"; - ctx.textAlign = "center"; - ctx.fillText("\u0394h", wR + 60, midBy - 2); - ctx.fillText(SIM.beamGap_mm + "mm", wR + 60, midBy + 10); - ctx.textAlign = "left"; - - // Falling drops - for (const drop of s.drops) { - if (!drop.active) continue; - const ft = s.time - drop.detachTime; - const oscVal = drop.oscAmp * Math.sin(2 * Math.PI * drop.oscFreq * ft + drop.oscPhase) * Math.exp(-drop.oscDecay * ft); - const pixRH = (drop.trueDia * (1 + oscVal) / 2) * VIS.scale; - const pixRV = (drop.trueDia * (1 - oscVal * 0.5) / 2) * VIS.scale; - const pixY = VIS.chamberTop + drop.y_mm * VIS.scale; - - ctx.beginPath(); - ctx.ellipse(VIS.chamberX, pixY + 1, pixRH + 1, pixRV + 1, 0, 0, Math.PI * 2); - ctx.fillStyle = "rgba(0,0,0,0.3)"; - ctx.fill(); - - ctx.beginPath(); - ctx.ellipse(VIS.chamberX, pixY, pixRH, pixRV, 0, 0, Math.PI * 2); - const grad = ctx.createRadialGradient( - VIS.chamberX - pixRH * 0.3, pixY - pixRV * 0.3, 0, - VIS.chamberX, pixY, Math.max(pixRH, pixRV) - ); - grad.addColorStop(0, fluid.color + "ff"); - grad.addColorStop(0.6, fluid.color + "cc"); - grad.addColorStop(1, fluid.color + "88"); - ctx.fillStyle = grad; - ctx.fill(); - ctx.strokeStyle = fluid.color + "44"; - ctx.lineWidth = 0.5; - ctx.stroke(); - - // Specular highlight - ctx.beginPath(); - ctx.ellipse(VIS.chamberX - pixRH * 0.25, pixY - pixRV * 0.25, pixRH * 0.25, pixRV * 0.2, -0.4, 0, Math.PI * 2); - ctx.fillStyle = "rgba(255,255,255,0.2)"; - ctx.fill(); - } - - // Chamber bottom pool + outlet tube - ctx.fillStyle = fluid.color + "30"; - ctx.fillRect(wL + 2, VIS.chamberBot - 18, wR - wL - 4, 18); - ctx.fillStyle = "#445566"; - ctx.fillRect(VIS.chamberX - 3, VIS.chamberBot, 6, 25); - - // Chamber label - ctx.fillStyle = "#5a8899"; - ctx.font = "bold 10px monospace"; - ctx.textAlign = "center"; - ctx.fillText("DRIP CHAMBER", VIS.chamberX, VIS.chamberTop - 18); - ctx.textAlign = "left"; - - // ─── SCOPE ──────────────────────────────────────────────────────────── - sctx.clearRect(0, 0, SW, SHt); - sctx.fillStyle = "#060a0e"; - sctx.fillRect(0, 0, SW, SHt); - - const mg = { l: 52, r: 16, t: 30, b: 30 }; - const pw = SW - mg.l - mg.r; - const ph = SHt - mg.t - mg.b; - - sctx.strokeStyle = "#111a22"; - sctx.lineWidth = 0.5; - for (let i = 0; i <= 10; i++) { - const x = mg.l + (pw / 10) * i; - sctx.beginPath(); sctx.moveTo(x, mg.t); sctx.lineTo(x, mg.t + ph); sctx.stroke(); - } - for (let i = 0; i <= 4; i++) { - const y = mg.t + (ph / 4) * i; - sctx.beginPath(); sctx.moveTo(mg.l, y); sctx.lineTo(mg.l + pw, y); sctx.stroke(); - } - - sctx.fillStyle = "#3a5566"; - sctx.font = "9px monospace"; - sctx.fillText("1.0", mg.l - 28, mg.t + 4); - sctx.fillText("0.5", mg.l - 28, mg.t + ph / 2 + 3); - sctx.fillText("0.0", mg.l - 28, mg.t + ph + 4); - sctx.textAlign = "center"; - sctx.fillText("V/Vmax", mg.l - 10, mg.t - 10); - sctx.fillText("Time \u2192", mg.l + pw / 2, SHt - 6); - sctx.textAlign = "left"; - - const len = 1200; - const head = s.scopeIdx; - - function drawSig(data, color) { - sctx.beginPath(); - sctx.strokeStyle = color; - sctx.lineWidth = 1.5; - for (let i = 0; i < len; i++) { - const idx = (head + i) % len; - const x = mg.l + (i / len) * pw; - const y = mg.t + (1 - data[idx]) * ph; - if (i === 0) sctx.moveTo(x, y); - else sctx.lineTo(x, y); - } - sctx.stroke(); - } - - drawSig(s.scope1, "#ff3333"); - drawSig(s.scope2, "#3388ff"); - - sctx.strokeStyle = "#2a3a4a"; - sctx.lineWidth = 1; - sctx.strokeRect(mg.l, mg.t, pw, ph); - - sctx.fillStyle = "#ff4444"; - sctx.fillRect(mg.l + pw - 120, mg.t + 6, 10, 2); - sctx.fillStyle = "#ff6666"; - sctx.font = "bold 9px monospace"; - sctx.fillText("CH1 Beam 1", mg.l + pw - 106, mg.t + 10); - sctx.fillStyle = "#3388ff"; - sctx.fillRect(mg.l + pw - 120, mg.t + 20, 10, 2); - sctx.fillStyle = "#5599ff"; - sctx.fillText("CH2 Beam 2", mg.l + pw - 106, mg.t + 24); - - sctx.fillStyle = "#5a8899"; - sctx.font = "bold 10px monospace"; - sctx.textAlign = "center"; - sctx.fillText("OSCILLOSCOPE \u2014 PHOTODIODE OUTPUT", SW / 2, 16); - sctx.textAlign = "left"; + drawChamberCanvas(ctx, CW, CH, s, fluid, interval, s.drops, b1Val, b2Val); + drawScope(sctx, SW, SHt, s.scope1, s.scope2, s.scopeIdx); animRef.current = requestAnimationFrame(tick); } animRef.current = requestAnimationFrame(tick); return () => cancelAnimationFrame(animRef.current); - }, [isRunning, fluidType, dropRate, makeDrop]); + }, [isRunning, fluidType, dropRate, makeDropForFluid]); const d = display; @@ -696,14 +194,11 @@ export default function DripitoSim() { {/* Main canvas + panels */}
- {/* Chamber canvas */}
- {/* Right panels */}
- {/* Scope */}
diff --git a/src/components/Btn.jsx b/src/components/Btn.jsx new file mode 100644 index 0000000..3388817 --- /dev/null +++ b/src/components/Btn.jsx @@ -0,0 +1,28 @@ +import { useState } from "react"; + +export default function Btn({ label, sublabel, color, bg, onClick, red }) { + const [pressed, setPressed] = useState(false); + return ( + + ); +} diff --git a/src/components/DevicePanel.jsx b/src/components/DevicePanel.jsx new file mode 100644 index 0000000..30cae42 --- /dev/null +++ b/src/components/DevicePanel.jsx @@ -0,0 +1,30 @@ +import LCD from "./LCD"; +import Btn from "./Btn"; + +export default function DevicePanel({ lines, alarmLevel, onMode, onSet, onPlus, onMute }) { + return ( +
+ {/* brand bar */} +
+ DRIPITO + V2 PROTOTYPE +
+ {/* LCD */} +
+
+ +
+
+ {/* Buttons */} +
+ + + + +
+
↓ CLIP-ON · IV DRIP CHAMBER ↓
+
+ ); +} diff --git a/src/components/DropSimulatorPanel.jsx b/src/components/DropSimulatorPanel.jsx new file mode 100644 index 0000000..eae7293 --- /dev/null +++ b/src/components/DropSimulatorPanel.jsx @@ -0,0 +1,59 @@ +import { DRIP_SETS } from "../constants/deviceConstants"; + +export default function DropSimulatorPanel({ onDrop, dripSetIdx, dripSet, onDripSetChange }) { + return ( +
+
+ Drop Simulator +
+ + {/* Big DROP button */} +
+ +
+ +
+ Click or  + SPACE +
+ + {/* Drip set selector */} +
+
+ Drip Set +
+
+ {DRIP_SETS.map((ds, i) => ( + + ))} +
+
+ Active: {dripSet.label} — {dripSet.note} +
+
+
+ ); +} diff --git a/src/components/LCD.jsx b/src/components/LCD.jsx new file mode 100644 index 0000000..549a3ad --- /dev/null +++ b/src/components/LCD.jsx @@ -0,0 +1,31 @@ +const LCD_FONT_URL = "https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap"; + +export default function LCD({ lines, alarmLevel }) { + const padded = [...lines]; + while (padded.length < 4) padded.push(""); + const bg = alarmLevel === "HIGH" ? "#c8e87a" : alarmLevel === "WARN" ? "#d0e87a" : "#c8d87a"; + return ( +
+ +
+
+ {padded.slice(0,4).map((line, i) => ( +
+ {(line + " ".repeat(16)).slice(0,16)} +
+ ))} +
+ ); +} diff --git a/src/components/LiveStatsPanel.jsx b/src/components/LiveStatsPanel.jsx new file mode 100644 index 0000000..fbc4061 --- /dev/null +++ b/src/components/LiveStatsPanel.jsx @@ -0,0 +1,44 @@ +export default function LiveStatsPanel({ + flowMlh, instFlowMlh, dropCount, totalMl, eH, eM, eS, + armedFlow, devP, alarmLevel, GTT_PER_ML, + demoMode, onDemoModeChange, onReset, +}) { + const alLv = alarmLevel; + return ( +
+
+ Live Computed Values +
+
+ {[ + ["Avg flow", flowMlh>0?`${flowMlh.toFixed(1)} mL/h`:"—", "#1a4a1a"], + ["Inst flow", instFlowMlh>0?`${instFlowMlh.toFixed(1)} mL/h`:"—", "#1a4a1a"], + ["Total drops", String(dropCount), "#1a4a1a"], + ["Total vol.", `${totalMl.toFixed(2)} mL`, "#1a4a1a"], + ["Elapsed", `${eH}:${eM}:${eS}`, "#1a4a1a"], + ["Armed target", armedFlow?`${armedFlow.toFixed(1)} mL/h`:"—", armedFlow?"#1a6a1a":"#999"], + ["Deviation", armedFlow&&flowMlh>0?`${devP>=0?"+":""}${devP.toFixed(1)}%`:"—", + alLv==="HIGH"?"#cc2200":alLv==="WARN"?"#cc7700":"#2a8a2a"], + ["Drip factor", `${GTT_PER_ML} gtt/mL`, "#1a4a1a"], + ].map(([k, v, vc]) => ( + <> + {k} + {v} + + ))} +
+
+ + +
+
+ ); +} diff --git a/src/components/ScreenJumper.jsx b/src/components/ScreenJumper.jsx new file mode 100644 index 0000000..083b745 --- /dev/null +++ b/src/components/ScreenJumper.jsx @@ -0,0 +1,35 @@ +import { S } from "../constants/deviceConstants"; + +export default function ScreenJumper({ screen, flowMlh, onJump, bootDoneRef, measDoneRef, setArmedFlow }) { + const screens = [ + {l:"Boot", s:S.BOOT}, + {l:"Measuring",s:S.MEASURING}, + {l:"Main", s:S.MAIN}, + {l:"Armed", s:S.ARMED}, + {l:"⚠ Warn", s:S.ALARM_WARN}, + {l:"🔴 Alarm",s:S.ALARM_HIGH}, + {l:"No Flow",s:S.ALARM_NOFLOW}, + {l:"Low Bat",s:S.ALARM_LOWBAT}, + ]; + return ( +
+
JUMP TO SCREEN
+
+ {screens.map(({l, s}) => ( + + ))} +
+
+ ); +} diff --git a/src/components/SpecPanel.jsx b/src/components/SpecPanel.jsx new file mode 100644 index 0000000..2a86516 --- /dev/null +++ b/src/components/SpecPanel.jsx @@ -0,0 +1,130 @@ +import { DRIP_SETS, FLOW_AVG_WINDOW } from "../constants/deviceConstants"; + +export default function SpecPanel({ + GTT_PER_ML, demoMode, armedFlow, flowMlh, alarmLevel, devP, dripSetIdx, +}) { + const alLv = alarmLevel; + return ( +
+
Design Specification
+ + {/* Spec cards */} + {[ + { + title:"Drop → Flow Algorithm", color:"#1a6a1a", + rows:[ + ["Each drop", "Timestamp in ms (equivalent to EXTI interrupt on STM32)"], + ["Inst. flow", `dt = now − prev_ms → 3 600 000 ÷ (dt × ${GTT_PER_ML}) = mL/h`], + ["Avg. flow", `Moving average over last ${FLOW_AVG_WINDOW} inter-drop intervals`], + ["Total vol.", `drop_count ÷ ${GTT_PER_ML} gtt/mL = mL infused`], + ["No-flow WD", `${demoMode?"8 s (demo)":"60 s"} since last drop → ALARM_NOFLOW`], + ["Drip change", "Recalculates flow & volume instantly from existing buffer"], + ] + }, + { + title:"Button Map", color:"#1a4a9a", + rows:[ + ["MODE", "Cycle row-2: Infused / Elapsed / Drops · dismiss alarm → armed/main"], + ["SET", "Arm alarm to current avg flow · re-arm if already armed"], + ["+", "Disarm alarm (return to unmonitored main)"], + ["MUTE", "Silence active alarm · stay on armed screen"], + ["SPACE", "Keyboard shortcut: simulate a drop"], + ] + }, + { + title:"Alarm Thresholds (Paediatric)", color:"#b85a00", + rows:[ + ["±15% warn", "Intermittent beep · row-1 blinks · nurse adjusts clamp"], + ["±25% alarm", "Rapid continuous beep · full blink · urgent intervention"], + ["No flow", `No drops for ${demoMode?"8 s (demo)":"60 s"} → highest priority`], + ["Low battery", "Non-blocking, single beep every 60 s"], + ["Clinical ref", "IEC 60601-2-24 ±20% for pumps. ±15% warn tightened for paediatric (NICE CG174). Gravity sets show >85% obs outside ±10% (Atanda 2023)"], + ] + }, + ].map(sec => ( +
+
+ {sec.title} +
+ {sec.rows.map(([k, v], i) => ( +
+ {k} + {v} +
+ ))} +
+ ))} + + {/* Alarm band visual */} +
+
+ Alarm Bands +
+ {(() => { + const c = armedFlow || (flowMlh > 0 ? flowMlh : 100); + const lo25 = (c * 0.75).toFixed(1); const lo15 = (c * 0.85).toFixed(1); + const hi15 = (c * 1.15).toFixed(1); const hi25 = (c * 1.25).toFixed(1); + return ( +
+
+ {[ + {bg:"#ff4444", label:`ALARM\n<${lo25}`, flex:1}, + {bg:"#ffaa00", label:`WARN\n${lo25}–${lo15}`,flex:1}, + {bg:"#44aa44", label:`OK\n${lo15}–${hi15}`, flex:1.6, bold:true}, + {bg:"#ffaa00", label:`WARN\n${hi15}–${hi25}`,flex:1}, + {bg:"#ff4444", label:`ALARM\n>${hi25}`, flex:1}, + ].map((seg, i) => ( +
{seg.label}
+ ))} +
+
+ Target: {c.toFixed(1)} mL/h {armedFlow?"(armed)":"(arm to update)"} + {armedFlow && flowMlh > 0 && ( + + · {flowMlh.toFixed(1)} mL/h ({devP>=0?"+":""}{devP.toFixed(1)}%) + → {alLv==="NONE"?"✓ OK":alLv==="WARN"?"⚠ WARN":"🔴 ALARM"} + + )} +
+
+ {GTT_PER_ML} gtt/mL · avg over last {FLOW_AVG_WINDOW} intervals +
+
+ ); + })()} +
+ + {/* Drop rate reference table */} +
+
+ Reference: drops/min for target flow +
+
+
mL/h
+ {DRIP_SETS.map(ds => ( +
{ds.gtt}gtt
+ ))} + {[20, 40, 60, 80, 100, 125, 150, 200].map(rate => ( + [ +
{rate}
, + ...DRIP_SETS.map(ds => { + const dpm = (rate * ds.gtt / 60).toFixed(1); + const sel = dripSetIdx === DRIP_SETS.indexOf(ds); + return
{dpm}
; + }) + ] + ))} +
+
Highlighted column = active drip set.
+
+
+ ); +} diff --git a/src/constants/deviceConstants.js b/src/constants/deviceConstants.js new file mode 100644 index 0000000..d260e2d --- /dev/null +++ b/src/constants/deviceConstants.js @@ -0,0 +1,23 @@ +export const DRIP_SETS = [ + { label: "10 gtt/mL", gtt: 10, note: "Macro – rapid infusion" }, + { label: "15 gtt/mL", gtt: 15, note: "Macro – standard adult" }, + { label: "20 gtt/mL", gtt: 20, note: "Macro – standard adult" }, + { label: "60 gtt/mL", gtt: 60, note: "Micro – paediatric/neonate" }, +]; + +export const FLOW_AVG_WINDOW = 5; +export const NO_FLOW_DEMO = 8000; // ms — short for demo +export const NO_FLOW_REAL = 60000; // ms — 60s real clinical + +export const S = { + BOOT: "BOOT", + MEASURING: "MEASURING", + MAIN: "MAIN", + ARMED: "ARMED", + ALARM_WARN: "ALARM_WARN", + ALARM_HIGH: "ALARM_HIGH", + ALARM_NOFLOW: "ALARM_NOFLOW", + ALARM_LOWBAT: "ALARM_LOWBAT", +}; + +export const INFO_MODES = ["INFUSED", "ELAPSED", "DROPS"]; diff --git a/src/constants/simConstants.js b/src/constants/simConstants.js new file mode 100644 index 0000000..c9055cc --- /dev/null +++ b/src/constants/simConstants.js @@ -0,0 +1,30 @@ +export const SIM = { + nozzleY_mm: 0, + beam1Y_mm: 25, + beam2Y_mm: 35, + chamberBottom_mm: 60, + chamberWidth_mm: 22, + g: 9810, + beamGap_mm: 10, +}; + +export const VIS = { + scale: 6.0, + chamberX: 260, + chamberTop: 50, + get nozzleY() { return this.chamberTop + SIM.nozzleY_mm * this.scale; }, + get beam1Y() { return this.chamberTop + SIM.beam1Y_mm * this.scale; }, + get beam2Y() { return this.chamberTop + SIM.beam2Y_mm * this.scale; }, + get chamberBot() { return this.chamberTop + SIM.chamberBottom_mm * this.scale; }, + get chamberLeft() { return this.chamberX - (SIM.chamberWidth_mm / 2) * this.scale; }, + get chamberRight(){ return this.chamberX + (SIM.chamberWidth_mm / 2) * this.scale; }, +}; + +export const TIME_SCALE = 0.15; + +export const FLUIDS = { + nacl: { name: "NaCl 0.9%", gamma: 72.0, rho: 1005, color: "#a8d8ea", nozzleDia: 4.0 }, + ringer:{ name: "Ringer's Lactate", gamma: 71.5, rho: 1005, color: "#b8e6c8", nozzleDia: 4.0 }, + d5w: { name: "D5W (5% Dextrose)", gamma: 68.0, rho: 1020, color: "#f5e6a3", nozzleDia: 4.0 }, + d10w: { name: "D10W (10% Dextrose)", gamma: 64.0, rho: 1040, color: "#f0d080", nozzleDia: 4.0 }, +}; diff --git a/src/hooks/useDripMonitor.js b/src/hooks/useDripMonitor.js new file mode 100644 index 0000000..95b71a1 --- /dev/null +++ b/src/hooks/useDripMonitor.js @@ -0,0 +1,195 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { DRIP_SETS, FLOW_AVG_WINDOW, NO_FLOW_DEMO, NO_FLOW_REAL, S } from "../constants/deviceConstants"; +import { calcInstFlow, calcAvgFlow, calcTotalMl } from "../utils/flowCalc"; + +export function useDripMonitor() { + // drip set + const [dripSetIdx, setDripSetIdx] = useState(2); + const dripSet = DRIP_SETS[dripSetIdx]; + const GTT_PER_ML = dripSet.gtt; + + // drop tracking refs (no re-render on every drop, mirrors firmware) + const intervalBuffer = useRef([]); + const lastDropTime = useRef(null); + const noFlowTimer = useRef(null); + const startTime = useRef(null); + + // reactive state + const [dropCount, setDropCount] = useState(0); + const [flowMlh, setFlowMlh] = useState(0); + const [instFlowMlh, setInstFlowMlh] = useState(0); + const [totalMl, setTotalMl] = useState(0); + const [elapsedMs, setElapsedMs] = useState(0); + + // UI state + const [screen, setScreen] = useState(S.BOOT); + const [infoMode, setInfoMode] = useState(0); + const [armedFlow, setArmedFlow] = useState(null); + const [demoMode, setDemoMode] = useState(true); + const [blink, setBlink] = useState(true); + const [blinkFast, setBlinkFast] = useState(true); + + const bootDone = useRef(false); + const measDone = useRef(false); + + // Ref copies for use inside callbacks — must stay at hook body top level + const armedFlowRef = useRef(null); + const gttRef = useRef(GTT_PER_ML); + const screenRef = useRef(S.BOOT); + armedFlowRef.current = armedFlow; + gttRef.current = GTT_PER_ML; + screenRef.current = screen; + + // blink tickers + useEffect(() => { + const t1 = setInterval(() => setBlink(b => !b), 600); + const t2 = setInterval(() => setBlinkFast(b => !b), 280); + return () => { clearInterval(t1); clearInterval(t2); }; + }, []); + + // elapsed timer + useEffect(() => { + const active = [S.MAIN, S.ARMED, S.ALARM_WARN, S.ALARM_HIGH, S.ALARM_NOFLOW]; + if (!active.includes(screen)) return; + const t = setInterval(() => { + if (startTime.current) setElapsedMs(Date.now() - startTime.current); + }, 500); + return () => clearInterval(t); + }, [screen]); + + // boot sequence + useEffect(() => { + if (screen === S.BOOT && !bootDone.current) { + bootDone.current = true; + setTimeout(() => { setScreen(S.MEASURING); measDone.current = false; }, 2500); + } + if (screen === S.MEASURING && !measDone.current) { + measDone.current = true; + startTime.current = Date.now(); + setTimeout(() => setScreen(S.MAIN), 4000); + } + }, [screen]); + + // no-flow watchdog + const resetNoFlowTimer = useCallback(() => { + if (noFlowTimer.current) clearTimeout(noFlowTimer.current); + const timeout = demoMode ? NO_FLOW_DEMO : NO_FLOW_REAL; + noFlowTimer.current = setTimeout(() => setScreen(S.ALARM_NOFLOW), timeout); + }, [demoMode]); + + useEffect(() => { + const monitoring = [S.MAIN, S.ARMED, S.ALARM_WARN, S.ALARM_HIGH]; + if (monitoring.includes(screen)) resetNoFlowTimer(); + return () => { if (noFlowTimer.current) clearTimeout(noFlowTimer.current); }; + }, [screen, resetNoFlowTimer]); + + // ── Core drop handler (mirrors EXTI ISR + flow calc in firmware) ──────────── + const registerDrop = useCallback(() => { + const now = Date.now(); + const gtt = gttRef.current; + const armed = armedFlowRef.current; + const curScr = screenRef.current; + + const valid = [S.MAIN, S.ARMED, S.ALARM_WARN, S.ALARM_HIGH, S.ALARM_NOFLOW, S.MEASURING]; + if (!valid.includes(curScr)) return; + + resetNoFlowTimer(); + + let newInst = 0; + let newAvg = 0; + + if (lastDropTime.current !== null) { + const dt = now - lastDropTime.current; + newInst = calcInstFlow(dt, gtt); + + intervalBuffer.current.push(dt); + if (intervalBuffer.current.length > FLOW_AVG_WINDOW) + intervalBuffer.current.shift(); + + newAvg = calcAvgFlow(intervalBuffer.current, gtt); + } + lastDropTime.current = now; + + setDropCount(prev => { + const n = prev + 1; + setTotalMl(calcTotalMl(n, gtt)); + return n; + }); + setInstFlowMlh(Math.round(newInst * 10) / 10); + setFlowMlh(Math.round(newAvg * 10) / 10); + + if (armed !== null && intervalBuffer.current.length >= 1) { + const dev = Math.abs(newAvg - armed) / armed; + setScreen(prev => { + if (prev === S.ALARM_NOFLOW) return S.ARMED; + if (dev >= 0.25) return S.ALARM_HIGH; + if (dev >= 0.15) return S.ALARM_WARN; + return S.ARMED; + }); + } else if (curScr === S.ALARM_NOFLOW) { + setScreen(armedFlowRef.current ? S.ARMED : S.MAIN); + } + }, [resetNoFlowTimer]); + + // keyboard shortcut: SPACE = drop + useEffect(() => { + const handler = (e) => { + if (e.code === "Space") { e.preventDefault(); registerDrop(); } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [registerDrop]); + + // recalculate flow when drip set changes + useEffect(() => { + if (intervalBuffer.current.length > 0) { + setFlowMlh(Math.round(calcAvgFlow(intervalBuffer.current, GTT_PER_ML) * 10) / 10); + } + setDropCount(prev => { setTotalMl(calcTotalMl(prev, GTT_PER_ML)); return prev; }); + // eslint-disable-next-line + }, [GTT_PER_ML]); + + // ── Button handlers ───────────────────────────────────────────────────────── + function handleMode() { + if ([S.MAIN, S.ARMED].includes(screen)) setInfoMode(m => (m + 1) % 3); + else if ([S.ALARM_WARN, S.ALARM_HIGH, S.ALARM_NOFLOW, S.ALARM_LOWBAT].includes(screen)) + setScreen(armedFlow ? S.ARMED : S.MAIN); + } + function handleSet() { + if ([S.MAIN, S.MEASURING, S.ARMED].includes(screen) && flowMlh > 0) { + setArmedFlow(flowMlh); setScreen(S.ARMED); + } + } + function handlePlus() { + if ([S.ARMED, S.ALARM_WARN, S.ALARM_HIGH].includes(screen)) { setArmedFlow(null); setScreen(S.MAIN); } + } + function handleMute() { + if ([S.ALARM_WARN, S.ALARM_HIGH, S.ALARM_NOFLOW, S.ALARM_LOWBAT].includes(screen)) + setScreen(armedFlow ? S.ARMED : S.MAIN); + } + + function hardReset() { + intervalBuffer.current = []; lastDropTime.current = null; startTime.current = null; + if (noFlowTimer.current) clearTimeout(noFlowTimer.current); + setDropCount(0); setFlowMlh(0); setInstFlowMlh(0); setTotalMl(0); setElapsedMs(0); + setArmedFlow(null); bootDone.current = false; measDone.current = false; setScreen(S.BOOT); + } + + // expose bootDone/measDone refs for ScreenJumper + const _bootDone = bootDone; + const _measDone = measDone; + + return { + // drip set + dripSetIdx, setDripSetIdx, dripSet, GTT_PER_ML, + // measurements + dropCount, flowMlh, instFlowMlh, totalMl, elapsedMs, + // device state + screen, setScreen, infoMode, armedFlow, setArmedFlow, demoMode, setDemoMode, + blink, blinkFast, + // actions + registerDrop, handleMode, handleSet, handlePlus, handleMute, hardReset, + // internal refs needed by ScreenJumper jump buttons + _bootDone, _measDone, + }; +} diff --git a/src/index.css b/src/index.css index 08a3ac9..eee093f 100644 --- a/src/index.css +++ b/src/index.css @@ -1,68 +1,12 @@ :root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - body { margin: 0; - display: flex; - place-items: center; min-width: 320px; min-height: 100vh; } - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/src/sim/drawChamber.js b/src/sim/drawChamber.js new file mode 100644 index 0000000..38f0b92 --- /dev/null +++ b/src/sim/drawChamber.js @@ -0,0 +1,257 @@ +import { SIM, VIS } from "../constants/simConstants"; + +function roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.arcTo(x + w, y, x + w, y + r, r); + ctx.lineTo(x + w, y + h - r); + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); + ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); +} + +function drawSensorModule(ctx, b1Val, b2Val) { + const wL = VIS.chamberLeft - 8; + const hLeft = 14; + const hRight = 150; + const hTop = VIS.chamberTop - 26; + const hBot = VIS.chamberBot + 26; + const hCx = (hLeft + hRight) / 2; + + // Housing body + roundRect(ctx, hLeft, hTop, hRight - hLeft, hBot - hTop, 10); + ctx.fillStyle = "#0d141e"; ctx.fill(); + roundRect(ctx, hLeft, hTop, hRight - hLeft, hBot - hTop, 10); + ctx.strokeStyle = "#253848"; ctx.lineWidth = 1.8; ctx.stroke(); + + // PCB substrate + ctx.fillStyle = "#060e07"; + ctx.fillRect(hLeft + 7, hTop + 7, hRight - hLeft - 14, hBot - hTop - 14); + + // PCB traces + ctx.strokeStyle = "#0c1e0e"; ctx.lineWidth = 0.5; + for (let y = hTop + 20; y < hBot - 6; y += 13) { + ctx.beginPath(); ctx.moveTo(hLeft + 8, y); ctx.lineTo(hRight - 8, y); ctx.stroke(); + } + // PCB vias + ctx.fillStyle = "#1a3a1a"; + for (let y = hTop + 28; y < hBot - 20; y += 32) { + for (let x = hLeft + 16; x < hRight - 20; x += 18) { + ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI * 2); ctx.fill(); + } + } + + // MCU chip + const chipX = hLeft + 10, chipY = hTop + 36; + ctx.fillStyle = "#0a0e12"; ctx.fillRect(chipX, chipY, 36, 22); + ctx.strokeStyle = "#1e2e3e"; ctx.lineWidth = 0.8; ctx.strokeRect(chipX, chipY, 36, 22); + ctx.strokeStyle = "#2a4038"; ctx.lineWidth = 0.8; + for (let i = 0; i < 5; i++) { + ctx.beginPath(); ctx.moveTo(chipX + 5 + i * 6, chipY - 2); ctx.lineTo(chipX + 5 + i * 6, chipY); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(chipX + 5 + i * 6, chipY + 22); ctx.lineTo(chipX + 5 + i * 6, chipY + 24); ctx.stroke(); + } + ctx.fillStyle = "#1a2a2a"; ctx.font = "5.5px monospace"; + ctx.fillText("STM32", chipX + 3, chipY + 11); + ctx.fillText("G030", chipX + 5, chipY + 18); + + // Bottom connector block + ctx.fillStyle = "#161e2c"; ctx.fillRect(hCx - 14, hBot - 9, 28, 14); + ctx.strokeStyle = "#263848"; ctx.lineWidth = 1; ctx.strokeRect(hCx - 14, hBot - 9, 28, 14); + for (let i = 0; i < 4; i++) { + ctx.fillStyle = "#8a9a6a"; ctx.fillRect(hCx - 9 + i * 6, hBot + 3, 3, 9); + } + + // LED emitter modules + for (let i = 0; i < 2; i++) { + const beamY = i === 0 ? VIS.beam1Y : VIS.beam2Y; + const val = i === 0 ? b1Val : b2Val; + const ledX = hRight - 10; + const baseCol = i === 0 ? [255, 60, 60] : [60, 150, 255]; + const txLabel = i === 0 ? "TX\u2081" : "TX\u2082"; + + roundRect(ctx, ledX - 18, beamY - 13, 28, 26, 4); + ctx.fillStyle = "#16202c"; ctx.fill(); + ctx.strokeStyle = "#2a3a50"; ctx.lineWidth = 1; ctx.stroke(); + + ctx.beginPath(); ctx.arc(ledX + 4, beamY, 7, 0, Math.PI * 2); + ctx.fillStyle = "#18202a"; ctx.fill(); + ctx.strokeStyle = "#303850"; ctx.lineWidth = 1; ctx.stroke(); + + const ledAlpha = 0.35 + val * 0.65; + ctx.beginPath(); ctx.arc(ledX + 4, beamY, 4.5, 0, Math.PI * 2); + ctx.fillStyle = `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},${ledAlpha})`; ctx.fill(); + + if (val > 0.4) { + const halo = ctx.createRadialGradient(ledX + 4, beamY, 0, ledX + 4, beamY, 18); + halo.addColorStop(0, `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},${0.35 * val})`); + halo.addColorStop(1, `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},0)`); + ctx.beginPath(); ctx.arc(ledX + 4, beamY, 18, 0, Math.PI * 2); + ctx.fillStyle = halo; ctx.fill(); + } + + ctx.fillStyle = i === 0 ? "#cc4444" : "#4477cc"; + ctx.font = "bold 8px monospace"; ctx.textAlign = "center"; + ctx.fillText(txLabel, ledX - 10, beamY + 3); ctx.textAlign = "left"; + } + + // Clip arms (C-clamp) + const clipTop = hTop + 16; + const clipBot = hBot - 16; + ctx.strokeStyle = "#18242e"; ctx.lineWidth = 8; ctx.lineCap = "square"; + ctx.beginPath(); ctx.moveTo(hRight - 2, clipTop); ctx.lineTo(wL - 6, clipTop); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(wL - 6, clipTop); ctx.lineTo(wL - 6, clipTop + 24); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(hRight - 2, clipBot); ctx.lineTo(wL - 6, clipBot); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(wL - 6, clipBot); ctx.lineTo(wL - 6, clipBot - 24); ctx.stroke(); + + ctx.strokeStyle = "#22344a"; ctx.lineWidth = 5; + ctx.beginPath(); ctx.moveTo(hRight - 2, clipTop); ctx.lineTo(wL - 6, clipTop); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(wL - 6, clipTop); ctx.lineTo(wL - 6, clipTop + 24); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(hRight - 2, clipBot); ctx.lineTo(wL - 6, clipBot); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(wL - 6, clipBot); ctx.lineTo(wL - 6, clipBot - 24); ctx.stroke(); + + ctx.strokeStyle = "#2e4860"; ctx.lineWidth = 2; + ctx.beginPath(); ctx.moveTo(hRight - 2, clipTop - 1); ctx.lineTo(wL - 6, clipTop - 1); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(hRight - 2, clipBot + 1); ctx.lineTo(wL - 6, clipBot + 1); ctx.stroke(); + ctx.lineCap = "butt"; + + // Cable + ctx.strokeStyle = "#161e2a"; ctx.lineWidth = 6; + ctx.beginPath(); ctx.moveTo(hCx - 4, hBot + 10); ctx.bezierCurveTo(hCx - 8, hBot + 40, hLeft + 4, hBot + 60, 0, hBot + 90); ctx.stroke(); + ctx.strokeStyle = "#1e2e3e"; ctx.lineWidth = 3; + ctx.beginPath(); ctx.moveTo(hCx - 4, hBot + 10); ctx.bezierCurveTo(hCx - 8, hBot + 40, hLeft + 4, hBot + 60, 0, hBot + 90); ctx.stroke(); + + // Module label + ctx.fillStyle = "#4a7a8a"; ctx.font = "bold 9px monospace"; ctx.textAlign = "center"; + ctx.fillText("DRIPITO MODULE", hCx, hTop - 12); + ctx.fillText("IR SENSOR", hCx, hTop - 2); + ctx.textAlign = "left"; +} + +function drawDripChamber(ctx, fluid, s, interval, drops, b1Val, b2Val) { + const wL = VIS.chamberLeft - 8; + const wR = VIS.chamberRight + 8; + + // Chamber walls + ctx.strokeStyle = "#2a3a4a"; ctx.lineWidth = 2.5; + ctx.beginPath(); + ctx.moveTo(wL, VIS.chamberTop - 5); ctx.lineTo(wL, VIS.chamberBot + 5); + ctx.moveTo(wR, VIS.chamberTop - 5); ctx.lineTo(wR, VIS.chamberBot + 5); + ctx.stroke(); + + ctx.fillStyle = fluid.color + "0a"; + ctx.fillRect(wL + 2, VIS.chamberTop, wR - wL - 4, VIS.chamberBot - VIS.chamberTop); + + // Nozzle + ctx.fillStyle = "#445566"; + const nw = 6; + ctx.fillRect(VIS.chamberX - nw / 2, VIS.chamberTop - 10, nw, VIS.nozzleY - VIS.chamberTop + 12); + + // Forming drop at nozzle + const tSinceDrop = s.time - s.lastDropTime; + const prog = Math.min(tSinceDrop / interval, 0.92); + if (prog > 0.08) { + const pr = prog * 2.2 * VIS.scale; + ctx.beginPath(); + ctx.ellipse(VIS.chamberX, VIS.nozzleY + pr * 0.7, pr * 0.65, pr, 0, 0, Math.PI * 2); + ctx.fillStyle = fluid.color + "bb"; ctx.fill(); + ctx.beginPath(); + ctx.moveTo(VIS.chamberX - 2, VIS.nozzleY); + ctx.quadraticCurveTo(VIS.chamberX - pr * 0.4, VIS.nozzleY + pr * 0.35, VIS.chamberX - pr * 0.65, VIS.nozzleY + pr * 0.7); + ctx.moveTo(VIS.chamberX + 2, VIS.nozzleY); + ctx.quadraticCurveTo(VIS.chamberX + pr * 0.4, VIS.nozzleY + pr * 0.35, VIS.chamberX + pr * 0.65, VIS.nozzleY + pr * 0.7); + ctx.strokeStyle = fluid.color + "66"; ctx.lineWidth = 1; ctx.stroke(); + } + + // IR beams + RX side + for (let i = 0; i < 2; i++) { + const by = i === 0 ? VIS.beam1Y : VIS.beam2Y; + const val = i === 0 ? b1Val : b2Val; + const baseCol = i === 0 ? [255, 60, 60] : [60, 150, 255]; + + ctx.beginPath(); ctx.moveTo(wL - 1, by); ctx.lineTo(wR + 1, by); + ctx.strokeStyle = `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},${0.12 + val * 0.55})`; + ctx.lineWidth = val < 0.8 ? 3 : 2; ctx.stroke(); + + if (val < 0.7) { + ctx.beginPath(); ctx.moveTo(wL - 1, by); ctx.lineTo(wR + 1, by); + ctx.strokeStyle = `rgba(${baseCol[0]},${baseCol[1]},${baseCol[2]},${(1 - val) * 0.3})`; + ctx.lineWidth = 6; ctx.stroke(); + } + + ctx.fillStyle = i === 0 ? "#551818" : "#182855"; + ctx.fillRect(wR + 2, by - 6, 22, 12); + ctx.strokeStyle = i === 0 ? "#7a2a2a" : "#2a4a7a"; ctx.lineWidth = 0.8; + ctx.strokeRect(wR + 2, by - 6, 22, 12); + + ctx.beginPath(); ctx.arc(wR + 6, by, 3, 0, Math.PI * 2); + ctx.fillStyle = i === 0 ? "#2a0808" : "#081828"; ctx.fill(); + + ctx.fillStyle = i === 0 ? "#ff5555" : "#5599ff"; + ctx.font = "bold 9px monospace"; + ctx.fillText(i === 0 ? "RX\u2081" : "RX\u2082", wR + 28, by + 3); + } + + // Δh annotation + const midBy = (VIS.beam1Y + VIS.beam2Y) / 2; + ctx.strokeStyle = "#445566"; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); + ctx.beginPath(); + ctx.moveTo(wR + 54, VIS.beam1Y); ctx.lineTo(wR + 66, VIS.beam1Y); + ctx.moveTo(wR + 54, VIS.beam2Y); ctx.lineTo(wR + 66, VIS.beam2Y); + ctx.moveTo(wR + 60, VIS.beam1Y + 4); ctx.lineTo(wR + 60, VIS.beam2Y - 4); + ctx.stroke(); ctx.setLineDash([]); + ctx.fillStyle = "#88aacc"; ctx.font = "bold 9px monospace"; ctx.textAlign = "center"; + ctx.fillText("\u0394h", wR + 60, midBy - 2); + ctx.fillText(SIM.beamGap_mm + "mm", wR + 60, midBy + 10); + ctx.textAlign = "left"; + + // Falling drops + for (const drop of drops) { + if (!drop.active) continue; + const ft = s.time - drop.detachTime; + const oscVal = drop.oscAmp * Math.sin(2 * Math.PI * drop.oscFreq * ft + drop.oscPhase) * Math.exp(-drop.oscDecay * ft); + const pixRH = (drop.trueDia * (1 + oscVal) / 2) * VIS.scale; + const pixRV = (drop.trueDia * (1 - oscVal * 0.5) / 2) * VIS.scale; + const pixY = VIS.chamberTop + drop.y_mm * VIS.scale; + + ctx.beginPath(); ctx.ellipse(VIS.chamberX, pixY + 1, pixRH + 1, pixRV + 1, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.3)"; ctx.fill(); + + ctx.beginPath(); ctx.ellipse(VIS.chamberX, pixY, pixRH, pixRV, 0, 0, Math.PI * 2); + const grad = ctx.createRadialGradient( + VIS.chamberX - pixRH * 0.3, pixY - pixRV * 0.3, 0, + VIS.chamberX, pixY, Math.max(pixRH, pixRV) + ); + grad.addColorStop(0, fluid.color + "ff"); + grad.addColorStop(0.6, fluid.color + "cc"); + grad.addColorStop(1, fluid.color + "88"); + ctx.fillStyle = grad; ctx.fill(); + ctx.strokeStyle = fluid.color + "44"; ctx.lineWidth = 0.5; ctx.stroke(); + + ctx.beginPath(); ctx.ellipse(VIS.chamberX - pixRH * 0.25, pixY - pixRV * 0.25, pixRH * 0.25, pixRV * 0.2, -0.4, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255,255,255,0.2)"; ctx.fill(); + } + + // Pool + outlet tube + ctx.fillStyle = fluid.color + "30"; + ctx.fillRect(wL + 2, VIS.chamberBot - 18, wR - wL - 4, 18); + ctx.fillStyle = "#445566"; + ctx.fillRect(VIS.chamberX - 3, VIS.chamberBot, 6, 25); + + // Chamber label + ctx.fillStyle = "#5a8899"; ctx.font = "bold 10px monospace"; ctx.textAlign = "center"; + ctx.fillText("DRIP CHAMBER", VIS.chamberX, VIS.chamberTop - 18); + ctx.textAlign = "left"; +} + +export function drawChamberCanvas(ctx, CW, CH, s, fluid, interval, drops, b1Val, b2Val) { + ctx.clearRect(0, 0, CW, CH); + ctx.fillStyle = "#080c11"; + ctx.fillRect(0, 0, CW, CH); + + drawSensorModule(ctx, b1Val, b2Val); + drawDripChamber(ctx, fluid, s, interval, drops, b1Val, b2Val); +} diff --git a/src/sim/drawScope.js b/src/sim/drawScope.js new file mode 100644 index 0000000..7977452 --- /dev/null +++ b/src/sim/drawScope.js @@ -0,0 +1,75 @@ +function drawSig(sctx, data, color, mg, pw, ph, len, head) { + sctx.beginPath(); + sctx.strokeStyle = color; + sctx.lineWidth = 1.5; + for (let i = 0; i < len; i++) { + const idx = (head + i) % len; + const x = mg.l + (i / len) * pw; + const y = mg.t + (1 - data[idx]) * ph; + if (i === 0) sctx.moveTo(x, y); + else sctx.lineTo(x, y); + } + sctx.stroke(); +} + +export function drawScope(sctx, SW, SHt, scope1, scope2, scopeIdx) { + sctx.clearRect(0, 0, SW, SHt); + sctx.fillStyle = "#060a0e"; + sctx.fillRect(0, 0, SW, SHt); + + const mg = { l: 52, r: 16, t: 30, b: 30 }; + const pw = SW - mg.l - mg.r; + const ph = SHt - mg.t - mg.b; + const len = 1200; + const head = scopeIdx; + + // Grid + sctx.strokeStyle = "#111a22"; + sctx.lineWidth = 0.5; + for (let i = 0; i <= 10; i++) { + const x = mg.l + (pw / 10) * i; + sctx.beginPath(); sctx.moveTo(x, mg.t); sctx.lineTo(x, mg.t + ph); sctx.stroke(); + } + for (let i = 0; i <= 4; i++) { + const y = mg.t + (ph / 4) * i; + sctx.beginPath(); sctx.moveTo(mg.l, y); sctx.lineTo(mg.l + pw, y); sctx.stroke(); + } + + // Axis labels + sctx.fillStyle = "#3a5566"; + sctx.font = "9px monospace"; + sctx.fillText("1.0", mg.l - 28, mg.t + 4); + sctx.fillText("0.5", mg.l - 28, mg.t + ph / 2 + 3); + sctx.fillText("0.0", mg.l - 28, mg.t + ph + 4); + sctx.textAlign = "center"; + sctx.fillText("V/Vmax", mg.l - 10, mg.t - 10); + sctx.fillText("Time \u2192", mg.l + pw / 2, SHt - 6); + sctx.textAlign = "left"; + + // Signals + drawSig(sctx, scope1, "#ff3333", mg, pw, ph, len, head); + drawSig(sctx, scope2, "#3388ff", mg, pw, ph, len, head); + + // Border + sctx.strokeStyle = "#2a3a4a"; + sctx.lineWidth = 1; + sctx.strokeRect(mg.l, mg.t, pw, ph); + + // Legend + sctx.fillStyle = "#ff4444"; + sctx.fillRect(mg.l + pw - 120, mg.t + 6, 10, 2); + sctx.fillStyle = "#ff6666"; + sctx.font = "bold 9px monospace"; + sctx.fillText("CH1 Beam 1", mg.l + pw - 106, mg.t + 10); + sctx.fillStyle = "#3388ff"; + sctx.fillRect(mg.l + pw - 120, mg.t + 20, 10, 2); + sctx.fillStyle = "#5599ff"; + sctx.fillText("CH2 Beam 2", mg.l + pw - 106, mg.t + 24); + + // Title + sctx.fillStyle = "#5a8899"; + sctx.font = "bold 10px monospace"; + sctx.textAlign = "center"; + sctx.fillText("OSCILLOSCOPE \u2014 PHOTODIODE OUTPUT", SW / 2, 16); + sctx.textAlign = "left"; +} diff --git a/src/sim/measurement.js b/src/sim/measurement.js new file mode 100644 index 0000000..168f430 --- /dev/null +++ b/src/sim/measurement.js @@ -0,0 +1,38 @@ +import { SIM } from "../constants/simConstants"; +import { sphereVolume } from "./physics"; + +// Attempt to measure a drop after it has fully passed both beams. +// Returns a measurement object if successful, or null otherwise. +export function tryMeasureDrop(drop, simTime) { + if (drop.b2Hit && !drop.measured) { + const rV = drop.trueDia * 0.5; // approximate rV for exit check + // Check if drop has fully cleared beam2 + if (drop.y_mm - rV <= SIM.beam2Y_mm + 2) return null; + + if (drop.b1Hit && drop.b1OccEnd > drop.b1OccStart && drop.b2OccEnd > drop.b2OccStart) { + drop.measured = true; + const dt_transit = drop.b2TimeFirst - drop.b1TimeFirst; + if (dt_transit > 1e-6) { + const dh = SIM.beamGap_mm; + const v1 = (dh - 0.5 * SIM.g * dt_transit * dt_transit) / dt_transit; + const v2 = v1 + SIM.g * dt_transit; + const occ1 = drop.b1OccEnd - drop.b1OccStart; + const occ2 = drop.b2OccEnd - drop.b2OccStart; + const d1 = Math.abs(v1) * occ1; + const d2 = Math.abs(v2) * occ2; + const dAvg = (d1 + d2) / 2; + const vEst = sphereVolume(dAvg); + + return { + dt: dt_transit * 1000, + v1: v1 / 1000, v2: v2 / 1000, + occ1: occ1 * 1000, occ2: occ2 * 1000, + d1, d2, dAvg, vEst, + trueVol: drop.trueVol, trueDia: drop.trueDia, + err: ((vEst - drop.trueVol) / drop.trueVol) * 100, + }; + } + } + } + return null; +} diff --git a/src/sim/physics.js b/src/sim/physics.js new file mode 100644 index 0000000..4ed44d2 --- /dev/null +++ b/src/sim/physics.js @@ -0,0 +1,78 @@ +import { SIM } from "../constants/simConstants"; + +function rayleighFreq(gamma_mNm, rho_kgm3, R_mm) { + const gamma = gamma_mNm * 1e-3; + const R = R_mm * 1e-3; + return (1 / (2 * Math.PI)) * Math.sqrt((8 * gamma) / (rho_kgm3 * R * R * R)); +} + +function sphereVolume(d_mm) { + return (Math.PI / 6) * d_mm * d_mm * d_mm; +} + +// Create a new drop from fluid properties and current sim time +export function makeDrop(fluid, simTime) { + const vol = (Math.PI * fluid.nozzleDia * (fluid.gamma * 1e-3) * 0.6) / ((fluid.rho * 1e-9) * SIM.g); + const v = vol * (1 + (Math.random() - 0.5) * 0.02); + const d = Math.pow((6 * v) / Math.PI, 1 / 3); + const oscF = rayleighFreq(fluid.gamma, fluid.rho, d / 2); + return { + y_mm: SIM.nozzleY_mm, vy: 0, + trueDia: d, trueVol: v, + oscFreq: oscF, oscPhase: Math.random() * Math.PI * 2, + oscAmp: 0.15, oscDecay: 2.5, + detachTime: simTime, + b1Hit: false, b2Hit: false, + b1TimeFirst: 0, b2TimeFirst: 0, + b1OccStart: 0, b1OccEnd: 0, + b2OccStart: 0, b2OccEnd: 0, + measured: false, active: true, + }; +} + +// Update a drop's physics for one simulation step; mutates drop in place +export function updateDropPhysics(drop, simDt, simTime) { + drop.vy += SIM.g * simDt; + drop.y_mm += drop.vy * simDt; + + const ft = simTime - drop.detachTime; + const osc = drop.oscAmp * Math.sin(2 * Math.PI * drop.oscFreq * ft + drop.oscPhase) * Math.exp(-drop.oscDecay * ft); + return { + dH: drop.trueDia * (1 + osc), + dV: drop.trueDia * (1 - osc * 0.5), + osc, + }; +} + +// Calculate beam occlusion for a drop against both beams; mutates drop hit tracking +export function calcBeamOcclusion(drop, simTime, shape) { + const { dH, dV } = shape; + const rH = dH / 2, rV = dV / 2; + let b1Val = 1.0, b2Val = 1.0; + + for (let bi = 0; bi < 2; bi++) { + const beamY = bi === 0 ? SIM.beam1Y_mm : SIM.beam2Y_mm; + const dropTop = drop.y_mm - rV; + const dropBot = drop.y_mm + rV; + + if (dropTop < beamY + 0.3 && dropBot > beamY - 0.3) { + const dist = Math.abs(drop.y_mm - beamY); + const norm = Math.min(dist / rV, 1); + const chord = norm < 1 ? 2 * rH * Math.sqrt(1 - norm * norm) : 0; + const occ = Math.min(chord / SIM.chamberWidth_mm, 0.9); + + if (bi === 0) { + b1Val = Math.min(b1Val, 1 - occ); + if (!drop.b1Hit && occ > 0.02) { drop.b1Hit = true; drop.b1TimeFirst = simTime; drop.b1OccStart = simTime; } + if (drop.b1Hit && occ > 0.02) drop.b1OccEnd = simTime; + } else { + b2Val = Math.min(b2Val, 1 - occ); + if (!drop.b2Hit && occ > 0.02) { drop.b2Hit = true; drop.b2TimeFirst = simTime; drop.b2OccStart = simTime; } + if (drop.b2Hit && occ > 0.02) drop.b2OccEnd = simTime; + } + } + } + return { b1Val, b2Val }; +} + +export { sphereVolume }; diff --git a/src/styles/theme.js b/src/styles/theme.js new file mode 100644 index 0000000..b18a0fe --- /dev/null +++ b/src/styles/theme.js @@ -0,0 +1,46 @@ +// Design tokens for recurring color values across the app. +// Import these instead of using magic hex strings directly. + +export const COLORS = { + // Brand greens + green: { + darkest: "#0a4a0a", + dark: "#1a4a1a", + medium: "#1a6a1a", + light: "#2a8a2a", + muted: "#4a7a4a", + soft: "#6a8a6a", + pale: "#8aaa8a", + }, + // Device body + device: { + bg: "#e8eae6", + border: "#c8c8c4", + panelBg: "#f0f4f0", + }, + // LCD display + lcd: { + bgNormal: "#c8d87a", + bgWarn: "#d0e87a", + bgHigh: "#c8e87a", + border: "#6a7a40", + text: "#151e08", + }, + // Alarm levels + alarm: { + high: "#cc2200", + warn: "#cc7700", + ok: "#2a8a2a", + red: "#ff4444", + amber: "#ffaa00", + greenBand: "#44aa44", + }, + // Simulation (dark theme) + sim: { + bg: "#050810", + panelBg: "#0a0e14", + panelBorder:"#151e28", + text: "#b0c4d4", + accent: "#6aacbc", + }, +}; diff --git a/src/utils/displayHelpers.js b/src/utils/displayHelpers.js new file mode 100644 index 0000000..fc00a43 --- /dev/null +++ b/src/utils/displayHelpers.js @@ -0,0 +1,54 @@ +import { S } from "../constants/deviceConstants"; + +export function formatElapsed(elapsedMs) { + const eTotal = elapsedMs / 1000; + return { + eH: String(Math.floor(eTotal / 3600)).padStart(2, "0"), + eM: String(Math.floor((eTotal % 3600) / 60)).padStart(2, "0"), + eS: String(Math.floor(eTotal % 60)).padStart(2, "0"), + }; +} + +export function alarmLevel(screen) { + if (screen === S.ALARM_HIGH || screen === S.ALARM_NOFLOW) return "HIGH"; + if (screen === S.ALARM_WARN) return "WARN"; + return "NONE"; +} + +export function devPct(armedFlow, flowMlh) { + if (!armedFlow || flowMlh === 0) return 0; + return (flowMlh - armedFlow) / armedFlow * 100; +} + +export function infoRow(infoMode, totalMl, eH, eM, eS, dropCount) { + if (infoMode === 0) return `Infsd:${totalMl.toFixed(1).padStart(5, " ")} mL`; + if (infoMode === 1) return `Time: ${eH}:${eM}:${eS}`; + return `Drops: ${String(dropCount).padStart(5, " ")} `; +} + +export function getLines({ screen, flowMlh, GTT_PER_ML, dropCount, armedFlow, infoMode, + totalMl, blink, blinkFast, eH, eM, eS, devP }) { + const flow = flowMlh > 0 ? String(Math.round(flowMlh)).padStart(4, " ") + : (screen === S.MEASURING ? "----" : " 0"); + const bat = `BAT:72%`; + const gttStr = String(GTT_PER_ML).padStart(2, "0"); + const info = infoRow(infoMode, totalMl, eH, eM, eS, dropCount); + + switch (screen) { + case S.BOOT: return [" "," Dripito V2 "," ETHZ GHE ", blink?" ":" Loading... "]; + case S.MEASURING: return ["-- Measuring -- ",`Flow:${flow} mL/h`,`Drops: ${String(dropCount).padStart(5," ")} `, blink?" Please wait.. ":" Stabilising.. "]; + case S.MAIN: return [`Flow:${flow} mL/h`, info,`${bat} ${gttStr}gtt/mL`,"[SET]arm [MODE]+"]; + case S.ARMED: return [`Flow:${flow} mL/h`, info,`Tgt:${String(Math.round(armedFlow)).padStart(4," ")} ${bat} `,"[MODE]+ [MUTE] "]; + case S.ALARM_WARN: { + const sign = devP >= 0 ? "+" : "-"; const p = Math.abs(devP).toFixed(0).padStart(2, " "); + return [blinkFast?"> RATE SHIFT < ":`Flow:${flow} mL/h`,`Dev:${sign}${p}% `,`Tgt:${String(Math.round(armedFlow)).padStart(4," ")} mL/h `,"[MUTE] silence "]; + } + case S.ALARM_HIGH: { + const sign = devP >= 0 ? "+" : "-"; const p = Math.abs(devP).toFixed(0).padStart(2, " "); + return [blinkFast?"!! RATE ALARM !!":" ",`Flow:${flow} mL/h`,`Dev:${sign}${p}% !!!`,"[MUTE] silence "]; + } + case S.ALARM_NOFLOW: return [blinkFast?"!! NO FLOW !!!!":" ",`Last:${String(Math.round(armedFlow||flowMlh)).padStart(4," ")} mL/h`,`${totalMl.toFixed(1).padStart(5," ")} mL infused`,"[MUTE] silence "]; + case S.ALARM_LOWBAT: return [blinkFast?"!! LOW BATTERY !":"LOW BATTERY ",`BAT: ~72% left `,`${totalMl.toFixed(1).padStart(5," ")} mL infused`,"[MUTE] silence "]; + default: return ["","","",""]; + } +} diff --git a/src/utils/flowCalc.js b/src/utils/flowCalc.js new file mode 100644 index 0000000..49017b1 --- /dev/null +++ b/src/utils/flowCalc.js @@ -0,0 +1,15 @@ +// mL/h from a single inter-drop interval +export function calcInstFlow(dt_ms, gtt) { + return (3600 * 1000) / (dt_ms * gtt); +} + +// mL/h from an array of inter-drop intervals +export function calcAvgFlow(buffer, gtt) { + const avgDt = buffer.reduce((a, b) => a + b, 0) / buffer.length; + return (3600 * 1000) / (avgDt * gtt); +} + +// total volume from drop count +export function calcTotalMl(dropCount, gtt) { + return dropCount / gtt; +}