11import type { UserStats } from '../api/historyApi'
22
33const W = 320
4- const H = 140
5- const PAD = { l : 26 , r : 10 , t : 18 , b : 10 }
4+ const H = 150
5+ const PAD = { l : 26 , r : 10 , t : 14 , b : 10 }
66const IW = W - PAD . l - PAD . r
77const IH = H - PAD . t - PAD . b
88const GRID = [ 0 , 50 , 100 ]
99
1010const clamp = ( v : number ) => Math . max ( 0 , Math . min ( 100 , v ) )
1111
12- // 종합 점수 추이를 라이브러리 없이 SVG 추세선(라인+영역)으로. recent 는 최신순이라 뒤집어 시간순으로.
12+ type MetricKey = 'overall' | 'technical' | 'logic' | 'communication'
13+ const METRICS : { key : MetricKey ; label : string ; color : string } [ ] = [
14+ { key : 'overall' , label : '종합' , color : 'var(--color-primary)' } ,
15+ { key : 'technical' , label : '기술' , color : 'var(--color-info)' } ,
16+ { key : 'logic' , label : '논리' , color : 'var(--color-success)' } ,
17+ { key : 'communication' , label : '전달력' , color : 'var(--color-warning)' } ,
18+ ]
19+
20+ // 지표별(종합·기술·논리·전달력) 점수 추이를 라이브러리 없이 SVG 멀티 라인으로.
21+ // recent 는 최신순이라 뒤집어 시간순으로, 종합이 채점된 세션을 x축 스파인으로 쓴다.
1322export function ScoreTrend ( { stats } : { stats : UserStats } ) {
14- const points = [ ...( stats . recent ?? [ ] ) ]
23+ const sessions = [ ...( stats . recent ?? [ ] ) ]
1524 . reverse ( )
1625 . filter ( ( r ) => typeof r . overall === 'number' )
17- . map ( ( r ) => ( { sessionId : r . sessionId , score : clamp ( r . overall as number ) } ) )
26+ const n = sessions . length
1827
19- if ( points . length === 0 ) {
28+ if ( n === 0 ) {
2029 return (
2130 < section className = "flex flex-col gap-2 rounded-2xl border border-border bg-surface-raised p-5 shadow-sm" >
2231 < span className = "text-caption text-fg-muted" > 점수 추이</ span >
@@ -25,22 +34,37 @@ export function ScoreTrend({ stats }: { stats: UserStats }) {
2534 )
2635 }
2736
28- const n = points . length
2937 const sx = ( i : number ) => ( n <= 1 ? PAD . l + IW / 2 : PAD . l + ( IW * i ) / ( n - 1 ) )
3038 const sy = ( s : number ) => PAD . t + IH * ( 1 - s / 100 )
31- const data = points . map ( ( p , i ) => ( { ...p , x : sx ( i ) , y : sy ( p . score ) } ) )
32- const linePts = data . map ( ( d ) => `${ d . x . toFixed ( 1 ) } ,${ d . y . toFixed ( 1 ) } ` ) . join ( ' ' )
33- const areaPts = `${ data [ 0 ] . x . toFixed ( 1 ) } ,${ PAD . t + IH } ${ linePts } ${ data [ n - 1 ] . x . toFixed ( 1 ) } ,${ PAD . t + IH } `
39+
40+ const series = METRICS . map ( ( m ) => {
41+ const pts = sessions
42+ . map ( ( s , i ) =>
43+ typeof s [ m . key ] === 'number'
44+ ? { x : sx ( i ) , y : sy ( clamp ( s [ m . key ] as number ) ) }
45+ : null ,
46+ )
47+ . filter ( ( p ) : p is { x : number ; y : number } => p !== null )
48+ const vals = sessions
49+ . map ( ( s ) => s [ m . key ] )
50+ . filter ( ( v ) : v is number => typeof v === 'number' )
51+ const latest = vals . length ? Math . round ( vals [ vals . length - 1 ] ) : null
52+ const delta =
53+ vals . length >= 2 ? Math . round ( vals [ vals . length - 1 ] - vals [ vals . length - 2 ] ) : null
54+ return { ...m , pts, latest, delta }
55+ } )
3456
3557 return (
3658 < section className = "flex flex-col gap-3 rounded-2xl border border-border bg-surface-raised p-5 shadow-sm" >
37- < span className = "text-caption text-fg-muted" > 종합 점수 추이 (최근 { n } 회)</ span >
59+ < span className = "text-caption text-fg-muted" > 지표별 점수 추이 (최근 { n } 회)</ span >
3860 < svg
3961 viewBox = { `0 0 ${ W } ${ H } ` }
4062 className = "h-36 w-full"
4163 preserveAspectRatio = "none"
4264 role = "img"
43- aria-label = { `종합 점수 추이, 최근 ${ n } 회: ${ data . map ( ( d ) => `${ d . score } 점` ) . join ( ', ' ) } ` }
65+ aria-label = { `지표별 점수 추이, 최근 ${ n } 회. ${ series
66+ . map ( ( s ) => `${ s . label } ${ s . latest ?? '미산정' } ` )
67+ . join ( ', ' ) } `}
4468 >
4569 { /* y축 가이드라인 + 눈금(0/50/100) */ }
4670 { GRID . map ( ( g ) => {
@@ -68,38 +92,54 @@ export function ScoreTrend({ stats }: { stats: UserStats }) {
6892 )
6993 } ) }
7094
71- { /* 영역 + 추세선 (점 2개 이상일 때) */ }
72- { n >= 2 && (
73- < >
74- < polygon points = { areaPts } style = { { fill : 'var(--color-primary)' } } fillOpacity = { 0.12 } />
75- < polyline
76- points = { linePts }
77- fill = "none"
78- style = { { stroke : 'var(--color-primary)' } }
79- strokeWidth = { 2 }
80- strokeLinejoin = "round"
81- strokeLinecap = "round"
95+ { /* 지표별 추세선 (점 2개 이상일 때) */ }
96+ { series . map (
97+ ( s ) =>
98+ s . pts . length >= 2 && (
99+ < polyline
100+ key = { s . key }
101+ points = { s . pts . map ( ( p ) => `${ p . x . toFixed ( 1 ) } ,${ p . y . toFixed ( 1 ) } ` ) . join ( ' ' ) }
102+ fill = "none"
103+ style = { { stroke : s . color } }
104+ strokeWidth = { 1.75 }
105+ strokeLinejoin = "round"
106+ strokeLinecap = "round"
107+ />
108+ ) ,
109+ ) }
110+ { /* 데이터 포인트 */ }
111+ { series . map ( ( s ) =>
112+ s . pts . map ( ( p , i ) => (
113+ < circle
114+ key = { `${ s . key } -${ i } ` }
115+ cx = { p . x }
116+ cy = { p . y }
117+ r = { 2 }
118+ style = { { fill : s . color } }
82119 />
83- </ >
120+ ) ) ,
84121 ) }
122+ </ svg >
85123
86- { /* 데이터 포인트 + 값 라벨 */ }
87- { data . map ( ( d ) => (
88- < g key = { d . sessionId } >
89- < circle cx = { d . x } cy = { d . y } r = { 3 } style = { { fill : 'var(--color-primary)' } } />
90- < text
91- x = { d . x }
92- y = { d . y - 7 }
93- textAnchor = "middle"
94- style = { { fill : 'var(--color-fg)' } }
95- fontSize = { 10 }
96- fontWeight = { 600 }
97- >
98- { d . score }
99- </ text >
100- </ g >
124+ { /* 범례 — 지표별 최신 점수 + 지난번 대비 델타 */ }
125+ < div className = "flex flex-wrap gap-x-4 gap-y-1.5" >
126+ { series . map ( ( s ) => (
127+ < div key = { s . key } className = "flex items-center gap-1.5 text-caption" >
128+ < span
129+ aria-hidden
130+ className = "inline-block h-2 w-2 rounded-full"
131+ style = { { backgroundColor : s . color } }
132+ />
133+ < span className = "text-fg-muted" > { s . label } </ span >
134+ < span className = "font-medium text-fg" > { s . latest ?? '—' } </ span >
135+ { s . delta != null && s . delta !== 0 && (
136+ < span className = { s . delta > 0 ? 'text-success-700' : 'text-danger-700' } >
137+ { s . delta > 0 ? `▲${ s . delta } ` : `▼${ Math . abs ( s . delta ) } ` }
138+ </ span >
139+ ) }
140+ </ div >
101141 ) ) }
102- </ svg >
142+ </ div >
103143 </ section >
104144 )
105145}
0 commit comments