feat: add comparison screen with simplified labels

This commit is contained in:
haneulai 2026-02-12 10:51:16 +09:00
parent 3420bee2bf
commit a1f8c74ec8
2 changed files with 454 additions and 0 deletions

22
App.js
View File

@ -14,6 +14,7 @@ import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
import PlantDetailScreen from './screens/PlantDetailScreen'; import PlantDetailScreen from './screens/PlantDetailScreen';
import ComparisonScreen from './screens/ComparisonScreen';
const API_URL = 'https://solorpower.dadot.net/plants/1'; const API_URL = 'https://solorpower.dadot.net/plants/1';
const Stack = createNativeStackNavigator(); const Stack = createNativeStackNavigator();
@ -156,6 +157,12 @@ function DashboardScreen({ navigation }) {
<View style={styles.header}> <View style={styles.header}>
<View style={styles.headerTop}> <View style={styles.headerTop}>
<Text style={styles.headerTitle}> 태양광 발전 현황</Text> <Text style={styles.headerTitle}> 태양광 발전 현황</Text>
<TouchableOpacity
style={styles.compareButton}
onPress={() => navigation.navigate('Comparison')}
>
<Text style={styles.compareButtonText}>📊 전체 비교</Text>
</TouchableOpacity>
</View> </View>
<Text style={styles.headerSubtitle}>실시간 모니터링 대시보드</Text> <Text style={styles.headerSubtitle}>실시간 모니터링 대시보드</Text>
</View> </View>
@ -269,6 +276,7 @@ export default function App() {
}} }}
> >
<Stack.Screen name="Dashboard" component={DashboardScreen} /> <Stack.Screen name="Dashboard" component={DashboardScreen} />
<Stack.Screen name="Comparison" component={ComparisonScreen} />
<Stack.Screen name="PlantDetail" component={PlantDetailScreen} /> <Stack.Screen name="PlantDetail" component={PlantDetailScreen} />
</Stack.Navigator> </Stack.Navigator>
</NavigationContainer> </NavigationContainer>
@ -497,4 +505,18 @@ const styles = StyleSheet.create({
width: 40, width: 40,
textAlign: 'right', textAlign: 'right',
}, },
compareButton: {
backgroundColor: 'rgba(255,255,255,0.2)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 20,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.4)',
marginLeft: 12,
},
compareButtonText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
}); });

432
screens/ComparisonScreen.js Normal file
View File

