INIT
This commit is contained in:
4
frontend/src/components/charts/index.ts
Normal file
4
frontend/src/components/charts/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// ============================================
|
||||
// 차트 컴포넌트
|
||||
// ============================================
|
||||
export { MptGaugeBar } from './mpt-gauge-bar';
|
||||
261
frontend/src/components/charts/mpt-gauge-bar.tsx
Normal file
261
frontend/src/components/charts/mpt-gauge-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user