INIT
This commit is contained in:
199
frontend/src/components/genome/genome-traits-table.tsx
Normal file
199
frontend/src/components/genome/genome-traits-table.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { apiClient } from "@/lib/api"
|
||||
|
||||
interface TraitData {
|
||||
trait: string
|
||||
regional: number
|
||||
myFarm: number
|
||||
}
|
||||
|
||||
interface GenomeTraitsTableProps {
|
||||
farmNo?: number | null
|
||||
}
|
||||
|
||||
export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) {
|
||||
const [traitData, setTraitData] = useState<TraitData[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTraitData = async () => {
|
||||
if (!farmNo) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const traits = [
|
||||
{ name: '12개월령체중', key: '12개월령체중' },
|
||||
{ name: '도체중', key: '도체중' },
|
||||
{ name: '근내지방도', key: '근내지방도' },
|
||||
{ name: '등심단면적', key: '등심단면적' },
|
||||
{ name: '등지방두께', key: '등지방두께' },
|
||||
]
|
||||
|
||||
const results: TraitData[] = []
|
||||
|
||||
for (const trait of traits) {
|
||||
try {
|
||||
const farmResponse = await apiClient.post('/cow/ranking', {
|
||||
filterOptions: { farmNo: farmNo },
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const globalResponse = await apiClient.post('/cow/ranking/global', {
|
||||
rankingOptions: {
|
||||
criteriaType: 'GENOME',
|
||||
traitConditions: [{ traitNm: trait.key, weight: 1.0 }]
|
||||
}
|
||||
})
|
||||
|
||||
const farmResult = farmResponse.data || farmResponse
|
||||
const globalResult = globalResponse.data || globalResponse
|
||||
|
||||
const farmScores = farmResult.items?.map((item: any) => item.sortValue) || []
|
||||
const farmAvg = farmScores.length > 0
|
||||
? farmScores.reduce((sum: number, score: number) => sum + score, 0) / farmScores.length
|
||||
: 0
|
||||
|
||||
const globalScores = globalResult.items?.map((item: any) => item.sortValue) || []
|
||||
const regionalAvg = globalScores.length > 0
|
||||
? globalScores.reduce((sum: number, score: number) => sum + score, 0) / globalScores.length
|
||||
: 0
|
||||
|
||||
results.push({
|
||||
trait: trait.name,
|
||||
myFarm: parseFloat(farmAvg.toFixed(2)),
|
||||
regional: parseFloat(regionalAvg.toFixed(2))
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`[형질 테이블] ${trait.name} 데이터 로드 실패:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
setTraitData(results)
|
||||
} catch (error) {
|
||||
console.error('[형질 테이블] 전체 데이터 로드 실패:', error)
|
||||
setTraitData([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchTraitData()
|
||||
}, [farmNo])
|
||||
|
||||
const getTraitShortName = (name: string) => {
|
||||
const shortNames: Record<string, string> = {
|
||||
'12개월령체중': '12개월령체중',
|
||||
'등심단면적': '등심단면적',
|
||||
'등지방두께': '등지방두께',
|
||||
'근내지방도': '근내지방도',
|
||||
'도체중': '도체중'
|
||||
}
|
||||
return shortNames[name] || name
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 p-4">
|
||||
<div className="flex items-center justify-center h-[180px]">
|
||||
<div className="w-6 h-6 border-2 border-slate-200 border-t-[#1482B0] rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#1482B0] to-[#0d5f82] flex items-center justify-center shadow-md shadow-[#1482B0]/20">
|
||||
<BarChart3 className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-slate-900">형질별 점수</h3>
|
||||
<p className="text-[10px] text-slate-500">보은군과 비교한 수치에요</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-lg text-[10px] font-semibold">5개 형질</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="p-5">
|
||||
{traitData.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-xs text-slate-400">
|
||||
데이터가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{traitData.map((item, idx) => {
|
||||
const diff = item.myFarm - item.regional
|
||||
const isPositive = diff >= 0
|
||||
// σ를 0~100 스케일로 변환 (-3σ~+3σ → 0~100)
|
||||
const toPercent = (sigma: number) => Math.min(100, Math.max(0, ((sigma + 3) / 6) * 100))
|
||||
|
||||
return (
|
||||
<div key={idx} className="space-y-1.5">
|
||||
{/* 형질명 + 차이 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-slate-800">
|
||||
{getTraitShortName(item.trait)}
|
||||
</span>
|
||||
<span className={`text-xs font-bold px-2.5 py-1 rounded-lg ${diff >= 0.3 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200 shadow-sm' :
|
||||
diff <= -0.3 ? 'bg-amber-50 text-amber-700 border border-amber-200 shadow-sm' :
|
||||
'bg-slate-50 text-slate-700 border border-slate-200'
|
||||
}`}>
|
||||
{diff > 0 ? '+' : ''}{diff.toFixed(1)}σ
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 비교 바 */}
|
||||
<div className="relative h-6 bg-slate-100 rounded-lg overflow-hidden shadow-inner">
|
||||
{/* 보은군 바 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 h-full bg-slate-300/80 rounded-lg transition-all duration-500"
|
||||
style={{ width: `${toPercent(item.regional)}%` }}
|
||||
/>
|
||||
{/* 내농장 바 */}
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full rounded-lg transition-all duration-500 shadow-sm ${isPositive
|
||||
? 'bg-gradient-to-r from-[#1482B0] via-[#1482B0] to-[#0d5f82]'
|
||||
: 'bg-gradient-to-r from-slate-400 to-slate-500'
|
||||
}`}
|
||||
style={{ width: `${toPercent(item.myFarm)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-[#1482B0] shadow-sm"></div>
|
||||
<span className="text-slate-600 font-medium">
|
||||
내농장 <span className="font-bold text-slate-900">{item.myFarm > 0 ? '+' : ''}{item.myFarm}σ</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-slate-300 shadow-sm"></div>
|
||||
<span className="text-slate-500 font-medium">
|
||||
보은군 {item.regional > 0 ? '+' : ''}{item.regional}σ
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user