guardia-messenger/components/MetricGraph.tsx

173 lines
5.7 KiB
TypeScript

/**
* MetricGraph (#47) — 실시간 메트릭 꺾은선 그래프
*
* react-native-svg가 설치되어 있지 않은 폐쇄망 호환 환경이므로
* SVG 대신 View + StyleSheet로 꺾은선(세그먼트 회전)을 직접 렌더링한다.
* 각 데이터 포인트 사이를 회전된 얇은 View(선분)로 연결.
*
* 범례: CPU(파랑) / MEM(초록) / DISK(주황)
* X축: 시간, Y축: 0~100%
*/
import { View, Text, StyleSheet, LayoutChangeEvent } from 'react-native'
import { useState } from 'react'
export interface MetricPoint {
timestamp: string
cpu: number
memory: number
disk: number
}
interface SeriesDef { key: keyof Omit<MetricPoint, 'timestamp'>; label: string; color: string }
const SERIES: SeriesDef[] = [
{ key: 'cpu', label: 'CPU', color: '#3b82f6' },
{ key: 'memory', label: 'MEM', color: '#22c55e' },
{ key: 'disk', label: 'DISK', color: '#f59e0b' },
]
const CHART_H = 160
const PAD_BOTTOM = 22 // X축 라벨 영역
/** 두 점을 잇는 선분(회전 View) 생성 */
function Segment({
x1, y1, x2, y2, color,
}: { x1: number; y1: number; x2: number; y2: number; color: string }) {
const dx = x2 - x1
const dy = y2 - y1
const length = Math.sqrt(dx * dx + dy * dy)
const angle = Math.atan2(dy, dx) * (180 / Math.PI)
return (
<View
style={{
position: 'absolute',
left: x1,
top: y1 - 1,
width: length,
height: 2,
backgroundColor: color,
transform: [{ translateX: 0 }, { rotate: `${angle}deg` }],
transformOrigin: 'left center',
}}
/>
)
}
export default function MetricGraph({
data,
title,
}: { data: MetricPoint[]; title?: string }) {
const [width, setWidth] = useState(0)
const onLayout = (e: LayoutChangeEvent) => setWidth(e.nativeEvent.layout.width)
const plotH = CHART_H - PAD_BOTTOM
const n = data.length
// y(0~100%) -> 화면 좌표 (0% 하단, 100% 상단)
const toY = (v: number) => plotH - (Math.max(0, Math.min(100, v)) / 100) * plotH
const toX = (i: number) => (n <= 1 ? 0 : (i / (n - 1)) * width)
const xLabels = (() => {
if (n === 0) return []
const fmt = (ts: string) => {
const d = new Date(ts)
if (isNaN(d.getTime())) return ts
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const first = data[0], last = data[n - 1], mid = data[Math.floor(n / 2)]
return [fmt(first.timestamp), n > 2 ? fmt(mid.timestamp) : '', fmt(last.timestamp)]
})()
return (
<View style={s.wrap}>
{title && <Text style={s.title}>{title}</Text>}
{/* 범례 */}
<View style={s.legend}>
{SERIES.map(se => (
<View key={se.key} style={s.legendItem}>
<View style={[s.legendDot, { backgroundColor: se.color }]} />
<Text style={s.legendTxt}>{se.label}</Text>
</View>
))}
</View>
<View style={s.chartRow}>
{/* Y축 라벨 */}
<View style={s.yAxis}>
{[100, 75, 50, 25, 0].map(v => (
<Text key={v} style={s.yLabel}>{v}</Text>
))}
</View>
{/* 차트 영역 */}
<View style={s.plot} onLayout={onLayout}>
{/* 가로 격자선 */}
{[0, 0.25, 0.5, 0.75, 1].map(g => (
<View key={g} style={[s.grid, { top: g * plotH }]} />
))}
{width > 0 && n >= 1 && SERIES.map(se =>
data.map((p, i) => {
if (i === n - 1) return null
return (
<Segment
key={`${se.key}-${i}`}
x1={toX(i)} y1={toY(p[se.key])}
x2={toX(i + 1)} y2={toY(data[i + 1][se.key])}
color={se.color}
/>
)
})
)}
{/* 데이터 포인트 점 */}
{width > 0 && SERIES.map(se =>
data.map((p, i) => (
<View
key={`pt-${se.key}-${i}`}
style={{
position: 'absolute',
left: toX(i) - 2,
top: toY(p[se.key]) - 2,
width: 4, height: 4, borderRadius: 2,
backgroundColor: se.color,
}}
/>
))
)}
{n === 0 && (
<View style={s.empty}><Text style={s.emptyTxt}> </Text></View>
)}
</View>
</View>
{/* X축 라벨 */}
<View style={s.xAxis}>
{xLabels.map((l, i) => (
<Text key={i} style={s.xLabel}>{l}</Text>
))}
</View>
</View>
)
}
const s = StyleSheet.create({
wrap: { backgroundColor: '#fff', borderRadius: 12, padding: 14 },
title: { fontSize: 13, fontWeight: '700', color: '#1e293b', marginBottom: 8 },
legend: { flexDirection: 'row', gap: 14, marginBottom: 10 },
legendItem: { flexDirection: 'row', alignItems: 'center', gap: 5 },
legendDot: { width: 10, height: 10, borderRadius: 5 },
legendTxt: { fontSize: 11, color: '#64748b', fontWeight: '600' },
chartRow: { flexDirection: 'row' },
yAxis: { width: 26, height: CHART_H - PAD_BOTTOM, justifyContent: 'space-between', alignItems: 'flex-end', paddingRight: 4 },
yLabel: { fontSize: 9, color: '#cbd5e1' },
plot: { flex: 1, height: CHART_H - PAD_BOTTOM, position: 'relative', overflow: 'hidden' },
grid: { position: 'absolute', left: 0, right: 0, height: 1, backgroundColor: '#f1f5f9' },
xAxis: { flexDirection: 'row', justifyContent: 'space-between', marginLeft: 26, marginTop: 4 },
xLabel: { fontSize: 9, color: '#94a3b8' },
empty: { flex: 1, alignItems: 'center', justifyContent: 'center' },
emptyTxt: { color: '#cbd5e1', fontSize: 12 },
})