This commit is contained in:
2025-12-09 17:02:27 +09:00
parent 26f8e1dab2
commit 83127da569
275 changed files with 139682 additions and 1 deletions

View File

@@ -0,0 +1,4 @@
// ============================================
// 차트 컴포넌트
// ============================================
export { MptGaugeBar } from './mpt-gauge-bar';

View File

@@ -0,0 +1,261 @@
'use client'
/**
* MPT 혈액대사검사 게이지/바 차트
* 정상 범위 대비 현재 값의 위치를 시각적으로 표시
*/
interface MptGaugeBarProps {
name: string
value: number
unit: string
lowerLimit: number | null
upperLimit: number | null
category: string
regionAverage?: number // 보은군 평균 (선택)
}
export function MptGaugeBar({
name,
value,
unit,
lowerLimit,
upperLimit,
category,
regionAverage
}: MptGaugeBarProps) {
// 상태 계산
const getStatus = (): 'low' | 'normal' | 'high' => {
if (upperLimit !== null && value > upperLimit) return 'high'
if (lowerLimit !== null && value < lowerLimit) return 'low'
return 'normal'
}
const status = getStatus()
// 범위와 값 기반 시각화 계산
const calculateVisualization = () => {
if (lowerLimit === null || upperLimit === null) {
// 범위 정보 없음 - 단순 값 표시
return { position: 50, hasRange: false }
}
// 모든 값 수집 (현재값, 보은군평균, 정상범위)
const allValues = [value, lowerLimit, upperLimit]
if (regionAverage !== undefined) {
allValues.push(regionAverage)
}
const dataMin = Math.min(...allValues)
const dataMax = Math.max(...allValues)
const dataRange = dataMax - dataMin
// 데이터 범위의 20% 패딩 추가
const padding = dataRange * 0.2
const chartMin = dataMin - padding
const chartMax = dataMax + padding
const chartRange = chartMax - chartMin
// 값의 위치 계산 (0-100%)
let position = ((value - chartMin) / chartRange) * 100
position = Math.max(0, Math.min(100, position)) // 0-100% 범위로 제한
// 정상범위 시작/끝 위치
const normalStart = ((lowerLimit - chartMin) / chartRange) * 100
const normalEnd = ((upperLimit - chartMin) / chartRange) * 100
return {
position,
normalStart,
normalEnd,
hasRange: true,
chartMin,
chartMax,
chartRange,
}
}
const viz = calculateVisualization()
// 카테고리별 색상 (제거 - 사용 안 함)
// 상태별 색상 (부드러운 톤)
const getStatusColor = () => {
switch (status) {
case 'high': return {
bg: 'bg-gradient-to-r from-red-400 to-red-500',
text: 'text-red-700',
badgeBg: 'bg-red-50',
badgeBorder: 'border-red-200',
barBg: 'linear-gradient(to right, #f87171, #ef4444)' // red-400 to red-500
}
case 'low': return {
bg: 'bg-gradient-to-r from-blue-400 to-blue-500',
text: 'text-blue-700',
badgeBg: 'bg-blue-50',
badgeBorder: 'border-blue-200',
barBg: 'linear-gradient(to right, #60a5fa, #3b82f6)' // blue-400 to blue-500
}
case 'normal': return {
bg: 'bg-gradient-to-r from-green-400 to-green-500',
text: 'text-green-700',
badgeBg: 'bg-green-50',
badgeBorder: 'border-green-200',
barBg: 'linear-gradient(to right, #4ade80, #22c55e)' // green-400 to green-500
}
}
}
const getStatusText = () => {
switch (status) {
case 'high': return '높음 ↑'
case 'low': return '낮음 ↓'
case 'normal': return '정상'
}
}
const statusColors = getStatusColor()
return (
<div className="p-3 md:p-3.5 rounded-xl border-2 border-slate-200 bg-white shadow-sm hover:shadow-md transition-shadow">
{/* 항목명과 현재값 */}
<div className="flex items-center justify-between mb-2 md:mb-2.5">
<span className="text-xs md:text-sm font-bold text-slate-800">{name}</span>
<div className="flex items-baseline gap-1">
<span className={`text-lg md:text-xl font-bold ${statusColors.text}`}>
{value.toFixed(1)}
</span>
<span className="text-xs md:text-sm font-semibold text-slate-600">{unit}</span>
</div>
</div>
{/* 게이지 바 */}
{viz.hasRange ? (
<div className="mb-2 md:mb-2.5">
{/* 현재값 및 보은군 평균 표시 (바 위) */}
<div className="relative h-10 md:h-11 mb-1">
{/* 보은군 평균 표시 */}
{regionAverage !== undefined && viz.hasRange && (() => {
const regionPosition = ((regionAverage - viz.chartMin!) / viz.chartRange!) * 100;
return (
<div
className="absolute -translate-x-1/2 transition-all duration-500"
style={{ left: `${Math.max(0, Math.min(100, regionPosition))}%`, top: '0px' }}
>
<div className="px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-semibold whitespace-nowrap bg-slate-100 text-slate-700 border border-slate-300 shadow-sm">
: {regionAverage.toFixed(1)}
</div>
{/* 화살표 */}
<div
className="w-0 h-0 mx-auto"
style={{
borderLeft: '4px solid transparent',
borderRight: '4px solid transparent',
borderTop: '4px solid #94a3b8',
}}
/>
</div>
);
})()}
{/* 현재값 표시 */}
<div
className="absolute -translate-x-1/2 transition-all duration-500"
style={{ left: `${viz.position}%`, top: regionAverage !== undefined ? '24px' : '0px' }}
>
<div className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-bold whitespace-nowrap shadow-sm border ${
status === 'high' ? 'bg-red-500 text-white border-red-600' :
status === 'low' ? 'bg-blue-500 text-white border-blue-600' :
'bg-green-500 text-white border-green-600'
}`}>
{value.toFixed(1)}
{regionAverage !== undefined && Math.abs(value - regionAverage) > 0.1 && (
<span className="ml-0.5">
({value > regionAverage ? '+' : ''}{(value - regionAverage).toFixed(1)})
</span>
)}
</div>
{/* 화살표 */}
<div
className="w-0 h-0 mx-auto"
style={{
borderLeft: '5px solid transparent',
borderRight: '5px solid transparent',
borderTop: `5px solid ${
status === 'high' ? '#ef4444' :
status === 'low' ? '#3b82f6' : '#22c55e'
}`,
}}
/>
</div>
</div>
<div className="relative h-3 md:h-4 bg-slate-100 rounded-full overflow-hidden border-2 border-slate-200 shadow-inner">
{/* 정상 범위 영역 */}
<div
className="absolute h-full bg-green-100/60"
style={{
left: `${viz.normalStart}%`,
width: `${viz.normalEnd! - viz.normalStart!}%`,
}}
/>
{/* 보은군 평균 인디케이터 (회색 실선) */}
{regionAverage !== undefined && viz.hasRange && (() => {
const regionPosition = ((regionAverage - viz.chartMin!) / viz.chartRange!) * 100;
return (
<div
className="absolute top-0 h-full w-[3px] transition-all duration-500 z-[9]"
style={{
left: `${Math.max(0, Math.min(100, regionPosition))}%`,
background: '#94a3b8',
}}
/>
);
})()}
{/* 현재 값 인디케이터 */}
<div
className="absolute top-0 h-full w-1.5 md:w-2 transition-all duration-500 shadow-md z-10 rounded-full"
style={{
left: `${viz.position}%`,
background: statusColors.barBg,
}}
/>
</div>
{/* 정상 범위 수치 표기 (바 양옆) */}
<div className="flex items-center justify-between mt-1.5 md:mt-2 px-0.5">
<div className="flex flex-col items-start">
<span className="text-[10px] md:text-xs text-slate-500 font-semibold"></span>
<span className="text-xs md:text-sm font-bold text-slate-700">{lowerLimit}</span>
</div>
<div className="flex items-center gap-1">
<span className={`px-1.5 md:px-2 py-0.5 md:py-1 rounded text-[10px] md:text-xs font-bold border-2 ${statusColors.badgeBg} ${statusColors.text} ${statusColors.badgeBorder}`}>
{getStatusText()}
</span>
</div>
<div className="flex flex-col items-end">
<span className="text-[10px] md:text-xs text-slate-500 font-semibold"></span>
<span className="text-xs md:text-sm font-bold text-slate-700">{upperLimit}</span>
</div>
</div>
</div>
) : (
<div className="mb-2">
<div className="h-3 md:h-4 bg-slate-100 rounded-full overflow-hidden border-2 border-slate-200 shadow-inner">
<div
className={`h-full rounded-full transition-all duration-500`}
style={{ width: '100%', background: statusColors.barBg }}
/>
</div>
<div className="mt-2 text-center">
<span className={`px-2 md:px-2.5 py-1 md:py-1.5 rounded text-[10px] md:text-xs font-bold border-2 ${statusColors.badgeBg} ${statusColors.text} ${statusColors.badgeBorder}`}>
{getStatusText()}
</span>
</div>
</div>
)}
</div>
)
}