From 9e5ffb2c159f2a8ab66489ebebd4db7cf39c68a2 Mon Sep 17 00:00:00 2001 From: NYD Date: Wed, 7 Jan 2026 17:56:22 +0900 Subject: [PATCH] update_cow_list_detail_page --- backend/src/genome/genome.service.ts | 89 +++++- .../_components/normal-distribution-chart.tsx | 301 ++++++++++++++++-- frontend/src/app/cow/[cowNo]/page.tsx | 3 + frontend/src/app/cow/page.tsx | 10 +- frontend/src/lib/api/genome.api.ts | 2 + 5 files changed, 360 insertions(+), 45 deletions(-) diff --git a/backend/src/genome/genome.service.ts b/backend/src/genome/genome.service.ts index 29c23ea..1e7acf7 100644 --- a/backend/src/genome/genome.service.ts +++ b/backend/src/genome/genome.service.ts @@ -1044,6 +1044,7 @@ export class GenomeService { farmAvgScore: number | null; // 농가 평균 선발지수 regionAvgScore: number | null; // 보은군(지역) 평균 선발지수 details: { traitNm: string; ebv: number; weight: number; contribution: number }[]; + histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포 message?: string; }> { // Step 1: cowId로 개체 조회 @@ -1067,7 +1068,7 @@ export class GenomeService { farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, regionName: null, farmerName: null, farmAvgScore: null, regionAvgScore: null, - details: [], message: '유전체 분석 데이터 없음' + details: [], histogram: [], message: '유전체 분석 데이터 없음' }; } @@ -1082,7 +1083,7 @@ export class GenomeService { farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, regionName: null, farmerName: null, farmAvgScore: null, regionAvgScore: null, - details: [], message: '형질 데이터 없음' + details: [], histogram: [], message: '형질 데이터 없음' }; } @@ -1138,7 +1139,7 @@ export class GenomeService { // Step 7: 현재 개체의 농장/지역 정보 조회 let regionName: string | null = null; let farmerName: string | null = null; - let farmNo: number | null = latestRequest.fkFarmNo; + const farmNo: number | null = latestRequest.fkFarmNo; if (farmNo) { const farm = await this.farmRepository.findOne({ @@ -1162,12 +1163,13 @@ export class GenomeService { farmAvgScore: null, regionAvgScore: null, details, + histogram: [], message: '선택한 형질 중 일부 데이터가 없습니다', }; } // Step 9: 농가/지역 순위 및 평균 선발지수 계산 - const { farmRank, farmTotal, regionRank, regionTotal, farmAvgScore, regionAvgScore } = + const { farmRank, farmTotal, regionRank, regionTotal, farmAvgScore, regionAvgScore, histogram } = await this.calculateRanks(cowId, score, traitConditions, farmNo, regionName); return { @@ -1182,6 +1184,7 @@ export class GenomeService { farmAvgScore, regionAvgScore, details, + histogram, }; } @@ -1207,10 +1210,11 @@ export class GenomeService { regionTotal: number; farmAvgScore: number | null; // 농가 평균 선발지수 regionAvgScore: number | null; // 보은군(지역) 평균 선발지수 + histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포 }> { // 점수가 없으면 순위 계산 불가 if (currentScore === null) { - return { farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgScore: null, regionAvgScore: null }; + return { farmRank: null, farmTotal: 0, regionRank: null, regionTotal: 0, farmAvgScore: null, regionAvgScore: null, histogram: [] }; } // 모든 유전체 분석 의뢰 조회 (유전체 데이터가 있는 모든 개체) @@ -1297,7 +1301,7 @@ export class GenomeService { // 지역(보은군) 순위 및 평균 선발지수 계산 - 전체 유전체 데이터가 보은군이므로 필터링 없이 전체 사용 let regionRank: number | null = null; - let regionTotal = allScores.length; + const regionTotal = allScores.length; let regionAvgScore: number | null = null; const regionIndex = allScores.findIndex(s => s.cowId === currentCowId); @@ -1309,6 +1313,38 @@ export class GenomeService { regionAvgScore = Math.round((regionScoreSum / allScores.length) * 100) / 100; } + // 히스토그램 생성 (선발지수 실제 분포) + const histogram: { bin: number; count: number; farmCount: number }[] = []; + if (allScores.length > 0) { + // 최소/최대값 찾기 + const scores = allScores.map(s => s.score); + const minScore = Math.min(...scores); + const maxScore = Math.max(...scores); + const range = maxScore - minScore; + + // 구간 크기 결정 (전체 범위를 약 20-30개 구간으로 나눔) + const binSize = range > 0 ? Math.ceil(range / 25) : 1; + + // 구간별 집계 + const binMap = new Map(); + + allScores.forEach(({ score, farmNo: scoreFarmNo }) => { + const binStart = Math.floor(score / binSize) * binSize; + const existing = binMap.get(binStart) || { count: 0, farmCount: 0 }; + existing.count += 1; + if (scoreFarmNo === farmNo) { + existing.farmCount += 1; + } + binMap.set(binStart, existing); + }); + + // Map을 배열로 변환 및 정렬 + histogram.push(...Array.from(binMap.entries()) + .map(([bin, data]) => ({ bin, count: data.count, farmCount: data.farmCount })) + .sort((a, b) => a.bin - b.bin) + ); + } + return { farmRank, farmTotal, @@ -1316,6 +1352,7 @@ export class GenomeService { regionTotal, farmAvgScore, regionAvgScore, + histogram, }; } @@ -1338,6 +1375,7 @@ export class GenomeService { regionAvgEbv: number | null; farmAvgEpd: number | null; // 농가 평균 육종가(EPD) regionAvgEpd: number | null; // 보은군 평균 육종가(EPD) + histogram: { bin: number; count: number; farmCount: number }[]; // 실제 데이터 분포 }> { // 1. 현재 개체의 의뢰 정보 조회 const cow = await this.cowRepository.findOne({ @@ -1357,6 +1395,7 @@ export class GenomeService { regionAvgEbv: null, farmAvgEpd: null, regionAvgEpd: null, + histogram: [], }; } @@ -1378,6 +1417,7 @@ export class GenomeService { regionAvgEbv: null, farmAvgEpd: null, regionAvgEpd: null, + histogram: [], }; } @@ -1459,6 +1499,42 @@ export class GenomeService { ? Math.round((farmEpdValues.reduce((sum, v) => sum + v, 0) / farmEpdValues.length) * 100) / 100 : null; + // 8. 실제 데이터 분포 히스토그램 생성 (EPD 기준) + const histogram: { bin: number; count: number; farmCount: number }[] = []; + if (allScores.length > 0) { + // EPD 값들 수집 (EPD가 실제 육종가 값) + const epdValues = allScores.filter(s => s.epd !== null).map(s => ({ epd: s.epd as number, farmNo: s.farmNo })); + + if (epdValues.length > 0) { + // 최소/최대값 찾기 + const minEpd = Math.min(...epdValues.map(v => v.epd)); + const maxEpd = Math.max(...epdValues.map(v => v.epd)); + const range = maxEpd - minEpd; + + // 구간 크기 결정 (전체 범위를 약 20-30개 구간으로 나눔) + const binSize = range > 0 ? Math.ceil(range / 25) : 1; + + // 구간별 집계 + const binMap = new Map(); + + epdValues.forEach(({ epd, farmNo: scoreFarmNo }) => { + const binStart = Math.floor(epd / binSize) * binSize; + const existing = binMap.get(binStart) || { count: 0, farmCount: 0 }; + existing.count += 1; + if (scoreFarmNo === farmNo) { + existing.farmCount += 1; + } + binMap.set(binStart, existing); + }); + + // Map을 배열로 변환 및 정렬 + histogram.push(...Array.from(binMap.entries()) + .map(([bin, data]) => ({ bin, count: data.count, farmCount: data.farmCount })) + .sort((a, b) => a.bin - b.bin) + ); + } + } + return { traitName, cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null, @@ -1471,6 +1547,7 @@ export class GenomeService { regionAvgEbv, farmAvgEpd, regionAvgEpd, + histogram, // 실제 데이터 분포 추가 }; } 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 a7e845d..dd9a4e0 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 @@ -13,6 +13,7 @@ import { Customized, ReferenceLine, ResponsiveContainer, + Tooltip, XAxis, YAxis } from 'recharts' @@ -97,6 +98,8 @@ interface NormalDistributionChartProps { // 차트 필터 형질 선택 콜백 (외부 연동용) chartFilterTrait?: string onChartFilterTraitChange?: (trait: string) => void + // 전체 선발지수 히스토그램 (실제 분포 데이터) + selectionIndexHistogram?: { bin: number; count: number; farmCount: number }[] } export function NormalDistributionChart({ @@ -134,7 +137,8 @@ export function NormalDistributionChart({ highlightMode = null, onHighlightModeChange, chartFilterTrait: externalChartFilterTrait, - onChartFilterTraitChange + onChartFilterTraitChange, + selectionIndexHistogram = [] }: NormalDistributionChartProps) { const { filters } = useFilterStore() @@ -262,16 +266,87 @@ export function NormalDistributionChart({ } }, [chartFilterTrait, overallScore, overallPercentile, allTraits, traitRankData, farmAvgZ, regionAvgZ]) - // X축 범위 및 간격 계산 (내 개체 중심 방식) + // X축 범위 및 간격 계산 (실제 데이터에 맞게 조정, 중앙 정렬) const xAxisConfig = useMemo(() => { + const cowScore = chartDisplayValues.originalScore + + // 전체 선발지수: selectionIndexHistogram 사용 + if (chartFilterTrait === 'overall' && selectionIndexHistogram.length > 0) { + const bins = selectionIndexHistogram.map(item => item.bin - cowScore) + // 내 개체(0)도 범위에 포함 + const allValues = [...bins, 0] + const minData = Math.min(...allValues) + const maxData = Math.max(...allValues) + + // 데이터의 중심점 계산 + const center = (minData + maxData) / 2 + // 데이터 범위에 20% 여유 추가 + const dataRange = maxData - minData + const padding = dataRange * 0.2 + // 중심점 기준으로 좌우 대칭 범위 설정 + const halfRange = (dataRange / 2) + padding + + const min = Math.floor(center - halfRange) + const max = Math.ceil(center + halfRange) + const range = max - min + + let step: number + if (range <= 5) { + step = 0.5 + } else if (range <= 20) { + step = 2 + } else if (range <= 50) { + step = 5 + } else if (range <= 100) { + step = 10 + } else { + step = 20 + } + + return { min, max, step } + } + + // 형질별: traitRankData.histogram 사용 + if (chartFilterTrait !== 'overall' && traitRankData?.histogram && traitRankData.histogram.length > 0) { + const bins = traitRankData.histogram.map(item => item.bin - cowScore) + // 내 개체(0)도 범위에 포함 + const allValues = [...bins, 0] + const minData = Math.min(...allValues) + const maxData = Math.max(...allValues) + + // 데이터의 중심점 계산 + const center = (minData + maxData) / 2 + // 데이터 범위에 20% 여유 추가 + const dataRange = maxData - minData + const padding = dataRange * 0.2 + // 중심점 기준으로 좌우 대칭 범위 설정 + const halfRange = (dataRange / 2) + padding + + const min = Math.floor(center - halfRange) + const max = Math.ceil(center + halfRange) + const range = max - min + + let step: number + if (range <= 5) { + step = 0.5 + } else if (range <= 20) { + step = 2 + } else if (range <= 50) { + step = 5 + } else if (range <= 100) { + step = 10 + } else { + step = 20 + } + + return { min, max, step } + } + + // 히스토그램 데이터가 없으면 평균 대비 차이로 범위 계산 (폴백) const { cowVsFarm, cowVsRegion } = chartDisplayValues const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion)) - - // 데이터가 차트의 약 70% 영역에 들어오도록 범위 계산 - // maxDiff가 range의 70%가 되도록: range = maxDiff / 0.7 const targetRange = maxDiff / 0.7 - // step 계산: 범위에 따라 적절한 간격 선택 let step: number if (targetRange <= 1) { step = 0.2 @@ -285,12 +360,11 @@ export function NormalDistributionChart({ step = 10 } - // 범위를 step 단위로 올림 (최소값 보장) - const minRange = step * 3 // 최소 3개의 step + const minRange = step * 3 const range = Math.max(minRange, Math.ceil(targetRange / step) * step) return { min: -range, max: range, step } - }, [chartDisplayValues]) + }, [chartFilterTrait, selectionIndexHistogram, traitRankData, chartDisplayValues]) // X축 틱 계산 (동적 간격) const xTicks = useMemo(() => { @@ -302,22 +376,118 @@ export function NormalDistributionChart({ return ticks }, [xAxisConfig]) - // 히스토그램 데이터 생성 (내 개체 중심, 정규분포 곡선) + // 히스토그램 데이터 생성 (실제 데이터 분포 사용) const histogramData = useMemo(() => { - // X축 범위에 맞게 표준편차 조정 (범위의 약 1/4) + // 전체 선발지수: selectionIndexHistogram 사용 + if (chartFilterTrait === 'overall' && selectionIndexHistogram.length > 0) { + const histogram = selectionIndexHistogram + const totalCount = histogram.reduce((sum, item) => sum + item.count, 0) + + const bins = histogram.map(item => { + const cowScore = chartDisplayValues.originalScore + const relativeBin = item.bin - cowScore + const percent = (item.count / totalCount) * 100 + const farmPercent = totalCount > 0 ? (item.farmCount / totalCount) * 100 : 0 + + return { + midPoint: relativeBin, + regionPercent: percent, + percent: percent, + farmPercent: farmPercent, + count: item.count, + farmCount: item.farmCount + } + }) + + // 🔍 실제 히스토그램 데이터 콘솔 로그 + const dataMinMax = { min: Math.min(...bins.map(b => b.midPoint)), max: Math.max(...bins.map(b => b.midPoint)) } + const percentMinMax = { min: Math.min(...bins.map(b => b.percent)), max: Math.max(...bins.map(b => b.percent)) } + const calculatedYMax = Math.max(5, Math.ceil(percentMinMax.max * 1.2)) + + console.log('📊 [전체 선발지수 - 차트 범위 자동 조정]', { + 형질명: '전체 선발지수', + 전체개체수: totalCount, + '📏 X축': { + 데이터범위: `${dataMinMax.min.toFixed(1)} ~ ${dataMinMax.max.toFixed(1)}`, + 차트범위: `${xAxisConfig.min} ~ ${xAxisConfig.max}`, + 범위크기: `${(xAxisConfig.max - xAxisConfig.min).toFixed(1)}` + }, + '📏 Y축': { + 데이터최대: `${percentMinMax.max.toFixed(1)}%`, + 차트최대: `${calculatedYMax}%`, + 여유공간: `${((calculatedYMax - percentMinMax.max) / percentMinMax.max * 100).toFixed(0)}%` + }, + 총데이터개수: bins.length, + 샘플: { 첫5개: bins.slice(0, 5), 마지막5개: bins.slice(-5) } + }) + + return bins + } + + // 형질별 데이터가 있으면 실제 히스토그램 사용 + if (chartFilterTrait !== 'overall' && traitRankData?.histogram && traitRankData.histogram.length > 0) { + const histogram = traitRankData.histogram + const totalCount = histogram.reduce((sum, item) => sum + item.count, 0) + + // 백엔드에서 받은 히스토그램을 차트 데이터로 변환 + const bins = histogram.map(item => { + // bin 값은 구간의 시작값 (예: 110, 115, 120...) + // 개체 점수 대비 상대 위치로 변환 (내 개체 = 0 기준) + const cowScore = chartDisplayValues.originalScore + const relativeBin = item.bin - cowScore + + // 백분율 계산 + const percent = (item.count / totalCount) * 100 + const farmPercent = totalCount > 0 ? (item.farmCount / totalCount) * 100 : 0 + + return { + midPoint: relativeBin, + regionPercent: percent, + percent: percent, + farmPercent: farmPercent, + count: item.count, + farmCount: item.farmCount + } + }) + + // 🔍 실제 히스토그램 데이터 콘솔 로그 + const dataMinMax = { min: Math.min(...bins.map(b => b.midPoint)), max: Math.max(...bins.map(b => b.midPoint)) } + const percentMinMax = { min: Math.min(...bins.map(b => b.percent)), max: Math.max(...bins.map(b => b.percent)) } + const calculatedYMax = Math.max(5, Math.ceil(percentMinMax.max * 1.2)) + + console.log(`📊 [${chartFilterTrait} - 차트 범위 자동 조정]`, { + 형질명: chartFilterTrait, + 전체개체수: totalCount, + '📏 X축': { + 데이터범위: `${dataMinMax.min.toFixed(1)} ~ ${dataMinMax.max.toFixed(1)}`, + 차트범위: `${xAxisConfig.min} ~ ${xAxisConfig.max}`, + 범위크기: `${(xAxisConfig.max - xAxisConfig.min).toFixed(1)}` + }, + '📏 Y축': { + 데이터최대: `${percentMinMax.max.toFixed(1)}%`, + 차트최대: `${calculatedYMax}%`, + 여유공간: `${((calculatedYMax - percentMinMax.max) / percentMinMax.max * 100).toFixed(0)}%` + }, + 총데이터개수: bins.length, + 샘플: { 첫5개: bins.slice(0, 5), 마지막5개: bins.slice(-5) } + }) + + return bins + } + + // 히스토그램 데이터가 없을 때만 정규분포 곡선 사용 (폴백) const range = xAxisConfig.max - xAxisConfig.min const std = range / 4 - // 정규분포 PDF 계산 함수 (0~1 범위로 정규화) const normalPDF = (x: number, mean: number = 0) => { const exponent = -Math.pow(x - mean, 2) / (2 * Math.pow(std, 2)) - return Math.exp(exponent) // 0~1 범위 + return Math.exp(exponent) } const bins = [] - const stepSize = range / 100 // 100개의 점으로 부드러운 곡선 + const stepSize = range / 100 for (let x = xAxisConfig.min; x <= xAxisConfig.max; x += stepSize) { - const pdfValue = normalPDF(x) * 40 // 최대 40%로 스케일링 + const pdfValue = normalPDF(x) * 40 bins.push({ midPoint: x, regionPercent: pdfValue, @@ -325,11 +495,30 @@ export function NormalDistributionChart({ }) } - return bins - }, [xAxisConfig]) + // 🔍 정규분포 곡선 데이터 콘솔 로그 + console.log('📊 [정규분포 곡선 데이터 - 폴백]', { + 총데이터개수: bins.length, + X축범위: `${xAxisConfig.min} ~ ${xAxisConfig.max}`, + 표준편차: std, + 첫5개: bins.slice(0, 5), + 마지막5개: bins.slice(-5) + }) - // 최대 % (Y축 범위용) - 항상 40으로 고정 - const maxPercent = 40 + return bins + }, [xAxisConfig, chartFilterTrait, traitRankData, chartDisplayValues.originalScore, selectionIndexHistogram]) + + // Y축 범위 (실제 데이터에 맞게 조정) + const maxPercent = useMemo(() => { + if (histogramData.length === 0) return 40 + + const maxValue = Math.max(...histogramData.map(d => d.percent || 0)) + + // 실제 최대값에 20% 여유만 추가 (너무 빡빡하지 않게) + const calculatedMax = Math.ceil(maxValue * 1.2) + + // 최소 5% 보장 (데이터가 너무 작을 때만) + return Math.max(5, calculatedMax) + }, [histogramData]) return ( @@ -387,14 +576,13 @@ export function NormalDistributionChart({ {/* 확대 버튼 */} - + */} {/* 순위 및 평균 대비 요약 (차트 위에 배치) */} @@ -511,11 +699,11 @@ export function NormalDistributionChart({ {/* 데스크탑: 기존 레이아웃 */}
{/* 현재 보고 있는 조회 기준 표시 */} -
+ {/*
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 조회 기준 -
+
*/}
{/* 농가 내 순위 */} @@ -622,11 +810,20 @@ export function NormalDistributionChart({
- - + {/* 로딩 상태 */} + {(traitRankLoading && chartFilterTrait !== 'overall') || histogramData.length === 0 ? ( +
+
+

+ {chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 분포 데이터 로딩 중... +

+
+ ) : ( + + {/* 보은군 - Blue */} @@ -677,14 +874,47 @@ export function NormalDistributionChart({ tickFormatter={(value) => `${Math.round(value)}%`} /> - {/* 정규분포 곡선 */} + {/* Tooltip */} + { + if (!active || !payload || payload.length === 0) return null + + const data = payload[0].payload + const cowScore = chartDisplayValues.originalScore + const binStart = Math.round((data.midPoint + cowScore) * 100) / 100 + + return ( +
+

+ 구간: {binStart >= 0 ? '+' : ''}{binStart.toFixed(2)} +

+

+ 개체 수: {data.count || 0}마리 +

+

+ 비율: {data.percent?.toFixed(1) || 0}% +

+ {data.farmCount !== undefined && ( +

+ 내 농가: {data.farmCount}마리 +

+ )} +
+ ) + }} + cursor={{ fill: 'rgba(59, 130, 246, 0.1)' }} + /> + + {/* 실제 데이터 분포 (Area 그래프 + 점 표시) */} {/* 보은군 평균 위치 */} @@ -1048,10 +1278,11 @@ export function NormalDistributionChart({ />
+ )}
{/* 범례 */} -
+ {/*
보은군 평균 @@ -1064,7 +1295,7 @@ export function NormalDistributionChart({
{displayCowNumber.slice(-4)} 개체
-
+
*/} diff --git a/frontend/src/app/cow/[cowNo]/page.tsx b/frontend/src/app/cow/[cowNo]/page.tsx index 3b8a101..32acc17 100644 --- a/frontend/src/app/cow/[cowNo]/page.tsx +++ b/frontend/src/app/cow/[cowNo]/page.tsx @@ -110,6 +110,7 @@ export default function CowOverviewPage() { farmerName: string | null; farmAvgScore: number | null; regionAvgScore: number | null; + histogram: { bin: number; count: number; farmCount: number }[]; } | null>(null) // 4. 분포/비교 데이터 @@ -762,6 +763,7 @@ export default function CowOverviewPage() { regionRank={selectionIndex?.regionRank} highlightMode={highlightMode} onHighlightModeChange={setHighlightMode} + selectionIndexHistogram={selectionIndex?.histogram || []} regionTotal={selectionIndex?.regionTotal} chartFilterTrait={chartFilterTrait} onChartFilterTraitChange={setChartFilterTrait} @@ -1760,6 +1762,7 @@ export default function CowOverviewPage() { regionRank={selectionIndex?.regionRank} regionTotal={selectionIndex?.regionTotal} highlightMode={highlightMode} + selectionIndexHistogram={selectionIndex?.histogram || []} onHighlightModeChange={setHighlightMode} chartFilterTrait={chartFilterTrait} onChartFilterTraitChange={setChartFilterTrait} diff --git a/frontend/src/app/cow/page.tsx b/frontend/src/app/cow/page.tsx index d855db7..7b68366 100644 --- a/frontend/src/app/cow/page.tsx +++ b/frontend/src/app/cow/page.tsx @@ -936,7 +936,7 @@ function MyCowContent() { {/* 2번 영역: 형질 타이틀 */}
- 형질 + 유전체형질
{/* 3번 영역: 오른쪽 버튼 */} @@ -1448,8 +1448,9 @@ function MyCowContent() { {selectedDisplayTraits.length > 0 && (
e.stopPropagation()}> {(() => { + // 상위 4개만 표시 const isExpanded = expandedRows.has(`${cow.pkCowNo}-mobile-traits`) - const displayTraits = isExpanded ? selectedDisplayTraits : selectedDisplayTraits.slice(0, 4) + const displayTraits = selectedDisplayTraits.slice(0, 4) // 상위 4개만 const remainingCount = selectedDisplayTraits.length - 4 return ( @@ -1475,7 +1476,8 @@ function MyCowContent() { ) })}
- {selectedDisplayTraits.length > 4 && ( + {/* 형질 더보기 버튼 주석처리 */} + {/* {selectedDisplayTraits.length > 4 && ( - )} + )} */} ) })()} diff --git a/frontend/src/lib/api/genome.api.ts b/frontend/src/lib/api/genome.api.ts index 805057b..2019a51 100644 --- a/frontend/src/lib/api/genome.api.ts +++ b/frontend/src/lib/api/genome.api.ts @@ -119,6 +119,7 @@ export const genomeApi = { farmAvgScore: number | null; // 농가 평균 선발지수 regionAvgScore: number | null; // 보은군(지역) 평균 선발지수 details: { traitNm: string; ebv: number; weight: number; contribution: number }[]; + histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포 message?: string; }> => { return await apiClient.post(`/genome/selection-index/${cowId}`, { traitConditions }); @@ -211,6 +212,7 @@ export interface TraitRankDto { regionAvgEbv: number | null; farmAvgEpd: number | null; // 농가 평균 육종가(EPD) regionAvgEpd: number | null; // 보은군 평균 육종가(EPD) + histogram: { bin: number; count: number; farmCount: number }[]; // 실제 데이터 분포 } /**