feat: replace tree with faceted search + delete pattern button
- PatternExplorer: replace rigid taxonomy tree with FacetedSearch — combinable chip filters on Direction, Asset class, Category, Horizon, Source + full-text search (name/description/regime) + sort. No fixed hierarchy, scales with library size. - PatternCard: delete button (trash icon) for non-builtin patterns, two-step confirm/cancel to prevent accidental deletion. Shows #regime_tag chip inline. - RegimeCard and RegimeView unchanged. - Remove dead code: TaxonomyNode, TreeNodeRow, enrichTree, pathStartsWith, TreeView, usePatternTaxonomy import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,29 +4,19 @@ import clsx from 'clsx'
|
||||
import {
|
||||
TreePine, Search, ChevronRight, ChevronDown,
|
||||
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
|
||||
Layers, GitMerge, Tag,
|
||||
Layers, GitMerge, Tag, Trash2,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useAllPatterns,
|
||||
usePatternTaxonomy,
|
||||
usePatternsByInstrument,
|
||||
useLastScores,
|
||||
useDeletePattern,
|
||||
validateTicker,
|
||||
} from '../hooks/useApi'
|
||||
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
||||
|
||||
// ── Local types ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface TaxonomyNode {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
description?: string
|
||||
children?: TaxonomyNode[]
|
||||
count?: number
|
||||
path: string[]
|
||||
}
|
||||
|
||||
interface SuggestedTrade {
|
||||
strategy: string
|
||||
underlying: string
|
||||
@@ -90,12 +80,6 @@ function probStars(prob: number): string {
|
||||
return '★'.repeat(n) + '☆'.repeat(3 - n)
|
||||
}
|
||||
|
||||
function pathStartsWith(full: string[] | undefined, prefix: string[]): boolean {
|
||||
if (!full || prefix.length === 0) return true
|
||||
if (full.length < prefix.length) return false
|
||||
return prefix.every((seg, i) => seg === full[i])
|
||||
}
|
||||
|
||||
function formatTaxPath(path: string[] | undefined): string {
|
||||
if (!path || path.length === 0) return ''
|
||||
return path.join(' › ')
|
||||
@@ -156,11 +140,14 @@ function PatternCard({
|
||||
pattern: Pattern
|
||||
highlightTrades?: string[]
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [confirmDel, setConfirmDel] = useState(false)
|
||||
const { mutate: deletePattern, isPending: deleting } = useDeletePattern()
|
||||
|
||||
const trades = pattern.suggested_trades ?? []
|
||||
const instances = pattern.historical_instances ?? []
|
||||
const assetLabel = ASSET_CLASS_LABELS[pattern.asset_class] ?? pattern.asset_class
|
||||
const isDeletable = pattern.source !== 'builtin'
|
||||
|
||||
const moveSign = pattern.expected_move_pct >= 0 ? '+' : ''
|
||||
|
||||
@@ -173,12 +160,44 @@ function PatternCard({
|
||||
<span className="text-[10px] font-mono text-slate-500">{pattern.id}</span>
|
||||
<span className="text-[10px] text-amber-400 tracking-wider">{probStars(pattern.probability)}</span>
|
||||
{pattern.category && <CategoryBadge category={pattern.category} />}
|
||||
{pattern.regime_tag && (
|
||||
<span className="text-[10px] text-violet-400 font-mono">#{pattern.regime_tag}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{pattern.ai_quality_score != null && (
|
||||
<span className="text-[10px] font-mono text-slate-500">
|
||||
AI <span className="text-slate-300 font-semibold">{pattern.ai_quality_score}</span>/100
|
||||
</span>
|
||||
)}
|
||||
{isDeletable && (
|
||||
confirmDel ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deletePattern(pattern.id)}
|
||||
disabled={deleting}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-red-900/50 border border-red-700/60 text-red-400 hover:bg-red-900/70 transition-colors"
|
||||
>
|
||||
{deleting ? '…' : 'Confirm'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDel(false)}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded border border-slate-700 text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmDel(true)}
|
||||
className="text-slate-700 hover:text-red-400 transition-colors p-0.5 rounded"
|
||||
title="Delete pattern"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{pattern.ai_quality_score != null && (
|
||||
<span className="text-[10px] font-mono shrink-0 text-slate-500">
|
||||
AI <span className="text-slate-300 font-semibold">{pattern.ai_quality_score}</span>/100
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name + description */}
|
||||
@@ -254,277 +273,192 @@ function PatternCard({
|
||||
)
|
||||
}
|
||||
|
||||
// ── Taxonomy Tree Node ────────────────────────────────────────────────────────
|
||||
|
||||
function TreeNodeRow({
|
||||
node,
|
||||
level,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
defaultExpandedDepth,
|
||||
}: {
|
||||
node: TaxonomyNode
|
||||
level: number
|
||||
selectedPath: string[]
|
||||
onSelect: (path: string[]) => void
|
||||
defaultExpandedDepth: number
|
||||
}) {
|
||||
const hasChildren = (node.children ?? []).length > 0
|
||||
const [expanded, setExpanded] = useState(level < defaultExpandedDepth)
|
||||
|
||||
const isSelected =
|
||||
selectedPath.length === node.path.length &&
|
||||
node.path.every((seg, i) => seg === selectedPath[i])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(node.path)
|
||||
if (hasChildren) setExpanded(v => !v)
|
||||
}, [node.path, onSelect, hasChildren])
|
||||
|
||||
const indent = level * 16
|
||||
// ── Faceted Search ────────────────────────────────────────────────────────────
|
||||
|
||||
function FilterChip({
|
||||
active, label, onClick, color,
|
||||
}: { active: boolean; label: string; onClick: () => void; color?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
|
||||
)}
|
||||
style={{ paddingLeft: `${8 + indent}px` }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{hasChildren ? (
|
||||
expanded
|
||||
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
) : (
|
||||
<span className="w-3.5 shrink-0" />
|
||||
)}
|
||||
{node.icon && <span className="text-sm">{node.icon}</span>}
|
||||
<span className="flex-1 truncate">{node.label}</span>
|
||||
{node.count != null && node.count > 0 && (
|
||||
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full ml-1 shrink-0">
|
||||
{node.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{hasChildren && expanded && (
|
||||
<div>
|
||||
{node.children!.map(child => (
|
||||
<TreeNodeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
defaultExpandedDepth={defaultExpandedDepth}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'text-xs px-2.5 py-1 rounded-full border transition-colors whitespace-nowrap',
|
||||
active
|
||||
? color ?? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
|
||||
: 'border-slate-700/40 text-slate-500 hover:text-slate-300 hover:border-slate-600',
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helper: add computed path arrays to each node ────────────────────────────
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function enrichTree(node: any, parentPath: string[] = []): TaxonomyNode {
|
||||
const path = [...parentPath, node.id]
|
||||
return {
|
||||
...node,
|
||||
path,
|
||||
children: (node.children ?? []).map((c: any) => enrichTree(c, path)),
|
||||
}
|
||||
const DIR_COLORS: Record<string, string> = {
|
||||
bullish: 'bg-emerald-900/40 border-emerald-600/50 text-emerald-300',
|
||||
up: 'bg-emerald-900/40 border-emerald-600/50 text-emerald-300',
|
||||
bearish: 'bg-red-900/40 border-red-600/50 text-red-300',
|
||||
down: 'bg-red-900/40 border-red-600/50 text-red-300',
|
||||
neutral: 'bg-amber-900/40 border-amber-600/50 text-amber-300',
|
||||
}
|
||||
|
||||
// ── Tree View ─────────────────────────────────────────────────────────────────
|
||||
function FacetedSearch({ patterns }: { patterns: Pattern[] }) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [direction, setDirection] = useState('')
|
||||
const [assetClass, setAssetClass] = useState('')
|
||||
const [category, setCategory] = useState('')
|
||||
const [horizon, setHorizon] = useState('')
|
||||
const [source, setSource] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'ai_score' | 'probability' | 'expected_move' | 'date'>('ai_score')
|
||||
|
||||
function TreeView({ patterns }: { patterns: Pattern[] }) {
|
||||
const { data: taxonomyData } = usePatternTaxonomy()
|
||||
// API returns { tree: {...}, patterns: [...] } — extract the tree and enrich nodes with path arrays.
|
||||
// Start enriching from root's children with [] so paths match pattern taxonomy_path (no "root" prefix).
|
||||
const taxonomy: TaxonomyNode | undefined = useMemo(() => {
|
||||
const raw = (taxonomyData as any)?.tree
|
||||
if (!raw) return undefined
|
||||
return { ...raw, path: [], children: (raw.children ?? []).map((c: any) => enrichTree(c, [])) }
|
||||
}, [taxonomyData])
|
||||
const allDirections = useMemo(() => {
|
||||
const s = new Set(patterns.map(p => p.signal_direction).filter(Boolean) as string[])
|
||||
return Array.from(s).sort()
|
||||
}, [patterns])
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
||||
const [nameFilter, setNameFilter] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'ai_score' | 'probability' | 'expected_move' | 'date'>('ai_score')
|
||||
const allAssets = useMemo(() => {
|
||||
const s = new Set(patterns.map(p => p.asset_class).filter(Boolean))
|
||||
return Array.from(s).sort()
|
||||
}, [patterns])
|
||||
|
||||
// Patterns matching the selected taxonomy node + name filter
|
||||
const nodePatterns = useMemo(() => {
|
||||
const allCategories = useMemo(() => {
|
||||
const s = new Set(patterns.map(p => p.category).filter(Boolean) as string[])
|
||||
return Array.from(s).sort()
|
||||
}, [patterns])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = patterns
|
||||
if (selectedPath.length > 0)
|
||||
list = list.filter(p => pathStartsWith(p.taxonomy_path, selectedPath))
|
||||
if (nameFilter.trim()) {
|
||||
const q = nameFilter.trim().toLowerCase()
|
||||
list = list.filter(p => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q))
|
||||
if (query.trim()) {
|
||||
const q = query.trim().toLowerCase()
|
||||
list = list.filter(p =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.description.toLowerCase().includes(q) ||
|
||||
(p.regime_tag ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [patterns, selectedPath, nameFilter])
|
||||
|
||||
// Available categories within current node (for filter pills)
|
||||
const availableCategories = useMemo(() => {
|
||||
const cats = new Set(nodePatterns.map(p => p.category).filter(Boolean) as string[])
|
||||
return Array.from(cats).sort()
|
||||
}, [nodePatterns])
|
||||
|
||||
// Apply category filter + sort
|
||||
const filteredPatterns = useMemo(() => {
|
||||
let list = nodePatterns
|
||||
if (categoryFilter) list = list.filter(p => p.category === categoryFilter)
|
||||
if (direction) list = list.filter(p => p.signal_direction === direction)
|
||||
if (assetClass) list = list.filter(p => p.asset_class === assetClass)
|
||||
if (category) list = list.filter(p => p.category === category)
|
||||
if (horizon === 'short') list = list.filter(p => p.horizon_days < 30)
|
||||
if (horizon === 'medium') list = list.filter(p => p.horizon_days >= 30 && p.horizon_days <= 90)
|
||||
if (horizon === 'long') list = list.filter(p => p.horizon_days > 90)
|
||||
if (source) list = list.filter(p => p.source === source)
|
||||
return [...list].sort((a, b) => {
|
||||
if (sortBy === 'ai_score') {
|
||||
const sa = a.ai_quality_score ?? -1
|
||||
const sb = b.ai_quality_score ?? -1
|
||||
return sb - sa
|
||||
}
|
||||
if (sortBy === 'probability') return b.probability - a.probability
|
||||
if (sortBy === 'ai_score') return (b.ai_quality_score ?? -1) - (a.ai_quality_score ?? -1)
|
||||
if (sortBy === 'probability') return b.probability - a.probability
|
||||
if (sortBy === 'expected_move') return Math.abs(b.expected_move_pct) - Math.abs(a.expected_move_pct)
|
||||
// date
|
||||
const da = (a as any).created_at ?? ''
|
||||
const db = (b as any).created_at ?? ''
|
||||
return db.localeCompare(da)
|
||||
return ((b as any).created_at ?? '').localeCompare((a as any).created_at ?? '')
|
||||
})
|
||||
}, [nodePatterns, categoryFilter, sortBy])
|
||||
}, [patterns, query, direction, assetClass, category, horizon, source, sortBy])
|
||||
|
||||
const breadcrumb = selectedPath.length > 0 ? selectedPath.join(' › ') : 'All Patterns'
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
geopolitical: 'bg-red-900/40 border-red-700/40 text-red-300',
|
||||
macro: 'bg-blue-900/40 border-blue-700/40 text-blue-300',
|
||||
volatility: 'bg-amber-900/40 border-amber-700/40 text-amber-300',
|
||||
'risk-off': 'bg-orange-900/40 border-orange-700/40 text-orange-300',
|
||||
supply: 'bg-teal-900/40 border-teal-700/40 text-teal-300',
|
||||
credit: 'bg-purple-900/40 border-purple-700/40 text-purple-300',
|
||||
'geo-eco': 'bg-emerald-900/40 border-emerald-700/40 text-emerald-300',
|
||||
sentiment: 'bg-yellow-900/40 border-yellow-700/40 text-yellow-300',
|
||||
}
|
||||
const catColor = (cat: string) =>
|
||||
CATEGORY_COLORS[cat] ?? 'bg-slate-700/40 border-slate-600/40 text-slate-300'
|
||||
const hasFilters = !!(query || direction || assetClass || category || horizon || source)
|
||||
const toggle = (cur: string, val: string, set: (v: string) => void) => set(cur === val ? '' : val)
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 min-h-0 flex-1">
|
||||
{/* Left sidebar */}
|
||||
<div className="w-72 shrink-0 card flex flex-col gap-1 overflow-y-auto max-h-[calc(100vh-200px)]">
|
||||
<div className="text-[10px] uppercase tracking-widest text-slate-500 font-semibold px-2 pt-1 pb-2">
|
||||
Taxonomy
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Search + sort */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-lg">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name, description, or #regime…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="input text-sm pl-9 py-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Root "All" entry */}
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
|
||||
selectedPath.length === 0
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => { setSelectedPath([]); setCategoryFilter('') }}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="input text-xs py-2 pr-6 pl-2 shrink-0"
|
||||
>
|
||||
<BookOpen className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
||||
<span className="flex-1">All Patterns</span>
|
||||
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full">
|
||||
{patterns.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Taxonomy tree */}
|
||||
{taxonomy ? (
|
||||
(taxonomy.children ?? []).map(node => (
|
||||
<TreeNodeRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={path => { setSelectedPath(path); setCategoryFilter('') }}
|
||||
defaultExpandedDepth={1}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-slate-600 px-2 py-4">Loading taxonomy…</div>
|
||||
)}
|
||||
<option value="ai_score">AI Score ↓</option>
|
||||
<option value="probability">Probability ↓</option>
|
||||
<option value="expected_move">Expected Move ↓</option>
|
||||
<option value="date">Date Added ↓</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
||||
{/* Row 1: breadcrumb + search + count */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="text-sm text-slate-400 font-mono">
|
||||
<span className="text-slate-600">Path: </span>
|
||||
<span className="text-slate-300">{breadcrumb}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by name…"
|
||||
value={nameFilter}
|
||||
onChange={e => setNameFilter(e.target.value)}
|
||||
className="input text-sm pl-8 py-1.5 w-52"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 shrink-0">{filteredPatterns.length} patterns</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: category pills + sort */}
|
||||
{/* Filter panel */}
|
||||
<div className="flex flex-col gap-2.5 bg-dark-700/30 border border-slate-700/30 rounded-lg px-4 py-3">
|
||||
{/* Direction */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{availableCategories.length > 0 && (
|
||||
<>
|
||||
<span className="text-[10px] text-slate-600 uppercase tracking-wider shrink-0">Category:</span>
|
||||
<button
|
||||
onClick={() => setCategoryFilter('')}
|
||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors',
|
||||
categoryFilter === ''
|
||||
? 'bg-slate-600/50 border-slate-500/50 text-slate-200'
|
||||
: 'border-slate-700/30 text-slate-500 hover:text-slate-300')}
|
||||
>All</button>
|
||||
{availableCategories.map(cat => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setCategoryFilter(c => c === cat ? '' : cat)}
|
||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors capitalize',
|
||||
categoryFilter === cat
|
||||
? catColor(cat)
|
||||
: 'border-slate-700/30 text-slate-500 hover:text-slate-300')}
|
||||
>{cat}</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-slate-600 uppercase tracking-wider">Sort:</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="input text-xs py-1 pr-6 pl-2"
|
||||
>
|
||||
<option value="ai_score">AI Score ↓</option>
|
||||
<option value="probability">Probability ↓</option>
|
||||
<option value="expected_move">Expected Move ↓</option>
|
||||
<option value="date">Date Added ↓</option>
|
||||
</select>
|
||||
</div>
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Direction</span>
|
||||
<FilterChip active={!direction} label="All" onClick={() => setDirection('')} />
|
||||
{allDirections.map(d => (
|
||||
<FilterChip key={d} active={direction === d}
|
||||
label={d === 'bullish' || d === 'up' ? `↑ ${d}` : d === 'bearish' || d === 'down' ? `↓ ${d}` : `⚡ ${d}`}
|
||||
onClick={() => toggle(direction, d, setDirection)}
|
||||
color={DIR_COLORS[d]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pattern grid */}
|
||||
{filteredPatterns.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1 text-slate-600 text-sm py-16">
|
||||
No patterns match the selected filter
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 overflow-y-auto max-h-[calc(100vh-260px)] pr-1">
|
||||
{filteredPatterns.map(p => (
|
||||
<PatternCard key={p.id} pattern={p} />
|
||||
{/* Asset class */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Asset</span>
|
||||
<FilterChip active={!assetClass} label="All" onClick={() => setAssetClass('')} />
|
||||
{allAssets.map(a => (
|
||||
<FilterChip key={a} active={assetClass === a}
|
||||
label={ASSET_CLASS_LABELS[a] ?? a}
|
||||
onClick={() => toggle(assetClass, a, setAssetClass)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
{allCategories.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Category</span>
|
||||
<FilterChip active={!category} label="All" onClick={() => setCategory('')} />
|
||||
{allCategories.map(c => (
|
||||
<FilterChip key={c} active={category === c} label={c}
|
||||
onClick={() => toggle(category, c, setCategory)}
|
||||
color={CATEGORY_COLORS[c] ?? 'bg-violet-900/40 border-violet-600/50 text-violet-300'} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Horizon */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Horizon</span>
|
||||
<FilterChip active={!horizon} label="All" onClick={() => setHorizon('')} />
|
||||
<FilterChip active={horizon === 'short'} label="< 30d" onClick={() => toggle(horizon, 'short', setHorizon)} />
|
||||
<FilterChip active={horizon === 'medium'} label="30–90d" onClick={() => toggle(horizon, 'medium', setHorizon)} />
|
||||
<FilterChip active={horizon === 'long'} label="> 90d" onClick={() => toggle(horizon, 'long', setHorizon)} />
|
||||
</div>
|
||||
|
||||
{/* Source */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Source</span>
|
||||
<FilterChip active={!source} label="All" onClick={() => setSource('')} />
|
||||
<FilterChip active={source === 'builtin'} label="Built-in" onClick={() => toggle(source, 'builtin', setSource)} />
|
||||
<FilterChip active={source === 'backtested'} label="Backtested" onClick={() => toggle(source, 'backtested', setSource)}
|
||||
color="bg-violet-900/40 border-violet-600/50 text-violet-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Count + clear */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-500">{filtered.length} pattern{filtered.length !== 1 ? 's' : ''}</span>
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); setDirection(''); setAssetClass(''); setCategory(''); setHorizon(''); setSource('') }}
|
||||
className="text-xs text-slate-600 hover:text-slate-400 underline transition-colors"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pattern grid */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-24 text-slate-600 text-sm">
|
||||
No patterns match these filters
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
{filtered.map(p => <PatternCard key={p.id} pattern={p} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -883,10 +817,10 @@ function RegimeView({ patterns }: { patterns: Pattern[] }) {
|
||||
|
||||
// ── Page root ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type ViewMode = 'tree' | 'instrument' | 'regime'
|
||||
type ViewMode = 'search' | 'instrument' | 'regime'
|
||||
|
||||
export default function PatternExplorer() {
|
||||
const [view, setView] = useState<ViewMode>('tree')
|
||||
const [view, setView] = useState<ViewMode>('search')
|
||||
const { data: patternsData, isLoading } = useAllPatterns()
|
||||
const { data: _scores } = useLastScores()
|
||||
|
||||
@@ -921,14 +855,14 @@ export default function PatternExplorer() {
|
||||
<button
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
|
||||
view === 'tree'
|
||||
view === 'search'
|
||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
)}
|
||||
onClick={() => setView('tree')}
|
||||
onClick={() => setView('search')}
|
||||
>
|
||||
<TreePine className="w-3.5 h-3.5" />
|
||||
Tree
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -965,7 +899,7 @@ export default function PatternExplorer() {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{view === 'tree' && <TreeView patterns={patterns} />}
|
||||
{view === 'search' && <FacetedSearch patterns={patterns} />}
|
||||
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
||||
{view === 'regime' && <RegimeView patterns={patterns} />}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user