volfiros commited on
Commit ·
0f88060
1
Parent(s): 7ed491f
feat: add measurement UI components and measure page with phase machine
Browse files- frontend/src/components/BPMDisplay.tsx +61 -0
- frontend/src/components/InfoDialog.tsx +63 -0
- frontend/src/components/ProgressBar.tsx +29 -0
- frontend/src/components/PulseRing.tsx +19 -0
- frontend/src/components/ROIOverlay.tsx +30 -0
- frontend/src/components/StartButton.tsx +46 -0
- frontend/src/components/StatusPill.tsx +45 -0
- frontend/src/components/WaveformChart.tsx +35 -0
- frontend/src/components/WebcamFeed.tsx +5 -1
- frontend/src/pages/MeasurePage.tsx +189 -1
frontend/src/components/BPMDisplay.tsx
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 2 |
+
import { type MeasurementStatus } from "../lib/constants";
|
| 3 |
+
import { useState, useEffect, useRef } from "react";
|
| 4 |
+
|
| 5 |
+
interface BPMDisplayProps {
|
| 6 |
+
bpm: number;
|
| 7 |
+
status: MeasurementStatus;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
function useStableBpm(bpm: number): number {
|
| 11 |
+
const [stable, setStable] = useState(0);
|
| 12 |
+
const pendingRef = useRef<number | null>(null);
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
const rounded = Math.round(bpm);
|
| 16 |
+
if (rounded === stable) {
|
| 17 |
+
pendingRef.current = null;
|
| 18 |
+
return;
|
| 19 |
+
}
|
| 20 |
+
if (pendingRef.current === rounded) {
|
| 21 |
+
setStable(rounded);
|
| 22 |
+
pendingRef.current = null;
|
| 23 |
+
} else {
|
| 24 |
+
pendingRef.current = rounded;
|
| 25 |
+
}
|
| 26 |
+
}, [bpm, stable]);
|
| 27 |
+
|
| 28 |
+
return stable;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export default function BPMDisplay({ bpm, status }: BPMDisplayProps) {
|
| 32 |
+
const isActive = status === "measuring" || status === "calibrating";
|
| 33 |
+
const displayedBpm = useStableBpm(bpm);
|
| 34 |
+
|
| 35 |
+
return (
|
| 36 |
+
<div className="flex flex-col items-center">
|
| 37 |
+
<AnimatePresence mode="popLayout">
|
| 38 |
+
<motion.div
|
| 39 |
+
key={displayedBpm}
|
| 40 |
+
initial={{ opacity: 0, scale: 0.8, y: 10 }}
|
| 41 |
+
animate={{ opacity: 1, scale: 1, y: 0 }}
|
| 42 |
+
exit={{ opacity: 0, scale: 0.8, y: -10 }}
|
| 43 |
+
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
| 44 |
+
className="text-8xl md:text-9xl font-bold leading-none"
|
| 45 |
+
style={{
|
| 46 |
+
fontFamily: "var(--font-mono)",
|
| 47 |
+
color: isActive ? "var(--color-accent)" : "rgba(255, 255, 255, 0.3)",
|
| 48 |
+
}}
|
| 49 |
+
>
|
| 50 |
+
{displayedBpm > 0 ? displayedBpm : "—"}
|
| 51 |
+
</motion.div>
|
| 52 |
+
</AnimatePresence>
|
| 53 |
+
<span
|
| 54 |
+
className="mt-2 text-lg text-text-secondary uppercase tracking-widest"
|
| 55 |
+
style={{ fontFamily: "var(--font-mono)" }}
|
| 56 |
+
>
|
| 57 |
+
BPM
|
| 58 |
+
</span>
|
| 59 |
+
</div>
|
| 60 |
+
);
|
| 61 |
+
}
|
frontend/src/components/InfoDialog.tsx
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as Dialog from "@radix-ui/react-dialog";
|
| 2 |
+
import { Info } from "lucide-react";
|
| 3 |
+
|
| 4 |
+
const TIPS = [
|
| 5 |
+
"Stay still — motion artifacts are the #1 enemy",
|
| 6 |
+
"Face the camera directly — consistent ROI = stable signal",
|
| 7 |
+
"Avoid backlighting — don't sit with a bright window behind you",
|
| 8 |
+
"Decent indoor lighting — not too dim, not flickering fluorescent",
|
| 9 |
+
"Clean forehead — hair, glasses, or glare can distort the reading",
|
| 10 |
+
"Wait for calibration — 10-15 seconds for accurate results",
|
| 11 |
+
];
|
| 12 |
+
|
| 13 |
+
export default function InfoDialog() {
|
| 14 |
+
return (
|
| 15 |
+
<Dialog.Root>
|
| 16 |
+
<Dialog.Trigger asChild>
|
| 17 |
+
<button
|
| 18 |
+
className="fixed bottom-6 right-6 p-2 rounded-full bg-bg-card/60 text-text-secondary hover:text-text-primary transition-colors cursor-pointer"
|
| 19 |
+
style={{ zIndex: 10 }}
|
| 20 |
+
aria-label="Tips for accurate reading"
|
| 21 |
+
>
|
| 22 |
+
<Info size={20} />
|
| 23 |
+
</button>
|
| 24 |
+
</Dialog.Trigger>
|
| 25 |
+
<Dialog.Portal>
|
| 26 |
+
<Dialog.Overlay className="fixed inset-0 bg-black/60" style={{ zIndex: 50 }} />
|
| 27 |
+
<Dialog.Content
|
| 28 |
+
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-[24px] p-6"
|
| 29 |
+
style={{
|
| 30 |
+
zIndex: 51,
|
| 31 |
+
background: "rgba(0, 0, 0, 0.75)",
|
| 32 |
+
backdropFilter: "blur(12px)",
|
| 33 |
+
border: "1px solid rgba(255, 255, 255, 0.15)",
|
| 34 |
+
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.4)",
|
| 35 |
+
}}
|
| 36 |
+
>
|
| 37 |
+
<Dialog.Title
|
| 38 |
+
className="text-lg font-semibold text-text mb-4"
|
| 39 |
+
style={{ fontFamily: "var(--font-sans)" }}
|
| 40 |
+
>
|
| 41 |
+
Tips for Accurate Reading
|
| 42 |
+
</Dialog.Title>
|
| 43 |
+
<ul className="space-y-3">
|
| 44 |
+
{TIPS.map((tip, i) => (
|
| 45 |
+
<li key={i} className="flex gap-3 text-sm text-text-secondary">
|
| 46 |
+
<span className="text-accent mt-0.5">•</span>
|
| 47 |
+
<span>{tip}</span>
|
| 48 |
+
</li>
|
| 49 |
+
))}
|
| 50 |
+
</ul>
|
| 51 |
+
<Dialog.Close asChild>
|
| 52 |
+
<button
|
| 53 |
+
className="mt-6 w-full py-2 rounded-full text-sm cursor-pointer"
|
| 54 |
+
style={{ background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", color: "rgba(255, 255, 255, 0.7)", fontFamily: "var(--font-sans)" }}
|
| 55 |
+
>
|
| 56 |
+
Got it
|
| 57 |
+
</button>
|
| 58 |
+
</Dialog.Close>
|
| 59 |
+
</Dialog.Content>
|
| 60 |
+
</Dialog.Portal>
|
| 61 |
+
</Dialog.Root>
|
| 62 |
+
);
|
| 63 |
+
}
|
frontend/src/components/ProgressBar.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from "react";
|
| 2 |
+
|
| 3 |
+
interface ProgressBarProps {
|
| 4 |
+
estimatedSeconds: number;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export default function ProgressBar({ estimatedSeconds }: ProgressBarProps) {
|
| 8 |
+
const [progress, setProgress] = useState(0);
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
setProgress(0);
|
| 12 |
+
const interval = setInterval(() => {
|
| 13 |
+
setProgress((prev) => {
|
| 14 |
+
if (prev >= 100) return 100;
|
| 15 |
+
return prev + 100 / (estimatedSeconds * 10);
|
| 16 |
+
});
|
| 17 |
+
}, 100);
|
| 18 |
+
return () => clearInterval(interval);
|
| 19 |
+
}, [estimatedSeconds]);
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className="w-64 h-1.5 rounded-full bg-bg-card overflow-hidden">
|
| 23 |
+
<div
|
| 24 |
+
className="h-full rounded-full bg-accent transition-all duration-100"
|
| 25 |
+
style={{ width: `${Math.min(progress, 100)}%` }}
|
| 26 |
+
/>
|
| 27 |
+
</div>
|
| 28 |
+
);
|
| 29 |
+
}
|
frontend/src/components/PulseRing.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from "framer-motion";
|
| 2 |
+
|
| 3 |
+
export default function PulseRing() {
|
| 4 |
+
return (
|
| 5 |
+
<div className="relative flex items-center justify-center w-32 h-32">
|
| 6 |
+
<motion.div
|
| 7 |
+
animate={{ scale: [1, 1.2, 1], opacity: [0.6, 0.2, 0.6] }}
|
| 8 |
+
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
| 9 |
+
className="absolute w-full h-full rounded-full border-2 border-accent"
|
| 10 |
+
/>
|
| 11 |
+
<motion.div
|
| 12 |
+
animate={{ scale: [1, 1.35, 1], opacity: [0.4, 0.1, 0.4] }}
|
| 13 |
+
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
|
| 14 |
+
className="absolute w-full h-full rounded-full border border-accent-glow"
|
| 15 |
+
/>
|
| 16 |
+
<div className="w-4 h-4 rounded-full bg-accent" />
|
| 17 |
+
</div>
|
| 18 |
+
);
|
| 19 |
+
}
|
frontend/src/components/ROIOverlay.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
interface ROIOverlayProps {
|
| 2 |
+
roi: { x: number; y: number; width: number; height: number } | null;
|
| 3 |
+
videoWidth: number;
|
| 4 |
+
videoHeight: number;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
export default function ROIOverlay({ roi, videoWidth, videoHeight }: ROIOverlayProps) {
|
| 8 |
+
if (!roi || videoWidth === 0 || videoHeight === 0) return null;
|
| 9 |
+
|
| 10 |
+
const vw = window.innerWidth;
|
| 11 |
+
const vh = window.innerHeight;
|
| 12 |
+
const coverScale = Math.max(vw / videoWidth, vh / videoHeight);
|
| 13 |
+
const offsetX = (vw - videoWidth * coverScale) / 2;
|
| 14 |
+
const offsetY = (vh - videoHeight * coverScale) / 2;
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div
|
| 18 |
+
className="fixed border-2 rounded-md pointer-events-none"
|
| 19 |
+
style={{
|
| 20 |
+
left: roi.x * coverScale + offsetX,
|
| 21 |
+
top: roi.y * coverScale + offsetY,
|
| 22 |
+
width: roi.width * coverScale,
|
| 23 |
+
height: roi.height * coverScale,
|
| 24 |
+
borderColor: "rgba(16, 185, 129, 0.4)",
|
| 25 |
+
backgroundColor: "rgba(16, 185, 129, 0.05)",
|
| 26 |
+
zIndex: 1,
|
| 27 |
+
}}
|
| 28 |
+
/>
|
| 29 |
+
);
|
| 30 |
+
}
|
frontend/src/components/StartButton.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from "framer-motion";
|
| 2 |
+
|
| 3 |
+
interface StartButtonProps {
|
| 4 |
+
onStart: () => void;
|
| 5 |
+
onStop: () => void;
|
| 6 |
+
isMeasuring: boolean;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export default function StartButton({ onStart, onStop, isMeasuring }: StartButtonProps) {
|
| 10 |
+
if (isMeasuring) {
|
| 11 |
+
return (
|
| 12 |
+
<button
|
| 13 |
+
onClick={onStop}
|
| 14 |
+
className="fixed top-6 right-6 px-4 py-2 text-xs tracking-wide cursor-pointer"
|
| 15 |
+
style={{
|
| 16 |
+
background: "rgba(255, 255, 255, 0.03)",
|
| 17 |
+
border: "1px solid rgba(255, 255, 255, 0.08)",
|
| 18 |
+
borderRadius: "9999px",
|
| 19 |
+
color: "rgba(255, 255, 255, 0.5)",
|
| 20 |
+
fontFamily: "var(--font-mono)",
|
| 21 |
+
letterSpacing: "0.1em",
|
| 22 |
+
textTransform: "uppercase",
|
| 23 |
+
zIndex: 10,
|
| 24 |
+
transition: "all 150ms ease",
|
| 25 |
+
}}
|
| 26 |
+
>
|
| 27 |
+
Stop
|
| 28 |
+
</button>
|
| 29 |
+
);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<motion.button
|
| 34 |
+
initial={{ scale: 0.9, opacity: 0 }}
|
| 35 |
+
animate={{ scale: 1, opacity: 1 }}
|
| 36 |
+
transition={{ type: "spring", stiffness: 200 }}
|
| 37 |
+
whileHover={{ scale: 1.02 }}
|
| 38 |
+
whileTap={{ scale: 0.98 }}
|
| 39 |
+
onClick={onStart}
|
| 40 |
+
className="px-10 py-4 bg-accent text-white font-semibold text-sm tracking-wide cursor-pointer"
|
| 41 |
+
style={{ fontFamily: "var(--font-sans)", borderRadius: "9999px" }}
|
| 42 |
+
>
|
| 43 |
+
Start Measurement
|
| 44 |
+
</motion.button>
|
| 45 |
+
);
|
| 46 |
+
}
|
frontend/src/components/StatusPill.tsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { motion } from "framer-motion";
|
| 2 |
+
import { type MeasurementStatus } from "../lib/constants";
|
| 3 |
+
|
| 4 |
+
interface StatusPillProps {
|
| 5 |
+
status: MeasurementStatus;
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
const STATUS_CONFIG: Record<MeasurementStatus, { label: string; color: string; bg: string; border: string }> = {
|
| 9 |
+
buffering: { label: "Buffering...", color: "text-warning", bg: "rgba(245, 158, 11, 0.08)", border: "rgba(245, 158, 11, 0.2)" },
|
| 10 |
+
calibrating: { label: "Calibrating...", color: "text-warning", bg: "rgba(245, 158, 11, 0.08)", border: "rgba(245, 158, 11, 0.2)" },
|
| 11 |
+
measuring: { label: "Measuring", color: "text-accent", bg: "rgba(16, 185, 129, 0.08)", border: "rgba(16, 185, 129, 0.2)" },
|
| 12 |
+
poor_signal: { label: "Stay still", color: "text-danger", bg: "rgba(239, 68, 68, 0.08)", border: "rgba(239, 68, 68, 0.2)" },
|
| 13 |
+
error: { label: "Error", color: "text-danger", bg: "rgba(239, 68, 68, 0.08)", border: "rgba(239, 68, 68, 0.2)" },
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
export default function StatusPill({ status }: StatusPillProps) {
|
| 17 |
+
const config = STATUS_CONFIG[status] || STATUS_CONFIG.error;
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<motion.div
|
| 21 |
+
layout
|
| 22 |
+
className={`inline-flex items-center gap-2 px-4 py-1.5 rounded-full`}
|
| 23 |
+
style={{ background: config.bg, border: `1px solid ${config.border}` }}
|
| 24 |
+
>
|
| 25 |
+
<span
|
| 26 |
+
className="w-2 h-2 rounded-full"
|
| 27 |
+
style={{
|
| 28 |
+
backgroundColor:
|
| 29 |
+
status === "measuring"
|
| 30 |
+
? "var(--color-accent)"
|
| 31 |
+
: status === "poor_signal" || status === "error"
|
| 32 |
+
? "var(--color-danger)"
|
| 33 |
+
: "var(--color-warning)",
|
| 34 |
+
animation: status === "measuring" ? "pulse 1.5s ease-in-out infinite" : "none",
|
| 35 |
+
}}
|
| 36 |
+
/>
|
| 37 |
+
<span
|
| 38 |
+
className={`text-sm font-medium ${config.color}`}
|
| 39 |
+
style={{ fontFamily: "var(--font-mono)" }}
|
| 40 |
+
>
|
| 41 |
+
{config.label}
|
| 42 |
+
</span>
|
| 43 |
+
</motion.div>
|
| 44 |
+
);
|
| 45 |
+
}
|
frontend/src/components/WaveformChart.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useId } from "react";
|
| 2 |
+
import { AreaChart, Area, YAxis, ResponsiveContainer } from "recharts";
|
| 3 |
+
|
| 4 |
+
interface WaveformChartProps {
|
| 5 |
+
data: number[];
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
export default function WaveformChart({ data }: WaveformChartProps) {
|
| 9 |
+
const gradientId = useId();
|
| 10 |
+
const chartData = data.map((value, index) => ({ index, value }));
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<div className="w-full h-32 opacity-80">
|
| 14 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 15 |
+
<AreaChart data={chartData} margin={{ top: 0, right: 0, left: 0, bottom: 0 }}>
|
| 16 |
+
<defs>
|
| 17 |
+
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
| 18 |
+
<stop offset="0%" stopColor="#10b981" stopOpacity={0.6} />
|
| 19 |
+
<stop offset="100%" stopColor="#10b981" stopOpacity={0} />
|
| 20 |
+
</linearGradient>
|
| 21 |
+
</defs>
|
| 22 |
+
<YAxis domain={["auto", "auto"]} hide />
|
| 23 |
+
<Area
|
| 24 |
+
type="monotone"
|
| 25 |
+
dataKey="value"
|
| 26 |
+
stroke="#10b981"
|
| 27 |
+
strokeWidth={1.5}
|
| 28 |
+
fill={`url(#${gradientId})`}
|
| 29 |
+
isAnimationActive={false}
|
| 30 |
+
/>
|
| 31 |
+
</AreaChart>
|
| 32 |
+
</ResponsiveContainer>
|
| 33 |
+
</div>
|
| 34 |
+
);
|
| 35 |
+
}
|
frontend/src/components/WebcamFeed.tsx
CHANGED
|
@@ -7,9 +7,10 @@ interface WebcamFeedProps {
|
|
| 7 |
onFrame: (r: number, g: number, b: number) => void;
|
| 8 |
onFaceDetected: (roi: { x: number; y: number; width: number; height: number } | null) => void;
|
| 9 |
onCameraError: (error: string) => void;
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
-
export default function WebcamFeed({ onFrame, onFaceDetected, onCameraError }: WebcamFeedProps) {
|
| 13 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 14 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 15 |
const { detectFace, loading: detectorLoading } = useFaceDetection();
|
|
@@ -54,6 +55,9 @@ export default function WebcamFeed({ onFrame, onFaceDetected, onCameraError }: W
|
|
| 54 |
|
| 55 |
canvas.width = video.videoWidth;
|
| 56 |
canvas.height = video.videoHeight;
|
|
|
|
|
|
|
|
|
|
| 57 |
ctx.drawImage(video, 0, 0);
|
| 58 |
|
| 59 |
const now = performance.now();
|
|
|
|
| 7 |
onFrame: (r: number, g: number, b: number) => void;
|
| 8 |
onFaceDetected: (roi: { x: number; y: number; width: number; height: number } | null) => void;
|
| 9 |
onCameraError: (error: string) => void;
|
| 10 |
+
onVideoSize?: (width: number, height: number) => void;
|
| 11 |
}
|
| 12 |
|
| 13 |
+
export default function WebcamFeed({ onFrame, onFaceDetected, onCameraError, onVideoSize }: WebcamFeedProps) {
|
| 14 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 15 |
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 16 |
const { detectFace, loading: detectorLoading } = useFaceDetection();
|
|
|
|
| 55 |
|
| 56 |
canvas.width = video.videoWidth;
|
| 57 |
canvas.height = video.videoHeight;
|
| 58 |
+
if (canvas.width > 0 && canvas.height > 0) {
|
| 59 |
+
onVideoSize?.(canvas.width, canvas.height);
|
| 60 |
+
}
|
| 61 |
ctx.drawImage(video, 0, 0);
|
| 62 |
|
| 63 |
const now = performance.now();
|
frontend/src/pages/MeasurePage.tsx
CHANGED
|
@@ -1,3 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
export default function MeasurePage() {
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
}
|
|
|
|
| 1 |
+
import { useNavigate } from "react-router-dom";
|
| 2 |
+
import { motion, AnimatePresence } from "framer-motion";
|
| 3 |
+
import { usePulseCam } from "../hooks/usePulseCam";
|
| 4 |
+
import WebcamFeed from "../components/WebcamFeed";
|
| 5 |
+
import BPMDisplay from "../components/BPMDisplay";
|
| 6 |
+
import WaveformChart from "../components/WaveformChart";
|
| 7 |
+
import StatusPill from "../components/StatusPill";
|
| 8 |
+
import PulseRing from "../components/PulseRing";
|
| 9 |
+
import StartButton from "../components/StartButton";
|
| 10 |
+
import InfoDialog from "../components/InfoDialog";
|
| 11 |
+
import ROIOverlay from "../components/ROIOverlay";
|
| 12 |
+
import { useState, useCallback } from "react";
|
| 13 |
+
|
| 14 |
export default function MeasurePage() {
|
| 15 |
+
const navigate = useNavigate();
|
| 16 |
+
const { phase, bpm, confidence, waveform, status, sendFrame, start, stop } = usePulseCam();
|
| 17 |
+
const [cameraError, setCameraError] = useState<string | null>(null);
|
| 18 |
+
const [roi, setRoi] = useState<{ x: number; y: number; width: number; height: number } | null>(null);
|
| 19 |
+
const [videoSize, setVideoSize] = useState<{ w: number; h: number } | null>(null);
|
| 20 |
+
|
| 21 |
+
const handleFrame = useCallback(
|
| 22 |
+
(r: number, g: number, b: number) => {
|
| 23 |
+
sendFrame(r, g, b);
|
| 24 |
+
},
|
| 25 |
+
[sendFrame]
|
| 26 |
+
);
|
| 27 |
+
|
| 28 |
+
const handleFaceDetected = useCallback(
|
| 29 |
+
(newRoi: { x: number; y: number; width: number; height: number } | null) => {
|
| 30 |
+
setRoi(newRoi);
|
| 31 |
+
},
|
| 32 |
+
[]
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
const handleCameraError = useCallback((error: string) => {
|
| 36 |
+
setCameraError(error);
|
| 37 |
+
}, []);
|
| 38 |
+
|
| 39 |
+
const handleVideoSize = useCallback((w: number, h: number) => {
|
| 40 |
+
setVideoSize((prev) => (prev && prev.w === w && prev.h === h) ? prev : { w, h });
|
| 41 |
+
}, []);
|
| 42 |
+
|
| 43 |
+
const isMeasuring = phase === "measuring";
|
| 44 |
+
|
| 45 |
+
return (
|
| 46 |
+
<div className="relative w-screen h-screen overflow-hidden bg-bg-primary">
|
| 47 |
+
{isMeasuring && !cameraError && (
|
| 48 |
+
<WebcamFeed
|
| 49 |
+
onFrame={handleFrame}
|
| 50 |
+
onFaceDetected={handleFaceDetected}
|
| 51 |
+
onCameraError={handleCameraError}
|
| 52 |
+
onVideoSize={handleVideoSize}
|
| 53 |
+
/>
|
| 54 |
+
)}
|
| 55 |
+
|
| 56 |
+
{isMeasuring && roi && videoSize && (
|
| 57 |
+
<ROIOverlay roi={roi} videoWidth={videoSize.w} videoHeight={videoSize.h} />
|
| 58 |
+
)}
|
| 59 |
+
|
| 60 |
+
<AnimatePresence mode="wait">
|
| 61 |
+
{phase === "idle" && !cameraError && (
|
| 62 |
+
<motion.div
|
| 63 |
+
key="idle"
|
| 64 |
+
initial={{ opacity: 0 }}
|
| 65 |
+
animate={{ opacity: 1 }}
|
| 66 |
+
exit={{ opacity: 0 }}
|
| 67 |
+
className="relative z-10 flex flex-col items-center justify-center h-full"
|
| 68 |
+
>
|
| 69 |
+
<StartButton onStart={start} onStop={stop} isMeasuring={false} />
|
| 70 |
+
</motion.div>
|
| 71 |
+
)}
|
| 72 |
+
|
| 73 |
+
{phase === "checking" && (
|
| 74 |
+
<motion.div
|
| 75 |
+
key="checking"
|
| 76 |
+
initial={{ opacity: 0 }}
|
| 77 |
+
animate={{ opacity: 1 }}
|
| 78 |
+
exit={{ opacity: 0 }}
|
| 79 |
+
className="relative z-10 flex flex-col items-center justify-center h-full"
|
| 80 |
+
>
|
| 81 |
+
<PulseRing />
|
| 82 |
+
<p className="mt-6 text-text-secondary" style={{ fontFamily: "var(--font-mono)", fontSize: "11px", letterSpacing: "0.1em", textTransform: "uppercase" }}>
|
| 83 |
+
Checking server...
|
| 84 |
+
</p>
|
| 85 |
+
</motion.div>
|
| 86 |
+
)}
|
| 87 |
+
|
| 88 |
+
{phase === "connecting" && (
|
| 89 |
+
<motion.div
|
| 90 |
+
key="connecting"
|
| 91 |
+
initial={{ opacity: 0 }}
|
| 92 |
+
animate={{ opacity: 1 }}
|
| 93 |
+
exit={{ opacity: 0 }}
|
| 94 |
+
className="relative z-10 flex flex-col items-center justify-center h-full"
|
| 95 |
+
>
|
| 96 |
+
<PulseRing />
|
| 97 |
+
<p className="mt-6 text-text-secondary" style={{ fontFamily: "var(--font-mono)", fontSize: "11px", letterSpacing: "0.1em", textTransform: "uppercase" }}>
|
| 98 |
+
Connecting...
|
| 99 |
+
</p>
|
| 100 |
+
</motion.div>
|
| 101 |
+
)}
|
| 102 |
+
|
| 103 |
+
{phase === "measuring" && !cameraError && (
|
| 104 |
+
<motion.div
|
| 105 |
+
key="measuring"
|
| 106 |
+
initial={{ opacity: 0 }}
|
| 107 |
+
animate={{ opacity: 1 }}
|
| 108 |
+
exit={{ opacity: 0 }}
|
| 109 |
+
className="relative z-10 flex flex-col items-center justify-center h-full pointer-events-none"
|
| 110 |
+
>
|
| 111 |
+
<BPMDisplay bpm={bpm} status={status} />
|
| 112 |
+
<div className="mt-4">
|
| 113 |
+
<StatusPill status={status} />
|
| 114 |
+
</div>
|
| 115 |
+
{confidence > 0 && (
|
| 116 |
+
<p
|
| 117 |
+
className="mt-2"
|
| 118 |
+
style={{ fontFamily: "var(--font-mono)", fontSize: "10px", letterSpacing: "0.1em", color: "rgba(255, 255, 255, 0.4)" }}
|
| 119 |
+
>
|
| 120 |
+
Confidence: {Math.round(confidence * 100)}%
|
| 121 |
+
</p>
|
| 122 |
+
)}
|
| 123 |
+
</motion.div>
|
| 124 |
+
)}
|
| 125 |
+
|
| 126 |
+
{phase === "error" && (
|
| 127 |
+
<motion.div
|
| 128 |
+
key="error"
|
| 129 |
+
initial={{ opacity: 0 }}
|
| 130 |
+
animate={{ opacity: 1 }}
|
| 131 |
+
exit={{ opacity: 0 }}
|
| 132 |
+
className="relative z-10 flex flex-col items-center justify-center h-full"
|
| 133 |
+
>
|
| 134 |
+
<p className="text-danger text-lg font-medium" style={{ fontFamily: "var(--font-sans)" }}>
|
| 135 |
+
Something went wrong
|
| 136 |
+
</p>
|
| 137 |
+
<p className="mt-2 text-text-secondary text-sm text-center max-w-sm px-4">
|
| 138 |
+
The server could not be reached. Please try again.
|
| 139 |
+
</p>
|
| 140 |
+
<button
|
| 141 |
+
onClick={() => navigate("/")}
|
| 142 |
+
className="mt-6 px-6 py-2 cursor-pointer"
|
| 143 |
+
style={{ background: "rgba(255, 255, 255, 0.03)", border: "1px solid rgba(255, 255, 255, 0.08)", borderRadius: "9999px", color: "rgba(255, 255, 255, 0.7)", fontFamily: "var(--font-sans)", transition: "all 150ms ease" }}
|
| 144 |
+
>
|
| 145 |
+
Back to Home
|
| 146 |
+
</button>
|
| 147 |
+
</motion.div>
|
| 148 |
+
)}
|
| 149 |
+
</AnimatePresence>
|
| 150 |
+
|
| 151 |
+
{isMeasuring && waveform.length > 0 && (
|
| 152 |
+
<div className="fixed bottom-0 left-0 right-0 z-10 px-4 pb-4">
|
| 153 |
+
<WaveformChart data={waveform} />
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
|
| 157 |
+
{isMeasuring && (
|
| 158 |
+
<>
|
| 159 |
+
<StartButton onStart={start} onStop={stop} isMeasuring={true} />
|
| 160 |
+
<InfoDialog />
|
| 161 |
+
</>
|
| 162 |
+
)}
|
| 163 |
+
|
| 164 |
+
<button
|
| 165 |
+
onClick={() => navigate("/")}
|
| 166 |
+
className="fixed top-6 left-6 text-sm font-semibold cursor-pointer z-10"
|
| 167 |
+
style={{ fontFamily: "var(--font-sans)", color: "rgba(255, 255, 255, 0.5)", transition: "all 150ms ease" }}
|
| 168 |
+
>
|
| 169 |
+
PulseCam
|
| 170 |
+
</button>
|
| 171 |
+
|
| 172 |
+
{cameraError && (
|
| 173 |
+
<div className="relative z-10 flex flex-col items-center justify-center h-full">
|
| 174 |
+
<p className="text-danger text-lg font-medium" style={{ fontFamily: "var(--font-sans)" }}>
|
| 175 |
+
Camera access required
|
| 176 |
+
</p>
|
| 177 |
+
<p className="mt-2 text-text-secondary text-sm text-center max-w-sm px-4">
|
| 178 |
+
{cameraError}. Please allow camera permission and try again.
|
| 179 |
+
</p>
|
| 180 |
+
<button
|
| 181 |
+
onClick={() => { setCameraError(null); start(); }}
|
| 182 |
+
className="mt-6 px-6 py-2 cursor-pointer"
|
| 183 |
+
style={{ background: "rgba(16, 185, 129, 0.1)", border: "1px solid rgba(16, 185, 129, 0.3)", borderRadius: "9999px", color: "#10b981", fontFamily: "var(--font-sans)", transition: "all 150ms ease" }}
|
| 184 |
+
>
|
| 185 |
+
Retry
|
| 186 |
+
</button>
|
| 187 |
+
</div>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
}
|