diff --git a/frontend/src/app/cow/[cowNo]/_components/header.tsx b/frontend/src/app/cow/[cowNo]/_components/header.tsx deleted file mode 100644 index eab3b30..0000000 --- a/frontend/src/app/cow/[cowNo]/_components/header.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client' - -import { Button } from "@/components/ui/button" -import { ArrowLeft } from "lucide-react" -import { useRouter } from "next/navigation" - -interface CowHeaderProps { - from?: string | null -} - -export function CowHeader({ from }: CowHeaderProps) { - const router = useRouter() - - const handleBack = () => { - if (from === 'ranking') { - router.push('/ranking') - } else if (from === 'list') { - router.push('/list') - } else { - router.push('/cow') - } - } - - return ( -
- {/* 뒤로가기 버튼 */} - - - {/* 페이지 헤더 카드 */} -
-

개체 상세 정보

-

개체의 기본 정보와 분석 현황을 확인할 수 있습니다.

-
-
- ) -} diff --git a/frontend/src/app/cow/[cowNo]/genome/_components/normal-distribution-chart.tsx b/frontend/src/app/cow/[cowNo]/genome/_components/normal-distribution-chart.tsx index d8d83f0..a7e845d 100644 --- a/frontend/src/app/cow/[cowNo]/genome/_components/normal-distribution-chart.tsx +++ b/frontend/src/app/cow/[cowNo]/genome/_components/normal-distribution-chart.tsx @@ -17,7 +17,7 @@ import { YAxis } from 'recharts' -// 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상) +// 형질 카테고리별 색상 매핑 const CATEGORY_COLORS: Record = { '성장': '#3b82f6', // 블루 '생산': '#f59e0b', // 앰버 @@ -26,71 +26,25 @@ const CATEGORY_COLORS: Record = { '비율': '#ec4899' // 핑크 } -// 형질 비교용 색상 배열 -const TRAIT_COLORS = [ - '#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e', - '#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6', - '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', - '#f43f5e', '#fb923c', '#fbbf24', '#a3e635', '#4ade80', - '#2dd4bf', '#22d3ee', '#38bdf8', '#60a5fa', '#818cf8', - '#a78bfa', '#c084fc', '#e879f9', '#f472b6', '#fb7185', - '#fdba74', '#fcd34d', '#bef264', '#86efac', '#5eead4' -] -// 정규분포 CDF (누적분포함수) - σ값을 백분위로 변환 -// 표준정규분포에서 z값 이하의 확률을 반환 (0~1) -function normalCDF(z: number): number { - // Abramowitz and Stegun 근사법 (오차 < 7.5×10^-8) - const a1 = 0.254829592 - const a2 = -0.284496736 - const a3 = 1.421413741 - const a4 = -1.453152027 - const a5 = 1.061405429 - const p = 0.3275911 - - const sign = z < 0 ? -1 : 1 - z = Math.abs(z) - - const t = 1.0 / (1.0 + p * z) - const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-z * z / 2) - - return 0.5 * (1.0 + sign * y) -} - -// σ값을 상위 백분위(%)로 변환 (예: +1σ → 상위 15.87%) -function sigmaToPercentile(sigma: number): number { - // CDF는 "이하" 확률이므로, 상위 %는 (1 - CDF) * 100 - const percentile = (1 - normalCDF(sigma)) * 100 - return Math.max(1, Math.min(99, percentile)) -} - -// σ 값을 등급으로 변환 -function getGradeFromSigma(sigmaValue: number): { grade: string; color: string; bg: string } { - if (sigmaValue >= 1) { - return { grade: '우수', color: 'text-green-600', bg: 'bg-green-50' } - } else if (sigmaValue >= -1) { - return { grade: '보통', color: 'text-gray-600', bg: 'bg-gray-100' } - } else { - return { grade: '개선필요', color: 'text-orange-600', bg: 'bg-orange-50' } - } -} +/** 유전체 형질 데이터 타입 */ interface GenomicTrait { id?: number - traitName?: string - traitCategory?: string - breedVal?: number - percentile?: number - traitVal?: number + traitName?: string // 형질명 (예: 도체중, 등지방두께) + traitCategory?: string // 형질 카테고리 (성장/생산/체형/무게/비율) + breedVal?: number // 육종가 값 + percentile?: number // 백분위 순위 + traitVal?: number // 형질 값 (EPD) } -// 형질별 비교 데이터 타입 +/** 형질별 농가/보은군 비교 데이터 */ interface TraitComparison { - trait: string - shortName: string - myFarm: number // 농가 평균 - region: number // 보은군 평균 - diff: number // 차이 + trait: string // 형질명 + shortName: string // 짧은 형질명 (차트 표시용) + myFarm: number // 농가 평균 값 + region: number // 보은군 평균 값 + diff: number // 농가와 보은군 간 차이 } interface NormalDistributionChartProps { diff --git a/frontend/src/components/genome/CowCompareModal.tsx b/frontend/src/components/genome/CowCompareModal.tsx deleted file mode 100644 index 89be351..0000000 --- a/frontend/src/components/genome/CowCompareModal.tsx +++ /dev/null @@ -1,235 +0,0 @@ -'use client' - -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { User, CheckCircle2, BarChart3 } from "lucide-react" -import { CowDetail } from "@/types/cow.types" -import { GenomeTrait as GenomeTraitType } from "@/types/genome.types" -import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend, Tooltip as RechartsTooltip } from 'recharts' - -interface CowCompareModalProps { - isOpen: boolean - onClose: () => void - compareCowsData: { cow: CowDetail; genome: GenomeTraitType[] }[] - transformGenomeData: (genomeData: GenomeTraitType[]) => any[] - CATEGORIES: string[] - TRAIT_COLORS: string[] -} - -export function CowCompareModal({ - isOpen, - onClose, - compareCowsData, - transformGenomeData, - CATEGORIES, - TRAIT_COLORS -}: CowCompareModalProps) { - if (!isOpen || compareCowsData.length === 0) return null - - return ( -
-
- {/* 헤더 */} -
-
-

