Compare commits
9 Commits
0780f2e47c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d8e7121b1a | |||
| 65d56ecc85 | |||
| fce5dcc283 | |||
| c3ccab75c8 | |||
| dabee8666c | |||
| f8ff86e4ea | |||
| 9e5ffb2c15 | |||
| f5b52df26f | |||
| dae3808221 |
@@ -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
|
||||||
|
|||||||
@@ -806,7 +806,7 @@ export class AdminService {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, testDt: ${item.testDt}): ${error.message}`);
|
this.logger.error(`[배치업로드] 데이터 저장 실패 (cowId: ${item.cowId}, ${error.message}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { UserModel } from '../user/entities/user.entity';
|
import { UserModel } from '../user/entities/user.entity';
|
||||||
|
import { FarmModel } from '../farm/entities/farm.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
import { JwtModule } from 'src/common/jwt/jwt.module';
|
import { JwtModule } from 'src/common/jwt/jwt.module';
|
||||||
import { EmailModule } from 'src/shared/email/email.module';
|
import { EmailModule } from 'src/shared/email/email.module';
|
||||||
import { VerificationModule } from 'src/shared/verification/verification.module';
|
import { VerificationModule } from 'src/shared/verification/verification.module';
|
||||||
@@ -13,7 +15,7 @@ import { VerificationModule } from 'src/shared/verification/verification.module'
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([UserModel]),
|
TypeOrmModule.forFeature([UserModel, FarmModel, GenomeRequestModel]),
|
||||||
JwtModule,
|
JwtModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
VerificationModule,
|
VerificationModule,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { UserModel } from '../user/entities/user.entity';
|
import { UserModel } from '../user/entities/user.entity';
|
||||||
import { Repository } from 'typeorm';
|
import { FarmModel } from '../farm/entities/farm.entity';
|
||||||
|
import { GenomeRequestModel } from '../genome/entities/genome-request.entity';
|
||||||
|
import { Repository, IsNull } from 'typeorm';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { LoginResponseDto } from './dto/login-response.dto';
|
import { LoginResponseDto } from './dto/login-response.dto';
|
||||||
import { SignupDto } from './dto/signup.dto';
|
import { SignupDto } from './dto/signup.dto';
|
||||||
@@ -36,6 +38,10 @@ export class AuthService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserModel)
|
@InjectRepository(UserModel)
|
||||||
private readonly userRepository: Repository<UserModel>,
|
private readonly userRepository: Repository<UserModel>,
|
||||||
|
@InjectRepository(FarmModel)
|
||||||
|
private readonly farmRepository: Repository<FarmModel>,
|
||||||
|
@InjectRepository(GenomeRequestModel)
|
||||||
|
private readonly genomeRequestRepository: Repository<GenomeRequestModel>,
|
||||||
private readonly emailService: EmailService,
|
private readonly emailService: EmailService,
|
||||||
private readonly verificationService: VerificationService,
|
private readonly verificationService: VerificationService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@@ -78,7 +84,10 @@ export class AuthService {
|
|||||||
|
|
||||||
const accessToken = this.jwtService.sign(payload as any);
|
const accessToken = this.jwtService.sign(payload as any);
|
||||||
|
|
||||||
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}`);
|
// 최근 검사 년도 조회
|
||||||
|
const defaultAnalysisYear = await this.getDefaultAnalysisYear(user.pkUserNo);
|
||||||
|
|
||||||
|
this.logger.log(`[LOGIN] 로그인 성공 - userId: ${userId}, defaultAnalysisYear: ${defaultAnalysisYear}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: '로그인 성공',
|
message: '로그인 성공',
|
||||||
@@ -90,9 +99,61 @@ export class AuthService {
|
|||||||
userEmail: user.userEmail,
|
userEmail: user.userEmail,
|
||||||
userRole: user.userRole || 'USER',
|
userRole: user.userRole || 'USER',
|
||||||
},
|
},
|
||||||
|
defaultAnalysisYear,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 최근 검사 년도 조회
|
||||||
|
* @param userNo - 사용자 번호
|
||||||
|
* @returns 최근 검사 년도 (없으면 현재 년도)
|
||||||
|
*/
|
||||||
|
private async getDefaultAnalysisYear(userNo: number): Promise<number> {
|
||||||
|
try {
|
||||||
|
// 1. 사용자의 농장 번호 조회
|
||||||
|
const farm = await this.farmRepository.findOne({
|
||||||
|
where: { fkUserNo: userNo, delDt: IsNull() },
|
||||||
|
select: ['pkFarmNo'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!farm) {
|
||||||
|
this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, No farm found, returning current year`);
|
||||||
|
return new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 농장의 검사 이력에서 최신 날짜 조회
|
||||||
|
const result = await this.genomeRequestRepository
|
||||||
|
.createQueryBuilder('request')
|
||||||
|
.select('MAX(request.chipReportDt)', 'maxChipDt')
|
||||||
|
.addSelect('MAX(request.msReportDt)', 'maxMsDt')
|
||||||
|
.where('request.fkFarmNo = :farmNo', { farmNo: farm.pkFarmNo })
|
||||||
|
.andWhere('request.delDt IS NULL')
|
||||||
|
.getRawOne();
|
||||||
|
|
||||||
|
const maxChipDt = result?.maxChipDt ? new Date(result.maxChipDt) : null;
|
||||||
|
const maxMsDt = result?.maxMsDt ? new Date(result.maxMsDt) : null;
|
||||||
|
|
||||||
|
// 둘 중 최신 날짜 선택
|
||||||
|
let latestDate: Date | null = null;
|
||||||
|
if (maxChipDt && maxMsDt) {
|
||||||
|
latestDate = maxChipDt > maxMsDt ? maxChipDt : maxMsDt;
|
||||||
|
} else if (maxChipDt) {
|
||||||
|
latestDate = maxChipDt;
|
||||||
|
} else if (maxMsDt) {
|
||||||
|
latestDate = maxMsDt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear();
|
||||||
|
|
||||||
|
this.logger.log(`[getDefaultAnalysisYear] userNo: ${userNo}, farmNo: ${farm.pkFarmNo}, maxChipDt: ${maxChipDt?.toISOString()}, maxMsDt: ${maxMsDt?.toISOString()}, year: ${year}`);
|
||||||
|
|
||||||
|
return year;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`[getDefaultAnalysisYear] Error: ${error.message}`);
|
||||||
|
return new Date().getFullYear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 회원가입
|
* 회원가입
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export class LoginResponseDto {
|
|||||||
userEmail: string;
|
userEmail: string;
|
||||||
userRole: 'USER' | 'ADMIN';
|
userRole: 'USER' | 'ADMIN';
|
||||||
};
|
};
|
||||||
|
defaultAnalysisYear: number; // 최근 검사 년도
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,16 @@ export class GenomeController {
|
|||||||
return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName);
|
return this.genomeService.getYearlyTraitTrend(+farmNo, category, traitName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /genome/latest-analysis-year/:farmNo
|
||||||
|
* 농장의 가장 최근 분석 연도 조회 (chip_report_dt 또는 ms_report_dt 기준)
|
||||||
|
* @param farmNo - 농장 번호
|
||||||
|
*/
|
||||||
|
@Get('latest-analysis-year/:farmNo')
|
||||||
|
getLatestAnalysisYear(@Param('farmNo') farmNo: string) {
|
||||||
|
return this.genomeService.getLatestAnalysisYear(+farmNo);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /genome/:cowId
|
* GET /genome/:cowId
|
||||||
* cowId(개체식별번호)로 유전체 데이터 조회
|
* cowId(개체식별번호)로 유전체 데이터 조회
|
||||||
|
|||||||
@@ -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, // 실제 데이터 분포 추가
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1874,4 +1986,69 @@ export class GenomeService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 농장의 가장 최근 분석 연도 조회
|
||||||
|
* chip_report_dt 또는 ms_report_dt 중 가장 최근 날짜의 년도 반환
|
||||||
|
* 둘 다 없으면 현재 년도 반환
|
||||||
|
*
|
||||||
|
* @param farmNo - 농장 번호
|
||||||
|
* @returns { year: number } - 가장 최근 분석 연도
|
||||||
|
*/
|
||||||
|
async getLatestAnalysisYear(farmNo: number): Promise<{ year: number }> {
|
||||||
|
console.log(`[getLatestAnalysisYear] farmNo: ${farmNo}`);
|
||||||
|
|
||||||
|
// 농장의 모든 분석 의뢰 조회
|
||||||
|
const requests = await this.genomeRequestRepository.find({
|
||||||
|
where: { fkFarmNo: farmNo, delDt: IsNull() },
|
||||||
|
select: ['chipReportDt', 'msReportDt'],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[getLatestAnalysisYear] Found ${requests?.length || 0} requests`);
|
||||||
|
|
||||||
|
if (!requests || requests.length === 0) {
|
||||||
|
console.log('[getLatestAnalysisYear] No requests found, returning current year');
|
||||||
|
return { year: new Date().getFullYear() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// chip_report_dt와 ms_report_dt 중 가장 최근 날짜 찾기
|
||||||
|
let latestDate: Date | null = null;
|
||||||
|
let latestChipDate: Date | null = null;
|
||||||
|
let latestMsDate: Date | null = null;
|
||||||
|
|
||||||
|
for (const request of requests) {
|
||||||
|
// chip_report_dt 확인
|
||||||
|
if (request.chipReportDt) {
|
||||||
|
const chipDate = new Date(request.chipReportDt);
|
||||||
|
if (!latestChipDate || chipDate > latestChipDate) {
|
||||||
|
latestChipDate = chipDate;
|
||||||
|
}
|
||||||
|
if (!latestDate || chipDate > latestDate) {
|
||||||
|
latestDate = chipDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ms_report_dt 확인
|
||||||
|
if (request.msReportDt) {
|
||||||
|
const msDate = new Date(request.msReportDt);
|
||||||
|
if (!latestMsDate || msDate > latestMsDate) {
|
||||||
|
latestMsDate = msDate;
|
||||||
|
}
|
||||||
|
if (!latestDate || msDate > latestDate) {
|
||||||
|
latestDate = msDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[getLatestAnalysisYear] Latest chip_report_dt: ${latestChipDate?.toISOString()}`);
|
||||||
|
console.log(`[getLatestAnalysisYear] Latest ms_report_dt: ${latestMsDate?.toISOString()}`);
|
||||||
|
console.log(`[getLatestAnalysisYear] Latest date overall: ${latestDate?.toISOString()}`);
|
||||||
|
|
||||||
|
// 가장 최근 날짜가 있으면 그 연도, 없으면 현재 연도
|
||||||
|
const year = latestDate ? latestDate.getFullYear() : new Date().getFullYear();
|
||||||
|
|
||||||
|
console.log(`[getLatestAnalysisYear] Returning year: ${year}`);
|
||||||
|
|
||||||
|
return { year };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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) 값으로 표시 */}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,14 +404,30 @@
|
|||||||
|
|
||||||
/* 테이블 헤더 셀 */
|
/* 테이블 헤더 셀 */
|
||||||
.cow-table-header {
|
.cow-table-header {
|
||||||
@apply text-center py-3 px-3 font-semibold;
|
@apply text-center font-bold;
|
||||||
font-size: 0.9375rem; /* 15px */
|
padding: 0.5rem 0.5rem; /* py-2 px-2 */
|
||||||
|
font-size: 1.25rem; /* 20px */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
.cow-table-header {
|
||||||
|
padding: 0.625rem 0.625rem; /* py-2.5 px-2.5 */
|
||||||
|
font-size: 1.375rem; /* 22px */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.cow-table-header {
|
||||||
|
padding: 0.75rem 0.75rem; /* py-3 px-3 */
|
||||||
|
font-size: 1.5rem; /* 24px */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 테이블 바디 셀 */
|
/* 테이블 바디 셀 */
|
||||||
.cow-table-cell {
|
.cow-table-cell {
|
||||||
@apply text-center py-3 px-3;
|
@apply text-center;
|
||||||
font-size: 0.9375rem; /* 15px */
|
padding: 0.5rem 0.375rem; /* py-2 px-1.5 */
|
||||||
|
font-size: 1.35rem; /* 21.6px */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 분석불가 행 - 각 td에 오버레이 */
|
/* 분석불가 행 - 각 td에 오버레이 */
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ const userNavMain = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "개체 조회",
|
title: "개체 조회",
|
||||||
url: "/cow",
|
url: "/cow?reset=true",
|
||||||
icon: IconListDetails,
|
icon: IconListDetails,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "개체 목록",
|
title: "개체 목록",
|
||||||
url: "/cow",
|
url: "/cow?reset=true",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* 기능:
|
* 기능:
|
||||||
* - 현재 연도부터 5년 전까지 선택 가능 (예: 2025~2020)
|
* - 현재 연도부터 5년 전까지 선택 가능 (예: 2025~2020)
|
||||||
* - URL 파라미터 ?year=2024 와 동기화
|
* - URL 파라미터 ?year=2024 와 동기화
|
||||||
|
* - 농장의 가장 최근 분석 연도를 기본값으로 사용
|
||||||
*
|
*
|
||||||
* 사용처:
|
* 사용처:
|
||||||
* - site-header.tsx: 헤더 연도 선택 드롭다운
|
* - site-header.tsx: 헤더 연도 선택 드롭다운
|
||||||
@@ -33,28 +34,81 @@ function AnalysisYearProviderInner({ children }: { children: React.ReactNode })
|
|||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const availableYears = Array.from({ length: 6 }, (_, i) => currentYear - i)
|
const availableYears = Array.from({ length: 6 }, (_, i) => currentYear - i)
|
||||||
|
|
||||||
// URL 파라미터에서 연도 가져오기, 없으면 현재 연도 사용
|
// 초기 년도는 현재 년도로 설정 (클라이언트에서 useEffect로 업데이트)
|
||||||
const yearFromUrl = searchParams.get('year')
|
const [selectedYear, setSelectedYearState] = useState<number>(currentYear)
|
||||||
const initialYear = yearFromUrl && !isNaN(Number(yearFromUrl))
|
const [isInitialized, setIsInitialized] = useState<boolean>(false)
|
||||||
? Number(yearFromUrl)
|
|
||||||
: currentYear
|
|
||||||
|
|
||||||
const [selectedYear, setSelectedYearState] = useState<number>(initialYear)
|
// 클라이언트 사이드에서만 실행: localStorage와 URL에서 초기 년도 설정
|
||||||
|
|
||||||
// URL 파라미터와 동기화
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isInitialized || typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const yearFromUrl = searchParams.get('year')
|
||||||
|
if (yearFromUrl && !isNaN(Number(yearFromUrl))) {
|
||||||
|
console.log('[AnalysisYear] Initial year from URL:', yearFromUrl)
|
||||||
|
const year = Number(yearFromUrl)
|
||||||
|
// availableYears에 포함된 년도만 사용
|
||||||
|
const validYear = availableYears.includes(year) ? year : currentYear
|
||||||
|
setSelectedYearState(validYear)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedYear = localStorage.getItem('defaultAnalysisYear')
|
||||||
|
if (savedYear && !isNaN(Number(savedYear))) {
|
||||||
|
console.log('[AnalysisYear] Initial year from localStorage:', savedYear)
|
||||||
|
const year = Number(savedYear)
|
||||||
|
// availableYears에 포함된 년도만 사용 (없으면 현재 연도 사용)
|
||||||
|
const validYear = availableYears.includes(year) ? year : currentYear
|
||||||
|
setSelectedYearState(validYear)
|
||||||
|
// URL에 year 파라미터 추가 (유효한 년도만, 루트 페이지 제외)
|
||||||
|
if (pathname !== '/') {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
params.set('year', validYear.toString())
|
||||||
|
router.replace(`${pathname}?${params.toString()}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsInitialized(true)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []) // 의도적으로 빈 배열 사용 (최초 1회만 실행)
|
||||||
|
|
||||||
|
// URL 파라미터와 동기화 (초기화 이후에만 실행)
|
||||||
|
useEffect(() => {
|
||||||
|
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)) {
|
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])
|
}
|
||||||
|
}, [searchParams, availableYears, isInitialized, selectedYear, currentYear, pathname, router])
|
||||||
|
|
||||||
const setSelectedYear = (year: number) => {
|
const setSelectedYear = (year: number) => {
|
||||||
|
console.log('[AnalysisYear] setSelectedYear:', year)
|
||||||
setSelectedYearState(year)
|
setSelectedYearState(year)
|
||||||
|
|
||||||
|
// localStorage 업데이트 (사용자가 선택한 년도를 디폴트로 저장)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('defaultAnalysisYear', year.toString())
|
||||||
|
}
|
||||||
|
|
||||||
// URL 파라미터 업데이트
|
// URL 파라미터 업데이트
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
params.set('year', year.toString())
|
params.set('year', year.toString())
|
||||||
|
|||||||
@@ -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 }[]; // 실제 데이터 분포
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
import { authApi } from '@/lib/api/auth.api';
|
import { authApi } from '@/lib/api/auth.api';
|
||||||
import { UserDto, LoginDto, SignupDto } from '@/types/auth.types';
|
import { UserDto, LoginDto, SignupDto } from '@/types/auth.types';
|
||||||
|
import apiClient from '@/lib/api-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 인증 Store 상태 - 인증 관련 값 안올때 확인
|
* 인증 Store 상태 - 인증 관련 값 안올때 확인
|
||||||
@@ -59,6 +60,12 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
accessToken: response.accessToken,
|
accessToken: response.accessToken,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 최근 검사 년도를 localStorage에 저장
|
||||||
|
if (response.defaultAnalysisYear) {
|
||||||
|
localStorage.setItem('defaultAnalysisYear', response.defaultAnalysisYear.toString());
|
||||||
|
console.log('[AuthStore] defaultAnalysisYear saved:', response.defaultAnalysisYear);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('로그인 실패:', error);
|
console.error('로그인 실패:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export interface AuthResponseDto {
|
|||||||
message?: string // 결과 메시지
|
message?: string // 결과 메시지
|
||||||
accessToken: string // JWT 액세스 토큰
|
accessToken: string // JWT 액세스 토큰
|
||||||
user: UserDto // 사용자 정보
|
user: UserDto // 사용자 정보
|
||||||
|
defaultAnalysisYear: number // 최근 검사 년도
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
Reference in New Issue
Block a user