diff --git a/backend/src/genome/genome.service.ts b/backend/src/genome/genome.service.ts index 5e45a23..6f62505 100644 --- a/backend/src/genome/genome.service.ts +++ b/backend/src/genome/genome.service.ts @@ -552,14 +552,22 @@ export class GenomeService { farms.sort((a, b) => b.avgEbv - a.avgEbv); } - // 형질별 평균 및 순위 계산 + // 형질별 평균 및 순위 계산 (표준 경쟁 순위 방식: 동률 시 같은 순위, 다음 순위 건너뜀) const traitAverages = Array.from(traitDataMap.entries()).map(([traitName, data]) => { const avgEbv = Math.round((data.sum / data.count) * 100) / 100; const avgEpd = Math.round((data.epdSum / data.count) * 100) / 100; // 육종가(EPD) 평균 const rankings = traitRankingMap.get(traitName) || []; const totalFarms = rankings.length; - const rankIndex = rankings.findIndex(r => r.farmNo === farmNo); - const rank = rankIndex >= 0 ? rankIndex + 1 : null; + + // 표준 경쟁 순위 계산: 동률 처리 + let rank: number | null = null; + const farmData = rankings.find(r => r.farmNo === farmNo); + if (farmData) { + // 나보다 높은 점수를 가진 농장 수 + 1 = 내 순위 + const higherCount = rankings.filter(r => r.avgEbv > farmData.avgEbv).length; + rank = higherCount + 1; + } + const percentile = rank !== null && totalFarms > 0 ? Math.round((rank / totalFarms) * 100) : null; return { @@ -1793,9 +1801,14 @@ export class GenomeService { // 내림차순 정렬 farmAverages.sort((a, b) => b.avgScore - a.avgScore); - // 8. 현재 농가 순위 찾기 - const myFarmIndex = farmAverages.findIndex(f => f.farmNo === farmNo); + // 8. 현재 농가 순위 찾기 (표준 경쟁 순위: 동률 시 같은 순위) const myFarmData = farmAverages.find(f => f.farmNo === farmNo); + let farmRankInRegion: number | null = null; + if (myFarmData) { + // 나보다 높은 점수를 가진 농장 수 + 1 = 내 순위 + const higherCount = farmAverages.filter(f => f.avgScore > myFarmData.avgScore).length; + farmRankInRegion = higherCount + 1; + } // 9. 보은군 전체 평균 const regionAvgScore = allScores.length > 0 @@ -1807,10 +1820,10 @@ export class GenomeService { farmerName: farm.farmerName || null, farmAvgScore: myFarmData?.avgScore ?? null, regionAvgScore, - farmRankInRegion: myFarmIndex >= 0 ? myFarmIndex + 1 : null, + farmRankInRegion, totalFarmsInRegion: farmAverages.length, - percentile: myFarmIndex >= 0 && farmAverages.length > 0 - ? Math.round(((myFarmIndex + 1) / farmAverages.length) * 100) + percentile: farmRankInRegion !== null && farmAverages.length > 0 + ? Math.round((farmRankInRegion / farmAverages.length) * 100) : null, farmCowCount: myFarmData?.cowCount || 0, regionCowCount: allScores.length, diff --git a/frontend/src/app/cow/[cowNo]/genome/_components/normal-distribution-chart.tsx b/frontend/src/app/cow/[cowNo]/genome/_components/normal-distribution-chart.tsx index 7a66832..ac63478 100644 --- a/frontend/src/app/cow/[cowNo]/genome/_components/normal-distribution-chart.tsx +++ b/frontend/src/app/cow/[cowNo]/genome/_components/normal-distribution-chart.tsx @@ -16,6 +16,7 @@ import { YAxis } from 'recharts' import { genomeApi, TraitRankDto } from "@/lib/api/genome.api" +import { useGlobalFilter } from "@/contexts/GlobalFilterContext" // 카테고리 색상 (모던 & 다이나믹 - 생동감 있는 색상) const CATEGORY_COLORS: Record = { @@ -184,9 +185,23 @@ export function NormalDistributionChart({ chartFilterTrait: externalChartFilterTrait, onChartFilterTraitChange }: NormalDistributionChartProps) { + const { filters } = useGlobalFilter() + + // 필터에서 고정된 첫 번째 형질 (없으면 첫 번째 선택된 형질, 없으면 '도체중') + const firstPinnedTrait = filters.pinnedTraits?.[0] || selectedTraitData[0]?.name || '도체중' // 차트 필터 - 선택된 형질 또는 전체 선발지수 (외부 제어 가능) - const [internalChartFilterTrait, setInternalChartFilterTrait] = useState('overall') + // 필터 비활성 시 기본값은 첫 번째 고정 형질 + const [internalChartFilterTrait, setInternalChartFilterTrait] = useState(() => { + return filters.isActive ? 'overall' : firstPinnedTrait + }) + + // 필터 활성 상태 변경 시 기본값 업데이트 + useEffect(() => { + if (!filters.isActive && internalChartFilterTrait === 'overall') { + setInternalChartFilterTrait(firstPinnedTrait) + } + }, [filters.isActive, firstPinnedTrait]) // 외부에서 제어하면 외부 값 사용, 아니면 내부 상태 사용 const chartFilterTrait = externalChartFilterTrait ?? internalChartFilterTrait @@ -366,12 +381,15 @@ export function NormalDistributionChart({ - 전체 선발지수 - {selectedTraitData.length > 0 && ( + {filters.isActive && ( + 전체 선발지수 + )} + {/* 모든 형질 표시 (필터 설정과 무관) */} + {allTraits.length > 0 && ( <> {/* 카테고리별로 그룹핑 */} {(['성장', '생산', '체형', '무게', '비율'] as const).map((category) => { - const categoryTraits = selectedTraitData.filter(t => t.category === category) + const categoryTraits = allTraits.filter(t => t.category === category) if (categoryTraits.length === 0) return null return (
diff --git a/frontend/src/app/cow/[cowNo]/page.tsx b/frontend/src/app/cow/[cowNo]/page.tsx index 20fc6ce..9203b61 100644 --- a/frontend/src/app/cow/[cowNo]/page.tsx +++ b/frontend/src/app/cow/[cowNo]/page.tsx @@ -193,8 +193,21 @@ export default function CowOverviewPage() { const [highlightMode, setHighlightMode] = useState<'farm' | 'region' | null>(null) const distributionChartRef = useRef(null) + // 필터에서 고정된 첫 번째 형질 (없으면 '도체중') + const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중' + // 차트 형질 필터 (전체 선발지수 또는 개별 형질) - const [chartFilterTrait, setChartFilterTrait] = useState('overall') + // 필터 비활성 시 기본값은 첫 번째 고정 형질 + const [chartFilterTrait, setChartFilterTrait] = useState(() => { + return filters.isActive ? 'overall' : firstPinnedTrait + }) + + // 필터 활성 상태 변경 시 기본값 업데이트 + useEffect(() => { + if (!filters.isActive && chartFilterTrait === 'overall') { + setChartFilterTrait(firstPinnedTrait) + } + }, [filters.isActive, firstPinnedTrait, chartFilterTrait]) // 유전자 탭 필터 상태 const [geneSearchKeyword, setGeneSearchKeyword] = useState('') diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 50a929b..cd9cc60 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -16,6 +16,7 @@ import { import { apiClient, farmApi } from "@/lib/api" import { DashboardStatsDto, FarmRegionRankingDto, YearlyTraitTrendDto, genomeApi } from "@/lib/api/genome.api" import { useAuthStore } from "@/store/auth-store" +import { useGlobalFilter } from "@/contexts/GlobalFilterContext" import { AlertCircle, CheckCircle2, @@ -57,23 +58,50 @@ const TRAIT_CATEGORIES: Record = { export default function DashboardPage() { const { user } = useAuthStore() + const { filters } = useGlobalFilter() const [farmNo, setFarmNo] = useState(null) const [loading, setLoading] = useState(true) const [stats, setStats] = useState(null) const [farmRanking, setFarmRanking] = useState(null) + // 필터에서 고정된 첫 번째 형질 (없으면 '도체중') + const firstPinnedTrait = filters.pinnedTraits?.[0] || '도체중' + // 연도별 육종가 추이 관련 state const [selectedTrait, setSelectedTrait] = useState(() => { if (typeof window !== 'undefined') { - return localStorage.getItem('dashboard_trait') || '도체중' + return localStorage.getItem('dashboard_trait') || firstPinnedTrait } - return '도체중' + return firstPinnedTrait }) const [traitTrendData, setTraitTrendData] = useState(null) const [traitTrendLoading, setTraitTrendLoading] = useState(false) // 보은군 내 농가 위치 차트 분포기준 (선발지수 or 개별 형질) - const [distributionBasis, setDistributionBasis] = useState('overall') + // 필터 활성 시 'overall', 비활성 시 고정된 첫 번째 형질 + const [distributionBasis, setDistributionBasis] = useState(() => { + return filters.isActive ? 'overall' : firstPinnedTrait + }) + + // 필터 변경 시 기본값 업데이트 + useEffect(() => { + if (!filters.isActive && distributionBasis === 'overall') { + setDistributionBasis(firstPinnedTrait) + } + }, [filters.isActive, distributionBasis, firstPinnedTrait]) + + // 필터에서 고정된 형질이 변경되면 selectedTrait도 업데이트 + useEffect(() => { + if (filters.pinnedTraits && filters.pinnedTraits.length > 0) { + const newFirstPinned = filters.pinnedTraits[0] + // 첫 번째 고정 형질로 변경 + setSelectedTrait(newFirstPinned) + // distributionBasis가 overall이 아니면 첫 번째 고정 형질로 변경 + if (distributionBasis !== 'overall') { + setDistributionBasis(newFirstPinned) + } + } + }, [filters.pinnedTraits]) // 모든 형질 목록 (평탄화) const allTraits = Object.entries(TRAIT_CATEGORIES).flatMap(([cat, traits]) => @@ -431,7 +459,9 @@ export default function DashboardPage() { - 전체 선발지수 + {filters.isActive && ( + 전체 선발지수 + )} {Object.entries(TRAIT_CATEGORIES).map(([category, traits]) => (
{category}
diff --git a/frontend/src/components/common/global-filter-dialog.tsx b/frontend/src/components/common/global-filter-dialog.tsx index 5e0b9a7..7927062 100644 --- a/frontend/src/components/common/global-filter-dialog.tsx +++ b/frontend/src/components/common/global-filter-dialog.tsx @@ -8,10 +8,170 @@ import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input" import { ScrollArea } from "@/components/ui/scroll-area" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" -import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin } from "lucide-react" +import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin, GripVertical } from "lucide-react" import { useGlobalFilter } from "@/contexts/GlobalFilterContext" import { DEFAULT_FILTER_SETTINGS } from "@/types/filter.types" import { geneApi, type MarkerModel } from "@/lib/api/gene.api" +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' + +// 드래그 가능한 유전자 아이템 컴포넌트 +function SortableGeneItem({ + id, + isPinned, + onTogglePin, + onRemove, +}: { + id: string + isPinned: boolean + onTogglePin: () => void + onRemove: () => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
+ + {isPinned && } + {id} + + +
+ ) +} + +// 드래그 가능한 형질 아이템 컴포넌트 +function SortableTraitItem({ + id, + isPinned, + weight, + onTogglePin, + onRemove, + onWeightChange, +}: { + id: string + isPinned: boolean + weight: number + onTogglePin: () => void + onRemove: () => void + onWeightChange: (delta: number) => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( +
+ + {isPinned && } + {id} +
+ + {weight}점 + +
+ + +
+ ) +} // 형질 카테고리 정의 const TRAIT_CATEGORIES = [ @@ -162,14 +322,24 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa })) } - // 유전자 고정 토글 + // 유전자 고정 토글 (고정 시 고정된 항목들 맨 아래로 이동) const toggleGenePin = (markerNm: string) => { setLocalFilters(prev => { const pinnedGenes = prev.pinnedGenes || [] if (pinnedGenes.includes(markerNm)) { + // 고정 해제 return { ...prev, pinnedGenes: pinnedGenes.filter(g => g !== markerNm) } } else { - return { ...prev, pinnedGenes: [...pinnedGenes, markerNm] } + // 고정 + 고정된 항목들 중 맨 아래로 이동 + const newPinnedGenes = [...pinnedGenes, markerNm] + const unpinnedGenes = prev.selectedGenes.filter(g => !newPinnedGenes.includes(g)) + const pinnedInOrder = prev.selectedGenes.filter(g => pinnedGenes.includes(g)) + const newSelectedGenes = [...pinnedInOrder, markerNm, ...unpinnedGenes] + return { + ...prev, + pinnedGenes: newPinnedGenes, + selectedGenes: newSelectedGenes + } } }) } @@ -201,14 +371,25 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa }) } - // 형질 고정 토글 + // 형질 고정 토글 (고정 시 고정된 항목들 맨 아래로 이동) const toggleTraitPin = (traitName: string) => { setLocalFilters(prev => { const pinnedTraits = prev.pinnedTraits || [] + const selectedTraits = prev.selectedTraits || [] if (pinnedTraits.includes(traitName)) { + // 고정 해제 return { ...prev, pinnedTraits: pinnedTraits.filter(t => t !== traitName) } } else { - return { ...prev, pinnedTraits: [...pinnedTraits, traitName] } + // 고정 + 고정된 항목들 중 맨 아래로 이동 + const newPinnedTraits = [...pinnedTraits, traitName] + const unpinnedTraits = selectedTraits.filter(t => !newPinnedTraits.includes(t)) + const pinnedInOrder = selectedTraits.filter(t => pinnedTraits.includes(t)) + const newSelectedTraits = [...pinnedInOrder, traitName, ...unpinnedTraits] + return { + ...prev, + pinnedTraits: newPinnedTraits, + selectedTraits: newSelectedTraits + } } }) } @@ -272,15 +453,44 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa }) } - // 정렬: 고정된 것 먼저 - const sortedSelectedGenes = [ - ...(localFilters.pinnedGenes?.filter(g => localFilters.selectedGenes.includes(g)) || []), - ...localFilters.selectedGenes.filter(g => !localFilters.pinnedGenes?.includes(g)) - ] - const sortedSelectedTraits = [ - ...(localFilters.pinnedTraits?.filter(t => localFilters.selectedTraits?.includes(t)) || []), - ...(localFilters.selectedTraits?.filter(t => !localFilters.pinnedTraits?.includes(t)) || []) - ] + // 드래그 앤 드롭 센서 + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ) + + // 유전자 순서 변경 + const handleGeneDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (over && active.id !== over.id) { + setLocalFilters(prev => { + const oldIndex = prev.selectedGenes.indexOf(active.id as string) + const newIndex = prev.selectedGenes.indexOf(over.id as string) + return { + ...prev, + selectedGenes: arrayMove(prev.selectedGenes, oldIndex, newIndex) + } + }) + } + } + + // 형질 순서 변경 + const handleTraitDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (over && active.id !== over.id) { + setLocalFilters(prev => { + const selectedTraits = prev.selectedTraits || [] + const oldIndex = selectedTraits.indexOf(active.id as string) + const newIndex = selectedTraits.indexOf(over.id as string) + return { + ...prev, + selectedTraits: arrayMove(selectedTraits, oldIndex, newIndex) + } + }) + } + } return ( @@ -354,104 +564,62 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa {/* 유전자 */} - {sortedSelectedGenes.length > 0 && ( + {localFilters.selectedGenes.length > 0 && (
-
유전자
-
- {sortedSelectedGenes.map(geneId => { - const isPinned = localFilters.pinnedGenes?.includes(geneId) - return ( -
- {isPinned && } - {geneId} - - -
- ) - })} -
+
유전자
+ + +
+ {localFilters.selectedGenes.map(geneId => ( + toggleGenePin(geneId)} + onRemove={() => toggleGene(geneId)} + /> + ))} +
+
+
)} {/* 형질 */} - {sortedSelectedTraits.length > 0 && ( + {(localFilters.selectedTraits?.length || 0) > 0 && (
-
유전체 형질
-
- {sortedSelectedTraits.map(traitId => { - const isPinned = localFilters.pinnedTraits?.includes(traitId) - return ( -
- {isPinned && } - {traitId} - ({localFilters.traitWeights[traitId as TraitName] || 0}점) - - -
- ) - })} -
- - {/* 가중치 조절 */} -
-
가중치 조절
- {sortedSelectedTraits.map(traitId => ( -
- {traitId} -
- - - {localFilters.traitWeights[traitId as TraitName] || 0}점 - - -
+
유전체 형질
+ + +
+ {(localFilters.selectedTraits || []).map(traitId => ( + toggleTraitPin(traitId)} + onRemove={() => toggleTrait(traitId)} + onWeightChange={(delta) => updateTraitWeight(traitId, delta)} + /> + ))}
- ))} -
+ +
)}
@@ -484,15 +652,39 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
- {/* 검색창 */} -
- - setGeneSearch(e.target.value)} - /> + {/* 검색창 + 전체 선택/해제 버튼 */} +
+
+ + setGeneSearch(e.target.value)} + /> +
+ +
{loadingGenes ? ( @@ -685,15 +877,39 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
- {/* 검색창 */} -
- - setTraitSearch(e.target.value)} - /> + {/* 검색창 + 전체 선택/해제 버튼 */} +
+
+ + setTraitSearch(e.target.value)} + /> +
+ +
{/* 형질 목록 - 카테고리별 아코디언 */}