Compare commits

..

8 Commits

Author SHA1 Message Date
NYD
b0a00d8c23 fix_cow_list_search 2026-01-12 11:15:26 +09:00
NYD
d8e7121b1a fix_text_align 2026-01-08 19:18:52 +09:00
NYD
65d56ecc85 fix_loop_loading 2026-01-08 18:59:56 +09:00
NYD
fce5dcc283 fix_unlimit_loading 2026-01-08 18:55:56 +09:00
c3ccab75c8 Update backend/.env 2026-01-08 09:40:28 +00:00
NYD
dabee8666c fix_cow_detail_page 2026-01-08 18:30:03 +09:00
NYD
f8ff86e4ea update_cow_detail_page 2026-01-08 16:04:01 +09:00
NYD
9e5ffb2c15 update_cow_list_detail_page 2026-01-07 17:56:22 +09:00
13 changed files with 2001 additions and 1290 deletions

View File

@@ -3,8 +3,8 @@
# ============================================== # ==============================================
# DATABASE # DATABASE
# POSTGRES_HOST=192.168.11.46 POSTGRES_HOST=192.168.11.46
POSTGRES_HOST=localhost # POSTGRES_HOST=localhost
POSTGRES_USER=genome POSTGRES_USER=genome
POSTGRES_PASSWORD=genome1@3 POSTGRES_PASSWORD=genome1@3
POSTGRES_DB=genome_db POSTGRES_DB=genome_db

View File

