vignette/apps/web/src/pages/session-review/ValenceChart.tsx
2026-06-26 14:47:00 +09:00

122 lines
4.2 KiB
TypeScript

/* =====================================================================
ValenceChart — 감정 밸런스 타임라인 (SVG 라인 차트).
외부 ref의 Emotional Valence Timeline 차용 → Vignette 토큰 리스킨.
· 내담자 valence = 실선(테라코타 --clay)
· 상담자 baseline = 점선(세이지 --accent-bright)
범례·시간축·0선 포함. preserveAspectRatio="none" 로 컨테이너 채움.
순흑/순백 금지 — stroke 는 토큰 currentColor 계열만.
===================================================================== */
import type { ReviewValencePoint as ValencePoint } from "../../lib/api";
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>
);
}