diff --git a/frontend/src/app/demo/global-filter/page.tsx b/frontend/src/app/demo/global-filter/page.tsx
new file mode 100644
index 0000000..c89547c
--- /dev/null
+++ b/frontend/src/app/demo/global-filter/page.tsx
@@ -0,0 +1,2906 @@
+'use client'
+
+import { useState } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Slider } from "@/components/ui/slider"
+import {
+ Search,
+ ChevronDown,
+ ChevronUp,
+ X,
+ Filter,
+ Dna,
+ BarChart3,
+ Pin,
+ GripVertical,
+ ChevronLeft,
+ ChevronRight,
+ Settings,
+ Plus
+} from 'lucide-react'
+
+// 샘플 유전자 데이터 (실제 전역필터와 동일한 구조)
+const SAMPLE_GENES = {
+ quantity: [
+ { id: 'PLAG1', name: 'PLAG1', description: '성장 및 체중 증가' },
+ { id: 'NCAPG2', name: 'NCAPG2', description: '체구 크기와 성장 속도' },
+ { id: 'LCORL', name: 'LCORL', description: '체형 및 성장' },
+ { id: 'ARRDC3', name: 'ARRDC3', description: '체중 증가' },
+ { id: 'LAP3', name: 'LAP3', description: '성장 속도' },
+ ],
+ quality: [
+ { id: 'NT5E', name: 'NT5E', description: '근내지방도' },
+ { id: 'SCD', name: 'SCD', description: '지방산 불포화도' },
+ { id: 'FASN', name: 'FASN', description: '지방 합성' },
+ { id: 'CAPN1', name: 'CAPN1', description: '육질 연도' },
+ { id: 'CAST', name: 'CAST', description: '육질 연도 억제' },
+ { id: 'DGAT1', name: 'DGAT1', description: '지방 함량' },
+ ]
+}
+
+// 샘플 형질 데이터 (실제 전역필터와 동일한 구조)
+const SAMPLE_TRAITS = {
+ growth: [
+ { id: '12개월령체중', name: '12개월령체중', description: '12개월 시점 체중' },
+ ],
+ economic: [
+ { id: '도체중', name: '도체중', description: '도축 후 고기 무게' },
+ { id: '등심단면적', name: '등심단면적', description: '등심의 단면 크기' },
+ { id: '등지방두께', name: '등지방두께', description: '등 부위 지방 두께' },
+ { id: '근내지방도', name: '근내지방도', description: '마블링 정도' },
+ ],
+ body: [
+ { id: '체고', name: '체고', description: '어깨 높이' },
+ { id: '십자', name: '십자', description: '엉덩이뼈 높이' },
+ { id: '체장', name: '체장', description: '몸통 길이' },
+ { id: '흉심', name: '흉심', description: '가슴 깊이' },
+ { id: '흉폭', name: '흉폭', description: '가슴 너비' },
+ { id: '고장', name: '고장', description: '허리뼈 길이' },
+ { id: '요각폭', name: '요각폭', description: '허리뼈 너비' },
+ { id: '좌골폭', name: '좌골폭', description: '엉덩이뼈 너비' },
+ { id: '곤폭', name: '곤폭', description: '엉덩이뼈 끝 너비' },
+ { id: '흉위', name: '흉위', description: '가슴 둘레' },
+ ],
+ weight: [
+ { id: '안심weight', name: '안심', description: '안심 부위 무게' },
+ { id: '등심weight', name: '등심', description: '등심 부위 무게' },
+ { id: '채끝weight', name: '채끝', description: '채끝 부위 무게' },
+ { id: '목심weight', name: '목심', description: '목심 부위 무게' },
+ { id: '앞다리weight', name: '앞다리', description: '앞다리 부위 무게' },
+ { id: '우둔weight', name: '우둔', description: '우둔 부위 무게' },
+ { id: '설도weight', name: '설도', description: '설도 부위 무게' },
+ { id: '사태weight', name: '사태', description: '사태 부위 무게' },
+ { id: '양지weight', name: '양지', description: '양지 부위 무게' },
+ { id: '갈비weight', name: '갈비', description: '갈비 부위 무게' },
+ ],
+ rate: [
+ { id: '안심rate', name: '안심', description: '전체 대비 안심 비율' },
+ { id: '등심rate', name: '등심', description: '전체 대비 등심 비율' },
+ { id: '채끝rate', name: '채끝', description: '전체 대비 채끝 비율' },
+ { id: '목심rate', name: '목심', description: '전체 대비 목심 비율' },
+ { id: '앞다리rate', name: '앞다리', description: '전체 대비 앞다리 비율' },
+ { id: '우둔rate', name: '우둔', description: '전체 대비 우둔 비율' },
+ { id: '설도rate', name: '설도', description: '전체 대비 설도 비율' },
+ { id: '사태rate', name: '사태', description: '전체 대비 사태 비율' },
+ { id: '양지rate', name: '양지', description: '전체 대비 양지 비율' },
+ { id: '갈비rate', name: '갈비', description: '전체 대비 갈비 비율' },
+ ],
+}
+
+export default function GlobalFilterDemo() {
+ const [activeTab, setActiveTab] = useState('option-a')
+
+ return (
+
+
+
전역 필터 UI 시안 데모
+
+ 7가지 시안을 비교해보세요. 각 탭을 눌러 다른 UI 방식을 확인할 수 있습니다.
+
+
+ ✓ 모든 시안에 고정(Pin) 기능과 드래그 순서 변경 기능이 포함되어 있습니다.
+
+
+
+
+ A: 드롭다운
+ B: 통합검색
+ C: 현재방식
+ D: 스텝
+ E: 탭분리
+ F: 모달선택
+ H: 칩기반
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// 공통 드래그 가능한 아이템 컴포넌트
+function DraggableItem({
+ id,
+ label,
+ isPinned,
+ onTogglePin,
+ onRemove,
+ weight,
+ onWeightChange,
+ showWeight = false
+}: {
+ id: string
+ label: string
+ isPinned: boolean
+ onTogglePin: () => void
+ onRemove: () => void
+ weight?: number
+ onWeightChange?: (value: number) => void
+ showWeight?: boolean
+}) {
+ return (
+
+
+
{label}
+ {showWeight && weight !== undefined && onWeightChange && (
+
+ onWeightChange(v)}
+ max={10}
+ step={1}
+ className="w-20"
+ />
+ {weight}점
+
+ )}
+
+
+
+ )
+}
+
+/**
+ * 시안 A: 드롭다운 방식
+ * - 선택한 필터 조건 박스 안에서 "유전자를 선택해주세요" 클릭 시 드롭다운
+ * - 드롭다운 내에서 고정 영역 + 검색 + 목록
+ */
+function OptionA() {
+ const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD'])
+ const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E'])
+ const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적'])
+ const [pinnedTraits, setPinnedTraits] = useState(['도체중'])
+ const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 })
+ const [geneDropdownOpen, setGeneDropdownOpen] = useState(false)
+ const [traitDropdownOpen, setTraitDropdownOpen] = useState(false)
+ const [geneSearch, setGeneSearch] = useState('')
+ const [traitSearch, setTraitSearch] = useState('')
+
+ const toggleGene = (geneId: string) => {
+ if (selectedGenes.includes(geneId)) {
+ setSelectedGenes(prev => prev.filter(g => g !== geneId))
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else {
+ setSelectedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleGenePin = (geneId: string) => {
+ if (pinnedGenes.includes(geneId)) {
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else if (pinnedGenes.length < 5) {
+ setPinnedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleTrait = (traitId: string) => {
+ if (selectedTraits.includes(traitId)) {
+ setSelectedTraits(prev => prev.filter(t => t !== traitId))
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ setTraitWeights(prev => {
+ const newWeights = { ...prev }
+ delete newWeights[traitId]
+ return newWeights
+ })
+ } else {
+ setSelectedTraits(prev => [...prev, traitId])
+ setTraitWeights(prev => ({ ...prev, [traitId]: 5 }))
+ }
+ }
+
+ const toggleTraitPin = (traitId: string) => {
+ if (pinnedTraits.includes(traitId)) {
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ } else if (pinnedTraits.length < 5) {
+ setPinnedTraits(prev => [...prev, traitId])
+ }
+ }
+
+ const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality]
+ const allTraits = [...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.body]
+
+ const filteredGenes = allGenes.filter(g =>
+ g.name.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ g.description.toLowerCase().includes(geneSearch.toLowerCase())
+ )
+
+ const filteredTraits = allTraits.filter(t =>
+ t.name.toLowerCase().includes(traitSearch.toLowerCase()) ||
+ t.description.toLowerCase().includes(traitSearch.toLowerCase())
+ )
+
+ // 정렬: 고정된 것 먼저, 그 다음 일반
+ const sortedSelectedGenes = [
+ ...pinnedGenes.filter(g => selectedGenes.includes(g)),
+ ...selectedGenes.filter(g => !pinnedGenes.includes(g))
+ ]
+
+ const sortedSelectedTraits = [
+ ...pinnedTraits.filter(t => selectedTraits.includes(t)),
+ ...selectedTraits.filter(t => !pinnedTraits.includes(t))
+ ]
+
+ return (
+
+
+
+
+ 시안 A: 드롭다운 방식
+
+
+ 선택 영역 터치 → 드롭다운 펼침 → 고정 영역 + 검색 + 목록
+
+
+
+
+
선택한 필터 조건
+
+ {/* 유전자 드롭다운 */}
+
+
+
+
+
+
유전자
+ {selectedGenes.length > 0 && (
+
{selectedGenes.length}개
+ )}
+ {pinnedGenes.length > 0 && (
+
+ {pinnedGenes.length}
+
+ )}
+
+ {geneDropdownOpen ?
:
}
+
+
+
+
+ {/* 고정된 유전자 */}
+ {sortedSelectedGenes.length > 0 && (
+
+
+ {sortedSelectedGenes.filter(g => pinnedGenes.includes(g)).map(geneId => (
+
toggleGenePin(geneId)}
+ onRemove={() => toggleGene(geneId)}
+ />
+ ))}
+ {sortedSelectedGenes.filter(g => !pinnedGenes.includes(g)).length > 0 && (
+ <>
+ 일반
+ {sortedSelectedGenes.filter(g => !pinnedGenes.includes(g)).map(geneId => (
+ toggleGenePin(geneId)}
+ onRemove={() => toggleGene(geneId)}
+ />
+ ))}
+ >
+ )}
+
+ )}
+
+ {/* 검색창 */}
+
+
+ setGeneSearch(e.target.value)}
+ />
+
+
+ {/* 유전자 목록 */}
+
+
육량형
+ {filteredGenes.filter(g => SAMPLE_GENES.quantity.some(q => q.id === g.id)).map(gene => (
+
toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+ {selectedGenes.includes(gene.id) && (
+
+ )}
+
+ ))}
+
육질형
+ {filteredGenes.filter(g => SAMPLE_GENES.quality.some(q => q.id === g.id)).map(gene => (
+
toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+ {selectedGenes.includes(gene.id) && (
+
+ )}
+
+ ))}
+
+
+
+
+
+ {/* 형질 드롭다운 */}
+
+
+
+
+
+
형질
+ {selectedTraits.length > 0 && (
+
{selectedTraits.length}개
+ )}
+ {pinnedTraits.length > 0 && (
+
+ {pinnedTraits.length}
+
+ )}
+
+ {traitDropdownOpen ?
:
}
+
+
+
+
+ {/* 선택된 형질 + 가중치 */}
+ {sortedSelectedTraits.length > 0 && (
+
+
+ {sortedSelectedTraits.filter(t => pinnedTraits.includes(t)).map(traitId => (
+
toggleTraitPin(traitId)}
+ onRemove={() => toggleTrait(traitId)}
+ showWeight={true}
+ weight={traitWeights[traitId]}
+ onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ />
+ ))}
+ {sortedSelectedTraits.filter(t => !pinnedTraits.includes(t)).length > 0 && (
+ <>
+ 일반
+ {sortedSelectedTraits.filter(t => !pinnedTraits.includes(t)).map(traitId => (
+ toggleTraitPin(traitId)}
+ onRemove={() => toggleTrait(traitId)}
+ showWeight={true}
+ weight={traitWeights[traitId]}
+ onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ />
+ ))}
+ >
+ )}
+
+ )}
+
+ {/* 검색창 */}
+
+
+ setTraitSearch(e.target.value)}
+ />
+
+
+ {/* 형질 목록 */}
+
+
경제형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.economic.some(e => e.id === t.id)).map(trait => (
+
toggleTrait(trait.id)}
+ >
+
+
+ {trait.name}
+ {trait.description}
+
+ {selectedTraits.includes(trait.id) && (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * 시안 B: 통합검색 + 결과 바로 아래 + 하단 선택조건
+ */
+function OptionB() {
+ const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD'])
+ const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E'])
+ const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적'])
+ const [pinnedTraits, setPinnedTraits] = useState(['도체중'])
+ const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 })
+ const [searchQuery, setSearchQuery] = useState('')
+ const [quantityOpen, setQuantityOpen] = useState(false)
+ const [qualityOpen, setQualityOpen] = useState(false)
+ const [economicOpen, setEconomicOpen] = useState(false)
+
+ const toggleGene = (geneId: string) => {
+ if (selectedGenes.includes(geneId)) {
+ setSelectedGenes(prev => prev.filter(g => g !== geneId))
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else {
+ setSelectedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleGenePin = (geneId: string) => {
+ if (pinnedGenes.includes(geneId)) {
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else if (pinnedGenes.length < 5) {
+ setPinnedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleTrait = (traitId: string) => {
+ if (selectedTraits.includes(traitId)) {
+ setSelectedTraits(prev => prev.filter(t => t !== traitId))
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ setTraitWeights(prev => {
+ const newWeights = { ...prev }
+ delete newWeights[traitId]
+ return newWeights
+ })
+ } else {
+ setSelectedTraits(prev => [...prev, traitId])
+ setTraitWeights(prev => ({ ...prev, [traitId]: 5 }))
+ }
+ }
+
+ const toggleTraitPin = (traitId: string) => {
+ if (pinnedTraits.includes(traitId)) {
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ } else if (pinnedTraits.length < 5) {
+ setPinnedTraits(prev => [...prev, traitId])
+ }
+ }
+
+ // 검색 필터링
+ const filteredQuantityGenes = SAMPLE_GENES.quantity.filter(g =>
+ !searchQuery || g.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ g.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ '육량형'.includes(searchQuery)
+ )
+ const filteredQualityGenes = SAMPLE_GENES.quality.filter(g =>
+ !searchQuery || g.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ g.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ '육질형'.includes(searchQuery)
+ )
+ const filteredEconomicTraits = SAMPLE_TRAITS.economic.filter(t =>
+ !searchQuery || t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ t.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
+ '경제형질'.includes(searchQuery)
+ )
+
+ const shouldShowQuantity = !searchQuery || filteredQuantityGenes.length > 0
+ const shouldShowQuality = !searchQuery || filteredQualityGenes.length > 0
+ const shouldShowEconomic = !searchQuery || filteredEconomicTraits.length > 0
+
+ // 정렬
+ const sortedSelectedGenes = [
+ ...pinnedGenes.filter(g => selectedGenes.includes(g)),
+ ...selectedGenes.filter(g => !pinnedGenes.includes(g))
+ ]
+ const sortedSelectedTraits = [
+ ...pinnedTraits.filter(t => selectedTraits.includes(t)),
+ ...selectedTraits.filter(t => !pinnedTraits.includes(t))
+ ]
+
+ return (
+
+
+
+
+ 시안 B: 통합검색 + 하단 선택조건 (권장)
+
+
+ 통합검색 → 바로 아래 결과 → 맨 아래서 선택 조건 및 고정/순서 관리
+
+
+
+ {/* 통합 검색창 */}
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+ {/* 유전자 섹션 */}
+
+
+
+ 유전자 선택
+
+
+ {shouldShowQuantity && (
+
+
+
+
육량형
+
+ {filteredQuantityGenes.length}개
+ {(quantityOpen || !!searchQuery) ? : }
+
+
+
+
+ {filteredQuantityGenes.map(gene => (
+ toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+ {selectedGenes.includes(gene.id) && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {shouldShowQuality && (
+
+
+
+
육질형
+
+ {filteredQualityGenes.length}개
+ {(qualityOpen || !!searchQuery) ? : }
+
+
+
+
+ {filteredQualityGenes.map(gene => (
+ toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+ {selectedGenes.includes(gene.id) && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ {/* 형질 섹션 */}
+
+
+
+ 형질 가중치 설정
+
+
+ {shouldShowEconomic && (
+
+
+
+
경제형질
+
+ {filteredEconomicTraits.length}개
+ {(economicOpen || !!searchQuery) ? : }
+
+
+
+
+ {filteredEconomicTraits.map(trait => (
+
+
toggleTrait(trait.id)}
+ >
+
+
+ {trait.name}
+ {trait.description}
+
+ {selectedTraits.includes(trait.id) && (
+
+ )}
+
+ {selectedTraits.includes(trait.id) && (
+
+ setTraitWeights(prev => ({ ...prev, [trait.id]: v }))}
+ max={10}
+ step={1}
+ className="flex-1"
+ />
+ {traitWeights[trait.id] || 0}점
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ {/* 선택한 필터 조건 (하단) */}
+
+
+ 선택한 필터 조건
+ {selectedGenes.length + selectedTraits.length}개
+
+
+ {/* 유전자 */}
+ {sortedSelectedGenes.length > 0 && (
+
+
+
+ 유전자
+
+
+ {sortedSelectedGenes.map(geneId => (
+ toggleGenePin(geneId)}
+ onRemove={() => toggleGene(geneId)}
+ />
+ ))}
+
+
+ )}
+
+ {/* 형질 */}
+ {sortedSelectedTraits.length > 0 && (
+
+
+
+ 형질
+
+
+ {sortedSelectedTraits.map(traitId => (
+ toggleTraitPin(traitId)}
+ onRemove={() => toggleTrait(traitId)}
+ showWeight={true}
+ weight={traitWeights[traitId]}
+ onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ />
+ ))}
+
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * 시안 C: 현재 방식 (선택조건 상단 고정)
+ */
+function OptionC() {
+ const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD'])
+ const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E'])
+ const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적'])
+ const [pinnedTraits, setPinnedTraits] = useState(['도체중'])
+ const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 })
+ const [searchQuery, setSearchQuery] = useState('')
+ const [filterConditionOpen, setFilterConditionOpen] = useState(true)
+ const [quantityOpen, setQuantityOpen] = useState(false)
+ const [qualityOpen, setQualityOpen] = useState(false)
+ const [economicOpen, setEconomicOpen] = useState(false)
+
+ const toggleGene = (geneId: string) => {
+ if (selectedGenes.includes(geneId)) {
+ setSelectedGenes(prev => prev.filter(g => g !== geneId))
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else {
+ setSelectedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleGenePin = (geneId: string) => {
+ if (pinnedGenes.includes(geneId)) {
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else if (pinnedGenes.length < 5) {
+ setPinnedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleTrait = (traitId: string) => {
+ if (selectedTraits.includes(traitId)) {
+ setSelectedTraits(prev => prev.filter(t => t !== traitId))
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ setTraitWeights(prev => {
+ const newWeights = { ...prev }
+ delete newWeights[traitId]
+ return newWeights
+ })
+ } else {
+ setSelectedTraits(prev => [...prev, traitId])
+ setTraitWeights(prev => ({ ...prev, [traitId]: 5 }))
+ }
+ }
+
+ const toggleTraitPin = (traitId: string) => {
+ if (pinnedTraits.includes(traitId)) {
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ } else if (pinnedTraits.length < 5) {
+ setPinnedTraits(prev => [...prev, traitId])
+ }
+ }
+
+ const sortedSelectedGenes = [
+ ...pinnedGenes.filter(g => selectedGenes.includes(g)),
+ ...selectedGenes.filter(g => !pinnedGenes.includes(g))
+ ]
+ const sortedSelectedTraits = [
+ ...pinnedTraits.filter(t => selectedTraits.includes(t)),
+ ...selectedTraits.filter(t => !pinnedTraits.includes(t))
+ ]
+
+ return (
+
+
+
+
+ 시안 C: 현재 방식 (선택조건 상단)
+
+
+ 현재 구현된 방식 - 선택 조건이 검색창 위에 고정 (고정/순서 기능 추가)
+
+
+
+ {/* 선택한 필터 조건 (상단 고정) */}
+
+
+
+
선택한 필터 조건
+
+ {selectedGenes.length + selectedTraits.length}개
+ {filterConditionOpen ? : }
+
+
+
+
+
+ {/* 유전자 */}
+ {sortedSelectedGenes.length > 0 && (
+
+
+
+ 유전자
+
+
+ {sortedSelectedGenes.map(geneId => (
+ toggleGenePin(geneId)}
+ onRemove={() => toggleGene(geneId)}
+ />
+ ))}
+
+
+ )}
+
+ {/* 형질 */}
+ {sortedSelectedTraits.length > 0 && (
+
+
+
+ 형질
+
+
+ {sortedSelectedTraits.map(traitId => (
+ toggleTraitPin(traitId)}
+ onRemove={() => toggleTrait(traitId)}
+ showWeight={true}
+ weight={traitWeights[traitId]}
+ onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ />
+ ))}
+
+
+ )}
+
+
+
+
+ {/* 통합 검색창 */}
+
+
+ setSearchQuery(e.target.value)}
+ />
+
+
+ {/* 유전자 섹션 */}
+
+
+
+ 유전자 선택
+
+
+
+
+
+ 육량형
+ {quantityOpen ? : }
+
+
+
+ {SAMPLE_GENES.quantity.map(gene => (
+ toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+ {selectedGenes.includes(gene.id) && (
+
+ )}
+
+ ))}
+
+
+
+
+
+
+ 육질형
+ {qualityOpen ? : }
+
+
+
+ {SAMPLE_GENES.quality.map(gene => (
+ toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+ {selectedGenes.includes(gene.id) && (
+
+ )}
+
+ ))}
+
+
+
+
+ {/* 형질 섹션 */}
+
+
+
+ 형질 가중치 설정
+
+
+
+
+
+ 경제형질
+ {economicOpen ? : }
+
+
+
+ {SAMPLE_TRAITS.economic.map(trait => (
+
+
toggleTrait(trait.id)}
+ >
+
+
+ {trait.name}
+ {trait.description}
+
+ {selectedTraits.includes(trait.id) && (
+
+ )}
+
+ {selectedTraits.includes(trait.id) && (
+
+ setTraitWeights(prev => ({ ...prev, [trait.id]: v }))}
+ max={10}
+ step={1}
+ className="flex-1"
+ />
+ {traitWeights[trait.id] || 0}점
+
+ )}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * 시안 D: 스텝(단계별) 방식
+ */
+function OptionD() {
+ const [currentStep, setCurrentStep] = useState(1)
+ const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD'])
+ const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E'])
+ const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적'])
+ const [pinnedTraits, setPinnedTraits] = useState(['도체중'])
+ const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 })
+ const [geneSearch, setGeneSearch] = useState('')
+ const [traitSearch, setTraitSearch] = useState('')
+
+ const toggleGene = (geneId: string) => {
+ if (selectedGenes.includes(geneId)) {
+ setSelectedGenes(prev => prev.filter(g => g !== geneId))
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else {
+ setSelectedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleGenePin = (geneId: string) => {
+ if (pinnedGenes.includes(geneId)) {
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else if (pinnedGenes.length < 5) {
+ setPinnedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleTrait = (traitId: string) => {
+ if (selectedTraits.includes(traitId)) {
+ setSelectedTraits(prev => prev.filter(t => t !== traitId))
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ setTraitWeights(prev => {
+ const newWeights = { ...prev }
+ delete newWeights[traitId]
+ return newWeights
+ })
+ } else {
+ setSelectedTraits(prev => [...prev, traitId])
+ setTraitWeights(prev => ({ ...prev, [traitId]: 5 }))
+ }
+ }
+
+ const toggleTraitPin = (traitId: string) => {
+ if (pinnedTraits.includes(traitId)) {
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ } else if (pinnedTraits.length < 5) {
+ setPinnedTraits(prev => [...prev, traitId])
+ }
+ }
+
+ const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality]
+ const allTraits = [...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.body]
+
+ const filteredGenes = allGenes.filter(g =>
+ g.name.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ g.description.toLowerCase().includes(geneSearch.toLowerCase())
+ )
+
+ const filteredTraits = allTraits.filter(t =>
+ t.name.toLowerCase().includes(traitSearch.toLowerCase()) ||
+ t.description.toLowerCase().includes(traitSearch.toLowerCase())
+ )
+
+ const sortedSelectedGenes = [
+ ...pinnedGenes.filter(g => selectedGenes.includes(g)),
+ ...selectedGenes.filter(g => !pinnedGenes.includes(g))
+ ]
+ const sortedSelectedTraits = [
+ ...pinnedTraits.filter(t => selectedTraits.includes(t)),
+ ...selectedTraits.filter(t => !pinnedTraits.includes(t))
+ ]
+
+ return (
+
+
+
+
+ 시안 D: 스텝(단계별) 방식
+
+
+ Step 1: 유전자 선택 → Step 2: 형질 선택 → Step 3: 확인
+
+
+
+ {/* 스텝 인디케이터 */}
+
+ {[1, 2, 3].map((step) => (
+
+
+ {step < 3 && (
+
step ? 'bg-blue-500' : 'bg-slate-200'}`} />
+ )}
+
+ ))}
+
+
+ {currentStep === 1 && '유전자 선택'}
+ {currentStep === 2 && '형질 선택'}
+ {currentStep === 3 && '최종 확인'}
+
+
+ {/* Step 1: 유전자 선택 */}
+ {currentStep === 1 && (
+
+ {/* 선택된 유전자 (고정/순서) */}
+ {sortedSelectedGenes.length > 0 && (
+
+
선택됨 ({sortedSelectedGenes.length}개)
+ {sortedSelectedGenes.map(geneId => (
+
toggleGenePin(geneId)}
+ onRemove={() => toggleGene(geneId)}
+ />
+ ))}
+
+ )}
+
+ {/* 검색 */}
+
+
+ setGeneSearch(e.target.value)}
+ />
+
+
+ {/* 유전자 목록 */}
+
+
육량형
+ {filteredGenes.filter(g => SAMPLE_GENES.quantity.some(q => q.id === g.id)).map(gene => (
+
toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+
+ ))}
+
육질형
+ {filteredGenes.filter(g => SAMPLE_GENES.quality.some(q => q.id === g.id)).map(gene => (
+
toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+
+ ))}
+
+
+ )}
+
+ {/* Step 2: 형질 선택 */}
+ {currentStep === 2 && (
+
+ {/* 선택된 형질 (고정/순서/가중치) */}
+ {sortedSelectedTraits.length > 0 && (
+
+
선택됨 ({sortedSelectedTraits.length}개)
+ {sortedSelectedTraits.map(traitId => (
+
toggleTraitPin(traitId)}
+ onRemove={() => toggleTrait(traitId)}
+ showWeight={true}
+ weight={traitWeights[traitId]}
+ onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ />
+ ))}
+
+ )}
+
+ {/* 검색 */}
+
+
+ setTraitSearch(e.target.value)}
+ />
+
+
+ {/* 형질 목록 */}
+
+
경제형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.economic.some(e => e.id === t.id)).map(trait => (
+
toggleTrait(trait.id)}
+ >
+
+
+ {trait.name}
+ {trait.description}
+
+
+ ))}
+
+
+ )}
+
+ {/* Step 3: 최종 확인 */}
+ {currentStep === 3 && (
+
+
+
최종 선택 확인
+
+ {/* 유전자 */}
+
+
+
+ 유전자 ({sortedSelectedGenes.length}개)
+
+
+ {sortedSelectedGenes.map(geneId => (
+ toggleGenePin(geneId)}
+ onRemove={() => toggleGene(geneId)}
+ />
+ ))}
+
+
+
+ {/* 형질 */}
+
+
+
+ 형질 ({sortedSelectedTraits.length}개)
+
+
+ {sortedSelectedTraits.map(traitId => (
+ toggleTraitPin(traitId)}
+ onRemove={() => toggleTrait(traitId)}
+ showWeight={true}
+ weight={traitWeights[traitId]}
+ onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ />
+ ))}
+
+
+
+
+ )}
+
+ {/* 네비게이션 버튼 */}
+
+ {currentStep > 1 && (
+
+ )}
+
+ {currentStep < 3 ? (
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
+
+/**
+ * 시안 E: 탭 분리 방식
+ */
+function OptionE() {
+ const [innerTab, setInnerTab] = useState('genes')
+ const [selectedGenes, setSelectedGenes] = useState
(['PLAG1', 'NT5E', 'SCD'])
+ const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E'])
+ const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적'])
+ const [pinnedTraits, setPinnedTraits] = useState(['도체중'])
+ const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 })
+ const [geneSearch, setGeneSearch] = useState('')
+ const [traitSearch, setTraitSearch] = useState('')
+
+ const toggleGene = (geneId: string) => {
+ if (selectedGenes.includes(geneId)) {
+ setSelectedGenes(prev => prev.filter(g => g !== geneId))
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else {
+ setSelectedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleGenePin = (geneId: string) => {
+ if (pinnedGenes.includes(geneId)) {
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else if (pinnedGenes.length < 5) {
+ setPinnedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleTrait = (traitId: string) => {
+ if (selectedTraits.includes(traitId)) {
+ setSelectedTraits(prev => prev.filter(t => t !== traitId))
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ setTraitWeights(prev => {
+ const newWeights = { ...prev }
+ delete newWeights[traitId]
+ return newWeights
+ })
+ } else {
+ setSelectedTraits(prev => [...prev, traitId])
+ setTraitWeights(prev => ({ ...prev, [traitId]: 5 }))
+ }
+ }
+
+ const toggleTraitPin = (traitId: string) => {
+ if (pinnedTraits.includes(traitId)) {
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ } else if (pinnedTraits.length < 5) {
+ setPinnedTraits(prev => [...prev, traitId])
+ }
+ }
+
+ const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality]
+ const filteredGenes = allGenes.filter(g =>
+ g.name.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ g.description.toLowerCase().includes(geneSearch.toLowerCase())
+ )
+
+ const allTraits = [...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.body]
+ const filteredTraits = allTraits.filter(t =>
+ t.name.toLowerCase().includes(traitSearch.toLowerCase()) ||
+ t.description.toLowerCase().includes(traitSearch.toLowerCase())
+ )
+
+ const sortedSelectedGenes = [
+ ...pinnedGenes.filter(g => selectedGenes.includes(g)),
+ ...selectedGenes.filter(g => !pinnedGenes.includes(g))
+ ]
+ const sortedSelectedTraits = [
+ ...pinnedTraits.filter(t => selectedTraits.includes(t)),
+ ...selectedTraits.filter(t => !pinnedTraits.includes(t))
+ ]
+
+ return (
+
+
+
+
+ 시안 E: 탭 분리 방식
+
+
+ 유전자 / 형질 / 선택조건을 탭으로 분리하여 넓은 공간 활용
+
+
+
+
+
+
+
+ 유전자
+ {selectedGenes.length > 0 && {selectedGenes.length}}
+
+
+
+ 형질
+ {selectedTraits.length > 0 && {selectedTraits.length}}
+
+
+ 선택조건
+
+
+
+ {/* 유전자 탭 */}
+
+ {/* 선택된 유전자 */}
+ {sortedSelectedGenes.length > 0 && (
+
+
선택됨 (드래그로 순서 변경)
+ {sortedSelectedGenes.map(geneId => (
+
toggleGenePin(geneId)}
+ onRemove={() => toggleGene(geneId)}
+ />
+ ))}
+
+ )}
+
+
+
+ setGeneSearch(e.target.value)}
+ />
+
+
+
+
육량형
+ {filteredGenes.filter(g => SAMPLE_GENES.quantity.some(q => q.id === g.id)).map(gene => (
+
toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+ {selectedGenes.includes(gene.id) && (
+
+ )}
+
+ ))}
+
육질형
+ {filteredGenes.filter(g => SAMPLE_GENES.quality.some(q => q.id === g.id)).map(gene => (
+
toggleGene(gene.id)}
+ >
+
+
+ {gene.name}
+ {gene.description}
+
+ {selectedGenes.includes(gene.id) && (
+
+ )}
+
+ ))}
+
+
+
+ {/* 형질 탭 */}
+
+ {/* 선택된 형질 */}
+ {sortedSelectedTraits.length > 0 && (
+
+
선택됨 (드래그로 순서 변경)
+ {sortedSelectedTraits.map(traitId => (
+
toggleTraitPin(traitId)}
+ onRemove={() => toggleTrait(traitId)}
+ showWeight={true}
+ weight={traitWeights[traitId]}
+ onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ />
+ ))}
+
+ )}
+
+
+
+ setTraitSearch(e.target.value)}
+ />
+
+
+
+
경제형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.economic.some(e => e.id === t.id)).map(trait => (
+
toggleTrait(trait.id)}
+ >
+
+
+ {trait.name}
+ {trait.description}
+
+ {selectedTraits.includes(trait.id) && (
+
+ )}
+
+ ))}
+
성장형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.growth.some(g => g.id === t.id)).map(trait => (
+
toggleTrait(trait.id)}
+ >
+
+
+ {trait.name}
+ {trait.description}
+
+ {selectedTraits.includes(trait.id) && (
+
+ )}
+
+ ))}
+
체형형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.body.some(b => b.id === t.id)).map(trait => (
+
toggleTrait(trait.id)}
+ >
+
+
+ {trait.name}
+ {trait.description}
+
+ {selectedTraits.includes(trait.id) && (
+
+ )}
+
+ ))}
+
+
+
+ {/* 선택조건 탭 */}
+
+
+
선택한 필터 조건
+
+ {/* 유전자 */}
+ {sortedSelectedGenes.length > 0 && (
+
+
+
+ 유전자 ({sortedSelectedGenes.length}개)
+
+
+ {sortedSelectedGenes.map(geneId => (
+ toggleGenePin(geneId)}
+ onRemove={() => toggleGene(geneId)}
+ />
+ ))}
+
+
+ )}
+
+ {/* 형질 */}
+ {sortedSelectedTraits.length > 0 && (
+
+
+
+ 형질 ({sortedSelectedTraits.length}개)
+
+
+ {sortedSelectedTraits.map(traitId => (
+ toggleTraitPin(traitId)}
+ onRemove={() => toggleTrait(traitId)}
+ showWeight={true}
+ weight={traitWeights[traitId]}
+ onWeightChange={(v) => setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ />
+ ))}
+
+
+ )}
+
+ {sortedSelectedGenes.length === 0 && sortedSelectedTraits.length === 0 && (
+
+ 선택된 필터 조건이 없습니다
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * 시안 H: 칩(Chip) 기반 방식
+ * 태그/칩 형태로 선택 항목을 표시하고 관리
+ */
+function OptionH() {
+ const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD'])
+ const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E'])
+ const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적'])
+ const [pinnedTraits, setPinnedTraits] = useState(['도체중'])
+ const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 })
+ const [showGeneSelector, setShowGeneSelector] = useState(false)
+ const [showTraitSelector, setShowTraitSelector] = useState(false)
+ const [geneSearch, setGeneSearch] = useState('')
+ const [traitSearch, setTraitSearch] = useState('')
+
+ const toggleGene = (geneId: string) => {
+ if (selectedGenes.includes(geneId)) {
+ setSelectedGenes(prev => prev.filter(g => g !== geneId))
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else {
+ setSelectedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleGenePin = (geneId: string) => {
+ if (pinnedGenes.includes(geneId)) {
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else if (pinnedGenes.length < 5) {
+ setPinnedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleTrait = (traitId: string) => {
+ if (selectedTraits.includes(traitId)) {
+ setSelectedTraits(prev => prev.filter(t => t !== traitId))
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ setTraitWeights(prev => {
+ const newWeights = { ...prev }
+ delete newWeights[traitId]
+ return newWeights
+ })
+ } else {
+ setSelectedTraits(prev => [...prev, traitId])
+ setTraitWeights(prev => ({ ...prev, [traitId]: 5 }))
+ }
+ }
+
+ const toggleTraitPin = (traitId: string) => {
+ if (pinnedTraits.includes(traitId)) {
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ } else if (pinnedTraits.length < 5) {
+ setPinnedTraits(prev => [...prev, traitId])
+ }
+ }
+
+ const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality]
+ const filteredGenes = allGenes.filter(g =>
+ g.name.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ g.description.toLowerCase().includes(geneSearch.toLowerCase())
+ )
+
+ const allTraits = [...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.body]
+ const filteredTraits = allTraits.filter(t =>
+ t.name.toLowerCase().includes(traitSearch.toLowerCase()) ||
+ t.description.toLowerCase().includes(traitSearch.toLowerCase())
+ )
+
+ // 고정된 것 먼저 정렬
+ const sortedSelectedGenes = [
+ ...pinnedGenes.filter(g => selectedGenes.includes(g)),
+ ...selectedGenes.filter(g => !pinnedGenes.includes(g))
+ ]
+ const sortedSelectedTraits = [
+ ...pinnedTraits.filter(t => selectedTraits.includes(t)),
+ ...selectedTraits.filter(t => !pinnedTraits.includes(t))
+ ]
+
+ return (
+
+
+
+
+ 시안 H: 칩(Chip) 기반 방식
+
+
+ 태그/칩 형태로 선택 항목 표시 + 추가 버튼으로 새 항목 선택
+
+
+
+ {/* 유전자 칩 영역 */}
+
+
+
+
+ 유전자
+
+
+
+
+ {/* 선택된 유전자 칩 */}
+
+ {sortedSelectedGenes.length === 0 ? (
+
유전자를 선택해주세요
+ ) : (
+ sortedSelectedGenes.map(geneId => {
+ const isPinned = pinnedGenes.includes(geneId)
+ const isQuantity = SAMPLE_GENES.quantity.some(g => g.id === geneId)
+ return (
+
+
+ {isPinned &&
}
+
{geneId}
+
+
+
+ )
+ })
+ )}
+
+
+ {/* 유전자 선택 패널 */}
+ {showGeneSelector && (
+
+
+
+ setGeneSearch(e.target.value)}
+ />
+
+
+
육량형
+ {filteredGenes.filter(g => SAMPLE_GENES.quantity.some(q => q.id === g.id)).map(gene => (
+
toggleGene(gene.id)}
+ >
+
+ {gene.name}
+ {gene.description}
+
+ ))}
+
육질형
+ {filteredGenes.filter(g => SAMPLE_GENES.quality.some(q => q.id === g.id)).map(gene => (
+
toggleGene(gene.id)}
+ >
+
+ {gene.name}
+ {gene.description}
+
+ ))}
+
+
+
+ )}
+
+
+ {/* 형질 칩 영역 */}
+
+
+
+
+ 형질
+
+
+
+
+ {/* 선택된 형질 칩 */}
+
+ {sortedSelectedTraits.length === 0 ? (
+
형질을 선택해주세요
+ ) : (
+ sortedSelectedTraits.map(traitId => {
+ const isPinned = pinnedTraits.includes(traitId)
+ return (
+
+
+ {isPinned &&
}
+
{traitId}
+
({traitWeights[traitId] || 0}점)
+
+
+
+ )
+ })
+ )}
+
+
+ {/* 형질 가중치 조절 */}
+ {sortedSelectedTraits.length > 0 && (
+
+
가중치 조절
+ {sortedSelectedTraits.map(traitId => (
+
+ {traitId}
+ setTraitWeights(prev => ({ ...prev, [traitId]: v }))}
+ max={10}
+ step={1}
+ className="flex-1"
+ />
+ {traitWeights[traitId] || 0}점
+
+ ))}
+
+ )}
+
+ {/* 형질 선택 패널 */}
+ {showTraitSelector && (
+
+
+
+ setTraitSearch(e.target.value)}
+ />
+
+
+
경제형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.economic.some(e => e.id === t.id)).map(trait => (
+
toggleTrait(trait.id)}
+ >
+
+ {trait.name}
+ {trait.description}
+
+ ))}
+
성장형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.growth.some(g => g.id === t.id)).map(trait => (
+
toggleTrait(trait.id)}
+ >
+
+ {trait.name}
+ {trait.description}
+
+ ))}
+
체형형질
+ {filteredTraits.filter(t => SAMPLE_TRAITS.body.some(b => b.id === t.id)).map(trait => (
+
toggleTrait(trait.id)}
+ >
+
+ {trait.name}
+ {trait.description}
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * 시안 F: 모달 선택 방식 (추천)
+ * 평소엔 선택 결과만 보이고, 클릭하면 모달/패널에서 선택
+ */
+function OptionF() {
+ const [selectedGenes, setSelectedGenes] = useState(['PLAG1', 'NT5E', 'SCD'])
+ const [pinnedGenes, setPinnedGenes] = useState(['PLAG1', 'NT5E'])
+ const [selectedTraits, setSelectedTraits] = useState(['도체중', '등심단면적'])
+ const [pinnedTraits, setPinnedTraits] = useState(['도체중'])
+ const [traitWeights, setTraitWeights] = useState>({ '도체중': 7, '등심단면적': 5 })
+
+ const [showGeneModal, setShowGeneModal] = useState(false)
+ const [showTraitModal, setShowTraitModal] = useState(false)
+ const [geneSearch, setGeneSearch] = useState('')
+ const [traitSearch, setTraitSearch] = useState('')
+ const [activeGeneCategory, setActiveGeneCategory] = useState(null)
+ const [activeTraitCategory, setActiveTraitCategory] = useState(null)
+ const [openCategories, setOpenCategories] = useState>({})
+
+ const toggleGene = (geneId: string) => {
+ if (selectedGenes.includes(geneId)) {
+ setSelectedGenes(prev => prev.filter(g => g !== geneId))
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else {
+ setSelectedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleGenePin = (geneId: string) => {
+ if (pinnedGenes.includes(geneId)) {
+ setPinnedGenes(prev => prev.filter(g => g !== geneId))
+ } else if (pinnedGenes.length < 5) {
+ setPinnedGenes(prev => [...prev, geneId])
+ }
+ }
+
+ const toggleTrait = (traitId: string) => {
+ if (selectedTraits.includes(traitId)) {
+ setSelectedTraits(prev => prev.filter(t => t !== traitId))
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ setTraitWeights(prev => {
+ const newWeights = { ...prev }
+ delete newWeights[traitId]
+ return newWeights
+ })
+ } else {
+ setSelectedTraits(prev => [...prev, traitId])
+ setTraitWeights(prev => ({ ...prev, [traitId]: 5 }))
+ }
+ }
+
+ const toggleTraitPin = (traitId: string) => {
+ if (pinnedTraits.includes(traitId)) {
+ setPinnedTraits(prev => prev.filter(t => t !== traitId))
+ } else if (pinnedTraits.length < 5) {
+ setPinnedTraits(prev => [...prev, traitId])
+ }
+ }
+
+ const allGenes = [...SAMPLE_GENES.quantity, ...SAMPLE_GENES.quality]
+ const allTraits = [...SAMPLE_TRAITS.growth, ...SAMPLE_TRAITS.economic, ...SAMPLE_TRAITS.body, ...SAMPLE_TRAITS.weight, ...SAMPLE_TRAITS.rate]
+
+ // 형질 카테고리 정보
+ const traitCategories = [
+ { id: 'growth', name: '성장형질', color: 'purple', traits: SAMPLE_TRAITS.growth },
+ { id: 'economic', name: '경제형질', color: 'emerald', traits: SAMPLE_TRAITS.economic },
+ { id: 'body', name: '체형형질', color: 'cyan', traits: SAMPLE_TRAITS.body },
+ { id: 'weight', name: '부위별무게', color: 'amber', traits: SAMPLE_TRAITS.weight },
+ { id: 'rate', name: '부위별비율', color: 'rose', traits: SAMPLE_TRAITS.rate },
+ ]
+
+ // 카테고리 필터링
+ const getFilteredGenes = () => {
+ let genes = allGenes
+ if (activeGeneCategory === 'quantity') {
+ genes = SAMPLE_GENES.quantity
+ } else if (activeGeneCategory === 'quality') {
+ genes = SAMPLE_GENES.quality
+ }
+ if (geneSearch) {
+ genes = genes.filter(g =>
+ g.name.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ g.description.toLowerCase().includes(geneSearch.toLowerCase())
+ )
+ }
+ return genes
+ }
+
+ const getFilteredTraits = () => {
+ const category = traitCategories.find(c => c.id === activeTraitCategory)
+ let traits = category ? category.traits : allTraits
+ if (traitSearch) {
+ traits = traits.filter(t =>
+ t.name.toLowerCase().includes(traitSearch.toLowerCase()) ||
+ t.description.toLowerCase().includes(traitSearch.toLowerCase())
+ )
+ }
+ return traits
+ }
+
+ // 형질이 어떤 카테고리에 속하는지 찾기
+ const getTraitCategory = (traitId: string) => {
+ for (const cat of traitCategories) {
+ if (cat.traits.some(t => t.id === traitId)) {
+ return cat
+ }
+ }
+ return null
+ }
+
+ // 정렬: 고정된 것 먼저
+ const sortedSelectedGenes = [
+ ...pinnedGenes.filter(g => selectedGenes.includes(g)),
+ ...selectedGenes.filter(g => !pinnedGenes.includes(g))
+ ]
+ const sortedSelectedTraits = [
+ ...pinnedTraits.filter(t => selectedTraits.includes(t)),
+ ...selectedTraits.filter(t => !pinnedTraits.includes(t))
+ ]
+
+ return (
+
+
+
+
+ 시안 F: 모달 선택 방식 (추천)
+
+
+ 평소엔 선택 결과만 보이고, 클릭하면 모달에서 카테고리/검색으로 선택
+
+
+
+ {/* 유전자 선택 버튼 */}
+
+
+
+
+ 유전자
+
+
+
+
+
+ {/* 유전체 형질 선택 버튼 */}
+
+
+
+
+ 유전체 형질
+
+
+
+
+
+ {/* 선택한 조건 표시 */}
+
+
+ 선택한 조건 ({selectedGenes.length + selectedTraits.length}개)
+
+
+ {/* 유전자 */}
+ {sortedSelectedGenes.length > 0 && (
+
+
유전자
+
+ {sortedSelectedGenes.map(geneId => {
+ const isPinned = pinnedGenes.includes(geneId)
+ return (
+
+ {isPinned &&
}
+
{geneId}
+
+
+
+ )
+ })}
+
+
+ )}
+
+ {/* 유전체 형질 */}
+ {sortedSelectedTraits.length > 0 && (
+
+
유전체 형질
+
+ {sortedSelectedTraits.map(traitId => {
+ const isPinned = pinnedTraits.includes(traitId)
+ return (
+
+ {isPinned &&
}
+
{traitId}
+
({traitWeights[traitId]}점)
+
+
+
+ )
+ })}
+
+
+ {/* 가중치 조절 */}
+
+
가중치 조절
+ {sortedSelectedTraits.map(traitId => (
+
+
{traitId}
+
+
+ {traitWeights[traitId] || 0}점
+
+
+
+ ))}
+
+
+ )}
+
+ {selectedGenes.length === 0 && selectedTraits.length === 0 && (
+
+ 선택된 조건이 없습니다
+
+ )}
+
+
+
+
+
+
+
+ {/* 유전자 선택 모달 */}
+ {showGeneModal && (
+
+
+
+
유전자 선택
+
+
+
+
+ {/* 검색창 */}
+
+
+ setGeneSearch(e.target.value)}
+ />
+
+
+ {/* 유전자 목록 - 카테고리별 아코디언 */}
+
+ {/* 육량형 */}
+ {(() => {
+ const genes = SAMPLE_GENES.quantity
+ const selectedCount = genes.filter(g => selectedGenes.includes(g.id)).length
+ const isAllSelected = selectedCount === genes.length
+ const hasSelected = selectedCount > 0
+
+ // 검색어가 카테고리 이름과 매칭되는지 확인
+ const isCategoryMatch = geneSearch && '육량형'.includes(geneSearch.toLowerCase())
+
+ // 검색 필터링
+ const filteredGenes = geneSearch
+ ? isCategoryMatch
+ ? genes
+ : genes.filter(g =>
+ g.name.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ g.description.toLowerCase().includes(geneSearch.toLowerCase())
+ )
+ : genes
+
+ if (geneSearch && filteredGenes.length === 0) return null
+
+ const isExpanded = geneSearch ? true : (openCategories['quantity'] ?? false)
+
+ return (
+
setOpenCategories(prev => ({ ...prev, quantity: open }))}
+ >
+
+
+
+ {
+ if (checked) {
+ const newGenes = [...selectedGenes]
+ genes.forEach(g => {
+ if (!newGenes.includes(g.id)) newGenes.push(g.id)
+ })
+ setSelectedGenes(newGenes)
+ } else {
+ setSelectedGenes(prev => prev.filter(id => !genes.some(g => g.id === id)))
+ setPinnedGenes(prev => prev.filter(id => !genes.some(g => g.id === id)))
+ }
+ }}
+ className="h-4 w-4"
+ />
+
+ 육량형
+
+
+ {selectedCount}/{genes.length}
+
+
+
+
+
+
+
+
+ {filteredGenes.map(gene => {
+ const isSelected = selectedGenes.includes(gene.id)
+ return (
+
toggleGene(gene.id)}
+ >
+
+
+
+ {gene.name}
+ {gene.description}
+
+
+
+ )
+ })}
+
+
+
+
+ )
+ })()}
+
+ {/* 육질형 */}
+ {(() => {
+ const genes = SAMPLE_GENES.quality
+ const selectedCount = genes.filter(g => selectedGenes.includes(g.id)).length
+ const isAllSelected = selectedCount === genes.length
+ const hasSelected = selectedCount > 0
+
+ const isCategoryMatch = geneSearch && '육질형'.includes(geneSearch.toLowerCase())
+
+ const filteredGenes = geneSearch
+ ? isCategoryMatch
+ ? genes
+ : genes.filter(g =>
+ g.name.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ g.description.toLowerCase().includes(geneSearch.toLowerCase())
+ )
+ : genes
+
+ if (geneSearch && filteredGenes.length === 0) return null
+
+ const isExpanded = geneSearch ? true : (openCategories['quality'] ?? false)
+
+ return (
+
setOpenCategories(prev => ({ ...prev, quality: open }))}
+ >
+
+
+
+ {
+ if (checked) {
+ const newGenes = [...selectedGenes]
+ genes.forEach(g => {
+ if (!newGenes.includes(g.id)) newGenes.push(g.id)
+ })
+ setSelectedGenes(newGenes)
+ } else {
+ setSelectedGenes(prev => prev.filter(id => !genes.some(g => g.id === id)))
+ setPinnedGenes(prev => prev.filter(id => !genes.some(g => g.id === id)))
+ }
+ }}
+ className="h-4 w-4"
+ />
+
+ 육질형
+
+
+ {selectedCount}/{genes.length}
+
+
+
+
+
+
+
+
+ {filteredGenes.map(gene => {
+ const isSelected = selectedGenes.includes(gene.id)
+ return (
+
toggleGene(gene.id)}
+ >
+
+
+
+ {gene.name}
+ {gene.description}
+
+
+
+ )
+ })}
+
+
+
+
+ )
+ })()}
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 유전체 형질 선택 모달 */}
+ {showTraitModal && (
+
+
+
+
유전체 형질 선택
+
+
+
+
+ {/* 검색창 */}
+
+
+ setTraitSearch(e.target.value)}
+ />
+
+
+ {/* 형질 목록 - 카테고리별 아코디언 */}
+
+ {traitCategories.map(cat => {
+ const selectedCount = cat.traits.filter(t => selectedTraits.includes(t.id)).length
+ const isAllSelected = selectedCount === cat.traits.length && cat.traits.length > 0
+ const hasSelected = selectedCount > 0
+
+ // 검색어가 카테고리 이름과 매칭되는지 확인
+ const isCategoryMatch = traitSearch && cat.name.toLowerCase().includes(traitSearch.toLowerCase())
+
+ // 검색 필터링: 카테고리 이름 매칭이면 전체 표시, 아니면 개별 형질 필터링
+ const filteredTraits = traitSearch
+ ? isCategoryMatch
+ ? cat.traits
+ : cat.traits.filter(t =>
+ t.name.toLowerCase().includes(traitSearch.toLowerCase()) ||
+ t.description.toLowerCase().includes(traitSearch.toLowerCase())
+ )
+ : cat.traits
+
+ // 검색 결과가 없으면 카테고리 숨기기
+ if (traitSearch && filteredTraits.length === 0) return null
+
+ const colorClasses: Record
= {
+ purple: { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200' },
+ emerald: { bg: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200' },
+ cyan: { bg: 'bg-cyan-50', text: 'text-cyan-700', border: 'border-cyan-200' },
+ amber: { bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200' },
+ rose: { bg: 'bg-rose-50', text: 'text-rose-700', border: 'border-rose-200' },
+ }
+ const colors = colorClasses[cat.color]
+
+ // 검색 중이거나 선택된 항목이 있으면 자동으로 펼침
+ const isExpanded = traitSearch ? true : (openCategories[cat.id] ?? false)
+
+ return (
+ setOpenCategories(prev => ({ ...prev, [cat.id]: open }))}
+ >
+
+ {/* 카테고리 헤더 */}
+
+
+ {
+ if (checked) {
+ const newTraits = [...selectedTraits]
+ const newWeights = { ...traitWeights }
+ cat.traits.forEach(t => {
+ if (!newTraits.includes(t.id)) {
+ newTraits.push(t.id)
+ newWeights[t.id] = 5
+ }
+ })
+ setSelectedTraits(newTraits)
+ setTraitWeights(newWeights)
+ } else {
+ setSelectedTraits(prev => prev.filter(id => !cat.traits.some(t => t.id === id)))
+ setTraitWeights(prev => {
+ const newWeights = { ...prev }
+ cat.traits.forEach(t => delete newWeights[t.id])
+ return newWeights
+ })
+ }
+ }}
+ className="h-4 w-4"
+ />
+
+ {cat.name}
+
+
+ {selectedCount}/{cat.traits.length}
+
+
+
+
+
+
+
+ {/* 형질 목록 */}
+
+
+ {filteredTraits.map(trait => {
+ const isSelected = selectedTraits.includes(trait.id)
+ return (
+
+
toggleTrait(trait.id)}
+ >
+
+
+ {trait.name}
+ {trait.description}
+
+
+ {isSelected && (
+
+
가중치
+
+
+ {traitWeights[trait.id] || 0}점
+
+
+
+ )}
+
+ )
+ })}
+
+
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ )
+}
\ No newline at end of file
diff --git a/frontend/src/app/demo/sidebar-colors/page.tsx b/frontend/src/app/demo/sidebar-colors/page.tsx
new file mode 100644
index 0000000..f28abb8
--- /dev/null
+++ b/frontend/src/app/demo/sidebar-colors/page.tsx
@@ -0,0 +1,256 @@
+'use client';
+
+import { LayoutDashboard, Database, ChevronRight } from 'lucide-react';
+
+// 사이드바 색상 조합 데모
+export default function SidebarColorsDemo() {
+ const colorSchemes = [
+ {
+ name: '현재 스타일 (진한 파란색)',
+ sidebar: 'bg-[#1f3a8f]',
+ header: 'bg-white',
+ activeMenu: 'bg-white text-slate-800',
+ inactiveMenu: 'text-white hover:bg-white/20',
+ description: '강한 대비, 전문적인 느낌'
+ },
+ {
+ name: '밝은 회색 (Notion 스타일)',
+ sidebar: 'bg-slate-100',
+ header: 'bg-white',
+ activeMenu: 'bg-white text-slate-800 shadow-sm',
+ inactiveMenu: 'text-slate-600 hover:bg-slate-200',
+ description: '깔끔하고 모던한 느낌'
+ },
+ {
+ name: '진한 네이비',
+ sidebar: 'bg-slate-900',
+ header: 'bg-white',
+ activeMenu: 'bg-white text-slate-800',
+ inactiveMenu: 'text-slate-300 hover:bg-slate-800',
+ description: '고급스럽고 세련된 느낌'
+ },
+ {
+ name: '연한 파란색',
+ sidebar: 'bg-blue-50',
+ header: 'bg-white',
+ activeMenu: 'bg-white text-blue-900 shadow-sm',
+ inactiveMenu: 'text-blue-800 hover:bg-blue-100',
+ description: '부드럽고 친근한 느낌'
+ },
+ {
+ name: '흰색 + 파란 강조',
+ sidebar: 'bg-white border-r border-slate-200',
+ header: 'bg-white',
+ activeMenu: 'bg-blue-50 text-blue-700 border-l-2 border-blue-600',
+ inactiveMenu: 'text-slate-600 hover:bg-slate-50',
+ description: 'Linear/Vercel 스타일'
+ },
+ {
+ name: '그라데이션 (파란색)',
+ sidebar: 'bg-gradient-to-b from-blue-800 to-blue-900',
+ header: 'bg-white',
+ activeMenu: 'bg-white text-slate-800',
+ inactiveMenu: 'text-blue-100 hover:bg-white/10',
+ description: '현대적이고 세련된 느낌'
+ },
+ ];
+
+ return (
+
+
사이드바 색상 조합 데모
+
로그인 페이지(흰색 배경)와 어울리는 사이드바 스타일 비교
+
+
+ {colorSchemes.map((scheme, index) => (
+
+ {/* 미니 레이아웃 프리뷰 */}
+
+ {/* 사이드바 */}
+
+ {/* 헤더 */}
+
+
+ {/* 메뉴 */}
+
+ {/* 활성 메뉴 */}
+
+
+ 대시보드
+
+
+ {/* 비활성 메뉴 */}
+
+
+ 개체 조회
+
+
+
+
+ {/* 메인 콘텐츠 영역 */}
+
+
+
+ {/* 정보 */}
+
+
{scheme.name}
+
{scheme.description}
+
+
+ ))}
+
+
+ {/* 헤더 연결 스타일 비교 */}
+
헤더 연결 스타일 비교
+
+ {/* 현재: 흰색 헤더 + 파란 사이드바 */}
+
+
+
+ {/* 흰색 헤더 */}
+
+
+ {/* 파란 콘텐츠 */}
+
+
+
+ 대시보드
+
+
+
+ 개체 조회
+
+
+
+
+
+
+
현재 스타일
+
흰색 헤더 → 파란 사이드바, 활성 메뉴 흰색
+
+
+
+ {/* 대안: 전체 밝은 톤 */}
+
+
+
+ {/* 헤더 */}
+
+
+ {/* 콘텐츠 */}
+
+
+
+ 대시보드
+
+
+
+ 개체 조회
+
+
+
+
+
+
+
밝은 톤 스타일
+
전체 밝은 톤, 로그인 페이지와 자연스러운 연결
+
+
+
+ {/* 대안: 파란 헤더 + 파란 사이드바 */}
+
+
+
+ {/* 파란 헤더 */}
+
+
+ {/* 콘텐츠 */}
+
+
+
+ 대시보드
+
+
+
+ 개체 조회
+
+
+
+
+
+
+
전체 파란색 스타일
+
통일된 파란색, 강한 브랜드 아이덴티티
+
+
+
+ {/* 대안: 왼쪽 강조선 */}
+
+
+
+ {/* 헤더 */}
+
+
+ {/* 콘텐츠 */}
+
+
+
+ 대시보드
+
+
+
+ 개체 조회
+
+
+
+
+
+
+
왼쪽 강조선 스타일
+
미니멀하고 깔끔한 네비게이션
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/common/global-filter-dialog.tsx b/frontend/src/components/common/global-filter-dialog.tsx
index f75f537..5e0b9a7 100644
--- a/frontend/src/components/common/global-filter-dialog.tsx
+++ b/frontend/src/components/common/global-filter-dialog.tsx
@@ -2,26 +2,66 @@
import { useState, useEffect } from "react"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
-import { Label } from "@/components/ui/label"
-import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
-import { Slider } from "@/components/ui/slider"
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, HelpCircle, Dna, Activity, GripVertical } from "lucide-react"
+import { Settings2, Filter, X, Search, ChevronDown, ChevronUp, Loader2, Plus, Pin } from "lucide-react"
import { useGlobalFilter } from "@/contexts/GlobalFilterContext"
-import { DEFAULT_FILTER_SETTINGS, MAJOR_GENES } from "@/types/filter.types"
+import { DEFAULT_FILTER_SETTINGS } from "@/types/filter.types"
import { geneApi, type MarkerModel } from "@/lib/api/gene.api"
-import { GeneSearchModal } from "@/components/genome/gene-search-modal"
-import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent, DragStartEvent, DragOverlay, useDroppable, DragOverEvent } from '@dnd-kit/core'
-import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
-import { CSS } from '@dnd-kit/utilities'
+
+// 형질 카테고리 정의
+const TRAIT_CATEGORIES = [
+ { id: 'growth', name: '성장형질', traits: ['12개월령체중'] },
+ { id: 'economic', name: '경제형질', traits: ['도체중', '등심단면적', '등지방두께', '근내지방도'] },
+ { id: 'body', name: '체형형질', traits: ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'] },
+ { id: 'weight', name: '부위별무게', traits: ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'] },
+ { id: 'rate', name: '부위별비율', traits: ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'] },
+]
+
+// 형질 설명
+const TRAIT_DESCRIPTIONS: Record = {
+ '12개월령체중': '12개월 시점 체중',
+ '도체중': '도축 후 고기 무게',
+ '등심단면적': '등심 부위 크기',
+ '등지방두께': '등 부위 지방 두께',
+ '근내지방도': '마블링 정도',
+ '체고': '어깨 높이',
+ '십자': '엉덩이뼈 높이',
+ '체장': '몸통 길이',
+ '흉심': '가슴 깊이',
+ '흉폭': '가슴 너비',
+ '고장': '허리뼈 길이',
+ '요각폭': '허리뼈 너비',
+ '좌골폭': '엉덩이뼈 너비',
+ '곤폭': '엉덩이뼈 끝 너비',
+ '흉위': '가슴 둘레',
+ '안심weight': '안심 부위 무게',
+ '등심weight': '등심 부위 무게',
+ '채끝weight': '채끝 부위 무게',
+ '목심weight': '목심 부위 무게',
+ '앞다리weight': '앞다리 부위 무게',
+ '우둔weight': '우둔 부위 무게',
+ '설도weight': '설도 부위 무게',
+ '사태weight': '사태 부위 무게',
+ '양지weight': '양지 부위 무게',
+ '갈비weight': '갈비 부위 무게',
+ '안심rate': '전체 대비 안심 비율',
+ '등심rate': '전체 대비 등심 비율',
+ '채끝rate': '전체 대비 채끝 비율',
+ '목심rate': '전체 대비 목심 비율',
+ '앞다리rate': '전체 대비 앞다리 비율',
+ '우둔rate': '전체 대비 우둔 비율',
+ '설도rate': '전체 대비 설도 비율',
+ '사태rate': '전체 대비 사태 비율',
+ '양지rate': '전체 대비 양지 비율',
+ '갈비rate': '전체 대비 갈비 비율',
+}
+
+type TraitName = keyof typeof DEFAULT_FILTER_SETTINGS.traitWeights
interface GlobalFilterDialogProps {
externalOpen?: boolean
@@ -32,57 +72,21 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
const { filters, updateFilters, resetFilters } = useGlobalFilter()
const [internalOpen, setInternalOpen] = useState(false)
- // 외부에서 제어하는 경우 외부 상태 사용, 아니면 내부 상태 사용
const open = externalOpen !== undefined ? externalOpen : internalOpen
const setOpen = onExternalOpenChange || setInternalOpen
- const [geneSearchModalOpen, setGeneSearchModalOpen] = useState(false) // 유전자 검색 모달
- const [weightsOpen, setWeightsOpen] = useState(true) // 기본으로 펼쳐놓기
- const [filterConditionOpen, setFilterConditionOpen] = useState(true) // 선택한 필터 조건 드롭다운
- // 고정 항목 검색 모달 상태
- const [pinnedSearchModalOpen, setPinnedSearchModalOpen] = useState(false)
- const [pinnedSearchType, setPinnedSearchType] = useState<'gene' | 'trait'>('gene')
- const [pinnedSearchQuery, setPinnedSearchQuery] = useState("")
-
- // 검색 상태
- const [searchQuery, setSearchQuery] = useState("")
- const [searchType, setSearchType] = useState<'all' | 'genes' | 'traits'>('all')
-
- // 유전자 정렬 옵션
- const [geneSortOption, setGeneSortOption] = useState<'asc' | 'desc'>('asc')
-
-
- // 드래그 앤 드롭 상태
- const [activeId, setActiveId] = useState(null)
- const sensors = useSensors(
- useSensor(PointerSensor),
- useSensor(KeyboardSensor, {
- coordinateGetter: sortableKeyboardCoordinates,
- })
- )
-
-
- // 카테고리 정의
- const categories = [
- { id: 'quantity', name: '육량형', type: 'gene' as const },
- { id: 'quality', name: '육질형', type: 'gene' as const },
- { id: 'traits', name: '경제형질', type: 'trait' as const },
- ]
+ // 모달 상태
+ const [showGeneModal, setShowGeneModal] = useState(false)
+ const [showTraitModal, setShowTraitModal] = useState(false)
+ const [geneSearch, setGeneSearch] = useState('')
+ const [traitSearch, setTraitSearch] = useState('')
// 카테고리 열림/닫힘 상태
- const [categoryOpen, setCategoryOpen] = useState({
- quantity: false, // 육량형
- quality: false, // 육질형
- growth: false, // 성장형질
- economic: false, // 경제형질
- body: false, // 체형형질
- weight: false, // 부위별무게
- rate: false, // 부위별비율
- })
+ const [openCategories, setOpenCategories] = useState>({})
- // DB에서 가져온 유전자 목록 (전체)
- const [quantityGenes, setQuantityGenes] = useState([]) // 육량형 (전체)
- const [qualityGenes, setQualityGenes] = useState([]) // 육질형 (전체)
+ // DB에서 가져온 유전자 목록
+ const [quantityGenes, setQuantityGenes] = useState([])
+ const [qualityGenes, setQualityGenes] = useState([])
const [loadingGenes, setLoadingGenes] = useState(false)
// 로컬 상태
@@ -91,9 +95,6 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
...filters,
})
- // 변경사항이 있는지 확인
- const hasChanges = JSON.stringify(localFilters) !== JSON.stringify(filters)
-
useEffect(() => {
if (open) {
setLocalFilters({
@@ -101,34 +102,20 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
...filters,
})
loadAllGenes()
- // 검색창 초기화
- setSearchQuery("")
- setSearchType('all')
- setGeneSortOption('asc')
- // 카테고리 모두 닫기
- setCategoryOpen({
- quantity: false,
- quality: false,
- growth: false,
- economic: false,
- body: false,
- weight: false,
- rate: false,
- })
+ setGeneSearch('')
+ setTraitSearch('')
+ setOpenCategories({})
}
}, [open, filters])
- // 전체 유전자 로드 (필터링 없이 모두 가져오기)
+ // 전체 유전자 로드
const loadAllGenes = async () => {
try {
setLoadingGenes(true)
-
- // 육량형과 육질형 각각 불러오기 (전체)
const [qtyGenes, qltGenes] = await Promise.all([
- geneApi.getGenesByType('QTY'), // 육량형
- geneApi.getGenesByType('QLT'), // 육질형
+ geneApi.getGenesByType('QTY'),
+ geneApi.getGenesByType('QLT'),
])
-
setQuantityGenes(qtyGenes)
setQualityGenes(qltGenes)
} catch {
@@ -139,21 +126,15 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
}
}
-
// 필터 활성화 여부
const isFilterActive = (filterSettings: typeof localFilters) => {
- // 유전자가 선택되었거나
const hasSelectedGenes = filterSettings.selectedGenes.length > 0
-
- // 가중치가 기본값(0)과 다를 때 활성 - 모든 형질 체크
const hasCustomWeights = Object.values(filterSettings.traitWeights).some(weight => weight > 0)
-
return hasSelectedGenes || hasCustomWeights
}
// 필터 적용
const handleApply = () => {
- // 점수 방식은 합계 검증 불필요 (0-10 범위만 체크)
const updatedFilters = {
...localFilters,
isActive: isFilterActive(localFilters)
@@ -174,556 +155,133 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
...prev,
selectedGenes: prev.selectedGenes.includes(markerNm)
? prev.selectedGenes.filter(g => g !== markerNm)
- : [...prev.selectedGenes, markerNm]
+ : [...prev.selectedGenes, markerNm],
+ pinnedGenes: prev.selectedGenes.includes(markerNm)
+ ? prev.pinnedGenes?.filter(g => g !== markerNm) || []
+ : prev.pinnedGenes
}))
}
- // 드래그 시작
- const handleDragStart = (event: DragStartEvent) => {
- setActiveId(event.active.id as string)
- }
-
- // 드래그 종료 (유전자)
- const handleDragEnd = (event: DragEndEvent) => {
- const { active, over } = event
-
- if (!over) {
- setActiveId(null)
- return
- }
-
- const activeGene = active.id as string
- const pinnedGenes = localFilters.pinnedGenes || []
- const isActivePinned = pinnedGenes.includes(activeGene)
-
- // 고정 영역으로 드롭한 경우 (나머지 → 고정)
- // over.id가 드롭존 ID이거나 고정된 유전자 중 하나일 때
- if ((over.id === 'pinned-genes-drop-zone' || pinnedGenes.includes(over.id as string)) && !isActivePinned) {
- setLocalFilters(prev => {
- const currentPinned = prev.pinnedGenes || []
- // 이미 고정되어 있으면 추가 안함
- if (currentPinned.includes(activeGene)) {
- return prev
- }
- // 드롭존에 놓으면 맨 뒤에 추가, 특정 유전자 위에 놓으면 그 위치에 삽입
- if (over.id === 'pinned-genes-drop-zone') {
- return {
- ...prev,
- pinnedGenes: [...currentPinned, activeGene]
- }
- } else {
- const targetIndex = currentPinned.indexOf(over.id as string)
- const newPinned = [...currentPinned]
- newPinned.splice(targetIndex, 0, activeGene)
- return {
- ...prev,
- pinnedGenes: newPinned
- }
- }
- })
- setActiveId(null)
- return
- }
-
- // 나머지 영역으로 드롭한 경우 (고정 → 나머지 = 고정 해제)
- if ((over.id === 'remaining-genes-area' || (!pinnedGenes.includes(over.id as string) && localFilters.selectedGenes.includes(over.id as string))) && isActivePinned) {
- setLocalFilters(prev => ({
- ...prev,
- pinnedGenes: prev.pinnedGenes?.filter(g => g !== activeGene) || []
- }))
- setActiveId(null)
- return
- }
-
- // 고정 영역 내부에서 순서 변경
- if (isActivePinned && pinnedGenes.includes(over.id as string)) {
- setLocalFilters(prev => {
- const pinnedGenes = prev.pinnedGenes || []
- const oldIndex = pinnedGenes.indexOf(activeGene)
- const newIndex = pinnedGenes.indexOf(over.id as string)
-
- return {
- ...prev,
- pinnedGenes: arrayMove(pinnedGenes, oldIndex, newIndex)
- }
- })
- setActiveId(null)
- return
- }
-
- // 나머지 영역에서 순서 변경
- if (!isActivePinned && active.id !== over.id) {
- setLocalFilters(prev => {
- const oldIndex = prev.selectedGenes.indexOf(activeGene)
- const newIndex = prev.selectedGenes.indexOf(over.id as string)
-
- return {
- ...prev,
- selectedGenes: arrayMove(prev.selectedGenes, oldIndex, newIndex)
- }
- })
- }
-
- setActiveId(null)
- }
-
- // 드래그 종료 (형질)
- const handleTraitDragEnd = (event: DragEndEvent) => {
- const { active, over } = event
-
- if (!over) {
- setActiveId(null)
- return
- }
-
- const activeTrait = active.id as string
- const pinnedTraits = localFilters.pinnedTraits || []
- const isActivePinned = pinnedTraits.includes(activeTrait)
-
- // 고정 영역으로 드롭한 경우 (나머지 → 고정)
- // over.id가 드롭존 ID이거나 고정된 형질 중 하나일 때
- if ((over.id === 'pinned-traits-drop-zone' || pinnedTraits.includes(over.id as string)) && !isActivePinned) {
- setLocalFilters(prev => {
- const currentPinned = prev.pinnedTraits || []
- // 이미 고정되어 있으면 추가 안함
- if (currentPinned.includes(activeTrait)) {
- return prev
- }
- // 드롭존에 놓으면 맨 뒤에 추가, 특정 형질 위에 놓으면 그 위치에 삽입
- if (over.id === 'pinned-traits-drop-zone') {
- return {
- ...prev,
- pinnedTraits: [...currentPinned, activeTrait]
- }
- } else {
- const targetIndex = currentPinned.indexOf(over.id as string)
- const newPinned = [...currentPinned]
- newPinned.splice(targetIndex, 0, activeTrait)
- return {
- ...prev,
- pinnedTraits: newPinned
- }
- }
- })
- setActiveId(null)
- return
- }
-
- // 나머지 영역으로 드롭한 경우 (고정 → 나머지 = 고정 해제)
- const selectedTraits = localFilters.selectedTraits || []
- if ((over.id === 'remaining-traits-area' || (!pinnedTraits.includes(over.id as string) && selectedTraits.includes(over.id as string))) && isActivePinned) {
- setLocalFilters(prev => ({
- ...prev,
- pinnedTraits: prev.pinnedTraits?.filter(t => t !== activeTrait) || []
- }))
- setActiveId(null)
- return
- }
-
- // 고정 영역 내부에서 순서 변경
- if (isActivePinned && pinnedTraits.includes(over.id as string)) {
- setLocalFilters(prev => {
- const pinnedTraits = prev.pinnedTraits || []
- const oldIndex = pinnedTraits.indexOf(activeTrait)
- const newIndex = pinnedTraits.indexOf(over.id as string)
-
- return {
- ...prev,
- pinnedTraits: arrayMove(pinnedTraits, oldIndex, newIndex)
- }
- })
- setActiveId(null)
- return
- }
-
- // 나머지 영역에서 순서 변경
- if (!isActivePinned && active.id !== over.id) {
- setLocalFilters(prev => {
- const selectedTraits = prev.selectedTraits || []
- const oldIndex = selectedTraits.indexOf(activeTrait)
- const newIndex = selectedTraits.indexOf(over.id as string)
-
- return {
- ...prev,
- selectedTraits: arrayMove(selectedTraits, oldIndex, newIndex)
- }
- })
- }
-
- setActiveId(null)
- }
-
- // 드래그 종료 (고정 유전자)
- const handlePinnedGeneDragEnd = (event: DragEndEvent) => {
- const { active, over } = event
-
- if (over && active.id !== over.id) {
- setLocalFilters(prev => {
- const pinnedGenes = prev.pinnedGenes || []
- const oldIndex = pinnedGenes.indexOf(active.id as string)
- const newIndex = pinnedGenes.indexOf(over.id as string)
-
- return {
- ...prev,
- pinnedGenes: arrayMove(pinnedGenes, oldIndex, newIndex)
- }
- })
- }
-
- setActiveId(null)
- }
-
- // 드래그 종료 (고정 형질)
- const handlePinnedTraitDragEnd = (event: DragEndEvent) => {
- const { active, over } = event
-
- if (over && active.id !== over.id) {
- setLocalFilters(prev => {
- const pinnedTraits = prev.pinnedTraits || []
- const oldIndex = pinnedTraits.indexOf(active.id as string)
- const newIndex = pinnedTraits.indexOf(over.id as string)
-
- return {
- ...prev,
- pinnedTraits: arrayMove(pinnedTraits, oldIndex, newIndex)
- }
- })
- }
-
- setActiveId(null)
- }
-
- // 카테고리에서 유전자를 드롭 영역으로 추가
- const handleAddGene = (geneName: string) => {
- if (!localFilters.selectedGenes.includes(geneName)) {
- setLocalFilters(prev => ({
- ...prev,
- selectedGenes: [...prev.selectedGenes, geneName]
- }))
- }
- }
-
-
- // 가중치 변경 (점수: -10 ~ +10)
- const updateTraitWeight = (traitName: TraitName, value: number) => {
+ // 유전자 고정 토글
+ const toggleGenePin = (markerNm: string) => {
setLocalFilters(prev => {
- // 0 ~ 10 범위로 제한
- const adjustedValue = Math.min(10, Math.max(0, value))
-
- // 새로운 가중치 객체
- const newWeights = {
- ...prev.traitWeights,
- [traitName]: adjustedValue
- }
-
- // selectedTraits 배열 업데이트 (가중치 > 0인 형질들만 포함, 기존 순서 유지)
- const currentSelected = prev.selectedTraits || []
- let newSelectedTraits: string[]
-
- if (adjustedValue > 0) {
- // 가중치가 0보다 크면 추가 (이미 있으면 유지)
- if (!currentSelected.includes(traitName)) {
- newSelectedTraits = [...currentSelected, traitName]
- } else {
- newSelectedTraits = currentSelected
- }
+ const pinnedGenes = prev.pinnedGenes || []
+ if (pinnedGenes.includes(markerNm)) {
+ return { ...prev, pinnedGenes: pinnedGenes.filter(g => g !== markerNm) }
} else {
- // 가중치가 0이면 제거
- newSelectedTraits = currentSelected.filter(t => t !== traitName)
- }
-
- return {
- ...prev,
- traitWeights: newWeights,
- selectedTraits: newSelectedTraits
+ return { ...prev, pinnedGenes: [...pinnedGenes, markerNm] }
}
})
}
- // 형질별 설명
- const traitDescriptions: Record = {
- // 성장형질
- '12개월령체중': '12개월 시점의 체중. 높을수록 성장이 좋음',
+ // 형질 토글
+ const toggleTrait = (traitName: string) => {
+ setLocalFilters(prev => {
+ const selectedTraits = prev.selectedTraits || []
+ const traitWeights = { ...prev.traitWeights }
- // 경제형질
- '도체중': '도축 후 뼈와 내장을 제외한 고기 무게. 높을수록 좋음',
- '등심단면적': '등심 부위의 크기. 넓을수록 고급육 생산에 유리',
- '등지방두께': '등 부위 지방 두께. 적당히 얇을수록 좋음',
- '근내지방도': '고기 내 지방(마블링) 정도. 높을수록 육질 우수',
-
- // 체형형질 - DB 형질명과 일치
- '체고': '어깨 높이. 높을수록 좋음',
- '십자': '엉덩이뼈 높이. 높을수록 좋음',
- '체장': '몸통 길이. 길수록 좋음',
- '흉심': '가슴 깊이. 깊을수록 좋음',
- '흉폭': '가슴 너비. 넓을수록 좋음',
- '고장': '허리뼈 길이. 길수록 좋음',
- '요각폭': '허리뼈 너비. 넓을수록 좋음',
- '좌골폭': '엉덩이뼈 너비. 넓을수록 좋음',
- '곤폭': '엉덩이뼈 끝 너비. 넓을수록 좋음',
- '흉위': '가슴 둘레. 클수록 좋음',
-
- // 부위별 무게 - DB 형질명과 일치
- '안심weight': '안심 부위 무게. 높을수록 좋음',
- '등심weight': '등심 부위 무게. 높을수록 좋음',
- '채끝weight': '채끝 부위 무게. 높을수록 좋음',
- '목심weight': '목심 부위 무게. 높을수록 좋음',
- '앞다리weight': '앞다리 부위 무게. 높을수록 좋음',
- '우둔weight': '우둔 부위 무게. 높을수록 좋음',
- '설도weight': '설도 부위 무게. 높을수록 좋음',
- '사태weight': '사태 부위 무게. 높을수록 좋음',
- '양지weight': '양지 부위 무게. 높을수록 좋음',
- '갈비weight': '갈비 부위 무게. 높을수록 좋음',
-
- // 부위별 비율 - DB 형질명과 일치
- '안심rate': '전체 대비 안심 비율. 높을수록 좋음',
- '등심rate': '전체 대비 등심 비율. 높을수록 좋음',
- '채끝rate': '전체 대비 채끝 비율. 높을수록 좋음',
- '목심rate': '전체 대비 목심 비율. 높을수록 좋음',
- '앞다리rate': '전체 대비 앞다리 비율. 높을수록 좋음',
- '우둔rate': '전체 대비 우둔 비율. 높을수록 좋음',
- '설도rate': '전체 대비 설도 비율. 높을수록 좋음',
- '사태rate': '전체 대비 사태 비율. 높을수록 좋음',
- '양지rate': '전체 대비 양지 비율. 높을수록 좋음',
- '갈비rate': '전체 대비 갈비 비율. 높을수록 좋음',
+ if (selectedTraits.includes(traitName)) {
+ // 제거
+ traitWeights[traitName as TraitName] = 0
+ return {
+ ...prev,
+ selectedTraits: selectedTraits.filter(t => t !== traitName),
+ pinnedTraits: prev.pinnedTraits?.filter(t => t !== traitName) || [],
+ traitWeights
+ }
+ } else {
+ // 추가
+ traitWeights[traitName as TraitName] = 5
+ return {
+ ...prev,
+ selectedTraits: [...selectedTraits, traitName],
+ traitWeights
+ }
+ }
+ })
}
- // 형질별 라벨 (왼쪽: 낮게, 오른쪽: 높게)
- const traitLabels: Record = {
- // 성장형질
- '12개월령체중': { left: '낮게', right: '높게' },
-
- // 경제형질
- '도체중': { left: '낮게', right: '높게' },
- '등심단면적': { left: '낮게', right: '높게' },
- '등지방두께': { left: '두껍게', right: '얇게' }, // 반대!
- '근내지방도': { left: '낮게', right: '높게' },
-
- // 체형형질 (클수록 좋음) - DB 형질명과 일치
- '체고': { left: '낮게', right: '높게' },
- '십자': { left: '낮게', right: '높게' },
- '체장': { left: '짧게', right: '길게' },
- '흉심': { left: '얕게', right: '깊게' },
- '흉폭': { left: '좁게', right: '넓게' },
- '고장': { left: '짧게', right: '길게' },
- '요각폭': { left: '좁게', right: '넓게' },
- '좌골폭': { left: '좁게', right: '넓게' },
- '곤폭': { left: '좁게', right: '넓게' },
- '흉위': { left: '작게', right: '크게' },
-
- // 부위별 무게 (높을수록 좋음) - DB 형질명과 일치
- '안심weight': { left: '낮게', right: '높게' },
- '등심weight': { left: '낮게', right: '높게' },
- '채끝weight': { left: '낮게', right: '높게' },
- '목심weight': { left: '낮게', right: '높게' },
- '앞다리weight': { left: '낮게', right: '높게' },
- '우둔weight': { left: '낮게', right: '높게' },
- '설도weight': { left: '낮게', right: '높게' },
- '사태weight': { left: '낮게', right: '높게' },
- '양지weight': { left: '낮게', right: '높게' },
- '갈비weight': { left: '낮게', right: '높게' },
-
- // 부위별 비율 (높을수록 좋음) - DB 형질명과 일치
- '안심rate': { left: '낮게', right: '높게' },
- '등심rate': { left: '낮게', right: '높게' },
- '채끝rate': { left: '낮게', right: '높게' },
- '목심rate': { left: '낮게', right: '높게' },
- '앞다리rate': { left: '낮게', right: '높게' },
- '우둔rate': { left: '낮게', right: '높게' },
- '설도rate': { left: '낮게', right: '높게' },
- '사태rate': { left: '낮게', right: '높게' },
- '양지rate': { left: '낮게', right: '높게' },
- '갈비rate': { left: '낮게', right: '높게' },
+ // 형질 고정 토글
+ const toggleTraitPin = (traitName: string) => {
+ setLocalFilters(prev => {
+ const pinnedTraits = prev.pinnedTraits || []
+ if (pinnedTraits.includes(traitName)) {
+ return { ...prev, pinnedTraits: pinnedTraits.filter(t => t !== traitName) }
+ } else {
+ return { ...prev, pinnedTraits: [...pinnedTraits, traitName] }
+ }
+ })
}
- // 형질 카테고리 및 형질 목록 - DB 형질명과 일치
- const traitCategories = {
- growth: {
- name: '성장형질',
- traits: ['12개월령체중'] as const
- },
- economic: {
- name: '경제형질',
- traits: ['도체중', '등심단면적', '등지방두께', '근내지방도'] as const
- },
- body: {
- name: '체형형질',
- traits: ['체고', '십자', '체장', '흉심', '흉폭', '고장', '요각폭', '좌골폭', '곤폭', '흉위'] as const
- },
- weight: {
- name: '부위별무게',
- traits: ['안심weight', '등심weight', '채끝weight', '목심weight', '앞다리weight', '우둔weight', '설도weight', '사태weight', '양지weight', '갈비weight'] as const
- },
- rate: {
- name: '부위별비율',
- traits: ['안심rate', '등심rate', '채끝rate', '목심rate', '앞다리rate', '우둔rate', '설도rate', '사태rate', '양지rate', '갈비rate'] as const
- }
- }
-
- type TraitName = keyof typeof localFilters.traitWeights
-
- // 검색 필터링 로직 - 유전자 이름만 검색 (형질 검색과 동일하게)
- const filterBySearch = (items: MarkerModel[]) => {
- if (!searchQuery.trim()) return items
- const query = searchQuery.toLowerCase()
- return items.filter(item =>
- item.markerNm.toLowerCase().includes(query)
- )
- }
-
- // 카테고리 이름 매칭 여부 확인
- const isQuantityCategoryMatch = () => {
- if (!searchQuery.trim()) return false
- const query = searchQuery.toLowerCase()
- // "육량형" 또는 "육량" 또는 "quantity"가 검색어로 시작하면 매칭
- return '육량형'.startsWith(query) || '육량'.startsWith(query) || 'quantity'.startsWith(query)
- }
-
- const isQualityCategoryMatch = () => {
- if (!searchQuery.trim()) return false
- const query = searchQuery.toLowerCase()
- // "육질형" 또는 "육질" 또는 "quality"가 검색어로 시작하면 매칭
- return '육질형'.startsWith(query) || '육질'.startsWith(query) || 'quality'.startsWith(query)
- }
-
- // 유전자 정렬 함수
- const sortGenes = (genes: MarkerModel[]) => {
- const sorted = [...genes]
- switch (geneSortOption) {
- case 'asc':
- // 오름차순 (A → Z)
- return sorted.sort((a, b) => a.markerNm.localeCompare(b.markerNm))
- case 'desc':
- // 내림차순 (Z → A)
- return sorted.sort((a, b) => b.markerNm.localeCompare(a.markerNm))
- default:
- return sorted
- }
- }
-
- // 유전자 필터링 - 카테고리 이름 매칭 시 전체 표시, 아니면 유전자 이름만 검색
- const filteredQuantityGenes = sortGenes(isQuantityCategoryMatch() ? quantityGenes : filterBySearch(quantityGenes))
- const filteredQualityGenes = sortGenes(isQualityCategoryMatch() ? qualityGenes : filterBySearch(qualityGenes))
- const totalFilteredGenes = [...filteredQuantityGenes, ...filteredQualityGenes]
-
- // 검색 시 카테고리 자동 펼치기/닫기 (유전자 + 형질)
- useEffect(() => {
- if (searchQuery.trim()) {
- const query = searchQuery.toLowerCase()
-
- // 유전자 카테고리 - 유전자가 매칭되거나 카테고리 이름이 매칭되면 자동으로 열기
- const shouldOpenQuantity = filteredQuantityGenes.length > 0 || '육량형'.startsWith(query) || '육량'.startsWith(query) || 'quantity'.startsWith(query)
- const shouldOpenQuality = filteredQualityGenes.length > 0 || '육질형'.startsWith(query) || '육질'.startsWith(query) || 'quality'.startsWith(query)
-
- // 검색 결과에 따라 동적으로 열기/닫기 (이전 상태 무시)
- setCategoryOpen(prev => ({
+ // 가중치 변경
+ const updateTraitWeight = (traitName: string, delta: number) => {
+ setLocalFilters(prev => {
+ const current = prev.traitWeights[traitName as TraitName] || 0
+ const newValue = Math.min(10, Math.max(0, current + delta))
+ return {
...prev,
- quantity: shouldOpenQuantity,
- quality: shouldOpenQuality
- }))
-
- // 형질 카테고리 - 형질이 매칭되거나 카테고리 이름이 매칭되면 자동으로 열기
- const newTraitCategoryStates: Record = {}
- Object.entries(traitCategories).forEach(([key, category]) => {
- const hasTraitResults = (category.traits as unknown as string[]).some(trait =>
- trait.toLowerCase().includes(query)
- )
- const hasCategoryNameMatch = category.name.toLowerCase().includes(query)
-
- const shouldOpen = hasTraitResults || hasCategoryNameMatch
- newTraitCategoryStates[key] = shouldOpen // 검색 결과에 따라서만 열기/닫기
- })
-
- setCategoryOpen(prev => ({ ...prev, ...newTraitCategoryStates }))
- } else {
- // 검색어가 없으면 모든 카테고리 닫기
- setCategoryOpen({
- quantity: false,
- quality: false,
- growth: false,
- economic: false,
- body: false,
- weight: false,
- rate: false
- })
- }
- }, [searchQuery, filteredQuantityGenes.length, filteredQualityGenes.length])
-
- // 형질도 검색 필터링 - 모든 카테고리의 형질 포함
- const allTraits = Object.values(traitCategories).flatMap(cat => cat.traits as unknown as string[])
- const filteredTraits = searchQuery.trim()
- ? allTraits.filter(trait => trait.toLowerCase().includes(searchQuery.toLowerCase()))
- : allTraits
-
- // 카테고리 검색 필터링
- const filteredCategories = searchQuery.trim()
- ? categories.filter(cat => cat.name.toLowerCase().includes(searchQuery.toLowerCase()))
- : []
-
- // 검색 중인지 여부
- const isSearching = searchQuery.trim().length > 0
-
- // 검색 결과 표시 여부 (검색 타입에 따라)
- const showCategoryResults = isSearching && searchType === 'all' && filteredCategories.length > 0
- const showGeneResults = isSearching && (searchType === 'all' || searchType === 'genes') && totalFilteredGenes.length > 0
- const showTraitResults = isSearching && (searchType === 'all' || searchType === 'traits') && filteredTraits.length > 0
-
- // 카테고리 토글
- const toggleCategory = (category: 'quantity' | 'quality' | 'growth' | 'economic' | 'body' | 'weight' | 'rate') => {
- setCategoryOpen(prev => ({ ...prev, [category]: !prev[category] }))
+ traitWeights: { ...prev.traitWeights, [traitName]: newValue }
+ }
+ })
}
// 카테고리 전체 선택/해제
- const toggleCategoryAll = (category: 'quantity' | 'quality', select: boolean) => {
- const genes = category === 'quantity' ? filteredQuantityGenes : filteredQualityGenes
- const geneNames = genes.map(g => g.markerNm)
-
- setLocalFilters(prev => ({
- ...prev,
- selectedGenes: select
- ? [...new Set([...prev.selectedGenes, ...geneNames])] // 추가
- : prev.selectedGenes.filter(g => !geneNames.includes(g)) // 제거
- }))
- }
-
- // 카테고리가 모두 선택되었는지 확인
- const isCategoryFullySelected = (categoryId: string): boolean => {
- if (categoryId === 'quantity') {
- const geneNames = quantityGenes.map(g => g.markerNm)
- return geneNames.length > 0 && geneNames.every(name => localFilters.selectedGenes.includes(name))
- } else if (categoryId === 'quality') {
- const geneNames = qualityGenes.map(g => g.markerNm)
- return geneNames.length > 0 && geneNames.every(name => localFilters.selectedGenes.includes(name))
- } else if (categoryId === 'traits') {
- // 경제형질은 가중치 합계가 100인지 확인
- return Object.values(localFilters.traitWeights).reduce((a, b) => a + b, 0) === 100
- }
- return false
- }
-
- // 카테고리 선택/해제 토글 (검색 결과에서)
- const toggleCategorySelection = (categoryId: string) => {
- if (categoryId === 'quantity' || categoryId === 'quality') {
- const isSelected = isCategoryFullySelected(categoryId)
- toggleCategoryAll(categoryId as 'quantity' | 'quality', !isSelected)
- } else if (categoryId === 'traits') {
- // 경제형질은 가중치가 있으므로 기본값 설정
- if (isCategoryFullySelected('traits')) {
- // 모두 0으로 초기화
- setLocalFilters(prev => ({
- ...prev,
- traitWeights: { ...prev.traitWeights, 도체중: 0, 등심단면적: 0, 등지방두께: 0, 근내지방도: 0 }
- }))
+ const toggleCategoryGenes = (genes: MarkerModel[], select: boolean) => {
+ setLocalFilters(prev => {
+ if (select) {
+ const newGenes = [...prev.selectedGenes]
+ genes.forEach(g => {
+ if (!newGenes.includes(g.markerNm)) newGenes.push(g.markerNm)
+ })
+ return { ...prev, selectedGenes: newGenes }
} else {
- // 균등 분배 (25씩)
- setLocalFilters(prev => ({
+ return {
...prev,
- traitWeights: { ...prev.traitWeights, 도체중: 25, 등심단면적: 25, 등지방두께: 25, 근내지방도: 25 }
- }))
+ selectedGenes: prev.selectedGenes.filter(id => !genes.some(g => g.markerNm === id)),
+ pinnedGenes: prev.pinnedGenes?.filter(id => !genes.some(g => g.markerNm === id)) || []
+ }
}
- }
+ })
}
+ const toggleCategoryTraits = (traits: string[], select: boolean) => {
+ setLocalFilters(prev => {
+ const newWeights = { ...prev.traitWeights }
+ let newSelectedTraits = [...(prev.selectedTraits || [])]
+
+ if (select) {
+ traits.forEach(t => {
+ if (!newSelectedTraits.includes(t)) {
+ newSelectedTraits.push(t)
+ newWeights[t as TraitName] = 5
+ }
+ })
+ } else {
+ traits.forEach(t => {
+ newWeights[t as TraitName] = 0
+ })
+ newSelectedTraits = newSelectedTraits.filter(t => !traits.includes(t))
+ }
+
+ return {
+ ...prev,
+ selectedTraits: newSelectedTraits,
+ traitWeights: newWeights,
+ pinnedTraits: select ? prev.pinnedTraits : prev.pinnedTraits?.filter(t => !traits.includes(t)) || []
+ }
+ })
+ }
+
+ // 정렬: 고정된 것 먼저
+ 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)) || [])
+ ]
+
return (
@@ -734,8 +292,7 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
className="h-8 md:h-9 text-xs md:text-base font-semibold px-2 md:px-3"
>
- 필터
- 필터
+ 필터
{filters.isActive ? (
활성
) : (
@@ -743,1242 +300,530 @@ export function GlobalFilterDialog({ externalOpen, onExternalOpenChange }: Globa
)}
- {/* 85vw → 92vw (7% 더 넓게), calc(100vw-14rem) → calc(100vw-10rem) (사이드바 여백 축소)*/}
-
+
+
{/* 헤더 */}
-
+
-
-
+
+
전역 필터 설정
-
- 유전자와 유전체 형질을 선택하여 원하는 필터를 설정하세요
+
+ 유전자와 형질을 선택하세요
{/* 메인 콘텐츠 */}
-
-
- {/* 통합 검색창 */}
-
-
- {/* 검색 타입 드롭다운 (왼쪽) */}
-
-
- {/* 구분선 */}
-
-
- {/* 검색 아이콘 */}
-
-
- {/* 검색 입력창 */}
-
setSearchQuery(e.target.value)}
- className="flex-1 h-full text-sm sm:text-base md:text-lg border-0 border-none bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-0 shadow-none pl-2 sm:pl-3 pr-3 sm:pr-4"
- />
-
+
+
+ {/* 유전자 선택 버튼 */}
+
+
유전자
+
- {/* 선택한 필터 조건 요약 박스 - 유전자 또는 형질이 선택되었을 때 표시 */}
- {(localFilters.selectedGenes.length > 0 || (localFilters.selectedTraits && localFilters.selectedTraits.length > 0)) && (
-
- {/* 헤더 */}
-
-
-
-
-
선택한 필터 조건
-
- {localFilters.selectedGenes.length + (localFilters.selectedTraits?.length || 0)}개
-
-
- {filterConditionOpen ?
:
}
-
-
+ {/* 형질 선택 버튼 */}
+
+
유전체 형질
+
+
- {/* 1열(모바일) / 2열(데스크톱) 레이아웃: 유전자 + 형질 */}
-
-
-
- {/* 1. 선택된 유전자 */}
-
-
-
-
- 유전자
-
- {localFilters.selectedGenes.length}개
-
-
- {localFilters.selectedGenes.length > 0 && (
-
- )}
-
+ {/* 선택한 조건 표시 */}
+ {(localFilters.selectedGenes.length > 0 || (localFilters.selectedTraits?.length || 0) > 0) && (
+
+
+ 선택한 조건 ({localFilters.selectedGenes.length + (localFilters.selectedTraits?.length || 0)}개)
+
- {localFilters.selectedGenes.length > 0 ? (
-
0 && (
+
+
유전자
+
+ {sortedSelectedGenes.map(geneId => {
+ const isPinned = localFilters.pinnedGenes?.includes(geneId)
+ return (
+
- {/* 고정 영역 */}
-
-
-
- 📌 상위 고정
-
- {(localFilters.pinnedGenes?.length || 0)}개
-
-
-
-
-
- {(localFilters.pinnedGenes?.length || 0) > 0 ? (
-
-
- {localFilters.pinnedGenes?.map((gene, index) => (
- {
- setLocalFilters(prev => ({
- ...prev,
- pinnedGenes: prev.pinnedGenes?.filter(g => g !== gene)
- }))
- }}
- />
- ))}
-
-
- ) : (
-
- 검색하여 고정할 유전자를 추가하세요
-
- )}
-
-
-
- {activeId ? (
-
- {activeId}
-
- ) : null}
-
-
- ) : (
-
- )}
-
-
- {/* 2. 선택된 형질 (오른쪽) */}
-
-
-
-
-
형질
-
- {(localFilters.selectedTraits?.length || 0)}개
-
-
- {(localFilters.selectedTraits && localFilters.selectedTraits.length > 0) && (
-
-
- {(localFilters.selectedTraits && localFilters.selectedTraits.length > 0) ? (
-
- {/* 고정 영역 */}
-
-
-
- 📌 상위 고정
-
- {(localFilters.pinnedTraits?.length || 0)}개
-
-
-
{
- setPinnedSearchType('trait')
- setPinnedSearchQuery("")
- setPinnedSearchModalOpen(true)
- }}
- >
- + 추가
-
-
-
- {(localFilters.pinnedTraits?.length || 0) > 0 ? (
-
-
- {localFilters.pinnedTraits?.map((trait, index) => (
- {
- setLocalFilters(prev => ({
- ...prev,
- pinnedTraits: prev.pinnedTraits?.filter(t => t !== trait)
- }))
- }}
- />
- ))}
-
-
- ) : (
-
- 검색하여 고정할 형질을 추가하세요
-
- )}
-
-
-
- {activeId ? (
-
- {activeId}
-
- ) : null}
-
-
- ) : (
-
-
형질을 선택해주세요
+
+
+
toggleGene(geneId)}
+ className="p-0.5 rounded hover:bg-black/10"
+ >
+
+
- )}
-
+ )
+ })}
-
-
+ )}
+
+ {/* 형질 */}
+ {sortedSelectedTraits.length > 0 && (
+
+
유전체 형질
+
+ {sortedSelectedTraits.map(traitId => {
+ const isPinned = localFilters.pinnedTraits?.includes(traitId)
+ return (
+
+ {isPinned &&
}
+
{traitId}
+
({localFilters.traitWeights[traitId as TraitName] || 0}점)
+
toggleTraitPin(traitId)}
+ className="p-0.5 rounded hover:bg-black/10"
+ title={isPinned ? '고정 해제' : '고정'}
+ >
+
+
+
toggleTrait(traitId)}
+ className="p-0.5 rounded hover:bg-black/10"
+ >
+
+
+
+ )
+ })}
+
+
+ {/* 가중치 조절 */}
+
+
가중치 조절
+ {sortedSelectedTraits.map(traitId => (
+
+
{traitId}
+
+ updateTraitWeight(traitId, -1)}
+ disabled={(localFilters.traitWeights[traitId as TraitName] || 0) <= 0}
+ >
+ -
+
+
+ {localFilters.traitWeights[traitId as TraitName] || 0}점
+
+ updateTraitWeight(traitId, 1)}
+ disabled={(localFilters.traitWeights[traitId as TraitName] || 0) >= 10}
+ >
+ +
+
+
+
+ ))}
+
+
+ )}
+
)}
+
+
- {/* 카테고리별 선택 (2열 레이아웃) */}
-
-
-
-
+
+ 초기화
+
+
+ 적용
+
+
+
+ {/* 유전자 선택 모달 */}
+ {showGeneModal && (
+
+
+
+
유전자 선택
+ setShowGeneModal(false)}
+ className="p-2 hover:bg-slate-100 rounded-lg"
>
- 전체 초기화
-
-
- {/* 근친도 임계값 */}
-
-
- {/* 왼쪽: 라벨 */}
-
-
- %
-
-
-
-
낮을수록 유전적 다양성 확보
-
-
-
- {/* 중앙: 슬라이더 - 모바일에서 전체 너비 */}
-
-
- setLocalFilters(prev => ({ ...prev, inbreedingThreshold: value[0] }))
- }
- min={0}
- max={30}
- step={0.5}
- className="w-full h-10 sm:h-auto"
- />
-
- 0%
- 30%
-
-
-
- {/* 오른쪽: 값 표시 */}
-
- {
- const input = e.target.value
- // 숫자와 소수점만 허용
- if (input === '' || /^\d*\.?\d*$/.test(input)) {
- const val = input === '' ? 0 : parseFloat(input)
- if (!isNaN(val) && val >= 0 && val <= 30) {
- setLocalFilters(prev => ({
- ...prev,
- inbreedingThreshold: val
- }))
- } else if (input === '' || input === '.') {
- // 빈 값이거나 소수점만 입력 중인 경우 일시적으로 허용
- setLocalFilters(prev => ({
- ...prev,
- inbreedingThreshold: 0
- }))
- }
- }
- }}
- onBlur={() => {
- // blur 시 범위 체크 및 정리
- const val = Math.min(30, Math.max(0, localFilters.inbreedingThreshold))
- setLocalFilters(prev => ({
- ...prev,
- inbreedingThreshold: val
- }))
- }}
- className="w-22 sm:w-28 h-12 sm:h-14 text-center text-xl sm:text-2xl font-bold border border-slate-200"
- />
- %
-
-
+
+
- {loadingGenes ? (
-
-
+
+ {/* 검색창 */}
+
+
+ setGeneSearch(e.target.value)}
+ />
- ) : (
-
- {/* 왼쪽: 유전자 (육량형 + 육질형) */}
-
-
-
-
-
-
-
-
- {/* 육량형 Collapsible */}
-
toggleCategory('quantity')}
- >
- {(() => {
- const selectedCount = filteredQuantityGenes.filter(g =>
- localFilters.selectedGenes.includes(g.markerNm)
- ).length
- const hasSelected = selectedCount > 0
-
- return (
-
-
- {/* 왼쪽: 체크박스 + 라벨 */}
-
- 0}
- onCheckedChange={(checked) => {
- toggleCategoryAll('quantity', !!checked)
- }}
- className="h-4 w-4 sm:h-5 sm:w-5 cursor-pointer"
- />
- 육량형
-
- {selectedCount}/{filteredQuantityGenes.length}개
-
-
-
- {/* 오른쪽: 아코디언 토글 버튼 */}
-
-
- {categoryOpen.quantity ? : }
-
-
-
-
-
-
- {filteredQuantityGenes.length === 0 ? (
-
- 검색 결과가 없습니다
-
- ) : (
-
- {filteredQuantityGenes.map((gene) => {
- const isSelected = localFilters.selectedGenes.includes(gene.markerNm)
- return (
-
-
-
toggleGene(gene.markerNm)}
- className="h-5 w-5 sm:h-6 sm:w-6 cursor-pointer"
- />
- toggleGene(gene.markerNm)}>
-
{gene.markerNm}
- {gene.relatedTrait && (
-
{gene.relatedTrait}
- )}
-
-
-
- )
- })}
-
- )}
-
-
-
- )
- })()}
-
-
- {/* 육질형 Collapsible */}
-
toggleCategory('quality')}
- >
- {(() => {
- const selectedCount = filteredQualityGenes.filter(g =>
- localFilters.selectedGenes.includes(g.markerNm)
- ).length
- const hasSelected = selectedCount > 0
-
- return (
-
-
- {/* 왼쪽: 체크박스 + 라벨 */}
-
- 0}
- onCheckedChange={(checked) => {
- toggleCategoryAll('quality', !!checked)
- }}
- className="h-4 w-4 sm:h-5 sm:w-5 cursor-pointer"
- />
- 육질형
-
- {selectedCount}/{filteredQualityGenes.length}개
-
-
-
- {/* 오른쪽: 아코디언 토글 버튼 */}
-
-
- {categoryOpen.quality ? : }
-
-
-
-
-
-
- {filteredQualityGenes.length === 0 ? (
-
- 검색 결과가 없습니다
-
- ) : (
-
- {filteredQualityGenes.map((gene) => {
- const isSelected = localFilters.selectedGenes.includes(gene.markerNm)
- return (
-
-
-
toggleGene(gene.markerNm)}
- className="h-5 w-5 sm:h-6 sm:w-6 cursor-pointer"
- />
- toggleGene(gene.markerNm)}>
-
{gene.markerNm}
- {gene.relatedTrait && (
-
{gene.relatedTrait}
- )}
-
-
-
- )
- })}
-
- )}
-
-
-
- )
- })()}
-
+ {loadingGenes ? (
+
+
+ ) : (
+
+ {/* 육량형 */}
+ {(() => {
+ const genes = quantityGenes
+ const isCategoryMatch = geneSearch && '육량형'.includes(geneSearch.toLowerCase())
+ const filteredGenes = geneSearch
+ ? isCategoryMatch
+ ? genes
+ : genes.filter(g =>
+ g.markerNm.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ (g.relatedTrait && g.relatedTrait.toLowerCase().includes(geneSearch.toLowerCase()))
+ )
+ : genes
- {/* 오른쪽: 경제형질 + 가중치 */}
-
-
+ if (geneSearch && filteredGenes.length === 0) return null
- {/* 형질 카테고리별 Collapsible */}
- {Object.entries(traitCategories).map(([key, category]) => {
- // 검색 필터링 - 카테고리 이름 매칭 시 전체 표시
- const query = searchQuery.trim().toLowerCase()
- const isCategoryNameMatch = category.name.toLowerCase().includes(query)
-
- const filteredCategoryTraits = searchQuery.trim()
- ? isCategoryNameMatch
- ? (category.traits as unknown as string[]) // 카테고리 이름 매칭 시 전체 표시
- : (category.traits as unknown as string[]).filter(trait =>
- trait.toLowerCase().includes(query)
- )
- : (category.traits as unknown as string[])
-
- // 선택된 형질 개수 계산 (가중치 > 0)
- const selectedTraitCount = filteredCategoryTraits.filter(trait =>
- (localFilters.traitWeights[trait as TraitName] ?? 0) > 0
- ).length
- const hasTraitSelected = selectedTraitCount > 0
+ const selectedCount = filteredGenes.filter(g => localFilters.selectedGenes.includes(g.markerNm)).length
+ const isAllSelected = selectedCount === filteredGenes.length && filteredGenes.length > 0
+ const hasSelected = selectedCount > 0
+ const isExpanded = geneSearch ? true : (openCategories['quantity'] ?? false)
return (
0)}
- onOpenChange={() => toggleCategory(key as any)}
+ open={isExpanded}
+ onOpenChange={(open) => setOpenCategories(prev => ({ ...prev, quantity: open }))}
>
-
-
- {/* 왼쪽: 체크박스 + 라벨 */}
-
+
+
+
0}
- onCheckedChange={(checked) => {
- // 체크: 모든 형질 가중치를 5로 설정, 언체크: 0으로 설정
- const newWeights = { ...localFilters.traitWeights }
- filteredCategoryTraits.forEach((trait: string) => {
- newWeights[trait as TraitName] = checked ? 5 : 0
- })
-
- // selectedTraits 업데이트
- const currentSelected = localFilters.selectedTraits || []
- let newSelectedTraits: string[]
-
- if (checked) {
- // 체크: 카테고리의 형질들을 추가 (중복 제거)
- const traitsToAdd = filteredCategoryTraits.filter(t => !currentSelected.includes(t))
- newSelectedTraits = [...currentSelected, ...traitsToAdd]
- } else {
- // 언체크: 카테고리의 형질들을 제거
- newSelectedTraits = currentSelected.filter(t => !filteredCategoryTraits.includes(t))
- }
-
- setLocalFilters(prev => ({
- ...prev,
- traitWeights: newWeights,
- selectedTraits: newSelectedTraits
- }))
- }}
- className="h-4 w-4 sm:h-5 sm:w-5 cursor-pointer"
+ checked={isAllSelected}
+ onCheckedChange={(checked) => toggleCategoryGenes(filteredGenes, !!checked)}
+ className="h-4 w-4"
/>
- {category.name}
-
- {selectedTraitCount}/{filteredCategoryTraits.length}개
+
+ 육량형
+
+
+ {selectedCount}/{filteredGenes.length}
-
- {/* 오른쪽: 아코디언 토글 버튼 */}
-
- {categoryOpen[key as keyof typeof categoryOpen] ? : }
+
+ {isExpanded ? : }
-
- {filteredCategoryTraits.length === 0 ? (
-
- 검색 결과가 없습니다
-
- ) : (
-
- {filteredCategoryTraits.map((trait: string) => {
- const labels = traitLabels[trait] || { left: '낮게', right: '높게' }
- const description = traitDescriptions[trait] || ''
- const isTraitSelected = (localFilters.traitWeights[trait as TraitName] ?? 0) > 0
- return (
-
- {/* 모바일: 체크박스 + 라벨 + 중요도 컨트롤을 한 줄에 */}
-
-
-
{
- if (checked) {
- updateTraitWeight(trait as TraitName, 5)
- } else {
- updateTraitWeight(trait as TraitName, 0)
- }
- }}
- className="h-5 w-5 sm:h-6 sm:w-6 cursor-pointer"
- />
-
- {description && (
-
-
-
-
-
-
- {description}
-
-
-
- )}
-
- {/* 모바일: 숫자만 표시 */}
-
-
- {localFilters.traitWeights[trait as TraitName] ?? 0}
-
- /10
-
-
- {/* 모바일: 슬라이더 스타일 버튼 */}
-
-
{
- const current = localFilters.traitWeights[trait as TraitName] ?? 0
- if (current > 0) {
- updateTraitWeight(trait as TraitName, current - 1)
- }
- }}
- disabled={(localFilters.traitWeights[trait as TraitName] ?? 0) === 0}
- className="h-9 w-12 p-0 text-lg font-bold rounded-lg disabled:opacity-30"
- >
- −
-
-
-
{
- const current = localFilters.traitWeights[trait as TraitName] ?? 0
- if (current < 10) {
- updateTraitWeight(trait as TraitName, current + 1)
- }
- }}
- disabled={(localFilters.traitWeights[trait as TraitName] ?? 0) === 10}
- className="h-9 w-12 p-0 text-lg font-bold rounded-lg disabled:opacity-30"
- >
- +
-
-
- {/* 데스크톱: 기존 레이아웃 */}
-
- 중요도
- {
- const current = localFilters.traitWeights[trait as TraitName] ?? 0
- if (current > 0) {
- updateTraitWeight(trait as TraitName, current - 1)
- }
- }}
- disabled={(localFilters.traitWeights[trait as TraitName] ?? 0) === 0}
- className="h-8 w-8 p-0 text-base font-semibold rounded-lg hover:bg-slate-100 disabled:opacity-30 transition-colors"
- >
- −
-
- {
- const value = parseInt(e.target.value)
- if (isNaN(value)) {
- updateTraitWeight(trait as TraitName, 0)
- } else {
- const clamped = Math.min(10, Math.max(0, value))
- updateTraitWeight(trait as TraitName, clamped)
- }
- }}
- className={`text-sm font-bold w-12 h-8 text-center rounded-lg px-1 transition-colors ${(localFilters.traitWeights[trait as TraitName] ?? 0) > 0
- ? 'bg-primary text-primary-foreground'
- : 'bg-slate-100 text-slate-600'
- }`}
- />
- / 10
- {
- const current = localFilters.traitWeights[trait as TraitName] ?? 0
- if (current < 10) {
- updateTraitWeight(trait as TraitName, current + 1)
- }
- }}
- disabled={(localFilters.traitWeights[trait as TraitName] ?? 0) === 10}
- className="h-8 w-8 p-0 text-base font-semibold rounded-lg hover:bg-slate-100 disabled:opacity-30 transition-colors"
- >
- +
-
- updateTraitWeight(trait as TraitName, 0)}
- className="h-8 px-3 text-xs rounded-lg hover:bg-slate-100 transition-colors text-slate-600"
- >
- 초기화
-
-
+
+ {filteredGenes.map(gene => {
+ const isSelected = localFilters.selectedGenes.includes(gene.markerNm)
+ return (
+
toggleGene(gene.markerNm)}
+ >
+
+
+
+ {gene.markerNm}
+ {gene.relatedTrait && (
+ {gene.relatedTrait}
+ )}
- )
- })}
-
- )}
-
+
+
+ )
+ })}
+
)
- })}
+ })()}
+ {/* 육질형 */}
+ {(() => {
+ const genes = qualityGenes
+ const isCategoryMatch = geneSearch && '육질형'.includes(geneSearch.toLowerCase())
+ const filteredGenes = geneSearch
+ ? isCategoryMatch
+ ? genes
+ : genes.filter(g =>
+ g.markerNm.toLowerCase().includes(geneSearch.toLowerCase()) ||
+ (g.relatedTrait && g.relatedTrait.toLowerCase().includes(geneSearch.toLowerCase()))
+ )
+ : genes
+
+ if (geneSearch && filteredGenes.length === 0) return null
+
+ const selectedCount = filteredGenes.filter(g => localFilters.selectedGenes.includes(g.markerNm)).length
+ const isAllSelected = selectedCount === filteredGenes.length && filteredGenes.length > 0
+ const hasSelected = selectedCount > 0
+ const isExpanded = geneSearch ? true : (openCategories['quality'] ?? false)
+
+ return (
+ setOpenCategories(prev => ({ ...prev, quality: open }))}
+ >
+
+
+
+ toggleCategoryGenes(filteredGenes, !!checked)}
+ className="h-4 w-4"
+ />
+
+ 육질형
+
+
+ {selectedCount}/{filteredGenes.length}
+
+
+
+
+ {isExpanded ? : }
+
+
+
+
+
+ {filteredGenes.map(gene => {
+ const isSelected = localFilters.selectedGenes.includes(gene.markerNm)
+ return (
+
toggleGene(gene.markerNm)}
+ >
+
+
+
+ {gene.markerNm}
+ {gene.relatedTrait && (
+ {gene.relatedTrait}
+ )}
+
+
+
+ )
+ })}
+
+
+
+
+ )
+ })()}
-
- )}
-
-
-
+ )}
+
- {/* 변경사항 안내 배너 */}
- {hasChanges && (
-
-
-
-
- 변경사항이 저장되지 않았습니다. 적용 버튼을 눌러 저장하세요.
-
+
+ setShowGeneModal(false)}>
+ 취소
+
+ setShowGeneModal(false)}>
+ 완료 ({localFilters.selectedGenes.length}개 선택)
+
+
)}
- {/* 버튼 영역 */}
-
-
- 초기화
-
-
-
setOpen(false)} size="sm" className="h-10 md:h-11 text-sm md:text-base px-4 md:px-5">
- 취소
-
-
- 적용
-
+ {/* 형질 선택 모달 */}
+ {showTraitModal && (
+
+
+
+
유전체 형질 선택
+ setShowTraitModal(false)}
+ className="p-2 hover:bg-slate-100 rounded-lg"
+ >
+
+
+
+
+
+ {/* 검색창 */}
+
+
+ setTraitSearch(e.target.value)}
+ />
+
+
+ {/* 형질 목록 - 카테고리별 아코디언 */}
+
+ {TRAIT_CATEGORIES.map(cat => {
+ const isCategoryMatch = traitSearch && cat.name.toLowerCase().includes(traitSearch.toLowerCase())
+ const filteredTraits = traitSearch
+ ? isCategoryMatch
+ ? cat.traits
+ : cat.traits.filter(t =>
+ t.toLowerCase().includes(traitSearch.toLowerCase()) ||
+ (TRAIT_DESCRIPTIONS[t] && TRAIT_DESCRIPTIONS[t].toLowerCase().includes(traitSearch.toLowerCase()))
+ )
+ : cat.traits
+
+ if (traitSearch && filteredTraits.length === 0) return null
+
+ const selectedCount = filteredTraits.filter(t => localFilters.selectedTraits?.includes(t)).length
+ const isAllSelected = selectedCount === filteredTraits.length && filteredTraits.length > 0
+ const hasSelected = selectedCount > 0
+ const isExpanded = traitSearch ? true : (openCategories[cat.id] ?? false)
+
+ return (
+
setOpenCategories(prev => ({ ...prev, [cat.id]: open }))}
+ >
+
+
+
+ toggleCategoryTraits(filteredTraits, !!checked)}
+ className="h-4 w-4"
+ />
+
+ {cat.name}
+
+
+ {selectedCount}/{filteredTraits.length}
+
+
+
+
+ {isExpanded ? : }
+
+
+
+
+
+ {filteredTraits.map(trait => {
+ const isSelected = localFilters.selectedTraits?.includes(trait)
+ return (
+
+
toggleTrait(trait)}
+ >
+
+
+ {trait}
+ {TRAIT_DESCRIPTIONS[trait] && (
+ {TRAIT_DESCRIPTIONS[trait]}
+ )}
+
+
+ {isSelected && (
+
+
가중치
+
+ {
+ e.stopPropagation()
+ updateTraitWeight(trait, -1)
+ }}
+ disabled={(localFilters.traitWeights[trait as TraitName] || 0) <= 0}
+ >
+ -
+
+
+ {localFilters.traitWeights[trait as TraitName] || 0}점
+
+ {
+ e.stopPropagation()
+ updateTraitWeight(trait, 1)
+ }}
+ disabled={(localFilters.traitWeights[trait as TraitName] || 0) >= 10}
+ >
+ +
+
+
+
+ )}
+
+ )
+ })}
+
+
+
+
+ )
+ })}
+
+
+
+
+ setShowTraitModal(false)}>
+ 취소
+
+ setShowTraitModal(false)}>
+ 완료 ({localFilters.selectedTraits?.length || 0}개 선택)
+
+
+
-
+ )}
-
- {/* 유전자 검색 모달 */}
-
setLocalFilters(prev => ({ ...prev, selectedGenes: genes }))}
- />
-
- {/* 고정 항목 추가 모달 */}
-
)
}
-
-// Draggable Gene Item (카테고리용)
-function DraggableGeneItem({ gene, isSelected, onToggle }: {
- gene: MarkerModel
- isSelected: boolean
- onToggle: () => void
-}) {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: gene.markerNm })
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.3 : 1,
- }
-
- return (
-
-
-
-
-
-
-
-
{gene.markerNm}
- {gene.relatedTrait && (
-
{gene.relatedTrait}
- )}
-
-
-
- )
-}
-
-// Sortable Badge 컴포넌트
-function SortableGeneBadge({ gene, onRemove }: {
- gene: string
- onRemove: () => void
-}) {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: gene })
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.5 : 1,
- }
-
- return (
-
-
-
-
-
- {gene}
- {
- e.stopPropagation()
- onRemove()
- }}
- className="hover:bg-slate-200 rounded-full p-1 transition-colors"
- >
-
-
-
-
- )
-}
-
-// Sortable 형질 Badge 컴포넌트 (초록색 테마)
-function SortableTraitBadge({ trait, weight, onRemove }: {
- trait: string
- weight: number
- onRemove: () => void
-}) {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: trait })
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.5 : 1,
- }
-
- return (
-
-
-
-
-
- {trait}
- {
- e.stopPropagation()
- onRemove()
- }}
- className="hover:bg-slate-200 rounded-full p-1 transition-colors"
- >
-
-
-
-
- )
-}
-
-// Sortable 고정 유전자 아이템
-function SortablePinnedGeneItem({ gene, index, onRemove }: {
- gene: string
- index: number
- onRemove: () => void
-}) {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: gene })
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.5 : 1,
- }
-
- return (
-
-
-
- {index + 1}.
- {gene}
-
-
{
- e.stopPropagation()
- onRemove()
- }}
- className="hover:bg-slate-200 rounded-full p-1 transition-colors"
- >
-
-
-
- )
-}
-
-// Sortable 고정 형질 아이템
-function SortablePinnedTraitItem({ trait, index, weight, onRemove }: {
- trait: string
- index: number
- weight: number
- onRemove: () => void
-}) {
- const {
- attributes,
- listeners,
- setNodeRef,
- transform,
- transition,
- isDragging,
- } = useSortable({ id: trait })
-
- const style = {
- transform: CSS.Transform.toString(transform),
- transition,
- opacity: isDragging ? 0.5 : 1,
- }
-
- return (
-
-
-
- {index + 1}.
- {trait}
-
-
{
- e.stopPropagation()
- onRemove()
- }}
- className="hover:bg-slate-200 rounded-full p-1 transition-colors"
- >
-
-
-
- )
-}
-
-// 드롭 가능 영역 컴포넌트
-function DroppableArea({ id, children, className }: {
- id: string
- children: React.ReactNode
- className?: string
-}) {
- const { setNodeRef, isOver } = useDroppable({
- id: id,
- })
-
- return (
-
- {children}
-
- )
-}