- - 개체 유전체 비교 -

-

- {compareCowsData.length}개 개체 비교 -

-
- -
- - {/* 비교 내용 */} -
- {/* 개체 카드들 */} -
- {compareCowsData.map((cowData, idx) => { - const genomeTraits = transformGenomeData(cowData.genome) - const avgBreedVal = genomeTraits.length > 0 - ? genomeTraits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / genomeTraits.length - : 0 - const avgPercentile = genomeTraits.length > 0 - ? genomeTraits.reduce((sum: number, t: any) => sum + t.percentile, 0) / genomeTraits.length - : 0 - - return ( - - -
-
- {cowData.cow.cowId || cowData.cow.pkCowNo} - {cowData.cow.cowId && ( - {cowData.cow.cowId} - )} -
- {idx === 0 && ( - 현재 개체 - )} -
-
- -
-
-
종합 육종가
-
- {avgBreedVal > 0 ? '+' : ''}{avgBreedVal.toFixed(2)}σ -
-
- 상위 {(100 - avgPercentile).toFixed(1)}% -
-
- -
-
- 생년월일 - - {cowData.cow.cowBirthDt - ? new Date(cowData.cow.cowBirthDt).toLocaleDateString('ko-KR') - : 'N/A'} - -
-
- 나이 - - {cowData.cow.age ? `${cowData.cow.age}세` : 'N/A'} - -
-
- 평가 형질 수 - {genomeTraits.length}개 -
-
-
-
-
- ) - })} -
- - {/* 카테고리별 비교 차트 */} - - - 카테고리별 육종가 비교 - 각 개체의 카테고리별 평균 표준화육종가 - - -
- - - - - - - - {compareCowsData.map((cowData, idx) => { - const genomeTraits = transformGenomeData(cowData.genome) - const categoryData = CATEGORIES.map(cat => { - const traits = genomeTraits.filter((t: any) => t.category === cat) - const avgBreedVal = traits.length > 0 - ? traits.reduce((sum: number, t: any) => sum + t.breedVal, 0) / traits.length - : 0 - return { - category: cat, - value: avgBreedVal - } - }) - - const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo) - return ( - - ) - })} - - -
-
-
- - {/* 형질별 비교 테이블 */} - - - 주요 형질 비교 (Top 10) - - -
- - - - - {compareCowsData.map((cowData, idx) => { - const cowLabel = cowData.cow.cowId || String(cowData.cow.pkCowNo) - return ( - - ) - })} - - - - {transformGenomeData(compareCowsData[0].genome).slice(0, 10).map((trait: any) => ( - - - {compareCowsData.map((cowData) => { - const genomeTraits = transformGenomeData(cowData.genome) - const matchTrait = genomeTraits.find((t: any) => t.name === trait.name) - return ( - - ) - })} - - ))} - -
형질명 - {idx === 0 ? `${cowLabel}\n(현재)` : cowLabel} -
- {trait.name} - - {matchTrait ? ( -
-
0 ? 'text-primary' : 'text-muted-foreground'}`}> - {matchTrait.breedVal > 0 ? '+' : ''}{matchTrait.breedVal.toFixed(2)}σ -
-
- {matchTrait.percentile.toFixed(1)}% -
-
- ) : ( - N/A - )} -
-
-
-
-
- - {/* 푸터 */} -
- -
-
-
- ) -} diff --git a/frontend/src/components/genome/CowSelectSheet.tsx b/frontend/src/components/genome/CowSelectSheet.tsx deleted file mode 100644 index b4d4c77..0000000 --- a/frontend/src/components/genome/CowSelectSheet.tsx +++ /dev/null @@ -1,159 +0,0 @@ -'use client' - -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { User, CheckCircle2 } from "lucide-react" -import { CowDetail } from "@/types/cow.types" - -interface CowSelectSheetProps { - isOpen: boolean - onClose: () => void - farmCows: CowDetail[] - selectedCowsForCompare: number[] - toggleCowForCompare: (cowNo: number) => void - onCompare: () => void - onClearSelection: () => void -} - -export function CowSelectSheet({ - isOpen, - onClose, - farmCows, - selectedCowsForCompare, - toggleCowForCompare, - onCompare, - onClearSelection -}: CowSelectSheetProps) { - if (!isOpen) return null - - return ( -
-
- {/* 헤더 */} -
-
-

- - 농장 내 개체 비교 -

-

- 비교할 개체를 선택하세요 ({selectedCowsForCompare.length}/{farmCows.length}) -

-
- -
- - {/* 개체 목록 */} -
- {farmCows.length === 0 ? ( -
- -

같은 농장에 다른 개체가 없습니다

-
- ) : ( -
- {farmCows.map((farmCow) => { - const isSelected = selectedCowsForCompare.includes(farmCow.pkCowNo) - return ( -
toggleCowForCompare(farmCow.pkCowNo)} - className={`p-4 rounded-lg border-2 cursor-pointer transition-all ${ - isSelected - ? 'border-primary bg-primary/5' - : 'border-border hover:border-primary/50 hover:bg-muted/50' - }`} - > -
- {/* 체크박스 */} -
-
- {isSelected && ( - - )} -
-
- - {/* 개체 정보 */} -
-
-
-

{farmCow.cowId || farmCow.pkCowNo}

- {farmCow.cowId && ( -

{farmCow.cowId}

- )} -
-
- - {/* 상세 정보 */} -
-
-
생년월일
-
- {farmCow.cowBirthDt - ? new Date(farmCow.cowBirthDt).toLocaleDateString('ko-KR', { - year: '2-digit', - month: '2-digit', - day: '2-digit' - }) - : 'N/A'} -
-
-
-
나이
-
- {farmCow.age ? `${farmCow.age}세` : 'N/A'} -
-
-
-
성별
-
- {farmCow.cowSex === 'F' ? '암' : farmCow.cowSex === 'M' ? '수' : 'N/A'} -
-
-
-
-
-
- ) - })} -
- )} -
- - {/* 푸터 */} -
-
- {selectedCowsForCompare.length}개 개체 선택됨 -
-
- - -
-
-
-
- ) -} diff --git a/frontend/src/components/genome/gene-filter-modal.tsx b/frontend/src/components/genome/gene-filter-modal.tsx deleted file mode 100644 index d7460c5..0000000 --- a/frontend/src/components/genome/gene-filter-modal.tsx +++ /dev/null @@ -1,301 +0,0 @@ -'use client' - -import { useState, useMemo, useEffect } from "react" -import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { Checkbox } from "@/components/ui/checkbox" -import { Label } from "@/components/ui/label" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Search, Loader2 } from "lucide-react" -import { geneApi } from "@/lib/api/gene.api" - -/** - * 마커 데이터 타입 (API에서 받아오는 형식) - */ -interface MarkerData { - pkMarkerNo: number - markerNm: string - markerDesc: string - markerTypeCd: string - relatedTrait: string - favorableAllele: string - useYn: string - markerTypeInfo?: { - pkTypeCd: string - typeNm: string - typeDesc: string - } -} - -/** - * 유전자 필터에서 사용할 간소화된 타입 - */ -interface GeneOption { - name: string - description: string - type: 'QTY' | 'QLT' - relatedTrait: string -} - -interface GeneFilterModalProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedGenes: string[] - onConfirm: (genes: string[]) => void -} - -export function GeneFilterModal({ open, onOpenChange, selectedGenes, onConfirm }: GeneFilterModalProps) { - const [tempSelectedGenes, setTempSelectedGenes] = useState(selectedGenes) - const [searchQuery, setSearchQuery] = useState('') - const [sortBy, setSortBy] = useState<'name' | 'type'>('name') - const [allMarkers, setAllMarkers] = useState([]) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - - // API에서 마커 목록 가져오기 - useEffect(() => { - if (open) { - fetchMarkers() - } - }, [open]) - - // TODO: 백엔드 /gene/markers API 구현 후 활성화 - const fetchMarkers = async () => { - // try { - // setLoading(true) - // setError(null) - // const markers = await geneApi.getAllMarkers() as unknown as MarkerData[] - - // // API 데이터를 GeneOption 형식으로 변환 - // const geneOptions: GeneOption[] = markers.map(marker => ({ - // name: marker.markerNm, - // description: marker.relatedTrait || marker.markerDesc || '', - // type: marker.markerTypeCd as 'QTY' | 'QLT', - // relatedTrait: marker.relatedTrait || '' - // })) - - // setAllMarkers(geneOptions) - // } catch (err) { - // console.error('Failed to fetch markers:', err) - // setError('유전자 목록을 불러오는데 실패했습니다.') - // } finally { - // setLoading(false) - // } - } - - // 육량형/육질형 필터링 - const quantityGenes = useMemo(() => { - return allMarkers.filter(g => g.type === 'QTY').sort((a, b) => a.name.localeCompare(b.name)) - }, [allMarkers]) - - const qualityGenes = useMemo(() => { - return allMarkers.filter(g => g.type === 'QLT').sort((a, b) => a.name.localeCompare(b.name)) - }, [allMarkers]) - - // 전체 유전자 목록 (정렬) - const allGenes = useMemo(() => { - return [...allMarkers].sort((a, b) => { - if (sortBy === 'type') { - if (a.type !== b.type) { - return a.type.localeCompare(b.type) - } - } - return a.name.localeCompare(b.name) - }) - }, [allMarkers, sortBy]) - - const filteredGenes = useMemo(() => { - if (!searchQuery) return allGenes - return allGenes.filter(gene => - gene.name.toLowerCase().includes(searchQuery.toLowerCase()) || - gene.description.toLowerCase().includes(searchQuery.toLowerCase()) - ) - }, [allGenes, searchQuery]) - - const toggleGene = (geneName: string) => { - setTempSelectedGenes(prev => - prev.includes(geneName) - ? prev.filter(g => g !== geneName) - : [...prev, geneName] - ) - } - - const handleConfirm = () => { - onConfirm(tempSelectedGenes) - onOpenChange(false) - } - - const handleCancel = () => { - setTempSelectedGenes(selectedGenes) - onOpenChange(false) - } - - return ( - - - - 유전자 선택 - - 개체를 필터링할 유전자를 선택하세요. 시스템이 자동으로 중요도 순으로 정렬합니다. - - - - {loading ? ( -
- - 유전자 목록 불러오는 중... -
- ) : error ? ( -
-

{error}

- -
- ) : ( - - - 타입별 선택 ({allMarkers.length}개) - 전체 유전자 검색 - - - - - - 육량형 ({quantityGenes.length}개) - 육질형 ({qualityGenes.length}개) - - - - -
- {quantityGenes.length === 0 ? ( -

- 육량형 유전자가 없습니다. -

- ) : ( - quantityGenes.map((gene) => ( -
- toggleGene(gene.name)} - /> -
- -

{gene.description}

-
-
- )) - )} -
-
-
- - - -
- {qualityGenes.length === 0 ? ( -

- 육질형 유전자가 없습니다. -

- ) : ( - qualityGenes.map((gene) => ( -
- toggleGene(gene.name)} - /> -
- -

{gene.description}

-
-
- )) - )} -
-
-
-
-
- - -
-
- - setSearchQuery(e.target.value)} - /> -
- -
- - -
- {filteredGenes.length === 0 ? ( -

- 검색 결과가 없습니다. -

- ) : ( - filteredGenes.map((gene) => ( -
- toggleGene(gene.name)} - /> -
- -

{gene.description}

-
-
- )) - )} -
-
-
-
- )} - -
- 선택된 유전자: {tempSelectedGenes.length}개 -
- - - - - -
-
- ) -} diff --git a/frontend/src/components/genome/gene-search-modal.tsx b/frontend/src/components/genome/gene-search-modal.tsx deleted file mode 100644 index 206d052..0000000 --- a/frontend/src/components/genome/gene-search-modal.tsx +++ /dev/null @@ -1,246 +0,0 @@ -'use client' - -import { useState, useEffect } from "react" -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog" -import { Input } from "@/components/ui/input" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Search, X, Filter, Sparkles } from "lucide-react" -import { geneApi } from "@/lib/api/gene.api" -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs" - -interface GeneSearchDrawerProps { - open: boolean - onOpenChange: (open: boolean) => void - selectedGenes: string[] - onGenesChange: (genes: string[]) => void -} - -export function GeneSearchModal({ open, onOpenChange, selectedGenes, onGenesChange }: GeneSearchDrawerProps) { - const [allGenes, setAllGenes] = useState([]) - const [loading, setLoading] = useState(false) - const [searchQuery, setSearchQuery] = useState("") - const [filterType, setFilterType] = useState<'ALL' | 'QTY' | 'QLT'>('ALL') - - // 모달 열릴 때 전체 유전자 로드 - useEffect(() => { - if (open) { - loadAllGenes() - } - }, [open]) - - // TODO: 백엔드 /gene/markers API 구현 후 활성화 - const loadAllGenes = async () => { - // try { - // setLoading(true) - // const genes = await geneApi.getAllMarkers() - // setAllGenes(genes) - // } catch { - // // 유전자 로드 실패 시 빈 배열 유지 - // } finally { - // setLoading(false) - // } - } - - // 검색 및 필터링 - const filteredGenes = allGenes.filter((gene) => { - // 타입 필터 - if (filterType !== 'ALL' && gene.markerTypeCd !== filterType) { - return false - } - - // 검색어 필터 - if (searchQuery) { - const query = searchQuery.toLowerCase() - return ( - gene.markerNm.toLowerCase().includes(query) || - gene.markerDesc?.toLowerCase().includes(query) || - gene.relatedTrait?.toLowerCase().includes(query) - ) - } - - return true - }) - - const toggleGene = (markerNm: string) => { - if (selectedGenes.includes(markerNm)) { - onGenesChange(selectedGenes.filter(g => g !== markerNm)) - } else { - onGenesChange([...selectedGenes, markerNm]) - } - } - - const selectAllFiltered = () => { - const newGenes = [...selectedGenes] - filteredGenes.forEach(gene => { - if (!newGenes.includes(gene.markerNm)) { - newGenes.push(gene.markerNm) - } - }) - onGenesChange(newGenes) - } - - const clearAll = () => { - onGenesChange([]) - } - - return ( - - - {/* 헤더 */} - -
-
- -
-
- 유전자 검색 및 선택 - - 전체 {allGenes.length.toLocaleString()}개 / 선택 {selectedGenes.length}개 - -
-
-
- - {/* 검색 및 필터 */} -
- {/* 검색바 */} -
- - setSearchQuery(e.target.value)} - className="pl-9 pr-9 h-10 text-sm bg-background" - autoFocus - /> - {searchQuery && ( - - )} -
- - {/* 필터 탭 및 액션 버튼 */} -
- setFilterType(v as any)} className="flex-1"> - - - 전체 ({allGenes.length}) - - -
-
- 육량 ({allGenes.filter(g => g.markerTypeCd === 'QTY').length}) -
-
- -
-
- 육질 ({allGenes.filter(g => g.markerTypeCd === 'QLT').length}) -
-
-
-
- -
- - -
-
-
- - {/* 유전자 목록 */} -
- {loading ? ( -
-
-
-

유전자 데이터 로딩 중...

-
-
- ) : filteredGenes.length > 0 ? ( - -
- {filteredGenes.map((gene) => { - const isSelected = selectedGenes.includes(gene.markerNm) - const isQuantity = gene.markerTypeCd === 'QTY' - - return ( - toggleGene(gene.markerNm)} - title={`${gene.markerNm}\n${gene.markerDesc || ''}\n${gene.relatedTrait ? `관련 형질: ${gene.relatedTrait}` : ''}`} - > - {gene.markerNm} - - ) - })} -
-
- ) : ( -
-
- -

검색 결과가 없습니다

-

다른 검색어나 필터를 시도해보세요

-
-
- )} -
- - {/* 하단 버튼 */} -
-
- {searchQuery && ( - - 검색: {filteredGenes.length.toLocaleString()}개 - - )} - - 선택: {selectedGenes.length}개 - -
-
- - -
-
-
-
- ) -} diff --git a/frontend/src/components/genome/genome-distribution-donut.tsx b/frontend/src/components/genome/genome-distribution-donut.tsx deleted file mode 100644 index bd8fe4b..0000000 --- a/frontend/src/components/genome/genome-distribution-donut.tsx +++ /dev/null @@ -1,181 +0,0 @@ -'use client' - -import { PieChart as PieChartIcon } from "lucide-react" -import { useEffect, useState } from "react" -import apiClient from "@/lib/api-client" -import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip } from 'recharts' - -interface DistributionData { - name: string - value: number - color: string - range: string - description: string -} - -interface GenomeDistributionDonutProps { - farmNo: number | null -} - -export function GenomeDistributionDonut({ farmNo }: GenomeDistributionDonutProps) { - const [data, setData] = useState([]) - const [totalCount, setTotalCount] = useState(0) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const fetchData = async () => { - if (!farmNo) { - setLoading(false) - return - } - - try { - const response = await apiClient.post('/cow/ranking', { - filterOptions: { farmNo }, - rankingOptions: { - criteriaType: 'GENOME', - traitConditions: [ - { traitNm: '도체중', weight: 0.25 }, - { traitNm: '근내지방도', weight: 0.25 }, - { traitNm: '등심단면적', weight: 0.25 }, - { traitNm: '등지방두께', weight: 0.25 }, - ] - } - }) - - const result = response.data || response - const items = result.items || [] - setTotalCount(items.length) - - const distribution = { - top: 0, // 0σ 이상 - middle: 0, // -1.0σ ~ 0σ - bottom: 0 // -1.0σ 이하 - } - - items.forEach((item: any) => { - const score = item.sortValue || 0 - if (score >= 0) distribution.top++ - else if (score >= -1.0) distribution.middle++ - else distribution.bottom++ - }) - - setData([ - { name: '우수', value: distribution.top, color: '#10b981', range: '0σ 이상', description: '평균보다 우수해요' }, - { name: '양호', value: distribution.middle, color: '#1482B0', range: '-1.0σ ~ 0σ', description: '평균 수준이에요' }, - { name: '개선필요', value: distribution.bottom, color: '#94a3b8', range: '-1.0σ 이하', description: '조금 더 신경써요' }, - ].filter(d => d.value > 0)) - - } catch (error) { - console.error('분포 데이터 로드 실패:', error) - } finally { - setLoading(false) - } - } - - fetchData() - }, [farmNo]) - - if (loading) { - return ( -
-
-
-
-
- ) - } - - return ( -
- {/* 헤더 */} -
-
-
-
- -
-
-

우리 소들의 등급

-

총 {totalCount}두 분포

-
-
- {totalCount}두 -
-
- - {/* 차트 */} -
-
-
- - - - {data.map((entry, index) => ( - - ))} - - { - if (active && payload && payload.length) { - const item = payload[0].payload - return ( -
-

{item.name}

-

{item.description}

-
-

{item.value}두 ({Math.round(item.value / totalCount * 100)}%)

-

{item.range}

-
-
- ) - } - return null - }} - /> -
-
- {/* 중앙 텍스트 */} -
- {totalCount} - 전체 -
-
- - {/* 범례 */} -
-
- {data.map((item, idx) => ( -
-
-
-
- {item.name} - {item.description} -
-
-
-

{item.value}

-

{Math.round(item.value / totalCount * 100)}%

-
-
- ))} -
-

- σ(시그마)는 유전능력 수준을 나타내요
- 0보다 클수록 우수해요 -

-
-
-
-
- ) -} diff --git a/frontend/src/components/genome/genome-radar-chart.tsx b/frontend/src/components/genome/genome-radar-chart.tsx deleted file mode 100644 index 10b2836..0000000 --- a/frontend/src/components/genome/genome-radar-chart.tsx +++ /dev/null @@ -1,250 +0,0 @@ -'use client' - -import { Target } from "lucide-react" -import { useEffect, useState } from "react" -import apiClient from "@/lib/api-client" -import { ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip } from 'recharts' - -interface TraitScore { - trait: string - diff: number - myFarm: number - region: number -} - -interface GenomeRadarChartProps { - farmNo: number | null -} - -export function GenomeRadarChart({ farmNo }: GenomeRadarChartProps) { - const [data, setData] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const fetchData = async () => { - if (!farmNo) { - setLoading(false) - return - } - - try { - const traits = [ - { name: '도체중', key: '도체중' }, - { name: '근내지방', key: '근내지방도' }, - { name: '등심단면적', key: '등심단면적' }, - { name: '등지방', key: '등지방두께' }, - { name: '12개월체중', key: '12개월령체중' }, - ] - - const results: TraitScore[] = [] - - for (const trait of traits) { - try { - const farmResponse = await apiClient.post('/cow/ranking', { - filterOptions: { 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) => { - const traitDetail = item.details?.find((d: any) => d.code === trait.key) - return traitDetail?.value ?? item.sortValue ?? 0 - }) || [] - const farmAvgScore = farmScores.length > 0 - ? farmScores.reduce((sum: number, s: number) => sum + s, 0) / farmScores.length - : 0 - - const globalScores = globalResult.items?.map((item: any) => { - const traitDetail = item.details?.find((d: any) => d.code === trait.key) - return traitDetail?.value ?? item.sortValue ?? 0 - }) || [] - const regionAvgScore = globalScores.length > 0 - ? globalScores.reduce((sum: number, s: number) => sum + s, 0) / globalScores.length - : 0 - - const diff = farmAvgScore - regionAvgScore - - results.push({ - trait: trait.name, - diff: parseFloat(diff.toFixed(2)), - myFarm: parseFloat(farmAvgScore.toFixed(2)), - region: parseFloat(regionAvgScore.toFixed(2)) - }) - } catch (error) { - console.error(`형질 ${trait.name} 로드 실패:`, error) - } - } - - setData(results) - } catch (error) { - console.error('레이더 차트 데이터 로드 실패:', error) - } finally { - setLoading(false) - } - } - - fetchData() - }, [farmNo]) - - if (loading) { - return ( -
-
-
-
-
- ) - } - - const validDiffs = data.filter(d => !isNaN(d.diff)) - const avgDiff = validDiffs.length > 0 - ? validDiffs.reduce((sum, d) => sum + d.diff, 0) / validDiffs.length - : 0 - - return ( -
- {/* 헤더 */} -
-
-
-
- -
-
-

카테고리별 결과

-

주요 형질 평균 비교

-
-
-
= 0 - ? 'bg-emerald-50 text-emerald-700 border-emerald-200' - : 'bg-red-50 text-red-600 border-red-200' - }`}> - 평균 {avgDiff > 0 ? '+' : ''}{avgDiff.toFixed(2)}σ -
-
-
- - {/* 차트 */} -
-
- - - - - - - - - - - - 0} - stroke="#94a3b8" - fill="none" - strokeWidth={2} - strokeDasharray="5 3" - strokeOpacity={0.6} - /> - - { - if (active && payload && payload.length) { - const item = payload[0]?.payload - const diff = item?.diff ?? 0 - const myFarm = item?.myFarm ?? 0 - const region = item?.region ?? 0 - - return ( -
-

{item?.trait}

-
-

내농장: {myFarm > 0 ? '+' : ''}{myFarm}σ

-

보은군: {region > 0 ? '+' : ''}{region}σ

-
-
= 0.3 ? 'text-emerald-400' : - diff <= -0.3 ? 'text-amber-400' : - 'text-slate-300' - }`}> - {diff >= 0.3 ? '▲' : diff <= -0.3 ? '▼' : '='} {diff > 0 ? '+' : ''}{diff.toFixed(2)}σ -
-
- ) - } - return null - }} - /> -
-
-
- - {/* 범례 */} -
-
-
- 보은군 평균 -
-
-
- 내 농장 -
-
- - {/* 형질별 수치 */} -
- {data.map((item, idx) => ( -
-

{item.trait}

-

= 0.3 ? 'text-emerald-600' : - item.diff <= -0.3 ? 'text-amber-600' : - 'text-slate-700' - }`}> - {item.diff > 0 ? '+' : ''}{item.diff.toFixed(1)} -

-
- ))} -
-
-
- ) -} diff --git a/frontend/src/components/genome/genome-strengths-weaknesses.tsx b/frontend/src/components/genome/genome-strengths-weaknesses.tsx deleted file mode 100644 index ab99c48..0000000 --- a/frontend/src/components/genome/genome-strengths-weaknesses.tsx +++ /dev/null @@ -1,208 +0,0 @@ -'use client' - -import { ArrowUpRight, ArrowDownRight, TrendingUp, TrendingDown } from "lucide-react" -import { useEffect, useState } from "react" -import apiClient from "@/lib/api-client" - -interface GenomeData { - trait: string - score: number - type: string -} - -interface GenomeStrengthsWeaknessesProps { - farmNo?: number | null -} - -export function GenomeStrengthsWeaknesses({ farmNo }: GenomeStrengthsWeaknessesProps) { - const [allMetrics, setAllMetrics] = useState([]) - const [loading, setLoading] = useState(true) - - useEffect(() => { - const fetchTraitScores = async () => { - if (!farmNo) { - setLoading(false) - return - } - - try { - const traits = [ - { name: '도체중', key: '도체중' }, - { name: '근내지방도', key: '근내지방도' }, - { name: '등심단면적', key: '등심단면적' }, - { name: '등지방두께', key: '등지방두께' }, - { name: '12개월령체중', key: '12개월령체중' }, - { name: '체고', key: '체고' }, - ] - - const traitScores: GenomeData[] = [] - - for (const trait of traits) { - try { - const rankingRequest = { - filterOptions: { farmNo: farmNo }, - rankingOptions: { - criteriaType: 'GENOME', - traitConditions: [{ traitNm: trait.key, weight: 1.0 }] - } - } - - const response = await apiClient.post('/cow/ranking', rankingRequest) - const rankingResult = response.data || response - - const scores = rankingResult.items?.map((item: any) => item.sortValue) || [] - const avgScore = scores.length > 0 - ? scores.reduce((sum: number, score: number) => sum + score, 0) / scores.length - : 0 - - traitScores.push({ - trait: trait.name, - score: parseFloat(avgScore.toFixed(2)), - type: '유전체' - }) - } catch (error) { - console.error(`[강점/약점] 형질 ${trait.name} 데이터 로드 실패:`, error) - } - } - - setAllMetrics(traitScores) - } catch (error) { - console.error('형질 점수 로드 실패:', error) - setAllMetrics([]) - } finally { - setLoading(false) - } - } - - fetchTraitScores() - }, [farmNo]) - - const strengths = [...allMetrics].sort((a, b) => b.score - a.score).slice(0, 3) - const weaknesses = [...allMetrics].sort((a, b) => a.score - b.score).slice(0, 3) - - if (loading) { - return ( -
- {[1, 2].map((i) => ( -
-
-
-
-
- ))} -
- ) - } - - return ( -
- {/* 강점 */} -
window.location.href = '/dashboard/strengths-weaknesses'} - > -
-
-
-
- -
-
-

이 부분이 강해요

-

보은군보다 우수한 형질

-
-
- TOP 3 -
-
-
- {strengths.length === 0 ? ( -
- 데이터가 없습니다. -
- ) : ( -
- {strengths.map((item, idx) => ( -
-
- - {idx + 1} - - {item.trait} -
-
- - {item.score > 0 ? '+' : ''}{item.score} - - σ -
-
- ))} -
- )} -
-
- - {/* 약점 */} -
window.location.href = '/dashboard/strengths-weaknesses'} - > -
-
-
-
- -
-
-

