feat: implement zones mat and settings screens

This commit is contained in:
Yossi Elkrief
2026-03-02 12:58:47 +02:00
parent 779bf8ff43
commit a47c4363b7
15 changed files with 1801 additions and 0 deletions

View File

@@ -0,0 +1,505 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MAT Dashboard</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
background: #0a0e1a;
color: #e5e7eb;
font-family: 'Courier New', 'Consolas', monospace;
overflow: hidden;
}
#app {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
padding: 8px;
}
#status {
color: #6dd4df;
font-size: 12px;
letter-spacing: 0.5px;
}
#mapCanvas {
flex: 1;
width: 100%;
border: 1px solid #1e293b;
border-radius: 8px;
min-height: 180px;
background: #0a0e1a;
}
</style>
</head>
<body>
<div id="app">
<div id="status">Initializing MAT dashboard...</div>
<canvas id="mapCanvas"></canvas>
</div>
<script>
(function () {
const TRIAGE = {
Immediate: 0,
Delayed: 1,
Minimal: 2,
Expectant: 3,
Unknown: 4,
};
const TRIAGE_COLOR = ['#ff0000', '#ffcc00', '#00cc00', '#111111', '#888888'];
const PRIORITY = { Critical: 0, High: 1, Medium: 2, Low: 3 };
const toRgba = (status) => TRIAGE_COLOR[status] || TRIAGE_COLOR[4];
const safeId = () =>
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `id-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
const isNumber = (value) => typeof value === 'number' && Number.isFinite(value);
class MatDashboard {
constructor() {
this.event = null;
this.zones = new Map();
this.survivors = new Map();
this.alerts = new Map();
this.motionVector = { x: 0, y: 0 };
}
createEvent(type, lat, lon, name) {
const eventId = safeId();
this.event = {
event_id: eventId,
disaster_type: type,
latitude: lat,
longitude: lon,
description: name,
createdAt: Date.now(),
};
this.zones.clear();
this.survivors.clear();
this.alerts.clear();
return eventId;
}
addRectangleZone(name, x, y, w, h) {
const id = safeId();
this.zones.set(id, {
id,
name,
zone_type: 'rectangle',
status: 0,
scan_count: 0,
detection_count: 0,
x,
y,
width: w,
height: h,
});
return id;
}
addCircleZone(name, cx, cy, radius) {
const id = safeId();
this.zones.set(id, {
id,
name,
zone_type: 'circle',
status: 0,
scan_count: 0,
detection_count: 0,
center_x: cx,
center_y: cy,
radius,
});
return id;
}
addZoneFromPayload(payload) {
if (!payload || typeof payload !== 'object') {
return;
}
const source = payload;
const type = source.zone_type || source.type || 'rectangle';
const name = source.name || `Zone-${safeId().slice(0, 4)}`;
if (type === 'circle' || source.center_x !== undefined) {
const cx = isNumber(source.center_x) ? source.center_x : 120;
const cy = isNumber(source.center_y) ? source.center_y : 120;
const radius = isNumber(source.radius) ? source.radius : 50;
return this.addCircleZone(name, cx, cy, radius);
}
const x = isNumber(source.x) ? source.x : 40;
const y = isNumber(source.y) ? source.y : 40;
const width = isNumber(source.width) ? source.width : 100;
const height = isNumber(source.height) ? source.height : 100;
return this.addRectangleZone(name, x, y, width, height);
}
inferTriage(vitalSigns, confidence) {
const breathing = isNumber(vitalSigns?.breathing_rate) ? vitalSigns.breathing_rate : 14;
const heart = isNumber(vitalSigns?.heart_rate)
? vitalSigns.heart_rate
: isNumber(vitalSigns?.hr)
? vitalSigns.hr
: 70;
if (!isNumber(confidence) || confidence > 0.82) {
if (breathing < 10 || breathing > 35 || heart > 150) {
return TRIAGE.Immediate;
}
if (breathing >= 8 && breathing <= 34) {
return TRIAGE.Delayed;
}
}
if (breathing >= 6 && breathing <= 28 && heart > 45 && heart < 180) {
return TRIAGE.Minimal;
}
return TRIAGE.Expectant;
}
locateZoneForPoint(x, y) {
for (const [id, zone] of this.zones.entries()) {
if (zone.zone_type === 'circle') {
const dx = x - zone.center_x;
const dy = y - zone.center_y;
const inside = Math.sqrt(dx * dx + dy * dy) <= zone.radius;
if (inside) {
return id;
}
continue;
}
if (x >= zone.x && x <= zone.x + zone.width && y >= zone.y && y <= zone.y + zone.height) {
return id;
}
}
return this.zones.size > 0 ? this.zones.keys().next().value : safeId();
}
processSurvivorDetection(zone, confidence = 0.6, vital_signs = {}) {
const zoneKey =
typeof zone === 'string'
? [...this.zones.values()].find((entry) => entry.id === zone || entry.name === zone)
: null;
const selectedZone =
zoneKey
|| (this.zones.size > 0
? [...this.zones.values()][Math.floor(Math.random() * Math.max(1, this.zones.size))]
: null);
const bounds = this._pickPointInZone(selectedZone);
const triageStatus = this.inferTriage(vital_signs, confidence);
const breathingRate = isNumber(vital_signs?.breathing_rate)
? vital_signs.breathing_rate
: 10 + confidence * 28;
const heartRate = isNumber(vital_signs?.heart_rate)
? vital_signs.heart_rate
: isNumber(vital_signs?.hr)
? vital_signs.hr
: 55 + confidence * 60;
const id = safeId();
const zone_id = this.locateZoneForPoint(bounds.x, bounds.y);
const survivor = {
id,
zone_id,
x: bounds.x,
y: bounds.y,
depth: -Math.abs(isNumber(vital_signs.depth) ? vital_signs.depth : Math.random() * 3),
triage_status: triageStatus,
triage_color: toRgba(triageStatus),
confidence,
breathing_rate: breathingRate,
heart_rate: heartRate,
first_detected: new Date().toISOString(),
last_updated: new Date().toISOString(),
is_deteriorating: false,
};
this.survivors.set(id, survivor);
if (selectedZone) {
selectedZone.detection_count = (selectedZone.detection_count || 0) + 1;
}
if (typeof this.postMessage === 'function') {
this.postMessage({
type: 'SURVIVOR_DETECTED',
payload: survivor,
});
}
this.generateAlerts();
return id;
}
_pickPointInZone(zone) {
if (!zone) {
return {
x: 220 + Math.random() * 80,
y: 120 + Math.random() * 80,
};
}
if (zone.zone_type === 'circle') {
const angle = Math.random() * Math.PI * 2;
const radius = Math.random() * (zone.radius || 20);
return {
x: Math.max(10, Math.min(560, zone.center_x + Math.cos(angle) * radius)),
y: Math.max(10, Math.min(280, zone.center_y + Math.sin(angle) * radius)),
};
}
return {
x: Math.max(zone.x || 5, Math.min((zone.x || 5) + (zone.width || 40), (zone.x || 5) + Math.random() * (zone.width || 40))),
y: Math.max(zone.y || 5, Math.min((zone.y || 5) + (zone.height || 40), (zone.y || 5) + Math.random() * (zone.height || 40))),
};
}
generateAlerts() {
for (const survivor of this.survivors.values()) {
if ((survivor.triage_status !== TRIAGE.Immediate && survivor.triage_status !== TRIAGE.Delayed)) {
continue;
}
const alertId = `alert-${survivor.id}`;
if (this.alerts.has(alertId)) {
continue;
}
const priority =
survivor.triage_status === TRIAGE.Immediate ? PRIORITY.Critical : PRIORITY.High;
const message =
survivor.triage_status === TRIAGE.Immediate
? `Immediate rescue required at (${survivor.x.toFixed(0)}, ${survivor.y.toFixed(0)})`
: `High-priority rescue needed at (${survivor.x.toFixed(0)}, ${survivor.y.toFixed(0)})`;
const alert = {
id: alertId,
survivor_id: survivor.id,
priority,
title: survivor.triage_status === TRIAGE.Immediate ? 'URGENT' : 'HIGH',
message,
recommended_action: survivor.triage_status === TRIAGE.Immediate ? 'Dispatch now' : 'Coordinate rescue',
triage_status: survivor.triage_status,
location_x: survivor.x,
location_y: survivor.y,
created_at: new Date().toISOString(),
priority_color: survivor.triage_status === TRIAGE.Immediate ? '#ff0000' : '#ff8c00',
};
this.alerts.set(alertId, alert);
if (typeof this.postMessage === 'function') {
this.postMessage({
type: 'ALERT_GENERATED',
payload: alert,
});
}
}
}
processFrame(frame) {
const motion = Number(frame?.features?.motion_band_power || 0);
const xDelta = isNumber(motion) ? (motion - 0.1) * 4 : 0;
const yDelta = isNumber(frame?.features?.breathing_band_power || 0)
? (frame.features.breathing_band_power - 0.1) * 3
: 0;
this.motionVector = { x: xDelta || 0, y: yDelta || 0 };
for (const survivor of this.survivors.values()) {
const jitterX = (Math.random() - 0.5) * 2;
const jitterY = (Math.random() - 0.5) * 2;
survivor.x = Math.max(5, Math.min(560, survivor.x + this.motionVector.x + jitterX));
survivor.y = Math.max(5, Math.min(280, survivor.y + this.motionVector.y + jitterY));
survivor.last_updated = new Date().toISOString();
}
}
renderZones(ctx) {
for (const zone of this.zones.values()) {
const fill = 'rgba(0, 150, 255, 0.3)';
ctx.strokeStyle = '#0096ff';
ctx.fillStyle = fill;
ctx.lineWidth = 2;
if (zone.zone_type === 'circle') {
ctx.beginPath();
ctx.arc(zone.center_x, zone.center_y, zone.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#ffffff';
ctx.font = '12px monospace';
ctx.fillText(zone.name, zone.center_x - 22, zone.center_y);
} else {
ctx.fillRect(zone.x, zone.y, zone.width, zone.height);
ctx.strokeRect(zone.x, zone.y, zone.width, zone.height);
ctx.fillStyle = '#ffffff';
ctx.font = '12px monospace';
ctx.fillText(zone.name, zone.x + 4, zone.y + 14);
}
}
}
renderSurvivors(ctx) {
for (const survivor of this.survivors.values()) {
const radius = survivor.is_deteriorating ? 11 : 9;
if (survivor.triage_status === TRIAGE.Immediate) {
ctx.fillStyle = 'rgba(255, 0, 0, 0.26)';
ctx.beginPath();
ctx.arc(survivor.x, survivor.y, radius + 6, 0, Math.PI * 2);
ctx.fill();
}
ctx.fillStyle = survivor.triage_color || toRgba(TRIAGE.Minimal);
ctx.font = 'bold 18px monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('✦', survivor.x, survivor.y);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(survivor.x, survivor.y, radius, 0, Math.PI * 2);
ctx.stroke();
if (survivor.depth < 0) {
ctx.fillStyle = '#ffffff';
ctx.font = '9px monospace';
ctx.fillText(`${Math.abs(survivor.depth).toFixed(1)}m`, survivor.x + radius + 4, survivor.y + 4);
}
}
}
render(ctx, width, height) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#0a0e1a';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = '#1f2a3d';
ctx.lineWidth = 1;
const grid = 40;
for (let x = 0; x <= width; x += grid) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 0; y <= height; y += grid) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
this.renderZones(ctx);
this.renderSurvivors(ctx);
ctx.fillStyle = '#ffffff';
ctx.font = '12px monospace';
const stats = {
survivors: this.survivors.size,
alerts: this.alerts.size,
};
ctx.fillText(`Survivors: ${stats.survivors}`, 12, 20);
ctx.fillText(`Alerts: ${stats.alerts}`, 12, 36);
}
postMessage(message) {
if (typeof window.ReactNativeWebView !== 'undefined' && window.ReactNativeWebView.postMessage) {
window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
}
}
const dashboard = new MatDashboard();
const canvas = document.getElementById('mapCanvas');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const resize = () => {
canvas.width = Math.max(200, Math.floor(canvas.parentElement.clientWidth - 2));
canvas.height = Math.max(180, Math.floor(canvas.parentElement.clientHeight - 20));
};
const startup = () => {
dashboard.createEvent('earthquake', 37.7749, -122.4194, 'Training Scenario');
dashboard.addRectangleZone('Zone A', 60, 45, 170, 120);
dashboard.addCircleZone('Zone B', 300, 170, 70);
dashboard.processSurvivorDetection('Zone A', 0.94, { breathing_rate: 11, hr: 128 });
dashboard.processSurvivorDetection('Zone A', 0.88, { breathing_rate: 16, hr: 118 });
dashboard.processSurvivorDetection('Zone B', 0.71, { breathing_rate: 9, hr: 142 });
status.textContent = 'MAT dashboard ready';
dashboard.postMessage({ type: 'READY' });
};
const loop = () => {
if (dashboard.zones.size > 0) {
dashboard.render(ctx, canvas.width, canvas.height);
}
requestAnimationFrame(loop);
};
window.addEventListener('resize', resize);
window.addEventListener('message', (evt) => {
let incoming = evt.data;
try {
if (typeof incoming === 'string') {
incoming = JSON.parse(incoming);
}
} catch {
incoming = null;
}
if (!incoming || typeof incoming !== 'object') {
return;
}
if (incoming.type === 'CREATE_EVENT') {
const payload = incoming.payload || {};
dashboard.createEvent(
payload.type || payload.disaster_type || 'earthquake',
payload.latitude || 0,
payload.longitude || 0,
payload.name || payload.description || 'Disaster Event',
);
return;
}
if (incoming.type === 'ADD_ZONE') {
dashboard.addZoneFromPayload(incoming.payload || {});
return;
}
if (incoming.type === 'FRAME_UPDATE') {
dashboard.processFrame(incoming.payload || {});
}
});
resize();
startup();
loop();
})();
</script>
</body>
</html>

View File

@@ -0,0 +1,84 @@
import { View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { AlertPriority, type Alert } from '@/types/mat';
type SeverityLevel = 'URGENT' | 'HIGH' | 'NORMAL';
type AlertCardProps = {
alert: Alert;
};
type SeverityMeta = {
label: SeverityLevel;
icon: string;
color: string;
};
const resolveSeverity = (alert: Alert): SeverityMeta => {
if (alert.priority === AlertPriority.Critical) {
return {
label: 'URGENT',
icon: '‼',
color: colors.danger,
};
}
if (alert.priority === AlertPriority.High) {
return {
label: 'HIGH',
icon: '⚠',
color: colors.warn,
};
}
return {
label: 'NORMAL',
icon: '•',
color: colors.accent,
};
};
const formatTime = (value?: string): string => {
if (!value) {
return 'Unknown';
}
try {
return new Date(value).toLocaleTimeString();
} catch {
return 'Unknown';
}
};
export const AlertCard = ({ alert }: AlertCardProps) => {
const severity = resolveSeverity(alert);
return (
<View
style={{
backgroundColor: '#111827',
borderWidth: 1,
borderColor: `${severity.color}55`,
padding: spacing.md,
borderRadius: 10,
marginBottom: spacing.sm,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<ThemedText preset="labelMd" style={{ color: severity.color }}>
{severity.icon} {severity.label}
</ThemedText>
<View style={{ flex: 1 }}>
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
{formatTime(alert.created_at)}
</ThemedText>
</View>
</View>
<ThemedText preset="bodyMd" style={{ color: colors.textPrimary, marginTop: 6 }}>
{alert.message}
</ThemedText>
</View>
);
};

View File

@@ -0,0 +1,41 @@
import { FlatList, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import type { Alert } from '@/types/mat';
import { AlertCard } from './AlertCard';
type AlertListProps = {
alerts: Alert[];
};
export const AlertList = ({ alerts }: AlertListProps) => {
if (alerts.length === 0) {
return (
<View
style={{
alignItems: 'center',
justifyContent: 'center',
padding: spacing.md,
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
backgroundColor: '#111827',
}}
>
<ThemedText preset="bodyMd">No alerts system nominal</ThemedText>
</View>
);
}
return (
<FlatList
data={alerts}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <AlertCard alert={item} />}
contentContainerStyle={{ paddingBottom: spacing.md }}
showsVerticalScrollIndicator={false}
removeClippedSubviews={false}
/>
);
};

View File

@@ -0,0 +1,26 @@
import { StyleProp, ViewStyle } from 'react-native';
import WebView, { type WebViewMessageEvent } from 'react-native-webview';
import type { RefObject } from 'react';
import MAT_DASHBOARD_HTML from '@/assets/webview/mat-dashboard.html';
type MatWebViewProps = {
webViewRef: RefObject<WebView | null>;
onMessage: (event: WebViewMessageEvent) => void;
style?: StyleProp<ViewStyle>;
};
export const MatWebView = ({ webViewRef, onMessage, style }: MatWebViewProps) => {
return (
<WebView
ref={webViewRef}
originWhitelist={["*"]}
style={style}
source={{ html: MAT_DASHBOARD_HTML }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
mixedContentMode="always"
overScrollMode="never"
/>
);
};

View File

@@ -0,0 +1,89 @@
import { View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { TriageStatus, type Survivor } from '@/types/mat';
type SurvivorCounterProps = {
survivors: Survivor[];
};
type Breakdown = {
immediate: number;
delayed: number;
minor: number;
deceased: number;
unknown: number;
};
const getBreakdown = (survivors: Survivor[]): Breakdown => {
const output = {
immediate: 0,
delayed: 0,
minor: 0,
deceased: 0,
unknown: 0,
};
survivors.forEach((survivor) => {
if (survivor.triage_status === TriageStatus.Immediate) {
output.immediate += 1;
return;
}
if (survivor.triage_status === TriageStatus.Delayed) {
output.delayed += 1;
return;
}
if (survivor.triage_status === TriageStatus.Minor) {
output.minor += 1;
return;
}
if (survivor.triage_status === TriageStatus.Deceased) {
output.deceased += 1;
return;
}
output.unknown += 1;
});
return output;
};
const BreakoutChip = ({ label, value, color }: { label: string; value: number; color: string }) => (
<View
style={{
backgroundColor: '#0D1117',
borderRadius: 999,
borderWidth: 1,
borderColor: `${color}55`,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
marginRight: spacing.sm,
marginTop: spacing.sm,
}}
>
<ThemedText preset="bodySm" style={{ color }}>
{label}: {value}
</ThemedText>
</View>
);
export const SurvivorCounter = ({ survivors }: SurvivorCounterProps) => {
const total = survivors.length;
const breakdown = getBreakdown(survivors);
return (
<View style={{ paddingBottom: spacing.md }}>
<ThemedText preset="displayLg" style={{ color: colors.textPrimary }}>
{total} SURVIVORS DETECTED
</ThemedText>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', marginTop: spacing.sm }}>
<BreakoutChip label="Immediate" value={breakdown.immediate} color={colors.danger} />
<BreakoutChip label="Delayed" value={breakdown.delayed} color={colors.warn} />
<BreakoutChip label="Minimal" value={breakdown.minor} color={colors.success} />
<BreakoutChip label="Expectant" value={breakdown.deceased} color={colors.textSecondary} />
<BreakoutChip label="Unknown" value={breakdown.unknown} color="#a0aec0" />
</View>
</View>
);
};

View File

@@ -0,0 +1,138 @@
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;

View File

@@ -0,0 +1,118 @@
import { useCallback, useRef, useState } from 'react';
import type { WebView, WebViewMessageEvent } from 'react-native-webview';
import type { Alert, Survivor } from '@/types/mat';
import type { SensingFrame } from '@/types/sensing';
type MatBridgeMessageType = 'CREATE_EVENT' | 'ADD_ZONE' | 'FRAME_UPDATE';
type MatIncomingType = 'READY' | 'SURVIVOR_DETECTED' | 'ALERT_GENERATED';
type MatIncomingMessage = {
type: MatIncomingType;
payload?: unknown;
};
type MatOutgoingMessage = {
type: MatBridgeMessageType;
payload?: unknown;
};
type UseMatBridgeOptions = {
onSurvivorDetected?: (survivor: Survivor) => void;
onAlertGenerated?: (alert: Alert) => void;
};
const safeParseJson = (value: string): unknown | null => {
try {
return JSON.parse(value);
} catch {
return null;
}
};
export const useMatBridge = ({ onAlertGenerated, onSurvivorDetected }: UseMatBridgeOptions = {}) => {
const webViewRef = useRef<WebView | null>(null);
const isReadyRef = useRef(false);
const queuedMessages = useRef<string[]>([]);
const [ready, setReady] = useState(false);
const flush = useCallback(() => {
if (!webViewRef.current || !isReadyRef.current) {
return;
}
while (queuedMessages.current.length > 0) {
const payload = queuedMessages.current.shift();
if (payload) {
webViewRef.current.postMessage(payload);
}
}
}, []);
const sendMessage = useCallback(
(message: MatOutgoingMessage) => {
const payload = JSON.stringify(message);
if (isReadyRef.current && webViewRef.current) {
webViewRef.current.postMessage(payload);
return;
}
queuedMessages.current.push(payload);
},
[],
);
const sendFrameUpdate = useCallback(
(frame: SensingFrame) => {
sendMessage({ type: 'FRAME_UPDATE', payload: frame });
},
[sendMessage],
);
const postEvent = useCallback(
(type: 'CREATE_EVENT' | 'ADD_ZONE') => {
return (payload: unknown) => {
sendMessage({
type,
payload,
});
};
},
[sendMessage],
);
const onMessage = useCallback(
(event: WebViewMessageEvent) => {
const payload = safeParseJson(event.nativeEvent.data);
if (!payload || typeof payload !== 'object') {
return;
}
const message = payload as MatIncomingMessage;
if (message.type === 'READY') {
isReadyRef.current = true;
setReady(true);
flush();
return;
}
if (message.type === 'SURVIVOR_DETECTED') {
onSurvivorDetected?.(message.payload as Survivor);
return;
}
if (message.type === 'ALERT_GENERATED') {
onAlertGenerated?.(message.payload as Alert);
}
},
[flush, onAlertGenerated, onSurvivorDetected],
);
return {
webViewRef,
ready,
onMessage,
sendMessage,
sendFrameUpdate,
postEvent,
};
};

View File

@@ -0,0 +1,36 @@
import { Platform, Switch, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type RssiToggleProps = {
enabled: boolean;
onChange: (value: boolean) => void;
};
export const RssiToggle = ({ enabled, onChange }: RssiToggleProps) => {
return (
<View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<ThemedText preset="bodyMd">RSSI Scan</ThemedText>
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
Scan for nearby Wi-Fi signals from Android devices
</ThemedText>
</View>
<Switch
value={enabled}
onValueChange={onChange}
trackColor={{ true: colors.accent, false: colors.surfaceAlt }}
thumbColor={colors.textPrimary}
/>
</View>
{Platform.OS === 'ios' && (
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginTop: spacing.xs }}>
iOS: RSSI scan is currently limited using stub data.
</ThemedText>
)}
</View>
);
};

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { Pressable, TextInput, View } from 'react-native';
import { validateServerUrl } from '@/utils/urlValidator';
import { apiService } from '@/services/api.service';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type ServerUrlInputProps = {
value: string;
onChange: (value: string) => void;
onSave: () => void;
};
export const ServerUrlInput = ({ value, onChange, onSave }: ServerUrlInputProps) => {
const [testResult, setTestResult] = useState('');
const validation = validateServerUrl(value);
const handleTest = async () => {
if (!validation.valid) {
setTestResult('✗ Invalid URL');
return;
}
const start = Date.now();
try {
await apiService.getStatus();
setTestResult(`${Date.now() - start}ms`);
} catch {
setTestResult('✗ Failed');
}
};
return (
<View>
<ThemedText preset="labelMd" style={{ marginBottom: spacing.sm }}>
Server URL
</ThemedText>
<TextInput
value={value}
onChangeText={onChange}
autoCapitalize="none"
autoCorrect={false}
placeholder="http://192.168.1.100:8080"
keyboardType="url"
placeholderTextColor={colors.textSecondary}
style={{
borderWidth: 1,
borderColor: validation.valid ? colors.border : colors.danger,
borderRadius: 10,
backgroundColor: colors.surface,
color: colors.textPrimary,
padding: spacing.sm,
marginBottom: spacing.sm,
}}
/>
{!validation.valid && (
<ThemedText preset="bodySm" style={{ color: colors.danger, marginBottom: spacing.sm }}>
{validation.error}
</ThemedText>
)}
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginBottom: spacing.sm }}>
{testResult || 'Ready to test connection'}
</ThemedText>
<View style={{ flexDirection: 'row', gap: spacing.sm }}>
<Pressable
onPress={handleTest}
disabled={!validation.valid}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: validation.valid ? colors.accentDim : colors.surfaceAlt,
alignItems: 'center',
}}
>
<ThemedText preset="labelMd" style={{ color: colors.textPrimary }}>
Test Connection
</ThemedText>
</Pressable>
<Pressable
onPress={onSave}
disabled={!validation.valid}
style={{
flex: 1,
paddingVertical: 10,
borderRadius: 8,
backgroundColor: validation.valid ? colors.success : colors.surfaceAlt,
alignItems: 'center',
}}
>
<ThemedText preset="labelMd" style={{ color: colors.textPrimary }}>
Save
</ThemedText>
</Pressable>
</View>
</View>
);
};

View File

@@ -0,0 +1,47 @@
import { Pressable, View } from 'react-native';
import { ThemeMode } from '@/theme/ThemeContext';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
type ThemePickerProps = {
value: ThemeMode;
onChange: (value: ThemeMode) => void;
};
const OPTIONS: ThemeMode[] = ['light', 'dark', 'system'];
export const ThemePicker = ({ value, onChange }: ThemePickerProps) => {
return (
<View
style={{
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.sm,
}}
>
{OPTIONS.map((option) => {
const isActive = option === value;
return (
<Pressable
key={option}
onPress={() => onChange(option)}
style={{
flex: 1,
borderRadius: 8,
borderWidth: 1,
borderColor: isActive ? colors.accent : colors.border,
backgroundColor: isActive ? `${colors.accent}22` : '#0D1117',
paddingVertical: 10,
alignItems: 'center',
}}
>
<ThemedText preset="labelMd" style={{ color: isActive ? colors.accent : colors.textSecondary }}>
{option.toUpperCase()}
</ThemedText>
</Pressable>
);
})}
</View>
);
};

View File

@@ -0,0 +1,169 @@
import { useEffect, useMemo, useState } from 'react';
import { Linking, ScrollView, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { WS_PATH } from '@/constants/websocket';
import { apiService } from '@/services/api.service';
import { wsService } from '@/services/ws.service';
import { useSettingsStore } from '@/stores/settingsStore';
import { Alert, Pressable, Platform } from 'react-native';
import { ThemePicker } from './ThemePicker';
import { RssiToggle } from './RssiToggle';
import { ServerUrlInput } from './ServerUrlInput';
type GlowCardProps = {
title: string;
children: React.ReactNode;
};
const GlowCard = ({ title, children }: GlowCardProps) => {
return (
<View
style={{
backgroundColor: '#0F141E',
borderRadius: 14,
borderWidth: 1,
borderColor: `${colors.accent}35`,
padding: spacing.md,
marginBottom: spacing.md,
}}
>
<ThemedText preset="labelMd" style={{ marginBottom: spacing.sm, color: colors.textPrimary }}>
{title}
</ThemedText>
{children}
</View>
);
};
const ScanIntervalPicker = ({
value,
onChange,
}: {
value: number;
onChange: (value: number) => void;
}) => {
const options = [1, 2, 5];
return (
<View style={{ flexDirection: 'row', gap: spacing.sm, marginTop: spacing.sm }}>
{options.map((interval) => {
const isActive = interval === value;
return (
<Pressable
key={interval}
onPress={() => onChange(interval)}
style={{
flex: 1,
borderWidth: 1,
borderColor: isActive ? colors.accent : colors.border,
borderRadius: 8,
backgroundColor: isActive ? `${colors.accent}20` : colors.surface,
alignItems: 'center',
}}
>
<ThemedText
preset="bodySm"
style={{
color: isActive ? colors.accent : colors.textSecondary,
paddingVertical: 8,
}}
>
{interval}s
</ThemedText>
</Pressable>
);
})}
</View>
);
};
export const SettingsScreen = () => {
const serverUrl = useSettingsStore((state) => state.serverUrl);
const rssiScanEnabled = useSettingsStore((state) => state.rssiScanEnabled);
const theme = useSettingsStore((state) => state.theme);
const setServerUrl = useSettingsStore((state) => state.setServerUrl);
const setRssiScanEnabled = useSettingsStore((state) => state.setRssiScanEnabled);
const setTheme = useSettingsStore((state) => state.setTheme);
const [draftUrl, setDraftUrl] = useState(serverUrl);
const [scanInterval, setScanInterval] = useState(2);
useEffect(() => {
setDraftUrl(serverUrl);
}, [serverUrl]);
const intervalSummary = useMemo(() => `${scanInterval}s`, [scanInterval]);
const handleSaveUrl = () => {
setServerUrl(draftUrl);
apiService.setBaseUrl(draftUrl);
wsService.disconnect();
wsService.connect(draftUrl);
};
const handleOpenGitHub = async () => {
const handled = await Linking.canOpenURL('https://github.com');
if (!handled) {
Alert.alert('Unable to open link', 'Please open https://github.com manually in your browser.');
return;
}
await Linking.openURL('https://github.com');
};
return (
<ThemedView style={{ flex: 1, backgroundColor: colors.bg, padding: spacing.md }}>
<ScrollView
contentContainerStyle={{
paddingBottom: spacing.xl,
}}
>
<GlowCard title="SERVER">
<ServerUrlInput value={draftUrl} onChange={setDraftUrl} onSave={handleSaveUrl} />
</GlowCard>
<GlowCard title="SENSING">
<RssiToggle enabled={rssiScanEnabled} onChange={setRssiScanEnabled} />
<ThemedText preset="bodyMd" style={{ marginTop: spacing.md }}>
Scan interval
</ThemedText>
<ScanIntervalPicker value={scanInterval} onChange={setScanInterval} />
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginTop: spacing.sm }}>
Active interval: {intervalSummary}
</ThemedText>
{Platform.OS === 'ios' && (
<ThemedText preset="bodySm" style={{ color: colors.textSecondary, marginTop: spacing.sm }}>
iOS: RSSI scanning uses stubbed telemetry in this build.
</ThemedText>
)}
</GlowCard>
<GlowCard title="APPEARANCE">
<ThemePicker value={theme} onChange={setTheme} />
</GlowCard>
<GlowCard title="ABOUT">
<ThemedText preset="bodyMd" style={{ marginBottom: spacing.xs }}>
WiFi-DensePose Mobile v1.0.0
</ThemedText>
<ThemedText
preset="bodySm"
style={{ color: colors.accent, marginBottom: spacing.sm }}
onPress={handleOpenGitHub}
>
View on GitHub
</ThemedText>
<ThemedText preset="bodySm">WebSocket: {WS_PATH}</ThemedText>
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
Triage priority mapping: Immediate/Delayed/Minor/Deceased/Unknown
</ThemedText>
</GlowCard>
</ScrollView>
</ThemedView>
);
};
export default SettingsScreen;

View File

@@ -0,0 +1,201 @@
import { useEffect, useMemo } from 'react';
import { View, ViewStyle } from 'react-native';
import Svg, { Circle, Polygon, Rect } from 'react-native-svg';
import Animated, {
createAnimatedComponent,
useAnimatedProps,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withTiming,
type SharedValue,
} from 'react-native-reanimated';
import {
Gesture,
GestureDetector,
} from 'react-native-gesture-handler';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { valueToColor } from '@/utils/colorMap';
const GRID_SIZE = 20;
const CELL_COUNT = GRID_SIZE * GRID_SIZE;
type Point = {
x: number;
y: number;
};
type FloorPlanSvgProps = {
gridValues: number[];
personPositions: Point[];
size?: number;
style?: ViewStyle;
};
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
const colorToRgba = (value: number): string => {
const [r, g, b] = valueToColor(clamp01(value));
return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`;
};
const normalizeGrid = (values: number[]): number[] => {
const normalized = new Array(CELL_COUNT).fill(0);
const sourceLength = Math.min(values.length, CELL_COUNT);
for (let i = 0; i < sourceLength; i += 1) {
const raw = values?.[i];
normalized[i] = clamp01(typeof raw === 'number' && Number.isFinite(raw) ? raw : 0);
}
return normalized;
};
const AnimatedRect = createAnimatedComponent(Rect);
const AnimatedContainer = Animated.View;
const Cell = ({
index,
size,
values,
progress,
}: {
index: number;
size: number;
values: SharedValue<number[]>;
progress: SharedValue<number>;
}) => {
const cellSize = size / GRID_SIZE;
const x = (index % GRID_SIZE) * cellSize;
const y = Math.floor(index / GRID_SIZE) * cellSize;
const animatedProps = useAnimatedProps(() => {
const fill = colorToRgba(values.value[index] ?? 0);
return {
fill,
opacity: 0.95 + (progress.value - 1) * 0.05,
};
}, [index]);
return <AnimatedRect x={x} y={y} width={cellSize} height={cellSize} rx={1} animatedProps={animatedProps} />;
};
const RouterMarker = ({ cellSize }: { cellSize: number }) => {
const cx = cellSize * 5.5;
const cy = cellSize * 17.5;
const radius = cellSize * 0.35;
return (
<Polygon
points={`${cx},${cy - radius} ${cx + radius},${cy} ${cx},${cy + radius} ${cx - radius},${cy}`}
fill="rgba(50, 184, 198, 0.25)"
stroke={colors.accent}
strokeWidth={2}
/>
);
};
export const FloorPlanSvg = ({ gridValues, personPositions, size = 320, style }: FloorPlanSvgProps) => {
const normalizedValues = useMemo(() => normalizeGrid(gridValues), [gridValues]);
const values = useSharedValue(normalizedValues);
const previousValues = useSharedValue(normalizedValues);
const targetValues = useSharedValue(normalizedValues);
const progress = useSharedValue(1);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const panStartX = useSharedValue(0);
const panStartY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onStart(() => {
panStartX.value = translateX.value;
panStartY.value = translateY.value;
})
.onUpdate((event) => {
translateX.value = panStartX.value + event.translationX;
translateY.value = panStartY.value + event.translationY;
})
.onEnd(() => {
panStartX.value = translateX.value;
panStartY.value = translateY.value;
});
const panStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
useDerivedValue(() => {
const interpolated = new Array(CELL_COUNT).fill(0);
const from = previousValues.value;
const to = targetValues.value;
const p = progress.value;
for (let i = 0; i < CELL_COUNT; i += 1) {
const start = from[i] ?? 0;
const end = to[i] ?? 0;
interpolated[i] = start + (end - start) * p;
}
values.value = interpolated;
});
useEffect(() => {
const next = normalizeGrid(normalizedValues);
previousValues.value = values.value;
targetValues.value = next;
progress.value = 0;
progress.value = withTiming(1, { duration: 500 });
}, [normalizedValues, previousValues, targetValues, progress, values]);
const markers = useMemo(() => {
const cellSize = size / GRID_SIZE;
return personPositions
.map((point, idx) => {
const cx = (Math.max(0, Math.min(GRID_SIZE - 1, point.x)) + 0.5) * cellSize;
const cy = (Math.max(0, Math.min(GRID_SIZE - 1, point.y)) + 0.5) * cellSize;
const radius = Math.max(2.8, cellSize * 0.22);
return (
<Circle
key={`person-${idx}`}
cx={cx}
cy={cy}
r={radius}
fill={colors.accent}
stroke="#FFFFFF"
strokeWidth={1.8}
/>
);
})
.concat(
<RouterMarker key="router" cellSize={size / GRID_SIZE} />,
);
}, [personPositions, size]);
return (
<View style={[{ overflow: 'hidden', paddingBottom: spacing.xs }, style]}>
<GestureDetector gesture={panGesture}>
<AnimatedContainer style={panStyle}>
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{Array.from({ length: CELL_COUNT }).map((_, index) => (
<Cell
key={`cell-${index}`}
index={index}
size={size}
values={values}
progress={progress}
/>
))}
{markers}
</Svg>
</AnimatedContainer>
</GestureDetector>
</View>
);
};

View File

@@ -0,0 +1,54 @@
import { View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { valueToColor } from '@/utils/colorMap';
type LegendStop = {
label: string;
color: string;
};
const LEGEND_STOPS: LegendStop[] = [
{ label: 'Quiet', color: colorToRgba(0) },
{ label: 'Low', color: colorToRgba(0.25) },
{ label: 'Medium', color: colorToRgba(0.5) },
{ label: 'High', color: colorToRgba(0.75) },
{ label: 'Active', color: colorToRgba(1) },
];
function colorToRgba(value: number): string {
const [r, g, b] = valueToColor(value);
return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 1)`;
}
export const ZoneLegend = () => {
return (
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: spacing.md }}>
{LEGEND_STOPS.map((stop) => (
<View
key={stop.label}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
}}
>
<View
style={{
width: 14,
height: 14,
borderRadius: 3,
backgroundColor: stop.color,
borderColor: colors.border,
borderWidth: 1,
}}
/>
<ThemedText preset="bodySm" style={{ color: colors.textSecondary }}>
{stop.label}
</ThemedText>
</View>
))}
</View>
);
};

View File

@@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { ScrollView, useWindowDimensions, View } from 'react-native';
import { ConnectionBanner } from '@/components/ConnectionBanner';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { usePoseStore } from '@/stores/poseStore';
import { type ConnectionStatus } from '@/types/sensing';
import { useOccupancyGrid } from './useOccupancyGrid';
import { FloorPlanSvg } from './FloorPlanSvg';
import { ZoneLegend } from './ZoneLegend';
const getLastUpdateSeconds = (timestamp?: number): string => {
if (!timestamp) {
return 'N/A';
}
const ageMs = Date.now() - timestamp;
const secs = Math.max(0, ageMs / 1000);
return `${secs.toFixed(1)}s`;
};
const resolveBannerState = (status: ConnectionStatus): 'connected' | 'simulated' | 'disconnected' => {
if (status === 'connecting') {
return 'disconnected';
}
return status;
};
export const ZonesScreen = () => {
const connectionStatus = usePoseStore((state) => state.connectionStatus);
const lastFrame = usePoseStore((state) => state.lastFrame);
const signalField = usePoseStore((state) => state.signalField);
const { gridValues, personPositions } = useOccupancyGrid(signalField);
const { width } = useWindowDimensions();
const mapSize = useMemo(() => Math.max(240, Math.min(width - spacing.md * 2, 520)), [width]);
return (
<ThemedView style={{ flex: 1, backgroundColor: colors.bg }}>
<ScrollView contentContainerStyle={{ padding: spacing.md, paddingBottom: spacing.xxl }}>
<ConnectionBanner status={resolveBannerState(connectionStatus)} />
<View
style={{
marginTop: 28,
marginBottom: spacing.md,
}}
>
<ThemedText preset="labelLg" style={{ color: colors.textSecondary, marginBottom: 8 }}>
Floor Plan Occupancy Heatmap
</ThemedText>
</View>
<FloorPlanSvg
gridValues={gridValues}
personPositions={personPositions}
size={mapSize}
style={{ alignSelf: 'center' }}
/>
<ZoneLegend />
<View
style={{
marginTop: spacing.md,
flexDirection: 'row',
justifyContent: 'space-between',
gap: spacing.md,
}}
>
<ThemedText preset="bodyMd">Occupancy: {personPositions.length} persons detected</ThemedText>
<ThemedText preset="bodyMd">Last update: {getLastUpdateSeconds(lastFrame?.timestamp)}</ThemedText>
</View>
</ScrollView>
</ThemedView>
);
};
export default ZonesScreen;

View File

@@ -0,0 +1,109 @@
import { useMemo } from 'react';
import type { Classification, SignalField } from '@/types/sensing';
import { usePoseStore } from '@/stores/poseStore';
const GRID_SIZE = 20;
const CELL_COUNT = GRID_SIZE * GRID_SIZE;
type Point = {
x: number;
y: number;
};
const clamp01 = (value: number): number => {
if (Number.isNaN(value)) {
return 0;
}
return Math.max(0, Math.min(1, value));
};
const parseNumber = (value: unknown): number | null => {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
};
const parsePoint = (value: unknown): Point | null => {
if (!value || typeof value !== 'object') {
return null;
}
const record = value as Record<string, unknown>;
const x = parseNumber(record.x);
const y = parseNumber(record.y);
if (x === null || y === null) {
return null;
}
return {
x,
y,
};
};
const collectPositions = (value: unknown): Point[] => {
if (!Array.isArray(value)) {
return [];
}
return value
.map((entry) => parsePoint(entry))
.filter((point): point is Point => point !== null)
.map((point) => ({
x: point.x,
y: point.y,
}));
};
const readClassificationPositions = (classification: Classification | undefined): Point[] => {
const source = classification as unknown as Record<string, unknown>;
return (
collectPositions(source?.persons) ??
collectPositions(source?.personPositions) ??
collectPositions(source?.positions) ??
[]
);
};
export const useOccupancyGrid = (signalField: SignalField | null): { gridValues: number[]; personPositions: Point[] } => {
const classification = usePoseStore((state) => state.classification) as Classification | undefined;
const gridValues = useMemo(() => {
const sourceValues = signalField?.values;
if (!sourceValues || sourceValues.length === 0) {
return new Array(CELL_COUNT).fill(0);
}
const normalized = new Array(CELL_COUNT).fill(0);
const sourceLength = Math.min(CELL_COUNT, sourceValues.length);
for (let i = 0; i < sourceLength; i += 1) {
const value = parseNumber(sourceValues[i]);
normalized[i] = clamp01(value ?? 0);
}
return normalized;
}, [signalField?.values]);
const personPositions = useMemo(() => {
const positions = readClassificationPositions(classification);
if (positions.length > 0) {
return positions
.map(({ x, y }) => ({
x: Math.max(0, Math.min(GRID_SIZE - 1, x)),
y: Math.max(0, Math.min(GRID_SIZE - 1, y)),
}))
.slice(0, 16);
}
return [] as Point[];
}, [classification]);
return {
gridValues,
personPositions,
};
};