Commit fcfb9aec authored by Sensor MVP Team's avatar Sensor MVP Team
Browse files

initial draft

parent 704ef42a
import { SensorData, Device } from '../services/api';
import { isSensorData, isDevice, toSafeNumber, toSafeString, safeGet } from './typeGuards';
// 차트 데이터 인터페이스
export interface ChartDataPoint {
device_id: string;
temperature: number;
humidity: number;
pm10: number;
pm25: number;
pressure: number;
illumination: number;
tvoc: number;
co2: number;
o2: number;
co: number;
timestamp?: string;
}
// 센서 데이터를 차트 데이터로 변환
export const transformSensorDataToChartData = (sensorData: SensorData[]): ChartDataPoint[] => {
if (!Array.isArray(sensorData) || sensorData.length === 0) {
return createDefaultChartData();
}
try {
const validData = sensorData.filter(data => {
if (!isSensorData(data)) {
console.warn('Invalid sensor data found:', data);
return false;
}
return true;
});
if (validData.length === 0) {
console.warn('No valid sensor data found, returning default data');
return createDefaultChartData();
}
return validData.map(data => ({
device_id: toSafeString(data.device_id, 'Unknown'),
temperature: toSafeNumber(data.temperature, 0),
humidity: toSafeNumber(data.humidity, 0),
pm10: toSafeNumber(data.pm10, 0),
pm25: toSafeNumber(data.pm25, 0),
pressure: toSafeNumber(data.pressure, 0),
illumination: toSafeNumber(data.illumination, 0),
tvoc: toSafeNumber(data.tvoc, 0),
co2: toSafeNumber(data.co2, 0),
o2: toSafeNumber(data.o2, 0),
co: toSafeNumber(data.co, 0),
timestamp: toSafeString(data.recorded_time, '')
}));
} catch (error) {
console.error('Error transforming sensor data to chart data:', error);
return createDefaultChartData();
}
};
// 기본 차트 데이터 생성
export const createDefaultChartData = (): ChartDataPoint[] => {
return [{
device_id: 'No Data',
temperature: 0,
humidity: 0,
pm10: 0,
pm25: 0,
pressure: 0,
illumination: 0,
tvoc: 0,
co2: 0,
o2: 0,
co: 0,
timestamp: new Date().toISOString()
}];
};
// 센서 카드 데이터 인터페이스
export interface SensorCardData {
title: string;
value: number;
unit: string;
icon: string;
bgColor: string;
precision: number;
}
// 센서 데이터를 카드 데이터로 변환
export const transformSensorDataToCardData = (sensorData: SensorData | null): SensorCardData[] => {
if (!sensorData || !isSensorData(sensorData)) {
return createDefaultCardData();
}
try {
return [
{
title: '현재 온도',
value: toSafeNumber(sensorData.temperature, 0),
unit: '°C',
icon: '🌡️',
bgColor: 'bg-green-100',
precision: 1
},
{
title: '현재 습도',
value: toSafeNumber(sensorData.humidity, 0),
unit: '%',
icon: '💧',
bgColor: 'bg-yellow-100',
precision: 1
},
{
title: 'PM10',
value: toSafeNumber(sensorData.pm10, 0),
unit: 'μg/m³',
icon: '🌫️',
bgColor: 'bg-orange-100',
precision: 1
},
{
title: 'PM2.5',
value: toSafeNumber(sensorData.pm25, 0),
unit: 'μg/m³',
icon: '💨',
bgColor: 'bg-red-100',
precision: 1
},
{
title: '기압',
value: toSafeNumber(sensorData.pressure, 0),
unit: 'hPa',
icon: '🌪️',
bgColor: 'bg-blue-100',
precision: 1
},
{
title: '조도',
value: toSafeNumber(sensorData.illumination, 0),
unit: 'lux',
icon: '☀️',
bgColor: 'bg-yellow-100',
precision: 0
},
{
title: 'TVOC',
value: toSafeNumber(sensorData.tvoc, 0),
unit: 'ppb',
icon: '🧪',
bgColor: 'bg-purple-100',
precision: 1
},
{
title: 'CO2',
value: toSafeNumber(sensorData.co2, 0),
unit: 'ppm',
icon: '🌿',
bgColor: 'bg-green-100',
precision: 0
},
{
title: 'O2',
value: toSafeNumber(sensorData.o2, 0),
unit: '%',
icon: '💨',
bgColor: 'bg-cyan-100',
precision: 1
},
{
title: 'CO',
value: toSafeNumber(sensorData.co, 0),
unit: 'ppm',
icon: '🔥',
bgColor: 'bg-red-100',
precision: 2
}
];
} catch (error) {
console.error('Error transforming sensor data to card data:', error);
return createDefaultCardData();
}
};
// 기본 카드 데이터 생성
export const createDefaultCardData = (): SensorCardData[] => {
return [
{
title: '현재 온도',
value: 0,
unit: '°C',
icon: '🌡️',
bgColor: 'bg-gray-100',
precision: 1
},
{
title: '현재 습도',
value: 0,
unit: '%',
icon: '💧',
bgColor: 'bg-gray-100',
precision: 1
},
{
title: 'PM10',
value: 0,
unit: 'μg/m³',
icon: '🌫️',
bgColor: 'bg-gray-100',
precision: 1
},
{
title: 'PM2.5',
value: 0,
unit: 'μg/m³',
icon: '💨',
bgColor: 'bg-gray-100',
precision: 1
},
{
title: '기압',
value: 0,
unit: 'hPa',
icon: '🌪️',
bgColor: 'bg-gray-100',
precision: 1
},
{
title: '조도',
value: 0,
unit: 'lux',
icon: '☀️',
bgColor: 'bg-gray-100',
precision: 0
},
{
title: 'TVOC',
value: 0,
unit: 'ppb',
icon: '🧪',
bgColor: 'bg-gray-100',
precision: 1
},
{
title: 'CO2',
value: 0,
unit: 'ppm',
icon: '🌿',
bgColor: 'bg-gray-100',
precision: 0
},
{
title: 'O2',
value: 0,
unit: '%',
icon: '💨',
bgColor: 'bg-gray-100',
precision: 1
},
{
title: 'CO',
value: 0,
unit: 'ppm',
icon: '🔥',
bgColor: 'bg-gray-100',
precision: 2
}
];
};
// 데이터 정규화 함수
export const normalizeSensorData = (data: any): SensorData | null => {
if (!data || typeof data !== 'object') {
return null;
}
try {
// 필수 필드 검증
const requiredFields = ['id', 'device_id', 'node_id', 'temperature', 'humidity'];
const hasRequiredFields = requiredFields.every(field =>
data.hasOwnProperty(field) && data[field] !== null && data[field] !== undefined
);
if (!hasRequiredFields) {
console.warn('Missing required fields in sensor data:', data);
return null;
}
// 데이터 정규화
const normalized: SensorData = {
id: toSafeNumber(data.id, 0),
device_id: toSafeString(data.device_id, ''),
node_id: toSafeNumber(data.node_id, 0),
temperature: toSafeNumber(data.temperature, 0),
humidity: toSafeNumber(data.humidity, 0),
longitude: toSafeNumber(data.longitude, 0),
latitude: toSafeNumber(data.latitude, 0),
recorded_time: toSafeString(data.recorded_time, new Date().toISOString()),
received_time: toSafeString(data.received_time, new Date().toISOString()),
// 선택적 필드들
float_value: safeGet(data, 'float_value', undefined),
signed_int32_value: safeGet(data, 'signed_int32_value', undefined),
unsigned_int32_value: safeGet(data, 'unsigned_int32_value', undefined),
raw_tem: safeGet(data, 'raw_tem', undefined),
raw_hum: safeGet(data, 'raw_hum', undefined),
pm10: safeGet(data, 'pm10', undefined),
pm25: safeGet(data, 'pm25', undefined),
pressure: safeGet(data, 'pressure', undefined),
illumination: safeGet(data, 'illumination', undefined),
tvoc: safeGet(data, 'tvoc', undefined),
co2: safeGet(data, 'co2', undefined),
o2: safeGet(data, 'o2', undefined),
co: safeGet(data, 'co', undefined)
};
return normalized;
} catch (error) {
console.error('Error normalizing sensor data:', error);
return null;
}
};
// 데이터 유효성 검증 함수
export const validateChartData = (data: ChartDataPoint[]): boolean => {
if (!Array.isArray(data) || data.length === 0) {
return false;
}
return data.every(item => {
if (!item || typeof item !== 'object') {
return false;
}
// 필수 필드 검증
if (!item.device_id || typeof item.device_id !== 'string') {
return false;
}
// 숫자 필드 검증
const numberFields = ['temperature', 'humidity', 'pm10', 'pm25', 'pressure', 'illumination', 'tvoc', 'co2', 'o2', 'co'];
return numberFields.every(field => {
const value = item[field as keyof ChartDataPoint];
return typeof value === 'number' && !isNaN(value) && isFinite(value);
});
});
};
// 데이터 통계 계산 함수
export const calculateDataStats = (data: ChartDataPoint[]): {
avg: ChartDataPoint;
min: ChartDataPoint;
max: ChartDataPoint;
} => {
if (!Array.isArray(data) || data.length === 0) {
const defaultPoint = createDefaultChartData()[0];
return {
avg: defaultPoint,
min: defaultPoint,
max: defaultPoint
};
}
const numericFields = ['temperature', 'humidity', 'pm10', 'pm25', 'pressure', 'illumination', 'tvoc', 'co2', 'o2', 'co'] as const;
const stats = {
avg: { ...data[0] },
min: { ...data[0] },
max: { ...data[0] }
};
numericFields.forEach(field => {
const values = data.map(item => item[field]).filter(val => typeof val === 'number' && !isNaN(val));
if (values.length > 0) {
stats.avg[field] = values.reduce((sum, val) => sum + val, 0) / values.length;
stats.min[field] = Math.min(...values);
stats.max[field] = Math.max(...values);
}
});
return stats;
};
\ No newline at end of file
import { SensorData } from '../services/api';
export interface ExportOptions {
format: 'csv' | 'json';
includeHeaders?: boolean;
dateRange?: {
start: string;
end: string;
};
}
/**
* 센서 데이터를 CSV 형식으로 변환
*/
const convertToCSV = (data: SensorData[], includeHeaders: boolean = true): string => {
if (data.length === 0) return '';
const headers = [
'ID', 'Device ID', 'Node ID', 'Temperature (°C)', 'Humidity (%)',
'Longitude', 'Latitude', 'PM10 (μg/m³)', 'PM2.5 (μg/m³)',
'Pressure (hPa)', 'Illumination (lux)', 'TVOC (ppb)',
'CO2 (ppm)', 'O2 (%)', 'CO (ppm)', 'Recorded Time', 'Received Time'
];
const rows = data.map(item => [
item.id,
item.device_id,
item.node_id,
item.temperature,
item.humidity,
item.longitude,
item.latitude,
item.pm10 || '',
item.pm25 || '',
item.pressure || '',
item.illumination || '',
item.tvoc || '',
item.co2 || '',
item.o2 || '',
item.co || '',
item.recorded_time,
item.received_time
]);
let csv = '';
if (includeHeaders) {
csv += headers.join(',') + '\n';
}
csv += rows.map(row =>
row.map(cell =>
typeof cell === 'string' && cell.includes(',')
? `"${cell}"`
: cell
).join(',')
).join('\n');
return csv;
};
/**
* 센서 데이터를 JSON 형식으로 변환
*/
const convertToJSON = (data: SensorData[]): string => {
return JSON.stringify(data, null, 2);
};
/**
* 데이터를 파일로 다운로드
*/
const downloadFile = (content: string, filename: string, mimeType: string) => {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
/**
* 센서 데이터 내보내기
*/
export const exportSensorData = (
data: SensorData[],
options: ExportOptions
): void => {
const { format, includeHeaders = true, dateRange } = options;
// 날짜 범위 필터링
let filteredData = data;
if (dateRange) {
filteredData = data.filter(item => {
const recordedTime = new Date(item.recorded_time);
const start = new Date(dateRange.start);
const end = new Date(dateRange.end);
return recordedTime >= start && recordedTime <= end;
});
}
// 파일명 생성
const timestamp = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
const filename = `sensor-data-${timestamp}.${format}`;
let content: string;
let mimeType: string;
if (format === 'csv') {
content = convertToCSV(filteredData, includeHeaders);
mimeType = 'text/csv;charset=utf-8;';
} else {
content = convertToJSON(filteredData);
mimeType = 'application/json;charset=utf-8;';
}
downloadFile(content, filename, mimeType);
};
/**
* 현재 페이지의 센서 데이터 내보내기 (Dashboard용)
*/
export const exportCurrentData = (
data: SensorData[],
format: 'csv' | 'json' = 'csv'
): void => {
exportSensorData(data, { format });
};
\ No newline at end of file
/**
* 센서 값 포맷팅 함수
* @param value 센서 값
* @param precision 소수점 자릿수 (기본값: 1)
* @returns 포맷팅된 값 또는 'N/A'
*/
export const formatSensorValue = (value: number | undefined, precision: number = 1): string => {
if (value === undefined || value === null) {
return 'N/A';
}
// 매우 작은 값(e-40 등) 처리
if (Math.abs(value) < 1e-10) {
return '0.0';
}
// 무한대 값 처리
if (!isFinite(value)) {
return 'N/A';
}
return value.toFixed(precision);
};
/**
* 센서 값 검증 함수
* @param value 센서 값
* @param min 최소값
* @param max 최대값
* @returns 유효한 값인지 여부
*/
export const validateSensorValue = (value: number | undefined, min: number = -1000, max: number = 10000): boolean => {
if (value === undefined || value === null) {
return false;
}
if (!isFinite(value)) {
return false;
}
if (Math.abs(value) < 1e-10) {
return true; // 0 값은 유효
}
return value >= min && value <= max;
};
/**
* 센서별 기본 정밀도 반환
* @param sensorType 센서 타입
* @returns 기본 정밀도
*/
export const getSensorPrecision = (sensorType: string): number => {
const precisionMap: { [key: string]: number } = {
temperature: 1,
humidity: 1,
pressure: 1,
illumination: 0,
pm10: 1,
pm25: 1,
tvoc: 1,
co2: 0,
o2: 1,
co: 2,
};
return precisionMap[sensorType] || 1;
};
\ No newline at end of file
export enum LogLevel {
DEBUG = 'debug',
INFO = 'info',
WARN = 'warn',
ERROR = 'error'
}
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
data?: any;
userId?: string;
sessionId?: string;
userAgent?: string;
url?: string;
}
export interface UserAction {
action: string;
component: string;
data?: any;
timestamp: string;
sessionId: string;
}
class Logger {
private logs: LogEntry[] = [];
private userActions: UserAction[] = [];
private maxLogs = 1000;
private sessionId: string;
constructor() {
this.sessionId = this.generateSessionId();
this.setupGlobalErrorHandler();
}
private generateSessionId(): string {
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
private setupGlobalErrorHandler() {
window.addEventListener('error', (event) => {
this.error('Global error', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error: event.error?.stack
});
});
window.addEventListener('unhandledrejection', (event) => {
this.error('Unhandled promise rejection', {
reason: event.reason,
promise: event.promise
});
});
}
private addLog(level: LogLevel, message: string, data?: any) {
const logEntry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
data,
sessionId: this.sessionId,
userAgent: navigator.userAgent,
url: window.location.href
};
this.logs.push(logEntry);
// 로그 개수 제한
if (this.logs.length > this.maxLogs) {
this.logs = this.logs.slice(-this.maxLogs);
}
// 콘솔에 출력
console[level](`[${level.toUpperCase()}] ${message}`, data || '');
// 에러 로그는 서버로 전송
if (level === LogLevel.ERROR) {
this.sendErrorToServer(logEntry);
}
}
private async sendErrorToServer(logEntry: LogEntry) {
try {
await fetch('/api/logs/error', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(logEntry)
});
} catch (error) {
console.error('Failed to send error log to server:', error);
}
}
debug(message: string, data?: any) {
this.addLog(LogLevel.DEBUG, message, data);
}
info(message: string, data?: any) {
this.addLog(LogLevel.INFO, message, data);
}
warn(message: string, data?: any) {
this.addLog(LogLevel.WARN, message, data);
}
error(message: string, data?: any) {
this.addLog(LogLevel.ERROR, message, data);
}
// 사용자 행동 추적
trackUserAction(action: string, component: string, data?: any) {
const userAction: UserAction = {
action,
component,
data,
timestamp: new Date().toISOString(),
sessionId: this.sessionId
};
this.userActions.push(userAction);
// 사용자 행동 개수 제한
if (this.userActions.length > this.maxLogs) {
this.userActions = this.userActions.slice(-this.maxLogs);
}
this.debug(`User action: ${action}`, { component, data });
}
// 로그 내보내기
exportLogs(): LogEntry[] {
return [...this.logs];
}
// 사용자 행동 내보내기
exportUserActions(): UserAction[] {
return [...this.userActions];
}
// 로그 초기화
clearLogs() {
this.logs = [];
this.userActions = [];
}
// 세션 ID 가져오기
getSessionId(): string {
return this.sessionId;
}
// 로그 통계
getLogStats() {
const stats = {
total: this.logs.length,
byLevel: {
debug: 0,
info: 0,
warn: 0,
error: 0
},
userActions: this.userActions.length
};
this.logs.forEach(log => {
stats.byLevel[log.level]++;
});
return stats;
}
}
export const logger = new Logger();
\ No newline at end of file
import { SensorData, Device, HistoryResponse } from '../services/api';
// 센서 데이터 타입 가드
export const isSensorData = (data: any): data is SensorData => {
return data &&
typeof data === 'object' &&
typeof data.id === 'number' &&
typeof data.device_id === 'string' &&
typeof data.node_id === 'number' &&
typeof data.temperature === 'number' &&
typeof data.humidity === 'number' &&
typeof data.longitude === 'number' &&
typeof data.latitude === 'number' &&
typeof data.recorded_time === 'string' &&
typeof data.received_time === 'string';
};
// 디바이스 타입 가드
export const isDevice = (data: any): data is Device => {
return data &&
typeof data === 'object' &&
typeof data.id === 'number' &&
typeof data.device_id === 'string' &&
typeof data.name === 'string' &&
typeof data.description === 'string' && // description은 빈 문자열도 허용
typeof data.status === 'string' &&
typeof data.last_seen === 'string' &&
typeof data.created_at === 'string' &&
typeof data.updated_at === 'string';
};
// 히스토리 응답 타입 가드
export const isHistoryResponse = (data: any): data is HistoryResponse => {
return data &&
typeof data === 'object' &&
typeof data.success === 'boolean' &&
Array.isArray(data.data) &&
typeof data.total === 'number';
};
// 센서 데이터 배열 타입 가드
export const isSensorDataArray = (data: any): data is SensorData[] => {
return Array.isArray(data) && data.every(item => isSensorData(item));
};
// 디바이스 배열 타입 가드
export const isDeviceArray = (data: any): data is Device[] => {
return Array.isArray(data) && data.every(item => isDevice(item));
};
// 숫자 타입 가드
export const isNumber = (value: any): value is number => {
return typeof value === 'number' && !isNaN(value) && isFinite(value);
};
// 문자열 타입 가드
export const isString = (value: any): value is string => {
return typeof value === 'string' && value.length > 0;
};
// 객체 타입 가드
export const isObject = (value: any): value is Record<string, any> => {
return value !== null && typeof value === 'object' && !Array.isArray(value);
};
// 배열 타입 가드
export const isArray = (value: any): value is any[] => {
return Array.isArray(value);
};
// API 응답 타입 가드
export const isApiResponse = (data: any): boolean => {
return data && typeof data === 'object';
};
// 차트 데이터 타입 가드
export const isChartData = (data: any): boolean => {
return Array.isArray(data) &&
data.length > 0 &&
data.every(item =>
item &&
typeof item === 'object' &&
typeof item.device_id === 'string'
);
};
// 안전한 숫자 변환 함수
export const toSafeNumber = (value: any, defaultValue: number = 0): number => {
if (value === null || value === undefined) {
return defaultValue;
}
const num = Number(value);
if (isNumber(num)) {
return num;
}
return defaultValue;
};
// 안전한 문자열 변환 함수
export const toSafeString = (value: any, defaultValue: string = ''): string => {
if (value === null || value === undefined) {
return defaultValue;
}
const str = String(value);
if (isString(str)) {
return str;
}
return defaultValue;
};
// 객체에서 안전하게 값 추출하는 함수
export const safeGet = <T>(obj: any, key: string, defaultValue: T): T => {
if (!isObject(obj) || !(key in obj)) {
return defaultValue;
}
return obj[key] ?? defaultValue;
};
// 배열에서 안전하게 인덱스 접근하는 함수
export const safeGetAt = <T>(arr: any[], index: number, defaultValue: T): T => {
if (!isArray(arr) || index < 0 || index >= arr.length) {
return defaultValue;
}
return arr[index] ?? defaultValue;
};
\ No newline at end of file
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms'),
],
}
\ No newline at end of file
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"es6"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
\ No newline at end of file
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment