dynamic graph
This commit is contained in:
parent
a606796d9e
commit
5e029ff99c
17 changed files with 1707 additions and 569 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -39,3 +39,5 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
/data
|
38
app/api/sensors/history/route.ts
Normal file
38
app/api/sensors/history/route.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { SensorDataStorage } from '@/services/SensorDataStorage';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sensorId = searchParams.get('sensorId');
|
||||
const timespan = parseInt(searchParams.get('timespan') || '86400000'); // Default 24 hours in ms
|
||||
|
||||
if (!sensorId) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'sensorId parameter is required'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const sensorDataStorage = SensorDataStorage.getInstance();
|
||||
const sensorData = await sensorDataStorage.getDataForSensor(sensorId, timespan);
|
||||
|
||||
const timeSeriesData = sensorDataStorage.generateTimeSeriesData(sensorData, timespan);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sensorId,
|
||||
timespan,
|
||||
dataPoints: sensorData.length,
|
||||
data: timeSeriesData,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Sensor history API error:', error);
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: 'Failed to get sensor history data'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
|
@ -1,37 +1,38 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { BedHardware, PinState, PinChange } from '@/services/BedHardware';
|
||||
import { SensorDataStorage, SensorDataPoint } from '@/services/SensorDataStorage';
|
||||
|
||||
// Complete sensor configuration with positions and pin mappings
|
||||
// Complete sensor configuration with positions, pin mappings, and thresholds
|
||||
const SENSOR_CONFIG = [
|
||||
// Head area
|
||||
{ id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 15 },
|
||||
{ id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 12 },
|
||||
{ id: "head-1", x: 45, y: 15, zone: "head", label: "Head Left", pin: 2, baseNoise: 200, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 },
|
||||
{ id: "head-2", x: 55, y: 15, zone: "head", label: "Head Right", pin: 3, baseNoise: 150, warningThreshold: 3000, alarmThreshold: 3500, warningDelayMs: 30000 },
|
||||
|
||||
// Shoulder area
|
||||
{ id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4, baseNoise: 20 },
|
||||
{ id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5, baseNoise: 18 },
|
||||
{ id: "shoulder-1", x: 35, y: 25, zone: "shoulders", label: "Left Shoulder", pin: 4, baseNoise: 250, warningThreshold: 2800, alarmThreshold: 3200, warningDelayMs: 45000 },
|
||||
{ id: "shoulder-2", x: 65, y: 25, zone: "shoulders", label: "Right Shoulder", pin: 5, baseNoise: 220, warningThreshold: 2800, alarmThreshold: 3200, warningDelayMs: 45000 },
|
||||
|
||||
// Upper back
|
||||
{ id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6, baseNoise: 25 },
|
||||
{ id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7, baseNoise: 30 },
|
||||
{ id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8, baseNoise: 22 },
|
||||
{ id: "back-1", x: 40, y: 35, zone: "back", label: "Upper Back Left", pin: 6, baseNoise: 300, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
|
||||
{ id: "back-2", x: 50, y: 35, zone: "back", label: "Upper Back Center", pin: 7, baseNoise: 350, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
|
||||
{ id: "back-3", x: 60, y: 35, zone: "back", label: "Upper Back Right", pin: 8, baseNoise: 280, warningThreshold: 2500, alarmThreshold: 3000, warningDelayMs: 60000 },
|
||||
|
||||
// Lower back/Hip area
|
||||
{ id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 35 },
|
||||
{ id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 40 },
|
||||
{ id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 32 },
|
||||
{ id: "hip-1", x: 35, y: 50, zone: "hips", label: "Left Hip", pin: 9, baseNoise: 400, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
|
||||
{ id: "hip-2", x: 50, y: 50, zone: "hips", label: "Lower Back", pin: 10, baseNoise: 450, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
|
||||
{ id: "hip-3", x: 65, y: 50, zone: "hips", label: "Right Hip", pin: 11, baseNoise: 380, warningThreshold: 2200, alarmThreshold: 2800, warningDelayMs: 90000 },
|
||||
|
||||
// Thigh area
|
||||
{ id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 28 },
|
||||
{ id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 26 },
|
||||
{ id: "thigh-1", x: 40, y: 65, zone: "legs", label: "Left Thigh", pin: 12, baseNoise: 320, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 },
|
||||
{ id: "thigh-2", x: 60, y: 65, zone: "legs", label: "Right Thigh", pin: 13, baseNoise: 300, warningThreshold: 2000, alarmThreshold: 2500, warningDelayMs: 120000 },
|
||||
|
||||
// Calf area (mock data)
|
||||
{ id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf", baseNoise: 15 },
|
||||
{ id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf", baseNoise: 18 },
|
||||
{ id: "calf-1", x: 40, y: 75, zone: "legs", label: "Left Calf", baseNoise: 200, warningThreshold: 1800, alarmThreshold: 2200, warningDelayMs: 150000 },
|
||||
{ id: "calf-2", x: 60, y: 75, zone: "legs", label: "Right Calf", baseNoise: 220, warningThreshold: 1800, alarmThreshold: 2200, warningDelayMs: 150000 },
|
||||
|
||||
// Feet (mock data)
|
||||
{ id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot", baseNoise: 10 },
|
||||
{ id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot", baseNoise: 12 },
|
||||
{ id: "feet-1", x: 45, y: 85, zone: "feet", label: "Left Foot", baseNoise: 150, warningThreshold: 1500, alarmThreshold: 1800, warningDelayMs: 180000 },
|
||||
{ id: "feet-2", x: 55, y: 85, zone: "feet", label: "Right Foot", baseNoise: 160, warningThreshold: 1500, alarmThreshold: 1800, warningDelayMs: 180000 },
|
||||
];
|
||||
|
||||
// Create pin mapping from sensor config
|
||||
|
@ -43,19 +44,24 @@ SENSOR_CONFIG.forEach(sensor => {
|
|||
});
|
||||
|
||||
let bedHardware: BedHardware | null = null;
|
||||
const sensorDataStorage = SensorDataStorage.getInstance();
|
||||
const sensorData: Record<string, {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
label: string;
|
||||
zone: string;
|
||||
pressure: number;
|
||||
value: number; // Changed from pressure to value (0-4095)
|
||||
pin?: number;
|
||||
digitalState?: number;
|
||||
timestamp: string;
|
||||
source: 'hardware' | 'mock';
|
||||
data: Array<{ time: string; timestamp: number; pressure: number }>;
|
||||
data: Array<{ time: string; timestamp: number; value: number }>;
|
||||
status: string;
|
||||
warningStartTime?: number; // Track when warning state started
|
||||
warningThreshold: number;
|
||||
alarmThreshold: number;
|
||||
warningDelayMs: number;
|
||||
}> = {};
|
||||
let isHardwareConnected = false;
|
||||
|
||||
|
@ -69,12 +75,15 @@ function initializeSensorData() {
|
|||
y: sensor.y,
|
||||
label: sensor.label,
|
||||
zone: sensor.zone,
|
||||
pressure: 30 + Math.random() * 20, // Start with baseline pressure
|
||||
value: 1000 + Math.random() * 500, // Start with baseline analog value (1000-1500)
|
||||
pin: sensor.pin,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: sensor.pin ? 'hardware' : 'mock',
|
||||
data: generateTimeSeriesData(),
|
||||
status: 'normal'
|
||||
status: 'normal',
|
||||
warningThreshold: sensor.warningThreshold,
|
||||
alarmThreshold: sensor.alarmThreshold,
|
||||
warningDelayMs: sensor.warningDelayMs
|
||||
};
|
||||
}
|
||||
});
|
||||
|
@ -90,7 +99,7 @@ function generateTimeSeriesData(hours = 1) {
|
|||
data.push({
|
||||
time: time.toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: time.getTime(),
|
||||
pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40,
|
||||
value: Math.floor(Math.random() * 4096 + Math.sin(i / 60) * 500 + 2000), // 0-4095 range
|
||||
});
|
||||
}
|
||||
return data;
|
||||
|
@ -139,47 +148,81 @@ async function initializeHardware() {
|
|||
}
|
||||
}
|
||||
|
||||
// Convert digital pin state to analog pressure value with noise
|
||||
function digitalToPressure(pinState: number, baseNoise: number): number {
|
||||
// Base pressure from digital state
|
||||
const basePressure = pinState === 1 ? 60 : 20; // High when pin is HIGH, low when LOW
|
||||
// Convert digital pin state to analog value with noise
|
||||
function digitalToAnalogValue(pinState: number, baseNoise: number): number {
|
||||
// Base value from digital state
|
||||
const baseValue = pinState === 1 ? 3000 : 1000; // High when pin is HIGH, low when LOW
|
||||
|
||||
// Add realistic noise and variation
|
||||
const timeNoise = Math.sin(Date.now() / 10000) * 10; // Slow oscillation
|
||||
const timeNoise = Math.sin(Date.now() / 10000) * 200; // Slow oscillation
|
||||
const randomNoise = (Math.random() - 0.5) * baseNoise;
|
||||
const sensorDrift = (Math.random() - 0.5) * 5; // Small drift
|
||||
const sensorDrift = (Math.random() - 0.5) * 50; // Small drift
|
||||
|
||||
const pressure = basePressure + timeNoise + randomNoise + sensorDrift;
|
||||
const value = baseValue + timeNoise + randomNoise + sensorDrift;
|
||||
|
||||
// Clamp between 0 and 100
|
||||
return Math.max(0, Math.min(100, pressure));
|
||||
// Clamp between 0 and 4095
|
||||
return Math.max(0, Math.min(4095, Math.floor(value)));
|
||||
}
|
||||
|
||||
// Update sensor data from pin change
|
||||
function updateSensorFromPin(pin: number, state: number) {
|
||||
async function updateSensorFromPin(pin: number, state: number) {
|
||||
const mapping = PIN_SENSOR_MAP[pin];
|
||||
if (!mapping) return;
|
||||
|
||||
const pressure = digitalToPressure(state, mapping.baseNoise);
|
||||
const value = digitalToAnalogValue(state, mapping.baseNoise);
|
||||
const timestamp = Date.now();
|
||||
const time = new Date(timestamp).toLocaleTimeString("en-US", { hour12: false });
|
||||
|
||||
// Save to persistent storage
|
||||
const dataPoint: SensorDataPoint = {
|
||||
sensorId: mapping.id,
|
||||
value,
|
||||
timestamp,
|
||||
time,
|
||||
source: 'hardware',
|
||||
pin,
|
||||
digitalState: state
|
||||
};
|
||||
await sensorDataStorage.addDataPoint(dataPoint);
|
||||
|
||||
if (sensorData[mapping.id]) {
|
||||
// Update existing sensor data
|
||||
const currentData = sensorData[mapping.id];
|
||||
|
||||
// Determine status based on thresholds
|
||||
let status = 'normal';
|
||||
let warningStartTime = currentData.warningStartTime;
|
||||
|
||||
if (value >= mapping.alarmThreshold) {
|
||||
status = 'alarm';
|
||||
warningStartTime = undefined; // Clear warning timer for immediate alarm
|
||||
} else if (value >= mapping.warningThreshold) {
|
||||
status = 'warning';
|
||||
if (!warningStartTime) {
|
||||
warningStartTime = timestamp; // Start warning timer
|
||||
} else if (timestamp - warningStartTime >= mapping.warningDelayMs) {
|
||||
status = 'alarm'; // Escalate to alarm after delay
|
||||
}
|
||||
} else {
|
||||
warningStartTime = undefined; // Clear warning timer
|
||||
}
|
||||
|
||||
sensorData[mapping.id] = {
|
||||
...currentData,
|
||||
pressure,
|
||||
value,
|
||||
digitalState: state,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: 'hardware',
|
||||
data: [
|
||||
...currentData.data.slice(-287), // Keep last ~24 hours (288 points at 5min intervals)
|
||||
{
|
||||
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: Date.now(),
|
||||
pressure: pressure,
|
||||
time,
|
||||
timestamp,
|
||||
value: value,
|
||||
}
|
||||
],
|
||||
status: pressure > 80 ? 'critical' : pressure > 60 ? 'warning' : 'normal'
|
||||
status,
|
||||
warningStartTime
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -190,22 +233,23 @@ function updateMockSensorData() {
|
|||
if (!sensor.pin && sensorData[sensor.id]) {
|
||||
// This is a mock sensor, update with variation
|
||||
const currentSensor = sensorData[sensor.id];
|
||||
const variation = (Math.random() - 0.5) * 10;
|
||||
const newPressure = Math.max(0, Math.min(100, currentSensor.pressure + variation));
|
||||
const variation = (Math.random() - 0.5) * 200; // Larger variation for analog values
|
||||
const newValue = Math.max(0, Math.min(4095, currentSensor.value + variation));
|
||||
|
||||
sensorData[sensor.id] = {
|
||||
...currentSensor,
|
||||
pressure: newPressure,
|
||||
value: newValue,
|
||||
timestamp: new Date().toISOString(),
|
||||
data: [
|
||||
...currentSensor.data.slice(-287), // Keep last ~24 hours
|
||||
{
|
||||
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: Date.now(),
|
||||
pressure: newPressure,
|
||||
value: newValue,
|
||||
}
|
||||
],
|
||||
status: newPressure > 80 ? 'critical' : newPressure > 60 ? 'warning' : 'normal'
|
||||
status: newValue >= sensor.alarmThreshold ? 'alarm' :
|
||||
newValue >= sensor.warningThreshold ? 'warning' : 'normal'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
@ -229,9 +273,9 @@ export async function GET() {
|
|||
// If hardware is connected, get current pin states
|
||||
if (isHardwareConnected && bedHardware) {
|
||||
const pinStates = bedHardware.getAllPinStates();
|
||||
pinStates.forEach(pinState => {
|
||||
updateSensorFromPin(pinState.pin, pinState.state);
|
||||
});
|
||||
for (const pinState of pinStates) {
|
||||
await updateSensorFromPin(pinState.pin, pinState.state);
|
||||
}
|
||||
}
|
||||
|
||||
// Return all sensor data
|
||||
|
|
97
bun.lock
97
bun.lock
|
@ -4,6 +4,7 @@
|
|||
"": {
|
||||
"name": "m2-inno-bedpressure",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@types/serialport": "^10.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
@ -11,23 +12,25 @@
|
|||
"lucide-react": "^0.519.0",
|
||||
"next": "15.3.4",
|
||||
"port": "^0.8.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"serial": "^0.0.9",
|
||||
"serialport": "^13.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/node": "^20.19.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"tailwindcss": "^4",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5",
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -62,6 +65,14 @@
|
|||
|
||||
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.2", "", { "dependencies": { "@eslint/core": "^0.15.0", "levn": "^0.4.1" } }, "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.1", "", { "dependencies": { "@floating-ui/utils": "^0.2.9" } }, "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.1", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/utils": "^0.2.9" } }, "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.3", "", { "dependencies": { "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.9", "", {}, "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="],
|
||||
|
||||
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
||||
|
||||
"@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
|
||||
|
@ -154,10 +165,58 @@
|
|||
|
||||
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
|
||||
|
||||
"@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.11.0", "", {}, "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ=="],
|
||||
|
@ -326,6 +385,8 @@
|
|||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||
|
@ -438,6 +499,8 @@
|
|||
|
||||
"detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
@ -534,6 +597,8 @@
|
|||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
|
||||
|
@ -780,8 +845,14 @@
|
|||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.1", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
|
||||
|
@ -910,6 +981,10 @@
|
|||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
@ -928,6 +1003,8 @@
|
|||
|
||||
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"zustand": ["zustand@5.0.5", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg=="],
|
||||
|
||||
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||
|
||||
"@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw=="],
|
||||
|
|
|
@ -1,524 +1,39 @@
|
|||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { Activity, AlertTriangle, Download, Pause, Play, Settings, User } from "lucide-react"
|
||||
|
||||
// Mock data generator
|
||||
const generateTimeSeriesData = (hours = 24) => {
|
||||
const data = []
|
||||
const now = new Date()
|
||||
|
||||
for (let i = hours * 60; i >= 0; i -= 5) {
|
||||
const time = new Date(now.getTime() - i * 60 * 1000)
|
||||
data.push({
|
||||
time: time.toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: time.getTime(),
|
||||
pressure: Math.random() * 100 + Math.sin(i / 60) * 20 + 40,
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// Sensor configuration interface
|
||||
interface SensorConfig {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
zone: string;
|
||||
label: string;
|
||||
pin?: number;
|
||||
}
|
||||
|
||||
const getPressureColor = (pressure: number) => {
|
||||
if (pressure < 30) return "#22c55e" // Green - Low pressure
|
||||
if (pressure < 50) return "#eab308" // Yellow - Medium pressure
|
||||
if (pressure < 70) return "#f97316" // Orange - High pressure
|
||||
return "#ef4444" // Red - Very high pressure
|
||||
}
|
||||
|
||||
const getPressureLevel = (pressure: number) => {
|
||||
if (pressure < 30) return "Low"
|
||||
if (pressure < 50) return "Medium"
|
||||
if (pressure < 70) return "High"
|
||||
return "Critical"
|
||||
}
|
||||
|
||||
interface SensorData {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
zone: string;
|
||||
label: string;
|
||||
currentPressure: number;
|
||||
data: Array<{ time: string; timestamp: number; pressure: number }>;
|
||||
status: string;
|
||||
source?: 'hardware' | 'mock';
|
||||
pin?: number;
|
||||
digitalState?: number;
|
||||
}
|
||||
import { BedPressureHeader } from "./bed-pressure/BedPressureHeader"
|
||||
import { StatsCards } from "./bed-pressure/StatsCards"
|
||||
import { BedVisualization } from "./bed-pressure/BedVisualization"
|
||||
import { AlertsPanel } from "./bed-pressure/AlertsPanel"
|
||||
import { SensorDetailModal } from "./bed-pressure/SensorDetailModal"
|
||||
import { useBedPressureData } from "@/hooks/useBedPressureData"
|
||||
|
||||
export default function Component() {
|
||||
const [sensorData, setSensorData] = useState<Record<string, SensorData>>({})
|
||||
const [sensorConfig, setSensorConfig] = useState<SensorConfig[]>([])
|
||||
const [selectedSensor, setSelectedSensor] = useState<string | null>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isMonitoring, setIsMonitoring] = useState(true)
|
||||
const [alerts, setAlerts] = useState<Array<{ id: string; message: string; time: string }>>([])
|
||||
|
||||
// Initialize sensor configuration
|
||||
useEffect(() => {
|
||||
const fetchSensorConfig = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/sensors/config')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.sensors) {
|
||||
setSensorConfig(data.sensors)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sensor config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSensorConfig()
|
||||
}, [])
|
||||
|
||||
// Initialize sensor data
|
||||
useEffect(() => {
|
||||
if (sensorConfig.length === 0) return
|
||||
|
||||
const initialData: Record<string, SensorData> = {}
|
||||
sensorConfig.forEach((sensor) => {
|
||||
initialData[sensor.id] = {
|
||||
...sensor,
|
||||
currentPressure: Math.random() * 100,
|
||||
data: generateTimeSeriesData(),
|
||||
status: "normal",
|
||||
}
|
||||
})
|
||||
setSensorData(initialData)
|
||||
}, [sensorConfig])
|
||||
|
||||
// Fetch sensor data from API
|
||||
useEffect(() => {
|
||||
if (!isMonitoring) return
|
||||
|
||||
const fetchSensorData = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/sensors')
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success && data.sensors) {
|
||||
setSensorData((prev) => {
|
||||
const updated = { ...prev }
|
||||
const newAlerts: Array<{ id: string; message: string; time: string }> = []
|
||||
|
||||
data.sensors.forEach((sensor: {
|
||||
id: string;
|
||||
label: string;
|
||||
zone: string;
|
||||
pressure: number;
|
||||
source: 'hardware' | 'mock';
|
||||
pin?: number;
|
||||
digitalState?: number;
|
||||
}) => {
|
||||
const currentSensor = updated[sensor.id]
|
||||
const newPressure = sensor.pressure
|
||||
|
||||
// Check for alerts
|
||||
if (newPressure > 80 && currentSensor && currentSensor.currentPressure <= 80) {
|
||||
newAlerts.push({
|
||||
id: `${sensor.id}-${Date.now()}`,
|
||||
message: `High pressure detected at ${sensor.label}`,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
})
|
||||
}
|
||||
|
||||
// Update sensor data
|
||||
updated[sensor.id] = {
|
||||
...sensorConfig.find(s => s.id === sensor.id),
|
||||
id: sensor.id,
|
||||
x: sensorConfig.find(s => s.id === sensor.id)?.x || 50,
|
||||
y: sensorConfig.find(s => s.id === sensor.id)?.y || 50,
|
||||
zone: sensor.zone,
|
||||
label: sensor.label,
|
||||
currentPressure: newPressure,
|
||||
data: currentSensor ? [
|
||||
...currentSensor.data.slice(1),
|
||||
{
|
||||
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: Date.now(),
|
||||
pressure: newPressure,
|
||||
},
|
||||
] : [{
|
||||
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: Date.now(),
|
||||
pressure: newPressure,
|
||||
}],
|
||||
status: newPressure > 80 ? "critical" : newPressure > 60 ? "warning" : "normal",
|
||||
source: sensor.source,
|
||||
pin: sensor.pin,
|
||||
digitalState: sensor.digitalState
|
||||
}
|
||||
})
|
||||
|
||||
if (newAlerts.length > 0) {
|
||||
setAlerts((prev) => [...newAlerts, ...prev].slice(0, 10))
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sensor data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Initial fetch
|
||||
fetchSensorData()
|
||||
|
||||
// Set up polling
|
||||
const interval = setInterval(fetchSensorData, 2000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isMonitoring, sensorConfig])
|
||||
|
||||
const averagePressure =
|
||||
Object.values(sensorData).reduce((sum: number, sensor: SensorData) => sum + (sensor.currentPressure || 0), 0) /
|
||||
Object.keys(sensorData).length
|
||||
|
||||
const criticalSensors = Object.values(sensorData).filter((sensor: SensorData) => sensor.currentPressure > 80).length
|
||||
// Initialize data fetching
|
||||
useBedPressureData()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">Bed Pressure Monitor</h1>
|
||||
</div>
|
||||
<Badge variant={isMonitoring ? "default" : "secondary"} className="px-3 py-1">
|
||||
{isMonitoring ? "Live" : "Paused"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsMonitoring(!isMonitoring)}>
|
||||
{isMonitoring ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{isMonitoring ? "Pause" : "Resume"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="w-4 h-4" />
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
Settings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<BedPressureHeader />
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Patient</p>
|
||||
<p className="font-semibold">John Doe - Room 204</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">Average Pressure</p>
|
||||
<p className="text-2xl font-bold" style={{ color: getPressureColor(averagePressure) }}>
|
||||
{averagePressure.toFixed(1)} mmHg
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">Active Sensors</p>
|
||||
<p className="text-2xl font-bold text-green-600">{Object.keys(sensorData).length}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className={`w-5 h-5 ${criticalSensors > 0 ? "text-red-600" : "text-gray-400"}`} />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Critical Alerts</p>
|
||||
<p className={`text-2xl font-bold ${criticalSensors > 0 ? "text-red-600" : "text-gray-600"}`}>
|
||||
{criticalSensors}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<StatsCards />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Bed Visualization */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pressure Distribution Map</CardTitle>
|
||||
<p className="text-sm text-gray-600">Click on any sensor point to view detailed pressure graphs</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
{/* Bed outline */}
|
||||
<svg viewBox="0 0 100 100" className="w-full h-96 border-2 border-gray-200 rounded-lg bg-white">
|
||||
{/* Bed frame */}
|
||||
<rect x="25" y="10" width="50" height="80" fill="none" stroke="#e5e7eb" strokeWidth="2" rx="8" />
|
||||
|
||||
{/* Pillow area */}
|
||||
<rect x="30" y="12" width="40" height="15" fill="#f3f4f6" stroke="#d1d5db" strokeWidth="1" rx="4" />
|
||||
|
||||
{/* Pressure sensors */}
|
||||
{sensorConfig.map((sensor) => {
|
||||
const sensorInfo = sensorData[sensor.id]
|
||||
if (!sensorInfo) return null
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={sensor.id}
|
||||
cx={sensor.x}
|
||||
cy={sensor.y}
|
||||
r="3"
|
||||
fill={getPressureColor(sensorInfo.currentPressure)}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
className="cursor-pointer transition-all duration-200 hover:r-4 hover:opacity-80"
|
||||
onClick={() => {
|
||||
setSelectedSensor(sensor.id)
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Pressure Legend */}
|
||||
<div className="mt-4 flex items-center justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<span>Low ({"<"}30)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<span>Medium (30-50)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
|
||||
<span>High (50-70)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<span>Critical ({">"}70)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<BedVisualization />
|
||||
</div>
|
||||
|
||||
{/* Sensor Details & Alerts */}
|
||||
{/* Alerts Panel */}
|
||||
<div className="space-y-6">
|
||||
{/* Pressure Graph Modal */}
|
||||
{isModalOpen && selectedSensor && sensorData[selectedSensor] && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{sensorData[selectedSensor].label}</h2>
|
||||
<p className="text-gray-600">Pressure Monitoring Details</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Current Pressure</p>
|
||||
<p
|
||||
className="text-3xl font-bold"
|
||||
style={{ color: getPressureColor(sensorData[selectedSensor].currentPressure) }}
|
||||
>
|
||||
{sensorData[selectedSensor].currentPressure.toFixed(1)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">mmHg</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Status Level</p>
|
||||
<Badge
|
||||
className="text-lg px-3 py-1 mt-2"
|
||||
variant={
|
||||
sensorData[selectedSensor].status === "critical"
|
||||
? "destructive"
|
||||
: sensorData[selectedSensor].status === "warning"
|
||||
? "secondary"
|
||||
: "default"
|
||||
}
|
||||
>
|
||||
{getPressureLevel(sensorData[selectedSensor].currentPressure)}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Body Zone</p>
|
||||
<p className="text-xl font-semibold capitalize mt-2">{sensorData[selectedSensor].zone}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Large Pressure Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>24-Hour Pressure Trend</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={sensorData[selectedSensor].data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
label={{ value: "Pressure (mmHg)", angle: -90, position: "insideLeft" }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value.toFixed(1)} mmHg`, "Pressure"]}
|
||||
labelFormatter={(label) => `Time: ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="pressure"
|
||||
stroke={getPressureColor(sensorData[selectedSensor].currentPressure)}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Additional Statistics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Max Pressure</p>
|
||||
<p className="text-lg font-bold text-red-600">
|
||||
{Math.max(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Min Pressure</p>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{Math.min(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Average</p>
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{(
|
||||
sensorData[selectedSensor].data.reduce((sum: number, d: { time: string; timestamp: number; pressure: number }) => sum + d.pressure, 0) /
|
||||
sensorData[selectedSensor].data.length
|
||||
).toFixed(1)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Data Points</p>
|
||||
<p className="text-lg font-bold text-gray-600">{sensorData[selectedSensor].data.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alerts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Recent Alerts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4">No recent alerts</p>
|
||||
) : (
|
||||
alerts.map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-start gap-3 p-3 bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-red-800">{alert.message}</p>
|
||||
<p className="text-xs text-red-600">{alert.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<AlertsPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sensor Detail Modal */}
|
||||
<SensorDetailModal />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
186
components/bed-pressure/AlertsPanel.tsx
Normal file
186
components/bed-pressure/AlertsPanel.tsx
Normal file
|
@ -0,0 +1,186 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { AlertTriangle, VolumeX, Clock, CheckCircle } from "lucide-react"
|
||||
import { useBedPressureStore } from "@/stores/bedPressureStore"
|
||||
import { useEffect } from "react"
|
||||
|
||||
export function AlertsPanel() {
|
||||
const {
|
||||
alerts,
|
||||
activeAlarms,
|
||||
alarmManager,
|
||||
acknowledgeAlarm,
|
||||
silenceAlarm,
|
||||
silenceAllAlarms
|
||||
} = useBedPressureStore()
|
||||
|
||||
// Update active alarms periodically
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// The store will handle updating alarms automatically
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [alarmManager])
|
||||
|
||||
const handleAcknowledge = (alarmId: string) => {
|
||||
acknowledgeAlarm(alarmId)
|
||||
}
|
||||
|
||||
const handleSilence = (alarmId: string) => {
|
||||
silenceAlarm(alarmId, 300000) // Silence for 5 minutes
|
||||
}
|
||||
|
||||
const handleSilenceAll = () => {
|
||||
silenceAllAlarms(300000) // Silence all for 5 minutes
|
||||
}
|
||||
|
||||
const getAlarmIcon = (type: 'warning' | 'alarm') => {
|
||||
return type === 'alarm' ? (
|
||||
<AlertTriangle className="w-4 h-4 text-red-600 animate-pulse" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-600" />
|
||||
)
|
||||
}
|
||||
|
||||
const getAlarmBgColor = (type: 'warning' | 'alarm', silenced: boolean) => {
|
||||
if (silenced) return "bg-gray-100 border-gray-300"
|
||||
return type === 'alarm' ? "bg-red-50 border-red-200" : "bg-yellow-50 border-yellow-200"
|
||||
}
|
||||
|
||||
const hasActiveAlarms = activeAlarms.some(alarm => !alarm.silenced)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className={`w-5 h-5 ${hasActiveAlarms ? "text-red-600 animate-pulse" : "text-gray-400"}`} />
|
||||
Active Alarms ({activeAlarms.length})
|
||||
</CardTitle>
|
||||
{activeAlarms.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSilenceAll}
|
||||
className="text-orange-600 hover:text-orange-700"
|
||||
>
|
||||
<VolumeX className="w-4 h-4 mr-1" />
|
||||
Silence All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
||||
{activeAlarms.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">No active alarms</p>
|
||||
<p className="text-xs text-gray-400">System monitoring normally</p>
|
||||
</div>
|
||||
) : (
|
||||
activeAlarms.map((alarm) => (
|
||||
<div
|
||||
key={alarm.id}
|
||||
className={`p-3 rounded-lg border ${getAlarmBgColor(alarm.type, alarm.silenced)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{getAlarmIcon(alarm.type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className={`text-sm font-medium ${
|
||||
alarm.type === 'alarm' ? 'text-red-800' : 'text-yellow-800'
|
||||
}`}>
|
||||
{alarm.sensorLabel}
|
||||
</p>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
alarm.type === 'alarm'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{alarm.type.toUpperCase()}
|
||||
</span>
|
||||
{alarm.silenced && (
|
||||
<span className="text-xs px-2 py-1 rounded bg-gray-100 text-gray-600 flex items-center gap-1">
|
||||
<VolumeX className="w-3 h-3" />
|
||||
SILENCED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-xs mb-2 ${
|
||||
alarm.type === 'alarm' ? 'text-red-700' : 'text-yellow-700'
|
||||
}`}>
|
||||
Value: {alarm.value.toFixed(0)} (Threshold: {alarm.threshold})
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{alarm.time}</span>
|
||||
{alarm.acknowledged && (
|
||||
<span className="text-green-600 flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
ACK
|
||||
</span>
|
||||
)}
|
||||
{alarm.silenced && alarm.silencedUntil && (
|
||||
<span className="text-gray-500">
|
||||
Until {new Date(alarm.silencedUntil).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 ml-2">
|
||||
{!alarm.acknowledged && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAcknowledge(alarm.id)}
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
ACK
|
||||
</Button>
|
||||
)}
|
||||
{!alarm.silenced && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSilence(alarm.id)}
|
||||
className="text-xs h-6 px-2 text-orange-600 hover:text-orange-700"
|
||||
>
|
||||
<VolumeX className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legacy Alerts Section */}
|
||||
{alerts.length > 0 && (
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Recent Alerts</h4>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{alerts.slice(0, 3).map((alert) => (
|
||||
<div
|
||||
key={alert.id}
|
||||
className="flex items-start gap-2 p-2 bg-blue-50 rounded border border-blue-200"
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-blue-800">{alert.message}</p>
|
||||
<p className="text-xs text-blue-600">{alert.time}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
29
components/bed-pressure/BedPressureHeader.tsx
Normal file
29
components/bed-pressure/BedPressureHeader.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Activity, Pause, Play } from "lucide-react"
|
||||
import { useBedPressureStore } from "@/stores/bedPressureStore"
|
||||
|
||||
export function BedPressureHeader() {
|
||||
const { isMonitoring, setIsMonitoring } = useBedPressureStore()
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-8 h-8 text-blue-600" />
|
||||
<h1 className="text-3xl font-bold text-gray-900">Bed Pressure Monitor</h1>
|
||||
</div>
|
||||
<Badge variant={isMonitoring ? "default" : "secondary"} className="px-3 py-1">
|
||||
{isMonitoring ? "Live" : "Paused"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsMonitoring(!isMonitoring)}>
|
||||
{isMonitoring ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
{isMonitoring ? "Pause" : "Resume"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
79
components/bed-pressure/BedVisualization.tsx
Normal file
79
components/bed-pressure/BedVisualization.tsx
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useBedPressureStore } from "@/stores/bedPressureStore"
|
||||
|
||||
const getValueColor = (value: number) => {
|
||||
if (value < 1500) return "#22c55e" // Green - Low value
|
||||
if (value < 2500) return "#eab308" // Yellow - Medium value
|
||||
if (value < 3500) return "#f97316" // Orange - High value
|
||||
return "#ef4444" // Red - Very high value
|
||||
}
|
||||
|
||||
export function BedVisualization() {
|
||||
const { sensorConfig, sensorData, setSelectedSensor, setIsModalOpen } = useBedPressureStore()
|
||||
|
||||
const handleSensorClick = (sensorId: string) => {
|
||||
setSelectedSensor(sensorId)
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sensor Value Distribution Map</CardTitle>
|
||||
<p className="text-sm text-gray-600">Click on any sensor point to view detailed value graphs</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="relative">
|
||||
{/* Bed outline */}
|
||||
<svg viewBox="0 0 100 100" className="w-full h-96 border-2 border-gray-200 rounded-lg bg-white">
|
||||
{/* Bed frame */}
|
||||
<rect x="25" y="10" width="50" height="80" fill="none" stroke="#e5e7eb" strokeWidth="2" rx="8" />
|
||||
|
||||
{/* Pillow area */}
|
||||
<rect x="30" y="12" width="40" height="15" fill="#f3f4f6" stroke="#d1d5db" strokeWidth="1" rx="4" />
|
||||
|
||||
{/* Pressure sensors */}
|
||||
{sensorConfig.map((sensor) => {
|
||||
const sensorInfo = sensorData[sensor.id]
|
||||
if (!sensorInfo) return null
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={sensor.id}
|
||||
cx={sensor.x}
|
||||
cy={sensor.y}
|
||||
r="3"
|
||||
fill={getValueColor(sensorInfo.currentValue)}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
className="cursor-pointer transition-all duration-200 hover:r-4 hover:opacity-80"
|
||||
onClick={() => handleSensorClick(sensor.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Value Legend */}
|
||||
<div className="mt-4 flex items-center justify-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
<span>Low ({"<"}1500)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500"></div>
|
||||
<span>Medium (1500-2500)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-orange-500"></div>
|
||||
<span>High (2500-3500)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500"></div>
|
||||
<span>Critical ({">"}3500)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
230
components/bed-pressure/SensorDetailModal.tsx
Normal file
230
components/bed-pressure/SensorDetailModal.tsx
Normal file
|
@ -0,0 +1,230 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts"
|
||||
import { Download, Clock } from "lucide-react"
|
||||
import { useBedPressureStore } from "@/stores/bedPressureStore"
|
||||
import { useEffect } from "react"
|
||||
|
||||
const getValueColor = (value: number) => {
|
||||
if (value < 1500) return "#22c55e" // Green - Low value
|
||||
if (value < 2500) return "#eab308" // Yellow - Medium value
|
||||
if (value < 3500) return "#f97316" // Orange - High value
|
||||
return "#ef4444" // Red - Very high value
|
||||
}
|
||||
|
||||
const getValueLevel = (value: number) => {
|
||||
if (value < 1500) return "Low"
|
||||
if (value < 2500) return "Medium"
|
||||
if (value < 3500) return "High"
|
||||
return "Critical"
|
||||
}
|
||||
|
||||
export function SensorDetailModal() {
|
||||
const {
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
selectedSensor,
|
||||
sensorData,
|
||||
selectedTimespan,
|
||||
setSelectedTimespan,
|
||||
fetchSensorHistory
|
||||
} = useBedPressureStore()
|
||||
|
||||
const sensor = selectedSensor && sensorData[selectedSensor] ? sensorData[selectedSensor] : null
|
||||
|
||||
// Fetch historical data when modal opens or timespan changes
|
||||
useEffect(() => {
|
||||
if (isModalOpen && selectedSensor && sensor?.source === 'hardware') {
|
||||
fetchSensorHistory(selectedSensor, selectedTimespan)
|
||||
}
|
||||
}, [isModalOpen, selectedSensor, selectedTimespan, fetchSensorHistory, sensor?.source])
|
||||
|
||||
const handleTimespanChange = (value: string) => {
|
||||
const newTimespan = parseInt(value)
|
||||
setSelectedTimespan(newTimespan)
|
||||
}
|
||||
|
||||
const getTimespanLabel = (timespan: number) => {
|
||||
if (timespan < 60000) return `${timespan / 1000}s`
|
||||
if (timespan < 3600000) return `${timespan / 60000}m`
|
||||
if (timespan < 86400000) return `${timespan / 3600000}h`
|
||||
return `${timespan / 86400000}d`
|
||||
}
|
||||
|
||||
if (!isModalOpen || !selectedSensor || !sensor) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{sensor.label}</h2>
|
||||
<p className="text-gray-600">Pressure Monitoring Details</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Current Value</p>
|
||||
<p
|
||||
className="text-3xl font-bold"
|
||||
style={{ color: getValueColor(sensor.currentValue) }}
|
||||
>
|
||||
{sensor.currentValue.toFixed(0)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">ADC Units</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Status Level</p>
|
||||
<Badge
|
||||
className="text-lg px-3 py-1 mt-2"
|
||||
variant={
|
||||
sensor.status === "critical" || sensor.status === "alarm"
|
||||
? "destructive"
|
||||
: sensor.status === "warning"
|
||||
? "secondary"
|
||||
: "default"
|
||||
}
|
||||
>
|
||||
{getValueLevel(sensor.currentValue)}
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Body Zone</p>
|
||||
<p className="text-xl font-semibold capitalize mt-2">{sensor.zone}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Large Value Chart */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Value Trend</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-gray-500" />
|
||||
<Select value={selectedTimespan.toString()} onValueChange={handleTimespanChange}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="Select timespan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30000">30s</SelectItem>
|
||||
<SelectItem value="60000">1m</SelectItem>
|
||||
<SelectItem value="300000">5m</SelectItem>
|
||||
<SelectItem value="600000">10m</SelectItem>
|
||||
<SelectItem value="1800000">30m</SelectItem>
|
||||
<SelectItem value="3600000">1h</SelectItem>
|
||||
<SelectItem value="7200000">2h</SelectItem>
|
||||
<SelectItem value="21600000">6h</SelectItem>
|
||||
<SelectItem value="43200000">12h</SelectItem>
|
||||
<SelectItem value="86400000">24h</SelectItem>
|
||||
<SelectItem value="259200000">3d</SelectItem>
|
||||
<SelectItem value="604800000">7d</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing data for the last {getTimespanLabel(selectedTimespan)}
|
||||
{sensor.source === 'hardware' ? ' (Real sensor data)' : ' (Mock data)'}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={sensor.data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 12 }} interval="preserveStartEnd" />
|
||||
<YAxis
|
||||
tick={{ fontSize: 12 }}
|
||||
label={{ value: "ADC Value", angle: -90, position: "insideLeft" }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value.toFixed(0)}`, "Value"]}
|
||||
labelFormatter={(label) => `Time: ${label}`}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={getValueColor(sensor.currentValue)}
|
||||
strokeWidth={3}
|
||||
dot={false}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Additional Statistics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Max Value</p>
|
||||
<p className="text-lg font-bold text-red-600">
|
||||
{Math.max(...sensor.data.map(d => d.value)).toFixed(0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Min Value</p>
|
||||
<p className="text-lg font-bold text-green-600">
|
||||
{Math.min(...sensor.data.map(d => d.value)).toFixed(0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Average</p>
|
||||
<p className="text-lg font-bold text-blue-600">
|
||||
{(sensor.data.reduce((sum, d) => sum + d.value, 0) / sensor.data.length).toFixed(0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4 text-center">
|
||||
<p className="text-sm text-gray-600">Data Points</p>
|
||||
<p className="text-lg font-bold text-gray-600">{sensor.data.length}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 mt-6">
|
||||
<Button variant="outline" onClick={() => setIsModalOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
68
components/bed-pressure/StatsCards.tsx
Normal file
68
components/bed-pressure/StatsCards.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { AlertTriangle, User } from "lucide-react"
|
||||
import { useBedPressureStore } from "@/stores/bedPressureStore"
|
||||
|
||||
const getValueColor = (value: number) => {
|
||||
if (value < 1500) return "#22c55e" // Green - Low value
|
||||
if (value < 2500) return "#eab308" // Yellow - Medium value
|
||||
if (value < 3500) return "#f97316" // Orange - High value
|
||||
return "#ef4444" // Red - Very high value
|
||||
}
|
||||
|
||||
export function StatsCards() {
|
||||
const { sensorData, averageValue, criticalSensors } = useBedPressureStore()
|
||||
|
||||
const avgValue = averageValue()
|
||||
const criticalCount = criticalSensors()
|
||||
const activeSensors = Object.keys(sensorData).length
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Patient</p>
|
||||
<p className="font-semibold">John Doe - Room 204</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">Average Value</p>
|
||||
<p className="text-2xl font-bold" style={{ color: getValueColor(avgValue) }}>
|
||||
{avgValue.toFixed(0)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">Active Sensors</p>
|
||||
<p className="text-2xl font-bold text-green-600">{activeSensors}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className={`w-5 h-5 ${criticalCount > 0 ? "text-red-600" : "text-gray-400"}`} />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Critical Alerts</p>
|
||||
<p className={`text-2xl font-bold ${criticalCount > 0 ? "text-red-600" : "text-gray-600"}`}>
|
||||
{criticalCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
185
components/ui/select.tsx
Normal file
185
components/ui/select.tsx
Normal file
|
@ -0,0 +1,185 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
69
hooks/useBedPressureData.ts
Normal file
69
hooks/useBedPressureData.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useBedPressureStore, SensorData } from '@/stores/bedPressureStore'
|
||||
|
||||
// Mock data generator
|
||||
const generateTimeSeriesData = (hours = 24) => {
|
||||
const data = []
|
||||
const now = new Date()
|
||||
|
||||
for (let i = hours * 60; i >= 0; i -= 5) {
|
||||
const time = new Date(now.getTime() - i * 60 * 1000)
|
||||
data.push({
|
||||
time: time.toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: time.getTime(),
|
||||
value: Math.floor(Math.random() * 4096 + Math.sin(i / 60) * 500 + 2000), // 0-4095 range
|
||||
})
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function useBedPressureData() {
|
||||
const {
|
||||
sensorConfig,
|
||||
sensorData,
|
||||
isMonitoring,
|
||||
fetchSensorConfig,
|
||||
fetchSensorData,
|
||||
setSensorData
|
||||
} = useBedPressureStore()
|
||||
|
||||
// Initialize sensor configuration
|
||||
useEffect(() => {
|
||||
fetchSensorConfig()
|
||||
}, [fetchSensorConfig])
|
||||
|
||||
// Initialize sensor data
|
||||
useEffect(() => {
|
||||
if (sensorConfig.length === 0) return
|
||||
|
||||
const initialData: Record<string, SensorData> = {}
|
||||
sensorConfig.forEach((sensor) => {
|
||||
initialData[sensor.id] = {
|
||||
...sensor,
|
||||
currentValue: Math.floor(Math.random() * 1000 + 1000), // Start with baseline analog value (1000-2000)
|
||||
data: generateTimeSeriesData(),
|
||||
status: "normal",
|
||||
}
|
||||
})
|
||||
setSensorData(initialData)
|
||||
}, [sensorConfig, setSensorData])
|
||||
|
||||
// Fetch sensor data from API
|
||||
useEffect(() => {
|
||||
if (!isMonitoring) return
|
||||
|
||||
// Initial fetch
|
||||
fetchSensorData()
|
||||
|
||||
// Set up polling
|
||||
const interval = setInterval(fetchSensorData, 2000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isMonitoring, fetchSensorData])
|
||||
|
||||
return {
|
||||
sensorData,
|
||||
sensorConfig,
|
||||
isMonitoring
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
|
|
25
package.json
25
package.json
|
@ -9,6 +9,7 @@
|
|||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@types/serialport": "^10.2.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
@ -16,22 +17,24 @@
|
|||
"lucide-react": "^0.519.0",
|
||||
"next": "15.3.4",
|
||||
"port": "^0.8.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"serial": "^0.0.9",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"serialport": "^13.0.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"@types/node": "^20.19.1",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-config-next": "15.3.4",
|
||||
"tailwindcss": "^4",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
207
services/AlarmManager.ts
Normal file
207
services/AlarmManager.ts
Normal file
|
@ -0,0 +1,207 @@
|
|||
export interface AlarmEvent {
|
||||
id: string;
|
||||
sensorId: string;
|
||||
sensorLabel: string;
|
||||
type: 'warning' | 'alarm';
|
||||
value: number;
|
||||
threshold: number;
|
||||
timestamp: number;
|
||||
time: string;
|
||||
acknowledged: boolean;
|
||||
silenced: boolean;
|
||||
silencedUntil?: number;
|
||||
}
|
||||
|
||||
export class AlarmManager {
|
||||
private static instance: AlarmManager;
|
||||
private activeAlarms: Map<string, AlarmEvent> = new Map();
|
||||
private alarmHistory: AlarmEvent[] = [];
|
||||
private alarmCallbacks: ((alarm: AlarmEvent) => void)[] = [];
|
||||
private audioContext: AudioContext | null = null;
|
||||
private alarmSound: AudioBuffer | null = null;
|
||||
private isPlayingAlarm = false;
|
||||
|
||||
private constructor() {
|
||||
this.initializeAudio();
|
||||
}
|
||||
|
||||
static getInstance(): AlarmManager {
|
||||
if (!AlarmManager.instance) {
|
||||
AlarmManager.instance = new AlarmManager();
|
||||
}
|
||||
return AlarmManager.instance;
|
||||
}
|
||||
|
||||
private async initializeAudio() {
|
||||
try {
|
||||
// Check if we're in browser environment
|
||||
if (typeof window !== 'undefined') {
|
||||
this.audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
// Generate alarm sound programmatically
|
||||
await this.generateAlarmSound();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Audio context not available:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async generateAlarmSound() {
|
||||
if (!this.audioContext) return;
|
||||
|
||||
const sampleRate = this.audioContext.sampleRate;
|
||||
const duration = 1; // 1 second
|
||||
const buffer = this.audioContext.createBuffer(1, sampleRate * duration, sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
|
||||
// Generate a beeping sound (sine wave with modulation)
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const t = i / sampleRate;
|
||||
const frequency = 800; // Hz
|
||||
const envelope = Math.sin(t * Math.PI * 4) > 0 ? 1 : 0; // Beeping pattern
|
||||
data[i] = Math.sin(2 * Math.PI * frequency * t) * envelope * 0.3;
|
||||
}
|
||||
|
||||
this.alarmSound = buffer;
|
||||
}
|
||||
|
||||
private async playAlarmSound() {
|
||||
if (!this.audioContext || !this.alarmSound || this.isPlayingAlarm) return;
|
||||
|
||||
try {
|
||||
// Resume audio context if suspended
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
|
||||
const source = this.audioContext.createBufferSource();
|
||||
source.buffer = this.alarmSound;
|
||||
source.connect(this.audioContext.destination);
|
||||
source.start();
|
||||
|
||||
this.isPlayingAlarm = true;
|
||||
source.onended = () => {
|
||||
this.isPlayingAlarm = false;
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn('Failed to play alarm sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addAlarm(sensorId: string, sensorLabel: string, type: 'warning' | 'alarm', value: number, threshold: number) {
|
||||
const alarmId = `${sensorId}-${type}`;
|
||||
const timestamp = Date.now();
|
||||
|
||||
const alarm: AlarmEvent = {
|
||||
id: alarmId,
|
||||
sensorId,
|
||||
sensorLabel,
|
||||
type,
|
||||
value,
|
||||
threshold,
|
||||
timestamp,
|
||||
time: new Date(timestamp).toLocaleTimeString(),
|
||||
acknowledged: false,
|
||||
silenced: false
|
||||
};
|
||||
|
||||
// Check if alarm is already active and silenced
|
||||
const existingAlarm = this.activeAlarms.get(alarmId);
|
||||
if (existingAlarm?.silenced && existingAlarm.silencedUntil && timestamp < existingAlarm.silencedUntil) {
|
||||
// Update values but keep silenced state
|
||||
alarm.silenced = true;
|
||||
alarm.silencedUntil = existingAlarm.silencedUntil;
|
||||
}
|
||||
|
||||
this.activeAlarms.set(alarmId, alarm);
|
||||
this.alarmHistory.unshift(alarm);
|
||||
|
||||
// Keep only last 100 history items
|
||||
if (this.alarmHistory.length > 100) {
|
||||
this.alarmHistory = this.alarmHistory.slice(0, 100);
|
||||
}
|
||||
|
||||
// Play sound for new alarms
|
||||
if (type === 'alarm' && !alarm.silenced) {
|
||||
this.playAlarmSound();
|
||||
}
|
||||
|
||||
// Notify callbacks
|
||||
this.alarmCallbacks.forEach(callback => callback(alarm));
|
||||
|
||||
return alarm;
|
||||
}
|
||||
|
||||
clearAlarm(sensorId: string, type?: 'warning' | 'alarm') {
|
||||
if (type) {
|
||||
const alarmId = `${sensorId}-${type}`;
|
||||
this.activeAlarms.delete(alarmId);
|
||||
} else {
|
||||
// Clear all alarms for this sensor
|
||||
this.activeAlarms.delete(`${sensorId}-warning`);
|
||||
this.activeAlarms.delete(`${sensorId}-alarm`);
|
||||
}
|
||||
}
|
||||
|
||||
acknowledgeAlarm(alarmId: string) {
|
||||
const alarm = this.activeAlarms.get(alarmId);
|
||||
if (alarm) {
|
||||
alarm.acknowledged = true;
|
||||
this.activeAlarms.set(alarmId, alarm);
|
||||
}
|
||||
}
|
||||
|
||||
silenceAlarm(alarmId: string, durationMs: number = 300000) { // Default 5 minutes
|
||||
const alarm = this.activeAlarms.get(alarmId);
|
||||
if (alarm) {
|
||||
alarm.silenced = true;
|
||||
alarm.silencedUntil = Date.now() + durationMs;
|
||||
this.activeAlarms.set(alarmId, alarm);
|
||||
}
|
||||
}
|
||||
|
||||
silenceAllAlarms(durationMs: number = 300000) {
|
||||
const timestamp = Date.now();
|
||||
this.activeAlarms.forEach((alarm, alarmId) => {
|
||||
alarm.silenced = true;
|
||||
alarm.silencedUntil = timestamp + durationMs;
|
||||
this.activeAlarms.set(alarmId, alarm);
|
||||
});
|
||||
}
|
||||
|
||||
getActiveAlarms(): AlarmEvent[] {
|
||||
const now = Date.now();
|
||||
const activeAlarms: AlarmEvent[] = [];
|
||||
|
||||
this.activeAlarms.forEach((alarm, alarmId) => {
|
||||
// Check if silence period has expired
|
||||
if (alarm.silenced && alarm.silencedUntil && now >= alarm.silencedUntil) {
|
||||
alarm.silenced = false;
|
||||
alarm.silencedUntil = undefined;
|
||||
this.activeAlarms.set(alarmId, alarm);
|
||||
}
|
||||
|
||||
activeAlarms.push(alarm);
|
||||
});
|
||||
|
||||
return activeAlarms.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
getAlarmHistory(): AlarmEvent[] {
|
||||
return this.alarmHistory;
|
||||
}
|
||||
|
||||
hasUnacknowledgedAlarms(): boolean {
|
||||
return Array.from(this.activeAlarms.values()).some(alarm => !alarm.acknowledged);
|
||||
}
|
||||
|
||||
onAlarm(callback: (alarm: AlarmEvent) => void) {
|
||||
this.alarmCallbacks.push(callback);
|
||||
}
|
||||
|
||||
offAlarm(callback: (alarm: AlarmEvent) => void) {
|
||||
const index = this.alarmCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
this.alarmCallbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
131
services/SensorDataStorage.ts
Normal file
131
services/SensorDataStorage.ts
Normal file
|
@ -0,0 +1,131 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const SENSOR_DATA_FILE = path.join(DATA_DIR, 'sensor-data.json');
|
||||
|
||||
export interface SensorDataPoint {
|
||||
sensorId: string;
|
||||
value: number; // Changed from pressure to value (0-4095)
|
||||
timestamp: number;
|
||||
time: string;
|
||||
source: 'hardware' | 'mock';
|
||||
pin?: number;
|
||||
digitalState?: number;
|
||||
}
|
||||
|
||||
export class SensorDataStorage {
|
||||
private static instance: SensorDataStorage;
|
||||
private dataCache: SensorDataPoint[] = [];
|
||||
|
||||
private constructor() {
|
||||
this.initializeStorage();
|
||||
}
|
||||
|
||||
static getInstance(): SensorDataStorage {
|
||||
if (!SensorDataStorage.instance) {
|
||||
SensorDataStorage.instance = new SensorDataStorage();
|
||||
}
|
||||
return SensorDataStorage.instance;
|
||||
}
|
||||
|
||||
private async initializeStorage() {
|
||||
try {
|
||||
// Ensure data directory exists
|
||||
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||
|
||||
// Load existing data
|
||||
await this.loadData();
|
||||
} catch (error) {
|
||||
console.warn('Failed to initialize sensor data storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadData() {
|
||||
try {
|
||||
const data = await fs.readFile(SENSOR_DATA_FILE, 'utf8');
|
||||
this.dataCache = JSON.parse(data);
|
||||
console.log(`Loaded ${this.dataCache.length} sensor data points from storage`);
|
||||
} catch {
|
||||
// File doesn't exist or is corrupted, start with empty cache
|
||||
this.dataCache = [];
|
||||
console.log('Starting with empty sensor data cache');
|
||||
}
|
||||
}
|
||||
|
||||
private async saveData() {
|
||||
try {
|
||||
await fs.writeFile(SENSOR_DATA_FILE, JSON.stringify(this.dataCache, null, 2));
|
||||
} catch (error) {
|
||||
console.error('Failed to save sensor data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async addDataPoint(dataPoint: SensorDataPoint) {
|
||||
this.dataCache.push(dataPoint);
|
||||
|
||||
// Keep only last 7 days of data to prevent unlimited storage growth
|
||||
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
|
||||
this.dataCache = this.dataCache.filter(point => point.timestamp > sevenDaysAgo);
|
||||
|
||||
// Save to disk every 10 data points to reduce I/O
|
||||
if (this.dataCache.length % 10 === 0) {
|
||||
await this.saveData();
|
||||
}
|
||||
}
|
||||
|
||||
async getDataForSensor(
|
||||
sensorId: string,
|
||||
timespan: number = 24 * 60 * 60 * 1000 // Default 24 hours in milliseconds
|
||||
): Promise<SensorDataPoint[]> {
|
||||
const cutoffTime = Date.now() - timespan;
|
||||
return this.dataCache
|
||||
.filter(point => point.sensorId === sensorId && point.timestamp > cutoffTime)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
|
||||
async getAllRecentData(timespan: number = 24 * 60 * 60 * 1000): Promise<SensorDataPoint[]> {
|
||||
const cutoffTime = Date.now() - timespan;
|
||||
return this.dataCache
|
||||
.filter(point => point.timestamp > cutoffTime)
|
||||
.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}
|
||||
|
||||
async forceSave() {
|
||||
await this.saveData();
|
||||
}
|
||||
|
||||
// Generate time series data for a specific timespan
|
||||
generateTimeSeriesData(
|
||||
sensorData: SensorDataPoint[],
|
||||
timespan: number = 24 * 60 * 60 * 1000
|
||||
): Array<{ time: string; timestamp: number; value: number }> {
|
||||
if (sensorData.length === 0) {
|
||||
// Generate mock data if no real data exists
|
||||
return this.generateMockTimeSeriesData(timespan);
|
||||
}
|
||||
|
||||
return sensorData.map(point => ({
|
||||
time: point.time,
|
||||
timestamp: point.timestamp,
|
||||
value: point.value
|
||||
}));
|
||||
}
|
||||
|
||||
private generateMockTimeSeriesData(timespan: number): Array<{ time: string; timestamp: number; value: number }> {
|
||||
const data = [];
|
||||
const now = Date.now();
|
||||
const interval = Math.max(1000, timespan / 288); // At least 1 second intervals, up to 288 points
|
||||
|
||||
for (let i = timespan; i >= 0; i -= interval) {
|
||||
const timestamp = now - i;
|
||||
const time = new Date(timestamp);
|
||||
data.push({
|
||||
time: time.toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: timestamp,
|
||||
value: Math.floor(Math.random() * 4096 + Math.sin(i / 60000) * 500 + 2000), // 0-4095 range
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
}
|
275
stores/bedPressureStore.ts
Normal file
275
stores/bedPressureStore.ts
Normal file
|
@ -0,0 +1,275 @@
|
|||
import { create } from 'zustand'
|
||||
import { AlarmManager, AlarmEvent } from '@/services/AlarmManager'
|
||||
|
||||
export interface SensorConfig {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
zone: string;
|
||||
label: string;
|
||||
pin?: number;
|
||||
}
|
||||
|
||||
export interface SensorData {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
zone: string;
|
||||
label: string;
|
||||
currentValue: number; // Changed from currentPressure to currentValue (0-4095)
|
||||
data: Array<{ time: string; timestamp: number; value: number }>; // Changed from pressure to value
|
||||
status: string;
|
||||
source?: 'hardware' | 'mock';
|
||||
pin?: number;
|
||||
digitalState?: number;
|
||||
warningThreshold?: number;
|
||||
alarmThreshold?: number;
|
||||
warningDelayMs?: number;
|
||||
}
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
message: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
interface BedPressureStore {
|
||||
// State
|
||||
sensorData: Record<string, SensorData>;
|
||||
sensorConfig: SensorConfig[];
|
||||
selectedSensor: string | null;
|
||||
isModalOpen: boolean;
|
||||
isMonitoring: boolean;
|
||||
alerts: Alert[];
|
||||
isConnected: boolean;
|
||||
selectedTimespan: number; // in milliseconds
|
||||
activeAlarms: AlarmEvent[];
|
||||
alarmManager: AlarmManager;
|
||||
|
||||
// Actions
|
||||
setSensorData: (data: Record<string, SensorData>) => void;
|
||||
updateSensorData: (updater: (prev: Record<string, SensorData>) => Record<string, SensorData>) => void;
|
||||
setSensorConfig: (config: SensorConfig[]) => void;
|
||||
setSelectedSensor: (sensorId: string | null) => void;
|
||||
setIsModalOpen: (isOpen: boolean) => void;
|
||||
setIsMonitoring: (isMonitoring: boolean) => void;
|
||||
addAlerts: (newAlerts: Alert[]) => void;
|
||||
setIsConnected: (isConnected: boolean) => void;
|
||||
setSelectedTimespan: (timespan: number) => void;
|
||||
setActiveAlarms: (alarms: AlarmEvent[]) => void;
|
||||
|
||||
// Computed values
|
||||
averageValue: () => number; // Changed from averagePressure
|
||||
criticalSensors: () => number;
|
||||
|
||||
// API actions
|
||||
fetchSensorConfig: () => Promise<void>;
|
||||
fetchSensorData: () => Promise<void>;
|
||||
fetchSensorHistory: (sensorId: string, timespan?: number) => Promise<void>;
|
||||
|
||||
// Alarm actions
|
||||
acknowledgeAlarm: (alarmId: string) => void;
|
||||
silenceAlarm: (alarmId: string, durationMs?: number) => void;
|
||||
silenceAllAlarms: (durationMs?: number) => void;
|
||||
}
|
||||
|
||||
export const useBedPressureStore = create<BedPressureStore>((set, get) => ({
|
||||
// Initial state
|
||||
sensorData: {},
|
||||
sensorConfig: [],
|
||||
selectedSensor: null,
|
||||
isModalOpen: false,
|
||||
isMonitoring: true,
|
||||
alerts: [],
|
||||
isConnected: false,
|
||||
selectedTimespan: 24 * 60 * 60 * 1000, // Default 24 hours
|
||||
activeAlarms: [],
|
||||
alarmManager: AlarmManager.getInstance(),
|
||||
|
||||
// Actions
|
||||
setSensorData: (data) => set({ sensorData: data }),
|
||||
|
||||
updateSensorData: (updater) => set((state) => ({
|
||||
sensorData: updater(state.sensorData)
|
||||
})),
|
||||
|
||||
setSensorConfig: (config) => set({ sensorConfig: config }),
|
||||
|
||||
setSelectedSensor: (sensorId) => set({ selectedSensor: sensorId }),
|
||||
|
||||
setIsModalOpen: (isOpen) => set({ isModalOpen: isOpen }),
|
||||
|
||||
setIsMonitoring: (isMonitoring) => set({ isMonitoring }),
|
||||
|
||||
addAlerts: (newAlerts) => set((state) => ({
|
||||
alerts: [...newAlerts, ...state.alerts].slice(0, 10)
|
||||
})),
|
||||
|
||||
setIsConnected: (isConnected) => set({ isConnected }),
|
||||
|
||||
setSelectedTimespan: (timespan) => set({ selectedTimespan: timespan }),
|
||||
|
||||
setActiveAlarms: (alarms) => set({ activeAlarms: alarms }),
|
||||
|
||||
// Computed values
|
||||
averageValue: () => {
|
||||
const { sensorData } = get();
|
||||
const sensors = Object.values(sensorData) as SensorData[];
|
||||
if (sensors.length === 0) return 0;
|
||||
return sensors.reduce((sum: number, sensor: SensorData) => sum + sensor.currentValue, 0) / sensors.length;
|
||||
},
|
||||
|
||||
criticalSensors: () => {
|
||||
const { sensorData } = get();
|
||||
return (Object.values(sensorData) as SensorData[]).filter((sensor: SensorData) =>
|
||||
sensor.status === 'alarm' || sensor.status === 'critical'
|
||||
).length;
|
||||
},
|
||||
|
||||
// API actions
|
||||
fetchSensorConfig: async () => {
|
||||
try {
|
||||
const response = await fetch('/api/sensors/config');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.sensors) {
|
||||
set({ sensorConfig: data.sensors });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sensor config:', error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchSensorData: async () => {
|
||||
try {
|
||||
const response = await fetch('/api/sensors');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.sensors) {
|
||||
const { sensorData, sensorConfig, alarmManager } = get();
|
||||
const updated = { ...sensorData };
|
||||
const newAlerts: Alert[] = [];
|
||||
|
||||
data.sensors.forEach((sensor: {
|
||||
id: string;
|
||||
label: string;
|
||||
zone: string;
|
||||
value: number; // Changed from pressure to value
|
||||
source: 'hardware' | 'mock';
|
||||
pin?: number;
|
||||
digitalState?: number;
|
||||
warningThreshold?: number;
|
||||
alarmThreshold?: number;
|
||||
status?: string;
|
||||
}) => {
|
||||
const currentSensor = updated[sensor.id];
|
||||
const newValue = sensor.value;
|
||||
|
||||
// Check for alarms based on thresholds
|
||||
if (sensor.alarmThreshold && newValue >= sensor.alarmThreshold) {
|
||||
alarmManager.addAlarm(sensor.id, sensor.label, 'alarm', newValue, sensor.alarmThreshold);
|
||||
} else if (sensor.warningThreshold && newValue >= sensor.warningThreshold) {
|
||||
alarmManager.addAlarm(sensor.id, sensor.label, 'warning', newValue, sensor.warningThreshold);
|
||||
} else {
|
||||
alarmManager.clearAlarm(sensor.id);
|
||||
}
|
||||
|
||||
// Check for alerts (legacy alert system)
|
||||
if (newValue > 3000 && currentSensor && currentSensor.currentValue <= 3000) {
|
||||
newAlerts.push({
|
||||
id: `${sensor.id}-${Date.now()}`,
|
||||
message: `High value detected at ${sensor.label}`,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Update sensor data
|
||||
updated[sensor.id] = {
|
||||
...sensorConfig.find(s => s.id === sensor.id),
|
||||
id: sensor.id,
|
||||
x: sensorConfig.find(s => s.id === sensor.id)?.x || 50,
|
||||
y: sensorConfig.find(s => s.id === sensor.id)?.y || 50,
|
||||
zone: sensor.zone,
|
||||
label: sensor.label,
|
||||
currentValue: newValue,
|
||||
data: currentSensor ? [
|
||||
...currentSensor.data.slice(1),
|
||||
{
|
||||
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: Date.now(),
|
||||
value: newValue,
|
||||
},
|
||||
] : [{
|
||||
time: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
timestamp: Date.now(),
|
||||
value: newValue,
|
||||
}],
|
||||
status: sensor.status || (newValue > 3000 ? "critical" : newValue > 2500 ? "warning" : "normal"),
|
||||
source: sensor.source,
|
||||
pin: sensor.pin,
|
||||
digitalState: sensor.digitalState,
|
||||
warningThreshold: sensor.warningThreshold,
|
||||
alarmThreshold: sensor.alarmThreshold
|
||||
};
|
||||
});
|
||||
|
||||
set({
|
||||
sensorData: updated,
|
||||
isConnected: data.connected,
|
||||
activeAlarms: alarmManager.getActiveAlarms()
|
||||
});
|
||||
|
||||
if (newAlerts.length > 0) {
|
||||
get().addAlerts(newAlerts);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sensor data:', error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchSensorHistory: async (sensorId: string, timespan?: number) => {
|
||||
try {
|
||||
const { selectedTimespan } = get();
|
||||
const timespanToUse = timespan || selectedTimespan;
|
||||
|
||||
const response = await fetch(`/api/sensors/history?sensorId=${sensorId}×pan=${timespanToUse}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.data) {
|
||||
const { sensorData } = get();
|
||||
const updated = { ...sensorData };
|
||||
|
||||
if (updated[sensorId]) {
|
||||
updated[sensorId] = {
|
||||
...updated[sensorId],
|
||||
data: data.data
|
||||
};
|
||||
|
||||
set({ sensorData: updated });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch sensor history:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Alarm actions
|
||||
acknowledgeAlarm: (alarmId: string) => {
|
||||
const { alarmManager } = get();
|
||||
alarmManager.acknowledgeAlarm(alarmId);
|
||||
set({ activeAlarms: alarmManager.getActiveAlarms() });
|
||||
},
|
||||
|
||||
silenceAlarm: (alarmId: string, durationMs?: number) => {
|
||||
const { alarmManager } = get();
|
||||
alarmManager.silenceAlarm(alarmId, durationMs);
|
||||
set({ activeAlarms: alarmManager.getActiveAlarms() });
|
||||
},
|
||||
|
||||
silenceAllAlarms: (durationMs?: number) => {
|
||||
const { alarmManager } = get();
|
||||
alarmManager.silenceAllAlarms(durationMs);
|
||||
set({ activeAlarms: alarmManager.getActiveAlarms() });
|
||||
}
|
||||
}));
|
Loading…
Add table
Add a link
Reference in a new issue