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턴 — 좋은/나쁜 상담에 차등 반응 실증
122 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|