- {/* 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 (
+
{ setPressed(true); onClick?.(); }}
+ onMouseUp={() => setPressed(false)}
+ onMouseLeave={() => setPressed(false)}
+ style={{
+ background: pressed ? (red?"#cc2222":"#d0d8d0") : (bg||(red?"#f0e0e0":"#e8e8e8")),
+ border: `1.5px solid ${pressed?"#aaa":(red?"#e08080":"#bbb")}`,
+ borderBottom: pressed ? "1.5px solid #aaa" : "3px solid #999",
+ borderRadius: "5px", color: color||(red?"#aa1111":"#222"),
+ padding: "7px 10px", cursor: "pointer",
+ fontFamily: "'Share Tech Mono',monospace",
+ fontSize: "11px", fontWeight: "700", letterSpacing: "0.06em",
+ textTransform: "uppercase", minWidth: "60px",
+ transition: "all 0.07s",
+ transform: pressed ? "translateY(2px)" : "none",
+ textAlign: "center", lineHeight: 1.3, outline: "none", userSelect: "none",
+ }}
+ >
+ {label}
+ {sublabel && {sublabel}
}
+
+ );
+}
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 */}
+
+ onDrop()}
+ style={{ background:"linear-gradient(180deg,#2a8a2a 0%,#1a6a1a 100%)",
+ border:"none", borderBottom:"4px solid #0a4a0a", borderRadius:"50%",
+ width:76, height:76, color:"#fff",
+ fontFamily:"'Share Tech Mono',monospace", fontSize:"10px", fontWeight:"700",
+ cursor:"pointer", display:"flex", flexDirection:"column", alignItems:"center",
+ justifyContent:"center", boxShadow:"0 4px 12px rgba(0,0,0,0.2)",
+ userSelect:"none", outline:"none", lineHeight:1.4 }}>
+ 💧
+ DROP
+
+
+
+
+ Click or
+ SPACE
+
+
+ {/* Drip set selector */}
+
+
+ Drip Set
+
+
+ {DRIP_SETS.map((ds, i) => (
+
onDripSetChange(i)} style={{
+ background: dripSetIdx===i?"#1a6a1a":"#f0f4f0",
+ border:`1.5px solid ${dripSetIdx===i?"#1a6a1a":"#c8d8c8"}`,
+ borderRadius:5, color:dripSetIdx===i?"#fff":"#3a5a3a",
+ fontSize:10, padding:"5px 10px", cursor:"pointer",
+ fontFamily:"'Share Tech Mono',monospace",
+ fontWeight:dripSetIdx===i?"700":"400", transition:"all 0.1s",
+ lineHeight:1.4,
+ }}>
+ {ds.gtt} gtt
+ {ds.note.split("–")[0].trim()}
+
+ ))}
+
+
+ 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}
+ >
+ ))}
+
+
+
+ onDemoModeChange(e.target.checked)} style={{ accentColor:"#1a6a1a" }}/>
+ Demo: 8s no-flow timeout
+
+
+ RESET
+
+
+
+ );
+}
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}) => (
+ {
+ if (s === S.BOOT) { bootDoneRef.current = false; }
+ if (s === S.MEASURING){ measDoneRef.current = false; }
+ if ([S.ARMED, S.ALARM_WARN, S.ALARM_HIGH].includes(s) && flowMlh > 0) setArmedFlow(flowMlh);
+ onJump(s);
+ }} style={{
+ background: screen===s?"#e0f0e0":"#f0f0ec",
+ border:`1px solid ${screen===s?"#4a9a4a":"#ccc"}`,
+ borderRadius:4, color:screen===s?"#1a6a1a":"#666",
+ fontSize:9, padding:"3px 7px", cursor:"pointer",
+ fontFamily:"'Share Tech Mono',monospace",
+ }}>{l}
+ ))}
+
+
+ );
+}
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;
+}