From 5e029ff99c9ad4523ba5e5699f79ce4bcd863a10 Mon Sep 17 00:00:00 2001 From: Siwat Sirichai Date: Sat, 21 Jun 2025 12:55:27 +0700 Subject: [PATCH] dynamic graph --- .gitignore | 2 + app/api/sensors/history/route.ts | 38 ++ app/api/sensors/route.ts | 136 +++-- bun.lock | 97 +++- components/bed-pressure-monitor.tsx | 517 +----------------- components/bed-pressure/AlertsPanel.tsx | 186 +++++++ components/bed-pressure/BedPressureHeader.tsx | 29 + components/bed-pressure/BedVisualization.tsx | 79 +++ components/bed-pressure/SensorDetailModal.tsx | 230 ++++++++ components/bed-pressure/StatsCards.tsx | 68 +++ components/ui/select.tsx | 185 +++++++ hooks/useBedPressureData.ts | 69 +++ lib/utils.ts | 2 +- package.json | 25 +- services/AlarmManager.ts | 207 +++++++ services/SensorDataStorage.ts | 131 +++++ stores/bedPressureStore.ts | 275 ++++++++++ 17 files changed, 1707 insertions(+), 569 deletions(-) create mode 100644 app/api/sensors/history/route.ts create mode 100644 components/bed-pressure/AlertsPanel.tsx create mode 100644 components/bed-pressure/BedPressureHeader.tsx create mode 100644 components/bed-pressure/BedVisualization.tsx create mode 100644 components/bed-pressure/SensorDetailModal.tsx create mode 100644 components/bed-pressure/StatsCards.tsx create mode 100644 components/ui/select.tsx create mode 100644 hooks/useBedPressureData.ts create mode 100644 services/AlarmManager.ts create mode 100644 services/SensorDataStorage.ts create mode 100644 stores/bedPressureStore.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..b957a62 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/data \ No newline at end of file diff --git a/app/api/sensors/history/route.ts b/app/api/sensors/history/route.ts new file mode 100644 index 0000000..d76b20c --- /dev/null +++ b/app/api/sensors/history/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/app/api/sensors/route.ts b/app/api/sensors/route.ts index 1e5fdd3..282dfc7 100644 --- a/app/api/sensors/route.ts +++ b/app/api/sensors/route.ts @@ -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; + 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 diff --git a/bun.lock b/bun.lock index 2d15527..7d4a858 100644 --- a/bun.lock +++ b/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=="], diff --git a/components/bed-pressure-monitor.tsx b/components/bed-pressure-monitor.tsx index 5b249f8..355cb25 100644 --- a/components/bed-pressure-monitor.tsx +++ b/components/bed-pressure-monitor.tsx @@ -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>({}) - const [sensorConfig, setSensorConfig] = useState([]) - const [selectedSensor, setSelectedSensor] = useState(null) - const [isModalOpen, setIsModalOpen] = useState(false) - const [isMonitoring, setIsMonitoring] = useState(true) - const [alerts, setAlerts] = useState>([]) - - // 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 = {} - 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 (
{/* Header */} -
-
-
- -

Bed Pressure Monitor

-
- - {isMonitoring ? "Live" : "Paused"} - -
- -
- - - -
-
+ {/* Stats Cards */} -
- - -
- -
-

Patient

-

John Doe - Room 204

-
-
-
-
- - - -
-

Average Pressure

-

- {averagePressure.toFixed(1)} mmHg -

-
-
-
- - - -
-

Active Sensors

-

{Object.keys(sensorData).length}

-
-
-
- - - -
- 0 ? "text-red-600" : "text-gray-400"}`} /> -
-

Critical Alerts

-

0 ? "text-red-600" : "text-gray-600"}`}> - {criticalSensors} -

-
-
-
-
-
+
{/* Bed Visualization */}
- - - Pressure Distribution Map -

Click on any sensor point to view detailed pressure graphs

