/** * Gauge — 반원형 게이지 + 4단계 고정 높이 영역 구조 * * ┌──────────────────────────────┐ ← 그래프 영역 (SVG) * │ 그래프 영역 │ * └──────────────────────────────┘ * ┌──────────────────────────────┐ ← 지표명 영역 * │ 지표명 영역 │ * └──────────────────────────────┘ * ┌──────────────────────────────┐ ← 결과표시 영역 * │ 결과표시 영역 │ * └──────────────────────────────┘ * ┌──────────────────────────────┐ ← 바이오타입 영역 (타이틀만/팝업) * │ 바이오타입 영역 │ * └──────────────────────────────┘ */ import { useState } from 'react'; import styled from 'styled-components'; /** 정서계열 지표의 2D 바이오타입 (S축×C축 사분면) — analysis.indicators[].biotype */ export interface IndicatorBiotype { quadrant: string; // Ⅰ ~ Ⅳ label: string; // 정상 / 마스크형 / 복합형 / 신경증적 message: string; // 피검자용 중립 해석문 S: number; // S축(설문) T-score C: number; // C축(인지) T-score } interface GaugeProps { t: number; // T-score 0~100 (높을수록 나쁨) band?: string; // 정상 / 경계 / 유의 label: string; // 지표명 biotype?: IndicatorBiotype | null; // 정서계열 사분면 정보 } const SV = 200; const R = 65; const CX = SV / 2; const CY = SV / 2 + 15; const STROKE = 10; /** 밴드 색상 (report.py STATUS_BADGE와 동일 — 정상/경계/유의 단측 60/70) */ const BAND_COLOR: Record = { 정상: '#27ae60', 경계: '#f1a33c', 유의: '#e74c3c', }; /** * T-score (0~100, 50=평균)를 시계방향 각도(도, Degree)로 변환 * 0 → -135° (좌측 하단), 50 → 0° (12시 방향), 100 → +135° (우측 하단) * 총 호의 길이: 270° (전 지표 "높을수록 나쁨" 방향 → 우측이 위험) */ function tToAngle(t: number): number { const clamped = Math.max(0, Math.min(100, t)); return (clamped / 100) * 270 - 135; } /** 12시 방향(0°)을 기준(시계 방향)으로 X, Y 좌표 계산 */ function polar(r: number, angleDeg: number) { const rad = ((angleDeg - 90) * Math.PI) / 180; return { x: CX + r * Math.cos(rad), y: CY + r * Math.sin(rad), }; } /** SVG 호(Arc) 경로 생성 — startAngle ~ endAngle (시계 방향) */ function arcPath(r: number, startAngle: number, endAngle: number): string { const start = polar(r, startAngle); const end = polar(r, endAngle); const angleDiff = Math.abs(endAngle - startAngle); const largeArcFlag = angleDiff > 180 ? 1 : 0; const sweepFlag = endAngle >= startAngle ? 1 : 0; return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArcFlag} ${sweepFlag} ${end.x} ${end.y}`; } // ─── 1단계: Cell — 고정 높이 수직 스택 (그래프/지표명/결과/바이오타입) ─── const Cell = styled.div` width: 100%; display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start; padding: 0; `; // 그래프 영역 — SVG만 렌더링 (고정 높이) const GraphArea = styled.div` width: 100%; height: 145px; /* SVG 높이 (viewBox 200x200 기준) */ display: flex; align-items: flex-start; justify-content: center; overflow: hidden; `; // 지표명 영역 — 고정 높이 const NameArea = styled.div` height: 24px; display: flex; align-items: center; justify-content: center; `; // 결과표시 영역 — 고정 높이 (지표명 + 밴드) const ResultArea = styled.div` height: 30px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; `; // 바이오타입 영역 — 고정 높이 (타이틀만 또는 비어있음) const BioArea = styled.div` height: 26px; display: flex; align-items: center; justify-content: center; `; // ─── 공통 텍스트 스타일 ─── const Name = styled.div` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 15.6px; font-weight: 600; color: #333333; text-align: center; `; const BandBadge = styled.span<{ color: string }>` display: inline-block; padding: 2px 10px; border-radius: 10px; font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 12px; font-weight: 700; color: #fff; background: ${(p) => p.color}; `; // ─── 바이오타입 타이틀 (클릭 시 팝업) ─── const BioTitleBadge = styled.div` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 12px; font-weight: 700; color: #555; cursor: pointer; padding: 4px 12px; border-radius: 10px; background: #f0f4ff; border: 1px solid #dde4ff; transition: background 0.15s; &:hover { background: #e0e8ff; } `; // ─── 팝업 ─── const PopupOverlay = styled.div` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; `; const PopupBox = styled.div` background: var(--md3-surface, #fff); border-radius: 16px; padding: 24px; max-width: 340px; width: 100%; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); position: relative; `; const PopupClose = styled.button` position: absolute; top: 12px; right: 12px; width: 32px; height: 32px; border-radius: 50%; border: none; background: var(--md3-surface-variant, #e8ecf0); color: var(--md3-on-surface-variant, #555); font-size: 18px; font-weight: 600; cursor: pointer; display: flex; align-items: center; justify-content: center; line-height: 1; &:hover { background: var(--md3-outline-variant, #ccd0d5); } `; const PopupTitle = styled.h3` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 18px; font-weight: 700; color: var(--md3-on-surface, #333); margin: 0 0 12px; padding-right: 32px; `; const PopupContent = styled.p` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 14px; color: var(--md3-on-surface-variant, #555); margin: 0; line-height: 1.6; `; export default function Gauge({ t, band = '정상', label, biotype }: GaugeProps) { const [bioPopup, setBioPopup] = useState(false); const currentAngle = tToAngle(t); // 각 구간별 시작/끝 각도 (-135° ~ +135°) — 단측 밴드 경계 60 / 70 const startAngle = -135; const endAngle = 135; const band60 = tToAngle(60); // 27° const band70 = tToAngle(70); // 54° // 현재 값의 밴드 색상 (지표점수 텍스트 + 밴드 뱃지에 적용) const valueColor = BAND_COLOR[band] ?? '#27ae60'; // 바늘 시작 및 끝 위치 const needleEnd = polar(R - 8, currentAngle); const needleStart = polar(8, currentAngle); return ( {/* ── 1단계: 그래프 영역 ── */} {/* 회색 배경 트랙 (전체 270도 호) */} {/* 3색 구간 트랙 — 정상<60 / 경계60~70 / 유의>70 (전 지표 높을수록 나쁨) */} {/* 눈금선 및 숫자 라벨 (T 10/30/50/70/90) */} {[10, 30, 50, 70, 90].map((tv) => { const a = tToAngle(tv); const outer = polar(R + STROKE / 2 + 3, a); const inner = polar(R - STROKE / 2 - 1, a); const labelPos = polar(R + STROKE / 2 + 14, a); return ( {tv} ); })} {/* 게이지 바늘 */} {/* 중앙 하단부 T-score (바늘과 겹치지 않도록 중앙점 하단으로 이동) */} {t.toFixed(1)} {/* ── 2단계: 지표명 영역 ── */} {label} {/* ── 3단계: 결과표시 영역 ── */} {band} {/* ── 4단계: 바이오타입 영역 ── */} {biotype ? ( setBioPopup(true)}> {biotype.quadrant}. {biotype.label} ) : ( )} {/* ── 팝업 ── */} {bioPopup && biotype && ( setBioPopup(false)}> e.stopPropagation()}> setBioPopup(false)}>× {biotype.quadrant}. {biotype.label} {biotype.message} )} ); }