mirror of
https://github.com/ruvnet/RuView
synced 2026-07-30 18:41: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,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { render, type RenderOptions } from '@testing-library/react-native';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { ThemeProvider } from '@/theme/ThemeContext';
|
||||
|
||||
type TestProvidersProps = PropsWithChildren<object>;
|
||||
|
||||
const TestProviders = ({ children }: TestProvidersProps) => (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
|
||||
const TestProvidersWithNavigation = ({ children }: TestProvidersProps) => (
|
||||
<TestProviders>
|
||||
<NavigationContainer>{children}</NavigationContainer>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
interface RenderWithProvidersOptions extends Omit<RenderOptions, 'wrapper'> {
|
||||
withNavigation?: boolean;
|
||||
}
|
||||
|
||||
export const renderWithProviders = (
|
||||
ui: React.ReactElement,
|
||||
{ withNavigation, ...options }: RenderWithProvidersOptions = {},
|
||||
) => {
|
||||
return render(ui, {
|
||||
...options,
|
||||
wrapper: withNavigation ? TestProvidersWithNavigation : TestProviders,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('placeholder', () => {
|
||||
it('passes', () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,585 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
/>
|
||||
<title>WiFi DensePose Splat Viewer</title>
|
||||
<style>
|
||||
html,
|
||||
body,
|
||||
#gaussian-splat-root {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #0a0e1a;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
#gaussian-splat-root {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="gaussian-splat-root"></div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r165/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.165.0/examples/js/controls/OrbitControls.js"></script>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const postMessageToRN = (message) => {
|
||||
if (!window.ReactNativeWebView || typeof window.ReactNativeWebView.postMessage !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Failed to post RN message', error);
|
||||
}
|
||||
};
|
||||
|
||||
const postError = (message) => {
|
||||
postMessageToRN({
|
||||
type: 'ERROR',
|
||||
payload: {
|
||||
message: typeof message === 'string' ? message : 'Unknown bridge error',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Use global THREE from CDN
|
||||
const getThree = () => window.THREE;
|
||||
|
||||
// ---- Custom Splat Shaders --------------------------------------------
|
||||
|
||||
const SPLAT_VERTEX = `
|
||||
attribute float splatSize;
|
||||
attribute vec3 splatColor;
|
||||
attribute float splatOpacity;
|
||||
|
||||
varying vec3 vColor;
|
||||
varying float vOpacity;
|
||||
|
||||
void main() {
|
||||
vColor = splatColor;
|
||||
vOpacity = splatOpacity;
|
||||
|
||||
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
|
||||
gl_PointSize = splatSize * (300.0 / -mvPosition.z);
|
||||
gl_Position = projectionMatrix * mvPosition;
|
||||
}
|
||||
`;
|
||||
|
||||
const SPLAT_FRAGMENT = `
|
||||
varying vec3 vColor;
|
||||
varying float vOpacity;
|
||||
|
||||
void main() {
|
||||
// Circular soft-edge disc
|
||||
float dist = length(gl_PointCoord - vec2(0.5));
|
||||
if (dist > 0.5) discard;
|
||||
float alpha = smoothstep(0.5, 0.2, dist) * vOpacity;
|
||||
gl_FragColor = vec4(vColor, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
// ---- Color helpers ---------------------------------------------------
|
||||
|
||||
/** Map a scalar 0-1 to blue -> green -> red gradient */
|
||||
function valueToColor(v) {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
// blue(0) -> cyan(0.25) -> green(0.5) -> yellow(0.75) -> red(1)
|
||||
let r;
|
||||
let g;
|
||||
let b;
|
||||
if (clamped < 0.5) {
|
||||
const t = clamped * 2;
|
||||
r = 0;
|
||||
g = t;
|
||||
b = 1 - t;
|
||||
} else {
|
||||
const t = (clamped - 0.5) * 2;
|
||||
r = t;
|
||||
g = 1 - t;
|
||||
b = 0;
|
||||
}
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
// ---- GaussianSplatRenderer -------------------------------------------
|
||||
|
||||
class GaussianSplatRenderer {
|
||||
/** @param {HTMLElement} container - DOM element to attach the renderer to */
|
||||
constructor(container, opts = {}) {
|
||||
const THREE = getThree();
|
||||
if (!THREE) {
|
||||
throw new Error('Three.js not loaded');
|
||||
}
|
||||
|
||||
this.container = container;
|
||||
this.width = opts.width || container.clientWidth || 800;
|
||||
this.height = opts.height || 500;
|
||||
|
||||
// Scene
|
||||
this.scene = new THREE.Scene();
|
||||
this.scene.background = new THREE.Color(0x0a0e1a);
|
||||
|
||||
// Camera — perspective looking down at the room
|
||||
this.camera = new THREE.PerspectiveCamera(45, this.width / this.height, 0.1, 200);
|
||||
this.camera.position.set(0, 10, 12);
|
||||
this.camera.lookAt(0, 0, 0);
|
||||
|
||||
// Renderer
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
this.renderer.setSize(this.width, this.height);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
container.appendChild(this.renderer.domElement);
|
||||
|
||||
// Lights
|
||||
const ambient = new THREE.AmbientLight(0x9ec7ff, 0.35);
|
||||
this.scene.add(ambient);
|
||||
|
||||
const directional = new THREE.DirectionalLight(0x9ec7ff, 0.65);
|
||||
directional.position.set(4, 10, 6);
|
||||
directional.castShadow = false;
|
||||
this.scene.add(directional);
|
||||
|
||||
// Grid & room
|
||||
this._createRoom(THREE);
|
||||
|
||||
// Signal field splats (20x20 = 400 points on the floor plane)
|
||||
this.gridSize = 20;
|
||||
this._createFieldSplats(THREE);
|
||||
|
||||
// Node markers (ESP32 / router positions)
|
||||
this._createNodeMarkers(THREE);
|
||||
|
||||
// Body disruption blob
|
||||
this._createBodyBlob(THREE);
|
||||
|
||||
// Orbit controls for drag + pinch zoom
|
||||
this.controls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
|
||||
this.controls.target.set(0, 0, 0);
|
||||
this.controls.minDistance = 6;
|
||||
this.controls.maxDistance = 40;
|
||||
this.controls.enableDamping = true;
|
||||
this.controls.dampingFactor = 0.08;
|
||||
this.controls.update();
|
||||
|
||||
// Animation state
|
||||
this._animFrame = null;
|
||||
this._lastData = null;
|
||||
this._fpsFrames = [];
|
||||
this._lastFpsReport = 0;
|
||||
|
||||
// Start render loop
|
||||
this._animate();
|
||||
}
|
||||
|
||||
// ---- Scene setup ---------------------------------------------------
|
||||
|
||||
_createRoom(THREE) {
|
||||
// Floor grid (on y = 0), 20 units
|
||||
const grid = new THREE.GridHelper(20, 20, 0x1a3a4a, 0x0d1f28);
|
||||
grid.position.y = 0;
|
||||
this.scene.add(grid);
|
||||
|
||||
// Room boundary wireframe
|
||||
const boxGeo = new THREE.BoxGeometry(20, 6, 20);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
const line = new THREE.LineSegments(
|
||||
edges,
|
||||
new THREE.LineBasicMaterial({ color: 0x1a4a5a, opacity: 0.3, transparent: true }),
|
||||
);
|
||||
line.position.y = 3;
|
||||
this.scene.add(line);
|
||||
}
|
||||
|
||||
_createFieldSplats(THREE) {
|
||||
const count = this.gridSize * this.gridSize;
|
||||
|
||||
const positions = new Float32Array(count * 3);
|
||||
const sizes = new Float32Array(count);
|
||||
const colors = new Float32Array(count * 3);
|
||||
const opacities = new Float32Array(count);
|
||||
|
||||
// Lay splats on the floor plane (y = 0.05 to sit just above grid)
|
||||
for (let iz = 0; iz < this.gridSize; iz++) {
|
||||
for (let ix = 0; ix < this.gridSize; ix++) {
|
||||
const idx = iz * this.gridSize + ix;
|
||||
positions[idx * 3 + 0] = (ix - this.gridSize / 2) + 0.5; // x
|
||||
positions[idx * 3 + 1] = 0.05; // y
|
||||
positions[idx * 3 + 2] = (iz - this.gridSize / 2) + 0.5; // z
|
||||
|
||||
sizes[idx] = 1.5;
|
||||
colors[idx * 3] = 0.1;
|
||||
colors[idx * 3 + 1] = 0.2;
|
||||
colors[idx * 3 + 2] = 0.6;
|
||||
opacities[idx] = 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('splatSize', new THREE.BufferAttribute(sizes, 1));
|
||||
geo.setAttribute('splatColor', new THREE.BufferAttribute(colors, 3));
|
||||
geo.setAttribute('splatOpacity', new THREE.BufferAttribute(opacities, 1));
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: SPLAT_VERTEX,
|
||||
fragmentShader: SPLAT_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
this.fieldPoints = new THREE.Points(geo, mat);
|
||||
this.scene.add(this.fieldPoints);
|
||||
}
|
||||
|
||||
_createNodeMarkers(THREE) {
|
||||
// Router at center — green sphere
|
||||
const routerGeo = new THREE.SphereGeometry(0.3, 16, 16);
|
||||
const routerMat = new THREE.MeshBasicMaterial({ color: 0x00ff88, transparent: true, opacity: 0.8 });
|
||||
this.routerMarker = new THREE.Mesh(routerGeo, routerMat);
|
||||
this.routerMarker.position.set(0, 0.5, 0);
|
||||
this.scene.add(this.routerMarker);
|
||||
|
||||
// ESP32 node — cyan sphere (default position, updated from data)
|
||||
const nodeGeo = new THREE.SphereGeometry(0.25, 16, 16);
|
||||
const nodeMat = new THREE.MeshBasicMaterial({ color: 0x00ccff, transparent: true, opacity: 0.8 });
|
||||
this.nodeMarker = new THREE.Mesh(nodeGeo, nodeMat);
|
||||
this.nodeMarker.position.set(2, 0.5, 1.5);
|
||||
this.scene.add(this.nodeMarker);
|
||||
}
|
||||
|
||||
_createBodyBlob(THREE) {
|
||||
// A cluster of splats representing body disruption
|
||||
const count = 64;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const sizes = new Float32Array(count);
|
||||
const colors = new Float32Array(count * 3);
|
||||
const opacities = new Float32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Random sphere distribution
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = Math.random() * 1.5;
|
||||
positions[i * 3] = r * Math.sin(phi) * Math.cos(theta);
|
||||
positions[i * 3 + 1] = r * Math.cos(phi) + 2;
|
||||
positions[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta);
|
||||
|
||||
sizes[i] = 2 + Math.random() * 3;
|
||||
colors[i * 3] = 0.2;
|
||||
colors[i * 3 + 1] = 0.8;
|
||||
colors[i * 3 + 2] = 0.3;
|
||||
opacities[i] = 0.0; // hidden until presence detected
|
||||
}
|
||||
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
geo.setAttribute('splatSize', new THREE.BufferAttribute(sizes, 1));
|
||||
geo.setAttribute('splatColor', new THREE.BufferAttribute(colors, 3));
|
||||
geo.setAttribute('splatOpacity', new THREE.BufferAttribute(opacities, 1));
|
||||
|
||||
const mat = new THREE.ShaderMaterial({
|
||||
vertexShader: SPLAT_VERTEX,
|
||||
fragmentShader: SPLAT_FRAGMENT,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
this.bodyBlob = new THREE.Points(geo, mat);
|
||||
this.scene.add(this.bodyBlob);
|
||||
}
|
||||
|
||||
// ---- Data update --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Update the visualization with new sensing data.
|
||||
* @param {object} data - sensing_update JSON from ws_server
|
||||
*/
|
||||
update(data) {
|
||||
this._lastData = data;
|
||||
if (!data) return;
|
||||
|
||||
const features = data.features || {};
|
||||
const classification = data.classification || {};
|
||||
const signalField = data.signal_field || {};
|
||||
const nodes = data.nodes || [];
|
||||
|
||||
// -- Update signal field splats ------------------------------------
|
||||
if (signalField.values && this.fieldPoints) {
|
||||
const geo = this.fieldPoints.geometry;
|
||||
const clr = geo.attributes.splatColor.array;
|
||||
const sizes = geo.attributes.splatSize.array;
|
||||
const opac = geo.attributes.splatOpacity.array;
|
||||
const vals = signalField.values;
|
||||
const count = Math.min(vals.length, this.gridSize * this.gridSize);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const v = vals[i];
|
||||
const [r, g, b] = valueToColor(v);
|
||||
clr[i * 3] = r;
|
||||
clr[i * 3 + 1] = g;
|
||||
clr[i * 3 + 2] = b;
|
||||
sizes[i] = 1.0 + v * 4.0;
|
||||
opac[i] = 0.1 + v * 0.6;
|
||||
}
|
||||
|
||||
geo.attributes.splatColor.needsUpdate = true;
|
||||
geo.attributes.splatSize.needsUpdate = true;
|
||||
geo.attributes.splatOpacity.needsUpdate = true;
|
||||
}
|
||||
|
||||
// -- Update body blob ----------------------------------------------
|
||||
if (this.bodyBlob) {
|
||||
const bGeo = this.bodyBlob.geometry;
|
||||
const bOpac = bGeo.attributes.splatOpacity.array;
|
||||
const bClr = bGeo.attributes.splatColor.array;
|
||||
const bSize = bGeo.attributes.splatSize.array;
|
||||
|
||||
const presence = classification.presence || false;
|
||||
const motionLvl = classification.motion_level || 'absent';
|
||||
const confidence = classification.confidence || 0;
|
||||
const breathing = features.breathing_band_power || 0;
|
||||
|
||||
// Breathing pulsation
|
||||
const breathPulse = 1.0 + Math.sin(Date.now() * 0.004) * Math.min(breathing * 3, 0.4);
|
||||
|
||||
for (let i = 0; i < bOpac.length; i++) {
|
||||
if (presence) {
|
||||
bOpac[i] = confidence * 0.4;
|
||||
|
||||
// Color by motion level
|
||||
if (motionLvl === 'active') {
|
||||
bClr[i * 3] = 1.0;
|
||||
bClr[i * 3 + 1] = 0.2;
|
||||
bClr[i * 3 + 2] = 0.1;
|
||||
} else {
|
||||
bClr[i * 3] = 0.1;
|
||||
bClr[i * 3 + 1] = 0.8;
|
||||
bClr[i * 3 + 2] = 0.4;
|
||||
}
|
||||
|
||||
bSize[i] = (2 + Math.random() * 2) * breathPulse;
|
||||
} else {
|
||||
bOpac[i] = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
bGeo.attributes.splatOpacity.needsUpdate = true;
|
||||
bGeo.attributes.splatColor.needsUpdate = true;
|
||||
bGeo.attributes.splatSize.needsUpdate = true;
|
||||
}
|
||||
|
||||
// -- Update node positions -----------------------------------------
|
||||
if (nodes.length > 0 && nodes[0].position && this.nodeMarker) {
|
||||
const pos = nodes[0].position;
|
||||
this.nodeMarker.position.set(pos[0], 0.5, pos[2]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Render loop -------------------------------------------------
|
||||
|
||||
_animate() {
|
||||
this._animFrame = requestAnimationFrame(() => this._animate());
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
// Gentle router glow pulse
|
||||
if (this.routerMarker) {
|
||||
const pulse = 0.6 + 0.3 * Math.sin(now * 0.003);
|
||||
this.routerMarker.material.opacity = pulse;
|
||||
}
|
||||
|
||||
this.controls.update();
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
|
||||
this._fpsFrames.push(now);
|
||||
while (this._fpsFrames.length > 0 && this._fpsFrames[0] < now - 1000) {
|
||||
this._fpsFrames.shift();
|
||||
}
|
||||
|
||||
if (now - this._lastFpsReport >= 1000) {
|
||||
const fps = this._fpsFrames.length;
|
||||
this._lastFpsReport = now;
|
||||
postMessageToRN({
|
||||
type: 'FPS_TICK',
|
||||
payload: { fps },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Resize / cleanup --------------------------------------------
|
||||
|
||||
resize(width, height) {
|
||||
if (!width || !height) return;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._animFrame) {
|
||||
cancelAnimationFrame(this._animFrame);
|
||||
}
|
||||
|
||||
this.controls?.dispose();
|
||||
this.renderer.dispose();
|
||||
if (this.renderer.domElement.parentNode) {
|
||||
this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Expose renderer constructor for debugging/interop
|
||||
window.GaussianSplatRenderer = GaussianSplatRenderer;
|
||||
|
||||
let renderer = null;
|
||||
let pendingFrame = null;
|
||||
let pendingResize = null;
|
||||
|
||||
const postSafeReady = () => {
|
||||
postMessageToRN({ type: 'READY' });
|
||||
};
|
||||
|
||||
const routeMessage = (event) => {
|
||||
let raw = event.data;
|
||||
if (typeof raw === 'object' && raw != null && 'data' in raw) {
|
||||
raw = raw.data;
|
||||
}
|
||||
|
||||
let message = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
postError('Failed to parse RN message payload');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!message || typeof message !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'FRAME_UPDATE') {
|
||||
const payload = message.payload || null;
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderer) {
|
||||
pendingFrame = payload;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
renderer.update(payload);
|
||||
} catch (error) {
|
||||
postError((error && error.message) || 'Failed to update frame');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'RESIZE') {
|
||||
const dims = message.payload || {};
|
||||
const w = Number(dims.width);
|
||||
const h = Number(dims.height);
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h) || !w || !h) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!renderer) {
|
||||
pendingResize = { width: w, height: h };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
renderer.resize(w, h);
|
||||
} catch (error) {
|
||||
postError((error && error.message) || 'Failed to resize renderer');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'DISPOSE') {
|
||||
if (!renderer) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
renderer.dispose();
|
||||
} catch (error) {
|
||||
postError((error && error.message) || 'Failed to dispose renderer');
|
||||
}
|
||||
renderer = null;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const buildRenderer = () => {
|
||||
const container = document.getElementById('gaussian-splat-root');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
renderer = new GaussianSplatRenderer(container, {
|
||||
width: container.clientWidth || window.innerWidth,
|
||||
height: container.clientHeight || window.innerHeight,
|
||||
});
|
||||
|
||||
if (pendingFrame) {
|
||||
renderer.update(pendingFrame);
|
||||
pendingFrame = null;
|
||||
}
|
||||
|
||||
if (pendingResize) {
|
||||
renderer.resize(pendingResize.width, pendingResize.height);
|
||||
pendingResize = null;
|
||||
}
|
||||
|
||||
postSafeReady();
|
||||
} catch (error) {
|
||||
renderer = null;
|
||||
postError((error && error.message) || 'Failed to initialize renderer');
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', buildRenderer);
|
||||
} else {
|
||||
buildRenderer();
|
||||
}
|
||||
|
||||
window.addEventListener('message', routeMessage);
|
||||
window.addEventListener('resize', () => {
|
||||
if (!renderer) {
|
||||
pendingResize = {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
return;
|
||||
}
|
||||
renderer.resize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { ThemedText } from './ThemedText';
|
||||
|
||||
type ConnectionState = 'connected' | 'simulated' | 'disconnected';
|
||||
|
||||
type ConnectionBannerProps = {
|
||||
status: ConnectionState;
|
||||
};
|
||||
|
||||
const resolveState = (status: ConnectionState) => {
|
||||
if (status === 'connected') {
|
||||
return {
|
||||
label: 'LIVE STREAM',
|
||||
backgroundColor: '#0F6B2A',
|
||||
textColor: '#E2FFEA',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'disconnected') {
|
||||
return {
|
||||
label: 'DISCONNECTED',
|
||||
backgroundColor: '#8A1E2A',
|
||||
textColor: '#FFE3E7',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: 'SIMULATED DATA',
|
||||
backgroundColor: '#9A5F0C',
|
||||
textColor: '#FFF3E1',
|
||||
};
|
||||
};
|
||||
|
||||
export const ConnectionBanner = ({ status }: ConnectionBannerProps) => {
|
||||
const state = resolveState(status);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.banner,
|
||||
{
|
||||
backgroundColor: state.backgroundColor,
|
||||
borderBottomColor: state.textColor,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText preset="labelMd" style={[styles.text, { color: state.textColor }]}>
|
||||
{state.label}
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
zIndex: 100,
|
||||
paddingVertical: 6,
|
||||
borderBottomWidth: 2,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
text: {
|
||||
letterSpacing: 2,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Button, StyleSheet, View } from 'react-native';
|
||||
import { ThemedText } from './ThemedText';
|
||||
import { ThemedView } from './ThemedView';
|
||||
|
||||
type ErrorBoundaryProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type ErrorBoundaryState = {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error', error, errorInfo);
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({ hasError: false, error: undefined });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedText preset="displayMd">Something went wrong</ThemedText>
|
||||
<ThemedText preset="bodySm" style={styles.message}>
|
||||
{this.state.error?.message ?? 'An unexpected error occurred.'}
|
||||
</ThemedText>
|
||||
<View style={styles.buttonWrap}>
|
||||
<Button title="Retry" onPress={this.handleRetry} />
|
||||
</View>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
gap: 12,
|
||||
},
|
||||
message: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
buttonWrap: {
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Animated, { interpolateColor, useAnimatedProps, useSharedValue, withSpring } from 'react-native-reanimated';
|
||||
import Svg, { Circle, G, Text as SvgText } from 'react-native-svg';
|
||||
|
||||
type GaugeArcProps = {
|
||||
value: number;
|
||||
min?: number;
|
||||
max: number;
|
||||
label: string;
|
||||
unit: string;
|
||||
color: string;
|
||||
colorTo?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const AnimatedCircle = Animated.createAnimatedComponent(Circle);
|
||||
|
||||
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value));
|
||||
|
||||
export const GaugeArc = ({ value, min = 0, max, label, unit, color, colorTo, size = 140 }: GaugeArcProps) => {
|
||||
const radius = (size - 20) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const arcLength = circumference * 0.75;
|
||||
const strokeWidth = 12;
|
||||
const progress = useSharedValue(0);
|
||||
|
||||
const normalized = useMemo(() => {
|
||||
const span = max - min;
|
||||
const safeSpan = span > 0 ? span : 1;
|
||||
return clamp((value - min) / safeSpan, 0, 1);
|
||||
}, [value, min, max]);
|
||||
|
||||
const displayValue = useMemo(() => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return '--';
|
||||
}
|
||||
return `${Math.max(min, Math.min(max, value)).toFixed(1)} ${unit}`;
|
||||
}, [max, min, unit, value]);
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withSpring(normalized, {
|
||||
damping: 16,
|
||||
stiffness: 140,
|
||||
mass: 1,
|
||||
});
|
||||
}, [normalized, progress]);
|
||||
|
||||
const animatedStroke = useAnimatedProps(() => {
|
||||
const dashOffset = arcLength - arcLength * progress.value;
|
||||
const strokeColor = colorTo ? interpolateColor(progress.value, [0, 1], [color, colorTo]) : color;
|
||||
|
||||
return {
|
||||
strokeDashoffset: dashOffset,
|
||||
stroke: strokeColor,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<G transform={`rotate(-135 ${size / 2} ${size / 2})`}>
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke="#1E293B"
|
||||
fill="none"
|
||||
strokeDasharray={`${arcLength} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<AnimatedCircle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
strokeWidth={strokeWidth}
|
||||
stroke={color}
|
||||
fill="none"
|
||||
strokeDasharray={`${arcLength} ${circumference}`}
|
||||
strokeLinecap="round"
|
||||
animatedProps={animatedStroke}
|
||||
/>
|
||||
</G>
|
||||
<SvgText
|
||||
x={size / 2}
|
||||
y={size / 2 - 8}
|
||||
fill="#E2E8F0"
|
||||
fontSize={Math.round(size * 0.16)}
|
||||
fontFamily="Courier New"
|
||||
fontWeight="700"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{displayValue}
|
||||
</SvgText>
|
||||
<SvgText
|
||||
x={size / 2}
|
||||
y={size / 2 + 18}
|
||||
fill="#94A3B8"
|
||||
fontSize={Math.round(size * 0.085)}
|
||||
fontFamily="Courier New"
|
||||
textAnchor="middle"
|
||||
letterSpacing="0.6"
|
||||
>
|
||||
{label}
|
||||
</SvgText>
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StyleSheet, ViewStyle } from 'react-native';
|
||||
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
type LoadingSpinnerProps = {
|
||||
size?: number;
|
||||
color?: string;
|
||||
style?: ViewStyle;
|
||||
};
|
||||
|
||||
export const LoadingSpinner = ({ size = 36, color = colors.accent, style }: LoadingSpinnerProps) => {
|
||||
const rotation = useSharedValue(0);
|
||||
const strokeWidth = Math.max(4, size * 0.14);
|
||||
const center = size / 2;
|
||||
const radius = center - strokeWidth;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
useEffect(() => {
|
||||
rotation.value = withRepeat(withTiming(360, { duration: 900, easing: Easing.linear }), -1);
|
||||
}, [rotation]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ rotateZ: `${rotation.value}deg` }],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { width: size, height: size }, style, animatedStyle]} pointerEvents="none">
|
||||
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke="rgba(255,255,255,0.2)"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${circumference * 0.3} ${circumference * 0.7}`}
|
||||
strokeDashoffset={circumference * 0.2}
|
||||
/>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { ThemedText } from './ThemedText';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
type Mode = 'CSI' | 'RSSI' | 'SIM' | 'LIVE';
|
||||
|
||||
const modeStyle: Record<
|
||||
Mode,
|
||||
{
|
||||
background: string;
|
||||
border: string;
|
||||
color: string;
|
||||
}
|
||||
> = {
|
||||
CSI: {
|
||||
background: 'rgba(50, 184, 198, 0.25)',
|
||||
border: colors.accent,
|
||||
color: colors.accent,
|
||||
},
|
||||
RSSI: {
|
||||
background: 'rgba(255, 165, 2, 0.2)',
|
||||
border: colors.warn,
|
||||
color: colors.warn,
|
||||
},
|
||||
SIM: {
|
||||
background: 'rgba(255, 71, 87, 0.18)',
|
||||
border: colors.simulated,
|
||||
color: colors.simulated,
|
||||
},
|
||||
LIVE: {
|
||||
background: 'rgba(46, 213, 115, 0.18)',
|
||||
border: colors.connected,
|
||||
color: colors.connected,
|
||||
},
|
||||
};
|
||||
|
||||
type ModeBadgeProps = {
|
||||
mode: Mode;
|
||||
};
|
||||
|
||||
export const ModeBadge = ({ mode }: ModeBadgeProps) => {
|
||||
const style = modeStyle[mode];
|
||||
|
||||
return (
|
||||
<ThemedText
|
||||
preset="labelMd"
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
backgroundColor: style.background,
|
||||
borderColor: style.border,
|
||||
color: style.color,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{mode}
|
||||
</ThemedText>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
badge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
overflow: 'hidden',
|
||||
letterSpacing: 1,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { StyleProp, ViewStyle } from 'react-native';
|
||||
import Animated, { interpolateColor, useAnimatedProps, useSharedValue, withTiming, type SharedValue } from 'react-native-reanimated';
|
||||
import Svg, { Circle, G, Rect } from 'react-native-svg';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
type OccupancyGridProps = {
|
||||
values: number[];
|
||||
personPositions?: Point[];
|
||||
size?: number;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
const GRID_DIMENSION = 20;
|
||||
const CELLS = GRID_DIMENSION * GRID_DIMENSION;
|
||||
|
||||
const toColor = (value: number): string => {
|
||||
const clamped = Math.max(0, Math.min(1, value));
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
|
||||
if (clamped < 0.5) {
|
||||
const t = clamped * 2;
|
||||
r = Math.round(255 * 0);
|
||||
g = Math.round(255 * t);
|
||||
b = Math.round(255 * (1 - t));
|
||||
} else {
|
||||
const t = (clamped - 0.5) * 2;
|
||||
r = Math.round(255 * t);
|
||||
g = Math.round(255 * (1 - t));
|
||||
b = 0;
|
||||
}
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
};
|
||||
|
||||
const AnimatedRect = Animated.createAnimatedComponent(Rect);
|
||||
|
||||
const normalizeValues = (values: number[]) => {
|
||||
const normalized = new Array(CELLS).fill(0);
|
||||
for (let i = 0; i < CELLS; i += 1) {
|
||||
const value = values?.[i] ?? 0;
|
||||
normalized[i] = Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
type CellProps = {
|
||||
index: number;
|
||||
size: number;
|
||||
progress: SharedValue<number>;
|
||||
previousColors: string[];
|
||||
nextColors: string[];
|
||||
};
|
||||
|
||||
const Cell = ({ index, size, progress, previousColors, nextColors }: CellProps) => {
|
||||
const col = index % GRID_DIMENSION;
|
||||
const row = Math.floor(index / GRID_DIMENSION);
|
||||
const cellSize = size / GRID_DIMENSION;
|
||||
const x = col * cellSize;
|
||||
const y = row * cellSize;
|
||||
|
||||
const animatedProps = useAnimatedProps(() => ({
|
||||
fill: interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
[previousColors[index] ?? colors.surfaceAlt, nextColors[index] ?? colors.surfaceAlt],
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<AnimatedRect
|
||||
x={x}
|
||||
y={y}
|
||||
width={cellSize}
|
||||
height={cellSize}
|
||||
rx={1}
|
||||
animatedProps={animatedProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const OccupancyGrid = ({
|
||||
values,
|
||||
personPositions = [],
|
||||
size = 320,
|
||||
style,
|
||||
}: OccupancyGridProps) => {
|
||||
const normalizedValues = useMemo(() => normalizeValues(values), [values]);
|
||||
const previousColors = useRef<string[]>(normalizedValues.map(toColor));
|
||||
const nextColors = useRef<string[]>(normalizedValues.map(toColor));
|
||||
const progress = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
const next = normalizeValues(values);
|
||||
previousColors.current = normalizedValues.map(toColor);
|
||||
nextColors.current = next.map(toColor);
|
||||
progress.value = 0;
|
||||
progress.value = withTiming(1, { duration: 500 });
|
||||
}, [values, normalizedValues, progress]);
|
||||
|
||||
const markers = useMemo(() => {
|
||||
const cellSize = size / GRID_DIMENSION;
|
||||
return personPositions.map(({ x, y }, idx) => {
|
||||
const clampedX = Math.max(0, Math.min(GRID_DIMENSION - 1, Math.round(x)));
|
||||
const clampedY = Math.max(0, Math.min(GRID_DIMENSION - 1, Math.round(y)));
|
||||
const cx = (clampedX + 0.5) * cellSize;
|
||||
const cy = (clampedY + 0.5) * cellSize;
|
||||
const markerRadius = Math.max(3, cellSize * 0.25);
|
||||
return (
|
||||
<Circle
|
||||
key={`person-${idx}`}
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={markerRadius}
|
||||
fill={colors.accent}
|
||||
stroke={colors.textPrimary}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [personPositions, size]);
|
||||
|
||||
return (
|
||||
<Svg width={size} height={size} style={style} viewBox={`0 0 ${size} ${size}`}>
|
||||
<G>
|
||||
{Array.from({ length: CELLS }).map((_, index) => (
|
||||
<Cell
|
||||
key={index}
|
||||
index={index}
|
||||
size={size}
|
||||
progress={progress}
|
||||
previousColors={previousColors.current}
|
||||
nextColors={nextColors.current}
|
||||
/>
|
||||
))}
|
||||
</G>
|
||||
{markers}
|
||||
</Svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { ThemedText } from './ThemedText';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
type SignalBarProps = {
|
||||
value: number;
|
||||
label: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const clamp01 = (value: number) => Math.max(0, Math.min(1, value));
|
||||
|
||||
export const SignalBar = ({ value, label, color = colors.accent }: SignalBarProps) => {
|
||||
const progress = useSharedValue(clamp01(value));
|
||||
|
||||
useEffect(() => {
|
||||
progress.value = withTiming(clamp01(value), { duration: 250 });
|
||||
}, [value, progress]);
|
||||
|
||||
const animatedFill = useAnimatedStyle(() => ({
|
||||
width: `${progress.value * 100}%`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ThemedText preset="bodySm" style={styles.label}>
|
||||
{label}
|
||||
</ThemedText>
|
||||
<View style={styles.track}>
|
||||
<Animated.View style={[styles.fill, { backgroundColor: color }, animatedFill]} />
|
||||
</View>
|
||||
<ThemedText preset="bodySm" style={styles.percent}>
|
||||
{Math.round(clamp01(value) * 100)}%
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
track: {
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
fill: {
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
},
|
||||
percent: {
|
||||
textAlign: 'right',
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from 'react';
|
||||
import { View, ViewStyle } from 'react-native';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
type SparklineChartProps = {
|
||||
data: number[];
|
||||
color?: string;
|
||||
height?: number;
|
||||
style?: ViewStyle;
|
||||
};
|
||||
|
||||
const defaultHeight = 72;
|
||||
|
||||
export const SparklineChart = ({
|
||||
data,
|
||||
color = colors.accent,
|
||||
height = defaultHeight,
|
||||
style,
|
||||
}: SparklineChartProps) => {
|
||||
const normalizedData = data.length > 0 ? data : [0];
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
normalizedData.map((value, index) => ({
|
||||
x: index,
|
||||
y: value,
|
||||
})),
|
||||
[normalizedData],
|
||||
);
|
||||
|
||||
const yValues = normalizedData.map((value) => Number(value) || 0);
|
||||
const yMin = Math.min(...yValues);
|
||||
const yMax = Math.max(...yValues);
|
||||
const yPadding = yMax - yMin === 0 ? 1 : (yMax - yMin) * 0.2;
|
||||
|
||||
return (
|
||||
<View style={style}>
|
||||
<View
|
||||
accessibilityRole="image"
|
||||
style={{
|
||||
height,
|
||||
width: '100%',
|
||||
borderRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: color,
|
||||
opacity: 0.2,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{chartData.map((point) => (
|
||||
<View key={point.x} style={{ position: 'absolute', left: `${(point.x / Math.max(normalizedData.length - 1, 1)) * 100}%` }} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react';
|
||||
import { StyleSheet, ViewStyle } from 'react-native';
|
||||
import Animated, {
|
||||
cancelAnimation,
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withSequence,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { colors } from '../theme/colors';
|
||||
|
||||
type StatusType = 'connected' | 'simulated' | 'disconnected' | 'connecting';
|
||||
|
||||
type StatusDotProps = {
|
||||
status: StatusType;
|
||||
size?: number;
|
||||
style?: ViewStyle;
|
||||
};
|
||||
|
||||
const resolveColor = (status: StatusType): string => {
|
||||
if (status === 'connecting') return colors.warn;
|
||||
return colors[status];
|
||||
};
|
||||
|
||||
export const StatusDot = ({ status, size = 10, style }: StatusDotProps) => {
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(1);
|
||||
const isConnecting = status === 'connecting';
|
||||
|
||||
useEffect(() => {
|
||||
if (isConnecting) {
|
||||
scale.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(1.35, { duration: 800, easing: Easing.out(Easing.cubic) }),
|
||||
withTiming(1, { duration: 800, easing: Easing.in(Easing.cubic) }),
|
||||
),
|
||||
-1,
|
||||
);
|
||||
opacity.value = withRepeat(
|
||||
withSequence(
|
||||
withTiming(0.4, { duration: 800, easing: Easing.out(Easing.quad) }),
|
||||
withTiming(1, { duration: 800, easing: Easing.in(Easing.quad) }),
|
||||
),
|
||||
-1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
cancelAnimation(scale);
|
||||
cancelAnimation(opacity);
|
||||
scale.value = 1;
|
||||
opacity.value = 1;
|
||||
}, [isConnecting, opacity, scale]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.dot,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundColor: resolveColor(status),
|
||||
borderRadius: size / 2,
|
||||
},
|
||||
animatedStyle,
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
dot: {
|
||||
borderRadius: 999,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ComponentPropsWithoutRef } from 'react';
|
||||
import { StyleProp, Text, TextStyle } from 'react-native';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { colors } from '../theme/colors';
|
||||
import { typography } from '../theme/typography';
|
||||
|
||||
type TextPreset = keyof typeof typography;
|
||||
type ColorKey = keyof typeof colors;
|
||||
|
||||
type ThemedTextProps = Omit<ComponentPropsWithoutRef<typeof Text>, 'style'> & {
|
||||
preset?: TextPreset;
|
||||
color?: ColorKey;
|
||||
style?: StyleProp<TextStyle>;
|
||||
};
|
||||
|
||||
export const ThemedText = ({
|
||||
preset = 'bodyMd',
|
||||
color = 'textPrimary',
|
||||
style,
|
||||
...props
|
||||
}: ThemedTextProps) => {
|
||||
const { colors, typography } = useTheme();
|
||||
|
||||
const presetStyle = (typography as Record<TextPreset, TextStyle>)[preset];
|
||||
const colorStyle = { color: colors[color] };
|
||||
|
||||
return <Text {...props} style={[presetStyle, colorStyle, style]} />;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PropsWithChildren, forwardRef } from 'react';
|
||||
import { View, ViewProps } from 'react-native';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
|
||||
type ThemedViewProps = PropsWithChildren<ViewProps>;
|
||||
|
||||
export const ThemedView = forwardRef<View, ThemedViewProps>(({ children, style, ...props }, ref) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
{...props}
|
||||
style={[
|
||||
{
|
||||
backgroundColor: colors.bg,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
export const API_ROOT = '/api/v1';
|
||||
|
||||
export const API_POSE_STATUS_PATH = '/api/v1/pose/status';
|
||||
export const API_POSE_FRAMES_PATH = '/api/v1/pose/frames';
|
||||
export const API_POSE_ZONES_PATH = '/api/v1/pose/zones';
|
||||
export const API_POSE_CURRENT_PATH = '/api/v1/pose/current';
|
||||
export const API_STREAM_STATUS_PATH = '/api/v1/stream/status';
|
||||
export const API_STREAM_POSE_PATH = '/api/v1/stream/pose';
|
||||
export const API_MAT_EVENTS_PATH = '/api/v1/mat/events';
|
||||
|
||||
export const API_HEALTH_PATH = '/health';
|
||||
export const API_HEALTH_SYSTEM_PATH = '/health/health';
|
||||
export const API_HEALTH_READY_PATH = '/health/ready';
|
||||
export const API_HEALTH_LIVE_PATH = '/health/live';
|
||||
@@ -0,0 +1,20 @@
|
||||
export const SIMULATION_TICK_INTERVAL_MS = 500;
|
||||
export const SIMULATION_GRID_SIZE = 20;
|
||||
|
||||
export const RSSI_BASE_DBM = -45;
|
||||
export const RSSI_AMPLITUDE_DBM = 3;
|
||||
|
||||
export const VARIANCE_BASE = 1.5;
|
||||
export const VARIANCE_AMPLITUDE = 1.0;
|
||||
|
||||
export const MOTION_BAND_MIN = 0.05;
|
||||
export const MOTION_BAND_AMPLITUDE = 0.15;
|
||||
export const BREATHING_BAND_MIN = 0.03;
|
||||
export const BREATHING_BAND_AMPLITUDE = 0.08;
|
||||
|
||||
export const SIGNAL_FIELD_PRESENCE_LEVEL = 0.8;
|
||||
|
||||
export const BREATHING_BPM_MIN = 12;
|
||||
export const BREATHING_BPM_MAX = 24;
|
||||
export const HEART_BPM_MIN = 58;
|
||||
export const HEART_BPM_MAX = 96;
|
||||
@@ -0,0 +1,3 @@
|
||||
export const WS_PATH = '/api/v1/stream/pose';
|
||||
export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000];
|
||||
export const MAX_RECONNECT_ATTEMPTS = 10;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from 'react';
|
||||
import { wsService } from '@/services/ws.service';
|
||||
import { usePoseStore } from '@/stores/poseStore';
|
||||
|
||||
export interface UsePoseStreamResult {
|
||||
connectionStatus: ReturnType<typeof usePoseStore.getState>['connectionStatus'];
|
||||
lastFrame: ReturnType<typeof usePoseStore.getState>['lastFrame'];
|
||||
isSimulated: boolean;
|
||||
}
|
||||
|
||||
export function usePoseStream(): UsePoseStreamResult {
|
||||
const connectionStatus = usePoseStore((state) => state.connectionStatus);
|
||||
const lastFrame = usePoseStore((state) => state.lastFrame);
|
||||
const isSimulated = usePoseStore((state) => state.isSimulated);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = wsService.subscribe((frame) => {
|
||||
usePoseStore.getState().handleFrame(frame);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { connectionStatus, lastFrame, isSimulated };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { rssiService, type WifiNetwork } from '@/services/rssi.service';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
|
||||
export function useRssiScanner(): { networks: WifiNetwork[]; isScanning: boolean } {
|
||||
const enabled = useSettingsStore((state) => state.rssiScanEnabled);
|
||||
const [networks, setNetworks] = useState<WifiNetwork[]>([]);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
rssiService.stopScanning();
|
||||
setIsScanning(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = rssiService.subscribe((result) => {
|
||||
setNetworks(result);
|
||||
});
|
||||
rssiService.startScanning(2000);
|
||||
setIsScanning(true);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
rssiService.stopScanning();
|
||||
setIsScanning(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return { networks, isScanning };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiService } from '@/services/api.service';
|
||||
|
||||
interface ServerReachability {
|
||||
reachable: boolean;
|
||||
latencyMs: number | null;
|
||||
}
|
||||
|
||||
const POLL_MS = 10000;
|
||||
|
||||
export function useServerReachability(): ServerReachability {
|
||||
const [state, setState] = useState<ServerReachability>({
|
||||
reachable: false,
|
||||
latencyMs: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const check = async () => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
await apiService.getStatus();
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
reachable: true,
|
||||
latencyMs: Date.now() - started,
|
||||
});
|
||||
} catch {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
reachable: false,
|
||||
latencyMs: null,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void check();
|
||||
const timer = setInterval(check, POLL_MS);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext, ThemeContextValue } from '../theme/ThemeContext';
|
||||
|
||||
export const useTheme = (): ThemeContextValue => useContext(ThemeContext);
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { ThemedText } from '../components/ThemedText';
|
||||
import { ThemedView } from '../components/ThemedView';
|
||||
import { colors } from '../theme/colors';
|
||||
import { useMatStore } from '../stores/matStore';
|
||||
import { MainTabsParamList } from './types';
|
||||
|
||||
const createPlaceholder = (label: string) => {
|
||||
const Placeholder = () => (
|
||||
<ThemedView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ThemedText preset="bodyLg">{label} screen not implemented yet</ThemedText>
|
||||
<ThemedText preset="bodySm" color="textSecondary">
|
||||
Placeholder shell
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
);
|
||||
const LazyPlaceholder = React.lazy(async () => ({ default: Placeholder }));
|
||||
|
||||
const Wrapped = () => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<ThemedView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ActivityIndicator color={colors.accent} />
|
||||
<ThemedText preset="bodySm" color="textSecondary" style={{ marginTop: 8 }}>
|
||||
Loading {label}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
}
|
||||
>
|
||||
<LazyPlaceholder />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return Wrapped;
|
||||
};
|
||||
|
||||
const wrapLazy = (
|
||||
loader: () => Promise<{ default: React.ComponentType }>,
|
||||
label: string,
|
||||
) => {
|
||||
const fallback = createPlaceholder(label);
|
||||
return React.lazy(async () => {
|
||||
try {
|
||||
const module = await loader();
|
||||
if (module?.default) {
|
||||
return module;
|
||||
}
|
||||
} catch {
|
||||
// keep fallback for shell-only screens
|
||||
}
|
||||
return { default: fallback } as { default: React.ComponentType };
|
||||
});
|
||||
};
|
||||
|
||||
const LiveScreen = wrapLazy(() => import('../screens/LiveScreen'), 'Live');
|
||||
const VitalsScreen = wrapLazy(() => import('../screens/VitalsScreen'), 'Vitals');
|
||||
const ZonesScreen = wrapLazy(() => import('../screens/ZonesScreen'), 'Zones');
|
||||
const MATScreen = wrapLazy(() => import('../screens/MATScreen'), 'MAT');
|
||||
const SettingsScreen = wrapLazy(() => import('../screens/SettingsScreen'), 'Settings');
|
||||
|
||||
const toIconName = (routeName: keyof MainTabsParamList) => {
|
||||
switch (routeName) {
|
||||
case 'Live':
|
||||
return 'wifi';
|
||||
case 'Vitals':
|
||||
return 'heart';
|
||||
case 'Zones':
|
||||
return 'grid';
|
||||
case 'MAT':
|
||||
return 'shield-checkmark';
|
||||
case 'Settings':
|
||||
return 'settings';
|
||||
default:
|
||||
return 'ellipse';
|
||||
}
|
||||
};
|
||||
|
||||
const screens: ReadonlyArray<{ name: keyof MainTabsParamList; component: React.ComponentType }> = [
|
||||
{ name: 'Live', component: LiveScreen },
|
||||
{ name: 'Vitals', component: VitalsScreen },
|
||||
{ name: 'Zones', component: ZonesScreen },
|
||||
{ name: 'MAT', component: MATScreen },
|
||||
{ name: 'Settings', component: SettingsScreen },
|
||||
];
|
||||
|
||||
const Tab = createBottomTabNavigator<MainTabsParamList>();
|
||||
|
||||
const Suspended = ({ component: Component }: { component: React.ComponentType }) => (
|
||||
<Suspense fallback={<ActivityIndicator color={colors.accent} />}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
export const MainTabs = () => {
|
||||
const matAlertCount = useMatStore((state) => state.alerts.length);
|
||||
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.accent,
|
||||
tabBarInactiveTintColor: colors.textSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: '#0D1117',
|
||||
borderTopColor: colors.border,
|
||||
borderTopWidth: 1,
|
||||
},
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name={toIconName(route.name)} size={size} color={color} />,
|
||||
tabBarLabelStyle: {
|
||||
fontFamily: 'Courier New',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 10,
|
||||
},
|
||||
tabBarLabel: ({ children, color }) => <ThemedText style={{ color }}>{children}</ThemedText>,
|
||||
})}
|
||||
>
|
||||
{screens.map(({ name, component }) => (
|
||||
<Tab.Screen
|
||||
key={name}
|
||||
name={name}
|
||||
options={{
|
||||
tabBarBadge: name === 'MAT' ? (matAlertCount > 0 ? matAlertCount : undefined) : undefined,
|
||||
}}
|
||||
component={() => <Suspended component={component} />}
|
||||
/>
|
||||
))}
|
||||
</Tab.Navigator>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { MainTabs } from './MainTabs';
|
||||
|
||||
export const RootNavigator = () => {
|
||||
return <MainTabs />;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
export type RootStackParamList = {
|
||||
MainTabs: undefined;
|
||||
};
|
||||
|
||||
export type MainTabsParamList = {
|
||||
Live: undefined;
|
||||
Vitals: undefined;
|
||||
Zones: undefined;
|
||||
MAT: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { LayoutChangeEvent, StyleSheet } from 'react-native';
|
||||
import type { RefObject } from 'react';
|
||||
import { WebView, type WebViewMessageEvent } from 'react-native-webview';
|
||||
import GAUSSIAN_SPLATS_HTML from '@/assets/webview/gaussian-splats.html';
|
||||
|
||||
type GaussianSplatWebViewProps = {
|
||||
onMessage: (event: WebViewMessageEvent) => void;
|
||||
onError: () => void;
|
||||
webViewRef: RefObject<WebView | null>;
|
||||
onLayout?: (event: LayoutChangeEvent) => void;
|
||||
};
|
||||
|
||||
export const GaussianSplatWebView = ({
|
||||
onMessage,
|
||||
onError,
|
||||
webViewRef,
|
||||
onLayout,
|
||||
}: GaussianSplatWebViewProps) => {
|
||||
const html = typeof GAUSSIAN_SPLATS_HTML === 'string' ? GAUSSIAN_SPLATS_HTML : '';
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={{ html }}
|
||||
originWhitelist={['*']}
|
||||
allowFileAccess={false}
|
||||
javaScriptEnabled
|
||||
onMessage={onMessage}
|
||||
onError={onError}
|
||||
onLayout={onLayout}
|
||||
style={styles.webView}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webView: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0A0E1A',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import * as THREE from 'three';
|
||||
import type { SensingFrame } from '@/types/sensing';
|
||||
|
||||
type GaussianSplatWebViewWebProps = {
|
||||
onReady: () => void;
|
||||
onFps: (fps: number) => void;
|
||||
onError: (msg: string) => void;
|
||||
frame: SensingFrame | null;
|
||||
};
|
||||
|
||||
const BONES: [number, number][] = [
|
||||
[0,1],[0,2],[1,3],[2,4],[5,6],[5,7],[7,9],[6,8],[8,10],
|
||||
[5,11],[6,12],[11,12],[11,13],[13,15],[12,14],[14,16],
|
||||
];
|
||||
|
||||
export const GaussianSplatWebViewWeb = ({ onReady, onFps, onError, frame }: GaussianSplatWebViewWebProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<{
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
joints: THREE.Mesh[];
|
||||
boneLines: { line: THREE.Line; a: number; b: number }[];
|
||||
ring: THREE.Mesh;
|
||||
particleGeo: THREE.BufferGeometry;
|
||||
pointLight: THREE.PointLight;
|
||||
animId: number;
|
||||
cameraAngle: number;
|
||||
cameraRadius: number;
|
||||
cameraY: number;
|
||||
isDragging: boolean;
|
||||
frameCount: number;
|
||||
lastFpsTime: number;
|
||||
} | null>(null);
|
||||
const frameRef = useRef<SensingFrame | null>(null);
|
||||
|
||||
// Keep frame ref current without re-running effect
|
||||
frameRef.current = frame;
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
const s = sceneRef.current;
|
||||
if (!s) return;
|
||||
cancelAnimationFrame(s.animId);
|
||||
s.renderer.dispose();
|
||||
s.scene.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry.dispose();
|
||||
if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
|
||||
else obj.material.dispose();
|
||||
}
|
||||
});
|
||||
sceneRef.current = null;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const W = () => container.clientWidth || window.innerWidth;
|
||||
const H = () => container.clientHeight || window.innerHeight;
|
||||
|
||||
// Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
|
||||
renderer.setSize(W(), H());
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setClearColor(0x0a0e1a);
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0e1a);
|
||||
scene.fog = new THREE.FogExp2(0x0a0e1a, 0.008);
|
||||
|
||||
// Camera
|
||||
const camera = new THREE.PerspectiveCamera(60, W() / H(), 0.1, 500);
|
||||
camera.position.set(0, 2, 6);
|
||||
camera.lookAt(0, 1, 0);
|
||||
|
||||
// Grid
|
||||
const grid = new THREE.GridHelper(20, 40, 0x1a3a4a, 0x0d1f2a);
|
||||
scene.add(grid);
|
||||
|
||||
// Lights
|
||||
scene.add(new THREE.AmbientLight(0x32b8c6, 0.3));
|
||||
const pointLight = new THREE.PointLight(0x32b8c6, 1.5, 20);
|
||||
pointLight.position.set(0, 4, 0);
|
||||
scene.add(pointLight);
|
||||
|
||||
// Skeleton joints (17 COCO keypoints)
|
||||
const jointGeo = new THREE.SphereGeometry(0.06, 8, 8);
|
||||
const joints: THREE.Mesh[] = [];
|
||||
for (let i = 0; i < 17; i++) {
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: 0x32b8c6,
|
||||
emissive: 0x32b8c6,
|
||||
emissiveIntensity: 0.6,
|
||||
});
|
||||
const m = new THREE.Mesh(jointGeo, mat);
|
||||
m.visible = false;
|
||||
scene.add(m);
|
||||
joints.push(m);
|
||||
}
|
||||
|
||||
// Bone lines
|
||||
const boneMat = new THREE.LineBasicMaterial({
|
||||
color: 0x32b8c6,
|
||||
transparent: true,
|
||||
opacity: 0.7,
|
||||
});
|
||||
const boneLines = BONES.map(([a, b]) => {
|
||||
const g = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(),
|
||||
new THREE.Vector3(),
|
||||
]);
|
||||
const l = new THREE.Line(g, boneMat);
|
||||
l.visible = false;
|
||||
scene.add(l);
|
||||
return { line: l, a, b };
|
||||
});
|
||||
|
||||
// Particle field
|
||||
const N = 500;
|
||||
const particleGeo = new THREE.BufferGeometry();
|
||||
const pPos = new Float32Array(N * 3);
|
||||
for (let i = 0; i < N; i++) {
|
||||
pPos[i * 3] = (Math.random() - 0.5) * 16;
|
||||
pPos[i * 3 + 1] = Math.random() * 4;
|
||||
pPos[i * 3 + 2] = (Math.random() - 0.5) * 16;
|
||||
}
|
||||
particleGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
|
||||
const pMat = new THREE.PointsMaterial({
|
||||
color: 0x32b8c6,
|
||||
size: 0.04,
|
||||
transparent: true,
|
||||
opacity: 0.4,
|
||||
});
|
||||
scene.add(new THREE.Points(particleGeo, pMat));
|
||||
|
||||
// Signal ring
|
||||
const ringGeo = new THREE.TorusGeometry(2, 0.02, 8, 64);
|
||||
const ringMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x32b8c6,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
});
|
||||
const ring = new THREE.Mesh(ringGeo, ringMat);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.position.y = 0.01;
|
||||
scene.add(ring);
|
||||
|
||||
// State
|
||||
const state = {
|
||||
renderer,
|
||||
scene,
|
||||
camera,
|
||||
joints,
|
||||
boneLines,
|
||||
ring,
|
||||
particleGeo,
|
||||
pointLight,
|
||||
animId: 0,
|
||||
cameraAngle: 0,
|
||||
cameraRadius: 6,
|
||||
cameraY: 2,
|
||||
isDragging: false,
|
||||
frameCount: 0,
|
||||
lastFpsTime: performance.now(),
|
||||
};
|
||||
sceneRef.current = state;
|
||||
|
||||
// Mouse interaction
|
||||
const canvas = renderer.domElement;
|
||||
const onMouseDown = () => { state.isDragging = true; };
|
||||
const onMouseUp = () => { state.isDragging = false; };
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (state.isDragging) {
|
||||
state.cameraAngle += e.movementX * 0.01;
|
||||
state.cameraY = Math.max(0.5, Math.min(5, state.cameraY - e.movementY * 0.01));
|
||||
}
|
||||
};
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
state.cameraRadius = Math.max(2, Math.min(15, state.cameraRadius + e.deltaY * 0.005));
|
||||
};
|
||||
canvas.addEventListener('mousedown', onMouseDown);
|
||||
canvas.addEventListener('mouseup', onMouseUp);
|
||||
canvas.addEventListener('mousemove', onMouseMove);
|
||||
canvas.addEventListener('wheel', onWheel, { passive: true });
|
||||
|
||||
// Resize
|
||||
const onResize = () => {
|
||||
camera.aspect = W() / H();
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(W(), H());
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
// Animation loop
|
||||
const animate = () => {
|
||||
state.animId = requestAnimationFrame(animate);
|
||||
const t = performance.now() * 0.001;
|
||||
|
||||
// Camera orbit
|
||||
if (!state.isDragging) state.cameraAngle += 0.002;
|
||||
camera.position.set(
|
||||
Math.sin(state.cameraAngle) * state.cameraRadius,
|
||||
state.cameraY,
|
||||
Math.cos(state.cameraAngle) * state.cameraRadius,
|
||||
);
|
||||
camera.lookAt(0, 1, 0);
|
||||
|
||||
// Animate ring
|
||||
ring.material.opacity = 0.15 + Math.sin(t * 2) * 0.1;
|
||||
const scale = 1 + Math.sin(t) * 0.1;
|
||||
ring.scale.set(scale, scale, 1);
|
||||
|
||||
// Animate particles
|
||||
const pp = particleGeo.attributes.position as THREE.BufferAttribute;
|
||||
for (let i = 0; i < N; i++) {
|
||||
(pp.array as Float32Array)[i * 3 + 1] += Math.sin(t + i) * 0.001;
|
||||
}
|
||||
pp.needsUpdate = true;
|
||||
|
||||
// Update skeleton from frame data
|
||||
const currentFrame = frameRef.current;
|
||||
if (currentFrame) {
|
||||
const persons = (currentFrame as any).persons || [];
|
||||
if (persons.length > 0) {
|
||||
const kps = persons[0].keypoints || [];
|
||||
kps.forEach((kp: any, i: number) => {
|
||||
if (i < 17 && joints[i]) {
|
||||
joints[i].position.set(
|
||||
(kp.x - 0.5) * 4,
|
||||
(1 - kp.y) * 3,
|
||||
(kp.z || 0) * 2,
|
||||
);
|
||||
joints[i].visible = kp.confidence > 0.3;
|
||||
(joints[i].material as THREE.MeshStandardMaterial).emissiveIntensity =
|
||||
0.3 + kp.confidence * 0.7;
|
||||
}
|
||||
});
|
||||
boneLines.forEach(({ line, a, b }) => {
|
||||
if (joints[a].visible && joints[b].visible) {
|
||||
const pos = line.geometry.attributes.position as THREE.BufferAttribute;
|
||||
pos.setXYZ(0, joints[a].position.x, joints[a].position.y, joints[a].position.z);
|
||||
pos.setXYZ(1, joints[b].position.x, joints[b].position.y, joints[b].position.z);
|
||||
pos.needsUpdate = true;
|
||||
line.visible = true;
|
||||
} else {
|
||||
line.visible = false;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
joints.forEach((j) => { j.visible = false; });
|
||||
boneLines.forEach((bl) => { bl.line.visible = false; });
|
||||
}
|
||||
|
||||
// Adjust light from RSSI
|
||||
const features = (currentFrame as any).features;
|
||||
if (features) {
|
||||
const rssi = features.mean_rssi || -70;
|
||||
pointLight.intensity = 1 + Math.abs(rssi + 50) * 0.02;
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
|
||||
// FPS counter
|
||||
state.frameCount++;
|
||||
if (performance.now() - state.lastFpsTime >= 1000) {
|
||||
onFps(state.frameCount);
|
||||
state.frameCount = 0;
|
||||
state.lastFpsTime = performance.now();
|
||||
}
|
||||
};
|
||||
|
||||
animate();
|
||||
onReady();
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener('mousedown', onMouseDown);
|
||||
canvas.removeEventListener('mouseup', onMouseUp);
|
||||
canvas.removeEventListener('mousemove', onMouseMove);
|
||||
canvas.removeEventListener('wheel', onWheel);
|
||||
window.removeEventListener('resize', onResize);
|
||||
cleanup();
|
||||
if (container.contains(renderer.domElement)) {
|
||||
container.removeChild(renderer.domElement);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : 'Failed to initialize 3D renderer');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ width: '100%', height: '100%', backgroundColor: '#0a0e1a' }}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0a0e1a',
|
||||
},
|
||||
});
|
||||
|
||||
export default GaussianSplatWebViewWeb;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||
import { StatusDot } from '@/components/StatusDot';
|
||||
import { ModeBadge } from '@/components/ModeBadge';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { formatConfidence, formatRssi } from '@/utils/formatters';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import type { ConnectionStatus } from '@/types/sensing';
|
||||
|
||||
type LiveMode = 'LIVE' | 'SIM' | 'RSSI';
|
||||
|
||||
type LiveHUDProps = {
|
||||
rssi?: number;
|
||||
connectionStatus: ConnectionStatus;
|
||||
fps: number;
|
||||
confidence: number;
|
||||
personCount: number;
|
||||
mode: LiveMode;
|
||||
};
|
||||
|
||||
const statusTextMap: Record<ConnectionStatus, string> = {
|
||||
connected: 'Connected',
|
||||
simulated: 'Simulated',
|
||||
connecting: 'Connecting',
|
||||
disconnected: 'Disconnected',
|
||||
};
|
||||
|
||||
const statusDotStatusMap: Record<ConnectionStatus, 'connected' | 'simulated' | 'disconnected' | 'connecting'> = {
|
||||
connected: 'connected',
|
||||
simulated: 'simulated',
|
||||
connecting: 'connecting',
|
||||
disconnected: 'disconnected',
|
||||
};
|
||||
|
||||
export const LiveHUD = memo(
|
||||
({ rssi, connectionStatus, fps, confidence, personCount, mode }: LiveHUDProps) => {
|
||||
const [panelVisible, setPanelVisible] = useState(true);
|
||||
const panelAlpha = useSharedValue(1);
|
||||
|
||||
const togglePanel = useCallback(() => {
|
||||
const next = !panelVisible;
|
||||
setPanelVisible(next);
|
||||
panelAlpha.value = withTiming(next ? 1 : 0, { duration: 220 });
|
||||
}, [panelAlpha, panelVisible]);
|
||||
|
||||
const animatedPanelStyle = useAnimatedStyle(() => ({
|
||||
opacity: panelAlpha.value,
|
||||
}));
|
||||
|
||||
const statusText = statusTextMap[connectionStatus];
|
||||
|
||||
return (
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={togglePanel}>
|
||||
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, animatedPanelStyle]}>
|
||||
{/* App title */}
|
||||
<View style={styles.topLeft}>
|
||||
<ThemedText preset="labelLg" style={styles.appTitle}>
|
||||
WiFi-DensePose
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{/* Status + FPS */}
|
||||
<View style={styles.topRight}>
|
||||
<View style={styles.row}>
|
||||
<StatusDot status={statusDotStatusMap[connectionStatus]} size={10} />
|
||||
<ThemedText preset="labelMd" style={styles.statusText}>
|
||||
{statusText}
|
||||
</ThemedText>
|
||||
</View>
|
||||
{fps > 0 && (
|
||||
<View style={styles.row}>
|
||||
<ThemedText preset="labelMd">{fps} FPS</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Bottom panel */}
|
||||
<View style={styles.bottomPanel}>
|
||||
<View style={styles.bottomCell}>
|
||||
<ThemedText preset="bodySm">RSSI</ThemedText>
|
||||
<ThemedText preset="displayMd" style={styles.bigValue}>
|
||||
{formatRssi(rssi)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomCell}>
|
||||
<ModeBadge mode={mode} />
|
||||
</View>
|
||||
|
||||
<View style={styles.bottomCellRight}>
|
||||
<ThemedText preset="bodySm">Confidence</ThemedText>
|
||||
<ThemedText preset="bodyMd" style={styles.metaText}>
|
||||
{formatConfidence(confidence)}
|
||||
</ThemedText>
|
||||
<ThemedText preset="bodySm">People: {personCount}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topLeft: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
left: spacing.md,
|
||||
},
|
||||
appTitle: {
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
topRight: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
alignItems: 'flex-end',
|
||||
gap: 4,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
statusText: {
|
||||
color: colors.textPrimary,
|
||||
},
|
||||
bottomPanel: {
|
||||
position: 'absolute',
|
||||
left: spacing.sm,
|
||||
right: spacing.sm,
|
||||
bottom: spacing.sm,
|
||||
minHeight: 72,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(10,14,26,0.72)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(50,184,198,0.35)',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
bottomCell: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
bottomCellRight: {
|
||||
flex: 1,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
bigValue: {
|
||||
color: colors.accent,
|
||||
marginTop: 2,
|
||||
marginBottom: 2,
|
||||
},
|
||||
metaText: {
|
||||
color: colors.textPrimary,
|
||||
marginBottom: 4,
|
||||
},
|
||||
});
|
||||
|
||||
LiveHUD.displayName = 'LiveHUD';
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Button, Platform, StyleSheet, View } from 'react-native';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { usePoseStream } from '@/hooks/usePoseStream';
|
||||
import { colors, spacing } from '@/theme';
|
||||
import type { ConnectionStatus, SensingFrame } from '@/types/sensing';
|
||||
import { LiveHUD } from './LiveHUD';
|
||||
|
||||
type LiveMode = 'LIVE' | 'SIM' | 'RSSI';
|
||||
|
||||
const getMode = (
|
||||
status: ConnectionStatus,
|
||||
isSimulated: boolean,
|
||||
frame: SensingFrame | null,
|
||||
): LiveMode => {
|
||||
if (isSimulated || frame?.source === 'simulated') return 'SIM';
|
||||
if (status === 'connected') return 'LIVE';
|
||||
return 'RSSI';
|
||||
};
|
||||
|
||||
const isWeb = Platform.OS === 'web';
|
||||
|
||||
type ViewerProps = {
|
||||
frame: SensingFrame | null;
|
||||
onReady: () => void;
|
||||
onFps: (fps: number) => void;
|
||||
onError: (msg: string) => void;
|
||||
};
|
||||
|
||||
const WebLiveViewer = ({ frame, onReady, onFps, onError }: ViewerProps) => {
|
||||
const [Viewer, setViewer] = useState<React.ComponentType<any> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
import('./GaussianSplatWebView.web').then((mod) => {
|
||||
setViewer(() => mod.GaussianSplatWebViewWeb);
|
||||
}).catch(() => onError('Failed to load web viewer'));
|
||||
}, [onError]);
|
||||
|
||||
if (!Viewer) return null;
|
||||
return <Viewer frame={frame} onReady={onReady} onFps={onFps} onError={onError} />;
|
||||
};
|
||||
|
||||
const NativeLiveViewer = ({ frame, onReady, onFps, onError }: ViewerProps) => {
|
||||
const webViewRef = useRef(null);
|
||||
const [WVComponent, setWVComponent] = useState<React.ComponentType<any> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const { GaussianSplatWebView } = require('./GaussianSplatWebView');
|
||||
setWVComponent(() => GaussianSplatWebView);
|
||||
} catch {
|
||||
onError('WebView not available on this platform');
|
||||
}
|
||||
}, [onError]);
|
||||
|
||||
if (!WVComponent) return null;
|
||||
|
||||
return (
|
||||
<WVComponent
|
||||
webViewRef={webViewRef}
|
||||
onMessage={(event: any) => {
|
||||
try {
|
||||
const data = typeof event.nativeEvent.data === 'string'
|
||||
? JSON.parse(event.nativeEvent.data)
|
||||
: event.nativeEvent.data;
|
||||
if (data.type === 'READY') onReady();
|
||||
else if (data.type === 'FPS_TICK') onFps(data.payload?.fps ?? 0);
|
||||
else if (data.type === 'ERROR') onError(data.payload?.message ?? 'Unknown error');
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
onError={() => onError('WebView renderer failed')}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const LiveScreen = () => {
|
||||
const { lastFrame, connectionStatus, isSimulated } = usePoseStream();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [fps, setFps] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [viewerKey, setViewerKey] = useState(0);
|
||||
|
||||
const handleReady = useCallback(() => { setReady(true); setError(null); }, []);
|
||||
const handleFps = useCallback((f: number) => setFps(Math.max(0, Math.floor(f))), []);
|
||||
const handleError = useCallback((msg: string) => { setError(msg); setReady(false); }, []);
|
||||
const handleRetry = useCallback(() => { setError(null); setReady(false); setFps(0); setViewerKey((v) => v + 1); }, []);
|
||||
|
||||
const rssi = lastFrame?.features?.mean_rssi;
|
||||
const personCount = lastFrame?.classification?.presence ? 1 : 0;
|
||||
const mode = getMode(connectionStatus, isSimulated, lastFrame);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ThemedView style={styles.fallbackWrap}>
|
||||
<ThemedText preset="bodyLg">Live visualization failed</ThemedText>
|
||||
<ThemedText preset="bodySm" color="textSecondary" style={styles.errorText}>{error}</ThemedText>
|
||||
<Button title="Retry" onPress={handleRetry} />
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<View style={styles.container}>
|
||||
{isWeb ? (
|
||||
<WebLiveViewer key={viewerKey} frame={lastFrame} onReady={handleReady} onFps={handleFps} onError={handleError} />
|
||||
) : (
|
||||
<NativeLiveViewer key={viewerKey} frame={lastFrame} onReady={handleReady} onFps={handleFps} onError={handleError} />
|
||||
)}
|
||||
|
||||
<LiveHUD
|
||||
connectionStatus={connectionStatus}
|
||||
fps={fps}
|
||||
rssi={rssi}
|
||||
confidence={lastFrame?.classification?.confidence ?? 0}
|
||||
personCount={personCount}
|
||||
mode={mode}
|
||||
/>
|
||||
|
||||
{!ready && (
|
||||
<View style={styles.loadingWrap}>
|
||||
<LoadingSpinner />
|
||||
<ThemedText preset="bodyMd" style={styles.loadingText}>Loading live renderer</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveScreen;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: colors.bg },
|
||||
loadingWrap: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.bg, alignItems: 'center', justifyContent: 'center', gap: spacing.md },
|
||||
loadingText: { color: colors.textSecondary },
|
||||
fallbackWrap: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: spacing.md, padding: spacing.lg },
|
||||
errorText: { textAlign: 'center' },
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import type { WebViewMessageEvent } from 'react-native-webview';
|
||||
import { WebView } from 'react-native-webview';
|
||||
import type { SensingFrame } from '@/types/sensing';
|
||||
|
||||
export type GaussianBridgeMessageType = 'READY' | 'FPS_TICK' | 'ERROR';
|
||||
|
||||
type BridgeMessage = {
|
||||
type: GaussianBridgeMessageType;
|
||||
payload?: {
|
||||
fps?: number;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const toJsonScript = (message: unknown): string => {
|
||||
const serialized = JSON.stringify(message);
|
||||
return `window.dispatchEvent(new MessageEvent('message', { data: ${JSON.stringify(serialized)} })); true;`;
|
||||
};
|
||||
|
||||
export const useGaussianBridge = (webViewRef: RefObject<WebView | null>) => {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [fps, setFps] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const send = useCallback((message: unknown) => {
|
||||
const webView = webViewRef.current;
|
||||
if (!webView) {
|
||||
return;
|
||||
}
|
||||
|
||||
webView.injectJavaScript(toJsonScript(message));
|
||||
}, [webViewRef]);
|
||||
|
||||
const sendFrame = useCallback(
|
||||
(frame: SensingFrame) => {
|
||||
send({
|
||||
type: 'FRAME_UPDATE',
|
||||
payload: frame,
|
||||
});
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
const onMessage = useCallback((event: WebViewMessageEvent) => {
|
||||
let parsed: BridgeMessage | null = null;
|
||||
const raw = event.nativeEvent.data;
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
parsed = JSON.parse(raw) as BridgeMessage;
|
||||
} catch {
|
||||
setError('Invalid bridge message format');
|
||||
return;
|
||||
}
|
||||
} else if (typeof raw === 'object' && raw !== null) {
|
||||
parsed = raw as BridgeMessage;
|
||||
}
|
||||
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'READY') {
|
||||
setIsReady(true);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'FPS_TICK') {
|
||||
const fpsValue = parsed.payload?.fps;
|
||||
if (typeof fpsValue === 'number' && Number.isFinite(fpsValue)) {
|
||||
setFps(Math.max(0, Math.floor(fpsValue)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'ERROR') {
|
||||
setError(parsed.payload?.message ?? 'Unknown bridge error');
|
||||
setIsReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sendFrame,
|
||||
onMessage,
|
||||
isReady,
|
||||
fps,
|
||||
error,
|
||||
reset: () => {
|
||||
setIsReady(false);
|
||||
setFps(0);
|
||||
setError(null);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
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 = useMatStore((state) => state.survivors);
|
||||
const alerts = useMatStore((state) => state.alerts);
|
||||
const upsertSurvivor = useMatStore((state) => state.upsertSurvivor);
|
||||
const addAlert = useMatStore((state) => state.addAlert);
|
||||
const upsertEvent = useMatStore((state) => 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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
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 = () => {
|
||||
const newUrl = draftUrl.trim();
|
||||
setServerUrl(newUrl);
|
||||
wsService.disconnect();
|
||||
wsService.connect(newUrl);
|
||||
apiService.setBaseUrl(newUrl);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import axios, { type AxiosError, type AxiosInstance, type AxiosRequestConfig } from 'axios';
|
||||
import { API_POSE_FRAMES_PATH, API_POSE_STATUS_PATH, API_POSE_ZONES_PATH } from '@/constants/api';
|
||||
import type { ApiError, HistoricalFrames, PoseStatus, ZoneConfig } from '@/types/api';
|
||||
|
||||
class ApiService {
|
||||
private baseUrl = '';
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setBaseUrl(url: string): void {
|
||||
this.baseUrl = url ?? '';
|
||||
}
|
||||
|
||||
private buildUrl(path: string): string {
|
||||
if (!this.baseUrl) {
|
||||
return path;
|
||||
}
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
const normalized = this.baseUrl.replace(/\/$/, '');
|
||||
return `${normalized}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
private normalizeError(error: unknown): ApiError {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const axiosError = error as AxiosError<{ message?: string }>;
|
||||
const message =
|
||||
axiosError.response?.data && typeof axiosError.response.data === 'object' && 'message' in axiosError.response.data
|
||||
? String((axiosError.response.data as { message?: string }).message)
|
||||
: axiosError.message || 'Request failed';
|
||||
return {
|
||||
message,
|
||||
status: axiosError.response?.status,
|
||||
code: axiosError.code,
|
||||
details: axiosError.response?.data,
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return { message: error.message };
|
||||
}
|
||||
|
||||
return { message: 'Unknown error' };
|
||||
}
|
||||
|
||||
private async requestWithRetry<T>(config: AxiosRequestConfig, retriesLeft: number): Promise<T> {
|
||||
try {
|
||||
const response = await this.client.request<T>({
|
||||
...config,
|
||||
url: this.buildUrl(config.url || ''),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (retriesLeft > 0) {
|
||||
return this.requestWithRetry<T>(config, retriesLeft - 1);
|
||||
}
|
||||
throw this.normalizeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(path: string): Promise<T> {
|
||||
return this.requestWithRetry<T>({ method: 'GET', url: path }, 2);
|
||||
}
|
||||
|
||||
post<T>(path: string, body: unknown): Promise<T> {
|
||||
return this.requestWithRetry<T>({ method: 'POST', url: path, data: body }, 2);
|
||||
}
|
||||
|
||||
getStatus(): Promise<PoseStatus> {
|
||||
return this.get<PoseStatus>(API_POSE_STATUS_PATH);
|
||||
}
|
||||
|
||||
getZones(): Promise<ZoneConfig[]> {
|
||||
return this.get<ZoneConfig[]>(API_POSE_ZONES_PATH);
|
||||
}
|
||||
|
||||
getFrames(limit: number): Promise<HistoricalFrames> {
|
||||
return this.get<HistoricalFrames>(`${API_POSE_FRAMES_PATH}?limit=${encodeURIComponent(String(limit))}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { RssiService, WifiNetwork } from './rssi.service';
|
||||
import WifiManager from '@react-native-wifi-reborn';
|
||||
|
||||
type NativeWifiNetwork = {
|
||||
SSID?: string;
|
||||
BSSID?: string;
|
||||
level?: number;
|
||||
levelDbm?: number;
|
||||
};
|
||||
|
||||
class AndroidRssiService implements RssiService {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private listeners = new Set<(networks: WifiNetwork[]) => void>();
|
||||
|
||||
startScanning(intervalMs: number): void {
|
||||
this.stopScanning();
|
||||
this.scanOnce();
|
||||
this.timer = setInterval(() => {
|
||||
this.scanOnce();
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stopScanning(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (networks: WifiNetwork[]) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private async scanOnce(): Promise<void> {
|
||||
try {
|
||||
const results = (await WifiManager.loadWifiList()) as NativeWifiNetwork[];
|
||||
const mapped = results.map((item) => ({
|
||||
ssid: item.SSID || '',
|
||||
bssid: item.BSSID,
|
||||
level: typeof item.level === 'number' ? item.level : typeof item.levelDbm === 'number' ? item.levelDbm : -100,
|
||||
}));
|
||||
this.broadcast(mapped.filter((n) => n.ssid.length > 0));
|
||||
} catch {
|
||||
this.broadcast([]);
|
||||
}
|
||||
}
|
||||
|
||||
private broadcast(networks: WifiNetwork[]): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(networks);
|
||||
} catch {
|
||||
// listener safety
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const rssiService = new AndroidRssiService();
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { RssiService, WifiNetwork } from './rssi.service';
|
||||
|
||||
class IosRssiService implements RssiService {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private listeners = new Set<(networks: WifiNetwork[]) => void>();
|
||||
|
||||
startScanning(intervalMs: number): void {
|
||||
console.warn('iOS RSSI scanning not available; returning synthetic network data.');
|
||||
this.stopScanning();
|
||||
this.timer = setInterval(() => {
|
||||
this.broadcast([{ ssid: 'WiFi-DensePose', bssid: undefined, level: -60 }]);
|
||||
}, intervalMs);
|
||||
this.broadcast([{ ssid: 'WiFi-DensePose', bssid: undefined, level: -60 }]);
|
||||
}
|
||||
|
||||
stopScanning(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (networks: WifiNetwork[]) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private broadcast(networks: WifiNetwork[]): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(networks);
|
||||
} catch {
|
||||
// listener safety
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const rssiService = new IosRssiService();
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface WifiNetwork {
|
||||
ssid: string;
|
||||
bssid?: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface RssiService {
|
||||
startScanning(intervalMs: number): void;
|
||||
stopScanning(): void;
|
||||
subscribe(listener: (networks: WifiNetwork[]) => void): () => void;
|
||||
}
|
||||
|
||||
// Metro resolves the correct platform file automatically:
|
||||
// rssi.service.android.ts (Android)
|
||||
// rssi.service.ios.ts (iOS)
|
||||
// rssi.service.web.ts (Web)
|
||||
// This file only exports the shared types.
|
||||
// The platform entry is re-exported from the index barrel below.
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
// Lazy require to avoid bundling native modules on web
|
||||
function getPlatformService(): RssiService {
|
||||
if (Platform.OS === 'android') {
|
||||
return require('./rssi.service.android').rssiService;
|
||||
} else if (Platform.OS === 'ios') {
|
||||
return require('./rssi.service.ios').rssiService;
|
||||
} else {
|
||||
return require('./rssi.service.web').rssiService;
|
||||
}
|
||||
}
|
||||
|
||||
export const rssiService: RssiService = getPlatformService();
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RssiService, WifiNetwork } from './rssi.service';
|
||||
|
||||
class WebRssiService implements RssiService {
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private listeners = new Set<(networks: WifiNetwork[]) => void>();
|
||||
|
||||
startScanning(intervalMs: number): void {
|
||||
console.warn('Web RSSI scanning not available; returning synthetic network data.');
|
||||
this.stopScanning();
|
||||
this.timer = setInterval(() => {
|
||||
this.broadcast([
|
||||
{ ssid: 'WiFi-DensePose', bssid: 'AA:BB:CC:DD:EE:01', level: -55 },
|
||||
{ ssid: 'WiFi-Guest', bssid: 'AA:BB:CC:DD:EE:02', level: -72 },
|
||||
]);
|
||||
}, intervalMs);
|
||||
this.broadcast([
|
||||
{ ssid: 'WiFi-DensePose', bssid: 'AA:BB:CC:DD:EE:01', level: -55 },
|
||||
]);
|
||||
}
|
||||
|
||||
stopScanning(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(listener: (networks: WifiNetwork[]) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
private broadcast(networks: WifiNetwork[]): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(networks);
|
||||
} catch {
|
||||
// listener safety
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const rssiService = new WebRssiService();
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
BREATHING_BAND_AMPLITUDE,
|
||||
BREATHING_BAND_MIN,
|
||||
BREATHING_BPM_MAX,
|
||||
BREATHING_BPM_MIN,
|
||||
HEART_BPM_MAX,
|
||||
HEART_BPM_MIN,
|
||||
MOTION_BAND_AMPLITUDE,
|
||||
MOTION_BAND_MIN,
|
||||
RSSI_AMPLITUDE_DBM,
|
||||
RSSI_BASE_DBM,
|
||||
SIMULATION_GRID_SIZE,
|
||||
SIMULATION_TICK_INTERVAL_MS,
|
||||
SIGNAL_FIELD_PRESENCE_LEVEL,
|
||||
VARIANCE_AMPLITUDE,
|
||||
VARIANCE_BASE,
|
||||
} from '@/constants/simulation';
|
||||
import type { SensingFrame } from '@/types/sensing';
|
||||
|
||||
function gaussian(x: number, y: number, cx: number, cy: number, sigma: number): number {
|
||||
const dx = x - cx;
|
||||
const dy = y - cy;
|
||||
return Math.exp(-(dx * dx + dy * dy) / (2 * sigma * sigma));
|
||||
}
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
export function generateSimulatedData(timeMs = Date.now()): SensingFrame {
|
||||
const t = timeMs / 1000;
|
||||
|
||||
const baseRssi = RSSI_BASE_DBM + Math.sin(t * 0.5) * RSSI_AMPLITUDE_DBM;
|
||||
const variance = VARIANCE_BASE + Math.sin(t * 0.1) * VARIANCE_AMPLITUDE;
|
||||
const motionBand = MOTION_BAND_MIN + Math.abs(Math.sin(t * 0.3)) * MOTION_BAND_AMPLITUDE;
|
||||
const breathingBand = BREATHING_BAND_MIN + Math.abs(Math.sin(t * 0.05)) * BREATHING_BAND_AMPLITUDE;
|
||||
|
||||
const isPresent = variance > SIGNAL_FIELD_PRESENCE_LEVEL;
|
||||
const isActive = motionBand > 0.12;
|
||||
|
||||
const grid = SIMULATION_GRID_SIZE;
|
||||
const cx = grid / 2;
|
||||
const cy = grid / 2;
|
||||
const bodyX = cx + 3 * Math.sin(t * 0.2);
|
||||
const bodyY = cy + 2 * Math.cos(t * 0.15);
|
||||
const breathX = cx + 4 * Math.sin(t * 0.04);
|
||||
const breathY = cy + 4 * Math.cos(t * 0.04);
|
||||
|
||||
const values: number[] = [];
|
||||
for (let z = 0; z < grid; z += 1) {
|
||||
for (let x = 0; x < grid; x += 1) {
|
||||
let value = Math.max(0, 1 - Math.sqrt((x - cx) ** 2 + (z - cy) ** 2) / (grid * 0.7)) * 0.3;
|
||||
value += gaussian(x, z, bodyX, bodyY, 3.4) * (0.3 + motionBand * 3);
|
||||
value += gaussian(x, z, breathX, breathY, 6) * (0.15 + breathingBand * 2);
|
||||
if (!isPresent) {
|
||||
value *= 0.7;
|
||||
}
|
||||
values.push(clamp(value, 0, 1));
|
||||
}
|
||||
}
|
||||
|
||||
const dominantFreqHz = 0.3 + Math.sin(t * 0.02) * 0.1;
|
||||
const breathingBpm = BREATHING_BPM_MIN + ((Math.sin(t * 0.07) + 1) * 0.5) * (BREATHING_BPM_MAX - BREATHING_BPM_MIN);
|
||||
const hrProxy = HEART_BPM_MIN + ((Math.sin(t * 0.09) + 1) * 0.5) * (HEART_BPM_MAX - HEART_BPM_MIN);
|
||||
const confidence = 0.6 + Math.abs(Math.sin(t * 0.03)) * 0.4;
|
||||
|
||||
return {
|
||||
type: 'sensing_update',
|
||||
timestamp: timeMs,
|
||||
source: 'simulated',
|
||||
tick: Math.floor(t / (SIMULATION_TICK_INTERVAL_MS / 1000)),
|
||||
nodes: [
|
||||
{
|
||||
node_id: 1,
|
||||
rssi_dbm: baseRssi,
|
||||
position: [2, 0, 1.5],
|
||||
amplitude: [baseRssi],
|
||||
subcarrier_count: 1,
|
||||
},
|
||||
],
|
||||
features: {
|
||||
mean_rssi: baseRssi,
|
||||
variance,
|
||||
motion_band_power: motionBand,
|
||||
breathing_band_power: breathingBand,
|
||||
spectral_entropy: 1 - clamp(Math.abs(dominantFreqHz - 0.3), 0, 1),
|
||||
std: Math.sqrt(Math.abs(variance)),
|
||||
dominant_freq_hz: dominantFreqHz,
|
||||
change_points: Math.max(0, Math.floor(variance * 2)),
|
||||
spectral_power: motionBand + breathingBand,
|
||||
},
|
||||
classification: {
|
||||
motion_level: isActive ? 'active' : isPresent ? 'present_still' : 'absent',
|
||||
presence: isPresent,
|
||||
confidence: isPresent ? 0.75 + Math.abs(Math.sin(t * 0.03)) * 0.2 : 0.5 + Math.abs(Math.cos(t * 0.03)) * 0.3,
|
||||
},
|
||||
signal_field: {
|
||||
grid_size: [grid, 1, grid],
|
||||
values,
|
||||
},
|
||||
vital_signs: {
|
||||
breathing_bpm: breathingBpm,
|
||||
hr_proxy_bpm: hrProxy,
|
||||
confidence,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { SIMULATION_TICK_INTERVAL_MS } from '@/constants/simulation';
|
||||
import { MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAYS, WS_PATH } from '@/constants/websocket';
|
||||
import { usePoseStore } from '@/stores/poseStore';
|
||||
import { generateSimulatedData } from '@/services/simulation.service';
|
||||
import type { ConnectionStatus, SensingFrame } from '@/types/sensing';
|
||||
|
||||
type FrameListener = (frame: SensingFrame) => void;
|
||||
|
||||
class WsService {
|
||||
private ws: WebSocket | null = null;
|
||||
private listeners = new Set<FrameListener>();
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private simulationTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private targetUrl = '';
|
||||
private active = false;
|
||||
private status: ConnectionStatus = 'disconnected';
|
||||
|
||||
connect(url: string): void {
|
||||
this.targetUrl = url;
|
||||
this.active = true;
|
||||
this.reconnectAttempt = 0;
|
||||
|
||||
if (!url) {
|
||||
this.handleStatusChange('simulated');
|
||||
this.startSimulation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleStatusChange('connecting');
|
||||
|
||||
try {
|
||||
const endpoint = this.buildWsUrl(url);
|
||||
const socket = new WebSocket(endpoint);
|
||||
this.ws = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
this.reconnectAttempt = 0;
|
||||
this.stopSimulation();
|
||||
this.handleStatusChange('connected');
|
||||
};
|
||||
|
||||
socket.onmessage = (evt) => {
|
||||
try {
|
||||
const raw = typeof evt.data === 'string' ? evt.data : JSON.stringify(evt.data);
|
||||
const frame = JSON.parse(raw) as SensingFrame;
|
||||
this.listeners.forEach((listener) => listener(frame));
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
// handled by onclose
|
||||
};
|
||||
|
||||
socket.onclose = (evt) => {
|
||||
this.ws = null;
|
||||
if (!this.active) {
|
||||
this.handleStatusChange('disconnected');
|
||||
return;
|
||||
}
|
||||
if (evt.code === 1000) {
|
||||
this.handleStatusChange('disconnected');
|
||||
return;
|
||||
}
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
} catch {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.active = false;
|
||||
this.clearReconnectTimer();
|
||||
this.stopSimulation();
|
||||
if (this.ws) {
|
||||
this.ws.close(1000, 'client disconnect');
|
||||
this.ws = null;
|
||||
}
|
||||
this.handleStatusChange('disconnected');
|
||||
}
|
||||
|
||||
subscribe(listener: FrameListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
getStatus(): ConnectionStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
private buildWsUrl(rawUrl: string): string {
|
||||
const parsed = new URL(rawUrl);
|
||||
const proto = parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? 'wss:' : 'ws:';
|
||||
// Sensing server runs WS on port 3001 at /ws/sensing
|
||||
// If the HTTP server is on port 3000, connect WS to 3001
|
||||
const wsHost = parsed.port === '3000'
|
||||
? `${parsed.hostname}:3001`
|
||||
: parsed.host;
|
||||
const wsPath = parsed.port === '3000' ? '/ws/sensing' : WS_PATH;
|
||||
return `${proto}//${wsHost}${wsPath}`;
|
||||
}
|
||||
|
||||
private handleStatusChange(status: ConnectionStatus): void {
|
||||
if (status === this.status) {
|
||||
return;
|
||||
}
|
||||
this.status = status;
|
||||
usePoseStore.getState().setConnectionStatus(status);
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (!this.active) {
|
||||
this.handleStatusChange('disconnected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
this.handleStatusChange('simulated');
|
||||
this.startSimulation();
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = RECONNECT_DELAYS[Math.min(this.reconnectAttempt, RECONNECT_DELAYS.length - 1)];
|
||||
this.reconnectAttempt += 1;
|
||||
this.clearReconnectTimer();
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect(this.targetUrl);
|
||||
}, delay);
|
||||
this.startSimulation();
|
||||
}
|
||||
|
||||
private startSimulation(): void {
|
||||
if (this.simulationTimer) {
|
||||
return;
|
||||
}
|
||||
this.simulationTimer = setInterval(() => {
|
||||
this.handleStatusChange('simulated');
|
||||
const frame = generateSimulatedData();
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(frame);
|
||||
});
|
||||
}, SIMULATION_TICK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopSimulation(): void {
|
||||
if (this.simulationTimer) {
|
||||
clearInterval(this.simulationTimer);
|
||||
this.simulationTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const wsService = new WsService();
|
||||
@@ -0,0 +1,74 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Alert, DisasterEvent, ScanZone, Survivor } from '@/types/mat';
|
||||
|
||||
export interface MatState {
|
||||
events: DisasterEvent[];
|
||||
zones: ScanZone[];
|
||||
survivors: Survivor[];
|
||||
alerts: Alert[];
|
||||
selectedEventId: string | null;
|
||||
upsertEvent: (event: DisasterEvent) => void;
|
||||
addZone: (zone: ScanZone) => void;
|
||||
upsertSurvivor: (survivor: Survivor) => void;
|
||||
addAlert: (alert: Alert) => void;
|
||||
setSelectedEvent: (id: string | null) => void;
|
||||
}
|
||||
|
||||
export const useMatStore = create<MatState>((set) => ({
|
||||
events: [],
|
||||
zones: [],
|
||||
survivors: [],
|
||||
alerts: [],
|
||||
selectedEventId: null,
|
||||
|
||||
upsertEvent: (event) => {
|
||||
set((state) => {
|
||||
const index = state.events.findIndex((item) => item.event_id === event.event_id);
|
||||
if (index === -1) {
|
||||
return { events: [...state.events, event] };
|
||||
}
|
||||
const events = [...state.events];
|
||||
events[index] = event;
|
||||
return { events };
|
||||
});
|
||||
},
|
||||
|
||||
addZone: (zone) => {
|
||||
set((state) => {
|
||||
const index = state.zones.findIndex((item) => item.id === zone.id);
|
||||
if (index === -1) {
|
||||
return { zones: [...state.zones, zone] };
|
||||
}
|
||||
const zones = [...state.zones];
|
||||
zones[index] = zone;
|
||||
return { zones };
|
||||
});
|
||||
},
|
||||
|
||||
upsertSurvivor: (survivor) => {
|
||||
set((state) => {
|
||||
const index = state.survivors.findIndex((item) => item.id === survivor.id);
|
||||
if (index === -1) {
|
||||
return { survivors: [...state.survivors, survivor] };
|
||||
}
|
||||
const survivors = [...state.survivors];
|
||||
survivors[index] = survivor;
|
||||
return { survivors };
|
||||
});
|
||||
},
|
||||
|
||||
addAlert: (alert) => {
|
||||
set((state) => {
|
||||
if (state.alerts.some((item) => item.id === alert.id)) {
|
||||
return {
|
||||
alerts: state.alerts.map((item) => (item.id === alert.id ? alert : item)),
|
||||
};
|
||||
}
|
||||
return { alerts: [...state.alerts, alert] };
|
||||
});
|
||||
},
|
||||
|
||||
setSelectedEvent: (id) => {
|
||||
set({ selectedEventId: id });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,71 @@
|
||||
import { create } from 'zustand';
|
||||
import { RingBuffer } from '@/utils/ringBuffer';
|
||||
import type { Classification, ConnectionStatus, FeatureSet, SensingFrame, SignalField } from '@/types/sensing';
|
||||
|
||||
export interface PoseState {
|
||||
connectionStatus: ConnectionStatus;
|
||||
isSimulated: boolean;
|
||||
lastFrame: SensingFrame | null;
|
||||
rssiHistory: number[];
|
||||
features: FeatureSet | null;
|
||||
classification: Classification | null;
|
||||
signalField: SignalField | null;
|
||||
messageCount: number;
|
||||
uptimeStart: number | null;
|
||||
handleFrame: (frame: SensingFrame) => void;
|
||||
setConnectionStatus: (status: ConnectionStatus) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const MAX_RSSI_HISTORY = 60;
|
||||
const rssiHistory = new RingBuffer<number>(MAX_RSSI_HISTORY, (a, b) => a - b);
|
||||
|
||||
export const usePoseStore = create<PoseState>((set) => ({
|
||||
connectionStatus: 'disconnected',
|
||||
isSimulated: false,
|
||||
lastFrame: null,
|
||||
rssiHistory: [],
|
||||
features: null,
|
||||
classification: null,
|
||||
signalField: null,
|
||||
messageCount: 0,
|
||||
uptimeStart: null,
|
||||
|
||||
handleFrame: (frame: SensingFrame) => {
|
||||
if (typeof frame.features?.mean_rssi === 'number') {
|
||||
rssiHistory.push(frame.features.mean_rssi);
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
lastFrame: frame,
|
||||
features: frame.features,
|
||||
classification: frame.classification,
|
||||
signalField: frame.signal_field,
|
||||
messageCount: state.messageCount + 1,
|
||||
uptimeStart: state.uptimeStart ?? Date.now(),
|
||||
rssiHistory: rssiHistory.toArray(),
|
||||
}));
|
||||
},
|
||||
|
||||
setConnectionStatus: (status: ConnectionStatus) => {
|
||||
set({
|
||||
connectionStatus: status,
|
||||
isSimulated: status === 'simulated',
|
||||
});
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
rssiHistory.clear();
|
||||
set({
|
||||
connectionStatus: 'disconnected',
|
||||
isSimulated: false,
|
||||
lastFrame: null,
|
||||
rssiHistory: [],
|
||||
features: null,
|
||||
classification: null,
|
||||
signalField: null,
|
||||
messageCount: 0,
|
||||
uptimeStart: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,47 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface SettingsState {
|
||||
serverUrl: string;
|
||||
rssiScanEnabled: boolean;
|
||||
theme: Theme;
|
||||
alertSoundEnabled: boolean;
|
||||
setServerUrl: (url: string) => void;
|
||||
setRssiScanEnabled: (value: boolean) => void;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setAlertSoundEnabled: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
serverUrl: 'http://localhost:3000',
|
||||
rssiScanEnabled: false,
|
||||
theme: 'system',
|
||||
alertSoundEnabled: true,
|
||||
|
||||
setServerUrl: (url) => {
|
||||
set({ serverUrl: url });
|
||||
},
|
||||
|
||||
setRssiScanEnabled: (value) => {
|
||||
set({ rssiScanEnabled: value });
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
set({ theme });
|
||||
},
|
||||
|
||||
setAlertSoundEnabled: (value) => {
|
||||
set({ alertSoundEnabled: value });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'wifi-densepose-settings',
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { useColorScheme } from 'react-native';
|
||||
import { colors } from './colors';
|
||||
import { spacing } from './spacing';
|
||||
import { typography } from './typography';
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export type ThemeContextValue = {
|
||||
colors: typeof colors;
|
||||
typography: typeof typography;
|
||||
spacing: typeof spacing;
|
||||
isDark: boolean;
|
||||
};
|
||||
|
||||
const fallbackThemeValue: ThemeContextValue = {
|
||||
colors,
|
||||
typography,
|
||||
spacing,
|
||||
isDark: true,
|
||||
};
|
||||
|
||||
export const ThemeContext = React.createContext<ThemeContextValue>(fallbackThemeValue);
|
||||
|
||||
const isValidThemeMode = (value: unknown): value is ThemeMode => {
|
||||
return value === 'light' || value === 'dark' || value === 'system';
|
||||
};
|
||||
|
||||
const readThemeFromSettings = async (): Promise<ThemeMode> => {
|
||||
try {
|
||||
const settingsStore = (await import('../stores/settingsStore')) as Record<string, unknown>;
|
||||
const stateAccessors = [settingsStore.useSettingsStore, (settingsStore as { useStore?: unknown }).useStore].filter(
|
||||
(candidate): candidate is { getState: () => { theme?: unknown } } =>
|
||||
typeof candidate === 'function' &&
|
||||
typeof (candidate as { getState?: unknown }).getState === 'function',
|
||||
);
|
||||
|
||||
for (const accessor of stateAccessors) {
|
||||
const state = accessor.getState?.() as { theme?: unknown } | undefined;
|
||||
const candidateTheme = state?.theme;
|
||||
if (isValidThemeMode(candidateTheme)) {
|
||||
return candidateTheme;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No-op if store is unavailable during bootstrap.
|
||||
}
|
||||
|
||||
return 'system';
|
||||
};
|
||||
|
||||
export const ThemeProvider = ({ children }: PropsWithChildren<object>) => {
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>('system');
|
||||
const systemScheme = useColorScheme() ?? 'light';
|
||||
|
||||
useEffect(() => {
|
||||
void readThemeFromSettings().then(setThemeMode);
|
||||
}, []);
|
||||
|
||||
const isDark = themeMode === 'dark' || (themeMode === 'system' && systemScheme === 'dark');
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
colors,
|
||||
typography,
|
||||
spacing,
|
||||
isDark,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
export const colors = {
|
||||
bg: '#0A0E1A',
|
||||
surface: '#111827',
|
||||
surfaceAlt: '#1A2233',
|
||||
accent: '#32B8C6',
|
||||
accentDim: '#1A6B73',
|
||||
danger: '#FF4757',
|
||||
warn: '#FFA502',
|
||||
success: '#2ED573',
|
||||
textPrimary: '#E2E8F0',
|
||||
textSecondary: '#94A3B8',
|
||||
muted: '#475569',
|
||||
border: '#1E293B',
|
||||
connected: '#2ED573',
|
||||
simulated: '#FFA502',
|
||||
disconnected: '#FF4757',
|
||||
signalLow: '#3B82F6',
|
||||
signalMid: '#10B981',
|
||||
signalHigh: '#EF4444',
|
||||
};
|
||||
|
||||
export type ColorKey = keyof typeof colors;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './colors';
|
||||
export * from './spacing';
|
||||
export * from './typography';
|
||||
export * from './ThemeContext';
|
||||
@@ -0,0 +1,10 @@
|
||||
export const spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
xxl: 24,
|
||||
xxxl: 32,
|
||||
huge: 48,
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export const typography = {
|
||||
displayXl: { fontSize: 48, fontWeight: '700', letterSpacing: -1 },
|
||||
displayLg: { fontSize: 32, fontWeight: '700', letterSpacing: -0.5 },
|
||||
displayMd: { fontSize: 24, fontWeight: '600' },
|
||||
labelLg: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
labelMd: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
bodyLg: { fontSize: 16, fontWeight: '400' },
|
||||
bodyMd: { fontSize: 14, fontWeight: '400' },
|
||||
bodySm: { fontSize: 12, fontWeight: '400' },
|
||||
mono: {
|
||||
fontFamily: Platform.OS === 'ios' ? 'Courier New' : 'monospace',
|
||||
fontSize: 13,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SensingFrame } from './sensing';
|
||||
|
||||
export interface PoseStatus {
|
||||
status?: string;
|
||||
healthy?: boolean;
|
||||
services?: Record<string, unknown>;
|
||||
streaming?: {
|
||||
active?: boolean;
|
||||
active_connections?: number;
|
||||
total_messages?: number;
|
||||
uptime?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
timestamp?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ZoneConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'rectangle' | 'circle' | 'polygon';
|
||||
status?: string;
|
||||
scan_count?: number;
|
||||
detection_count?: number;
|
||||
bounds?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HistoricalFrames {
|
||||
frames: SensingFrame[];
|
||||
limit?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module '*.html' {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
export enum DisasterType {
|
||||
BuildingCollapse = 0,
|
||||
Earthquake = 1,
|
||||
Landslide = 2,
|
||||
Avalanche = 3,
|
||||
Flood = 4,
|
||||
MineCollapse = 5,
|
||||
Industrial = 6,
|
||||
TunnelCollapse = 7,
|
||||
Unknown = 8,
|
||||
}
|
||||
|
||||
export enum TriageStatus {
|
||||
Immediate = 0,
|
||||
Delayed = 1,
|
||||
Minor = 2,
|
||||
Deceased = 3,
|
||||
Unknown = 4,
|
||||
}
|
||||
|
||||
export enum ZoneStatus {
|
||||
Active = 0,
|
||||
Paused = 1,
|
||||
Complete = 2,
|
||||
Inaccessible = 3,
|
||||
}
|
||||
|
||||
export enum AlertPriority {
|
||||
Critical = 0,
|
||||
High = 1,
|
||||
Medium = 2,
|
||||
Low = 3,
|
||||
}
|
||||
|
||||
export interface DisasterEvent {
|
||||
event_id: string;
|
||||
disaster_type: DisasterType;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface RectangleZone {
|
||||
id: string;
|
||||
name: string;
|
||||
zone_type: 'rectangle';
|
||||
status: ZoneStatus;
|
||||
scan_count: number;
|
||||
detection_count: number;
|
||||
bounds_json: string;
|
||||
}
|
||||
|
||||
export interface CircleZone {
|
||||
id: string;
|
||||
name: string;
|
||||
zone_type: 'circle';
|
||||
status: ZoneStatus;
|
||||
scan_count: number;
|
||||
detection_count: number;
|
||||
bounds_json: string;
|
||||
}
|
||||
|
||||
export type ScanZone = RectangleZone | CircleZone;
|
||||
|
||||
export interface Survivor {
|
||||
id: string;
|
||||
zone_id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
depth: number;
|
||||
triage_status: TriageStatus;
|
||||
triage_color: string;
|
||||
confidence: number;
|
||||
breathing_rate: number;
|
||||
heart_rate: number;
|
||||
first_detected: string;
|
||||
last_updated: string;
|
||||
is_deteriorating: boolean;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
survivor_id: string;
|
||||
priority: AlertPriority;
|
||||
title: string;
|
||||
message: string;
|
||||
recommended_action: string;
|
||||
triage_status: TriageStatus;
|
||||
location_x: number;
|
||||
location_y: number;
|
||||
created_at: string;
|
||||
priority_color: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type RootStackParamList = {
|
||||
MainTabs: undefined;
|
||||
};
|
||||
|
||||
export type MainTabsParamList = {
|
||||
Live: undefined;
|
||||
Vitals: undefined;
|
||||
Zones: undefined;
|
||||
MAT: undefined;
|
||||
Settings: undefined;
|
||||
};
|
||||
|
||||
export type LiveScreenParams = undefined;
|
||||
export type VitalsScreenParams = undefined;
|
||||
export type ZonesScreenParams = undefined;
|
||||
export type MATScreenParams = undefined;
|
||||
export type SettingsScreenParams = undefined;
|
||||
@@ -0,0 +1,14 @@
|
||||
declare module '@react-native-wifi-reborn' {
|
||||
interface NativeWifiNetwork {
|
||||
SSID?: string;
|
||||
BSSID?: string;
|
||||
level?: number;
|
||||
levelDbm?: number;
|
||||
}
|
||||
|
||||
const WifiManager: {
|
||||
loadWifiList: () => Promise<NativeWifiNetwork[]>;
|
||||
};
|
||||
|
||||
export default WifiManager;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'simulated';
|
||||
|
||||
export interface SensingNode {
|
||||
node_id: number;
|
||||
rssi_dbm: number;
|
||||
position: [number, number, number];
|
||||
amplitude?: number[];
|
||||
subcarrier_count?: number;
|
||||
}
|
||||
|
||||
export interface FeatureSet {
|
||||
mean_rssi: number;
|
||||
variance: number;
|
||||
motion_band_power: number;
|
||||
breathing_band_power: number;
|
||||
spectral_entropy: number;
|
||||
std?: number;
|
||||
dominant_freq_hz?: number;
|
||||
change_points?: number;
|
||||
spectral_power?: number;
|
||||
}
|
||||
|
||||
export interface Classification {
|
||||
motion_level: 'absent' | 'present_still' | 'active';
|
||||
presence: boolean;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface SignalField {
|
||||
grid_size: [number, number, number];
|
||||
values: number[];
|
||||
}
|
||||
|
||||
export interface VitalsData {
|
||||
breathing_bpm: number;
|
||||
hr_proxy_bpm: number;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface SensingFrame {
|
||||
type?: string;
|
||||
timestamp?: number;
|
||||
source?: string;
|
||||
tick?: number;
|
||||
nodes: SensingNode[];
|
||||
features: FeatureSet;
|
||||
classification: Classification;
|
||||
signal_field: SignalField;
|
||||
vital_signs?: VitalsData;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function valueToColor(v: number): [number, number, number] {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
|
||||
let r: number;
|
||||
let g: number;
|
||||
let b: number;
|
||||
|
||||
if (clamped < 0.5) {
|
||||
const t = clamped * 2;
|
||||
r = 0;
|
||||
g = t;
|
||||
b = 1 - t;
|
||||
} else {
|
||||
const t = (clamped - 0.5) * 2;
|
||||
r = t;
|
||||
g = 1 - t;
|
||||
b = 0;
|
||||
}
|
||||
|
||||
return [r, g, b];
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export function formatRssi(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||
return '-- dBm';
|
||||
}
|
||||
return `${Math.round(v)} dBm`;
|
||||
}
|
||||
|
||||
export function formatBpm(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||
return '--';
|
||||
}
|
||||
return `${Math.round(v)} BPM`;
|
||||
}
|
||||
|
||||
export function formatConfidence(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
||||
return '--';
|
||||
}
|
||||
const normalized = v > 1 ? v / 100 : v;
|
||||
return `${Math.round(Math.max(0, Math.min(1, normalized)) * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatUptime(ms: number | null | undefined): string {
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms) || ms < 0) {
|
||||
return '--:--:--';
|
||||
}
|
||||
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export class RingBuffer<T> {
|
||||
private readonly capacity: number;
|
||||
private readonly compare?: (a: T, b: T) => number;
|
||||
private readonly values: T[] = [];
|
||||
|
||||
constructor(capacity: number, compare?: (a: T, b: T) => number) {
|
||||
if (!Number.isFinite(capacity) || capacity <= 0) {
|
||||
throw new Error('RingBuffer capacity must be greater than 0');
|
||||
}
|
||||
this.capacity = Math.floor(capacity);
|
||||
this.compare = compare;
|
||||
}
|
||||
|
||||
push(v: T): void {
|
||||
this.values.push(v);
|
||||
if (this.values.length > this.capacity) {
|
||||
this.values.shift();
|
||||
}
|
||||
}
|
||||
|
||||
toArray(): T[] {
|
||||
return [...this.values];
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.values.length = 0;
|
||||
}
|
||||
|
||||
get max(): T | null {
|
||||
if (this.values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!this.compare) {
|
||||
throw new Error('Comparator required for max()');
|
||||
}
|
||||
return this.values.reduce((acc, value) => (this.compare!(value, acc) > 0 ? value : acc), this.values[0]);
|
||||
}
|
||||
|
||||
get min(): T | null {
|
||||
if (this.values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (!this.compare) {
|
||||
throw new Error('Comparator required for min()');
|
||||
}
|
||||
return this.values.reduce((acc, value) => (this.compare!(value, acc) < 0 ? value : acc), this.values[0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ws:', 'wss:']);
|
||||
|
||||
export interface UrlValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function validateServerUrl(url: string): UrlValidationResult {
|
||||
if (typeof url !== 'string' || !url.trim()) {
|
||||
return { valid: false, error: 'URL must be a non-empty string.' };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!ALLOWED_PROTOCOLS.has(parsed.protocol)) {
|
||||
return { valid: false, error: 'URL must use http, https, ws, or wss.' };
|
||||
}
|
||||
if (!parsed.host) {
|
||||
return { valid: false, error: 'URL must include a host.' };
|
||||
}
|
||||
return { valid: true };
|
||||
} catch {
|
||||
return { valid: false, error: 'Invalid URL format.' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user