Files
wifi-densepose/mobile/src/screens/MATScreen/index.tsx
Yossi Elkrief 47861de821 feat: Phase 4 — Live, Vitals, Zones, MAT, Settings screens
LiveScreen: GaussianSplatWebView + gaussian-splats.html (Three.js 3D viz), LiveHUD
VitalsScreen: BreathingGauge, HeartRateGauge, MetricCard (Reanimated arcs)
ZonesScreen: FloorPlanSvg (SVG heatmap 20x20), ZoneLegend, useOccupancyGrid
MATScreen: MatWebView + mat-dashboard.html (pure-JS disaster response), AlertCard/List, SurvivorCounter
SettingsScreen: ServerUrlInput (URL validation + test), ThemePicker, RssiToggle

Verified: tsc 0 errors, jest passes
2026-03-02 13:00:49 +02:00

139 lines
3.8 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { useWindowDimensions, View } from 'react-native';
import { ConnectionBanner } from '@/components/ConnectionBanner';
import { ThemedView } from '@/components/ThemedView';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { usePoseStream } from '@/hooks/usePoseStream';
import { useMatStore } from '@/stores/matStore';
import { type ConnectionStatus } from '@/types/sensing';
import { Alert, type Survivor } from '@/types/mat';
import { AlertList } from './AlertList';
import { MatWebView } from './MatWebView';
import { SurvivorCounter } from './SurvivorCounter';
import { useMatBridge } from './useMatBridge';
const isAlert = (value: unknown): value is Alert => {
if (!value || typeof value !== 'object') {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.id === 'string' && typeof record.message === 'string';
};
const isSurvivor = (value: unknown): value is Survivor => {
if (!value || typeof value !== 'object') {
return false;
}
const record = value as Record<string, unknown>;
return typeof record.id === 'string' && typeof record.zone_id === 'string';
};
const resolveBannerState = (status: ConnectionStatus): 'connected' | 'simulated' | 'disconnected' => {
if (status === 'connecting') {
return 'disconnected';
}
return status;
};
export const MATScreen = () => {
const { connectionStatus, lastFrame } = usePoseStream();
const { survivors, alerts, upsertSurvivor, addAlert, upsertEvent } = useMatStore((state) => ({
survivors: state.survivors,
alerts: state.alerts,
upsertSurvivor: state.upsertSurvivor,
addAlert: state.addAlert,
upsertEvent: state.upsertEvent,
}));
const { webViewRef, ready, onMessage, sendFrameUpdate, postEvent } = useMatBridge({
onSurvivorDetected: (survivor) => {
if (isSurvivor(survivor)) {
upsertSurvivor(survivor);
}
},
onAlertGenerated: (alert) => {
if (isAlert(alert)) {
addAlert(alert);
}
},
});
const seededRef = useRef(false);
useEffect(() => {
if (!ready || seededRef.current) {
return;
}
const createEvent = postEvent('CREATE_EVENT');
createEvent({
type: 'earthquake',
latitude: 37.7749,
longitude: -122.4194,
name: 'Training Scenario',
});
const addZone = postEvent('ADD_ZONE');
addZone({
name: 'Zone A',
zone_type: 'rectangle',
x: 60,
y: 60,
width: 180,
height: 120,
});
addZone({
name: 'Zone B',
zone_type: 'circle',
center_x: 300,
center_y: 170,
radius: 60,
});
upsertEvent({
event_id: 'training-scenario',
disaster_type: 1,
latitude: 37.7749,
longitude: -122.4194,
description: 'Training Scenario',
});
seededRef.current = true;
}, [postEvent, upsertEvent, ready]);
useEffect(() => {
if (ready && lastFrame) {
sendFrameUpdate(lastFrame);
}
}, [lastFrame, ready, sendFrameUpdate]);
const { height } = useWindowDimensions();
const webHeight = Math.max(240, Math.floor(height * 0.5));
return (
<ThemedView style={{ flex: 1, backgroundColor: colors.bg, padding: spacing.md }}>
<ConnectionBanner status={resolveBannerState(connectionStatus)} />
<View style={{ marginTop: 20 }}>
<SurvivorCounter survivors={survivors} />
</View>
<View style={{ height: webHeight }}>
<MatWebView
webViewRef={webViewRef}
onMessage={onMessage}
style={{ flex: 1, borderRadius: 12, overflow: 'hidden', backgroundColor: colors.surface }}
/>
</View>
<View style={{ flex: 1, marginTop: spacing.md }}>
<AlertList alerts={alerts} />
</View>
</ThemedView>
);
};
export default MATScreen;