From 0e6b06f9caf063255cae27d20e7e83c77c4e8d23 Mon Sep 17 00:00:00 2001 From: chu eun ju Date: Fri, 12 Dec 2025 11:49:35 +0900 Subject: [PATCH] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=84=A0=EB=B0=9C?= =?UTF-8?q?=EC=A7=80=EC=88=98=20=EC=88=98=EC=A0=95=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/cow/cow.service.ts | 4 +- backend/src/genome/genome.service.ts | 35 +- .../_components/category-evaluation-card.tsx | 61 +- .../_components/normal-distribution-chart.tsx | 78 +- frontend/src/app/cow/[cowNo]/page.tsx | 2472 ++++++++--------- frontend/src/app/cow/page.tsx | 22 +- frontend/src/app/dashboard/page.tsx | 1889 ++++++------- .../components/common/cow-number-display.tsx | 2 +- frontend/src/lib/api/genome.api.ts | 4 + frontend/src/lib/api/index.ts | 2 +- 10 files changed, 2303 insertions(+), 2266 deletions(-) diff --git a/backend/src/cow/cow.service.ts b/backend/src/cow/cow.service.ts index 5574017..3272e13 100644 --- a/backend/src/cow/cow.service.ts +++ b/backend/src/cow/cow.service.ts @@ -214,7 +214,7 @@ export class CowService { * * @param cows - 필터링된 개체 목록 * @param traitConditions - 형질별 가중치 조건 배열 - * @returns 순위가 적용된 개체 목록 + * @returns 순위가 적용된 개체 목록 / 리스트에 전달 / 농가/보은군 차트 (farmBreedVal, regionBreedVal) * @example * traitConditions = [ * { traitNm: '도체중', weight: 8 }, @@ -253,7 +253,7 @@ export class CowService { // Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가 if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) { // 분석불가 사유 결정 - let unavailableReason = '미분석'; + let unavailableReason = '분석불가'; if (latestRequest) { if (latestRequest.chipSireName !== '일치') { unavailableReason = '부 불일치'; diff --git a/backend/src/genome/genome.service.ts b/backend/src/genome/genome.service.ts index 7c593f0..9459cde 100644 --- a/backend/src/genome/genome.service.ts +++ b/backend/src/genome/genome.service.ts @@ -92,6 +92,7 @@ interface TraitAverageDto { traitName: string; // 형질명 category: string; // 카테고리 avgEbv: number; // 평균 EBV (표준화 육종가) + avgEpd: number; // 평균 EPD (육종가 원본값) count: number; // 데이터 개수 } @@ -1155,6 +1156,7 @@ export class GenomeService { const results = await qb .select('detail.traitName', 'traitName') .addSelect('AVG(detail.traitEbv)', 'avgEbv') + .addSelect('AVG(detail.traitVal)', 'avgEpd') // 육종가(EPD) 평균 추가 .addSelect('COUNT(*)', 'count') .groupBy('detail.traitName') .getRawMany(); @@ -1164,6 +1166,7 @@ export class GenomeService { traitName: row.traitName, category: TRAIT_CATEGORY_MAP[row.traitName] || '기타', avgEbv: Math.round(parseFloat(row.avgEbv) * 100) / 100, + avgEpd: Math.round(parseFloat(row.avgEpd || 0) * 100) / 100, // 육종가(EPD) 평균 count: parseInt(row.count, 10), })); } @@ -1551,12 +1554,15 @@ export class GenomeService { async getTraitRank(cowId: string, traitName: string): Promise<{ traitName: string; cowEbv: number | null; + cowEpd: number | null; // 개체 육종가(EPD) farmRank: number | null; farmTotal: number; regionRank: number | null; regionTotal: number; farmAvgEbv: number | null; regionAvgEbv: number | null; + farmAvgEpd: number | null; // 농가 평균 육종가(EPD) + regionAvgEpd: number | null; // 보은군 평균 육종가(EPD) }> { // 1. 현재 개체의 의뢰 정보 조회 const cow = await this.cowRepository.findOne({ @@ -1567,12 +1573,15 @@ export class GenomeService { return { traitName, cowEbv: null, + cowEpd: null, farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgEbv: null, regionAvgEbv: null, + farmAvgEpd: null, + regionAvgEpd: null, }; } @@ -1585,12 +1594,15 @@ export class GenomeService { return { traitName, cowEbv: null, + cowEpd: null, farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgEbv: null, regionAvgEbv: null, + farmAvgEpd: null, + regionAvgEpd: null, }; } @@ -1602,8 +1614,8 @@ export class GenomeService { relations: ['cow', 'farm'], }); - // 3. 각 개체별 해당 형질 EBV 수집 - const allScores: { cowId: string; ebv: number; farmNo: number | null }[] = []; + // 3. 각 개체별 해당 형질 EBV, EPD 수집 + const allScores: { cowId: string; ebv: number; epd: number | null; farmNo: number | null }[] = []; for (const request of allRequests) { if (!request.cow?.cowId) continue; @@ -1621,6 +1633,7 @@ export class GenomeService { allScores.push({ cowId: request.cow.cowId, ebv: Number(traitDetail.traitEbv), + epd: traitDetail.traitVal !== null ? Number(traitDetail.traitVal) : null, // 육종가(EPD) farmNo: request.fkFarmNo, }); } @@ -1629,9 +1642,10 @@ export class GenomeService { // 4. EBV 기준 내림차순 정렬 allScores.sort((a, b) => b.ebv - a.ebv); - // 5. 현재 개체의 EBV 찾기 + // 5. 현재 개체의 EBV, EPD 찾기 const currentCowData = allScores.find(s => s.cowId === cowId); const cowEbv = currentCowData?.ebv ?? null; + const cowEpd = currentCowData?.epd ?? null; // 6. 보은군 전체 순위 const regionRank = currentCowData @@ -1639,10 +1653,14 @@ export class GenomeService { : null; const regionTotal = allScores.length; - // 보은군 평균 EBV + // 보은군 평균 EBV, EPD const regionAvgEbv = allScores.length > 0 ? Math.round((allScores.reduce((sum, s) => sum + s.ebv, 0) / allScores.length) * 100) / 100 : null; + const regionEpdValues = allScores.filter(s => s.epd !== null).map(s => s.epd as number); + const regionAvgEpd = regionEpdValues.length > 0 + ? Math.round((regionEpdValues.reduce((sum, v) => sum + v, 0) / regionEpdValues.length) * 100) / 100 + : null; // 7. 농가 내 순위 const farmScores = allScores.filter(s => s.farmNo === farmNo); @@ -1651,20 +1669,27 @@ export class GenomeService { : null; const farmTotal = farmScores.length; - // 농가 평균 EBV + // 농가 평균 EBV, EPD const farmAvgEbv = farmScores.length > 0 ? Math.round((farmScores.reduce((sum, s) => sum + s.ebv, 0) / farmScores.length) * 100) / 100 : null; + const farmEpdValues = farmScores.filter(s => s.epd !== null).map(s => s.epd as number); + const farmAvgEpd = farmEpdValues.length > 0 + ? Math.round((farmEpdValues.reduce((sum, v) => sum + v, 0) / farmEpdValues.length) * 100) / 100 + : null; return { traitName, cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null, + cowEpd: cowEpd !== null ? Math.round(cowEpd * 100) / 100 : null, farmRank: farmRank && farmRank > 0 ? farmRank : null, farmTotal, regionRank: regionRank && regionRank > 0 ? regionRank : null, regionTotal, farmAvgEbv, regionAvgEbv, + farmAvgEpd, + regionAvgEpd, }; } diff --git a/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx b/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx index 9d914a7..3a54901 100644 --- a/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx +++ b/frontend/src/app/cow/[cowNo]/genome/_components/category-evaluation-card.tsx @@ -192,14 +192,19 @@ export function CategoryEvaluationCard({ // 보은군/농가 형질별 평균 (데이터 없으면 0) const regionTraitAvg = traitAvgRegion?.avgEbv ?? 0 const farmTraitAvg = traitAvgFarm?.avgEbv ?? 0 + // 보은군/농가 형질별 EPD(육종가) 평균 + const regionEpdAvg = traitAvgRegion?.avgEpd ?? 0 + const farmEpdAvg = traitAvgFarm?.avgEpd ?? 0 return { name: traitName, shortName: TRAIT_SHORT_NAMES[traitName] || traitName, breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ) - epd: trait?.actualValue ?? 0, // 이 개체 EPD (예상후대차이) - regionVal: regionTraitAvg, // 보은군 평균 - farmVal: farmTraitAvg, // 농가 평균 + epd: trait?.actualValue ?? 0, // 이 개체 EPD (육종가) + regionVal: regionTraitAvg, // 보은군 평균 (표준화육종가) + farmVal: farmTraitAvg, // 농가 평균 (표준화육종가) + regionEpd: regionEpdAvg, // 보은군 평균 (육종가) + farmEpd: farmEpdAvg, // 농가 평균 (육종가) percentile: trait?.percentile ?? 50, category: trait?.category ?? '체형', diff: trait?.breedVal ?? 0, @@ -524,47 +529,45 @@ export function CategoryEvaluationCard({ - {/* 선택된 형질 정보 표시 (모바일 친화적) */} + {/* 선택된 형질 정보 표시 - 육종가(EPD) 값으로 표시 */} {selectedTraitName && (() => { const selectedTrait = traitChartData.find(t => t.name === selectedTraitName) if (!selectedTrait) return null return (
-
- {selectedTrait.shortName} + {/* 헤더: 형질명 + 닫기 */} +
+ + {selectedTrait.shortName} 조회 기준 +
-
-
- - - 보은군 - - - {selectedTrait.regionVal > 0 ? '+' : ''}{selectedTrait.regionVal.toFixed(2)}σ + {/* 3개 카드 그리드 */} +
+ {/* 보은군 카드 */} +
+ 보은군 평균 + + {selectedTrait.regionEpd?.toFixed(2) ?? '-'}
-
- - - 농가 - - - {selectedTrait.farmVal > 0 ? '+' : ''}{selectedTrait.farmVal.toFixed(2)}σ + {/* 농가 카드 */} +
+ 농가 평균 + + {selectedTrait.farmEpd?.toFixed(2) ?? '-'}
-
- - - {formatCowNoShort(cowNo)} 개체 - - - {selectedTrait.breedVal > 0 ? '+' : ''}{selectedTrait.breedVal.toFixed(2)}σ + {/* 개체 카드 */} +
+ 내 개체 + + {selectedTrait.epd?.toFixed(2) ?? '-'}
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 e365504..40866de 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 @@ -259,6 +259,8 @@ export function NormalDistributionChart({ // 차트 필터에 따른 표시 값 계산 (개체/농가/보은군 모두) // "내 개체 중심" 방식: 개체를 0에 고정하고 농가/보은군을 상대 위치로 표시 + // - 전체 선발지수: 선발지수 값 사용 + // - 개별 형질: 육종가(EPD) 값 사용 const chartDisplayValues = useMemo(() => { let baseScore = overallScore let basePercentile = overallPercentile @@ -267,16 +269,17 @@ export function NormalDistributionChart({ let baseRegionScore = regionAvgZ if (chartFilterTrait !== 'overall') { - // 선택된 형질 찾기 - const selectedTrait = selectedTraitData.find(t => t.name === chartFilterTrait) + // 모든 형질에서 찾기 (selectedTraitData는 선택된 형질만 포함하므로 allTraits에서 찾아야 함) + const selectedTrait = allTraits.find(t => t.name === chartFilterTrait) if (selectedTrait) { - baseScore = selectedTrait.breedVal + // 개별 형질 선택 시: 육종가(EPD) 값 사용 + baseScore = selectedTrait.actualValue ?? 0 // 개체 육종가 basePercentile = selectedTrait.percentile baseLabel = selectedTrait.name - // API에서 가져온 형질별 농가/보은군 평균 사용 (없으면 0으로 - 데이터 로딩 중) - baseFarmScore = traitRankData?.farmAvgEbv ?? 0 - baseRegionScore = traitRankData?.regionAvgEbv ?? 0 + // API에서 가져온 형질별 농가/보은군 평균 육종가(EPD) 사용 + baseFarmScore = traitRankData?.farmAvgEpd ?? 0 + baseRegionScore = traitRankData?.regionAvgEpd ?? 0 } } @@ -299,19 +302,34 @@ export function NormalDistributionChart({ cowVsFarm, cowVsRegion } - }, [chartFilterTrait, overallScore, overallPercentile, selectedTraitData, traitRankData, farmAvgZ, regionAvgZ]) + }, [chartFilterTrait, overallScore, overallPercentile, allTraits, traitRankData, farmAvgZ, regionAvgZ]) // X축 범위 및 간격 계산 (내 개체 중심 방식) const xAxisConfig = useMemo(() => { const { cowVsFarm, cowVsRegion } = chartDisplayValues const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion)) - // 차이가 3 초과면 1 단위, 이하면 0.5 단위 - const step = maxDiff > 3 ? 1 : 0.5 + // 데이터가 차트의 약 70% 영역에 들어오도록 범위 계산 + // maxDiff가 range의 70%가 되도록: range = maxDiff / 0.7 + const targetRange = maxDiff / 0.7 - // 범위 계산 (최소 2.5, step 단위로 올림) - const minRange = step === 1 ? 3 : 2.5 - const range = Math.max(minRange, Math.ceil(maxDiff / step) * step + step) + // step 계산: 범위에 따라 적절한 간격 선택 + let step: number + if (targetRange <= 1) { + step = 0.2 + } else if (targetRange <= 3) { + step = 0.5 + } else if (targetRange <= 10) { + step = 1 + } else if (targetRange <= 30) { + step = 5 + } else { + step = 10 + } + + // 범위를 step 단위로 올림 (최소값 보장) + const minRange = step * 3 // 최소 3개의 step + const range = Math.max(minRange, Math.ceil(targetRange / step) * step) return { min: -range, max: range, step } }, [chartDisplayValues]) @@ -401,19 +419,7 @@ export function NormalDistributionChart({
{categoryTraits.map((trait) => ( -
-
- {chartFilterTrait === trait.name && ( - - - - )} -
- {trait.name} -
+ {trait.name}
))}
@@ -423,16 +429,6 @@ export function NormalDistributionChart({ )} - {/* 선택된 형질 해제 버튼 */} - {chartFilterTrait !== 'overall' && ( - - )}
{/* 확대 버튼 */} - {/* 아이콘 */} -
- -
- {/* 타이틀 */} -
-

개체 분석 보고서

-

Analysis Report

+ + + + +
+ {/* 메인 컨테이너 여백 : p-6 */} +
+ {/* 헤더: 뒤로가기 + 타이틀 + 다운로드 */} +
+
+ {/* 뒤로가기 버튼 */} + + {/* 아이콘 */} +
+ +
+ {/* 타이틀 */} +
+

개체 분석 보고서

+

Analysis Report

+
-
- {/* 탭 네비게이션 */} - - - - - 유전체 - - {hasGenomeData ? '완료' : '미검사'} - - - - - 유전자 - - - - 번식능력 - - + {/* 탭 네비게이션 */} + + + + + 유전체 + + {hasGenomeData ? '완료' : '미검사'} + + + + + 유전자 + + + + 번식능력 + + - {/* 유전체 분석 탭 */} - - {hasGenomeData ? ( - <> - {/* 개체 정보 섹션 */} -

개체 정보

+ {/* 유전체 분석 탭 */} + + {hasGenomeData ? ( + <> + {/* 개체 정보 섹션 */} +

개체 정보

- - - {/* 모바일: 세로 리스트 / 데스크탑: 가로 그리드 */} -
-
-
- 개체번호 + + + {/* 모바일: 세로 리스트 / 데스크탑: 가로 그리드 */} +
+
+
+ 개체번호 +
+
+ +
-
- +
+
+ 생년월일 +
+
+ + {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} + +
-
-
-
- 생년월일 +
+
+ 월령 +
+
+ + {cow?.cowBirthDt + ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + : '-'} + +
-
- - {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} - -
-
-
-
- 월령 -
-
- - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` - : '-'} - -
-
-
-
- 유전체 분석일자 -
-
- - {genomeData[0]?.request?.requestDt - ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') - : '-'} - -
-
-
- {/* 모바일: 좌우 배치 리스트 */} -
-
- 개체번호 -
- -
-
-
- 생년월일 - - {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} - -
-
- 월령 - - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` - : '-'} - -
-
- 분석일자 - - {genomeData[0]?.request?.requestDt - ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') - : '-'} - -
-
- - - - {/* 친자확인 섹션 */} -

친자확인 결과

- - - - {/* 데스크탑: 가로 그리드 */} -
-
-
- 부 KPN번호 -
-
- - {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - - {(() => { - const chipSireName = genomeData[0]?.request?.chipSireName - if (chipSireName === '일치') { - return ( - - - 일치 - - ) - } else if (chipSireName && chipSireName !== '일치') { - return ( - - - 불일치 - - ) - } else { - return ( - - 정보없음 - - ) - } - })()} -
-
-
-
- 모 개체번호 -
-
- {cow?.damCowId && cow.damCowId !== '0' ? ( - - ) : ( - - - )} - {(() => { - const chipDamName = genomeData[0]?.request?.chipDamName - if (chipDamName === '일치') { - return ( - - - 일치 - - ) - } else if (chipDamName === '불일치') { - return ( - - - 불일치 - - ) - } else if (chipDamName === '이력제부재') { - return ( - - - 이력제부재 - - ) - } else { - // 정보없음(null/undefined)일 때는 배지 표시 안함 - return null - } - })()} -
-
-
- {/* 모바일: 좌우 배치 리스트 */} -
-
- 부 KPN번호 -
- - {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - - {(() => { - const chipSireName = genomeData[0]?.request?.chipSireName - if (chipSireName === '일치') { - return ( - - - 일치 - - ) - } else if (chipSireName && chipSireName !== '일치') { - return ( - - - 불일치 - - ) - } else { - return ( - - 정보없음 - - ) - } - })()} -
-
-
- 모 개체번호 -
- {cow?.damCowId && cow.damCowId !== '0' ? ( - - ) : ( - - - )} - {(() => { - const chipDamName = genomeData[0]?.request?.chipDamName - if (chipDamName === '일치') { - return ( - - - 일치 - - ) - } else if (chipDamName === '불일치') { - return ( - - - 불일치 - - ) - } else if (chipDamName === '이력제부재') { - return ( - - - 이력제부재 - - ) - } else { - // 정보없음(null/undefined)일 때는 배지 표시 안함 - return null - } - })()} -
-
-
-
-
- - {/* 친자확인 결과에 따른 분기 (유효성 조건: 아비일치 + 어미불일치/이력제부재 제외 + 제외목록) */} - {isValidGenomeAnalysis(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) ? ( - <> - {/* 농가 및 보은군 내 개체 위치 */} -

농가 및 보은군 내 개체 위치

-
- { }} - onToggleTraitSelection={toggleTraitSelection} - onClearSelectedTraits={() => setSelectedTraits([])} - onOpenChartModal={() => setIsChartModalOpen(true)} - distributionData={distributionData} - totalCowCount={totalCowCount} - traitComparisons={traitComparisons} - cowEpd={cowAvgEpd} - farmAvgEpd={farmAvgEpdValue} - regionAvgEpd={regionAvgEpdValue} - farmRank={selectionIndex?.farmRank} - farmTotal={selectionIndex?.farmTotal} - regionRank={selectionIndex?.regionRank} - highlightMode={highlightMode} - onHighlightModeChange={setHighlightMode} - regionTotal={selectionIndex?.regionTotal} - chartFilterTrait={chartFilterTrait} - onChartFilterTraitChange={setChartFilterTrait} - /> -
- - {/* 형질별 표준화육종가 비교 */} -

형질별 표준화육종가 비교

- - -

선택 형질 상세

- - - -

분석 정보

- - - -
-
-
접수일
-
+
+
+ 유전체 분석일자 +
+
+ {genomeData[0]?.request?.requestDt ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') : '-'} -
+
-
-
분석 완료일
-
- {genomeData[0]?.request?.chipReportDt - ? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR') - : '-'} -
-
-
-
칩 종류
-
- {genomeData[0]?.request?.chipType || '-'} -
-
-
- - - - ) : ( - <> -

유전체 분석 결과

- - - -
- - {getInvalidReason(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) || '분석 불가'} - -
-
-

- {getInvalidMessage(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId)} -

-
-

안내사항

-
    -
  • - - 유전체 분석 보고서는 친자확인이 완료된 개체에 한해 제공됩니다. -
  • -
  • - - 정확한 분석을 위해 재검사 또는 KPN 정보 확인이 필요합니다. -
  • -
-
-
-
-
- - )} - - ) : ( - <> - {/* 개체 정보 섹션 */} -

개체 정보

- - - - {/* 모바일: 세로 리스트 / 데스크탑: 가로 그리드 */} -
-
-
- 개체번호 -
-
-
-
-
- 생년월일 + {/* 모바일: 좌우 배치 리스트 */} +
+
+ 개체번호 +
+ +
-
- +
+ 생년월일 + {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'}
-
-
-
- 월령 -
-
- +
+ 월령 + {cow?.cowBirthDt ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` : '-'}
-
-
-
- 유전체 분석일자 -
-
- - -
-
-
- {/* 모바일: 좌우 배치 리스트 */} -
-
- 개체번호 -
- -
-
-
- 생년월일 - - {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} - -
-
- 월령 - - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` - : '-'} - -
-
- 분석일자 - - -
-
- - - - {/* 친자확인 섹션 */} -

친자확인 결과

- - - - {/* 데스크탑: 가로 그리드 */} -
-
-
- 부 KPN번호 -
-
- {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - {(() => { - const chipSireName = genomeRequest?.chipSireName - if (chipSireName === '일치') { - return ( - - - 일치 - - ) - } else if (chipSireName && chipSireName !== '일치') { - return ( - - - 불일치 - - ) - } else { - return ( - - 정보없음 - - ) - } - })()} -
-
-
-
- 모 개체번호 -
-
- {cow?.damCowId && cow.damCowId !== '0' ? ( - - ) : ( - - - )} - {(() => { - const chipDamName = genomeRequest?.chipDamName - if (chipDamName === '일치') { - return ( - - - 일치 - - ) - } else if (chipDamName === '불일치') { - return ( - - - 불일치 - - ) - } else if (chipDamName === '이력제부재') { - return ( - - - 이력제부재 - - ) - } else { - // 정보없음(null/undefined)일 때는 배지 표시 안함 - return null - } - })()} -
-
-
- {/* 모바일: 세로 리스트 */} -
-
- 부 KPN -
- {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - {(() => { - const chipSireName = genomeRequest?.chipSireName - if (chipSireName === '일치') { - return ( - - - 일치 - - ) - } else if (chipSireName && chipSireName !== '일치') { - return ( - - - 불일치 - - ) - } else { - return ( - - 정보없음 - - ) - } - })()} -
-
-
- 모 개체번호 -
- {cow?.damCowId && cow.damCowId !== '0' ? ( - - ) : ( - - - )} - {(() => { - const chipDamName = genomeRequest?.chipDamName - if (chipDamName === '일치') { - return ( - - - 일치 - - ) - } else if (chipDamName === '불일치') { - return ( - - - 불일치 - - ) - } else if (chipDamName === '이력제부재') { - return ( - - - 이력제부재 - - ) - } else { - // 정보없음(null/undefined)일 때는 배지 표시 안함 - return null - } - })()} -
-
-
-
-
- - {/* 분석불가 메시지 */} - - - -

- {genomeRequest ? '유전체 분석 불가' : '유전체 미분석'} -

-

- {genomeRequest - ? getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) - : '이 개체는 아직 유전체 분석이 진행되지 않았습니다.' - } -

-
-
- - )} - - - {/* 유전자 분석 탭 */} - - {hasGeneData ? ( - <> - {/* 개체 정보 섹션 (유전체 탭과 동일) */} -

개체 정보

- - - - {/* 데스크탑: 가로 그리드 */} -
-
-
- 개체번호 -
-
- -
-
-
-
- 생년월일 -
-
- - {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} - -
-
-
-
- 월령 -
-
- - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` - : '-'} - -
-
-
-
- 유전자 분석일자 -
-
- +
+ 분석일자 + {genomeData[0]?.request?.requestDt ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') : '-'}
-
- {/* 모바일: 좌우 배치 리스트 */} -
-
- 개체번호 -
- + + + + {/* 친자확인 섹션 */} +

친자확인 결과

+ + + + {/* 데스크탑: 가로 그리드 */} +
+
+
+ 부 KPN번호 +
+
+ + {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + + {(() => { + const chipSireName = genomeRequest?.chipSireName + if (chipSireName === '일치') { + return ( + + + 일치 + + ) + } else if (chipSireName && chipSireName !== '일치') { + return ( + + + 불일치 + + ) + } else { + return ( + + 분석불가 + + ) + } + })()} +
+
+
+
+ 모 개체번호 +
+
+ {cow?.damCowId && cow.damCowId !== '0' ? ( + + ) : ( + - + )} + {(() => { + const chipDamName = genomeRequest?.chipDamName + if (chipDamName === '일치') { + return ( + + + 일치 + + ) + } else if (chipDamName === '불일치') { + return ( + + + 불일치 + + ) + } else if (chipDamName === '이력제부재') { + return ( + + + 이력제부재 + + ) + } else { + // 정보없음(null/undefined)일 때는 배지 표시 안함 + return null + } + })()} +
-
- 생년월일 - - {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} - -
-
- 월령 - - {cow?.cowBirthDt - ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` - : '-'} - -
-
- 분석일자 - - {genomeData[0]?.request?.requestDt - ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') - : '-'} - -
-
- - - - {/* 친자확인 결과 섹션 (유전체 탭과 동일) */} -

친자확인 결과

- - - - {/* 데스크탑: 가로 그리드 */} -
-
-
- 부 KPN번호 + {/* 모바일: 좌우 배치 리스트 */} +
+
+ 부 KPN번호 +
+ + {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + + {(() => { + const chipSireName = genomeRequest?.chipSireName + if (chipSireName === '일치') { + return ( + + + 일치 + + ) + } else if (chipSireName && chipSireName !== '일치') { + return ( + + + 불일치 + + ) + } else { + return ( + + 분석불가 + + ) + } + })()} +
-
- - {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - - {(() => { - const chipSireName = genomeData[0]?.request?.chipSireName - if (chipSireName === '일치') { - return ( - - - 일치 - - ) - } else if (chipSireName && chipSireName !== '일치') { - return ( - - - 불일치 - - ) - } else { - return ( - - 정보없음 - - ) - } - })()} +
+ 모 개체번호 +
+ {cow?.damCowId && cow.damCowId !== '0' ? ( + + ) : ( + - + )} + {(() => { + const chipDamName = genomeRequest?.chipDamName + if (chipDamName === '일치') { + return ( + + + 일치 + + ) + } else if (chipDamName === '불일치') { + return ( + + + 불일치 + + ) + } else if (chipDamName === '이력제부재') { + return ( + + + 이력제부재 + + ) + } else { + // 정보없음(null/undefined)일 때는 배지 표시 안함 + return null + } + })()} +
-
-
- 모 개체번호 -
-
- {cow?.damCowId && cow.damCowId !== '0' ? ( - - ) : ( - - - )} - {(() => { - const chipDamName = genomeData[0]?.request?.chipDamName - if (chipDamName === '일치') { - return ( - - - 일치 - - ) - } else if (chipDamName === '불일치') { - return ( - - - 불일치 - - ) - } else if (chipDamName === '이력제부재') { - return ( - - - 이력제부재 - - ) - } else { - return null - } - })()} -
-
-
- {/* 모바일: 좌우 배치 리스트 */} -
-
- 부 KPN번호 -
- - {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} - - {(() => { - const chipSireName = genomeData[0]?.request?.chipSireName - if (chipSireName === '일치') { - return ( - - - 일치 - - ) - } else if (chipSireName && chipSireName !== '일치') { - return ( - - - 불일치 - - ) - } else { - return ( - - 정보없음 - - ) - } - })()} -
-
-
- 모 개체번호 -
- {cow?.damCowId && cow.damCowId !== '0' ? ( - - ) : ( - - - )} - {(() => { - const chipDamName = genomeData[0]?.request?.chipDamName - if (chipDamName === '일치') { - return ( - - - 일치 - - ) - } else if (chipDamName === '불일치') { - return ( - - - 불일치 - - ) - } else if (chipDamName === '이력제부재') { - return ( - - - 이력제부재 - - ) - } else { - return null - } - })()} -
-
-
- - + + - {/* 유전자 검색 및 필터 섹션 */} -

유전자 분석 결과

- -
- {/* 검색창 */} -
- - setGeneSearchKeyword(e.target.value)} - /> -
- - {/* 필터 옵션들 */} -
- {/* 유전자 타입 필터 */} -
- 구분: -
- - - -
-
- - {/* 정렬 드롭다운 */} -
- - -
-
-
- - {/* 유전자 테이블/카드 */} - {(() => { - const filteredData = geneData.filter(gene => { - // 검색 필터 - if (geneSearchKeyword) { - const keyword = geneSearchKeyword.toLowerCase() - const snpName = (gene.snpName || '').toLowerCase() - const chromosome = (gene.chromosome || '').toLowerCase() - const position = (gene.position || '').toLowerCase() - if (!snpName.includes(keyword) && !chromosome.includes(keyword) && !position.includes(keyword)) { - return false - } - } - // 유전자형 필터 - if (genotypeFilter !== 'all') { - const isHomozygous = gene.allele1 === gene.allele2 - if (genotypeFilter === 'homozygous' && !isHomozygous) return false - if (genotypeFilter === 'heterozygous' && isHomozygous) return false - } - return true - }) - - // 정렬 - const sortedData = [...filteredData].sort((a, b) => { - let aVal: string | number = '' - let bVal: string | number = '' - - switch (geneSortBy) { - case 'snpName': - aVal = a.snpName || '' - bVal = b.snpName || '' - break - case 'chromosome': - aVal = parseInt(a.chromosome || '0') || 0 - bVal = parseInt(b.chromosome || '0') || 0 - break - case 'position': - aVal = parseInt(a.position || '0') || 0 - bVal = parseInt(b.position || '0') || 0 - break - case 'genotype': - aVal = `${a.allele1 || ''}${a.allele2 || ''}` - bVal = `${b.allele1 || ''}${b.allele2 || ''}` - break - } - - if (typeof aVal === 'number' && typeof bVal === 'number') { - return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal - } - - const strA = String(aVal) - const strB = String(bVal) - return geneSortOrder === 'asc' - ? strA.localeCompare(strB) - : strB.localeCompare(strA) - }) - - const displayData = sortedData.length > 0 - ? sortedData.slice(0, 50) - : Array(10).fill(null) - - return ( + {/* 친자확인 결과에 따른 분기 (유효성 조건: 아비일치 + 어미불일치/이력제부재 제외 + 제외목록) */} + {isValidGenomeAnalysis(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) ? ( <> - {/* 데스크톱: 테이블 */} - + {/* 농가 및 보은군 내 개체 위치 */} +

농가 및 보은군 내 개체 위치

+
+ { }} + onToggleTraitSelection={toggleTraitSelection} + onClearSelectedTraits={() => setSelectedTraits([])} + onOpenChartModal={() => setIsChartModalOpen(true)} + distributionData={distributionData} + totalCowCount={totalCowCount} + traitComparisons={traitComparisons} + cowEpd={cowAvgEpd} + farmAvgEpd={farmAvgEpdValue} + regionAvgEpd={regionAvgEpdValue} + farmRank={selectionIndex?.farmRank} + farmTotal={selectionIndex?.farmTotal} + regionRank={selectionIndex?.regionRank} + highlightMode={highlightMode} + onHighlightModeChange={setHighlightMode} + regionTotal={selectionIndex?.regionTotal} + chartFilterTrait={chartFilterTrait} + onChartFilterTraitChange={setChartFilterTrait} + /> +
+ + {/* 유전체 형질별 육종가 비교 */} +

유전체 형질별 육종가 비교

+ + +

선택 형질 상세

+ + + +

분석 정보

+ + -
- - - - - - - - - - - - - - {displayData.map((gene, idx) => { - if (!gene) { +
+
+
접수일
+
+ {genomeData[0]?.request?.requestDt + ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') + : '-'} +
+
+
+
분석 완료일
+
+ {genomeData[0]?.request?.chipReportDt + ? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR') + : '-'} +
+
+
+
칩 종류
+
+ {genomeData[0]?.request?.chipType || '-'} +
+
+
+ + + + ) : ( + <> +

유전체 분석 결과

+ + + +
+ + {getInvalidReason(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) || '분석 불가'} + +
+
+

+ {getInvalidMessage(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId)} +

+
+

안내사항

+
    +
  • + + 유전체 분석 보고서는 친자확인이 완료된 개체에 한해 제공됩니다. +
  • +
  • + + 정확한 분석을 위해 재검사 또는 KPN 정보 확인이 필요합니다. +
  • +
+
+
+
+
+ + )} + + ) : ( + <> + {/* 개체 정보 섹션 */} +

개체 정보

+ + + + {/* 모바일: 세로 리스트 / 데스크탑: 가로 그리드 */} +
+
+
+ 개체번호 +
+
+ +
+
+
+
+ 생년월일 +
+
+ + {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} + +
+
+
+
+ 월령 +
+
+ + {cow?.cowBirthDt + ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + : '-'} + +
+
+
+
+ 유전체 분석일자 +
+
+ - +
+
+
+ {/* 모바일: 좌우 배치 리스트 */} +
+
+ 개체번호 +
+ +
+
+
+ 생년월일 + + {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} + +
+
+ 월령 + + {cow?.cowBirthDt + ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + : '-'} + +
+
+ 분석일자 + - +
+
+
+
+ + {/* 친자확인 섹션 */} +

친자확인 결과

+ + + + {/* 데스크탑: 가로 그리드 */} +
+
+
+ 부 KPN번호 +
+
+ {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + {(() => { + const chipSireName = genomeRequest?.chipSireName + if (chipSireName === '일치') { + return ( + + + 일치 + + ) + } else if (chipSireName && chipSireName !== '일치') { + return ( + + + 불일치 + + ) + } else { + return ( + + 분석불가 + + ) + } + })()} +
+
+
+
+ 모 개체번호 +
+
+ {cow?.damCowId && cow.damCowId !== '0' ? ( + + ) : ( + - + )} + {(() => { + const chipDamName = genomeRequest?.chipDamName + if (chipDamName === '일치') { + return ( + + + 일치 + + ) + } else if (chipDamName === '불일치') { + return ( + + + 불일치 + + ) + } else if (chipDamName === '이력제부재') { + return ( + + + 이력제부재 + + ) + } else { + // 정보없음(null/undefined)일 때는 배지 표시 안함 + return null + } + })()} +
+
+
+ {/* 모바일: 세로 리스트 */} +
+
+ 부 KPN +
+ {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + {(() => { + const chipSireName = genomeRequest?.chipSireName + if (chipSireName === '일치') { + return ( + + + 일치 + + ) + } else if (chipSireName && chipSireName !== '일치') { + return ( + + + 불일치 + + ) + } else { + return ( + + 분석불가 + + ) + } + })()} +
+
+
+ 모 개체번호 +
+ {cow?.damCowId && cow.damCowId !== '0' ? ( + + ) : ( + - + )} + {(() => { + const chipDamName = genomeRequest?.chipDamName + if (chipDamName === '일치') { + return ( + + + 일치 + + ) + } else if (chipDamName === '불일치') { + return ( + + + 불일치 + + ) + } else if (chipDamName === '이력제부재') { + return ( + + + 이력제부재 + + ) + } else { + // 정보없음(null/undefined)일 때는 배지 표시 안함 + return null + } + })()} +
+
+
+
+
+ + {/* 분석불가 메시지 */} + + + +

+ {genomeRequest ? '유전체 분석 불가' : '유전체 분석불가'} +

+

+ {genomeRequest + ? getInvalidMessage(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) + : '이 개체는 아직 유전체 분석이 진행되지 않았습니다.' + } +

+
+
+ + )} + + + {/* 유전자 분석 탭 */} + + {hasGeneData ? ( + <> + {/* 개체 정보 섹션 (유전체 탭과 동일) */} +

개체 정보

+ + + + {/* 데스크탑: 가로 그리드 */} +
+
+
+ 개체번호 +
+
+ +
+
+
+
+ 생년월일 +
+
+ + {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} + +
+
+
+
+ 월령 +
+
+ + {cow?.cowBirthDt + ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + : '-'} + +
+
+
+
+ 유전자 분석일자 +
+
+ + {genomeData[0]?.request?.requestDt + ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') + : '-'} + +
+
+
+ {/* 모바일: 좌우 배치 리스트 */} +
+
+ 개체번호 +
+ +
+
+
+ 생년월일 + + {cow?.cowBirthDt ? new Date(cow.cowBirthDt).toLocaleDateString('ko-KR') : '-'} + +
+
+ 월령 + + {cow?.cowBirthDt + ? `${Math.floor((new Date().getTime() - new Date(cow.cowBirthDt).getTime()) / (1000 * 60 * 60 * 24 * 30.44))}개월` + : '-'} + +
+
+ 분석일자 + + {genomeData[0]?.request?.requestDt + ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') + : '-'} + +
+
+
+
+ + {/* 친자확인 결과 섹션 (유전체 탭과 동일) */} +

친자확인 결과

+ + + + {/* 데스크탑: 가로 그리드 */} +
+
+
+ 부 KPN번호 +
+
+ + {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + + {(() => { + const chipSireName = genomeRequest?.chipSireName + if (chipSireName === '일치') { + return ( + + + 일치 + + ) + } else if (chipSireName && chipSireName !== '일치') { + return ( + + + 불일치 + + ) + } else { + return ( + + 분석불가 + + ) + } + })()} +
+
+
+
+ 모 개체번호 +
+
+ {cow?.damCowId && cow.damCowId !== '0' ? ( + + ) : ( + - + )} + {(() => { + const chipDamName = genomeRequest?.chipDamName + if (chipDamName === '일치') { + return ( + + + 일치 + + ) + } else if (chipDamName === '불일치') { + return ( + + + 불일치 + + ) + } else if (chipDamName === '이력제부재') { + return ( + + + 이력제부재 + + ) + } else { + return null + } + })()} +
+
+
+ {/* 모바일: 좌우 배치 리스트 */} +
+
+ 부 KPN번호 +
+ + {cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'} + + {(() => { + const chipSireName = genomeRequest?.chipSireName + if (chipSireName === '일치') { + return ( + + + 일치 + + ) + } else if (chipSireName && chipSireName !== '일치') { + return ( + + + 불일치 + + ) + } else { + return ( + + 분석불가 + + ) + } + })()} +
+
+
+ 모 개체번호 +
+ {cow?.damCowId && cow.damCowId !== '0' ? ( + + ) : ( + - + )} + {(() => { + const chipDamName = genomeRequest?.chipDamName + if (chipDamName === '일치') { + return ( + + + 일치 + + ) + } else if (chipDamName === '불일치') { + return ( + + + 불일치 + + ) + } else if (chipDamName === '이력제부재') { + return ( + + + 이력제부재 + + ) + } else { + return null + } + })()} +
+
+
+
+
+ + {/* 유전자 검색 및 필터 섹션 */} +

유전자 분석 결과

+ +
+ {/* 검색창 */} +
+ + setGeneSearchKeyword(e.target.value)} + /> +
+ + {/* 필터 옵션들 */} +
+ {/* 유전자 타입 필터 */} +
+ 구분: +
+ + + +
+
+ + {/* 정렬 드롭다운 */} +
+ + +
+
+
+ + {/* 유전자 테이블/카드 */} + {(() => { + const filteredData = geneData.filter(gene => { + // 검색 필터 + if (geneSearchKeyword) { + const keyword = geneSearchKeyword.toLowerCase() + const snpName = (gene.snpName || '').toLowerCase() + const chromosome = (gene.chromosome || '').toLowerCase() + const position = (gene.position || '').toLowerCase() + if (!snpName.includes(keyword) && !chromosome.includes(keyword) && !position.includes(keyword)) { + return false + } + } + // 유전자형 필터 + if (genotypeFilter !== 'all') { + const isHomozygous = gene.allele1 === gene.allele2 + if (genotypeFilter === 'homozygous' && !isHomozygous) return false + if (genotypeFilter === 'heterozygous' && isHomozygous) return false + } + return true + }) + + // 정렬 + const sortedData = [...filteredData].sort((a, b) => { + let aVal: string | number = '' + let bVal: string | number = '' + + switch (geneSortBy) { + case 'snpName': + aVal = a.snpName || '' + bVal = b.snpName || '' + break + case 'chromosome': + aVal = parseInt(a.chromosome || '0') || 0 + bVal = parseInt(b.chromosome || '0') || 0 + break + case 'position': + aVal = parseInt(a.position || '0') || 0 + bVal = parseInt(b.position || '0') || 0 + break + case 'genotype': + aVal = `${a.allele1 || ''}${a.allele2 || ''}` + bVal = `${b.allele1 || ''}${b.allele2 || ''}` + break + } + + if (typeof aVal === 'number' && typeof bVal === 'number') { + return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal + } + + const strA = String(aVal) + const strB = String(bVal) + return geneSortOrder === 'asc' + ? strA.localeCompare(strB) + : strB.localeCompare(strA) + }) + + const displayData = sortedData.length > 0 + ? sortedData.slice(0, 50) + : Array(10).fill(null) + + return ( + <> + {/* 데스크톱: 테이블 */} + + +
+
SNP 이름염색체 위치PositionSNP 구분첫번째 대립유전자두번째 대립유전자설명
+ + + + + + + + + + + + + {displayData.map((gene, idx) => { + if (!gene) { + return ( + + + + + + + + + + ) + } return ( - - - - - - - + + + + + + + ) - } - return ( - - - - - - - - - - ) - })} - -
SNP 이름염색체 위치PositionSNP 구분첫번째 대립유전자두번째 대립유전자설명
-------
-------{gene.snpName || '-'}{gene.chromosome || '-'}{gene.position || '-'}{gene.snpType || '-'}{gene.allele1 || '-'}{gene.allele2 || '-'}{gene.remarks || '-'}
{gene.snpName || '-'}{gene.chromosome || '-'}{gene.position || '-'}{gene.snpType || '-'}{gene.allele1 || '-'}{gene.allele2 || '-'}{gene.remarks || '-'}
-
+ })} + + +
+ {geneData.length > 50 && ( +
+ 전체 {geneData.length}개 중 50개 표시 +
+ )} + + + + {/* 모바일: 카드 뷰 */} +
+ {displayData.map((gene, idx) => { + return ( + + +
+ SNP 이름 + {gene?.snpName || '-'} +
+
+ 염색체 위치 + {gene?.chromosome || '-'} +
+
+ Position + {gene?.position || '-'} +
+
+ SNP 구분 + {gene?.snpType || '-'} +
+
+ 첫번째 대립유전자 + {gene?.allele1 || '-'} +
+
+ 두번째 대립유전자 + {gene?.allele2 || '-'} +
+
+ 설명 + {gene?.remarks || '-'} +
+
+
+ ) + })} {geneData.length > 50 && ( -
+
전체 {geneData.length}개 중 50개 표시
)} - - +
+ + ) + })()} + + ) : ( + + + +

유전자 분석 데이터 없음

+

+ 이 개체는 아직 유전자(SNP) 분석이 완료되지 않았습니다. +

+
+
+ )} + - {/* 모바일: 카드 뷰 */} -
- {displayData.map((gene, idx) => { - return ( - - -
- SNP 이름 - {gene?.snpName || '-'} -
-
- 염색체 위치 - {gene?.chromosome || '-'} -
-
- Position - {gene?.position || '-'} -
-
- SNP 구분 - {gene?.snpType || '-'} -
-
- 첫번째 대립유전자 - {gene?.allele1 || '-'} -
-
- 두번째 대립유전자 - {gene?.allele2 || '-'} -
-
- 설명 - {gene?.remarks || '-'} -
-
-
- ) - })} - {geneData.length > 50 && ( -
- 전체 {geneData.length}개 중 50개 표시 -
- )} -
- - ) - })()} - - ) : ( - - - -

유전자 분석 데이터 없음

-

- 이 개체는 아직 유전자(SNP) 분석이 완료되지 않았습니다. -

-
-
- )} - + {/* 번식능력 탭 */} + + {hasReproductionData ? ( +
+ {/* TODO: 번식능력 분석 결과 표시 */} +

번식능력 분석 결과

+
+ ) : ( + + + +

번식능력 분석 데이터 없음

+

+ 이 개체는 아직 번식능력 분석이 완료되지 않았습니다. +

+
+
+ )} +
+ +
+
+
- {/* 번식능력 탭 */} - - {hasReproductionData ? ( -
- {/* TODO: 번식능력 분석 결과 표시 */} -

번식능력 분석 결과

-
- ) : ( - - - -

번식능력 분석 데이터 없음

-

- 이 개체는 아직 번식능력 분석이 완료되지 않았습니다. -

-
-
- )} -
- -
- - - - {/* 차트 전체화면 모달 */} - setIsChartModalOpen(open ?? false)}> - - {/* 모달 헤더 */} -
- 개체 분포 위치 - -
- {/* 모달 콘텐츠 - 차트 */} -
- { }} - onToggleTraitSelection={toggleTraitSelection} - onClearSelectedTraits={() => setSelectedTraits([])} - onOpenChartModal={() => { }} - distributionData={distributionData} - totalCowCount={totalCowCount} - traitComparisons={traitComparisons} - cowEpd={cowAvgEpd} - farmAvgEpd={farmAvgEpdValue} - regionAvgEpd={regionAvgEpdValue} - farmRank={selectionIndex?.farmRank} - farmTotal={selectionIndex?.farmTotal} - regionRank={selectionIndex?.regionRank} - regionTotal={selectionIndex?.regionTotal} - highlightMode={highlightMode} - onHighlightModeChange={setHighlightMode} - chartFilterTrait={chartFilterTrait} - onChartFilterTraitChange={setChartFilterTrait} - /> -
-
-
- + {/* 차트 전체화면 모달 */} + setIsChartModalOpen(open ?? false)}> + + {/* 모달 헤더 */} +
+ 개체 분포 위치 + +
+ {/* 모달 콘텐츠 - 차트 */} +
+ { }} + onToggleTraitSelection={toggleTraitSelection} + onClearSelectedTraits={() => setSelectedTraits([])} + onOpenChartModal={() => { }} + distributionData={distributionData} + totalCowCount={totalCowCount} + traitComparisons={traitComparisons} + cowEpd={cowAvgEpd} + farmAvgEpd={farmAvgEpdValue} + regionAvgEpd={regionAvgEpdValue} + farmRank={selectionIndex?.farmRank} + farmTotal={selectionIndex?.farmTotal} + regionRank={selectionIndex?.regionRank} + regionTotal={selectionIndex?.regionTotal} + highlightMode={highlightMode} + onHighlightModeChange={setHighlightMode} + chartFilterTrait={chartFilterTrait} + onChartFilterTraitChange={setChartFilterTrait} + /> +
+
+
+ ) } diff --git a/frontend/src/app/cow/page.tsx b/frontend/src/app/cow/page.tsx index 1c9837c..dbb4bf9 100644 --- a/frontend/src/app/cow/page.tsx +++ b/frontend/src/app/cow/page.tsx @@ -192,7 +192,7 @@ function MyCowContent() { .filter(([, weight]) => weight > 0) .map(([traitNm, weight]) => ({ traitNm, - weight: weight / 100 // 0-100 → 0-1로 정규화 + weight // 0-10 가중치 그대로 사용 })) // 랭킹 모드에 따라 criteriaType 결정 @@ -987,8 +987,12 @@ function MyCowContent() { month: '2-digit', day: '2-digit' }) : ( - - {cow.unavailableReason || '미분석'} + + {cow.unavailableReason || '분석불가'} )} @@ -1190,12 +1194,16 @@ function MyCowContent() { {cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
-
- 분석일 +
+ {cow.anlysDt ? '분석일' : '분석결과'} {cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : ( - - {cow.unavailableReason || '미분석'} + + {cow.unavailableReason || '분석불가'} )} diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 9883086..1efbbda 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -130,6 +130,7 @@ export default function DashboardPage() { useEffect(() => { const fetchFarm = async () => { + setLoading(true) try { const userId = user?.pkUserNo const farms: any[] = userId @@ -148,21 +149,20 @@ export default function DashboardPage() { if (user) { fetchFarm() - } else { - setLoading(false) } + // user가 없으면 loading 상태 유지 (AuthGuard에서 처리) }, [user]) useEffect(() => { const fetchStats = async () => { if (!farmNo) return - // 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 - const traitConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0 - ? filters.selectedTraits.map(traitNm => ({ - traitNm, - weight: (filters.traitWeights as Record)[traitNm] || 1 - })) - : undefined + // 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 + const traitConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0 + ? filters.selectedTraits.map(traitNm => ({ + traitNm, + weight: (filters.traitWeights as Record)[traitNm] || 1 + })) + : undefined try { const [statsData, rankingData] = await Promise.all([ genomeApi.getDashboardStats(farmNo), @@ -259,6 +259,8 @@ export default function DashboardPage() { xAxisRange: { min: -2.5, max: 2.5 }, farmScore: 0, regionScore: 0, + originalFarmScore: 0, + originalRegionScore: 0, label: '', rank: null as number | null, totalFarms: 0, @@ -267,6 +269,8 @@ export default function DashboardPage() { let farmScore = 0 let regionScore = 0 + let originalFarmScore = 0 + let originalRegionScore = 0 let label = '전체 선발지수' let rank: number | null = null let totalFarms = farmRanking.totalFarmsInRegion || 0 @@ -279,6 +283,8 @@ export default function DashboardPage() { const rawRegionScore = farmRanking.regionAvgScore ?? 0 farmScore = rawFarmScore - rawRegionScore // 보은군 대비 차이 regionScore = 0 // 보은군 = 기준점 + originalFarmScore = rawFarmScore + originalRegionScore = rawRegionScore label = '전체 선발지수' rank = farmRanking.farmRankInRegion percentile = farmRanking.percentile @@ -290,6 +296,8 @@ export default function DashboardPage() { const regionEpd = traitData.regionAvgEpd ?? 0 farmScore = farmEpd - regionEpd // 보은군 대비 차이 regionScore = 0 // 보은군 = 기준점 (0) + originalFarmScore = farmEpd + originalRegionScore = regionEpd label = distributionBasis rank = traitData.rank ?? null totalFarms = traitData.totalFarms ?? farmRanking.totalFarmsInRegion ?? 0 @@ -359,6 +367,8 @@ export default function DashboardPage() { xAxisRange: { min: -range, max: range }, farmScore, regionScore, + originalFarmScore, + originalRegionScore, label, rank, totalFarms, @@ -368,164 +378,482 @@ export default function DashboardPage() { return ( - - - - -
- {loading ? ( -
-
-
-

데이터를 불러오는 중...

+ + + + +
+ {loading ? ( +
+
+
+

데이터를 불러오는 중...

+
-
- ) : !farmNo ? ( -
-
- -

농장 정보가 없습니다

-

관리자에게 문의해주세요

+ ) : !farmNo ? ( +
+
+ +

농장 정보가 없습니다

+

관리자에게 문의해주세요

+
-
- ) : ( - <> - {/* ========== 1. 핵심 KPI 카드 (2개) ========== */} -
- {/* 총 분석 두수 + 암/수 */} -
router.push('/cow')} - > -
-

유전체 총 분석

- - 분석일자{' '} - {(() => { - const latestRequest = stats?.requestHistory - ?.filter(r => r.requestDt) - ?.sort((a, b) => new Date(b.requestDt!).getTime() - new Date(a.requestDt!).getTime())[0] - if (latestRequest?.requestDt) { - return new Date(latestRequest.requestDt).toLocaleDateString('ko-KR', { year: 'numeric', month: 'numeric', day: 'numeric' }) - } - return '-' - })()} - -
-

- {stats?.summary.totalRequests || 0} - -

-
-
- ♂ 수 - {stats?.summary.maleCount || 0} + ) : ( + <> + {/* ========== 1. 핵심 KPI 카드 (2개) ========== */} +
+ {/* 총 분석 두수 + 암/수 */} +
router.push('/cow')} + > +
+

유전체 총 분석

+ + 분석일자{' '} + {(() => { + const latestRequest = stats?.requestHistory + ?.filter(r => r.requestDt) + ?.sort((a, b) => new Date(b.requestDt!).getTime() - new Date(a.requestDt!).getTime())[0] + if (latestRequest?.requestDt) { + return new Date(latestRequest.requestDt).toLocaleDateString('ko-KR', { year: 'numeric', month: 'numeric', day: 'numeric' }) + } + return '-' + })()} +
-
- ♀ 암 - {stats?.summary.femaleCount || 0} +

+ {stats?.summary.totalRequests || 0} + +

+
+
+ ♂ 수 + {stats?.summary.maleCount || 0} +
+
+ ♀ 암 + {stats?.summary.femaleCount || 0} +
+
+
+ + {/* 친자감별 결과 (넓게) */} +
+

유전체 친자감별 결과

+
+ {/* 도넛 차트 */} +
+ + + d.value > 0)} + cx="50%" + cy="50%" + innerRadius={32} + outerRadius={48} + dataKey="value" + stroke="none" + paddingAngle={2} + > + {[ + { name: '분석 완료', value: stats?.paternityStats?.analysisComplete || 0, color: '#1F3A8F' }, + { name: '부 불일치', value: stats?.paternityStats?.sireMismatch || 0, color: '#ef4444' }, + { name: '모 불일치', value: stats?.paternityStats?.damMismatch || 0, color: '#f97316' }, + { name: '모 이력제부재', value: stats?.paternityStats?.damNoRecord || 0, color: '#eab308' }, + ].filter(d => d.value > 0).map((entry, index) => ( + + ))} + + [`${value}건`, name]} + contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }} + /> + + + {/* 중앙 총합 표시 */} +
+ + {(stats?.paternityStats?.analysisComplete || 0) + + (stats?.paternityStats?.sireMismatch || 0) + + (stats?.paternityStats?.damMismatch || 0) + + (stats?.paternityStats?.damNoRecord || 0)} + +
+
+ {/* 범례 - 가로 배치 */} +
+
+ + + 분석 완료 + + {stats?.paternityStats?.analysisComplete || 0} +
+
+ + + 부 불일치 + + {stats?.paternityStats?.sireMismatch || 0} +
+
+ + + 모 불일치 + + {stats?.paternityStats?.damMismatch || 0} +
+
+ + + 모 이력제부재 + + {stats?.paternityStats?.damNoRecord || 0} +
+
- {/* 친자감별 결과 (넓게) */} -
-

유전체 친자감별 결과

-
- {/* 도넛 차트 */} -
- - - d.value > 0)} - cx="50%" - cy="50%" - innerRadius={32} - outerRadius={48} - dataKey="value" - stroke="none" - paddingAngle={2} - > - {[ - { name: '분석 완료', value: stats?.paternityStats?.analysisComplete || 0, color: '#1F3A8F' }, - { name: '부 불일치', value: stats?.paternityStats?.sireMismatch || 0, color: '#ef4444' }, - { name: '모 불일치', value: stats?.paternityStats?.damMismatch || 0, color: '#f97316' }, - { name: '모 이력제부재', value: stats?.paternityStats?.damNoRecord || 0, color: '#eab308' }, - ].filter(d => d.value > 0).map((entry, index) => ( - + {/* ========== 2. 좌측(농가위치+순위) + 우측(연도별 육종가) - 높이 맞춤 ========== */} +
+ {/* 좌측 영역: 보은군 내 농가 위치 + 순위 통합 */} +
+
+
+

보은군 내 농가 위치

+ +
+
+ 우리농가 + 보은군
- {/* 범례 - 가로 배치 */} -
-
- - - 분석 완료 - - {stats?.paternityStats?.analysisComplete || 0} -
-
- - - 부 불일치 - - {stats?.paternityStats?.sireMismatch || 0} -
-
- - - 모 불일치 - - {stats?.paternityStats?.damMismatch || 0} -
-
- - - 모 이력제부재 - - {stats?.paternityStats?.damNoRecord || 0} -
-
-
-
-
+ {farmRanking && (farmRanking.farmAvgScore !== null || distributionBasis !== 'overall') ? ( +
+ + + + + + + + + {/* 녹색 화살표 - 좋을 때: 오른쪽 방향 */} + + + + {/* 빨간색 화살표 - 나쁠 때: 왼쪽 방향 */} + + + + + + { + const ticks: number[] = [] + for (let t = farmPositionData.xAxisRange.min; t <= farmPositionData.xAxisRange.max; t += 1) { + ticks.push(Math.round(t * 10) / 10) + } + return ticks + })()} + tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} + tickFormatter={(value) => value === 0 ? '평균' : value > 0 ? `+${value}` : `${value}`} + axisLine={{ stroke: '#cbd5e1', strokeWidth: 2 }} + tickLine={false} + /> + `${value}%`} + axisLine={false} + tickLine={false} + width={32} + /> + + {/* 보은군 평균 */} + + {/* 우리 농가 위치 */} + + {/* 커스텀 라벨 */} + { + const { xAxisMap, yAxisMap } = props + if (!xAxisMap || !yAxisMap) return null + const xAxis = Object.values(xAxisMap)[0] as any + const yAxis = Object.values(yAxisMap)[0] as any + if (!xAxis || !yAxis) return null - {/* ========== 2. 좌측(농가위치+순위) + 우측(연도별 육종가) - 높이 맞춤 ========== */} -
- {/* 좌측 영역: 보은군 내 농가 위치 + 순위 통합 */} -
-
-
-

보은군 내 농가 위치

- + + - {filters.isActive && ( - 전체 선발지수 - )} {Object.entries(TRAIT_CATEGORIES).map(([category, traits]) => (
{category}
@@ -539,800 +867,457 @@ export default function DashboardPage() {
-
- 우리농가 - 보은군 -
-
- {farmRanking && (farmRanking.farmAvgScore !== null || distributionBasis !== 'overall') ? ( -
- - - - - - - - - {/* 녹색 화살표 - 좋을 때: 오른쪽 방향 */} - - - - {/* 빨간색 화살표 - 나쁠 때: 왼쪽 방향 */} - - - - - - { - const ticks: number[] = [] - for (let t = farmPositionData.xAxisRange.min; t <= farmPositionData.xAxisRange.max; t += 1) { - ticks.push(Math.round(t * 10) / 10) - } - return ticks - })()} - tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} - tickFormatter={(value) => value === 0 ? '평균' : value > 0 ? `+${value}` : `${value}`} - axisLine={{ stroke: '#cbd5e1', strokeWidth: 2 }} - tickLine={false} - /> - `${value}%`} - axisLine={false} - tickLine={false} - width={32} - /> - - {/* 보은군 평균 */} - - {/* 우리 농가 위치 */} - - {/* 커스텀 라벨 */} - { - const { xAxisMap, yAxisMap } = props - if (!xAxisMap || !yAxisMap) return null - const xAxis = Object.values(xAxisMap)[0] as any - const yAxis = Object.values(yAxisMap)[0] as any - if (!xAxis || !yAxis) return null - - const chartX = xAxis.x - const chartWidth = xAxis.width - const chartTop = yAxis.y - const chartBottom = yAxis.y + yAxis.height - const [domainMin, domainMax] = xAxis.domain || [-2.5, 2.5] - const domainRange = domainMax - domainMin - - const sigmaToX = (sigma: number) => { - const ratio = (sigma - domainMin) / domainRange - return chartX + ratio * chartWidth - } - - const farmScore = farmPositionData.farmScore - const regionScore = farmPositionData.regionScore - const farmX = sigmaToX(farmScore) - const regionX = sigmaToX(regionScore) - const diff = farmScore - regionScore - - // 배지 크기 - const badgeWidth = 110 - const badgeHeight = 36 - - // 클램핑 - const clampedFarmX = Math.min(Math.max(farmX, chartX + badgeWidth / 2 + 5), chartX + chartWidth - badgeWidth / 2 - 5) - - return ( - - {/* 좋음/나쁨 표시 - 더 크게 */} - - 좋음 → - - - ← 나쁨 - - - {/* 우리 농가 배지 - 더 크게 */} - - - 우리농가 - - - {farmScore >= 0 ? '+' : ''}{farmScore.toFixed(2)} - - {/* 배지에서 라인으로 연결 */} - - - {/* 보은군 평균 라벨 */} - - - 보은군 {regionScore >= 0 ? '+' : ''}{regionScore.toFixed(2)} - - - {/* 차이 화살표 또는 동일 표시 */} - {Math.abs(diff) >= 0.01 ? ( - - {(() => { - const arrowY = chartTop + 60 - const color = diff >= 0 ? '#16a34a' : '#dc2626' - const isMobileView = chartWidth < 300 - - // 화살표 머리 크기 (모바일/데스크탑) - const arrowBaseOffset = isMobileView ? 10 : 16 - const arrowTipOffset = isMobileView ? 10 : 16 - const arrowHeight = isMobileView ? 8 : 12 - - // 막대선 최소 길이 (모바일에서도 꼬리가 보이도록) - const minLineLength = isMobileView ? 25 : 30 - const actualLineLength = Math.abs(farmX - regionX) - const lineStartX = diff >= 0 - ? (actualLineLength < minLineLength ? farmX - minLineLength : regionX + 5) - : (actualLineLength < minLineLength ? farmX + minLineLength : regionX - 5) - - // 화살표 머리 시작점 (막대기 끝점) - const arrowHeadStart = diff >= 0 - ? farmX - arrowBaseOffset - arrowTipOffset - : farmX + arrowBaseOffset + arrowTipOffset - - return ( - - {/* 막대선: 화살표 머리 시작점까지만 */} - - {/* 화살표 머리 (농가선에 딱 맞게) */} - = 0 - ? `${arrowHeadStart},${arrowY - arrowHeight} ${arrowHeadStart},${arrowY + arrowHeight} ${farmX},${arrowY}` - : `${arrowHeadStart},${arrowY - arrowHeight} ${arrowHeadStart},${arrowY + arrowHeight} ${farmX},${arrowY}` - } - fill={color} - /> - - ) - })()} - {/* 차이값 배지 */} - = 0 ? '#16a34a' : '#dc2626'} - filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))" - /> - - {diff >= 0 ? '+' : ''}{diff.toFixed(2)} - - - ) : ( - - {/* 동일 표시 (diff가 0에 가까울 때) */} - - - = 동일 - - - )} - - ) - }} - /> - - -
- ) : ( -
-
- -

농가 데이터 없음

+ {/* 차트 */} + {traitTrendLoading ? ( +
+
+ ) : ( + (() => { + // 먼저 실제 데이터만으로 chartData 구성 + const rawChartData = last3Years.map(year => { + const yearData = traitTrendData?.yearlyData.find(d => d.year === year) + const hasData = yearData && (yearData.farmCount > 0 || yearData.regionCount > 0) + return { + year: `${year}`, + 농가: hasData ? yearData.farmAvgEbv : 0, + 보은군: hasData ? yearData.regionAvgEbv : 0, + hasData, + } + }) + // 실제 데이터의 최대 절대값을 기준으로 최소 막대 높이 계산 (약 0.5% - 아주 살짝만) + const validValues = rawChartData.filter(d => d.hasData).flatMap(d => [d.농가, d.보은군]) + const maxAbsValue = validValues.length > 0 ? Math.max(...validValues.map(Math.abs), 0.5) : 1 + const minBarValue = maxAbsValue * 0.005 + // 데이터 없는 항목에 최소값 적용 + const chartData = rawChartData.map(d => ({ + ...d, + 농가: d.hasData ? d.농가 : minBarValue, + 보은군: d.hasData ? d.보은군 : minBarValue, + })) + // 모바일/웹 차트 설정 + const labelFontSize = isMobile ? 12 : 16 + const barMaxSize = isMobile ? 50 : 70 // 막대 너비 설정 + const chartHeight = isMobile ? 300 : 350 // 막대 높이 설정 + const chartMarginBottom = isMobile ? 40 : 70 + return ( + + + + + + + { + if (!props.payload.hasData) return ['데이터 없음', name] + return [`${value > 0 ? '+' : ''}${value.toFixed(2)}`, name] + }} + contentStyle={{ borderRadius: '10px', border: '1px solid #e2e8f0', fontSize: '14px', fontWeight: 600 }} + /> + { + const item = chartData[index] + if (!item.hasData) return - + const v = item.농가 + // 양수: 막대 위에, 음수: 막대 바로 아래 + const labelY = height < 0 ? y + 20 : y - 14 + return {(v > 0 ? '+' : '') + v.toFixed(2)} + }} + > + {chartData.map((entry, index) => ( + + ))} + + { + const item = chartData[index] + if (!item.hasData) return - + const v = item.보은군 + // 양수: 막대 위에, 음수: 막대 바로 아래 + const labelY = height < 0 ? y + 20 : y - 14 + return {(v > 0 ? '+' : '') + v.toFixed(2)} + }} + > + {chartData.map((entry, index) => ( + + ))} + + + + ) + })() + )} + {/* 농가 순위 정보 */} +
+ {selectedTrait} 보은군 내 순위 + {traitTrendData?.farmRank?.rank !== null && traitTrendData?.farmRank?.rank !== undefined ? ( + + 상위 {traitTrendData.farmRank.percentile}% + + ({traitTrendData.farmRank.rank}위/{traitTrendData.farmRank.totalFarms}개 농가) + + + ) : ( + 데이터 없음 + )}
- )} + {/* 전년대비 / 보은군대비 / 3년추세 카드 */} + {(() => { + const thisYearData = traitTrendData?.yearlyData.find(d => d.year === currentYear) + const lastYearData = traitTrendData?.yearlyData.find(d => d.year === lastYear) + const threeYearsAgoData = traitTrendData?.yearlyData.find(d => d.year === currentYear - 2) - {/* 순위 정보 (차트 하단에 통합) - 드롭다운 선택에 따라 연동 */} - {(farmPositionData.rank !== null || farmRanking?.farmAvgScore !== null) && ( -
- {/* 현재 선택된 기준 표시 */} -

- {farmPositionData.label} 기준 -

- {/* 모바일/웹 모두 3열 */} -
- {/* 순위 카드 */} -
-

순위

-

- {farmPositionData.rank ?? '-'} -

-

/{farmPositionData.totalFarms}개 농가

-
- {/* 백분율 카드 */} -
-

상위

-

- {farmPositionData.percentile ?? '-'}% -

-

보은군 내

-
- {/* 점수 비교 카드 */} -
= 0 - ? 'border-emerald-200 bg-emerald-50/30' - : 'border-red-200 bg-red-50/30' - }`}> -

평균대비

-

= 0 ? 'text-emerald-600' : 'text-red-500'}`}> - {farmPositionData.farmScore > 0 ? '+' : ''}{farmPositionData.farmScore.toFixed(2)} -

-

= 0 ? 'text-emerald-500' : 'text-red-400'}`}> - {farmPositionData.farmScore >= 0 ? ( - <>높음 + // 전년대비 변화 (농가) + const yearOverYear = thisYearData && lastYearData && thisYearData.farmCount > 0 && lastYearData.farmCount > 0 + ? thisYearData.farmAvgEbv - lastYearData.farmAvgEbv + : null + + // 보은군 대비 (올해 기준) + const vsRegion = thisYearData && thisYearData.farmCount > 0 + ? thisYearData.farmAvgEbv - thisYearData.regionAvgEbv + : null + + // 3년 추세 계산 + let trend: '상승' | '유지' | '하락' | null = null + if (threeYearsAgoData && thisYearData && threeYearsAgoData.farmCount > 0 && thisYearData.farmCount > 0) { + const diff = thisYearData.farmAvgEbv - threeYearsAgoData.farmAvgEbv + if (diff > 0.1) trend = '상승' + else if (diff < -0.1) trend = '하락' + else trend = '유지' + } + + return ( +

+ {/* 전년대비 */} +
= 0 ? 'border-blue-200 bg-blue-50/30' : 'border-red-200 bg-red-50/30') : 'border-slate-200 bg-slate-50/30'}`}> +

전년대비

+ {yearOverYear !== null ? ( +

= 0 ? 'text-[#1F3A8F]' : 'text-red-500'}`}> + {yearOverYear >= 0 ? '+' : ''}{yearOverYear.toFixed(2)} + {yearOverYear >= 0 ? : } +

) : ( - <>낮음 +

-

)} -

+
+ {/* 보은군대비 */} +
= 0 ? 'border-emerald-200 bg-emerald-50/30' : 'border-amber-200 bg-amber-50/30') : 'border-slate-200 bg-slate-50/30'}`}> +

보은군대비

+ {vsRegion !== null ? ( +

= 0 ? 'text-emerald-600' : 'text-amber-600'}`}> + {vsRegion >= 0 ? '+' : ''}{vsRegion.toFixed(2)} +

+ ) : ( +

-

+ )} +
+ {/* 3년 추세 */} +
+

3년 추세

+ {trend !== null ? ( +

+ {trend} + {trend === '상승' && } + {trend === '하락' && } +

+ ) : ( +

-

+ )} +
-
-
- )} + ) + })()} +
- {/* 우측 영역: 연도별 육종가 추이 */} -
-
-

연도별 육종가 추이

-
- 우리농가 - 보은군 -
-
- {/* 형질 선택 드롭다운 */} -
- -
- {/* 차트 */} - {traitTrendLoading ? ( -
-
-
- ) : ( + {/* ========== 3. 카테고리별 보은군 대비 비교 - 전체 너비 ========== */} +
+

보은군 대비 카테고리별 육종가 평균

+ {stats?.traitAverages && stats.traitAverages.length > 0 ? ( (() => { - // 먼저 실제 데이터만으로 chartData 구성 - const rawChartData = last3Years.map(year => { - const yearData = traitTrendData?.yearlyData.find(d => d.year === year) - const hasData = yearData && (yearData.farmCount > 0 || yearData.regionCount > 0) + const categories = ['성장', '생산', '체형', '무게', '비율'] + const categoryData = categories.map(cat => { + const traits = stats.traitAverages.filter(t => t.category === cat) + const avgEpd = traits.length > 0 + ? traits.reduce((sum, t) => sum + (t.avgEpd ?? 0), 0) / traits.length + : 0 + const avgPercentile = traits.length > 0 + ? traits.reduce((sum, t) => sum + (t.percentile ?? 50), 0) / traits.length + : 50 return { - year: `${year}`, - 농가: hasData ? yearData.farmAvgEbv : 0, - 보은군: hasData ? yearData.regionAvgEbv : 0, - hasData, + category: cat, + avgEpd: Math.round(avgEpd * 100) / 100, + avgPercentile: Math.round(avgPercentile), + traitCount: traits.length } }) - // 실제 데이터의 최대 절대값을 기준으로 최소 막대 높이 계산 (약 0.5% - 아주 살짝만) - const validValues = rawChartData.filter(d => d.hasData).flatMap(d => [d.농가, d.보은군]) - const maxAbsValue = validValues.length > 0 ? Math.max(...validValues.map(Math.abs), 0.5) : 1 - const minBarValue = maxAbsValue * 0.005 - // 데이터 없는 항목에 최소값 적용 - const chartData = rawChartData.map(d => ({ - ...d, - 농가: d.hasData ? d.농가 : minBarValue, - 보은군: d.hasData ? d.보은군 : minBarValue, - })) - // 모바일/웹 차트 설정 - const labelFontSize = isMobile ? 12 : 16 - const barMaxSize = isMobile ? 50 : 70 // 막대 너비 설정 - const chartHeight = isMobile ? 300 : 350 // 막대 높이 설정 - const chartMarginBottom = isMobile ? 40 : 70 - return ( - - - - - - - { - if (!props.payload.hasData) return ['데이터 없음', name] - return [`${value > 0 ? '+' : ''}${value.toFixed(2)}`, name] - }} - contentStyle={{ borderRadius: '10px', border: '1px solid #e2e8f0', fontSize: '14px', fontWeight: 600 }} - /> - { - const item = chartData[index] - if (!item.hasData) return - - const v = item.농가 - // 양수: 막대 위에, 음수: 막대 바로 아래 - const labelY = height < 0 ? y + 20 : y - 14 - return {(v > 0 ? '+' : '') + v.toFixed(2)} - }} - > - {chartData.map((entry, index) => ( - - ))} - - { - const item = chartData[index] - if (!item.hasData) return - - const v = item.보은군 - // 양수: 막대 위에, 음수: 막대 바로 아래 - const labelY = height < 0 ? y + 20 : y - 14 - return {(v > 0 ? '+' : '') + v.toFixed(2)} - }} - > - {chartData.map((entry, index) => ( - - ))} - - - - ) - })() - )} - {/* 농가 순위 정보 */} -
- {selectedTrait} 보은군 내 순위 - {traitTrendData?.farmRank?.rank !== null && traitTrendData?.farmRank?.rank !== undefined ? ( - - 상위 {traitTrendData.farmRank.percentile}% - - ({traitTrendData.farmRank.rank}위/{traitTrendData.farmRank.totalFarms}개 농가) - - - ) : ( - 데이터 없음 - )} -
- {/* 전년대비 / 보은군대비 / 3년추세 카드 */} - {(() => { - const thisYearData = traitTrendData?.yearlyData.find(d => d.year === currentYear) - const lastYearData = traitTrendData?.yearlyData.find(d => d.year === lastYear) - const threeYearsAgoData = traitTrendData?.yearlyData.find(d => d.year === currentYear - 2) + const maxAbs = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 1) - // 전년대비 변화 (농가) - const yearOverYear = thisYearData && lastYearData && thisYearData.farmCount > 0 && lastYearData.farmCount > 0 - ? thisYearData.farmAvgEbv - lastYearData.farmAvgEbv - : null + // 레이더 차트용 설정 + const centerX = 140 + const centerY = 150 + const maxRadius = 95 + const angleStep = (2 * Math.PI) / categories.length + const startAngle = -Math.PI / 2 - // 보은군 대비 (올해 기준) - const vsRegion = thisYearData && thisYearData.farmCount > 0 - ? thisYearData.farmAvgEbv - thisYearData.regionAvgEbv - : null + // 농가 데이터 다각형 좌표 (EPD 기반) + // 육종가 범위에 맞게 스케일 조정 + const epdScale = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 10) + const farmPoints = categoryData.map((d, i) => { + const angle = startAngle + i * angleStep + // 보은군 평균(0)을 50%로, ±epdScale를 0%/100%로 매핑 + const normalizedValue = Math.min(100, Math.max(0, (d.avgEpd / epdScale + 1) / 2 * 100)) + const radius = (normalizedValue / 100) * maxRadius + return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) } + }) - // 3년 추세 계산 - let trend: '상승' | '유지' | '하락' | null = null - if (threeYearsAgoData && thisYearData && threeYearsAgoData.farmCount > 0 && thisYearData.farmCount > 0) { - const diff = thisYearData.farmAvgEbv - threeYearsAgoData.farmAvgEbv - if (diff > 0.1) trend = '상승' - else if (diff < -0.1) trend = '하락' - else trend = '유지' - } + // 보은군 평균 (50% 지점 = EBV 0 기준) + const regionPoints = categories.map((_, i) => { + const angle = startAngle + i * angleStep + const radius = 0.5 * maxRadius + return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) } + }) - return ( -
- {/* 전년대비 */} -
= 0 ? 'border-blue-200 bg-blue-50/30' : 'border-red-200 bg-red-50/30') : 'border-slate-200 bg-slate-50/30'}`}> -

전년대비

- {yearOverYear !== null ? ( -

= 0 ? 'text-[#1F3A8F]' : 'text-red-500'}`}> - {yearOverYear >= 0 ? '+' : ''}{yearOverYear.toFixed(2)} - {yearOverYear >= 0 ? : } -

- ) : ( -

-

- )} -
- {/* 보은군대비 */} -
= 0 ? 'border-emerald-200 bg-emerald-50/30' : 'border-amber-200 bg-amber-50/30') : 'border-slate-200 bg-slate-50/30'}`}> -

보은군대비

- {vsRegion !== null ? ( -

= 0 ? 'text-emerald-600' : 'text-amber-600'}`}> - {vsRegion >= 0 ? '+' : ''}{vsRegion.toFixed(2)} -

- ) : ( -

-

- )} -
- {/* 3년 추세 */} -
-

3년 추세

- {trend !== null ? ( -

- {trend} - {trend === '상승' && } - {trend === '하락' && } -

- ) : ( -

-

- )} -
-
- ) - })()} -
-
+ const farmPolygon = farmPoints.map(p => `${p.x},${p.y}`).join(' ') + const regionPolygon = regionPoints.map(p => `${p.x},${p.y}`).join(' ') - {/* ========== 3. 카테고리별 보은군 대비 비교 - 전체 너비 ========== */} -
-

보은군 대비 카테고리별 육종가 평균

- {stats?.traitAverages && stats.traitAverages.length > 0 ? ( - (() => { - const categories = ['성장', '생산', '체형', '무게', '비율'] - const categoryData = categories.map(cat => { - const traits = stats.traitAverages.filter(t => t.category === cat) - const avgEpd = traits.length > 0 - ? traits.reduce((sum, t) => sum + (t.avgEpd ?? 0), 0) / traits.length - : 0 - const avgPercentile = traits.length > 0 - ? traits.reduce((sum, t) => sum + (t.percentile ?? 50), 0) / traits.length - : 50 - return { - category: cat, - avgEpd: Math.round(avgEpd * 100) / 100, - avgPercentile: Math.round(avgPercentile), - traitCount: traits.length - } - }) - const maxAbs = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 1) + // 호버된 포인트 인덱스를 위한 로컬 컴포넌트 + const RadarChart = () => { + const [hoveredIndex, setHoveredIndex] = useState(null) + const [clickedIndex, setClickedIndex] = useState(null) - // 레이더 차트용 설정 - const centerX = 140 - const centerY = 140 - const maxRadius = 105 - const angleStep = (2 * Math.PI) / categories.length - const startAngle = -Math.PI / 2 + // 실제 표시할 인덱스 (클릭된 것 우선, 없으면 호버된 것) + const activeIndex = clickedIndex !== null ? clickedIndex : hoveredIndex - // 농가 데이터 다각형 좌표 (EPD 기반) - // 육종가 범위에 맞게 스케일 조정 - const epdScale = Math.max(...categoryData.map(d => Math.abs(d.avgEpd)), 10) - const farmPoints = categoryData.map((d, i) => { - const angle = startAngle + i * angleStep - // 보은군 평균(0)을 50%로, ±epdScale를 0%/100%로 매핑 - const normalizedValue = Math.min(100, Math.max(0, (d.avgEpd / epdScale + 1) / 2 * 100)) - const radius = (normalizedValue / 100) * maxRadius - return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) } - }) - - // 보은군 평균 (50% 지점 = EBV 0 기준) - const regionPoints = categories.map((_, i) => { - const angle = startAngle + i * angleStep - const radius = 0.5 * maxRadius - return { x: centerX + radius * Math.cos(angle), y: centerY + radius * Math.sin(angle) } - }) - - const farmPolygon = farmPoints.map(p => `${p.x},${p.y}`).join(' ') - const regionPolygon = regionPoints.map(p => `${p.x},${p.y}`).join(' ') - - // 호버된 포인트 인덱스를 위한 로컬 컴포넌트 - const RadarChart = () => { - const [hoveredIndex, setHoveredIndex] = useState(null) - const [clickedIndex, setClickedIndex] = useState(null) - - // 실제 표시할 인덱스 (클릭된 것 우선, 없으면 호버된 것) - const activeIndex = clickedIndex !== null ? clickedIndex : hoveredIndex - - // 클릭/터치 핸들러: 토글 방식 - const handleClick = (e: React.MouseEvent | React.TouchEvent, index: number) => { - e.preventDefault() - e.stopPropagation() - setClickedIndex(prev => prev === index ? null : index) - } - - // 호버 핸들러: 클릭된 상태가 아닐 때만 동작 - const handleMouseEnter = (index: number) => { - if (clickedIndex === null) { - setHoveredIndex(index) + // 클릭/터치 핸들러: 토글 방식 + const handleClick = (e: React.MouseEvent | React.TouchEvent, index: number) => { + e.preventDefault() + e.stopPropagation() + setClickedIndex(prev => prev === index ? null : index) } - } - const handleMouseLeave = () => { - if (clickedIndex === null) { - setHoveredIndex(null) + // 호버 핸들러: 클릭된 상태가 아닐 때만 동작 + const handleMouseEnter = (index: number) => { + if (clickedIndex === null) { + setHoveredIndex(index) + } } - } - return ( - - {/* 그리드 라인 */} - {[0.25, 0.5, 0.75, 1].map((level, idx) => ( - { - const angle = startAngle + i * angleStep - const r = level * maxRadius - return `${centerX + r * Math.cos(angle)},${centerY + r * Math.sin(angle)}` - }).join(' ')} - fill="none" - stroke={level === 0.5 ? '#94a3b8' : '#e2e8f0'} - strokeWidth={level === 0.5 ? 2 : 1} - strokeDasharray={level === 0.5 ? '4 2' : undefined} - /> - ))} - {/* 축 라인 */} - {categories.map((_, i) => { - const angle = startAngle + i * angleStep - return ( - { + if (clickedIndex === null) { + setHoveredIndex(null) + } + } + + return ( + + {/* 그리드 라인 */} + {[0.25, 0.5, 0.75, 1].map((level, idx) => ( + { + const angle = startAngle + i * angleStep + const r = level * maxRadius + return `${centerX + r * Math.cos(angle)},${centerY + r * Math.sin(angle)}` + }).join(' ')} + fill="none" + stroke={level === 0.5 ? '#94a3b8' : '#e2e8f0'} + strokeWidth={level === 0.5 ? 2 : 1} + strokeDasharray={level === 0.5 ? '4 2' : undefined} /> - ) - })} - {/* 보은군 평균 다각형 */} - - {/* 농가 데이터 다각형 */} - - {/* 농가 데이터 포인트 */} - {farmPoints.map((p, i) => ( - handleMouseEnter(i)} - onMouseLeave={handleMouseLeave} - onClick={(e) => handleClick(e, i)} - onTouchEnd={(e) => handleClick(e, i)} - > - = 0 ? '#1F3A8F' : '#ef4444'} - stroke="white" strokeWidth={1.5} - /> - {/* 호버/터치 영역 확대 */} - - - ))} - {/* 카테고리 라벨 (클릭/호버 가능) */} - {categories.map((cat, i) => { - const angle = startAngle + i * angleStep - const labelRadius = maxRadius + 22 - const labelX = centerX + labelRadius * Math.cos(angle) - const labelY = centerY + labelRadius * Math.sin(angle) - return ( - { + const angle = startAngle + i * angleStep + return ( + + ) + })} + {/* 보은군 평균 다각형 */} + + {/* 농가 데이터 다각형 */} + + {/* 농가 데이터 포인트 */} + {farmPoints.map((p, i) => ( + handleMouseEnter(i)} onMouseLeave={handleMouseLeave} onClick={(e) => handleClick(e, i)} onTouchEnd={(e) => handleClick(e, i)} > + = 0 ? '#1F3A8F' : '#ef4444'} + stroke="white" strokeWidth={1.5} + /> {/* 호버/터치 영역 확대 */} - - + + ))} + {/* 카테고리 라벨 (클릭/호버 가능) - 배지 스타일 */} + {categories.map((cat, i) => { + const angle = startAngle + i * angleStep + const labelRadius = maxRadius + 22 + const labelX = centerX + labelRadius * Math.cos(angle) + const labelY = centerY + labelRadius * Math.sin(angle) + const isActive = activeIndex === i + const textWidth = cat.length * 14 + 20 + const textHeight = 28 + return ( + handleMouseEnter(i)} + onMouseLeave={handleMouseLeave} + onClick={(e) => handleClick(e, i)} + onTouchEnd={(e) => handleClick(e, i)} > - {cat} - - - ) - })} - {/* 툴팁 - 맨 마지막에 렌더링하여 항상 위에 표시 */} - {activeIndex !== null && (() => { - const data = categoryData[activeIndex] - // 라벨 위치 계산 (카테고리 라벨 근처에 툴팁 표시) - const angle = startAngle + activeIndex * angleStep - const labelRadius = maxRadius + 22 - const labelX = centerX + labelRadius * Math.cos(angle) - const labelY = centerY + labelRadius * Math.sin(angle) - - // 툴팁 위치 조정 (라벨 기준으로 배치) - const tooltipWidth = 160 - const tooltipHeight = 130 - - // 카테고리별 툴팁 위치 최적화 - let tooltipX = labelX - let tooltipY = labelY - - // 성장 (위쪽) - 아래로 - if (activeIndex === 0) { - tooltipY = labelY + 20 - } - // 생산 (오른쪽 위) - 왼쪽 아래로 - else if (activeIndex === 1) { - tooltipX = labelX - 30 - tooltipY = labelY + 10 - } - // 체형 (오른쪽 아래) - 왼쪽 위로 - else if (activeIndex === 2) { - tooltipX = labelX - 40 - tooltipY = labelY - tooltipHeight + 20 - } - // 무게 (왼쪽 아래) - 오른쪽 위로 - else if (activeIndex === 3) { - tooltipX = labelX + 40 - tooltipY = labelY - tooltipHeight + 20 - } - // 비율 (왼쪽 위) - 오른쪽 아래로 - else if (activeIndex === 4) { - tooltipX = labelX + 30 - tooltipY = labelY + 10 - } - - return ( - - {/* 배경 */} - - {/* 카테고리명 + 형질 개수 */} - - {data.category} - - - {data.traitCount}개 형질 평균 - - {/* 구분선 */} - - {/* 보은군 대비 차이 */} - = 0 ? '#60a5fa' : '#f87171'}> - {data.avgEpd >= 0 ? '+' : ''}{data.avgEpd.toFixed(2)} - - - 보은군 대비 - - {/* 순위 표시 */} - - - 상위 {data.avgPercentile}% - - - ) - })()} - - ) - } - - return ( -
- {/* 레이더 차트 - 32% */} -
- -
- - {/* 우측: 상세 바 차트 - 68% */} -
- {categoryData.map(({ category, avgEpd, avgPercentile, traitCount }, index) => { - const isPositive = avgEpd >= 0 - // 바 너비: 육종가 스케일에 맞게 계산 - const barWidth = Math.min(Math.abs(avgEpd) / maxAbs * 45, 48) - const isFirst = index === 0 - return ( -
- {/* 모바일: 카테고리명 + 값을 한 줄에 */} -
- {category} -
- - {isPositive ? '+' : ''}{avgEpd.toFixed(2)} - - - 상위 - {avgPercentile}% - -
-
- {/* 모바일: 바 차트 전체 너비 */} -
-
-
-
- {/* 데스크톱: 한 줄 레이아웃 */} - {category} -
- {/* 보은군 평균 라벨 (첫 번째 바 위에만 표시) */} - {isFirst && ( - 보은군 평균 + {/* 선택 시 배지 배경 */} + {isActive && ( + )} - {/* 바 차트 */} -
+ {/* 호버/터치 영역 확대 */} + + + {cat} + + + ) + })} + {/* 툴팁 - 맨 마지막에 렌더링하여 항상 위에 표시 */} + {activeIndex !== null && (() => { + const data = categoryData[activeIndex] + // 라벨 위치 계산 (카테고리 라벨 근처에 툴팁 표시) + const angle = startAngle + activeIndex * angleStep + const labelRadius = maxRadius + 22 + const labelX = centerX + labelRadius * Math.cos(angle) + const labelY = centerY + labelRadius * Math.sin(angle) + + // 툴팁 위치 조정 (차트 밖으로 배치) + const tooltipWidth = 150 + const tooltipHeight = 120 + + // 툴팁 위치 - 차트 중앙에 고정 (어느 화면에서든 잘리지 않음) + const tooltipX = centerX + const tooltipY = centerY - tooltipHeight / 2 + + return ( + + {/* 배경 */} + + {/* 카테고리명 + 형질 개수 */} + + {data.category} + + + {data.traitCount}개 형질 평균 + + {/* 구분선 */} + + {/* 보은군 대비 차이 */} + = 0 ? '#60a5fa' : '#f87171'}> + {data.avgEpd >= 0 ? '+' : ''}{data.avgEpd.toFixed(2)} + + + 보은군 대비 + + {/* 순위 배지 */} + + + 상위 {data.avgPercentile}% + + + ) + })()} + + ) + } + + return ( +
+ {/* 레이더 차트 - 32% */} +
+ +
+ + {/* 우측: 상세 바 차트 - 68% */} +
+ {categoryData.map(({ category, avgEpd, avgPercentile, traitCount }, index) => { + const isPositive = avgEpd >= 0 + // 바 너비: 육종가 스케일에 맞게 계산 + const barWidth = Math.min(Math.abs(avgEpd) / maxAbs * 45, 48) + const isFirst = index === 0 + return ( +
+ {/* 모바일: 카테고리명 + 값을 한 줄에 */} +
+ {category} +
+ + {isPositive ? '+' : ''}{avgEpd.toFixed(2)} + + + 상위 + {avgPercentile}% + +
+
+ {/* 모바일: 바 차트 전체 너비 */} +
+ {/* 데스크톱: 한 줄 레이아웃 */} + {category} +
+ {/* 보은군 평균 라벨 (첫 번째 바 위에만 표시) */} + {isFirst && ( + 보은군 평균 + )} + {/* 바 차트 */} +
+
+
+
+
+ + {isPositive ? '+' : ''}{avgEpd.toFixed(2)} + + + 상위 + {avgPercentile}% +
- - {isPositive ? '+' : ''}{avgEpd.toFixed(2)} - - - 상위 - {avgPercentile}% - -
- ) - })} + ) + })} +
-
- ) - })() - ) : ( -
- 데이터 없음 + ) + })() + ) : ( +
+ 데이터 없음 +
+ )} + {/* 범례 */} +
+ 우리농가 + 보은군평균
- )} - {/* 범례 */} -
- 우리농가 - 보은군평균
-
- - )} -
- - + + )} +
+ + ) } diff --git a/frontend/src/components/common/cow-number-display.tsx b/frontend/src/components/common/cow-number-display.tsx index 3a5c5c0..b5f472e 100644 --- a/frontend/src/components/common/cow-number-display.tsx +++ b/frontend/src/components/common/cow-number-display.tsx @@ -45,7 +45,7 @@ export function CowNumberDisplay({ cowId, cowShortNo, className = '', variant = case 'highlight': return 'bg-primary/15 text-primary font-bold px-1.5 py-0.5 rounded' default: - return 'bg-blue-100 text-blue-700 font-bold px-1.5 py-0.5 rounded' + return 'bg-blue-100 text-primary font-bold px-1.5 py-0.5 rounded' } } diff --git a/frontend/src/lib/api/genome.api.ts b/frontend/src/lib/api/genome.api.ts index 7fec4b0..9aff58c 100644 --- a/frontend/src/lib/api/genome.api.ts +++ b/frontend/src/lib/api/genome.api.ts @@ -22,6 +22,7 @@ export interface TraitAverageDto { traitName: string; // 형질명 category: string; // 카테고리 avgEbv: number; // 평균 EBV (표준화 육종가) + avgEpd: number; // 평균 EPD (육종가 원본값) count: number; // 데이터 개수 } @@ -225,12 +226,15 @@ export interface YearlyTraitTrendDto { export interface TraitRankDto { traitName: string; cowEbv: number | null; + cowEpd: number | null; // 개체 육종가(EPD) farmRank: number | null; farmTotal: number; regionRank: number | null; regionTotal: number; farmAvgEbv: number | null; regionAvgEbv: number | null; + farmAvgEpd: number | null; // 농가 평균 육종가(EPD) + regionAvgEpd: number | null; // 보은군 평균 육종가(EPD) } /** diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts index 403934c..39fbe12 100644 --- a/frontend/src/lib/api/index.ts +++ b/frontend/src/lib/api/index.ts @@ -14,7 +14,7 @@ export { cowApi } from './cow.api'; export { dashboardApi } from './dashboard.api'; export { farmApi } from './farm.api'; export { geneApi, type GeneDetail, type GeneSummary } from './gene.api'; -export { genomeApi, type ComparisonAveragesDto, type CategoryAverageDto, type FarmTraitComparisonDto, type TraitComparisonAveragesDto, type TraitAverageDto } from './genome.api'; +export { genomeApi, type ComparisonAveragesDto, type CategoryAverageDto, type FarmTraitComparisonDto, type TraitComparisonAveragesDto, type TraitAverageDto, type GenomeRequestDto } from './genome.api'; export { reproApi } from './repro.api'; export { breedApi } from './breed.api';