화면 선발지수 수정 반영
This commit is contained in:
@@ -214,7 +214,7 @@ export class CowService {
|
|||||||
*
|
*
|
||||||
* @param cows - 필터링된 개체 목록
|
* @param cows - 필터링된 개체 목록
|
||||||
* @param traitConditions - 형질별 가중치 조건 배열
|
* @param traitConditions - 형질별 가중치 조건 배열
|
||||||
* @returns 순위가 적용된 개체 목록
|
* @returns 순위가 적용된 개체 목록 / 리스트에 전달 / 농가/보은군 차트 (farmBreedVal, regionBreedVal)
|
||||||
* @example
|
* @example
|
||||||
* traitConditions = [
|
* traitConditions = [
|
||||||
* { traitNm: '도체중', weight: 8 },
|
* { traitNm: '도체중', weight: 8 },
|
||||||
@@ -253,7 +253,7 @@ export class CowService {
|
|||||||
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
// Step 2: 친자감별 확인 - 유효하지 않으면 분석 불가
|
||||||
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
if (!latestRequest || !isValidGenomeAnalysis(latestRequest.chipSireName, latestRequest.chipDamName, cow.cowId)) {
|
||||||
// 분석불가 사유 결정
|
// 분석불가 사유 결정
|
||||||
let unavailableReason = '미분석';
|
let unavailableReason = '분석불가';
|
||||||
if (latestRequest) {
|
if (latestRequest) {
|
||||||
if (latestRequest.chipSireName !== '일치') {
|
if (latestRequest.chipSireName !== '일치') {
|
||||||
unavailableReason = '부 불일치';
|
unavailableReason = '부 불일치';
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ interface TraitAverageDto {
|
|||||||
traitName: string; // 형질명
|
traitName: string; // 형질명
|
||||||
category: string; // 카테고리
|
category: string; // 카테고리
|
||||||
avgEbv: number; // 평균 EBV (표준화 육종가)
|
avgEbv: number; // 평균 EBV (표준화 육종가)
|
||||||
|
avgEpd: number; // 평균 EPD (육종가 원본값)
|
||||||
count: number; // 데이터 개수
|
count: number; // 데이터 개수
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1155,6 +1156,7 @@ export class GenomeService {
|
|||||||
const results = await qb
|
const results = await qb
|
||||||
.select('detail.traitName', 'traitName')
|
.select('detail.traitName', 'traitName')
|
||||||
.addSelect('AVG(detail.traitEbv)', 'avgEbv')
|
.addSelect('AVG(detail.traitEbv)', 'avgEbv')
|
||||||
|
.addSelect('AVG(detail.traitVal)', 'avgEpd') // 육종가(EPD) 평균 추가
|
||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.groupBy('detail.traitName')
|
.groupBy('detail.traitName')
|
||||||
.getRawMany();
|
.getRawMany();
|
||||||
@@ -1164,6 +1166,7 @@ export class GenomeService {
|
|||||||
traitName: row.traitName,
|
traitName: row.traitName,
|
||||||
category: TRAIT_CATEGORY_MAP[row.traitName] || '기타',
|
category: TRAIT_CATEGORY_MAP[row.traitName] || '기타',
|
||||||
avgEbv: Math.round(parseFloat(row.avgEbv) * 100) / 100,
|
avgEbv: Math.round(parseFloat(row.avgEbv) * 100) / 100,
|
||||||
|
avgEpd: Math.round(parseFloat(row.avgEpd || 0) * 100) / 100, // 육종가(EPD) 평균
|
||||||
count: parseInt(row.count, 10),
|
count: parseInt(row.count, 10),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1551,12 +1554,15 @@ export class GenomeService {
|
|||||||
async getTraitRank(cowId: string, traitName: string): Promise<{
|
async getTraitRank(cowId: string, traitName: string): Promise<{
|
||||||
traitName: string;
|
traitName: string;
|
||||||
cowEbv: number | null;
|
cowEbv: number | null;
|
||||||
|
cowEpd: number | null; // 개체 육종가(EPD)
|
||||||
farmRank: number | null;
|
farmRank: number | null;
|
||||||
farmTotal: number;
|
farmTotal: number;
|
||||||
regionRank: number | null;
|
regionRank: number | null;
|
||||||
regionTotal: number;
|
regionTotal: number;
|
||||||
farmAvgEbv: number | null;
|
farmAvgEbv: number | null;
|
||||||
regionAvgEbv: number | null;
|
regionAvgEbv: number | null;
|
||||||
|
farmAvgEpd: number | null; // 농가 평균 육종가(EPD)
|
||||||
|
regionAvgEpd: number | null; // 보은군 평균 육종가(EPD)
|
||||||
}> {
|
}> {
|
||||||
// 1. 현재 개체의 의뢰 정보 조회
|
// 1. 현재 개체의 의뢰 정보 조회
|
||||||
const cow = await this.cowRepository.findOne({
|
const cow = await this.cowRepository.findOne({
|
||||||
@@ -1567,12 +1573,15 @@ export class GenomeService {
|
|||||||
return {
|
return {
|
||||||
traitName,
|
traitName,
|
||||||
cowEbv: null,
|
cowEbv: null,
|
||||||
|
cowEpd: null,
|
||||||
farmRank: null,
|
farmRank: null,
|
||||||
farmTotal: 0,
|
farmTotal: 0,
|
||||||
regionRank: null,
|
regionRank: null,
|
||||||
regionTotal: 0,
|
regionTotal: 0,
|
||||||
farmAvgEbv: null,
|
farmAvgEbv: null,
|
||||||
regionAvgEbv: null,
|
regionAvgEbv: null,
|
||||||
|
farmAvgEpd: null,
|
||||||
|
regionAvgEpd: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1585,12 +1594,15 @@ export class GenomeService {
|
|||||||
return {
|
return {
|
||||||
traitName,
|
traitName,
|
||||||
cowEbv: null,
|
cowEbv: null,
|
||||||
|
cowEpd: null,
|
||||||
farmRank: null,
|
farmRank: null,
|
||||||
farmTotal: 0,
|
farmTotal: 0,
|
||||||
regionRank: null,
|
regionRank: null,
|
||||||
regionTotal: 0,
|
regionTotal: 0,
|
||||||
farmAvgEbv: null,
|
farmAvgEbv: null,
|
||||||
regionAvgEbv: null,
|
regionAvgEbv: null,
|
||||||
|
farmAvgEpd: null,
|
||||||
|
regionAvgEpd: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1602,8 +1614,8 @@ export class GenomeService {
|
|||||||
relations: ['cow', 'farm'],
|
relations: ['cow', 'farm'],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. 각 개체별 해당 형질 EBV 수집
|
// 3. 각 개체별 해당 형질 EBV, EPD 수집
|
||||||
const allScores: { cowId: string; ebv: number; farmNo: number | null }[] = [];
|
const allScores: { cowId: string; ebv: number; epd: number | null; farmNo: number | null }[] = [];
|
||||||
|
|
||||||
for (const request of allRequests) {
|
for (const request of allRequests) {
|
||||||
if (!request.cow?.cowId) continue;
|
if (!request.cow?.cowId) continue;
|
||||||
@@ -1621,6 +1633,7 @@ export class GenomeService {
|
|||||||
allScores.push({
|
allScores.push({
|
||||||
cowId: request.cow.cowId,
|
cowId: request.cow.cowId,
|
||||||
ebv: Number(traitDetail.traitEbv),
|
ebv: Number(traitDetail.traitEbv),
|
||||||
|
epd: traitDetail.traitVal !== null ? Number(traitDetail.traitVal) : null, // 육종가(EPD)
|
||||||
farmNo: request.fkFarmNo,
|
farmNo: request.fkFarmNo,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1629,9 +1642,10 @@ export class GenomeService {
|
|||||||
// 4. EBV 기준 내림차순 정렬
|
// 4. EBV 기준 내림차순 정렬
|
||||||
allScores.sort((a, b) => b.ebv - a.ebv);
|
allScores.sort((a, b) => b.ebv - a.ebv);
|
||||||
|
|
||||||
// 5. 현재 개체의 EBV 찾기
|
// 5. 현재 개체의 EBV, EPD 찾기
|
||||||
const currentCowData = allScores.find(s => s.cowId === cowId);
|
const currentCowData = allScores.find(s => s.cowId === cowId);
|
||||||
const cowEbv = currentCowData?.ebv ?? null;
|
const cowEbv = currentCowData?.ebv ?? null;
|
||||||
|
const cowEpd = currentCowData?.epd ?? null;
|
||||||
|
|
||||||
// 6. 보은군 전체 순위
|
// 6. 보은군 전체 순위
|
||||||
const regionRank = currentCowData
|
const regionRank = currentCowData
|
||||||
@@ -1639,10 +1653,14 @@ export class GenomeService {
|
|||||||
: null;
|
: null;
|
||||||
const regionTotal = allScores.length;
|
const regionTotal = allScores.length;
|
||||||
|
|
||||||
// 보은군 평균 EBV
|
// 보은군 평균 EBV, EPD
|
||||||
const regionAvgEbv = allScores.length > 0
|
const regionAvgEbv = allScores.length > 0
|
||||||
? Math.round((allScores.reduce((sum, s) => sum + s.ebv, 0) / allScores.length) * 100) / 100
|
? Math.round((allScores.reduce((sum, s) => sum + s.ebv, 0) / allScores.length) * 100) / 100
|
||||||
: null;
|
: 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. 농가 내 순위
|
// 7. 농가 내 순위
|
||||||
const farmScores = allScores.filter(s => s.farmNo === farmNo);
|
const farmScores = allScores.filter(s => s.farmNo === farmNo);
|
||||||
@@ -1651,20 +1669,27 @@ export class GenomeService {
|
|||||||
: null;
|
: null;
|
||||||
const farmTotal = farmScores.length;
|
const farmTotal = farmScores.length;
|
||||||
|
|
||||||
// 농가 평균 EBV
|
// 농가 평균 EBV, EPD
|
||||||
const farmAvgEbv = farmScores.length > 0
|
const farmAvgEbv = farmScores.length > 0
|
||||||
? Math.round((farmScores.reduce((sum, s) => sum + s.ebv, 0) / farmScores.length) * 100) / 100
|
? Math.round((farmScores.reduce((sum, s) => sum + s.ebv, 0) / farmScores.length) * 100) / 100
|
||||||
: null;
|
: 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 {
|
return {
|
||||||
traitName,
|
traitName,
|
||||||
cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null,
|
cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null,
|
||||||
|
cowEpd: cowEpd !== null ? Math.round(cowEpd * 100) / 100 : null,
|
||||||
farmRank: farmRank && farmRank > 0 ? farmRank : null,
|
farmRank: farmRank && farmRank > 0 ? farmRank : null,
|
||||||
farmTotal,
|
farmTotal,
|
||||||
regionRank: regionRank && regionRank > 0 ? regionRank : null,
|
regionRank: regionRank && regionRank > 0 ? regionRank : null,
|
||||||
regionTotal,
|
regionTotal,
|
||||||
farmAvgEbv,
|
farmAvgEbv,
|
||||||
regionAvgEbv,
|
regionAvgEbv,
|
||||||
|
farmAvgEpd,
|
||||||
|
regionAvgEpd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,14 +192,19 @@ export function CategoryEvaluationCard({
|
|||||||
// 보은군/농가 형질별 평균 (데이터 없으면 0)
|
// 보은군/농가 형질별 평균 (데이터 없으면 0)
|
||||||
const regionTraitAvg = traitAvgRegion?.avgEbv ?? 0
|
const regionTraitAvg = traitAvgRegion?.avgEbv ?? 0
|
||||||
const farmTraitAvg = traitAvgFarm?.avgEbv ?? 0
|
const farmTraitAvg = traitAvgFarm?.avgEbv ?? 0
|
||||||
|
// 보은군/농가 형질별 EPD(육종가) 평균
|
||||||
|
const regionEpdAvg = traitAvgRegion?.avgEpd ?? 0
|
||||||
|
const farmEpdAvg = traitAvgFarm?.avgEpd ?? 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: traitName,
|
name: traitName,
|
||||||
shortName: TRAIT_SHORT_NAMES[traitName] || traitName,
|
shortName: TRAIT_SHORT_NAMES[traitName] || traitName,
|
||||||
breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ)
|
breedVal: trait?.breedVal ?? 0, // 이 개체 표준화육종가 (σ)
|
||||||
epd: trait?.actualValue ?? 0, // 이 개체 EPD (예상후대차이)
|
epd: trait?.actualValue ?? 0, // 이 개체 EPD (육종가)
|
||||||
regionVal: regionTraitAvg, // 보은군 평균
|
regionVal: regionTraitAvg, // 보은군 평균 (표준화육종가)
|
||||||
farmVal: farmTraitAvg, // 농가 평균
|
farmVal: farmTraitAvg, // 농가 평균 (표준화육종가)
|
||||||
|
regionEpd: regionEpdAvg, // 보은군 평균 (육종가)
|
||||||
|
farmEpd: farmEpdAvg, // 농가 평균 (육종가)
|
||||||
percentile: trait?.percentile ?? 50,
|
percentile: trait?.percentile ?? 50,
|
||||||
category: trait?.category ?? '체형',
|
category: trait?.category ?? '체형',
|
||||||
diff: trait?.breedVal ?? 0,
|
diff: trait?.breedVal ?? 0,
|
||||||
@@ -524,47 +529,45 @@ export function CategoryEvaluationCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 형질 정보 표시 (모바일 친화적) */}
|
{/* 선택된 형질 정보 표시 - 육종가(EPD) 값으로 표시 */}
|
||||||
{selectedTraitName && (() => {
|
{selectedTraitName && (() => {
|
||||||
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
|
const selectedTrait = traitChartData.find(t => t.name === selectedTraitName)
|
||||||
if (!selectedTrait) return null
|
if (!selectedTrait) return null
|
||||||
return (
|
return (
|
||||||
<div className="mx-4 mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
|
<div className="mx-4 mb-4 p-4 bg-slate-50 rounded-xl border border-slate-200 animate-fade-in">
|
||||||
<div className="flex items-center justify-between mb-3">
|
{/* 헤더: 형질명 + 닫기 */}
|
||||||
<span className="text-lg font-bold text-foreground">{selectedTrait.shortName}</span>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
|
||||||
|
{selectedTrait.shortName} 조회 기준
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedTraitName(null)}
|
onClick={() => setSelectedTraitName(null)}
|
||||||
className="text-muted-foreground hover:text-foreground p-1"
|
className="text-muted-foreground hover:text-foreground p-1 hover:bg-slate-200 rounded-full transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
{/* 3개 카드 그리드 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<span className="flex items-center gap-2">
|
{/* 보은군 카드 */}
|
||||||
<span className="w-3 h-3 rounded" style={{ backgroundColor: '#10b981' }}></span>
|
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-emerald-300 shadow-sm">
|
||||||
<span className="text-sm text-muted-foreground">보은군</span>
|
<span className="text-xs text-muted-foreground mb-1 font-medium">보은군 평균</span>
|
||||||
</span>
|
<span className="text-lg font-bold text-emerald-600">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
{selectedTrait.regionEpd?.toFixed(2) ?? '-'}
|
||||||
{selectedTrait.regionVal > 0 ? '+' : ''}{selectedTrait.regionVal.toFixed(2)}σ
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
{/* 농가 카드 */}
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
|
||||||
<span className="w-3 h-3 rounded" style={{ backgroundColor: '#1F3A8F' }}></span>
|
<span className="text-xs text-muted-foreground mb-1 font-medium">농가 평균</span>
|
||||||
<span className="text-sm text-muted-foreground">농가</span>
|
<span className="text-lg font-bold text-[#1F3A8F]">
|
||||||
</span>
|
{selectedTrait.farmEpd?.toFixed(2) ?? '-'}
|
||||||
<span className="text-sm font-semibold text-foreground">
|
|
||||||
{selectedTrait.farmVal > 0 ? '+' : ''}{selectedTrait.farmVal.toFixed(2)}σ
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
{/* 개체 카드 */}
|
||||||
<span className="flex items-center gap-2">
|
<div className="flex flex-col items-center justify-center p-3 bg-white rounded-xl border-2 border-[#1482B0]/30 shadow-sm">
|
||||||
<span className="w-3 h-3 rounded" style={{ backgroundColor: '#1482B0' }}></span>
|
<span className="text-xs text-muted-foreground mb-1 font-medium">내 개체</span>
|
||||||
<span className="text-sm font-medium text-foreground">{formatCowNoShort(cowNo)} 개체</span>
|
<span className="text-lg font-bold text-[#1482B0]">
|
||||||
</span>
|
{selectedTrait.epd?.toFixed(2) ?? '-'}
|
||||||
<span className="text-sm font-bold text-primary">
|
|
||||||
{selectedTrait.breedVal > 0 ? '+' : ''}{selectedTrait.breedVal.toFixed(2)}σ
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -259,6 +259,8 @@ export function NormalDistributionChart({
|
|||||||
|
|
||||||
// 차트 필터에 따른 표시 값 계산 (개체/농가/보은군 모두)
|
// 차트 필터에 따른 표시 값 계산 (개체/농가/보은군 모두)
|
||||||
// "내 개체 중심" 방식: 개체를 0에 고정하고 농가/보은군을 상대 위치로 표시
|
// "내 개체 중심" 방식: 개체를 0에 고정하고 농가/보은군을 상대 위치로 표시
|
||||||
|
// - 전체 선발지수: 선발지수 값 사용
|
||||||
|
// - 개별 형질: 육종가(EPD) 값 사용
|
||||||
const chartDisplayValues = useMemo(() => {
|
const chartDisplayValues = useMemo(() => {
|
||||||
let baseScore = overallScore
|
let baseScore = overallScore
|
||||||
let basePercentile = overallPercentile
|
let basePercentile = overallPercentile
|
||||||
@@ -267,16 +269,17 @@ export function NormalDistributionChart({
|
|||||||
let baseRegionScore = regionAvgZ
|
let baseRegionScore = regionAvgZ
|
||||||
|
|
||||||
if (chartFilterTrait !== 'overall') {
|
if (chartFilterTrait !== 'overall') {
|
||||||
// 선택된 형질 찾기
|
// 모든 형질에서 찾기 (selectedTraitData는 선택된 형질만 포함하므로 allTraits에서 찾아야 함)
|
||||||
const selectedTrait = selectedTraitData.find(t => t.name === chartFilterTrait)
|
const selectedTrait = allTraits.find(t => t.name === chartFilterTrait)
|
||||||
|
|
||||||
if (selectedTrait) {
|
if (selectedTrait) {
|
||||||
baseScore = selectedTrait.breedVal
|
// 개별 형질 선택 시: 육종가(EPD) 값 사용
|
||||||
|
baseScore = selectedTrait.actualValue ?? 0 // 개체 육종가
|
||||||
basePercentile = selectedTrait.percentile
|
basePercentile = selectedTrait.percentile
|
||||||
baseLabel = selectedTrait.name
|
baseLabel = selectedTrait.name
|
||||||
// API에서 가져온 형질별 농가/보은군 평균 사용 (없으면 0으로 - 데이터 로딩 중)
|
// API에서 가져온 형질별 농가/보은군 평균 육종가(EPD) 사용
|
||||||
baseFarmScore = traitRankData?.farmAvgEbv ?? 0
|
baseFarmScore = traitRankData?.farmAvgEpd ?? 0
|
||||||
baseRegionScore = traitRankData?.regionAvgEbv ?? 0
|
baseRegionScore = traitRankData?.regionAvgEpd ?? 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,19 +302,34 @@ export function NormalDistributionChart({
|
|||||||
cowVsFarm,
|
cowVsFarm,
|
||||||
cowVsRegion
|
cowVsRegion
|
||||||
}
|
}
|
||||||
}, [chartFilterTrait, overallScore, overallPercentile, selectedTraitData, traitRankData, farmAvgZ, regionAvgZ])
|
}, [chartFilterTrait, overallScore, overallPercentile, allTraits, traitRankData, farmAvgZ, regionAvgZ])
|
||||||
|
|
||||||
// X축 범위 및 간격 계산 (내 개체 중심 방식)
|
// X축 범위 및 간격 계산 (내 개체 중심 방식)
|
||||||
const xAxisConfig = useMemo(() => {
|
const xAxisConfig = useMemo(() => {
|
||||||
const { cowVsFarm, cowVsRegion } = chartDisplayValues
|
const { cowVsFarm, cowVsRegion } = chartDisplayValues
|
||||||
const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion))
|
const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion))
|
||||||
|
|
||||||
// 차이가 3 초과면 1 단위, 이하면 0.5 단위
|
// 데이터가 차트의 약 70% 영역에 들어오도록 범위 계산
|
||||||
const step = maxDiff > 3 ? 1 : 0.5
|
// maxDiff가 range의 70%가 되도록: range = maxDiff / 0.7
|
||||||
|
const targetRange = maxDiff / 0.7
|
||||||
|
|
||||||
// 범위 계산 (최소 2.5, step 단위로 올림)
|
// step 계산: 범위에 따라 적절한 간격 선택
|
||||||
const minRange = step === 1 ? 3 : 2.5
|
let step: number
|
||||||
const range = Math.max(minRange, Math.ceil(maxDiff / step) * step + step)
|
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 }
|
return { min: -range, max: range, step }
|
||||||
}, [chartDisplayValues])
|
}, [chartDisplayValues])
|
||||||
@@ -401,19 +419,7 @@ export function NormalDistributionChart({
|
|||||||
</div>
|
</div>
|
||||||
{categoryTraits.map((trait) => (
|
{categoryTraits.map((trait) => (
|
||||||
<SelectItem key={trait.id} value={trait.name}>
|
<SelectItem key={trait.id} value={trait.name}>
|
||||||
<div className="flex items-center gap-2.5">
|
{trait.name}
|
||||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 ${chartFilterTrait === trait.name
|
|
||||||
? 'bg-primary border-primary'
|
|
||||||
: 'border-muted-foreground/50'
|
|
||||||
}`}>
|
|
||||||
{chartFilterTrait === trait.name && (
|
|
||||||
<svg className="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span>{trait.name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -423,16 +429,6 @@ export function NormalDistributionChart({
|
|||||||
)}
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{/* 선택된 형질 해제 버튼 */}
|
|
||||||
{chartFilterTrait !== 'overall' && (
|
|
||||||
<button
|
|
||||||
onClick={() => setChartFilterTrait('overall')}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" />
|
|
||||||
<span className="hidden sm:inline">해제</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{/* 확대 버튼 */}
|
{/* 확대 버튼 */}
|
||||||
<button
|
<button
|
||||||
@@ -449,10 +445,10 @@ export function NormalDistributionChart({
|
|||||||
<div className="mb-3 sm:mb-5 px-4 py-3 sm:p-5 bg-slate-50 rounded-xl border border-slate-200">
|
<div className="mb-3 sm:mb-5 px-4 py-3 sm:p-5 bg-slate-50 rounded-xl border border-slate-200">
|
||||||
{/* 모바일: 명확한 레이아웃 */}
|
{/* 모바일: 명확한 레이아웃 */}
|
||||||
<div className="sm:hidden space-y-2.5">
|
<div className="sm:hidden space-y-2.5">
|
||||||
{/* 상위 % + 기준 표시 */}
|
{/* 상위 % + 조회 기준 표시 */}
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="px-3 py-1 bg-slate-200 text-slate-700 text-sm font-semibold rounded-full">
|
<span className="px-3 py-1 bg-slate-200 text-slate-700 text-sm font-bold rounded-full">
|
||||||
{chartFilterTrait === 'overall' ? '선발지수' : chartFilterTrait} 기준
|
{chartFilterTrait === 'overall' ? '선발지수' : chartFilterTrait} 조회 기준
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1 bg-primary text-white text-sm font-bold rounded-full">
|
<span className="px-3 py-1 bg-primary text-white text-sm font-bold rounded-full">
|
||||||
상위 {chartDisplayValues.percentile?.toFixed(0) || 0}%
|
상위 {chartDisplayValues.percentile?.toFixed(0) || 0}%
|
||||||
@@ -558,10 +554,10 @@ export function NormalDistributionChart({
|
|||||||
|
|
||||||
{/* 데스크탑: 기존 레이아웃 */}
|
{/* 데스크탑: 기존 레이아웃 */}
|
||||||
<div className="hidden sm:block">
|
<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-semibold rounded-full">
|
<span className="px-4 py-1.5 bg-slate-200 text-slate-700 text-base font-bold rounded-full">
|
||||||
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 기준
|
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} 조회 기준
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -318,12 +318,13 @@ export default function CowOverviewPage() {
|
|||||||
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
'우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate',
|
||||||
]
|
]
|
||||||
|
|
||||||
// 필터가 활성화되고 형질이 선택되어 있으면 가중치 조건 생성 (리스트와 동일 로직)
|
// 필터가 활성화되어 있으면 가중치 > 0인 형질만 사용 (리스트와 동일 로직)
|
||||||
const finalConditions = filters.isActive && filters.selectedTraits && filters.selectedTraits.length > 0
|
const traitConditions = Object.entries(filters.traitWeights as Record<string, number>)
|
||||||
? filters.selectedTraits.map(traitNm => ({
|
.filter(([, weight]) => weight > 0)
|
||||||
traitNm,
|
.map(([traitNm, weight]) => ({ traitNm, weight }))
|
||||||
weight: ((filters.traitWeights as Record<string, number>)[traitNm] || 100) / 100 // 0-100 → 0-1로 정규화
|
|
||||||
}))
|
const finalConditions = filters.isActive && traitConditions.length > 0
|
||||||
|
? traitConditions
|
||||||
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }))
|
: ALL_TRAITS.map(traitNm => ({ traitNm, weight: 1 }))
|
||||||
|
|
||||||
|
|
||||||
@@ -361,7 +362,7 @@ export default function CowOverviewPage() {
|
|||||||
// 종합 지표
|
// 종합 지표
|
||||||
const overallScore = useMemo(() => {
|
const overallScore = useMemo(() => {
|
||||||
if (selectionIndex?.score !== null && selectionIndex?.score !== undefined) {
|
if (selectionIndex?.score !== null && selectionIndex?.score !== undefined) {
|
||||||
return selectionIndex.score
|
return selectionIndex.score // 내개체
|
||||||
}
|
}
|
||||||
if (GENOMIC_TRAITS.length === 0) return 0
|
if (GENOMIC_TRAITS.length === 0) return 0
|
||||||
return GENOMIC_TRAITS.reduce((sum, t) => sum + t.breedVal, 0) / GENOMIC_TRAITS.length
|
return GENOMIC_TRAITS.reduce((sum, t) => sum + t.breedVal, 0) / GENOMIC_TRAITS.length
|
||||||
@@ -642,7 +643,7 @@ export default function CowOverviewPage() {
|
|||||||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const chipSireName = genomeData[0]?.request?.chipSireName
|
const chipSireName = genomeRequest?.chipSireName
|
||||||
if (chipSireName === '일치') {
|
if (chipSireName === '일치') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
@@ -660,7 +661,7 @@ export default function CowOverviewPage() {
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
<span>정보없음</span>
|
<span>분석불가</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -678,7 +679,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="text-2xl font-bold text-foreground">-</span>
|
<span className="text-2xl font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const chipDamName = genomeData[0]?.request?.chipDamName
|
const chipDamName = genomeRequest?.chipDamName
|
||||||
if (chipDamName === '일치') {
|
if (chipDamName === '일치') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
@@ -717,7 +718,7 @@ export default function CowOverviewPage() {
|
|||||||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const chipSireName = genomeData[0]?.request?.chipSireName
|
const chipSireName = genomeRequest?.chipSireName
|
||||||
if (chipSireName === '일치') {
|
if (chipSireName === '일치') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
@@ -735,7 +736,7 @@ export default function CowOverviewPage() {
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
<span>정보없음</span>
|
<span>분석불가</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -751,7 +752,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="text-base font-bold text-foreground">-</span>
|
<span className="text-base font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const chipDamName = genomeData[0]?.request?.chipDamName
|
const chipDamName = genomeRequest?.chipDamName
|
||||||
if (chipDamName === '일치') {
|
if (chipDamName === '일치') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
@@ -827,8 +828,8 @@ export default function CowOverviewPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 형질별 표준화육종가 비교 */}
|
{/* 유전체 형질별 육종가 비교 */}
|
||||||
<h3 className="text-lg lg:text-xl font-bold text-foreground">형질별 표준화육종가 비교</h3>
|
<h3 className="text-lg lg:text-xl font-bold text-foreground">유전체 형질별 육종가 비교</h3>
|
||||||
<CategoryEvaluationCard
|
<CategoryEvaluationCard
|
||||||
categoryStats={categoryStats}
|
categoryStats={categoryStats}
|
||||||
comparisonAverages={comparisonAverages}
|
comparisonAverages={comparisonAverages}
|
||||||
@@ -1027,7 +1028,7 @@ export default function CowOverviewPage() {
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
<span>정보없음</span>
|
<span>분석불가</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1100,7 +1101,7 @@ export default function CowOverviewPage() {
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
<span>정보없음</span>
|
<span>분석불가</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1154,7 +1155,7 @@ export default function CowOverviewPage() {
|
|||||||
<CardContent className="p-8 text-center">
|
<CardContent className="p-8 text-center">
|
||||||
<BarChart3 className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
<BarChart3 className="h-12 w-12 text-slate-400 mx-auto mb-4" />
|
||||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">
|
<h3 className="text-lg font-semibold text-slate-700 mb-2">
|
||||||
{genomeRequest ? '유전체 분석 불가' : '유전체 미분석'}
|
{genomeRequest ? '유전체 분석 불가' : '유전체 분석불가'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
{genomeRequest
|
{genomeRequest
|
||||||
@@ -1272,7 +1273,7 @@ export default function CowOverviewPage() {
|
|||||||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const chipSireName = genomeData[0]?.request?.chipSireName
|
const chipSireName = genomeRequest?.chipSireName
|
||||||
if (chipSireName === '일치') {
|
if (chipSireName === '일치') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
@@ -1290,7 +1291,7 @@ export default function CowOverviewPage() {
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
<span className="flex items-center gap-1.5 bg-slate-400 text-white text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
<span>정보없음</span>
|
<span>분석불가</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1308,7 +1309,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="text-2xl font-bold text-foreground">-</span>
|
<span className="text-2xl font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const chipDamName = genomeData[0]?.request?.chipDamName
|
const chipDamName = genomeRequest?.chipDamName
|
||||||
if (chipDamName === '일치') {
|
if (chipDamName === '일치') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
<span className="flex items-center gap-1.5 bg-primary text-primary-foreground text-sm font-semibold px-3 py-1.5 rounded-full shrink-0">
|
||||||
@@ -1346,7 +1347,7 @@ export default function CowOverviewPage() {
|
|||||||
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
{cow?.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const chipSireName = genomeData[0]?.request?.chipSireName
|
const chipSireName = genomeRequest?.chipSireName
|
||||||
if (chipSireName === '일치') {
|
if (chipSireName === '일치') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
@@ -1364,7 +1365,7 @@ export default function CowOverviewPage() {
|
|||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
<span className="flex items-center gap-1 bg-slate-400 text-white text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
<span>정보없음</span>
|
<span>분석불가</span>
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1380,7 +1381,7 @@ export default function CowOverviewPage() {
|
|||||||
<span className="text-base font-bold text-foreground">-</span>
|
<span className="text-base font-bold text-foreground">-</span>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const chipDamName = genomeData[0]?.request?.chipDamName
|
const chipDamName = genomeRequest?.chipDamName
|
||||||
if (chipDamName === '일치') {
|
if (chipDamName === '일치') {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
<span className="flex items-center gap-1 bg-primary text-primary-foreground text-xs font-semibold px-2 py-1 rounded-full shrink-0">
|
||||||
@@ -1435,8 +1436,7 @@ export default function CowOverviewPage() {
|
|||||||
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
<div className="flex bg-slate-100 rounded-lg p-1 gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setGeneTypeFilter('all')}
|
onClick={() => setGeneTypeFilter('all')}
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${geneTypeFilter === 'all'
|
||||||
geneTypeFilter === 'all'
|
|
||||||
? 'bg-white text-slate-900 shadow-sm'
|
? 'bg-white text-slate-900 shadow-sm'
|
||||||
: 'text-slate-500 hover:text-slate-700'
|
: 'text-slate-500 hover:text-slate-700'
|
||||||
}`}
|
}`}
|
||||||
@@ -1445,8 +1445,7 @@ export default function CowOverviewPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setGeneTypeFilter('QTY')}
|
onClick={() => setGeneTypeFilter('QTY')}
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${geneTypeFilter === 'QTY'
|
||||||
geneTypeFilter === 'QTY'
|
|
||||||
? 'bg-white text-slate-900 shadow-sm'
|
? 'bg-white text-slate-900 shadow-sm'
|
||||||
: 'text-slate-500 hover:text-slate-700'
|
: 'text-slate-500 hover:text-slate-700'
|
||||||
}`}
|
}`}
|
||||||
@@ -1455,8 +1454,7 @@ export default function CowOverviewPage() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setGeneTypeFilter('QLT')}
|
onClick={() => setGeneTypeFilter('QLT')}
|
||||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${geneTypeFilter === 'QLT'
|
||||||
geneTypeFilter === 'QLT'
|
|
||||||
? 'bg-white text-slate-900 shadow-sm'
|
? 'bg-white text-slate-900 shadow-sm'
|
||||||
: 'text-slate-500 hover:text-slate-700'
|
: 'text-slate-500 hover:text-slate-700'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ function MyCowContent() {
|
|||||||
.filter(([, weight]) => weight > 0)
|
.filter(([, weight]) => weight > 0)
|
||||||
.map(([traitNm, weight]) => ({
|
.map(([traitNm, weight]) => ({
|
||||||
traitNm,
|
traitNm,
|
||||||
weight: weight / 100 // 0-100 → 0-1로 정규화
|
weight // 0-10 가중치 그대로 사용
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 랭킹 모드에 따라 criteriaType 결정
|
// 랭킹 모드에 따라 criteriaType 결정
|
||||||
@@ -987,8 +987,12 @@ function MyCowContent() {
|
|||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit'
|
day: '2-digit'
|
||||||
}) : (
|
}) : (
|
||||||
<span className={cow.unavailableReason ? 'text-red-500 font-medium' : 'text-slate-400'}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||||
{cow.unavailableReason || '미분석'}
|
cow.unavailableReason?.includes('부') ? 'bg-red-100 text-red-700' :
|
||||||
|
cow.unavailableReason?.includes('모') ? 'bg-orange-100 text-orange-700' :
|
||||||
|
'bg-slate-100 text-slate-600'
|
||||||
|
}`}>
|
||||||
|
{cow.unavailableReason || '분석불가'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -1190,12 +1194,16 @@ function MyCowContent() {
|
|||||||
<span className="text-muted-foreground">부</span>
|
<span className="text-muted-foreground">부</span>
|
||||||
<span className="font-medium">{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
<span className="font-medium">{cow.sireKpn && cow.sireKpn !== '0' ? cow.sireKpn : '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-muted-foreground">분석일</span>
|
<span className="text-muted-foreground">{cow.anlysDt ? '분석일' : '분석결과'}</span>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : (
|
{cow.anlysDt ? new Date(cow.anlysDt).toLocaleDateString('ko-KR', { year: '2-digit', month: 'numeric', day: 'numeric' }) : (
|
||||||
<span className={cow.unavailableReason ? 'text-red-500' : 'text-slate-400'}>
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
|
||||||
{cow.unavailableReason || '미분석'}
|
cow.unavailableReason?.includes('부') ? 'bg-red-100 text-red-700' :
|
||||||
|
cow.unavailableReason?.includes('모') ? 'bg-orange-100 text-orange-700' :
|
||||||
|
'bg-slate-100 text-slate-600'
|
||||||
|
}`}>
|
||||||
|
{cow.unavailableReason || '분석불가'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchFarm = async () => {
|
const fetchFarm = async () => {
|
||||||
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const userId = user?.pkUserNo
|
const userId = user?.pkUserNo
|
||||||
const farms: any[] = userId
|
const farms: any[] = userId
|
||||||
@@ -148,9 +149,8 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
fetchFarm()
|
fetchFarm()
|
||||||
} else {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
|
// user가 없으면 loading 상태 유지 (AuthGuard에서 처리)
|
||||||
}, [user])
|
}, [user])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -259,6 +259,8 @@ export default function DashboardPage() {
|
|||||||
xAxisRange: { min: -2.5, max: 2.5 },
|
xAxisRange: { min: -2.5, max: 2.5 },
|
||||||
farmScore: 0,
|
farmScore: 0,
|
||||||
regionScore: 0,
|
regionScore: 0,
|
||||||
|
originalFarmScore: 0,
|
||||||
|
originalRegionScore: 0,
|
||||||
label: '',
|
label: '',
|
||||||
rank: null as number | null,
|
rank: null as number | null,
|
||||||
totalFarms: 0,
|
totalFarms: 0,
|
||||||
@@ -267,6 +269,8 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
let farmScore = 0
|
let farmScore = 0
|
||||||
let regionScore = 0
|
let regionScore = 0
|
||||||
|
let originalFarmScore = 0
|
||||||
|
let originalRegionScore = 0
|
||||||
let label = '전체 선발지수'
|
let label = '전체 선발지수'
|
||||||
let rank: number | null = null
|
let rank: number | null = null
|
||||||
let totalFarms = farmRanking.totalFarmsInRegion || 0
|
let totalFarms = farmRanking.totalFarmsInRegion || 0
|
||||||
@@ -279,6 +283,8 @@ export default function DashboardPage() {
|
|||||||
const rawRegionScore = farmRanking.regionAvgScore ?? 0
|
const rawRegionScore = farmRanking.regionAvgScore ?? 0
|
||||||
farmScore = rawFarmScore - rawRegionScore // 보은군 대비 차이
|
farmScore = rawFarmScore - rawRegionScore // 보은군 대비 차이
|
||||||
regionScore = 0 // 보은군 = 기준점
|
regionScore = 0 // 보은군 = 기준점
|
||||||
|
originalFarmScore = rawFarmScore
|
||||||
|
originalRegionScore = rawRegionScore
|
||||||
label = '전체 선발지수'
|
label = '전체 선발지수'
|
||||||
rank = farmRanking.farmRankInRegion
|
rank = farmRanking.farmRankInRegion
|
||||||
percentile = farmRanking.percentile
|
percentile = farmRanking.percentile
|
||||||
@@ -290,6 +296,8 @@ export default function DashboardPage() {
|
|||||||
const regionEpd = traitData.regionAvgEpd ?? 0
|
const regionEpd = traitData.regionAvgEpd ?? 0
|
||||||
farmScore = farmEpd - regionEpd // 보은군 대비 차이
|
farmScore = farmEpd - regionEpd // 보은군 대비 차이
|
||||||
regionScore = 0 // 보은군 = 기준점 (0)
|
regionScore = 0 // 보은군 = 기준점 (0)
|
||||||
|
originalFarmScore = farmEpd
|
||||||
|
originalRegionScore = regionEpd
|
||||||
label = distributionBasis
|
label = distributionBasis
|
||||||
rank = traitData.rank ?? null
|
rank = traitData.rank ?? null
|
||||||
totalFarms = traitData.totalFarms ?? farmRanking.totalFarmsInRegion ?? 0
|
totalFarms = traitData.totalFarms ?? farmRanking.totalFarmsInRegion ?? 0
|
||||||
@@ -359,6 +367,8 @@ export default function DashboardPage() {
|
|||||||
xAxisRange: { min: -range, max: range },
|
xAxisRange: { min: -range, max: range },
|
||||||
farmScore,
|
farmScore,
|
||||||
regionScore,
|
regionScore,
|
||||||
|
originalFarmScore,
|
||||||
|
originalRegionScore,
|
||||||
label,
|
label,
|
||||||
rank,
|
rank,
|
||||||
totalFarms,
|
totalFarms,
|
||||||
@@ -625,6 +635,8 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const farmScore = farmPositionData.farmScore
|
const farmScore = farmPositionData.farmScore
|
||||||
const regionScore = farmPositionData.regionScore
|
const regionScore = farmPositionData.regionScore
|
||||||
|
const originalFarmScore = farmPositionData.originalFarmScore
|
||||||
|
const originalRegionScore = farmPositionData.originalRegionScore
|
||||||
const farmX = sigmaToX(farmScore)
|
const farmX = sigmaToX(farmScore)
|
||||||
const regionX = sigmaToX(regionScore)
|
const regionX = sigmaToX(regionScore)
|
||||||
const diff = farmScore - regionScore
|
const diff = farmScore - regionScore
|
||||||
@@ -660,7 +672,7 @@ export default function DashboardPage() {
|
|||||||
우리농가
|
우리농가
|
||||||
</text>
|
</text>
|
||||||
<text x={clampedFarmX} y={chartTop - 2} textAnchor="middle" fill="white" fontSize={16} fontWeight={800}>
|
<text x={clampedFarmX} y={chartTop - 2} textAnchor="middle" fill="white" fontSize={16} fontWeight={800}>
|
||||||
{farmScore >= 0 ? '+' : ''}{farmScore.toFixed(2)}
|
{originalFarmScore >= 0 ? '+' : ''}{originalFarmScore.toFixed(2)}
|
||||||
</text>
|
</text>
|
||||||
{/* 배지에서 라인으로 연결 */}
|
{/* 배지에서 라인으로 연결 */}
|
||||||
<line x1={clampedFarmX} y1={chartTop + 4} x2={farmX} y2={chartTop + 20} stroke="#1F3A8F" strokeWidth={2} />
|
<line x1={clampedFarmX} y1={chartTop + 4} x2={farmX} y2={chartTop + 20} stroke="#1F3A8F" strokeWidth={2} />
|
||||||
@@ -668,7 +680,7 @@ export default function DashboardPage() {
|
|||||||
{/* 보은군 평균 라벨 */}
|
{/* 보은군 평균 라벨 */}
|
||||||
<rect x={regionX - 45} y={chartBottom - 50} width={90} height={28} rx={6} fill="#64748b" />
|
<rect x={regionX - 45} y={chartBottom - 50} width={90} height={28} rx={6} fill="#64748b" />
|
||||||
<text x={regionX} y={chartBottom - 32} textAnchor="middle" fill="white" fontSize={13} fontWeight={700}>
|
<text x={regionX} y={chartBottom - 32} textAnchor="middle" fill="white" fontSize={13} fontWeight={700}>
|
||||||
보은군 {regionScore >= 0 ? '+' : ''}{regionScore.toFixed(2)}
|
보은군 {originalRegionScore >= 0 ? '+' : ''}{originalRegionScore.toFixed(2)}
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* 차이 화살표 또는 동일 표시 */}
|
{/* 차이 화살표 또는 동일 표시 */}
|
||||||
@@ -1060,8 +1072,8 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
// 레이더 차트용 설정
|
// 레이더 차트용 설정
|
||||||
const centerX = 140
|
const centerX = 140
|
||||||
const centerY = 140
|
const centerY = 150
|
||||||
const maxRadius = 105
|
const maxRadius = 95
|
||||||
const angleStep = (2 * Math.PI) / categories.length
|
const angleStep = (2 * Math.PI) / categories.length
|
||||||
const startAngle = -Math.PI / 2
|
const startAngle = -Math.PI / 2
|
||||||
|
|
||||||
@@ -1115,7 +1127,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg width="260" height="290" className="overflow-visible scale-100 sm:scale-100">
|
<svg width="280" height="280" className="overflow-visible">
|
||||||
{/* 그리드 라인 */}
|
{/* 그리드 라인 */}
|
||||||
{[0.25, 0.5, 0.75, 1].map((level, idx) => (
|
{[0.25, 0.5, 0.75, 1].map((level, idx) => (
|
||||||
<polygon
|
<polygon
|
||||||
@@ -1162,12 +1174,15 @@ export default function DashboardPage() {
|
|||||||
<circle cx={p.x} cy={p.y} r={20} fill="transparent" />
|
<circle cx={p.x} cy={p.y} r={20} fill="transparent" />
|
||||||
</g>
|
</g>
|
||||||
))}
|
))}
|
||||||
{/* 카테고리 라벨 (클릭/호버 가능) */}
|
{/* 카테고리 라벨 (클릭/호버 가능) - 배지 스타일 */}
|
||||||
{categories.map((cat, i) => {
|
{categories.map((cat, i) => {
|
||||||
const angle = startAngle + i * angleStep
|
const angle = startAngle + i * angleStep
|
||||||
const labelRadius = maxRadius + 22
|
const labelRadius = maxRadius + 22
|
||||||
const labelX = centerX + labelRadius * Math.cos(angle)
|
const labelX = centerX + labelRadius * Math.cos(angle)
|
||||||
const labelY = centerY + labelRadius * Math.sin(angle)
|
const labelY = centerY + labelRadius * Math.sin(angle)
|
||||||
|
const isActive = activeIndex === i
|
||||||
|
const textWidth = cat.length * 14 + 20
|
||||||
|
const textHeight = 28
|
||||||
return (
|
return (
|
||||||
<g key={cat}
|
<g key={cat}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
@@ -1176,6 +1191,17 @@ export default function DashboardPage() {
|
|||||||
onClick={(e) => handleClick(e, i)}
|
onClick={(e) => handleClick(e, i)}
|
||||||
onTouchEnd={(e) => handleClick(e, i)}
|
onTouchEnd={(e) => handleClick(e, i)}
|
||||||
>
|
>
|
||||||
|
{/* 선택 시 배지 배경 */}
|
||||||
|
{isActive && (
|
||||||
|
<rect
|
||||||
|
x={labelX - textWidth / 2}
|
||||||
|
y={labelY - textHeight / 2}
|
||||||
|
width={textWidth}
|
||||||
|
height={textHeight}
|
||||||
|
rx={6}
|
||||||
|
fill="#1F3A8F"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* 호버/터치 영역 확대 */}
|
{/* 호버/터치 영역 확대 */}
|
||||||
<rect
|
<rect
|
||||||
x={labelX - 30}
|
x={labelX - 30}
|
||||||
@@ -1188,7 +1214,9 @@ export default function DashboardPage() {
|
|||||||
x={labelX}
|
x={labelX}
|
||||||
y={labelY}
|
y={labelY}
|
||||||
textAnchor="middle" dominantBaseline="middle"
|
textAnchor="middle" dominantBaseline="middle"
|
||||||
className={`text-sm font-bold transition-colors ${activeIndex === i ? 'fill-[#1F3A8F]' : 'fill-slate-600'}`}
|
fontSize={14}
|
||||||
|
fontWeight={isActive ? 700 : 600}
|
||||||
|
fill={isActive ? '#ffffff' : '#475569'}
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</text>
|
</text>
|
||||||
@@ -1204,38 +1232,13 @@ export default function DashboardPage() {
|
|||||||
const labelX = centerX + labelRadius * Math.cos(angle)
|
const labelX = centerX + labelRadius * Math.cos(angle)
|
||||||
const labelY = centerY + labelRadius * Math.sin(angle)
|
const labelY = centerY + labelRadius * Math.sin(angle)
|
||||||
|
|
||||||
// 툴팁 위치 조정 (라벨 기준으로 배치)
|
// 툴팁 위치 조정 (차트 밖으로 배치)
|
||||||
const tooltipWidth = 160
|
const tooltipWidth = 150
|
||||||
const tooltipHeight = 130
|
const tooltipHeight = 120
|
||||||
|
|
||||||
// 카테고리별 툴팁 위치 최적화
|
// 툴팁 위치 - 차트 중앙에 고정 (어느 화면에서든 잘리지 않음)
|
||||||
let tooltipX = labelX
|
const tooltipX = centerX
|
||||||
let tooltipY = labelY
|
const tooltipY = centerY - tooltipHeight / 2
|
||||||
|
|
||||||
// 성장 (위쪽) - 아래로
|
|
||||||
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 (
|
return (
|
||||||
<g className="pointer-events-none">
|
<g className="pointer-events-none">
|
||||||
@@ -1247,34 +1250,34 @@ export default function DashboardPage() {
|
|||||||
height={tooltipHeight}
|
height={tooltipHeight}
|
||||||
rx={10}
|
rx={10}
|
||||||
fill="#1e293b"
|
fill="#1e293b"
|
||||||
filter="drop-shadow(0 4px 12px rgba(0,0,0,0.25))"
|
filter="drop-shadow(0 4px 10px rgba(0,0,0,0.25))"
|
||||||
/>
|
/>
|
||||||
{/* 카테고리명 + 형질 개수 */}
|
{/* 카테고리명 + 형질 개수 */}
|
||||||
<text x={tooltipX} y={tooltipY + 25} textAnchor="middle" fontSize={16} fontWeight={700} fill="#ffffff">
|
<text x={tooltipX} y={tooltipY + 20} textAnchor="middle" fontSize={15} fontWeight={700} fill="#ffffff">
|
||||||
{data.category}
|
{data.category}
|
||||||
</text>
|
</text>
|
||||||
<text x={tooltipX} y={tooltipY + 43} textAnchor="middle" fontSize={11} fill="#94a3b8">
|
<text x={tooltipX} y={tooltipY + 36} textAnchor="middle" fontSize={11} fill="#94a3b8">
|
||||||
{data.traitCount}개 형질 평균
|
{data.traitCount}개 형질 평균
|
||||||
</text>
|
</text>
|
||||||
{/* 구분선 */}
|
{/* 구분선 */}
|
||||||
<line x1={tooltipX - 65} y1={tooltipY + 55} x2={tooltipX + 65} y2={tooltipY + 55} stroke="#475569" strokeWidth={1} />
|
<line x1={tooltipX - 60} y1={tooltipY + 46} x2={tooltipX + 60} y2={tooltipY + 46} stroke="#475569" strokeWidth={1} />
|
||||||
{/* 보은군 대비 차이 */}
|
{/* 보은군 대비 차이 */}
|
||||||
<text x={tooltipX} y={tooltipY + 77} textAnchor="middle" fontSize={22} fontWeight={800} fill={data.avgEpd >= 0 ? '#60a5fa' : '#f87171'}>
|
<text x={tooltipX} y={tooltipY + 68} textAnchor="middle" fontSize={20} fontWeight={800} fill={data.avgEpd >= 0 ? '#60a5fa' : '#f87171'}>
|
||||||
{data.avgEpd >= 0 ? '+' : ''}{data.avgEpd.toFixed(2)}
|
{data.avgEpd >= 0 ? '+' : ''}{data.avgEpd.toFixed(2)}
|
||||||
</text>
|
</text>
|
||||||
<text x={tooltipX} y={tooltipY + 95} textAnchor="middle" fontSize={11} fill="#94a3b8">
|
<text x={tooltipX} y={tooltipY + 84} textAnchor="middle" fontSize={11} fill="#94a3b8">
|
||||||
보은군 대비
|
보은군 대비
|
||||||
</text>
|
</text>
|
||||||
{/* 순위 표시 */}
|
{/* 순위 배지 */}
|
||||||
<rect
|
<rect
|
||||||
x={tooltipX - 40}
|
x={tooltipX - 38}
|
||||||
y={tooltipY + 103}
|
y={tooltipY + 92}
|
||||||
width={80}
|
width={76}
|
||||||
height={22}
|
height={22}
|
||||||
rx={11}
|
rx={11}
|
||||||
fill="#16a34a"
|
fill="#16a34a"
|
||||||
/>
|
/>
|
||||||
<text x={tooltipX} y={tooltipY + 118} textAnchor="middle" fontSize={12} fontWeight={700} fill="#ffffff">
|
<text x={tooltipX} y={tooltipY + 107} textAnchor="middle" fontSize={12} fontWeight={700} fill="#ffffff">
|
||||||
상위 {data.avgPercentile}%
|
상위 {data.avgPercentile}%
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function CowNumberDisplay({ cowId, cowShortNo, className = '', variant =
|
|||||||
case 'highlight':
|
case 'highlight':
|
||||||
return 'bg-primary/15 text-primary font-bold px-1.5 py-0.5 rounded'
|
return 'bg-primary/15 text-primary font-bold px-1.5 py-0.5 rounded'
|
||||||
default:
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface TraitAverageDto {
|
|||||||
traitName: string; // 형질명
|
traitName: string; // 형질명
|
||||||
category: string; // 카테고리
|
category: string; // 카테고리
|
||||||
avgEbv: number; // 평균 EBV (표준화 육종가)
|
avgEbv: number; // 평균 EBV (표준화 육종가)
|
||||||
|
avgEpd: number; // 평균 EPD (육종가 원본값)
|
||||||
count: number; // 데이터 개수
|
count: number; // 데이터 개수
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,12 +226,15 @@ export interface YearlyTraitTrendDto {
|
|||||||
export interface TraitRankDto {
|
export interface TraitRankDto {
|
||||||
traitName: string;
|
traitName: string;
|
||||||
cowEbv: number | null;
|
cowEbv: number | null;
|
||||||
|
cowEpd: number | null; // 개체 육종가(EPD)
|
||||||
farmRank: number | null;
|
farmRank: number | null;
|
||||||
farmTotal: number;
|
farmTotal: number;
|
||||||
regionRank: number | null;
|
regionRank: number | null;
|
||||||
regionTotal: number;
|
regionTotal: number;
|
||||||
farmAvgEbv: number | null;
|
farmAvgEbv: number | null;
|
||||||
regionAvgEbv: number | null;
|
regionAvgEbv: number | null;
|
||||||
|
farmAvgEpd: number | null; // 농가 평균 육종가(EPD)
|
||||||
|
regionAvgEpd: number | null; // 보은군 평균 육종가(EPD)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export { cowApi } from './cow.api';
|
|||||||
export { dashboardApi } from './dashboard.api';
|
export { dashboardApi } from './dashboard.api';
|
||||||
export { farmApi } from './farm.api';
|
export { farmApi } from './farm.api';
|
||||||
export { geneApi, type GeneDetail, type GeneSummary } from './gene.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 { reproApi } from './repro.api';
|
||||||
export { breedApi } from './breed.api';
|
export { breedApi } from './breed.api';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user