122 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|