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)
// 형질별 평균 데이터에서 해당 형질 찾기 // 형질별 평균 데이터에서 해당 형질 찾기
@@ -132,16 +171,49 @@ export function CategoryEvaluationCard({
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% 여유분만 추가)
// useMemo를 사용하는 이유: traitChartData가 변경될 때만 재계산하여 성능 최적화
// - traitChartData는 activeTraits, chartTraits, allTraits, traitComparisonAverages에 의존
// - 이 값들이 변경될 때마다 스케일을 다시 계산해야 함
// - 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 allValues = traitChartData.flatMap(d => [d.breedVal, d.regionVal, d.farmVal])
const maxAbsValue = Math.max(...allValues.map(Math.abs), 0.3) // 최소 0.3
const dynamicDomain = Math.ceil(maxAbsValue * 1.2 * 10) / 10 // 20% 여유 // 실제 데이터의 최소값과 최대값 찾기
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]'}>
{/* 범례 - 좌측 상단 */}
<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%"> <ResponsiveContainer width="100%" height="100%">
<RadarChart data={traitChartData} margin={{ top: 40, right: 45, bottom: 40, left: 45 }}> <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,6 +818,15 @@ 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="농가 및 보은군 내 개체 위치">
{/* 로딩 상태 */}
{(traitRankLoading && chartFilterTrait !== 'overall') || histogramData.length === 0 ? (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-slate-50/80 rounded-xl z-10">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent mb-4"></div>
<p className="text-sm text-muted-foreground font-medium">
{chartFilterTrait === 'overall' ? '전체 선발지수' : chartFilterTrait} ...
</p>
</div>
) : (
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<ComposedChart <ComposedChart
data={histogramData} data={histogramData}
@@ -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,43 +41,35 @@ 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) => {
const rankData = trait.traitName ? traitRanks[trait.traitName] : null
return (
<tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors"> <tr key={trait.traitName || idx} className="border-b border-border last:border-b-0 hover:bg-muted/30 transition-colors">
<td className="px-3 sm:px-5 py-4 text-center"> <td className="px-3 sm:px-5 py-4 text-center">
<span className="text-sm sm:text-lg font-semibold text-foreground">{trait.shortName}</span> <span className="font-medium text-foreground">{trait.shortName}</span>
</td> </td>
<td className="px-3 sm:px-5 py-4 text-left"> <td className="px-3 sm:px-5 py-4 text-center">
{trait.traitCategory && ( <div className="flex items-center justify-center gap-2">
<span <span className={`font-bold ${(() => {
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'}`}
>
{trait.traitCategory}
</span>
)}
</td>
<td className="px-3 sm:px-5 py-4 text-left">
<div className="flex items-center gap-2">
<span className={`text-base sm:text-xl font-bold ${(() => {
const value = trait.traitVal ?? 0 const value = trait.traitVal ?? 0
const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '') const isNegativeTrait = NEGATIVE_TRAITS.includes(trait.traitName || '')
// 등지방두께: 음수가 좋음(녹색), 양수가 나쁨(빨간색)
// 나머지: 양수가 좋음(녹색), 음수가 나쁨(빨간색)
if (value === 0) return 'text-muted-foreground' if (value === 0) return 'text-muted-foreground'
if (isNegativeTrait) { if (isNegativeTrait) {
return value < 0 ? 'text-green-600' : 'text-red-600' return value < 0 ? 'text-green-600' : 'text-red-600'
@@ -88,13 +82,28 @@ function TraitListView({ traits, cowName }: {
</span> </span>
</div> </div>
</td> </td>
<td className="px-3 sm:px-5 py-4 text-left"> <td className="px-3 sm:px-5 py-4 text-center">
<span className="text-base sm:text-xl font-bold text-foreground"> <span className="font-bold text-foreground">
{(trait.percentile || 0).toFixed(0)}% {(trait.percentile || 0).toFixed(0)}%
</span> </span>
</td> </td>
<td className="px-3 sm:px-5 py-4 text-center">
<span className="font-bold text-foreground">
{rankData?.farmRank && rankData.farmTotal ? (
`${rankData.farmRank}위/${rankData.farmTotal}`
) : '-'}
</span>
</td>
<td className="px-3 sm:px-5 py-4 text-center">
<span className="font-bold text-foreground">
{rankData?.regionRank && rankData.regionTotal ? (
`${rankData.regionRank}위/${rankData.regionTotal}`
) : '-'}
</span>
</td>
</tr> </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} />
</> </>
) )
} }

View File

@@ -90,7 +90,16 @@ export default function CowOverviewPage() {
const [geneDataLoaded, setGeneDataLoaded] = useState(false) const [geneDataLoaded, setGeneDataLoaded] = useState(false)
const [geneDataLoading, setGeneDataLoading] = useState(false) const [geneDataLoading, setGeneDataLoading] = useState(false)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<string>('genome') const [activeTab, setActiveTab] = useState<string>(() => {
// 목록에서 진입 시 초기화
if (from === 'list') return 'genome'
// 그 외에는 localStorage에서 복원
if (typeof window !== 'undefined') {
const saved = localStorage.getItem(`cowDetailActiveTab_${cowNo}`)
return saved || 'genome'
}
return 'genome'
})
// 2. 검사 상태 // 2. 검사 상태
const [hasGenomeData, setHasGenomeData] = useState(false) const [hasGenomeData, setHasGenomeData] = useState(false)
@@ -110,6 +119,7 @@ export default function CowOverviewPage() {
farmerName: string | null; farmerName: string | null;
farmAvgScore: number | null; farmAvgScore: number | null;
regionAvgScore: number | null; regionAvgScore: number | null;
histogram: { bin: number; count: number; farmCount: number }[];
} | null>(null) } | null>(null)
// 4. 분포/비교 데이터 // 4. 분포/비교 데이터
@@ -141,14 +151,74 @@ export default function CowOverviewPage() {
}) })
// 7. 유전자 탭 필터/정렬 // 7. 유전자 탭 필터/정렬
const [geneSearchInput, setGeneSearchInput] = useState('') const [geneSearchInput, setGeneSearchInput] = useState(() => {
const [geneSearchKeyword, setGeneSearchKeyword] = useState('') if (typeof window !== 'undefined' && from !== 'list') {
const saved = localStorage.getItem('geneSearchInput')
return saved || ''
}
return ''
})
const [geneSearchKeyword, setGeneSearchKeyword] = useState(() => {
if (typeof window !== 'undefined' && from !== 'list') {
const saved = localStorage.getItem('geneSearchKeyword')
return saved || ''
}
return ''
})
const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all') const [geneTypeFilter, setGeneTypeFilter] = useState<'all' | 'QTY' | 'QLT'>('all')
const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all') const [genotypeFilter, setGenotypeFilter] = useState<'all' | 'homozygous' | 'heterozygous'>('all')
const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName') const [geneSortBy, setGeneSortBy] = useState<'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks'>('snpName')
const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc') const [geneSortOrder, setGeneSortOrder] = useState<'asc' | 'desc'>('asc')
const [geneCurrentPage, setGeneCurrentPage] = useState(1)
const GENES_PER_PAGE = 50 // 무한 스크롤 페이지네이션
const [geneCurrentLoadedPage, setGeneCurrentLoadedPage] = useState(() => {
if (typeof window !== 'undefined' && from !== 'list') {
const saved = localStorage.getItem('geneCurrentLoadedPage')
return saved ? parseInt(saved, 10) : 1
}
return 1
})
const [genesPerPage, setGenesPerPage] = useState(() => {
if (typeof window !== 'undefined' && from !== 'list') {
const saved = localStorage.getItem('genesPerPage')
return saved ? parseInt(saved, 10) : 50
}
return 50
})
const [isLoadingMoreGenes, setIsLoadingMoreGenes] = useState(false)
// ========================================
// useEffect - localStorage 저장 (유전자 탭)
// ========================================
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('geneSearchInput', geneSearchInput)
}
}, [geneSearchInput])
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('geneSearchKeyword', geneSearchKeyword)
}
}, [geneSearchKeyword])
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('genesPerPage', genesPerPage.toString())
}
}, [genesPerPage])
// 검색어 또는 genesPerPage 변경 시 1페이지로 리셋
useEffect(() => {
setGeneCurrentLoadedPage(1)
}, [geneSearchKeyword, genesPerPage])
// activeTab 변경 시 localStorage 저장 (목록에서 진입 시 제외)
useEffect(() => {
if (typeof window !== 'undefined' && from !== 'list') {
localStorage.setItem(`cowDetailActiveTab_${cowNo}`, activeTab)
}
}, [activeTab, cowNo, from])
// ======================================== // ========================================
// useEffect - UI 이벤트 // useEffect - UI 이벤트
@@ -175,11 +245,18 @@ export default function CowOverviewPage() {
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setGeneSearchKeyword(geneSearchInput) setGeneSearchKeyword(geneSearchInput)
setGeneCurrentPage(1) setGeneCurrentLoadedPage(1)
}, 300) }, 300)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [geneSearchInput]) }, [geneSearchInput])
// 유전자 테이블 무한 스크롤: geneCurrentLoadedPage가 변경되면 localStorage에 저장
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('geneCurrentLoadedPage', geneCurrentLoadedPage.toString())
}
}, [geneCurrentLoadedPage])
// ======================================== // ========================================
// 헬퍼 함수 // 헬퍼 함수
// ======================================== // ========================================
@@ -341,12 +418,14 @@ export default function CowOverviewPage() {
setHasReproductionData(false) setHasReproductionData(false)
} }
// 5. 탭 자동 선택 // 5. 탭 자동 선택 (목록에서 진입하거나 저장된 탭이 없을 때만)
if (from === 'list' || (typeof window !== 'undefined' && !localStorage.getItem(`cowDetailActiveTab_${cowNo}`))) {
if (genomeExists) { if (genomeExists) {
setActiveTab('genome') setActiveTab('genome')
} else if (geneData && geneData.length > 0) { } else if (geneData && geneData.length > 0) {
setActiveTab('gene') setActiveTab('gene')
} }
}
// 6. 비교 데이터 + 선발지수 조회 // 6. 비교 데이터 + 선발지수 조회
if (genomeDataResult.length > 0) { if (genomeDataResult.length > 0) {
@@ -480,6 +559,86 @@ export default function CowOverviewPage() {
// 정규분포 곡선 데이터 (전국/지역/농가 비교 차트) // 정규분포 곡선 데이터 (전국/지역/농가 비교 차트)
const multiDistribution = useMemo(() => generateMultipleDistributions(0, 1, regionAvgZ, 1, farmAvgZ, 1), [regionAvgZ, farmAvgZ]) const multiDistribution = useMemo(() => generateMultipleDistributions(0, 1, regionAvgZ, 1, farmAvgZ, 1), [regionAvgZ, farmAvgZ])
// 유전자 데이터 필터링 및 정렬 (useMemo로 최상위에서 관리)
const filteredAndSortedGeneData = useMemo(() => {
const filteredData = geneData.filter(gene => {
// 검색 필터
if (geneSearchKeyword) {
const keyword = geneSearchKeyword.toLowerCase()
const snpName = (gene.snpName || '').toLowerCase()
const chromosome = (gene.chromosome || '').toLowerCase()
const position = (gene.position || '').toLowerCase()
const snpType = (gene.snpType || '').toLowerCase()
const allele1 = (gene.allele1 || '').toLowerCase()
const allele2 = (gene.allele2 || '').toLowerCase()
const remarks = (gene.remarks || '').toLowerCase()
if (!snpName.includes(keyword) &&
!chromosome.includes(keyword) &&
!position.includes(keyword) &&
!snpType.includes(keyword) &&
!allele1.includes(keyword) &&
!allele2.includes(keyword) &&
!remarks.includes(keyword)) {
return false
}
}
// 유전자형 필터
if (genotypeFilter !== 'all') {
const isHomozygous = gene.allele1 === gene.allele2
if (genotypeFilter === 'homozygous' && !isHomozygous) return false
if (genotypeFilter === 'heterozygous' && isHomozygous) return false
}
return true
})
// 정렬
return [...filteredData].sort((a, b) => {
let aVal: string | number = ''
let bVal: string | number = ''
switch (geneSortBy) {
case 'snpName':
aVal = a.snpName || ''
bVal = b.snpName || ''
break
case 'chromosome':
aVal = parseInt(a.chromosome || '0') || 0
bVal = parseInt(b.chromosome || '0') || 0
break
case 'position':
aVal = parseInt(a.position || '0') || 0
bVal = parseInt(b.position || '0') || 0
break
case 'snpType':
aVal = a.snpType || ''
bVal = b.snpType || ''
break
case 'allele1':
aVal = a.allele1 || ''
bVal = b.allele1 || ''
break
case 'allele2':
aVal = a.allele2 || ''
bVal = b.allele2 || ''
break
case 'remarks':
aVal = a.remarks || ''
bVal = b.remarks || ''
break
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal
}
const strA = String(aVal)
const strB = String(bVal)
return geneSortOrder === 'asc'
? strA.localeCompare(strB)
: strB.localeCompare(strA)
})
}, [geneData, geneSearchKeyword, genotypeFilter, geneSortBy, geneSortOrder])
const toggleTraitSelection = (traitId: number) => { const toggleTraitSelection = (traitId: number) => {
setSelectedTraits(prev => setSelectedTraits(prev =>
prev.includes(traitId) prev.includes(traitId)
@@ -488,6 +647,24 @@ export default function CowOverviewPage() {
) )
} }
// 유전자 테이블 스크롤 핸들러 (간단하게 함수로만 정의)
const handleGeneTableScroll = (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget
const { scrollTop, scrollHeight, clientHeight } = target
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100
if (isNearBottom && !isLoadingMoreGenes) {
const totalPages = Math.ceil(filteredAndSortedGeneData.length / genesPerPage)
if (geneCurrentLoadedPage < totalPages) {
setIsLoadingMoreGenes(true)
setTimeout(() => {
setGeneCurrentLoadedPage(prev => prev + 1)
setIsLoadingMoreGenes(false)
}, 300)
}
}
}
if (loading) { if (loading) {
return ( return (
<SidebarProvider> <SidebarProvider>
@@ -512,9 +689,9 @@ export default function CowOverviewPage() {
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<SiteHeader /> <SiteHeader />
<main className="flex-1 overflow-y-auto bg-white min-h-screen"> <main className="flex-1 overflow-y-auto bg-white">
{/* 메인 컨테이너 여백 : p-6 */} {/* 메인 컨테이너 여백 : p-6 */}
<div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6"> <div className="w-full p-6 sm:px-6 lg:px-8 sm:py-6 space-y-6" style={{ paddingBottom: '0px' }}>
{/* 헤더: 뒤로가기 + 타이틀 + 다운로드 */} {/* 헤더: 뒤로가기 + 타이틀 + 다운로드 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-4"> <div className="flex items-center gap-2 sm:gap-4">
@@ -546,13 +723,13 @@ export default function CowOverviewPage() {
{/* 탭 네비게이션 */} {/* 탭 네비게이션 */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full"> <Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="w-full grid grid-cols-3 bg-transparent p-0 h-auto gap-0 rounded-none border-b border-border"> <TabsList className="tabs_nav_area w-full grid grid-cols-3 bg-transparent p-0 h-auto gap-0 rounded-none border-b border-border">
<TabsTrigger <TabsTrigger
value="genome" value="genome"
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none" className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
> >
<BarChart3 className="hidden sm:block h-6 w-6 shrink-0" /> <BarChart3 className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></span> <span className="font-bold text-sm lg:!text-[1.5rem]"></span>
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGenomeData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}> <span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGenomeData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasGenomeData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? '완료' : '미검사'} {hasGenomeData && isValidGenomeAnalysis(genomeRequest?.chipSireName, genomeRequest?.chipDamName, cow?.cowId) ? '완료' : '미검사'}
</span> </span>
@@ -562,7 +739,7 @@ export default function CowOverviewPage() {
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none" className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
> >
<Dna className="hidden sm:block h-6 w-6 shrink-0" /> <Dna className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></span> <span className="font-bold text-sm lg:!text-[1.5rem]"></span>
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}> <span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? '완료' : '미검사'} {hasGeneData && !(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? '완료' : '미검사'}
</span> </span>
@@ -572,19 +749,21 @@ export default function CowOverviewPage() {
className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none" className="flex items-center justify-center gap-1.5 sm:gap-3 rounded-none border-b-2 border-transparent data-[state=active]:border-b-primary data-[state=active]:text-primary bg-transparent px-1.5 sm:px-5 py-2.5 sm:py-5 text-muted-foreground data-[state=active]:shadow-none"
> >
<Activity className="hidden sm:block h-6 w-6 shrink-0" /> <Activity className="hidden sm:block h-6 w-6 shrink-0" />
<span className="font-bold text-sm sm:text-xl"></span> <span className="font-bold text-sm lg:!text-[1.5rem]"></span>
<span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasReproductionData ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}> <span className={`text-xs sm:text-sm px-1.5 sm:px-2.5 py-0.5 sm:py-1 rounded font-semibold shrink-0 ${hasReproductionData ? 'bg-green-500 text-white' : 'bg-slate-300 text-slate-600'}`}>
{hasReproductionData ? '완료' : '미검사'} {hasReproductionData ? '완료' : '미검사'}
</span> </span>
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* 탭 콘텐츠 영역 */}
<div className="tab_contents_area h-[calc(100vh-215px)] sm:h-[calc(100vh-260px)] lg:h-[calc(100vh-275px)] overflow-y-auto">
{/* 유전체 분석 탭 */} {/* 유전체 분석 탭 */}
<TabsContent value="genome" className="mt-6 space-y-6"> <TabsContent value="genome" className="mt-6 space-y-6">
{hasGenomeData ? ( {hasGenomeData ? (
<> <>
{/* 개체 정보 섹션 */} {/* 개체 정보 섹션 */}
<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">
@@ -668,7 +847,7 @@ export default function CowOverviewPage() {
</Card> </Card>
{/* 친자확인 섹션 */} {/* 친자확인 섹션 */}
<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">
@@ -729,7 +908,7 @@ export default function CowOverviewPage() {
{isValidGenomeAnalysis(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) ? ( {isValidGenomeAnalysis(genomeData[0]?.request?.chipSireName, genomeData[0]?.request?.chipDamName, cow?.cowId) ? (
<> <>
{/* 농가 및 보은군 내 개체 위치 */} {/* 농가 및 보은군 내 개체 위치 */}
<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>
<div ref={distributionChartRef}> <div ref={distributionChartRef}>
<NormalDistributionChart <NormalDistributionChart
multiDistribution={multiDistribution} multiDistribution={multiDistribution}
@@ -762,6 +941,7 @@ export default function CowOverviewPage() {
regionRank={selectionIndex?.regionRank} regionRank={selectionIndex?.regionRank}
highlightMode={highlightMode} highlightMode={highlightMode}
onHighlightModeChange={setHighlightMode} onHighlightModeChange={setHighlightMode}
selectionIndexHistogram={selectionIndex?.histogram || []}
regionTotal={selectionIndex?.regionTotal} regionTotal={selectionIndex?.regionTotal}
chartFilterTrait={chartFilterTrait} chartFilterTrait={chartFilterTrait}
onChartFilterTraitChange={setChartFilterTrait} onChartFilterTraitChange={setChartFilterTrait}
@@ -769,7 +949,7 @@ export default function CowOverviewPage() {
</div> </div>
{/* 유전체 형질별 육종가 비교 */} {/* 유전체 형질별 육종가 비교 */}
<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>
<CategoryEvaluationCard <CategoryEvaluationCard
categoryStats={categoryStats} categoryStats={categoryStats}
comparisonAverages={comparisonAverages} comparisonAverages={comparisonAverages}
@@ -781,42 +961,48 @@ export default function CowOverviewPage() {
hideTraitCards={true} hideTraitCards={true}
/> />
<h3 className="text-lg lg:text-xl font-bold text-foreground mt-6"> </h3> <h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground mt-6"> </h3>
<TraitDistributionCharts <TraitDistributionCharts
allTraits={GENOMIC_TRAITS} allTraits={GENOMIC_TRAITS}
regionAvgZ={regionAvgZ} regionAvgZ={regionAvgZ}
farmAvgZ={farmAvgZ} farmAvgZ={farmAvgZ}
cowName={cow?.cowId || cowNo} cowName={cow?.cowId || cowNo}
cowNo={cow?.cowId || cowNo}
totalCowCount={totalCowCount} totalCowCount={totalCowCount}
selectedTraits={filterSelectedTraitData} selectedTraits={filterSelectedTraitData}
traitWeights={filters.traitWeights} traitWeights={filters.traitWeights}
/> />
<h3 className="text-lg lg:text-xl font-bold text-foreground mt-6"> </h3> <h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground mt-6"> </h3>
<div className="analysis_info_notice bg-blue-50 border border-blue-200 rounded-xl p-4 sm:p-5 text-sm sm:text-base text-foreground leading-relaxed">
<p> '한우암소 유전체 분석 서비스' .</p>
<p>--- .</p>
<p> 6 , '25.8.1. ~ '26.1.31. .</p>
<p> (SNP) , .</p>
</div>
<Card className="bg-white border border-border rounded-xl overflow-hidden"> <Card className="bg-white border border-border rounded-xl overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div className="grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-border"> <div className="grid grid-cols-1 sm:grid-cols-3 divide-y sm:divide-y-0 sm:divide-x divide-border">
<div className="p-3 sm:p-4 flex justify-between sm:block"> <div className="p-3 sm:p-4 flex justify-between sm:block">
<div className="text-xs font-medium text-muted-foreground sm:mb-1"></div> <div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1"></div>
<div className="text-sm font-semibold text-foreground"> <div className="text-[1.3rem] font-semibold text-foreground">
{genomeData[0]?.request?.requestDt {genomeData[0]?.request?.requestDt
? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR') ? new Date(genomeData[0].request.requestDt).toLocaleDateString('ko-KR')
: '-'} : '-'}
</div> </div>
</div> </div>
<div className="p-3 sm:p-4 flex justify-between sm:block"> <div className="p-3 sm:p-4 flex justify-between sm:block">
<div className="text-xs font-medium text-muted-foreground sm:mb-1"> </div> <div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1"> </div>
<div className="text-sm font-semibold text-foreground"> <div className="text-[1.3rem] font-semibold text-foreground">
{genomeData[0]?.request?.chipReportDt {genomeData[0]?.request?.chipReportDt
? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR') ? new Date(genomeData[0].request.chipReportDt).toLocaleDateString('ko-KR')
: '-'} : '-'}
</div> </div>
</div> </div>
<div className="p-3 sm:p-4 flex justify-between sm:block"> <div className="p-3 sm:p-4 flex justify-between sm:block">
<div className="text-xs font-medium text-muted-foreground sm:mb-1"> </div> <div className="text-[1.5rem] font-medium text-muted-foreground sm:mb-1"> </div>
<div className="text-sm font-semibold text-foreground"> <div className="text-[1.3rem] font-semibold text-foreground">
{genomeData[0]?.request?.chipType || '-'} {genomeData[0]?.request?.chipType || '-'}
</div> </div>
</div> </div>
@@ -826,7 +1012,7 @@ export default function CowOverviewPage() {
</> </>
) : ( ) : (
<> <>
<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">
@@ -861,7 +1047,7 @@ export default function CowOverviewPage() {
) : ( ) : (
<> <>
{/* 개체 정보 섹션 */} {/* 개체 정보 섹션 */}
<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">
@@ -937,7 +1123,7 @@ export default function CowOverviewPage() {
</Card> </Card>
{/* 친자확인 섹션 */} {/* 친자확인 섹션 */}
<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">
@@ -1022,7 +1208,7 @@ export default function CowOverviewPage() {
) : hasGeneData ? ( ) : hasGeneData ? (
<> <>
{/* 개체 정보 섹션 (유전체 탭과 동일) */} {/* 개체 정보 섹션 (유전체 탭과 동일) */}
<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">
@@ -1106,7 +1292,7 @@ export default function CowOverviewPage() {
</Card> </Card>
{/* 친자확인 결과 섹션 (유전체 탭과 동일) */} {/* 친자확인 결과 섹션 (유전체 탭과 동일) */}
<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">
@@ -1164,7 +1350,24 @@ export default function CowOverviewPage() {
</Card> </Card>
{/* 유전자 검색 및 필터 섹션 */} {/* 유전자 검색 및 필터 섹션 */}
<h3 className="text-lg lg:text-xl font-bold text-foreground"> </h3> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<h3 className="text-lg lg:!text-[1.5rem] font-bold text-foreground"> </h3>
<div className="flex items-center gap-2">
<Select value={genesPerPage.toString()} onValueChange={(value) => {
setGenesPerPage(parseInt(value, 10))
setGeneCurrentLoadedPage(1)
}}>
<SelectTrigger className="w-[90px] h-9 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
<SelectItem value="1000">1000</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 유전자 탭 분기: 분석불가/정보없음만 차단, 불일치/이력제부재는 유전자 데이터 표시 */} {/* 유전자 탭 분기: 분석불가/정보없음만 차단, 불일치/이력제부재는 유전자 데이터 표시 */}
{!(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? ( {!(isExcludedCow(cow?.cowId) || genomeRequest?.chipSireName === '분석불가' || genomeRequest?.chipSireName === '정보없음') ? (
@@ -1182,9 +1385,9 @@ export default function CowOverviewPage() {
</div> </div>
{/* 필터 옵션들 */} {/* 필터 옵션들 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3"> {/* <div className="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 border-t border-slate-200/70 pt-3 sm:pt-3"> */}
{/* 유전자 타입 필터 */} {/* 유전자 타입 필터 */}
<div className="flex items-center gap-2"> {/* <div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600 shrink-0">구분:</span> <span className="text-sm font-medium text-slate-600 shrink-0">구분:</span>
<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
@@ -1215,10 +1418,10 @@ export default function CowOverviewPage() {
육질형 육질형
</button> </button>
</div> </div>
</div> </div> */}
{/* 정렬 드롭다운 */} {/* 정렬 드롭다운 */}
<div className="flex items-center gap-2 sm:ml-auto"> {/* <div className="flex items-center gap-2 sm:ml-auto">
<Select <Select
value={geneSortBy} value={geneSortBy}
onValueChange={(value: 'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks') => setGeneSortBy(value)} onValueChange={(value: 'snpName' | 'chromosome' | 'position' | 'snpType' | 'allele1' | 'allele2' | 'remarks') => setGeneSortBy(value)}
@@ -1248,257 +1451,80 @@ export default function CowOverviewPage() {
<SelectItem value="desc">내림차순</SelectItem> <SelectItem value="desc">내림차순</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div> */}
</div> {/* </div> */}
</div> </div>
{/* 유전자 테이블/카드 */} {/* 유전자 테이블/카드 */}
{(() => { {(() => {
const filteredData = geneData.filter(gene => { // 무한 스크롤 계산
// 검색 필터 (테이블의 모든 필드 검색) const totalItems = geneCurrentLoadedPage * genesPerPage
if (geneSearchKeyword) { const displayData = filteredAndSortedGeneData.length > 0
const keyword = geneSearchKeyword.toLowerCase() ? filteredAndSortedGeneData.slice(0, totalItems)
const snpName = (gene.snpName || '').toLowerCase() : []
const chromosome = (gene.chromosome || '').toLowerCase()
const position = (gene.position || '').toLowerCase()
const snpType = (gene.snpType || '').toLowerCase()
const allele1 = (gene.allele1 || '').toLowerCase()
const allele2 = (gene.allele2 || '').toLowerCase()
const remarks = (gene.remarks || '').toLowerCase()
if (!snpName.includes(keyword) &&
!chromosome.includes(keyword) &&
!position.includes(keyword) &&
!snpType.includes(keyword) &&
!allele1.includes(keyword) &&
!allele2.includes(keyword) &&
!remarks.includes(keyword)) {
return false
}
}
// 유전자형 필터
if (genotypeFilter !== 'all') {
const isHomozygous = gene.allele1 === gene.allele2
if (genotypeFilter === 'homozygous' && !isHomozygous) return false
if (genotypeFilter === 'heterozygous' && isHomozygous) return false
}
return true
})
// 정렬
const sortedData = [...filteredData].sort((a, b) => {
let aVal: string | number = ''
let bVal: string | number = ''
switch (geneSortBy) {
case 'snpName':
aVal = a.snpName || ''
bVal = b.snpName || ''
break
case 'chromosome':
aVal = parseInt(a.chromosome || '0') || 0
bVal = parseInt(b.chromosome || '0') || 0
break
case 'position':
aVal = parseInt(a.position || '0') || 0
bVal = parseInt(b.position || '0') || 0
break
case 'snpType':
aVal = a.snpType || ''
bVal = b.snpType || ''
break
case 'allele1':
aVal = a.allele1 || ''
bVal = b.allele1 || ''
break
case 'allele2':
aVal = a.allele2 || ''
bVal = b.allele2 || ''
break
case 'remarks':
aVal = a.remarks || ''
bVal = b.remarks || ''
break
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return geneSortOrder === 'asc' ? aVal - bVal : bVal - aVal
}
const strA = String(aVal)
const strB = String(bVal)
return geneSortOrder === 'asc'
? strA.localeCompare(strB)
: strB.localeCompare(strA)
})
// 페이지네이션 계산
const totalPages = Math.ceil(sortedData.length / GENES_PER_PAGE)
const startIndex = (geneCurrentPage - 1) * GENES_PER_PAGE
const endIndex = startIndex + GENES_PER_PAGE
const displayData = sortedData.length > 0
? sortedData.slice(startIndex, endIndex)
: Array(10).fill(null)
// 페이지네이션 UI 컴포넌트
const PaginationUI = () => {
if (sortedData.length <= GENES_PER_PAGE) return null
// 표시할 페이지 번호들 계산 (모바일: 3개 단순, 데스크탑: 5개 + 1/마지막 고정)
const getPageNumbers = () => {
const pages: (number | string)[] = []
const showPages = isMobile ? 3 : 5
const offset = isMobile ? 1 : 2
let start = Math.max(1, geneCurrentPage - offset)
let end = Math.min(totalPages, start + showPages - 1)
if (end - start < showPages - 1) {
start = Math.max(1, end - showPages + 1)
}
// 모바일: 현재 페이지 기준 앞뒤만 표시 (1, 마지막 고정 없음)
if (isMobile) {
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
}
// 데스크탑: 1과 마지막 페이지 고정
if (start > 1) {
pages.push(1)
if (start > 2) pages.push('...')
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
if (end < totalPages) {
if (end < totalPages - 1) pages.push('...')
pages.push(totalPages)
}
return pages
}
return (
<div className="px-3 sm:px-4 py-3 bg-muted/30 border-t flex flex-col sm:flex-row items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{sortedData.length.toLocaleString()} {startIndex + 1}-{Math.min(endIndex, sortedData.length)}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(1)}
disabled={geneCurrentPage === 1}
className="px-2.5 h-9 text-sm"
>
«
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(p => Math.max(1, p - 1))}
disabled={geneCurrentPage === 1}
className="px-2.5 h-9 text-sm"
>
</Button>
{getPageNumbers().map((page, idx) => (
typeof page === 'number' ? (
<Button
key={idx}
variant={geneCurrentPage === page ? "default" : "outline"}
size="sm"
onClick={() => setGeneCurrentPage(page)}
className="px-2.5 min-w-[36px] h-9 text-sm"
>
{page}
</Button>
) : (
<span key={idx} className="px-1 text-sm text-muted-foreground">...</span>
)
))}
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={geneCurrentPage === totalPages}
className="px-2.5 h-9 text-sm"
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setGeneCurrentPage(totalPages)}
disabled={geneCurrentPage === totalPages}
className="px-2.5 h-9 text-sm"
>
»
</Button>
</div>
</div>
)
}
return ( return (
<> <>
{/* 데스크톱: 테이블 */} {/* 데스크톱: 테이블 */}
<Card className="hidden lg:block bg-white border border-border shadow-sm rounded-2xl overflow-hidden"> <div className="hidden lg:block mb-0">
<Card className="snp_result_table bg-white border border-border shadow-sm rounded-2xl overflow-hidden">
<CardContent className="p-0"> <CardContent className="p-0">
<div> <div onScroll={handleGeneTableScroll} className="overflow-y-auto" style={{ height: 'calc(100vh - 470px)', maxHeight: 'calc(100vh - 420px)' }}>
<table className="w-full table-fixed"> <table className="w-full table-fixed text-[1.5rem]">
<thead className="bg-muted/50 border-b border-border"> <thead className="bg-slate-50 border-b border-border sticky top-0 z-1">
<tr> <tr>
<th className="px-4 py-3 text-center text-base font-semibold text-muted-foreground w-[22%]">SNP </th> <th className="px-4 py-3 text-center font-semibold text-muted-foreground w-[22%]">SNP </th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[10%]"> </th> <th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[10%]"> </th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[11%]">Position</th> <th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[11%]">Position</th>
<th className="px-3 py-3 text-center text-base font-semibold text-muted-foreground w-[11%]">SNP </th> <th className="px-3 py-3 text-center font-semibold text-muted-foreground w-[11%]">SNP </th>
<th className="px-2 py-3 text-center text-base font-semibold text-muted-foreground w-[13%]"> </th> <th className="px-2 py-3 text-center font-semibold text-muted-foreground w-[13%]"> </th>
<th className="px-2 py-3 text-center text-base font-semibold text-muted-foreground w-[13%]"> </th> <th className="px-2 py-3 text-center font-semibold text-muted-foreground w-[13%]"> </th>
<th className="px-4 py-3 text-center text-base font-semibold text-muted-foreground w-[20%]"></th> <th className="px-4 py-3 text-center font-semibold text-muted-foreground w-[20%]"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{displayData.map((gene, idx) => { {displayData.map((gene, idx) => (
if (!gene) {
return (
<tr key={idx} className="hover:bg-muted/30"> <tr key={idx} className="hover:bg-muted/30">
<td className="px-4 py-3 text-base text-center text-muted-foreground">-</td> <td className="px-4 py-3 text-center font-medium text-foreground">{gene.snpName || '-'}</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td> <td className="px-3 py-3 text-center text-foreground">{gene.chromosome || '-'}</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td> <td className="px-3 py-3 text-center text-foreground">{gene.position || '-'}</td>
<td className="px-3 py-3 text-base text-center text-muted-foreground">-</td> <td className="px-3 py-3 text-center text-foreground">{gene.snpType || '-'}</td>
<td className="px-2 py-3 text-base text-center text-muted-foreground">-</td> <td className="px-2 py-3 text-center text-foreground">{gene.allele1 || '-'}</td>
<td className="px-2 py-3 text-base text-center text-muted-foreground">-</td> <td className="px-2 py-3 text-center text-foreground">{gene.allele2 || '-'}</td>
<td className="px-4 py-3 text-base text-center text-muted-foreground">-</td> <td className="px-4 py-3 text-center text-muted-foreground">{gene.remarks || '-'}</td>
</tr> </tr>
) ))}
} {isLoadingMoreGenes && (
return ( <tr>
<tr key={idx} className="hover:bg-muted/30"> <td colSpan={7} className="px-4 py-3 text-center text-sm text-muted-foreground">
<td className="px-4 py-3 text-center text-base font-medium text-foreground">{gene.snpName || '-'}</td> ...
<td className="px-3 py-3 text-center text-base text-foreground">{gene.chromosome || '-'}</td> </td>
<td className="px-3 py-3 text-center text-base text-foreground">{gene.position || '-'}</td>
<td className="px-3 py-3 text-center text-base text-foreground">{gene.snpType || '-'}</td>
<td className="px-2 py-3 text-center text-base text-foreground">{gene.allele1 || '-'}</td>
<td className="px-2 py-3 text-center text-base text-foreground">{gene.allele2 || '-'}</td>
<td className="px-4 py-3 text-center text-base text-muted-foreground">{gene.remarks || '-'}</td>
</tr> </tr>
) )}
})}
</tbody> </tbody>
</table> </table>
</div> </div>
<PaginationUI />
</CardContent> </CardContent>
</Card> </Card>
{/* 현황 정보 표시 */}
<div className="flex items-center justify-center py-4 border-t">
<span className="text-base font-bold text-muted-foreground">
{filteredAndSortedGeneData.length > 0 ? (
<>
{filteredAndSortedGeneData.length.toLocaleString()} 1-{displayData.length.toLocaleString()}
{isLoadingMoreGenes && ' (로딩 중...)'}
</>
) : (
'데이터 없음'
)}
</span>
</div>
</div>
{/* 모바일: 카드 뷰 */} {/* 모바일: 카드 뷰 */}
<div className="lg:hidden space-y-3"> <div className="lg:hidden">
{displayData.map((gene, idx) => { <div onScroll={handleGeneTableScroll} className="space-y-3 overflow-y-auto" style={{ height: 'calc(100vh - 470px)', maxHeight: 'calc(100vh - 420px)' }}>
return ( {displayData.map((gene, idx) => (
<Card key={idx} className="bg-white border border-border shadow-sm rounded-xl"> <Card key={idx} className="bg-white border border-border shadow-sm rounded-xl">
<CardContent className="p-4 space-y-2"> <CardContent className="p-4 space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -1531,11 +1557,26 @@ export default function CowOverviewPage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) ))}
})} {isLoadingMoreGenes && (
<div className="text-center py-4 text-sm text-muted-foreground">
...
</div>
)}
</div>
{/* 현황 정보 표시 */}
<div className="flex items-center justify-center py-4 border-t">
<span className="text-sm font-bold text-muted-foreground">
{filteredAndSortedGeneData.length > 0 ? (
<>
{filteredAndSortedGeneData.length.toLocaleString()} 1-{displayData.length.toLocaleString()}
{isLoadingMoreGenes && ' (로딩 중...)'}
</>
) : (
'데이터 없음'
)}
</span>
</div> </div>
<div className="lg:hidden">
<PaginationUI />
</div> </div>
</> </>
) )
@@ -1558,7 +1599,7 @@ export default function CowOverviewPage() {
) : ( ) : (
<> <>
{/* 개체 정보 섹션 */} {/* 개체 정보 섹션 */}
<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">
@@ -1684,7 +1725,7 @@ export default function CowOverviewPage() {
</Card> </Card>
{/* 유전자 분석 결과 섹션 */} {/* 유전자 분석 결과 섹션 */}
<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-slate-100 border border-slate-300 rounded-2xl"> <Card className="bg-slate-100 border border-slate-300 rounded-2xl">
<CardContent className="p-8 text-center"> <CardContent className="p-8 text-center">
<Dna className="h-12 w-12 text-slate-400 mx-auto mb-4" /> <Dna className="h-12 w-12 text-slate-400 mx-auto mb-4" />
@@ -1703,12 +1744,12 @@ export default function CowOverviewPage() {
)} )}
</TabsContent> </TabsContent>
{/* 번식능력 탭 */} {/* 번식능력 탭 */}
<TabsContent value="reproduction" className="mt-6 space-y-6"> <TabsContent value="reproduction" className="mt-6 space-y-6">
{/* 혈액화학검사(MPT) 테이블 */} {/* 혈액화학검사(MPT) 테이블 */}
<MptTable cowShortNo={cowNo?.slice(-4)} cowNo={cowNo} farmNo={cow?.fkFarmNo} cow={cow} genomeRequest={genomeRequest} /> <MptTable cowShortNo={cowNo?.slice(-4)} cowNo={cowNo} farmNo={cow?.fkFarmNo} cow={cow} genomeRequest={genomeRequest} />
</TabsContent> </TabsContent>
</div>
</Tabs> </Tabs>
</div> </div>
</main> </main>
@@ -1760,6 +1801,7 @@ export default function CowOverviewPage() {
regionRank={selectionIndex?.regionRank} regionRank={selectionIndex?.regionRank}
regionTotal={selectionIndex?.regionTotal} regionTotal={selectionIndex?.regionTotal}
highlightMode={highlightMode} highlightMode={highlightMode}
selectionIndexHistogram={selectionIndex?.histogram || []}
onHighlightModeChange={setHighlightMode} onHighlightModeChange={setHighlightMode}
chartFilterTrait={chartFilterTrait} chartFilterTrait={chartFilterTrait}
onChartFilterTraitChange={setChartFilterTrait} onChartFilterTraitChange={setChartFilterTrait}

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>
) : ( ) : (
cow.anlysDt ? (
<Badge className="text-sm px-3 py-1.5 bg-slate-600 text-white border-0 font-semibold"> <Badge className="text-sm px-3 py-1.5 bg-slate-600 text-white border-0 font-semibold">
</Badge> </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,12 +63,16 @@ 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
setSelectedYearState(validYear)
// URL에 year 파라미터 추가 (유효한 년도만, 루트 페이지 제외)
if (pathname !== '/') {
const params = new URLSearchParams(searchParams.toString()) const params = new URLSearchParams(searchParams.toString())
params.set('year', year.toString()) params.set('year', validYear.toString())
router.replace(`${pathname}?${params.toString()}`) router.replace(`${pathname}?${params.toString()}`)
} }
}
setIsInitialized(true) setIsInitialized(true)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -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)) {
// 유효한 년도면 상태 업데이트
if (year !== selectedYear) {
setSelectedYearState(year) 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 }[]; // 실제 데이터 분포
} }
/** /**