11/**
2- * Six compact SVG sparklines, one per world metric, overlaying
3- * A and B so the user sees where the curves cross across the run.
2+ * Six compact SVG sparklines, one per world metric. Renders two
3+ * shapes:
4+ *
5+ * - Pair mode (default): overlays leader A + leader B polylines on
6+ * each card so the user spots crossover turns. Driven by
7+ * `metric.a` / `metric.b` in the MetricSeries payload.
8+ * - Cohort mode: when `metric.series` is populated, overlays one
9+ * polyline per actor (up to N) and renders a wrapped legend at the
10+ * top. Triggered by ReportView when the user runs a cohort with the
11+ * pair-focus toggle OFF.
12+ *
413 * No chart library; same inline-SVG pattern as CommanderTrajectoryCard.
514 *
615 * @module paracosm/dashboard/reports/MetricSparklines
@@ -35,22 +44,81 @@ function SparkCard({ metric, sideAColor, sideBColor }: CardProps) {
3544 const padX = 4 ;
3645 const padY = 6 ;
3746
38- const all = [ ...metric . a , ...metric . b ] ;
39- if ( all . length === 0 ) return null ;
47+ // Cohort mode: one polyline per actor in `series`. Falls through to
48+ // pair mode when the array is absent or empty.
49+ const isCohortShape = Array . isArray ( metric . series ) && metric . series . length >= 2 ;
50+
51+ const allPoints = isCohortShape
52+ ? metric . series ! . flatMap ( s => s . points )
53+ : [ ...metric . a , ...metric . b ] ;
54+ if ( allPoints . length === 0 ) return null ;
4055
41- const minTurn = Math . min ( ...all . map ( p => p . turn ) ) ;
42- const maxTurn = Math . max ( ...all . map ( p => p . turn ) ) ;
43- const minVal = Math . min ( ...all . map ( p => p . value ) ) ;
44- const maxVal = Math . max ( ...all . map ( p => p . value ) ) ;
56+ const minTurn = Math . min ( ...allPoints . map ( p => p . turn ) ) ;
57+ const maxTurn = Math . max ( ...allPoints . map ( p => p . turn ) ) ;
58+ const minVal = Math . min ( ...allPoints . map ( p => p . value ) ) ;
59+ const maxVal = Math . max ( ...allPoints . map ( p => p . value ) ) ;
4560 const valRange = Math . max ( 1e-6 , maxVal - minVal ) ;
4661 const turnRange = Math . max ( 1 , maxTurn - minTurn ) ;
4762
4863 const xFor = ( turn : number ) => padX + ( W - padX * 2 ) * ( ( turn - minTurn ) / turnRange ) ;
4964 const yFor = ( value : number ) => padY + ( H - padY * 2 ) * ( 1 - ( value - minVal ) / valRange ) ;
5065
66+ if ( isCohortShape ) {
67+ return (
68+ < div className = { styles . card } >
69+ < div className = { styles . cardHeader } >
70+ < span className = { styles . cardLabel } > { metric . label } </ span >
71+ < span className = { styles . cardRange } > T{ minTurn } → T{ maxTurn } </ span >
72+ </ div >
73+ < svg
74+ viewBox = { `0 0 ${ W } ${ H } ` }
75+ width = "100%"
76+ height = { H }
77+ role = "img"
78+ aria-label = { `${ metric . label } sparkline (${ metric . series ! . length } actors)` }
79+ >
80+ < line x1 = { padX } y1 = { H / 2 } x2 = { W - padX } y2 = { H / 2 } stroke = "var(--border)" strokeWidth = "0.5" strokeDasharray = "2,3" />
81+ { metric . series ! . map ( ( s ) => {
82+ const points = s . points . map ( p => `${ xFor ( p . turn ) } ,${ yFor ( p . value ) } ` ) . join ( ' ' ) ;
83+ if ( ! points ) return null ;
84+ return (
85+ < polyline
86+ key = { s . actorId }
87+ points = { points }
88+ fill = "none"
89+ stroke = { s . color }
90+ strokeWidth = "1.5"
91+ strokeLinejoin = "round"
92+ strokeLinecap = "round"
93+ opacity = { 0.78 }
94+ >
95+ < title > { s . name } </ title >
96+ </ polyline >
97+ ) ;
98+ } ) }
99+ </ svg >
100+ < div className = { styles . cardFooterCohort } >
101+ { metric . series ! . map ( ( s ) => {
102+ const last = s . points [ s . points . length - 1 ] ?. value ;
103+ return (
104+ < span
105+ key = { s . actorId }
106+ className = { styles . lastCohort }
107+ style = { { [ '--actor-color' as string ] : s . color } as CSSProperties }
108+ title = { s . name }
109+ >
110+ { last != null ? formatValue ( last , metric . unit ) : '·' }
111+ </ span >
112+ ) ;
113+ } ) }
114+ </ div >
115+ </ div >
116+ ) ;
117+ }
118+
119+ // Pair mode (original rendering).
51120 const aPoints = metric . a . map ( p => `${ xFor ( p . turn ) } ,${ yFor ( p . value ) } ` ) . join ( ' ' ) ;
52121 const bPoints = metric . b . map ( p => `${ xFor ( p . turn ) } ,${ yFor ( p . value ) } ` ) . join ( ' ' ) ;
53-
54122 const aLast = metric . a [ metric . a . length - 1 ] ?. value ;
55123 const bLast = metric . b [ metric . b . length - 1 ] ?. value ;
56124
@@ -91,7 +159,14 @@ export function MetricSparklines(props: MetricSparklinesProps) {
91159 const { metrics, leaderAName, leaderBName } = props ;
92160 const sideAColor = props . sideAColor ?? 'var(--vis)' ;
93161 const sideBColor = props . sideBColor ?? 'var(--eng)' ;
94- const populated = metrics . filter ( m => m . a . length > 0 || m . b . length > 0 ) ;
162+ // Detect cohort shape off the first metric — collectMetricSeriesCohort
163+ // populates `series` on every entry uniformly.
164+ const isCohortShape = metrics . length > 0 && Array . isArray ( metrics [ 0 ] . series ) && metrics [ 0 ] . series ! . length >= 2 ;
165+ const populated = metrics . filter ( m =>
166+ isCohortShape
167+ ? ( m . series ?? [ ] ) . some ( s => s . points . length > 0 )
168+ : ( m . a . length > 0 || m . b . length > 0 ) ,
169+ ) ;
95170 if ( populated . length === 0 ) return null ;
96171
97172 const themeStyle = {
@@ -103,11 +178,25 @@ export function MetricSparklines(props: MetricSparklinesProps) {
103178 < section aria-label = "Metric sparklines" className = { styles . section } style = { themeStyle } >
104179 < div className = { styles . sectionHeader } >
105180 < span className = { styles . sectionTitle } > Metric Trajectories</ span >
106- < span className = { styles . legend } >
107- < span className = { styles . legendA } > { leaderAName } </ span >
108- { ' · ' }
109- < span className = { styles . legendB } > { leaderBName } </ span >
110- </ span >
181+ { isCohortShape ? (
182+ < span className = { styles . legendCohort } >
183+ { ( populated [ 0 ] . series ?? [ ] ) . map ( ( s ) => (
184+ < span
185+ key = { s . actorId }
186+ className = { styles . legendCohortEntry }
187+ style = { { [ '--actor-color' as string ] : s . color } as CSSProperties }
188+ >
189+ { s . name }
190+ </ span >
191+ ) ) }
192+ </ span >
193+ ) : (
194+ < span className = { styles . legend } >
195+ < span className = { styles . legendA } > { leaderAName } </ span >
196+ { ' · ' }
197+ < span className = { styles . legendB } > { leaderBName } </ span >
198+ </ span >
199+ ) }
111200 </ div >
112201 < div className = { `responsive-grid-3 ${ styles . grid } ` } >
113202 { populated . map ( m => (
0 commit comments