더 좋아질 수 있어요

-

개선하면 좋을 형질

-
-
- BOTTOM 3 -
-
-
- {weaknesses.length === 0 ? ( -
- 데이터가 없습니다. -
- ) : ( -
- {weaknesses.map((item, idx) => ( -
-
- - {idx + 1} - - {item.trait} -
-
- - {item.score > 0 ? '+' : ''}{item.score} - - σ -
-
- ))} -
- )} -
-
-
- ) -} diff --git a/frontend/src/components/genome/genome-traits-table.tsx b/frontend/src/components/genome/genome-traits-table.tsx deleted file mode 100644 index dd52eb3..0000000 --- a/frontend/src/components/genome/genome-traits-table.tsx +++ /dev/null @@ -1,199 +0,0 @@ -'use client' - -import { BarChart3 } from "lucide-react" -import { useEffect, useState } from "react" -import apiClient from "@/lib/api-client" - -interface TraitData { - trait: string - regional: number - myFarm: number -} - -interface GenomeTraitsTableProps { - farmNo?: number | null -} - -export function GenomeTraitsTable({ farmNo }: GenomeTraitsTableProps) { - const [traitData, setTraitData] = useState([]) - 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 = { - '12개월령체중': '12개월령체중', - '등심단면적': '등심단면적', - '등지방두께': '등지방두께', - '근내지방도': '근내지방도', - '도체중': '도체중' - } - return shortNames[name] || name - } - - if (loading) { - return ( -
-
-
-
-
- ) - } - - return ( -
- {/* 헤더 */} -
-
-
-
- -
-
-

형질별 점수

-

보은군과 비교한 수치에요

-
-
- 5개 형질 -
-
- - {/* 콘텐츠 */} -
- {traitData.length === 0 ? ( -
- 데이터가 없습니다. -
- ) : ( -
- {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 ( -
- {/* 형질명 + 차이 */} -
- - {getTraitShortName(item.trait)} - - = 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)}σ - -
- - {/* 비교 바 */} -
- {/* 보은군 바 */} -
- {/* 내농장 바 */} -
-
- - {/* 라벨 */} -
-
-
- - 내농장 {item.myFarm > 0 ? '+' : ''}{item.myFarm}σ - -
-
-
- - 보은군 {item.regional > 0 ? '+' : ''}{item.regional}σ - -
-
-
- ) - })} -
- )} -
-
- ) -}