173 lines
5.7 KiB
TypeScript
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 },
|
|
})
|