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' && (
-
- )}
{/* 확대 버튼 */}