-
- -
- {/* Bed outline */} - - {/* Bed frame */} - - - {/* Pillow area */} - - - {/* Pressure sensors */} - {sensorConfig.map((sensor) => { - const sensorInfo = sensorData[sensor.id] - if (!sensorInfo) return null - - return ( - { - setSelectedSensor(sensor.id) - setIsModalOpen(true) - }} - /> - ) - })} - - - {/* Pressure Legend */} -
-
-
- Low ({"<"}30) -
-
-
- Medium (30-50) -
-
-
- High (50-70) -
-
-
- Critical ({">"}70) -
-
-
-
-
+
- {/* Sensor Details & Alerts */} + {/* Alerts Panel */}
- {/* Pressure Graph Modal */} - {isModalOpen && selectedSensor && sensorData[selectedSensor] && ( -
-
-
-
-
-

{sensorData[selectedSensor].label}

-

Pressure Monitoring Details

-
- -
- -
- - -

Current Pressure

-

- {sensorData[selectedSensor].currentPressure.toFixed(1)} -

-

mmHg

-
-
- - - -

Status Level

- - {getPressureLevel(sensorData[selectedSensor].currentPressure)} - -
-
- - - -

Body Zone

-

{sensorData[selectedSensor].zone}

-
-
-
- - {/* Large Pressure Chart */} - - - 24-Hour Pressure Trend - - -
- - - - - - [`${value.toFixed(1)} mmHg`, "Pressure"]} - labelFormatter={(label) => `Time: ${label}`} - /> - - - -
-
-
- - {/* Additional Statistics */} -
- - -

Max Pressure

-

- {Math.max(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)} -

-
-
- - - -

Min Pressure

-

- {Math.min(...sensorData[selectedSensor].data.map((d: { time: string; timestamp: number; pressure: number }) => d.pressure)).toFixed(1)} -

-
-
- - - -

Average

-

- {( - sensorData[selectedSensor].data.reduce((sum: number, d: { time: string; timestamp: number; pressure: number }) => sum + d.pressure, 0) / - sensorData[selectedSensor].data.length - ).toFixed(1)} -

-
-
- - - -

Data Points

-

{sensorData[selectedSensor].data.length}

-
-
-
- -
- - -
-
-
-
- )} - - {/* Alerts */} - - - - - Recent Alerts - - - -
- {alerts.length === 0 ? ( -

No recent alerts

- ) : ( - alerts.map((alert) => ( -
- -
-

{alert.message}

-

{alert.time}

-
-
- )) - )} -
-
-
+
+ + {/* Sensor Detail Modal */} +
) diff --git a/components/bed-pressure/AlertsPanel.tsx b/components/bed-pressure/AlertsPanel.tsx new file mode 100644 index 0000000..6f785b4 --- /dev/null +++ b/components/bed-pressure/AlertsPanel.tsx @@ -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' ? ( + + ) : ( + + ) + } + + 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 ( + + +
+ + + Active Alarms ({activeAlarms.length}) + + {activeAlarms.length > 0 && ( + + )} +
+
+ +
+ {activeAlarms.length === 0 ? ( +
+ +

No active alarms

+

System monitoring normally

+
+ ) : ( + activeAlarms.map((alarm) => ( +
+
+
+ {getAlarmIcon(alarm.type)} +
+
+

+ {alarm.sensorLabel} +

+ + {alarm.type.toUpperCase()} + + {alarm.silenced && ( + + + SILENCED + + )} +
+

+ Value: {alarm.value.toFixed(0)} (Threshold: {alarm.threshold}) +

+
+ + {alarm.time} + {alarm.acknowledged && ( + + + ACK + + )} + {alarm.silenced && alarm.silencedUntil && ( + + Until {new Date(alarm.silencedUntil).toLocaleTimeString()} + + )} +
+
+
+ +
+ {!alarm.acknowledged && ( + + )} + {!alarm.silenced && ( + + )} +
+
+
+ )) + )} +
+ + {/* Legacy Alerts Section */} + {alerts.length > 0 && ( +
+

Recent Alerts

+
+ {alerts.slice(0, 3).map((alert) => ( +
+ +
+

{alert.message}

+

{alert.time}

+
+
+ ))} +
+
+ )} +
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/BedPressureHeader.tsx b/components/bed-pressure/BedPressureHeader.tsx new file mode 100644 index 0000000..863e920 --- /dev/null +++ b/components/bed-pressure/BedPressureHeader.tsx @@ -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 ( +
+
+
+ +

Bed Pressure Monitor

+
+ + {isMonitoring ? "Live" : "Paused"} + +
+ +
+ +
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/BedVisualization.tsx b/components/bed-pressure/BedVisualization.tsx new file mode 100644 index 0000000..db97fe3 --- /dev/null +++ b/components/bed-pressure/BedVisualization.tsx @@ -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 ( + + + Sensor Value Distribution Map +

Click on any sensor point to view detailed value graphs

+
+ +
+ {/* Bed outline */} + + {/* Bed frame */} + + + {/* Pillow area */} + + + {/* Pressure sensors */} + {sensorConfig.map((sensor) => { + const sensorInfo = sensorData[sensor.id] + if (!sensorInfo) return null + + return ( + handleSensorClick(sensor.id)} + /> + ) + })} + + + {/* Value Legend */} +
+
+
+ Low ({"<"}1500) +
+
+
+ Medium (1500-2500) +
+
+
+ High (2500-3500) +
+
+
+ Critical ({">"}3500) +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/SensorDetailModal.tsx b/components/bed-pressure/SensorDetailModal.tsx new file mode 100644 index 0000000..04752b3 --- /dev/null +++ b/components/bed-pressure/SensorDetailModal.tsx @@ -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 ( +
+
+
+
+
+

{sensor.label}

+

Pressure Monitoring Details

+
+ +
+ +
+ + +

Current Value

+

+ {sensor.currentValue.toFixed(0)} +

+

ADC Units

+
+
+ + + +

Status Level

+ + {getValueLevel(sensor.currentValue)} + +
+
+ + + +

Body Zone

+

{sensor.zone}

+
+
+
+ + {/* Large Value Chart */} + + +
+ Value Trend +
+ + +
+
+

+ Showing data for the last {getTimespanLabel(selectedTimespan)} + {sensor.source === 'hardware' ? ' (Real sensor data)' : ' (Mock data)'} +

+
+ +
+ + + + + + [`${value.toFixed(0)}`, "Value"]} + labelFormatter={(label) => `Time: ${label}`} + /> + + + +
+
+
+ + {/* Additional Statistics */} +
+ + +

Max Value

+

+ {Math.max(...sensor.data.map(d => d.value)).toFixed(0)} +

+
+
+ + + +

Min Value

+

+ {Math.min(...sensor.data.map(d => d.value)).toFixed(0)} +

+
+
+ + + +

Average

+

+ {(sensor.data.reduce((sum, d) => sum + d.value, 0) / sensor.data.length).toFixed(0)} +

+
+
+ + + +

Data Points

+

{sensor.data.length}

+
+
+
+ +
+ + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/bed-pressure/StatsCards.tsx b/components/bed-pressure/StatsCards.tsx new file mode 100644 index 0000000..183f372 --- /dev/null +++ b/components/bed-pressure/StatsCards.tsx @@ -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 ( +
+ + +
+ +
+

Patient

+

John Doe - Room 204

+
+
+
+
+ + + +
+

Average Value

+

+ {avgValue.toFixed(0)} +

+
+
+
+ + + +
+

Active Sensors

+

{activeSensors}

+
+
+
+ + + +
+ 0 ? "text-red-600" : "text-gray-400"}`} /> +
+

Critical Alerts

+

0 ? "text-red-600" : "text-gray-600"}`}> + {criticalCount} +

