Skip to content

Commit de43caa

Browse files
committed
feat(reports): cohort sparklines render one polyline per actor when focus-pair is off
1 parent 457c731 commit de43caa

4 files changed

Lines changed: 222 additions & 17 deletions

File tree

src/dashboard/src/components/reports/MetricSparklines.module.scss

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,52 @@
8888
color: var(--side-b-color, var(--eng));
8989
font-weight: 700;
9090
}
91+
92+
/* Cohort variant: chips of the per-actor last-value, wrapping when
93+
* many actors are present so a 12-actor cohort doesn't blow up the
94+
* sparkline card's footer width. */
95+
.cardFooterCohort {
96+
display: flex;
97+
flex-wrap: wrap;
98+
gap: 4px 10px;
99+
margin-top: 4px;
100+
font-size: var(--font-2xs);
101+
font-family: var(--mono);
102+
}
103+
104+
.lastCohort {
105+
color: var(--actor-color, var(--text-3));
106+
font-weight: 700;
107+
letter-spacing: 0.02em;
108+
}
109+
110+
/* Cohort legend at the section header. Wrapping flex of per-actor
111+
* chips; each chip carries the actor's color via the --actor-color
112+
* CSS var set inline. */
113+
.legendCohort {
114+
display: flex;
115+
flex-wrap: wrap;
116+
gap: 4px 10px;
117+
font-family: var(--mono);
118+
font-size: var(--font-2xs);
119+
max-width: 70%;
120+
justify-content: flex-end;
121+
}
122+
123+
.legendCohortEntry {
124+
color: var(--actor-color, var(--text-2));
125+
font-weight: 700;
126+
letter-spacing: 0.02em;
127+
white-space: nowrap;
128+
129+
&::before {
130+
content: '';
131+
display: inline-block;
132+
width: 6px;
133+
height: 6px;
134+
border-radius: 50%;
135+
background: var(--actor-color, var(--text-3));
136+
margin-right: 4px;
137+
vertical-align: 1px;
138+
}
139+
}

src/dashboard/src/components/reports/MetricSparklines.tsx

Lines changed: 104 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
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 => (

src/dashboard/src/components/reports/ReportView.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import { CohortVerdict } from './CohortVerdict';
2525
import { RunStrip } from './RunStrip';
2626
import { MetricSparklines } from './MetricSparklines';
2727
import { ReportSideNav, type SideNavItem } from './ReportSideNav';
28-
import { collectMetricSeries, collectRunStripData } from './reports-shared';
28+
import { collectMetricSeries, collectMetricSeriesCohort, collectRunStripData } from './reports-shared';
29+
import { getActorColorVar } from '../../hooks/useGameState';
2930
import styles from './ReportView.module.scss';
3031

3132
/**
@@ -388,7 +389,16 @@ export function ReportView({ state, verdict, reportSections }: ReportViewProps)
388389
// Strip + sparklines respect the picker so swapping actors rotates
389390
// every panel that mentions A/B in lockstep.
390391
const stripCells = useMemo(() => collectRunStripData(turns), [turns]);
392+
// Pair-mode metric series (A vs B). Used when isPairFocus is on for
393+
// cohort runs and for all 2-actor pair runs.
391394
const metricSeries = useMemo(() => collectMetricSeries(state, aId, bId), [state, aId, bId]);
395+
// Cohort-mode metric series (one polyline per actor). Used when the
396+
// pair-focus toggle is off for cohort runs so the sparklines render
397+
// all N actors at once instead of just the picked pair.
398+
const cohortMetricSeries = useMemo(
399+
() => (isNActor ? collectMetricSeriesCohort(state, getActorColorVar) : null),
400+
[isNActor, state],
401+
);
392402
const sideNavItems = useMemo<SideNavItem[]>(() => {
393403
// Order now matches the new section layout: turn-by-turn content
394404
// at the top (Strip → Metrics → Trajectory → individual turns →
@@ -618,6 +628,15 @@ export function ReportView({ state, verdict, reportSections }: ReportViewProps)
618628
</section>
619629
)}
620630

631+
{/* Cohort sparklines: N polylines per metric card when the
632+
pair-focus toggle is off, replacing the 2-color A/B overlay
633+
with the full cohort trajectory. */}
634+
{isNActor && !isPairFocus && cohortMetricSeries && (
635+
<section id="sparklines">
636+
<MetricSparklines metrics={cohortMetricSeries} leaderAName="" leaderBName="" />
637+
</section>
638+
)}
639+
621640
{/* Commander personality arcs. Shown once per side once there's at
622641
least one turn of drift data, so the user can visually inspect
623642
how each commander's HEXACO evolved across the run. Data comes

src/dashboard/src/components/reports/reports-shared.ts

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,25 @@ export function classifyTurn(
3232
return aFirstTitle === bFirstTitle ? 'shared' : 'divergent';
3333
}
3434

35-
/** Series shape consumed by MetricSparklines. */
35+
/** Per-actor entry in the cohort metric series. */
36+
export interface CohortMetricSeriesPoint {
37+
actorId: string;
38+
name: string;
39+
color: string;
40+
points: Array<{ turn: number; value: number }>;
41+
}
42+
43+
/** Series shape consumed by MetricSparklines. The `a` / `b` pair is the
44+
* pair-mode rendering shape kept for back-compat; when `series` is
45+
* populated MetricSparklines branches into N-polyline cohort mode and
46+
* ignores `a` / `b`. */
3647
export interface MetricSeries {
3748
id: 'population' | 'morale' | 'foodMonthsReserve' | 'powerKw' | 'infrastructureModules' | 'scienceOutput';
3849
label: string;
3950
unit?: string;
4051
a: Array<{ turn: number; value: number }>;
4152
b: Array<{ turn: number; value: number }>;
53+
series?: CohortMetricSeriesPoint[];
4254
}
4355

4456
const METRIC_DEFS: Array<{ id: MetricSeries['id']; label: string; unit?: string }> = [
@@ -98,6 +110,42 @@ export function collectMetricSeries(
98110
}));
99111
}
100112

113+
/**
114+
* Cohort variant: builds the six-metric series for every actor in the
115+
* run. Each MetricSeries carries a populated `series` array (one entry
116+
* per actor) so MetricSparklines can render N polylines per card instead
117+
* of the pair-mode A/B overlay. Leaves `a` / `b` empty since the cohort
118+
* card ignores them when `series` is present.
119+
*/
120+
export function collectMetricSeriesCohort(
121+
state: GameState,
122+
paletteFor: (idx: number) => string,
123+
): MetricSeries[] {
124+
const perActor = state.actorIds.map((actorId, idx) => {
125+
const events = state.actors[actorId]?.events as Array<{ turn?: number; data: Record<string, unknown> }> | undefined;
126+
return {
127+
actorId,
128+
idx,
129+
name: state.actors[actorId]?.leader?.name ?? actorId,
130+
color: paletteFor(idx),
131+
events,
132+
};
133+
});
134+
return METRIC_DEFS.map(def => ({
135+
id: def.id,
136+
label: def.label,
137+
unit: def.unit,
138+
a: [],
139+
b: [],
140+
series: perActor.map(p => ({
141+
actorId: p.actorId,
142+
name: p.name,
143+
color: p.color,
144+
points: p.events ? seriesForSide(p.events, def.id) : [],
145+
})),
146+
}));
147+
}
148+
101149
export interface RunStripCell {
102150
turn: number;
103151
time?: number;

0 commit comments

Comments
 (0)