@ -0,0 +1,432 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
StyleSheet,
View,
Text,
TouchableOpacity,
ActivityIndicator,
ScrollView,
useWindowDimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { BarChart } from 'react-native-gifted-charts';
const API_BASE_URL = 'https://solorpower.dadot.net';
export default function ComparisonScreen({ navigation }) {
const [period, setPeriod] = useState('day');
const [statsData, setStatsData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [currentDate, setCurrentDate] = useState(new Date());
const { width } = useWindowDimensions();
useEffect(() => {
setCurrentDate(new Date());
}, [period]);
const moveDate = (direction) => {
const newDate = new Date(currentDate);
if (period === 'day') {
newDate.setDate(newDate.getDate() + direction);
} else if (period === 'month') {
newDate.setMonth(newDate.getMonth() + direction);
} else if (period === 'year') {
newDate.setFullYear(newDate.getFullYear() + direction);
}
setCurrentDate(newDate);
};
const formatDateDisplay = () => {
const y = currentDate.getFullYear();
const m = currentDate.getMonth() + 1;
const d = currentDate.getDate();
if (period === 'day') return `${y}${m}${d}`;
if (period === 'month') return `${y}${m}`;
if (period === 'year') return `${y}`;
return '';
};
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
const y = currentDate.getFullYear();
const m = currentDate.getMonth() + 1;
const d = currentDate.getDate();
const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
let query = `period=${period}`;
if (period === 'day') {
query += `&date=${dateStr}`;
} else if (period === 'month') {
query += `&year=${y}&month=${m}`;
} else if (period === 'year') {
query += `&year=${y}`;
}
const response = await fetch(`${API_BASE_URL}/plants/stats/comparison?${query}`);
if (!response.ok) throw new Error(`HTTP Error: ${response.status}`);
const result = await response.json();
setStatsData(result.data || []);
} catch (err) {
setError(err.message);
setStatsData([]);
} finally {
setLoading(false);
}
}, [period, currentDate]);
useEffect(() => {
fetchStats();
}, [fetchStats]);
// Chart Data Preparation
const chartData = statsData.map(item => {
let label = item.plant_name;
const match = label.match(/(\d+호기)/);
if (match) {
label = match[1];
} else {
label = label.replace(/태양과바람|발전소/g, '').trim();
}
return {
value: item.generation,
label: label,
frontColor: '#3B82F6',
topLabelComponent: () => (
<Text style={{ color: '#6B7280', fontSize: 10, marginBottom: 4 }}>
{item.generation.toFixed(0)}
</Text>
),
};
});
const renderTabs = () => (
<View style={styles.tabContainer}>
{['day', 'month', 'year'].map((p) => (
<TouchableOpacity
key={p}
style={[styles.tab, period === p && styles.tabActive]}
onPress={() => setPeriod(p)}
>
<Text style={[styles.tabText, period === p && styles.tabTextActive]}>
{p === 'day' ? '일간' : p === 'month' ? '월간' : '연간'}
</Text>
</TouchableOpacity>
))}
</View>
);
const renderDateControl = () => (
<View style={styles.dateControl}>
<TouchableOpacity onPress={() => moveDate(-1)} style={styles.arrowButton}>
<Text style={styles.arrowText}></Text>
</TouchableOpacity>
<Text style={styles.dateText}>{formatDateDisplay()}</Text>
<TouchableOpacity onPress={() => moveDate(1)} style={styles.arrowButton}>
<Text style={styles.arrowText}></Text>
</TouchableOpacity>
</View>
);
const renderTable = () => (
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.columnHeader, { flex: 2 }]}>발전소</Text>
<Text style={[styles.columnHeader, { flex: 1.5 }]}>발전량</Text>
<Text style={[styles.columnHeader, { flex: 1.5 }]}>용량</Text>
<Text style={[styles.columnHeader, { flex: 1.5 }]}>시간</Text>
</View>
{statsData.map((item, index) => (
<View key={item.plant_id} style={[styles.tableRow, index % 2 === 1 && styles.tableRowAlt]}>
<Text style={[styles.cell, { flex: 2, textAlign: 'left' }]}>{item.plant_name}</Text>
<Text style={[styles.cell, { flex: 1.5 }]}>{item.generation.toFixed(1)}</Text>
<Text style={[styles.cell, { flex: 1.5, color: '#9CA3AF' }]}>{item.capacity}</Text>
<Text style={[styles.cell, { flex: 1.5, fontWeight: 'bold', color: '#10B981' }]}>
{item.generation_hours.toFixed(2)}
</Text>
</View>
))}
{statsData.length === 0 && (
<View style={styles.emptyRow}>
<Text style={styles.emptyText}>데이터가 없습니다</Text>
</View>
)}
</View>
);
const chartWidth = width - 40;
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
<Text style={styles.backButtonText}> 뒤로</Text>
</TouchableOpacity>
<Text style={styles.headerTitle}>전체 발전소 비교</Text>
<View style={{ width: 60 }} />
</View>
<ScrollView style={styles.content} contentContainerStyle={styles.scrollContent}>
{renderDateControl()}
{renderTabs()}
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#3B82F6" />
<Text style={styles.loadingText}>데이터 불러오는 ...</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>데이터 조회 실패</Text>
<Text style={styles.errorDetail}>{error}</Text>
<TouchableOpacity style={styles.retryButton} onPress={fetchStats}>
<Text style={styles.retryButtonText}>다시 시도</Text>
</TouchableOpacity>
</View>
) : (
<>
<View style={styles.chartSection}>
<Text style={styles.sectionTitle}>📊 발전량 비교 (kWh)</Text>
<View style={styles.chartContainer}>
{statsData.length > 0 ? (
<BarChart
data={chartData}
width={chartWidth}
height={200}
barWidth={20}
spacing={15}
barBorderRadius={4}
frontColor="#3B82F6"
yAxisThickness={0}
xAxisThickness={1}
yAxisTextStyle={{ color: '#9CA3AF', fontSize: 10 }}
xAxisLabelTextStyle={{ color: '#6B7280', fontSize: 10 }}
hideRules
showValuesAsTopLabel
/>
) : (
<Text style={styles.noDataText}>표시할 데이터가 없습니다.</Text>
)}
</View>
</View>
<View style={styles.tableSection}>
<View style={styles.tableHeaderRow}>
<Text style={styles.sectionTitle}>📋 상세 현황</Text>
<Text style={styles.unitText}>시간 = 발전량 / 설비용량 {period === 'year' ? '/ 365' : ''}</Text>
</View>
{renderTable()}
</View>
</>
)}
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#F3F4F6',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: '#1E40AF',
padding: 16,
},
backButton: {
padding: 8,
},
backButtonText: {
color: '#FFFFFF',
fontSize: 16,
fontWeight: '500',
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#FFFFFF',
},
content: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 40,
},
dateControl: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
gap: 16,
},
arrowButton: {
padding: 8,
},
arrowText: {
fontSize: 20,
color: '#4B5563',
},
dateText: {
fontSize: 18,
fontWeight: 'bold',
color: '#1F2937',
},
tabContainer: {
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderRadius: 12,
padding: 4,
marginBottom: 16,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
tab: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
borderRadius: 8,
},
tabActive: {
backgroundColor: '#3B82F6',
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#6B7280',
},
tabTextActive: {
color: '#FFFFFF',
},
loadingContainer: {
height: 300,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
color: '#6B7280',
},
errorContainer: {
height: 300,
justifyContent: 'center',
alignItems: 'center',
},
errorText: {
fontSize: 16,
color: '#EF4444',
marginBottom: 8,
},
errorDetail: {
fontSize: 12,
color: '#6B7280',
marginBottom: 16,
},
retryButton: {
backgroundColor: '#3B82F6',
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
retryButtonText: {
color: '#FFFFFF',
},
chartSection: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
marginBottom: 16,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#1F2937',
marginBottom: 12,
},
chartContainer: {
alignItems: 'center',
paddingVertical: 10,
},
noDataText: {
color: '#9CA3AF',
marginVertical: 20,
},
tableSection: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
padding: 16,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
tableHeaderRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
unitText: {
fontSize: 11,
color: '#6B7280',
},
table: {
borderWidth: 1,
borderColor: '#E5E7EB',
borderRadius: 8,
overflow: 'hidden',
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#F3F4F6',
paddingVertical: 10,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
columnHeader: {
fontSize: 12,
fontWeight: 'bold',
color: '#4B5563',
textAlign: 'center',
},
tableRow: {
flexDirection: 'row',
paddingVertical: 12,
paddingHorizontal: 8,
borderBottomWidth: 1,
borderBottomColor: '#E5E7EB',
},
tableRowAlt: {
backgroundColor: '#F9FAFB',
},
cell: {
fontSize: 12,
color: '#1F2937',
textAlign: 'center',
},
emptyRow: {
padding: 20,
alignItems: 'center',
},
emptyText: {
color: '#9CA3AF',
fontSize: 14,
},
});