update_cow_list_detail_page

This commit is contained in:
NYD
2026-01-07 17:56:22 +09:00
parent f5b52df26f
commit 9e5ffb2c15
5 changed files with 360 additions and 45 deletions

View File

@@ -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<number, { count: number; farmCount: number }>();
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<number, { count: number; farmCount: number }>();
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, // 실제 데이터 분포 추가
};
}

View File

@@ -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({
</Select>
</div>
{/* 확대 버튼 */}
<button
{/* <button
onClick={onOpenChartModal}
className="flex items-center gap-1.5 px-2 sm:px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-muted rounded-lg transition-colors"
aria-label="차트 확대"
>
aria-label="차트 확대">
<Maximize2 className="w-4 h-4" />
<span className="hidden sm:inline">확대</span>
</button>
</button> */}
</div>
{/* 순위 및 평균 대비 요약 (차트 위에 배치) */}
@@ -511,11 +699,11 @@ export function NormalDistributionChart({
{/* 데스크탑: 기존 레이아웃 */}
<div className="hidden sm:block">
{/* 현재 보고 있는 조회 기준 표시 */}
<div className="flex items-center justify-center mb-4">
{/* <div className="flex items-center justify-center mb-4">
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 조회 기준
</span>
</div>
</div> */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* 농가 내 순위 */}
@@ -622,11 +810,20 @@ export function NormalDistributionChart({
</div>
<div className="h-[400px] sm:h-[440px] md:h-[470px] bg-gradient-to-b from-slate-50 to-slate-100 rounded-xl p-2 sm:p-4 relative" role="img" aria-label="농가 및 보은군 내 개체 위치">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={histogramData}
margin={isMobileView ? { top: 110, right: 10, left: 5, bottom: 25 } : { top: 110, right: 20, left: 10, bottom: 45 }}
>
{/* 로딩 상태 */}
{(traitRankLoading && chartFilterTrait !== 'overall') || histogramData.length === 0 ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4"></div>
<p className="text-sm text-muted-foreground font-medium">
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} ...
</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={histogramData}
margin={isMobileView ? { top: 110, right: 10, left: 5, bottom: 25 } : { top: 110, right: 20, left: 10, bottom: 45 }}
>
<defs>
{/* 보은군 - Blue */}
<linearGradient id="regionGradient" x1="0" y1="0" x2="0" y2="1">
@@ -677,14 +874,47 @@ export function NormalDistributionChart({
tickFormatter={(value) => `${Math.round(value)}%`}
/>
{/* 정규분포 곡선 */}
{/* Tooltip */}
<Tooltip
content={({ active, payload }) => {
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 (
<div className="bg-white p-3 border border-border rounded-lg shadow-lg">
<p className="text-sm font-semibold mb-2">
: {binStart >= 0 ? '+' : ''}{binStart.toFixed(2)}
</p>
<p className="text-sm text-muted-foreground">
: <span className="font-bold text-foreground">{data.count || 0}</span>
</p>
<p className="text-sm text-muted-foreground">
: <span className="font-bold text-foreground">{data.percent?.toFixed(1) || 0}%</span>
</p>
{data.farmCount !== undefined && (
<p className="text-sm text-blue-600 mt-1">
: <span className="font-bold">{data.farmCount}</span>
</p>
)}
</div>
)
}}
cursor={{ fill: 'rgba(59, 130, 246, 0.1)' }}
/>
{/* 실제 데이터 분포 (Area 그래프 + 점 표시) */}
<Area
type="natural"
type="linear"
dataKey="percent"
stroke="#64748b"
strokeWidth={2.5}
fill="url(#areaFillGradient)"
dot={false}
dot={{ r: 4, fill: '#64748b', strokeWidth: 2, stroke: '#fff' }}
activeDot={{ r: 6, fill: '#3b82f6', strokeWidth: 2, stroke: '#fff' }}
isAnimationActive={false}
/>
{/* 보은군 평균 위치 */}
@@ -1048,10 +1278,11 @@ export function NormalDistributionChart({
/>
</ComposedChart>
</ResponsiveContainer>
)}
</div>
{/* 범례 */}
<div className="flex items-center justify-center gap-4 sm:gap-6 mt-4 pt-4 border-t border-border">
{/* <div className="flex items-center justify-center gap-4 sm:gap-6 mt-4 pt-4 border-t border-border">
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-slate-500"></div>
<span className="text-sm sm:text-sm text-muted-foreground">보은군 평균</span>
@@ -1064,7 +1295,7 @@ export function NormalDistributionChart({
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-[#1482B0]"></div>
<span className="text-sm sm:text-sm font-medium text-foreground">{displayCowNumber.slice(-4)} 개체</span>
</div>
</div>
</div> */}
</CardContent>
</Card>

View File

@@ -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}

View File

@@ -936,7 +936,7 @@ function MyCowContent() {
{/* 2번 영역: 형질 타이틀 */}
<div className="flex-1 flex items-center justify-center">
<span className="font-bold"></span>
<span className="font-bold"></span>
</div>
{/* 3번 영역: 오른쪽 버튼 */}
@@ -1448,8 +1448,9 @@ function MyCowContent() {
{selectedDisplayTraits.length > 0 && (
<div className="pt-3 border-t mt-3" onClick={(e) => 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() {
)
})}
</div>
{selectedDisplayTraits.length > 4 && (
{/* 형질 더보기 버튼 주석처리 */}
{/* {selectedDisplayTraits.length > 4 && (
<button
onClick={() => {
const newExpanded = new Set(expandedRows)
@@ -1491,7 +1493,7 @@ function MyCowContent() {
>
{isExpanded ? '접기' : `+${remainingCount}개 더`}
</button>
)}
)} */}
</>
)
})()}

View File

@@ -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 }[]; // 실제 데이터 분포
}
/**