vignette/apps/web/src/pages/session-review/ValenceChart.tsx
Yun Chan 24b1b7a6e1 feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG
web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
2026-06-25 23:37:22 +09:00

122 lines
4.1 KiB
TypeScript

/* =====================================================================
ValenceChart — 감정 밸런스 타임라인 (SVG 라인 차트).
외부 ref의 Emotional Valence Timeline 차용 → Vignette 토큰 리스킨.
· 내담자 valence = 실선(테라코타 --clay)
· 상담자 baseline = 점선(세이지 --accent-bright)
범례·시간축·0선 포함. preserveAspectRatio="none" 로 컨테이너 채움.
순흑/순백 금지 — stroke 는 토큰 currentColor 계열만.
===================================================================== */
import type { ValencePoint } from "./mock";
export interface ValenceChartProps {
client: ValencePoint[];
baseline: ValencePoint[];
/** 시간축 라벨 (좌→우) */
xLabels: string[];
}
/** valence(-1~1) → SVG y(0~100, 위가 +1). */
function toY(v: number): number {
const clamped = Math.max(-1, Math.min(1, v));
return 50 - clamped * 45; // +1 → 5, 0 → 50, -1 → 95 (상하 5% 여백)
}
/** 점 배열 → 부드러운 Catmull-Rom→베지어 path d. (단조 곡선, 과한 출렁임 없게) */
function smoothPath(points: ValencePoint[]): string {
if (points.length === 0) return "";
const pts = points.map((p) => ({ x: p.t * 100, y: toY(p.v) }));
if (pts.length === 1) return `M${pts[0].x},${pts[0].y}`;
let d = `M${pts[0].x.toFixed(2)},${pts[0].y.toFixed(2)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] ?? pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] ?? p2;
// Catmull-Rom → 베지어 (tension 1/6)
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C${c1x.toFixed(2)},${c1y.toFixed(2)} ${c2x.toFixed(2)},${c2y.toFixed(2)} ${p2.x.toFixed(2)},${p2.y.toFixed(2)}`;
}
return d;
}
export function ValenceChart({ client, baseline, xLabels }: ValenceChartProps) {
const clientPath = smoothPath(client);
const baselinePath = smoothPath(baseline);
return (
<div className="sr-chart">
<div className="sr-chart__legend">
<span className="sr-legend-item">
<span className="sr-legend-line sr-legend-line--client" />
</span>
<span className="sr-legend-item">
<span className="sr-legend-line sr-legend-line--baseline" />
</span>
</div>
<div className="sr-chart__plot">
<div className="sr-chart__yaxis" aria-hidden="true">
<span>+1.0</span>
<span>0.0</span>
<span>-1.0</span>
</div>
<div className="sr-chart__canvas">
<svg
className="sr-chart__svg"
viewBox="0 0 100 100"
preserveAspectRatio="none"
role="img"
aria-label="회기 동안 내담자 정서가와 상담자 기준선의 변화 추이"
>
{/* 0선 (중립) */}
<line
x1="0"
y1="50"
x2="100"
y2="50"
stroke="var(--hair)"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
{/* 상담자 baseline (점선, 세이지) */}
<path
d={baselinePath}
fill="none"
stroke="var(--accent-bright)"
strokeWidth="2"
strokeDasharray="4 4"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
opacity="0.7"
/>
{/* 내담자 valence (실선, 테라코타) */}
<path
d={clientPath}
fill="none"
stroke="var(--clay)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
</div>
<div className="sr-chart__xaxis" aria-hidden="true">
{xLabels.map((label, i) => (
<span key={i}>{label}</span>
))}
</div>
</div>
</div>
);
}