mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
feat: Implement RSSI service for iOS and Web platforms
- Added IosRssiService to handle synthetic RSSI data for iOS. - Created WebRssiService to simulate RSSI scanning on the web. - Defined shared types for WifiNetwork and RssiService in rssi.service.ts. - Introduced simulation service to generate synthetic sensing data. - Implemented WebSocket service for real-time data handling with reconnection logic. - Established Zustand stores for managing application state related to MAT and pose data. - Developed theme context and utility functions for consistent styling and formatting. - Added type definitions for various application entities including API responses and sensing data. - Created utility functions for color mapping and URL validation. - Configured TypeScript settings for the mobile application.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { usePoseStore } from '@/stores/poseStore';
|
||||
import { GaugeArc } from '@/components/GaugeArc';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
|
||||
const BREATHING_MIN_BPM = 0;
|
||||
const BREATHING_MAX_BPM = 30;
|
||||
const BREATHING_BAND_MAX = 0.3;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const deriveBreathingValue = (
|
||||
breathingBand?: number,
|
||||
breathingBpm?: number,
|
||||
): number => {
|
||||
if (typeof breathingBpm === 'number' && Number.isFinite(breathingBpm)) {
|
||||
return clamp(breathingBpm, BREATHING_MIN_BPM, BREATHING_MAX_BPM);
|
||||
}
|
||||
|
||||
const bandValue = typeof breathingBand === 'number' && Number.isFinite(breathingBand) ? breathingBand : 0;
|
||||
const normalized = clamp(bandValue / BREATHING_BAND_MAX, 0, 1);
|
||||
return normalized * BREATHING_MAX_BPM;
|
||||
};
|
||||
|
||||
export const BreathingGauge = () => {
|
||||
const breathingBand = usePoseStore((state) => state.features?.breathing_band_power);
|
||||
const breathingBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.breathing_bpm);
|
||||
|
||||
const value = useMemo(
|
||||
() => deriveBreathingValue(breathingBand, breathingBpm),
|
||||
[breathingBand, breathingBpm],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ThemedText preset="labelMd" style={styles.label}>
|
||||
BREATHING
|
||||
</ThemedText>
|
||||
<GaugeArc value={value} min={BREATHING_MIN_BPM} max={BREATHING_MAX_BPM} label="" unit="BPM" color={colors.accent} />
|
||||
<ThemedText preset="labelMd" color="textSecondary" style={styles.unit}>
|
||||
BPM
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
color: '#94A3B8',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
unit: {
|
||||
marginTop: -12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { usePoseStore } from '@/stores/poseStore';
|
||||
import { GaugeArc } from '@/components/GaugeArc';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
|
||||
const HEART_MIN_BPM = 40;
|
||||
const HEART_MAX_BPM = 120;
|
||||
const MOTION_BAND_MAX = 0.5;
|
||||
const BREATH_BAND_MAX = 0.3;
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
const deriveHeartRate = (
|
||||
heartbeat?: number,
|
||||
motionBand?: number,
|
||||
breathingBand?: number,
|
||||
): number => {
|
||||
if (typeof heartbeat === 'number' && Number.isFinite(heartbeat)) {
|
||||
return clamp(heartbeat, HEART_MIN_BPM, HEART_MAX_BPM);
|
||||
}
|
||||
|
||||
const motionValue = typeof motionBand === 'number' && Number.isFinite(motionBand) ? clamp(motionBand / MOTION_BAND_MAX, 0, 1) : 0;
|
||||
const breathValue = typeof breathingBand === 'number' && Number.isFinite(breathingBand) ? clamp(breathingBand / BREATH_BAND_MAX, 0, 1) : 0;
|
||||
|
||||
const normalized = 0.7 * motionValue + 0.3 * breathValue;
|
||||
return HEART_MIN_BPM + normalized * (HEART_MAX_BPM - HEART_MIN_BPM);
|
||||
};
|
||||
|
||||
export const HeartRateGauge = () => {
|
||||
const heartProxyBpm = usePoseStore((state) => state.lastFrame?.vital_signs?.hr_proxy_bpm);
|
||||
const motionBand = usePoseStore((state) => state.features?.motion_band_power);
|
||||
const breathingBand = usePoseStore((state) => state.features?.breathing_band_power);
|
||||
|
||||
const value = useMemo(
|
||||
() => deriveHeartRate(heartProxyBpm, motionBand, breathingBand),
|
||||
[heartProxyBpm, motionBand, breathingBand],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ThemedText preset="labelMd" style={styles.label}>
|
||||
HR PROXY
|
||||
</ThemedText>
|
||||
<GaugeArc
|
||||
value={value}
|
||||
min={HEART_MIN_BPM}
|
||||
max={HEART_MAX_BPM}
|
||||
label=""
|
||||
unit="BPM"
|
||||
color={colors.danger}
|
||||
colorTo={colors.success}
|
||||
/>
|
||||
<ThemedText preset="bodySm" color="textSecondary" style={styles.note}>
|
||||
(estimated)
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
color: '#94A3B8',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
note: {
|
||||
marginTop: -12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import {
|
||||
runOnJS,
|
||||
useAnimatedReaction,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import { SparklineChart } from '@/components/SparklineChart';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { colors } from '@/theme/colors';
|
||||
|
||||
type MetricCardProps = {
|
||||
label: string;
|
||||
value: number | string;
|
||||
unit?: string;
|
||||
color?: string;
|
||||
sparklineData?: number[];
|
||||
};
|
||||
|
||||
const formatMetricValue = (value: number, unit?: string) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '--';
|
||||
}
|
||||
const decimals = Math.abs(value) >= 100 ? 0 : Math.abs(value) >= 10 ? 2 : 3;
|
||||
const text = value.toFixed(decimals);
|
||||
return unit ? `${text} ${unit}` : text;
|
||||
};
|
||||
|
||||
export const MetricCard = ({ label, value, unit, color = colors.accent, sparklineData }: MetricCardProps) => {
|
||||
const numericValue = typeof value === 'number' ? value : null;
|
||||
const [displayValue, setDisplayValue] = useState(() =>
|
||||
numericValue !== null ? formatMetricValue(numericValue, unit) : String(value ?? '--'),
|
||||
);
|
||||
|
||||
const valueAnimation = useSharedValue(numericValue ?? 0);
|
||||
|
||||
const finalValue = useMemo(
|
||||
() => (numericValue !== null ? numericValue : NaN),
|
||||
[numericValue],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (numericValue === null) {
|
||||
setDisplayValue(String(value ?? '--'));
|
||||
return;
|
||||
}
|
||||
|
||||
valueAnimation.value = withSpring(finalValue, {
|
||||
damping: 18,
|
||||
stiffness: 160,
|
||||
mass: 1,
|
||||
});
|
||||
}, [finalValue, numericValue, value, valueAnimation]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => valueAnimation.value,
|
||||
(current) => {
|
||||
runOnJS(setDisplayValue)(formatMetricValue(current, unit));
|
||||
},
|
||||
[unit],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.card, { borderColor: color, shadowColor: color, shadowOpacity: 0.35 }]} accessibilityRole="summary">
|
||||
<ThemedText preset="labelMd" style={styles.label}>
|
||||
{label}
|
||||
</ThemedText>
|
||||
<ThemedText preset="displayMd" style={styles.value}>
|
||||
{displayValue}
|
||||
</ThemedText>
|
||||
{sparklineData && sparklineData.length > 0 && (
|
||||
<View style={styles.sparklineWrap}>
|
||||
<SparklineChart data={sparklineData} color={color} height={56} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderWidth: 1,
|
||||
borderRadius: 14,
|
||||
padding: 12,
|
||||
marginBottom: 10,
|
||||
gap: 6,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
label: {
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.8,
|
||||
},
|
||||
value: {
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
sparklineWrap: {
|
||||
marginTop: 4,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.border,
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect } from 'react';
|
||||
import { ScrollView, StyleSheet, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||
import { BreathingGauge } from './BreathingGauge';
|
||||
import { HeartRateGauge } from './HeartRateGauge';
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { ConnectionBanner } from '@/components/ConnectionBanner';
|
||||
import { ModeBadge } from '@/components/ModeBadge';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { SparklineChart } from '@/components/SparklineChart';
|
||||
import { usePoseStore } from '@/stores/poseStore';
|
||||
import { usePoseStream } from '@/hooks/usePoseStream';
|
||||
import { colors } from '@/theme/colors';
|
||||
|
||||
type ConnectionBannerState = 'connected' | 'simulated' | 'disconnected';
|
||||
|
||||
const clampPercent = (value: number) => {
|
||||
const normalized = Number.isFinite(value) ? value : 0;
|
||||
return Math.max(0, Math.min(1, normalized > 1 ? normalized / 100 : normalized));
|
||||
};
|
||||
|
||||
export default function VitalsScreen() {
|
||||
usePoseStream();
|
||||
|
||||
const connectionStatus = usePoseStore((state) => state.connectionStatus);
|
||||
const isSimulated = usePoseStore((state) => state.isSimulated);
|
||||
const features = usePoseStore((state) => state.features);
|
||||
const classification = usePoseStore((state) => state.classification);
|
||||
const rssiHistory = usePoseStore((state) => state.rssiHistory);
|
||||
|
||||
const confidence = clampPercent(classification?.confidence ?? 0);
|
||||
const badgeLabel = (classification?.motion_level ?? 'ABSENT').toUpperCase();
|
||||
|
||||
const bannerStatus: ConnectionBannerState = connectionStatus === 'connected' ? 'connected' : connectionStatus === 'simulated' ? 'simulated' : 'disconnected';
|
||||
|
||||
const confidenceProgress = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
confidenceProgress.value = withSpring(confidence, {
|
||||
damping: 16,
|
||||
stiffness: 150,
|
||||
mass: 1,
|
||||
});
|
||||
}, [confidence, confidenceProgress]);
|
||||
|
||||
const animatedConfidenceStyle = useAnimatedStyle(() => ({
|
||||
width: `${confidenceProgress.value * 100}%`,
|
||||
}));
|
||||
|
||||
const classificationColor =
|
||||
classification?.motion_level === 'active'
|
||||
? colors.success
|
||||
: classification?.motion_level === 'present_still'
|
||||
? colors.warn
|
||||
: colors.muted;
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.screen}>
|
||||
<ConnectionBanner status={bannerStatus} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.headerRow}>{isSimulated ? <ModeBadge mode="SIM" /> : null}</View>
|
||||
|
||||
<View style={styles.gaugesRow}>
|
||||
<View style={styles.gaugeCard}>
|
||||
<BreathingGauge />
|
||||
</View>
|
||||
<View style={styles.gaugeCard}>
|
||||
<HeartRateGauge />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<ThemedText preset="labelLg" color="textSecondary">
|
||||
RSSI HISTORY
|
||||
</ThemedText>
|
||||
<SparklineChart data={rssiHistory.length > 0 ? rssiHistory : [0]} color={colors.accent} />
|
||||
</View>
|
||||
|
||||
<MetricCard label="Variance" value={features?.variance ?? 0} unit="" sparklineData={rssiHistory} color={colors.accent} />
|
||||
<MetricCard
|
||||
label="Motion Band"
|
||||
value={features?.motion_band_power ?? 0}
|
||||
unit=""
|
||||
color={colors.success}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Breath Band"
|
||||
value={features?.breathing_band_power ?? 0}
|
||||
unit=""
|
||||
color={colors.warn}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Spectral Entropy"
|
||||
value={features?.spectral_entropy ?? 0}
|
||||
unit=""
|
||||
color={colors.connected}
|
||||
/>
|
||||
|
||||
<View style={styles.classificationSection}>
|
||||
<ThemedText preset="labelLg" style={styles.rowLabel}>
|
||||
Classification: {badgeLabel}
|
||||
</ThemedText>
|
||||
<View style={[styles.badgePill, { borderColor: classificationColor, backgroundColor: `${classificationColor}18` }]}>
|
||||
<ThemedText preset="labelMd" style={{ color: classificationColor }}>
|
||||
{badgeLabel}
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View style={styles.confidenceContainer}>
|
||||
<ThemedText preset="bodySm" color="textSecondary">
|
||||
Confidence
|
||||
</ThemedText>
|
||||
<View style={styles.confidenceBarTrack}>
|
||||
<Animated.View style={[styles.confidenceBarFill, animatedConfidenceStyle]} />
|
||||
</View>
|
||||
<ThemedText preset="bodySm">{Math.round(confidence * 100)}%</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
paddingTop: 40,
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
content: {
|
||||
paddingTop: 12,
|
||||
paddingBottom: 30,
|
||||
gap: 12,
|
||||
},
|
||||
headerRow: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
gaugesRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
gaugeCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#111827',
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(50,184,198,0.45)',
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.accent,
|
||||
shadowOpacity: 0.3,
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 0,
|
||||
},
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(50,184,198,0.35)',
|
||||
padding: 12,
|
||||
gap: 10,
|
||||
},
|
||||
classificationSection: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(50,184,198,0.35)',
|
||||
padding: 12,
|
||||
gap: 10,
|
||||
marginBottom: 6,
|
||||
},
|
||||
rowLabel: {
|
||||
color: colors.textSecondary,
|
||||
marginBottom: 8,
|
||||
},
|
||||
badgePill: {
|
||||
alignSelf: 'flex-start',
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
confidenceContainer: {
|
||||
gap: 6,
|
||||
},
|
||||
confidenceBarTrack: {
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
confidenceBarFill: {
|
||||
height: '100%',
|
||||
backgroundColor: colors.success,
|
||||
borderRadius: 999,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user