/** * 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; 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 ( ) } 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 ( {title && {title}} {/* 범례 */} {SERIES.map(se => ( {se.label} ))} {/* Y축 라벨 */} {[100, 75, 50, 25, 0].map(v => ( {v} ))} {/* 차트 영역 */} {/* 가로 격자선 */} {[0, 0.25, 0.5, 0.75, 1].map(g => ( ))} {width > 0 && n >= 1 && SERIES.map(se => data.map((p, i) => { if (i === n - 1) return null return ( ) }) )} {/* 데이터 포인트 점 */} {width > 0 && SERIES.map(se => data.map((p, i) => ( )) )} {n === 0 && ( 데이터 없음 )} {/* X축 라벨 */} {xLabels.map((l, i) => ( {l} ))} ) } 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 }, })