+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..dcbbc0c --- /dev/null +++ b/components/ui/select.tsx @@ -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) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/hooks/useBedPressureData.ts b/hooks/useBedPressureData.ts new file mode 100644 index 0000000..a00822f --- /dev/null +++ b/hooks/useBedPressureData.ts @@ -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 = {} + 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 + } +} \ No newline at end of file diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..d084cca 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -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[]) { diff --git a/package.json b/package.json index 934ac85..78a2b12 100644 --- a/package.json +++ b/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" } } diff --git a/services/AlarmManager.ts b/services/AlarmManager.ts new file mode 100644 index 0000000..1d16615 --- /dev/null +++ b/services/AlarmManager.ts @@ -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 = 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); + } + } +} \ No newline at end of file diff --git a/services/SensorDataStorage.ts b/services/SensorDataStorage.ts new file mode 100644 index 0000000..d5a5013 --- /dev/null +++ b/services/SensorDataStorage.ts @@ -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 { + 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 { + 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; + } +} \ No newline at end of file diff --git a/stores/bedPressureStore.ts b/stores/bedPressureStore.ts new file mode 100644 index 0000000..0d4aa1d --- /dev/null +++ b/stores/bedPressureStore.ts @@ -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; + 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) => void; + updateSensorData: (updater: (prev: Record) => Record) => 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; + fetchSensorData: () => Promise; + fetchSensorHistory: (sensorId: string, timespan?: number) => Promise; + + // Alarm actions + acknowledgeAlarm: (alarmId: string) => void; + silenceAlarm: (alarmId: string, durationMs?: number) => void; + silenceAllAlarms: (durationMs?: number) => void; +} + +export const useBedPressureStore = create((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() }); + } +})); \ No newline at end of file