@@ -169,18 +169,22 @@ export class CowService {
private async getFilteredCows(filterOptions?: any): Promise<{ cows: CowModel[], mptCowIdMap: Map<string, { testDt: string; monthAge: number }> }> { private async getFilteredCows(filterOptions?: any): Promise<{ cows: CowModel[], mptCowIdMap: Map<string, { testDt: string; monthAge: number }> }> {
// Step 1: 4가지 데이터 소스에서 cowId 수집 (병렬 처리) // Step 1: 4가지 데이터 소스에서 cowId 수집 (병렬 처리)
const [genomeRequestCowIds, genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([ const [genomeRequestCowIds, genomeCowIds, geneCowIds, mptCowIds] = await Promise.all([
// 유전체 분석 의뢰가 있는 개체의 cowId (cow 테이블 조인) // 유전체 분석 의뢰가 있고, 유효한 형질 데이터가 있는 개체의 cowId만 조회
// (genomeScore가 null이지만 anlysDt가 있는 경우 제외)
this.genomeRequestRepository this.genomeRequestRepository
.createQueryBuilder('request') .createQueryBuilder('request')
.innerJoin('request.cow', 'cow') .innerJoin('request.cow', 'cow')
.innerJoin('request.traitDetails', 'trait', 'trait.delDt IS NULL AND trait.traitEbv IS NOT NULL')
.select('DISTINCT cow.cowId', 'cowId') .select('DISTINCT cow.cowId', 'cowId')
.where('request.delDt IS NULL') .where('request.delDt IS NULL')
.andWhere('request.requestDt IS NOT NULL')
.getRawMany(), .getRawMany(),
// 유전체 형질 데이터가 있는 cowId // 유전체 형질 데이터가 있는 cowId (유효한 EBV 값이 있는 경우만)
this.genomeTraitDetailRepository this.genomeTraitDetailRepository
.createQueryBuilder('trait') .createQueryBuilder('trait')
.select('DISTINCT trait.cowId', 'cowId') .select('DISTINCT trait.cowId', 'cowId')
.where('trait.delDt IS NULL') .where('trait.delDt IS NULL')
.andWhere('trait.traitEbv IS NOT NULL')
.getRawMany(), .getRawMany(),
// 유전자 데이터가 있는 cowId // 유전자 데이터가 있는 cowId
this.geneDetailRepository this.geneDetailRepository
@@ -465,8 +469,27 @@ export class CowService {
// "criteriaType": "GENOME" // "criteriaType": "GENOME"
// } // }
// Step 8: 점수 기준 내림차순 정렬 // Step 8: genomeScore(sortValue)가 null/undefined이고 anlysDt가 있는 데이터 제외
const sorted = cowsWithScore.sort((a, b) => { // 단, 부모불일치인 개체는 조회되어야 하므로 제외하지 않음
const filteredCows = cowsWithScore.filter((item) => {
const sortValue = item.sortValue;
const anlysDt = (item.entity as any).anlysDt;
const unavailableReason = (item.entity as any).unavailableReason;
// 부모불일치 관련 사유인 경우는 조회 유지
const isParentMismatch = unavailableReason === '부 불일치' ||
unavailableReason === '모 불일치' ||
unavailableReason === '모 이력제부재';
// sortValue가 null/undefined이고 anlysDt가 있지만, 부모불일치가 아닌 경우만 제외
if ((sortValue === null || sortValue === undefined) && anlysDt && !isParentMismatch) {
return false;
}
return true;
});
// Step 9: 점수 기준 내림차순 정렬
const sorted = filteredCows.sort((a, b) => {
// null 값은 맨 뒤로 // null 값은 맨 뒤로
if (a.sortValue === null && b.sortValue === null) return 0; if (a.sortValue === null && b.sortValue === null) return 0;
if (a.sortValue === null) return 1; if (a.sortValue === null) return 1;
@@ -475,7 +498,7 @@ export class CowService {
return b.sortValue - a.sortValue; return b.sortValue - a.sortValue;
}); });
// Step 9: 순위 부여 후 반환 // Step 10: 순위 부여 후 반환
return { return {
items: sorted.map((item, index) => ({ items: sorted.map((item, index) => ({
...item, ...item,

View File

@@ -1044,6 +1044,7 @@ export class GenomeService {
farmAvgScore: number | null; // 농가 평균 선발지수 farmAvgScore: number | null; // 농가 평균 선발지수
regionAvgScore: number | null; // 보은군(지역) 평균 선발지수 regionAvgScore: number | null; // 보은군(지역) 평균 선발지수
details: { traitNm: string; ebv: number; weight: number; contribution: number }[]; details: { traitNm: string; ebv: number; weight: number; contribution: number }[];
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포
message?: string; message?: string;
}> { }> {
// Step 1: cowId로 개체 조회 // Step 1: cowId로 개체 조회
@@ -1067,7 +1068,7 @@ export class GenomeService {
farmRank: null, farmTotal: 0, farmRank: null, farmTotal: 0,
regionRank: null, regionTotal: 0, regionName: null, farmerName: null, regionRank: null, regionTotal: 0, regionName: null, farmerName: null,
farmAvgScore: null, regionAvgScore: null, farmAvgScore: null, regionAvgScore: null,
details: [], message: '유전체 분석 데이터 없음' details: [], histogram: [], message: '유전체 분석 데이터 없음'
}; };
} }
@@ -1082,7 +1083,7 @@ export class GenomeService {
farmRank: null, farmTotal: 0, farmRank: null, farmTotal: 0,
regionRank: null, regionTotal: 0, regionName: null, farmerName: null, regionRank: null, regionTotal: 0, regionName: null, farmerName: null,
farmAvgScore: null, regionAvgScore: null, farmAvgScore: null, regionAvgScore: null,
details: [], message: '형질 데이터 없음' details: [], histogram: [], message: '형질 데이터 없음'
}; };
} }
@@ -1138,7 +1139,7 @@ export class GenomeService {
// Step 7: 현재 개체의 농장/지역 정보 조회 // Step 7: 현재 개체의 농장/지역 정보 조회
let regionName: string | null = null; let regionName: string | null = null;
let farmerName: string | null = null; let farmerName: string | null = null;
let farmNo: number | null = latestRequest.fkFarmNo; const farmNo: number | null = latestRequest.fkFarmNo;
if (farmNo) { if (farmNo) {
const farm = await this.farmRepository.findOne({ const farm = await this.farmRepository.findOne({
@@ -1162,12 +1163,13 @@ export class GenomeService {
farmAvgScore: null, farmAvgScore: null,
regionAvgScore: null, regionAvgScore: null,
details, details,
histogram: [],
message: '선택한 형질 중 일부 데이터가 없습니다', message: '선택한 형질 중 일부 데이터가 없습니다',
}; };
} }
// Step 9: 농가/지역 순위 및 평균 선발지수 계산 // 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); await this.calculateRanks(cowId, score, traitConditions, farmNo, regionName);
return { return {
@@ -1182,6 +1184,7 @@ export class GenomeService {
farmAvgScore, farmAvgScore,
regionAvgScore, regionAvgScore,
details, details,
histogram,
}; };
} }
@@ -1207,10 +1210,11 @@ export class GenomeService {
regionTotal: number; regionTotal: number;
farmAvgScore: number | null; // 농가 평균 선발지수 farmAvgScore: number | null; // 농가 평균 선발지수
regionAvgScore: number | null; // 보은군(지역) 평균 선발지수 regionAvgScore: number | null; // 보은군(지역) 평균 선발지수
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포
}> { }> {
// 점수가 없으면 순위 계산 불가 // 점수가 없으면 순위 계산 불가
if (currentScore === null) { 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 regionRank: number | null = null;
let regionTotal = allScores.length; const regionTotal = allScores.length;
let regionAvgScore: number | null = null; let regionAvgScore: number | null = null;
const regionIndex = allScores.findIndex(s => s.cowId === currentCowId); const regionIndex = allScores.findIndex(s => s.cowId === currentCowId);
@@ -1309,6 +1313,38 @@ export class GenomeService {
regionAvgScore = Math.round((regionScoreSum / allScores.length) * 100) / 100; 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 { return {
farmRank, farmRank,
farmTotal, farmTotal,
@@ -1316,6 +1352,7 @@ export class GenomeService {
regionTotal, regionTotal,
farmAvgScore, farmAvgScore,
regionAvgScore, regionAvgScore,
histogram,
}; };
} }
@@ -1338,6 +1375,7 @@ export class GenomeService {
regionAvgEbv: number | null; regionAvgEbv: number | null;
farmAvgEpd: number | null; // 농가 평균 육종가(EPD) farmAvgEpd: number | null; // 농가 평균 육종가(EPD)
regionAvgEpd: number | null; // 보은군 평균 육종가(EPD) regionAvgEpd: number | null; // 보은군 평균 육종가(EPD)
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 데이터 분포
}> { }> {
// 1. 현재 개체의 의뢰 정보 조회 // 1. 현재 개체의 의뢰 정보 조회
const cow = await this.cowRepository.findOne({ const cow = await this.cowRepository.findOne({
@@ -1357,6 +1395,7 @@ export class GenomeService {
regionAvgEbv: null, regionAvgEbv: null,
farmAvgEpd: null, farmAvgEpd: null,
regionAvgEpd: null, regionAvgEpd: null,
histogram: [],
}; };
} }
@@ -1378,6 +1417,7 @@ export class GenomeService {
regionAvgEbv: null, regionAvgEbv: null,
farmAvgEpd: null, farmAvgEpd: null,
regionAvgEpd: null, regionAvgEpd: null,
histogram: [],
}; };
} }
@@ -1459,6 +1499,77 @@ export class GenomeService {
? Math.round((farmEpdValues.reduce((sum, v) => sum + v, 0) / farmEpdValues.length) * 100) / 100 ? Math.round((farmEpdValues.reduce((sum, v) => sum + v, 0) / farmEpdValues.length) * 100) / 100
: null; : 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;
// rate 형질 여부 확인 (형질명에 'rate' 또는 'Rate' 포함)
const isRateTrait = traitName.toLowerCase().includes('rate');
// 구간 크기 결정
let binSize: number;
if (isRateTrait) {
// rate 형질: 소수점 binSize 사용 (더 촘촘한 구간)
binSize = range > 0 ? range / 25 : 0.1;
// 너무 작으면 최소값 보장
if (binSize < 0.1) binSize = 0.1;
// 소수점 둘째자리까지 반올림
binSize = Math.round(binSize * 100) / 100;
console.log(`📊 [${traitName}] rate 형질 히스토그램 생성:`, {
: `${minEpd.toFixed(2)} ~ ${maxEpd.toFixed(2)}`,
range: range.toFixed(2),
binSize: binSize.toFixed(2),
: '소수점'
});
} else {
// 일반 형질: 기존 로직 (정수 binSize)
binSize = range > 0 ? Math.ceil(range / 25) : 1;
}
// 구간별 집계
const binMap = new Map<number, { count: number; farmCount: number }>();
epdValues.forEach(({ epd, farmNo: scoreFarmNo }) => {
// rate 형질은 소수점 구간, 일반 형질은 정수 구간
const binStart = isRateTrait
? Math.round((Math.floor(epd / binSize) * binSize) * 100) / 100 // 소수점 둘째자리까지
: 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을 배열로 변환 및 정렬
const sortedHistogram = Array.from(binMap.entries())
.map(([bin, data]) => ({ bin, count: data.count, farmCount: data.farmCount }))
.sort((a, b) => a.bin - b.bin);
histogram.push(...sortedHistogram);
// rate 형질일 때만 로그 출력
if (isRateTrait && sortedHistogram.length > 0) {
console.log(`📊 [${traitName}] 최종 히스토그램:`, {
구간수: sortedHistogram.length,
첫구간: sortedHistogram[0].bin,
마지막구간: sortedHistogram[sortedHistogram.length - 1].bin,
샘플: sortedHistogram.slice(0, 5).map(h => `${h.bin.toFixed(2)}(${h.count}마리)`)
});
}
}
}
return { return {
traitName, traitName,
cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null, cowEbv: cowEbv !== null ? Math.round(cowEbv * 100) / 100 : null,
@@ -1471,6 +1582,7 @@ export class GenomeService {
regionAvgEbv, regionAvgEbv,
farmAvgEpd, farmAvgEpd,
regionAvgEpd, regionAvgEpd,
histogram, // 실제 데이터 분포 추가
}; };
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState } from 'react' import { useState, useEffect, useMemo } from 'react'
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -74,19 +74,53 @@ export function CategoryEvaluationCard({
// 차트에 표시할 형질 목록 (커스텀 가능) // 차트에 표시할 형질 목록 (커스텀 가능)
const [chartTraits, setChartTraits] = useState<string[]>([...DEFAULT_TRAITS]) const [chartTraits, setChartTraits] = useState<string[]>([...DEFAULT_TRAITS])
// 활성화된 형질 목록 (차트에 표시할 형질)
const [activeTraits, setActiveTraits] = useState<Set<string>>(new Set([...DEFAULT_TRAITS]))
// 형질 추가 모달/드로어 상태 // 형질 추가 모달/드로어 상태
const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false) const [isTraitSelectorOpen, setIsTraitSelectorOpen] = useState(false)
// 선택된 형질 (터치/클릭 시 정보 표시용) // 선택된 형질 (터치/클릭 시 정보 표시용)
const [selectedTraitName, setSelectedTraitName] = useState<string | null>(null) const [selectedTraitName, setSelectedTraitName] = useState<string | null>(null)
// 차트 로딩 상태
const [isChartLoading, setIsChartLoading] = useState(false)
// 모바일 여부 확인 // 모바일 여부 확인
const isDesktop = useMediaQuery("(min-width: 768px)") const isDesktop = useMediaQuery("(min-width: 768px)")
// 형질 활성화/비활성화 토글
const toggleTraitActive = (traitName: string) => {
setActiveTraits(prev => {
const newSet = new Set(prev)
if (newSet.has(traitName)) {
// 비활성화 시 제한 없음 (2개 이하일 때 차트 비활성화로 처리)
newSet.delete(traitName)
} else {
newSet.add(traitName)
}
return newSet
})
}
// 차트 데이터 변경 시 로딩 처리
useEffect(() => {
setIsChartLoading(true)
const timer = setTimeout(() => {
setIsChartLoading(false)
}, 300) // 차트 렌더링 시뮬레이션
return () => clearTimeout(timer)
}, [activeTraits])
// 형질 제거 // 형질 제거
const removeTrait = (traitName: string) => { const removeTrait = (traitName: string) => {
if (chartTraits.length > 3) { // 최소 3개는 유지 if (chartTraits.length > 3) { // 최소 3개는 유지
setChartTraits(prev => prev.filter(t => t !== traitName)) setChartTraits(prev => prev.filter(t => t !== traitName))
setActiveTraits(prev => {
const newSet = new Set(prev)
newSet.delete(traitName)
return newSet
})
} }
} }
@@ -94,16 +128,21 @@ export function CategoryEvaluationCard({
const addTrait = (traitName: string) => { const addTrait = (traitName: string) => {
if (chartTraits.length < 7 && !chartTraits.includes(traitName)) { // 최대 7개 if (chartTraits.length < 7 && !chartTraits.includes(traitName)) { // 최대 7개
setChartTraits(prev => [...prev, traitName]) setChartTraits(prev => [...prev, traitName])
setActiveTraits(prev => new Set([...prev, traitName]))
} }
} }
// 기본값으로 초기화 // 기본값으로 초기화
const resetToDefault = () => { const resetToDefault = () => {
setChartTraits([...DEFAULT_TRAITS]) setChartTraits([...DEFAULT_TRAITS])
setActiveTraits(new Set([...DEFAULT_TRAITS]))
} }
// 폴리곤 차트용 데이터 생성 (선택된 형질 기반) - 보은군, 농가, 이 개체 비교 // 폴리곤 차트용 데이터 생성 (활성화된 형질만 포함) - 보은군, 농가, 이 개체 비교
const traitChartData = chartTraits.map(traitName => { const traitChartData = useMemo(() => {
return chartTraits
.filter(traitName => activeTraits.has(traitName))
.map(traitName => {
const trait = allTraits.find((t: GenomeCowTraitDto) => t.traitName === traitName) const trait = allTraits.find((t: GenomeCowTraitDto) => t.traitName === traitName)
// 형질별 평균 데이터에서 해당 형질 찾기 // 형질별 평균 데이터에서 해당 형질 찾기
@@ -131,17 +170,50 @@ export function CategoryEvaluationCard({
diff: trait?.breedVal ?? 0, diff: trait?.breedVal ?? 0,
hasData: !!trait hasData: !!trait
} }
}) })
}, [chartTraits, activeTraits, allTraits, traitComparisonAverages])
// 가장 높은 형질 찾기 (이 개체 기준) // 가장 높은 형질 찾기 (이 개체 기준)
const bestTraitName = traitChartData.reduce((best, current) => const bestTraitName = traitChartData.reduce((best, current) =>
current.breedVal > best.breedVal ? current : best current.breedVal > best.breedVal ? current : best
, traitChartData[0])?.shortName , traitChartData[0])?.shortName
// 동적 스케일 계산 (모든 값의 최대 절대값 기준) // 동적 스케일 계산 (실제 데이터 범위를 기반으로, min/max 각각에 5% 여유분만 추가)
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal]) // useMemo를 사용하는 이유: traitChartData가 변경될 때만 재계산하여 성능 최적화
const maxAbsValue = Math.max(...allValues.map(Math.abs), 0.3) // 최소 0.3 // - traitChartData는 activeTraits, chartTraits, allTraits, traitComparisonAverages에 의존
const dynamicDomain = Math.ceil(maxAbsValue * 1.2 * 10) / 10 // 20% 여유 // - 이 값들이 변경될 때마다 스케일을 다시 계산해야 함
// - useMemo를 사용하면 의존성이 변경되지 않으면 이전 계산 결과를 재사용
const dynamicDomain = useMemo(() => {
if (traitChartData.length === 0) return [-0.3, 0.3]
// 모든 값 수집 (breedVal, regionVal, farmVal)
const allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
// 실제 데이터의 최소값과 최대값 찾기
const minValue = Math.min(...allValues)
const maxValue = Math.max(...allValues)
// 데이터 범위 계산
const dataRange = maxValue - minValue
// 데이터 범위가 너무 작으면 최소 범위 보장 (0.3)
const effectiveRange = Math.max(dataRange, 0.3)
// min/max 각각에 범위의 10%만큼 여유분 추가 (대칭 처리하지 않음)
const padding = effectiveRange * 0.10
let domainMin = minValue - padding
let domainMax = maxValue + padding
// 소수점 첫째자리까지 반올림
domainMin = Math.floor(domainMin * 10) / 10
domainMax = Math.ceil(domainMax * 10) / 10
return [domainMin, domainMax]
}, [traitChartData])
// 활성화된 형질 개수
const activeTraitsCount = activeTraits.size
const hasEnoughTraits = activeTraitsCount >= 3
// 형질 이름으로 원본 형질명 찾기 (shortName -> name) // 형질 이름으로 원본 형질명 찾기 (shortName -> name)
const findTraitNameByShortName = (shortName: string) => { const findTraitNameByShortName = (shortName: string) => {
@@ -189,7 +261,7 @@ export function CategoryEvaluationCard({
y={0} y={0}
dy={5} dy={5}
textAnchor="middle" textAnchor="middle"
fontSize={15} fontSize={isDesktop ? 17 : 15}
fontWeight={isSelected ? 700 : 600} fontWeight={isSelected ? 700 : 600}
fill={isSelected ? '#ffffff' : '#334155'} fill={isSelected ? '#ffffff' : '#334155'}
> >
@@ -227,11 +299,12 @@ export function CategoryEvaluationCard({
}`} }`}
> >
{getTraitDisplayName(trait)} {getTraitDisplayName(trait)}
{traitData && traitData.breedVal !== undefined && ( {/* 육종가(EBV) 값 표시 (주석 처리) */}
{/* {traitData && traitData.breedVal !== undefined && (
<span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}> <span className={`ml-1 text-xs ${isSelected ? 'text-primary-foreground/80' : 'text-muted-foreground'}`}>
({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)}) ({traitData.breedVal > 0 ? '+' : ''}{traitData.breedVal.toFixed(1)})
</span> </span>
)} )} */}
</button> </button>
) )
})} })}
@@ -282,38 +355,52 @@ export function CategoryEvaluationCard({
return ( return (
<div className="bg-white rounded-xl border border-border overflow-hidden"> <div className="bg-white rounded-xl border border-border overflow-hidden">
{/* 2열 레이아웃: 왼쪽 차트 + 오른쪽 카드 */} {/* 2열 레이아웃: 왼쪽 차트 + 오른쪽 카드 */}
<div className="p-4 lg:p-6"> <div className="p-4 lg:p-6 lg:pb-0">
{/* 형질 선택 칩 영역 */} {/* 형질 선택 칩 영역 */}
<div className="mb-4 lg:mb-6"> <div className="mb-4 lg:mb-2">
<div className="flex items-center justify-between mb-2 lg:mb-3"> <div className="flex items-center justify-between mb-2 lg:mb-0">
<span className="text-sm lg:text-base font-medium text-muted-foreground"> </span> <div className="flex items-center justify-between gap-2 flex-wrap">
<button <div className="text-lg lg:text-base font-medium text-muted-foreground"> :</div>
onClick={() => setIsTraitSelectorOpen(true)} <div className="flex flex-wrap gap-1.5 lg:gap-2">
className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors" {chartTraits.map(trait => {
> const isActive = activeTraits.has(trait)
return (
<div
key={trait}
className={`inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 rounded-full text-sm lg:text-base font-medium transition-all cursor-pointer ${
isActive
? 'bg-primary text-primary-foreground'
: 'bg-primary/10 text-primary opacity-50'
}`}
onClick={() => toggleTraitActive(trait)}
>
<span className="text-md font-bold">{getTraitDisplayName(trait)}</span>
{chartTraits.length > 3 && (
<span
onClick={(e) => {
e.stopPropagation()
removeTrait(trait)
setActiveTraits(prev => {
const newSet = new Set(prev)
newSet.delete(trait)
return newSet
})
}}
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 hover:opacity-100 transition-opacity cursor-pointer"
>
<X className="w-3 h-3 lg:w-4 lg:h-4" />
</span>
)}
</div>
)
})}
</div>
</div>
<button onClick={() => setIsTraitSelectorOpen(true)} className="text-sm lg:text-base text-primary flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary/30 hover:bg-primary/10 hover:border-primary/50 transition-colors">
<Pencil className="w-4 h-4 lg:w-5 lg:h-5" /> <Pencil className="w-4 h-4 lg:w-5 lg:h-5" />
</button> </button>
</div> </div>
<div className="flex flex-wrap gap-1.5 lg:gap-2">
{chartTraits.map(trait => (
<span
key={trait}
className="inline-flex items-center gap-1 lg:gap-1.5 px-2.5 lg:px-3.5 py-1 lg:py-1.5 bg-primary/10 text-primary rounded-full text-sm lg:text-base font-medium group"
>
{getTraitDisplayName(trait)}
{chartTraits.length > 3 && (
<button
onClick={() => removeTrait(trait)}
className="w-4 h-4 lg:w-5 lg:h-5 rounded-full hover:bg-primary/20 flex items-center justify-center opacity-60 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3 lg:w-4 lg:h-4" />
</button>
)}
</span>
))}
</div>
{/* 형질 편집 모달 - 웹: Dialog, 모바일: Drawer */} {/* 형질 편집 모달 - 웹: Dialog, 모바일: Drawer */}
{isDesktop ? ( {isDesktop ? (
@@ -342,18 +429,47 @@ export function CategoryEvaluationCard({
<div className={`flex flex-col ${hideTraitCards ? '' : 'lg:flex-row'} gap-6`}> <div className={`flex flex-col ${hideTraitCards ? '' : 'lg:flex-row'} gap-6`}>
{/* 폴리곤 차트 */} {/* 폴리곤 차트 */}
<div className={hideTraitCards ? 'w-full' : 'lg:w-1/2'}> <div className={hideTraitCards ? 'w-full' : 'lg:w-1/2'}>
<div className="bg-muted/20 rounded-xl h-full"> <div className="bg-muted/20 rounded-xl h-full relative">
<div className={hideTraitCards ? 'h-[95vw] max-h-[520px] sm:h-[420px]' : 'h-[95vw] max-h-[520px] sm:h-[440px]'}> <div className={hideTraitCards ? 'h-[95vw] max-h-[520px] sm:h-[420px]' : 'h-[95vw] max-h-[520px] sm:h-[440px]'}>
<ResponsiveContainer width="100%" height="100%"> {/* 범례 - 좌측 상단 */}
<RadarChart data={traitChartData} margin={{ top: 40, right: 45, bottom: 40, left: 45 }}> <div className="absolute top-2 left-2 z-20 flex items-center gap-2 sm:gap-3 flex-wrap">
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#10b981' }}></div>
<span className="text-lg sm:text-base font-medium text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
<span className="text-lg sm:text-base font-medium text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-3.5 sm:h-3.5 rounded" style={{ backgroundColor: '#1482B0' }}></div>
<span className="text-lg sm:text-base font-medium text-foreground">{formatCowNoShort(cowNo)} </span>
</div>
</div>
{/* 로딩 상태 또는 최소 형질 개수 미달 */}
{(isChartLoading || !hasEnoughTraits) ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
{isChartLoading ? (
<>
<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"> ...</p>
</>
) : (
<p className="text-lg text-muted-foreground font-bold"> 3 .</p>
)}
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={traitChartData} margin={{ top: 40, right: 0, bottom: 0, left: 0 }}>
<PolarGrid <PolarGrid
stroke="#e2e8f0" stroke="#e2e8f0"
strokeWidth={1} strokeWidth={1}
/> />
<PolarRadiusAxis <PolarRadiusAxis
angle={90} angle={90}
domain={[-dynamicDomain, dynamicDomain]} domain={dynamicDomain}
tick={{ fontSize: 12, fill: '#64748b', fontWeight: 500 }} tick={{ fontSize: isDesktop ? 16 : 15, fill: '#64748b', fontWeight: 700 }}
tickCount={5} tickCount={5}
axisLine={false} axisLine={false}
/> />
@@ -362,8 +478,9 @@ export function CategoryEvaluationCard({
name="보은군 평균" name="보은군 평균"
dataKey="regionVal" dataKey="regionVal"
stroke="#10b981" stroke="#10b981"
fill="#10b981" // fill="#10b981"
fillOpacity={0.2} // fillOpacity={0.2}
fill="transparent"
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
/> />
@@ -372,8 +489,9 @@ export function CategoryEvaluationCard({
name="농가 평균" name="농가 평균"
dataKey="farmVal" dataKey="farmVal"
stroke="#1F3A8F" stroke="#1F3A8F"
fill="#1F3A8F" // fill="#1F3A8F"
fillOpacity={0.3} // fillOpacity={0.3}
fill="transparent"
strokeWidth={2.5} strokeWidth={2.5}
dot={false} dot={false}
/> />
@@ -382,8 +500,9 @@ export function CategoryEvaluationCard({
name={formatCowNo(cowNo)} name={formatCowNo(cowNo)}
dataKey="breedVal" dataKey="breedVal"
stroke="#1482B0" stroke="#1482B0"
fill="#1482B0" // fill="#1482B0"
fillOpacity={0.35} // fillOpacity={0.35}
fill="transparent"
strokeWidth={isDesktop ? 3 : 2} strokeWidth={isDesktop ? 3 : 2}
dot={{ dot={{
fill: '#1482B0', fill: '#1482B0',
@@ -399,6 +518,7 @@ export function CategoryEvaluationCard({
tickLine={false} tickLine={false}
/> />
<RechartsTooltip <RechartsTooltip
animationDuration={0}
content={({ active, payload }) => { content={({ active, payload }) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
const item = payload[0]?.payload const item = payload[0]?.payload
@@ -408,8 +528,8 @@ export function CategoryEvaluationCard({
return ( return (
<div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg"> <div className="bg-foreground px-4 py-3 rounded-lg text-sm shadow-lg">
<p className="text-white font-bold mb-2">{item?.name}</p> <p className="text-white font-bold mb-2 text-lg">{item?.name}</p>
<div className="space-y-1.5 text-xs"> <div className="space-y-1.5 text-lg">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span> <span className="w-2 h-2 rounded" style={{ backgroundColor: '#10b981' }}></span>
@@ -440,22 +560,7 @@ export function CategoryEvaluationCard({
/> />
</RadarChart> </RadarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> )}
{/* 범례 */}
<div className="flex items-center justify-center gap-4 sm:gap-6 py-3 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" style={{ backgroundColor: '#10b981' }}></div>
<span className="text-sm text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded" style={{ backgroundColor: '#1F3A8F' }}></div>
<span className="text-sm text-muted-foreground"> </span>
</div>
<div className="flex items-center gap-1.5 sm:gap-2">
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded" style={{ backgroundColor: '#1482B0' }}></div>
<span className="text-sm font-medium text-foreground">{formatCowNoShort(cowNo)} </span>
</div>
</div> </div>
{/* 선택된 형질 정보 표시 - 육종가(EPD) 값으로 표시 */} {/* 선택된 형질 정보 표시 - 육종가(EPD) 값으로 표시 */}

View File

@@ -13,6 +13,7 @@ import {
Customized, Customized,
ReferenceLine, ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
Tooltip,
XAxis, XAxis,
YAxis YAxis
} from 'recharts' } from 'recharts'
@@ -97,6 +98,8 @@ interface NormalDistributionChartProps {
// 차트 필터 형질 선택 콜백 (외부 연동용) // 차트 필터 형질 선택 콜백 (외부 연동용)
chartFilterTrait?: string chartFilterTrait?: string
onChartFilterTraitChange?: (trait: string) => void onChartFilterTraitChange?: (trait: string) => void
// 전체 선발지수 히스토그램 (실제 분포 데이터)
selectionIndexHistogram?: { bin: number; count: number; farmCount: number }[]
} }
export function NormalDistributionChart({ export function NormalDistributionChart({
@@ -134,7 +137,8 @@ export function NormalDistributionChart({
highlightMode = null, highlightMode = null,
onHighlightModeChange, onHighlightModeChange,
chartFilterTrait: externalChartFilterTrait, chartFilterTrait: externalChartFilterTrait,
onChartFilterTraitChange onChartFilterTraitChange,
selectionIndexHistogram = []
}: NormalDistributionChartProps) { }: NormalDistributionChartProps) {
const { filters } = useFilterStore() const { filters } = useFilterStore()
@@ -262,16 +266,95 @@ export function NormalDistributionChart({
} }
}, [chartFilterTrait, overallScore, overallPercentile, allTraits, traitRankData, farmAvgZ, regionAvgZ]) }, [chartFilterTrait, overallScore, overallPercentile, allTraits, traitRankData, farmAvgZ, regionAvgZ])
// X축 범위 및 간격 계산 (내 개체 중심 방식) // X축 범위 및 간격 계산 (실제 데이터에 맞게 조정, 중앙 정렬)
const xAxisConfig = useMemo(() => { 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, chartDisplayValues.farmScore, chartDisplayValues.regionScore]
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, chartDisplayValues.farmScore, chartDisplayValues.regionScore]
const minData = Math.min(...allValues)
const maxData = Math.max(...allValues)
console.log(`[${chartFilterTrait}] X축 범위 계산:`, {
bins: `${bins[0].toFixed(2)} ~ ${bins[bins.length-1].toFixed(2)}`,
내개체: 0,
농가평균위치: chartDisplayValues.farmScore.toFixed(2),
보은군평균위치: chartDisplayValues.regionScore.toFixed(2),
allValues범위: `${minData.toFixed(2)} ~ ${maxData.toFixed(2)}`,
})
// 데이터의 중심점 계산
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 { cowVsFarm, cowVsRegion } = chartDisplayValues
const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion)) const maxDiff = Math.max(Math.abs(cowVsFarm), Math.abs(cowVsRegion))
// 데이터가 차트의 약 70% 영역에 들어오도록 범위 계산
// maxDiff가 range의 70%가 되도록: range = maxDiff / 0.7
const targetRange = maxDiff / 0.7 const targetRange = maxDiff / 0.7
// step 계산: 범위에 따라 적절한 간격 선택
let step: number let step: number
if (targetRange <= 1) { if (targetRange <= 1) {
step = 0.2 step = 0.2
@@ -285,12 +368,11 @@ export function NormalDistributionChart({
step = 10 step = 10
} }
// 범위를 step 단위로 올림 (최소값 보장) const minRange = step * 3
const minRange = step * 3 // 최소 3개의 step
const range = Math.max(minRange, Math.ceil(targetRange / step) * step) const range = Math.max(minRange, Math.ceil(targetRange / step) * step)
return { min: -range, max: range, step } return { min: -range, max: range, step }
}, [chartDisplayValues]) }, [chartFilterTrait, selectionIndexHistogram, traitRankData, chartDisplayValues])
// X축 틱 계산 (동적 간격) // X축 틱 계산 (동적 간격)
const xTicks = useMemo(() => { const xTicks = useMemo(() => {
@@ -302,22 +384,118 @@ export function NormalDistributionChart({
return ticks return ticks
}, [xAxisConfig]) }, [xAxisConfig])
// 히스토그램 데이터 생성 (내 개체 중심, 정규분포 곡선) // 히스토그램 데이터 생성 (실제 데이터 분포 사용)
const histogramData = useMemo(() => { 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 range = xAxisConfig.max - xAxisConfig.min
const std = range / 4 const std = range / 4
// 정규분포 PDF 계산 함수 (0~1 범위로 정규화)
const normalPDF = (x: number, mean: number = 0) => { const normalPDF = (x: number, mean: number = 0) => {
const exponent = -Math.pow(x - mean, 2) / (2 * Math.pow(std, 2)) 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 bins = []
const stepSize = range / 100 // 100개의 점으로 부드러운 곡선 const stepSize = range / 100
for (let x = xAxisConfig.min; x <= xAxisConfig.max; x += stepSize) { for (let x = xAxisConfig.min; x <= xAxisConfig.max; x += stepSize) {
const pdfValue = normalPDF(x) * 40 // 최대 40%로 스케일링 const pdfValue = normalPDF(x) * 40
bins.push({ bins.push({
midPoint: x, midPoint: x,
regionPercent: pdfValue, regionPercent: pdfValue,
@@ -325,11 +503,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으로 고정 return bins
const maxPercent = 40 }, [xAxisConfig, chartFilterTrait, traitRankData, chartDisplayValues.originalScore, selectionIndexHistogram])
// Y축 범위 (실제 데이터에 맞게 조정 - 개체수 기준)
const maxCount = useMemo(() => {
if (histogramData.length === 0) return 100
const maxValue = Math.max(...histogramData.map(d => ('count' in d ? d.count : 0) || 0))
// 실제 최대값에 20% 여유만 추가 (너무 빡빡하지 않게)
const calculatedMax = Math.ceil(maxValue * 1.2)
// 최소 10개체 보장 (데이터가 너무 작을 때만)
return Math.max(10, calculatedMax)
}, [histogramData])
return ( return (
@@ -387,14 +584,13 @@ export function NormalDistributionChart({
</Select> </Select>
</div> </div>
{/* 확대 버튼 */} {/* 확대 버튼 */}
<button {/* <button
onClick={onOpenChartModal} 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" 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" /> <Maximize2 className="w-4 h-4" />
<span className="hidden sm:inline">확대</span> <span className="hidden sm:inline">확대</span>
</button> </button> */}
</div> </div>
{/* 순위 및 평균 대비 요약 (차트 위에 배치) */} {/* 순위 및 평균 대비 요약 (차트 위에 배치) */}
@@ -511,16 +707,16 @@ 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-bold 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> */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* 농가 내 순위 */} {/* 농가 내 순위 */}
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm"> <div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-[#1F3A8F]/30 shadow-sm">
<span className="text-sm text-muted-foreground mb-2 font-medium"> </span> <span className="text-2xl text-muted-foreground mb-2 font-medium"> </span>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
{traitRankLoading && chartFilterTrait !== 'overall' ? ( {traitRankLoading && chartFilterTrait !== 'overall' ? (
<span className="text-2xl font-bold text-muted-foreground">...</span> <span className="text-2xl font-bold text-muted-foreground">...</span>
@@ -550,7 +746,7 @@ export function NormalDistributionChart({
{/* 보은군 내 순위 */} {/* 보은군 내 순위 */}
<div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-300 shadow-sm"> <div className="flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 border-slate-300 shadow-sm">
<span className="text-sm text-muted-foreground mb-2 font-medium"> </span> <span className="text-2xl text-muted-foreground mb-2 font-medium"> </span>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
{traitRankLoading && chartFilterTrait !== 'overall' ? ( {traitRankLoading && chartFilterTrait !== 'overall' ? (
<span className="text-2xl font-bold text-muted-foreground">...</span> <span className="text-2xl font-bold text-muted-foreground">...</span>
@@ -580,7 +776,7 @@ export function NormalDistributionChart({
{/* 농가 평균 대비 */} {/* 농가 평균 대비 */}
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalFarmScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}> <div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalFarmScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
<span className="text-sm text-muted-foreground mb-2 font-medium"> </span> <span className="text-2xl text-muted-foreground mb-2 font-medium"> </span>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
{traitRankLoading && chartFilterTrait !== 'overall' ? ( {traitRankLoading && chartFilterTrait !== 'overall' ? (
<span className="text-2xl font-bold text-muted-foreground">...</span> <span className="text-2xl font-bold text-muted-foreground">...</span>
@@ -600,7 +796,7 @@ export function NormalDistributionChart({
{/* 보은군 평균 대비 */} {/* 보은군 평균 대비 */}
<div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalRegionScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}> <div className={`flex flex-col items-center justify-center p-4 bg-white rounded-xl border-2 shadow-sm ${(chartDisplayValues.originalScore - chartDisplayValues.originalRegionScore) >= 0 ? 'border-green-300' : 'border-red-300'}`}>
<span className="text-sm text-muted-foreground mb-2 font-medium"> </span> <span className="text-2xl text-muted-foreground mb-2 font-medium"> </span>
<div className="flex items-baseline gap-1.5"> <div className="flex items-baseline gap-1.5">
{traitRankLoading && chartFilterTrait !== 'overall' ? ( {traitRankLoading && chartFilterTrait !== 'overall' ? (
<span className="text-2xl font-bold text-muted-foreground">...</span> <span className="text-2xl font-bold text-muted-foreground">...</span>
@@ -622,11 +818,20 @@ export function NormalDistributionChart({
</div> </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="농가 및 보은군 내 개체 위치"> <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 {(traitRankLoading && chartFilterTrait !== 'overall') || histogramData.length === 0 ? (
data={histogramData} <div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
margin={isMobileView ? { top: 110, right: 10, left: 5, bottom: 25 } : { top: 110, right: 20, left: 10, bottom: 45 }} <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> <defs>
{/* 보은군 - Blue */} {/* 보은군 - Blue */}
<linearGradient id="regionGradient" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="regionGradient" x1="0" y1="0" x2="0" y2="1">
@@ -660,31 +865,68 @@ export function NormalDistributionChart({
type="number" type="number"
domain={[xAxisConfig.min, xAxisConfig.max]} domain={[xAxisConfig.min, xAxisConfig.max]}
ticks={xTicks} ticks={xTicks}
tick={{ fontSize: isMobileView ? 11 : 13, fill: '#64748b', fontWeight: 600 }} tick={{ fontSize: isMobileView ? 16 : 18, fill: '#64748b', fontWeight: 700 }}
tickLine={false} tickLine={false}
axisLine={{ stroke: '#cbd5e1', strokeWidth: 2 }} axisLine={{ stroke: '#cbd5e1', strokeWidth: 2 }}
tickFormatter={(value) => { tickFormatter={(value) => {
if (value === 0) return '내 개체' if (value === 0) {
// cowNo의 뒤에서 5번째부터 2번째까지 4자리 추출 (예: KOR002203259861 -> 5986)
const shortId = cowNo ? cowNo.slice(-5, -1) : ''
return shortId || '0'
}
return value > 0 ? `+${value}` : `${value}` return value > 0 ? `+${value}` : `${value}`
}} }}
/> />
<YAxis <YAxis
axisLine={{ stroke: '#cbd5e1', strokeWidth: 1 }} axisLine={{ stroke: '#cbd5e1', strokeWidth: 1 }}
tickLine={false} tickLine={false}
tick={{ fontSize: isMobileView ? 10 : 11, fill: '#64748b' }} tick={{ fontSize: isMobileView ? 15 : 17, fill: '#64748b', fontWeight: 700 }}
width={isMobileView ? 35 : 45} width={isMobileView ? 45 : 60}
domain={[0, Math.ceil(maxPercent)]} domain={[0, Math.ceil(maxCount)]}
tickFormatter={(value) => `${Math.round(value)}%`} 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 <Area
type="natural" type="linear"
dataKey="percent" dataKey="count"
stroke="#64748b" stroke="#64748b"
strokeWidth={2.5} strokeWidth={2.5}
fill="url(#areaFillGradient)" 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}
/> />
{/* 보은군 평균 위치 */} {/* 보은군 평균 위치 */}
@@ -833,7 +1075,7 @@ export function NormalDistributionChart({
fontSize={isMobile ? 13 : 15} fontSize={isMobile ? 13 : 15}
fontWeight={600} fontWeight={600}
> >
{cowNo ? cowNo.slice(-5, -1) : '0'}
</text> </text>
<text <text
x={clamp(cowX, cowBadgeW / 2)} x={clamp(cowX, cowBadgeW / 2)}
@@ -1048,10 +1290,11 @@ export function NormalDistributionChart({
/> />
</ComposedChart> </ComposedChart>
</ResponsiveContainer> </ResponsiveContainer>
)}
</div> </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="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> <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> <span className="text-sm sm:text-sm text-muted-foreground">보은군 평균</span>
@@ -1064,7 +1307,7 @@ export function NormalDistributionChart({
<div className="w-3 h-3 sm:w-4 sm:h-4 rounded bg-[#1482B0]"></div> <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> <span className="text-sm sm:text-sm font-medium text-foreground">{displayCowNumber.slice(-4)} 개체</span>
</div> </div>
</div> </div> */}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,9 +1,10 @@
'use client' 'use client'
import { useMemo } from 'react' import { useMemo, useEffect, useState } from 'react'
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { DEFAULT_TRAITS, NEGATIVE_TRAITS, getTraitDisplayName } from "@/constants/traits" import { DEFAULT_TRAITS, NEGATIVE_TRAITS, getTraitDisplayName } from "@/constants/traits"
import { GenomeCowTraitDto } from "@/types/genome.types" import { GenomeCowTraitDto } from "@/types/genome.types"
import { genomeApi, TraitRankDto } from "@/lib/api/genome.api"
// 카테고리별 배지 스타일 (진한 톤) // 카테고리별 배지 스타일 (진한 톤)
const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = { const CATEGORY_STYLES: Record<string, { bg: string; text: string; border: string }> = {
@@ -23,13 +24,14 @@ interface TraitDistributionChartsProps {
regionAvgZ: number regionAvgZ: number
farmAvgZ: number farmAvgZ: number
cowName?: string cowName?: string
cowNo?: string // API 호출용 개체번호
totalCowCount?: number totalCowCount?: number
selectedTraits?: GenomeCowTraitDto[] selectedTraits?: GenomeCowTraitDto[]
traitWeights?: Record<string, number> traitWeights?: Record<string, number>
} }
// 리스트 뷰 컴포넌트 // 테이블 뷰 컴포넌트 (데스크탑)
function TraitListView({ traits, cowName }: { function TraitTableView({ traits, traitRanks }: {
traits: Array<{ traits: Array<{
traitName?: string; traitName?: string;
shortName: string; shortName: string;
@@ -39,62 +41,69 @@ function TraitListView({ traits, cowName }: {
traitVal?: number; traitVal?: number;
hasData?: boolean; hasData?: boolean;
}>; }>;
cowName: string traitRanks: Record<string, TraitRankDto>
}) { }) {
return ( return (
<Card className="bg-white border border-border rounded-xl overflow-hidden shadow-md"> <Card className="hidden lg:block bg-white border border-border rounded-xl overflow-hidden shadow-md">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full text-[1.5rem]">
<thead> <thead>
<tr className="border-b-2 border-border bg-muted/70"> <tr className="border-b-2 border-border bg-muted/70">
<th className="px-3 sm:px-5 py-4 text-center text-sm sm:text-base font-bold text-foreground"></th> <th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"></th>
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground"></th> <th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"> </th>
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground"></th> <th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"> </th>
<th className="px-3 sm:px-5 py-4 text-left text-sm sm:text-base font-bold text-foreground"> </th> <th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"> </th>
<th className="px-3 sm:px-5 py-4 text-center font-semibold text-foreground"> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{traits.map((trait, idx) => ( {traits.map((trait, idx) => {
<tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors"> const rankData = trait.traitName ? traitRanks[trait.traitName] : null
<td className="px-3 sm:px-5 py-4 text-center"> return (
<span className="text-sm sm:text-lg font-semibold text-foreground">{trait.shortName}</span> <tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
</td> <td className="px-3 sm:px-5 py-4 text-center">
<td className="px-3 sm:px-5 py-4 text-left"> <span className="font-medium text-foreground">{trait.shortName}</span>
{trait.traitCategory && ( </td>
<span <td className="px-3 sm:px-5 py-4 text-center">
className={`inline-flex items-center text-xs sm:text-sm font-bold px-3 sm:px-4 py-1.5 rounded-full whitespace-nowrap border-2 ${CATEGORY_STYLES[trait.traitCategory]?.bg || 'bg-slate-50'} ${CATEGORY_STYLES[trait.traitCategory]?.text || 'text-slate-700'} ${CATEGORY_STYLES[trait.traitCategory]?.border || 'border-slate-200'}`} <div className="flex items-center justify-center gap-2">
> <span className={`font-bold ${(() => {
{trait.traitCategory} const value = trait.traitVal ?? 0
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
if (value === 0) return 'text-muted-foreground'
if (isNegativeTrait) {
return value < 0 ? 'text-green-600' : 'text-red-600'
}
return value > 0 ? 'text-green-600' : 'text-red-600'
})()}`}>
{trait.traitVal !== undefined ? (
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
) : '-'}
</span>
</div>
</td>
<td className="px-3 sm:px-5 py-4 text-center">
<span className="font-bold text-foreground">
{(trait.percentile || 0).toFixed(0)}%
</span> </span>
)} </td>
</td> <td className="px-3 sm:px-5 py-4 text-center">
<td className="px-3 sm:px-5 py-4 text-left"> <span className="font-bold text-foreground">
<div className="flex items-center gap-2"> {rankData?.farmRank && rankData.farmTotal ? (
<span className={`text-base sm:text-xl font-bold ${(() => { `${rankData.farmRank}위/${rankData.farmTotal}`
const value = trait.traitVal ?? 0
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
// 등지방두께: 음수가 좋음(녹색), 양수가 나쁨(빨간색)
// 나머지: 양수가 좋음(녹색), 음수가 나쁨(빨간색)
if (value === 0) return 'text-muted-foreground'
if (isNegativeTrait) {
return value < 0 ? 'text-green-600' : 'text-red-600'
}
return value > 0 ? 'text-green-600' : 'text-red-600'
})()}`}>
{trait.traitVal !== undefined ? (
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
) : '-'} ) : '-'}
</span> </span>
</div> </td>
</td> <td className="px-3 sm:px-5 py-4 text-center">
<td className="px-3 sm:px-5 py-4 text-left"> <span className="font-bold text-foreground">
<span className="text-base sm:text-xl font-bold text-foreground"> {rankData?.regionRank && rankData.regionTotal ? (
{(trait.percentile || 0).toFixed(0)}% `${rankData.regionRank}위/${rankData.regionTotal}`
</span> ) : '-'}
</td> </span>
</tr> </td>
))} </tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -103,12 +112,96 @@ function TraitListView({ traits, cowName }: {
) )
} }
// 카드 뷰 컴포넌트 (모바일)
function TraitCardView({ traits, traitRanks }: {
traits: Array<{
traitName?: string;
shortName: string;
breedVal: number;
percentile?: number;
traitCategory?: string;
traitVal?: number;
hasData?: boolean;
}>;
traitRanks: Record<string, TraitRankDto>
}) {
return (
<div className="lg:hidden space-y-3">
{traits.map((trait, idx) => {
const rankData = trait.traitName ? traitRanks[trait.traitName] : null
const value = trait.traitVal ?? 0
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
const valueColor = (() => {
if (value === 0) return 'text-muted-foreground'
if (isNegativeTrait) {
return value < 0 ? 'text-green-600' : 'text-red-600'
}
return value > 0 ? 'text-green-600' : 'text-red-600'
})()
return (
<Card key={trait.traitName || idx} className="bg-white border border-border rounded-xl overflow-hidden shadow-sm">
<CardContent className="p-4">
<div className="space-y-3">
{/* 형질명 */}
<div className="flex items-center justify-between pb-3 border-b border-border">
<span className="text-sm font-medium text-muted-foreground"></span>
<span className="text-base font-bold text-foreground">{trait.shortName}</span>
</div>
{/* 유전체 육종가 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className={`text-base font-bold ${valueColor}`}>
{trait.traitVal !== undefined ? (
<>{trait.traitVal > 0 ? '+' : ''}{trait.traitVal.toFixed(1)}</>
) : '-'}
</span>
</div>
{/* 전국 백분위 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base font-bold text-foreground">
{(trait.percentile || 0).toFixed(0)}%
</span>
</div>
{/* 농가 내 순위 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base font-bold text-foreground">
{rankData?.farmRank && rankData.farmTotal ? (
`${rankData.farmRank}위/${rankData.farmTotal}`
) : '-'}
</span>
</div>
{/* 보은군 내 순위 */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground"> </span>
<span className="text-base font-bold text-foreground">
{rankData?.regionRank && rankData.regionTotal ? (
`${rankData.regionRank}위/${rankData.regionTotal}`
) : '-'}
</span>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)
}
// 메인 컴포넌트 // 메인 컴포넌트
export function TraitDistributionCharts({ export function TraitDistributionCharts({
allTraits, allTraits,
regionAvgZ, regionAvgZ,
farmAvgZ, farmAvgZ,
cowName = '개체', cowName = '개체',
cowNo,
totalCowCount = 100, totalCowCount = 100,
selectedTraits = [], selectedTraits = [],
traitWeights = {} traitWeights = {}
@@ -153,6 +246,53 @@ export function TraitDistributionCharts({
}) })
}, [allTraits, selectedTraits, traitWeights]) }, [allTraits, selectedTraits, traitWeights])
// 표시할 형질명 목록 (순위 조회용)
const traitNames = useMemo(() => {
return displayTraits
.filter(trait => trait.traitName && trait.hasData)
.map(trait => trait.traitName!)
.sort() // 정렬하여 안정적인 키 생성
}, [displayTraits])
// 형질명 목록의 안정적인 키 (dependency용)
const traitNamesKey = useMemo(() => {
return traitNames.join(',')
}, [traitNames])
// 각 형질의 순위 정보 가져오기
const [traitRanks, setTraitRanks] = useState<Record<string, TraitRankDto>>({})
const [loadingRanks, setLoadingRanks] = useState(false)
useEffect(() => {
if (!cowNo || traitNames.length === 0) return
const fetchRanks = async () => {
setLoadingRanks(true)
try {
const rankPromises = traitNames.map(traitName =>
genomeApi.getTraitRank(cowNo, traitName)
.then(rank => ({ traitName, rank }))
.catch(() => null)
)
const results = await Promise.all(rankPromises)
const ranksMap: Record<string, TraitRankDto> = {}
results.forEach(result => {
if (result) {
ranksMap[result.traitName] = result.rank
}
})
setTraitRanks(ranksMap)
} catch (error) {
console.error('순위 정보 로드 실패:', error)
} finally {
setLoadingRanks(false)
}
}
fetchRanks()
}, [cowNo, traitNamesKey])
return ( return (
<> <>
{/* 헤더 */} {/* 헤더 */}
@@ -166,8 +306,11 @@ export function TraitDistributionCharts({
</div> </div>
</div> </div>
{/* 리스트 뷰 */} {/* 테이블 뷰 (데스크탑) */}
<TraitListView traits={displayTraits} cowName={displayCowNumber} /> <TraitTableView traits={displayTraits} traitRanks={traitRanks} />
{/* 카드 뷰 (모바일) */}
<TraitCardView traits={displayTraits} traitRanks={traitRanks} />
</> </>
) )
} }

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* 개체 정보 섹션 */} {/* 개체 정보 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3> <h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden"> <Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
{/* 데스크탑: 가로 그리드 */} {/* 데스크탑: 가로 그리드 */}
@@ -161,7 +161,7 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{/* 검사 정보 */} {/* 검사 정보 */}
{selectedMpt && ( {selectedMpt && (
<> <>
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3> <h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground"> </h3>
<Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden"> <Card className="bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border"> <div className="hidden lg:grid lg:grid-cols-4 divide-x divide-border">
@@ -245,22 +245,22 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{/* 혈액화학검사 결과 테이블 - selectedMpt가 있을 때만 표시 */} {/* 혈액화학검사 결과 테이블 - selectedMpt가 있을 때만 표시 */}
{selectedMpt ? ( {selectedMpt ? (
<> <>
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3> <h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground"> </h3>
{/* 데스크탑: 테이블 */} {/* 데스크탑: 테이블 */}
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden"> <Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full text-[1.5rem]">
<thead> <thead>
<tr className="bg-muted/50 border-b border-border"> <tr className="bg-muted/50 border-b border-border">
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}></th> <th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-left text-sm font-semibold text-muted-foreground" style={{ width: '18%' }}></th> <th className="px-4 py-3 text-left font-semibold text-muted-foreground" style={{ width: '18%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '15%' }}></th> <th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '15%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}></th> <th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '12%' }}></th> <th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '12%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '15%' }}></th> <th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '15%' }}></th>
<th className="px-4 py-3 text-center text-sm font-semibold text-muted-foreground" style={{ width: '16%' }}></th> <th className="px-4 py-3 text-center font-semibold text-muted-foreground" style={{ width: '16%' }}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -275,14 +275,14 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{itemIdx === 0 && ( {itemIdx === 0 && (
<td <td
rowSpan={category.items.length} rowSpan={category.items.length}
className={`px-4 py-3 text-sm font-semibold text-foreground ${category.color} align-middle text-center`} className={`px-4 py-3 font-semibold text-foreground ${category.color} align-middle text-center`}
> >
{category.name} {category.name}
</td> </td>
)} )}
<td className="px-4 py-3 text-sm font-medium text-foreground">{ref?.name || itemKey}</td> <td className="px-4 py-3 font-medium text-foreground">{ref?.name || itemKey}</td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<span className={`text-lg font-bold ${ <span className={`font-bold ${
status === 'safe' ? 'text-green-600' : status === 'safe' ? 'text-green-600' :
status === 'caution' ? 'text-amber-600' : status === 'caution' ? 'text-amber-600' :
'text-muted-foreground' 'text-muted-foreground'
@@ -290,12 +290,12 @@ export function MptTable({ cowShortNo, cowNo, farmNo, cow, genomeRequest }: MptT
{value !== null && value !== undefined ? value.toFixed(2) : '-'} {value !== null && value !== undefined ? value.toFixed(2) : '-'}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.lowerLimit ?? '-'}</td> <td className="px-4 py-3 text-center text-muted-foreground">{ref?.lowerLimit ?? '-'}</td>
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.upperLimit ?? '-'}</td> <td className="px-4 py-3 text-center text-muted-foreground">{ref?.upperLimit ?? '-'}</td>
<td className="px-4 py-3 text-center text-sm text-muted-foreground">{ref?.unit || '-'}</td> <td className="px-4 py-3 text-center text-muted-foreground">{ref?.unit || '-'}</td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
{value !== null && value !== undefined ? ( {value !== null && value !== undefined ? (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold ${ <span className={`inline-flex items-center px-4 py-1 rounded-full font-semibold ${
status === 'safe' ? 'bg-green-100 text-green-700' : status === 'safe' ? 'bg-green-100 text-green-700' :
status === 'caution' ? 'bg-amber-100 text-amber-700' : status === 'caution' ? 'bg-amber-100 text-amber-700' :
'bg-slate-100 text-slate-500' 'bg-slate-100 text-slate-500'

View File

@@ -597,7 +597,7 @@ function MyCowContent() {
// handleCowClick - cowId 또는 pkCowNo로 상세 페이지 이동 // handleCowClick - cowId 또는 pkCowNo로 상세 페이지 이동
const handleCowClick = (cowNo: number | string) => { const handleCowClick = (cowNo: number | string) => {
router.push(`/cow/${cowNo}`) router.push(`/cow/${cowNo}?from=list`)
} }
// 무한 스크롤로 대체됨 // 무한 스크롤로 대체됨
@@ -717,7 +717,7 @@ function MyCowContent() {
<SiteHeader /> <SiteHeader />
<div className="flex flex-1 flex-col h-screen overflow-hidden"> <div className="flex flex-1 flex-col h-screen overflow-hidden">
<div className="@container/main flex flex-1 flex-col gap-1 overflow-hidden"> <div className="@container/main flex flex-1 flex-col gap-1 overflow-hidden">
<div className="flex flex-col gap-2 sm:gap-2.5 md:gap-3 py-3 sm:py-3 md:py-4 flex-shrink-0"> <div className="cow_list_header flex flex-col gap-2 sm:gap-2.5 md:gap-3 py-3 sm:py-3 md:py-4 flex-shrink-0">
{/* 헤더 */} {/* 헤더 */}
<div className="px-3 sm:px-4 md:px-6 lg:px-8"> <div className="px-3 sm:px-4 md:px-6 lg:px-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -733,10 +733,10 @@ function MyCowContent() {
setItemsPerPage(parseInt(value, 10)) setItemsPerPage(parseInt(value, 10))
}} }}
> >
<SelectTrigger className="w-[120px] sm:w-[140px] h-12 sm:h-14 text-lg sm:text-xl font-medium"> <SelectTrigger className="cnt_per_list w-[120px] sm:w-[140px] h-12 sm:h-14 text-lg sm:text-xl font-medium">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="cnt_per_list"> <SelectContent className="cnt_per_list_opts">
<SelectItem value="50" className="text-lg sm:text-xl">50</SelectItem> <SelectItem value="50" className="text-lg sm:text-xl">50</SelectItem>
<SelectItem value="100" className="text-lg sm:text-xl">100</SelectItem> <SelectItem value="100" className="text-lg sm:text-xl">100</SelectItem>
</SelectContent> </SelectContent>
@@ -814,7 +814,7 @@ function MyCowContent() {
{/* 데스크톱 테이블 뷰 */} {/* 데스크톱 테이블 뷰 */}
{( {(
<div className="hidden md:block border rounded-lg overflow-hidden"> <div className="hidden md:block border rounded-lg overflow-hidden">
<div ref={tableScrollRef} className="overflow-y-auto" style={{ height: 'calc(100vh - 405px)', maxHeight: 'calc(100vh - 350px)' }}> <div ref={tableScrollRef} className="cows_list_table overflow-y-auto" style={{ height: 'calc(100vh - 410px)', maxHeight: 'calc(100vh - 350px)' }}>
<table className="w-full"> <table className="w-full">
<thead className="border-b sticky top-0 z-1"> <thead className="border-b sticky top-0 z-1">
{/* <thead className="border-b"> */} {/* <thead className="border-b"> */}
@@ -936,7 +936,7 @@ function MyCowContent() {
{/* 2번 영역: 형질 타이틀 */} {/* 2번 영역: 형질 타이틀 */}
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
<span className="font-bold"></span> <span className="font-bold"></span>
</div> </div>
{/* 3번 영역: 오른쪽 버튼 */} {/* 3번 영역: 오른쪽 버튼 */}
@@ -1067,9 +1067,19 @@ function MyCowContent() {
{cow.genomeScore.toFixed(2)} {cow.genomeScore.toFixed(2)}
</div> </div>
) : ( ) : (
<Badge className="text-sm px-3 py-1.5 bg-slate-600 text-white border-0 font-semibold">
cow.anlysDt ? (
</Badge> <Badge className="text-sm px-3 py-1.5 bg-slate-600 text-white border-0 font-semibold">
</Badge>
) : (
<Badge className="text-sm px-3 py-1.5 bg-slate-300 text-white border-0 font-semibold">
</Badge>
)
// <Badge className="text-sm px-3 py-1.5 bg-slate-600 text-white border-0 font-semibold">
// 분석불가
// </Badge>
)} )}
</td> </td>
{selectedDisplayGenes.length > 0 && ( {selectedDisplayGenes.length > 0 && (
@@ -1293,9 +1303,15 @@ function MyCowContent() {
{cow.genomeScore.toFixed(2)} {cow.genomeScore.toFixed(2)}
</span> </span>
) : ( ) : (
cow.anlysDt ? (
<Badge className="text-xs px-2 py-1 bg-slate-500 text-white border-0 font-medium"> <Badge className="text-xs px-2 py-1 bg-slate-500 text-white border-0 font-medium">
</Badge> </Badge>
) : (
<Badge className="text-xs px-2 py-1 bg-slate-300 text-white border-0 font-medium">
</Badge>
)
)} )}
</div> </div>
</div> </div>
@@ -1448,8 +1464,9 @@ function MyCowContent() {
{selectedDisplayTraits.length > 0 && ( {selectedDisplayTraits.length > 0 && (
<div className="pt-3 border-t mt-3" onClick={(e) => e.stopPropagation()}> <div className="pt-3 border-t mt-3" onClick={(e) => e.stopPropagation()}>
{(() => { {(() => {
// 상위 4개만 표시
const isExpanded = expandedRows.has(`${cow.pkCowNo}-mobile-traits`) 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 const remainingCount = selectedDisplayTraits.length - 4
return ( return (
@@ -1475,7 +1492,8 @@ function MyCowContent() {
) )
})} })}
</div> </div>
{selectedDisplayTraits.length > 4 && ( {/* 형질 더보기 버튼 주석처리 */}
{/* {selectedDisplayTraits.length > 4 && (
<button <button
onClick={() => { onClick={() => {
const newExpanded = new Set(expandedRows) const newExpanded = new Set(expandedRows)
@@ -1491,7 +1509,7 @@ function MyCowContent() {
> >
{isExpanded ? '접기' : `+${remainingCount}개 더`} {isExpanded ? '접기' : `+${remainingCount}개 더`}
</button> </button>
)} )} */}
</> </>
) )
})()} })()}

View File

@@ -110,7 +110,7 @@
} }
.text-base { .text-base {
font-size: 1rem; /* 16px */ font-size: 1.0rem; /* 16px */
line-height: 1.6; line-height: 1.6;
} }

View File

@@ -42,6 +42,7 @@ function TabsTrigger({
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"cursor-pointer",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className
)} )}

View File

@@ -45,8 +45,17 @@ function AnalysisYearProviderInner({ children }: { children: React.ReactNode })
const yearFromUrl = searchParams.get('year') const yearFromUrl = searchParams.get('year')
if (yearFromUrl && !isNaN(Number(yearFromUrl))) { if (yearFromUrl && !isNaN(Number(yearFromUrl))) {
console.log('[AnalysisYear] Initial year from URL:', yearFromUrl) console.log('[AnalysisYear] Initial year from URL:', yearFromUrl)
setSelectedYearState(Number(yearFromUrl)) const year = Number(yearFromUrl)
// availableYears에 포함된 년도만 사용
const validYear = availableYears.includes(year) ? year : currentYear
setSelectedYearState(validYear)
setIsInitialized(true) setIsInitialized(true)
// URL에 유효하지 않은 연도가 있으면 제거
if (!availableYears.includes(year) && pathname !== '/') {
const params = new URLSearchParams(searchParams.toString())
params.delete('year')
router.replace(params.toString() ? `${pathname}?${params.toString()}` : pathname)
}
return return
} }
@@ -54,11 +63,15 @@ function AnalysisYearProviderInner({ children }: { children: React.ReactNode })
if (savedYear && !isNaN(Number(savedYear))) { if (savedYear && !isNaN(Number(savedYear))) {
console.log('[AnalysisYear] Initial year from localStorage:', savedYear) console.log('[AnalysisYear] Initial year from localStorage:', savedYear)
const year = Number(savedYear) const year = Number(savedYear)
setSelectedYearState(year) // availableYears에 포함된 년도만 사용 (없으면 현재 연도 사용)
// URL에 year 파라미터 추가 const validYear = availableYears.includes(year) ? year : currentYear
const params = new URLSearchParams(searchParams.toString()) setSelectedYearState(validYear)
params.set('year', year.toString()) // URL에 year 파라미터 추가 (유효한 년도만, 루트 페이지 제외)
router.replace(`${pathname}?${params.toString()}`) if (pathname !== '/') {
const params = new URLSearchParams(searchParams.toString())
params.set('year', validYear.toString())
router.replace(`${pathname}?${params.toString()}`)
}
} }
setIsInitialized(true) setIsInitialized(true)
@@ -67,16 +80,25 @@ function AnalysisYearProviderInner({ children }: { children: React.ReactNode })
// URL 파라미터와 동기화 (초기화 이후에만 실행) // URL 파라미터와 동기화 (초기화 이후에만 실행)
useEffect(() => { useEffect(() => {
if (!isInitialized) return if (!isInitialized || pathname === '/') return // 루트 페이지에서는 실행 안 함
const yearParam = searchParams.get('year') const yearParam = searchParams.get('year')
if (yearParam && !isNaN(Number(yearParam))) { if (yearParam && !isNaN(Number(yearParam))) {
const year = Number(yearParam) const year = Number(yearParam)
if (availableYears.includes(year) && year !== selectedYear) { if (availableYears.includes(year)) {
setSelectedYearState(year) // 유효한 년도면 상태 업데이트
if (year !== selectedYear) {
setSelectedYearState(year)
}
} else {
// 유효하지 않은 년도면 URL에서 제거하고 현재 연도로 설정
const params = new URLSearchParams(searchParams.toString())
params.delete('year')
router.replace(params.toString() ? `${pathname}?${params.toString()}` : pathname)
setSelectedYearState(currentYear)
} }
} }
}, [searchParams, availableYears, isInitialized, selectedYear]) }, [searchParams, availableYears, isInitialized, selectedYear, currentYear, pathname, router])
const setSelectedYear = (year: number) => { const setSelectedYear = (year: number) => {
console.log('[AnalysisYear] setSelectedYear:', year) console.log('[AnalysisYear] setSelectedYear:', year)

View File

@@ -119,6 +119,7 @@ export const genomeApi = {
farmAvgScore: number | null; // 농가 평균 선발지수 farmAvgScore: number | null; // 농가 평균 선발지수
regionAvgScore: number | null; // 보은군(지역) 평균 선발지수 regionAvgScore: number | null; // 보은군(지역) 평균 선발지수
details: { traitNm: string; ebv: number; weight: number; contribution: number }[]; details: { traitNm: string; ebv: number; weight: number; contribution: number }[];
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 분포
message?: string; message?: string;
}> => { }> => {
return await apiClient.post(`/genome/selection-index/${cowId}`, { traitConditions }); return await apiClient.post(`/genome/selection-index/${cowId}`, { traitConditions });
@@ -211,6 +212,7 @@ export interface TraitRankDto {
regionAvgEbv: number | null; regionAvgEbv: number | null;
farmAvgEpd: number | null; // 농가 평균 육종가(EPD) farmAvgEpd: number | null; // 농가 평균 육종가(EPD)
regionAvgEpd: number | null; // 보은군 평균 육종가(EPD) regionAvgEpd: number | null; // 보은군 평균 육종가(EPD)
histogram: { bin: number; count: number; farmCount: number }[]; // 실제 데이터 분포
} }
/** /**