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 {
|
import {
|
||||||
TreePine, Search, ChevronRight, ChevronDown,
|
TreePine, Search, ChevronRight, ChevronDown,
|
||||||
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
|
TrendingUp, TrendingDown, Zap, BookOpen, ExternalLink, RefreshCw,
|
||||||
Layers, GitMerge, Tag,
|
Layers, GitMerge, Tag, Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
useAllPatterns,
|
useAllPatterns,
|
||||||
usePatternTaxonomy,
|
|
||||||
usePatternsByInstrument,
|
usePatternsByInstrument,
|
||||||
useLastScores,
|
useLastScores,
|
||||||
|
useDeletePattern,
|
||||||
validateTicker,
|
validateTicker,
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
import { INSTRUMENTS, INSTRUMENT_CATEGORIES } from '../constants/instruments'
|
||||||
|
|
||||||
// ── Local types ───────────────────────────────────────────────────────────────
|
// ── Local types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface TaxonomyNode {
|
|
||||||
id: string
|
|
||||||
label: string
|
|
||||||
icon?: string
|
|
||||||
description?: string
|
|
||||||
children?: TaxonomyNode[]
|
|
||||||
count?: number
|
|
||||||
path: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SuggestedTrade {
|
interface SuggestedTrade {
|
||||||
strategy: string
|
strategy: string
|
||||||
underlying: string
|
underlying: string
|
||||||
@@ -90,12 +80,6 @@ function probStars(prob: number): string {
|
|||||||
return '★'.repeat(n) + '☆'.repeat(3 - n)
|
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 {
|
function formatTaxPath(path: string[] | undefined): string {
|
||||||
if (!path || path.length === 0) return ''
|
if (!path || path.length === 0) return ''
|
||||||
return path.join(' › ')
|
return path.join(' › ')
|
||||||
@@ -156,11 +140,14 @@ function PatternCard({
|
|||||||
pattern: Pattern
|
pattern: Pattern
|
||||||
highlightTrades?: string[]
|
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 trades = pattern.suggested_trades ?? []
|
||||||
const instances = pattern.historical_instances ?? []
|
const instances = pattern.historical_instances ?? []
|
||||||
const assetLabel = ASSET_CLASS_LABELS[pattern.asset_class] ?? pattern.asset_class
|
const assetLabel = ASSET_CLASS_LABELS[pattern.asset_class] ?? pattern.asset_class
|
||||||
|
const isDeletable = pattern.source !== 'builtin'
|
||||||
|
|
||||||
const moveSign = pattern.expected_move_pct >= 0 ? '+' : ''
|
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] font-mono text-slate-500">{pattern.id}</span>
|
||||||
<span className="text-[10px] text-amber-400 tracking-wider">{probStars(pattern.probability)}</span>
|
<span className="text-[10px] text-amber-400 tracking-wider">{probStars(pattern.probability)}</span>
|
||||||
{pattern.category && <CategoryBadge category={pattern.category} />}
|
{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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Name + description */}
|
{/* Name + description */}
|
||||||
@@ -254,277 +273,192 @@ function PatternCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Taxonomy Tree Node ────────────────────────────────────────────────────────
|
// ── Faceted Search ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
function FilterChip({
|
||||||
|
active, label, onClick, color,
|
||||||
|
}: { active: boolean; label: string; onClick: () => void; color?: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<button
|
||||||
<button
|
onClick={onClick}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1.5 w-full text-left px-2 py-1 rounded text-sm transition-colors',
|
'text-xs px-2.5 py-1 rounded-full border transition-colors whitespace-nowrap',
|
||||||
isSelected
|
active
|
||||||
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
? color ?? 'bg-blue-600/30 border-blue-500/50 text-blue-300'
|
||||||
: 'text-slate-400 hover:bg-slate-700/30 hover:text-slate-200'
|
: 'border-slate-700/40 text-slate-500 hover:text-slate-300 hover:border-slate-600',
|
||||||
)}
|
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helper: add computed path arrays to each node ────────────────────────────
|
const DIR_COLORS: Record<string, string> = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
bullish: 'bg-emerald-900/40 border-emerald-600/50 text-emerald-300',
|
||||||
function enrichTree(node: any, parentPath: string[] = []): TaxonomyNode {
|
up: 'bg-emerald-900/40 border-emerald-600/50 text-emerald-300',
|
||||||
const path = [...parentPath, node.id]
|
bearish: 'bg-red-900/40 border-red-600/50 text-red-300',
|
||||||
return {
|
down: 'bg-red-900/40 border-red-600/50 text-red-300',
|
||||||
...node,
|
neutral: 'bg-amber-900/40 border-amber-600/50 text-amber-300',
|
||||||
path,
|
|
||||||
children: (node.children ?? []).map((c: any) => enrichTree(c, path)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 allDirections = useMemo(() => {
|
||||||
const { data: taxonomyData } = usePatternTaxonomy()
|
const s = new Set(patterns.map(p => p.signal_direction).filter(Boolean) as string[])
|
||||||
// API returns { tree: {...}, patterns: [...] } — extract the tree and enrich nodes with path arrays.
|
return Array.from(s).sort()
|
||||||
// Start enriching from root's children with [] so paths match pattern taxonomy_path (no "root" prefix).
|
}, [patterns])
|
||||||
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 [selectedPath, setSelectedPath] = useState<string[]>([])
|
const allAssets = useMemo(() => {
|
||||||
const [nameFilter, setNameFilter] = useState('')
|
const s = new Set(patterns.map(p => p.asset_class).filter(Boolean))
|
||||||
const [categoryFilter, setCategoryFilter] = useState('')
|
return Array.from(s).sort()
|
||||||
const [sortBy, setSortBy] = useState<'ai_score' | 'probability' | 'expected_move' | 'date'>('ai_score')
|
}, [patterns])
|
||||||
|
|
||||||
// Patterns matching the selected taxonomy node + name filter
|
const allCategories = useMemo(() => {
|
||||||
const nodePatterns = 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
|
let list = patterns
|
||||||
if (selectedPath.length > 0)
|
if (query.trim()) {
|
||||||
list = list.filter(p => pathStartsWith(p.taxonomy_path, selectedPath))
|
const q = query.trim().toLowerCase()
|
||||||
if (nameFilter.trim()) {
|
list = list.filter(p =>
|
||||||
const q = nameFilter.trim().toLowerCase()
|
p.name.toLowerCase().includes(q) ||
|
||||||
list = list.filter(p => p.name.toLowerCase().includes(q) || p.description.toLowerCase().includes(q))
|
p.description.toLowerCase().includes(q) ||
|
||||||
|
(p.regime_tag ?? '').toLowerCase().includes(q),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return list
|
if (direction) list = list.filter(p => p.signal_direction === direction)
|
||||||
}, [patterns, selectedPath, nameFilter])
|
if (assetClass) list = list.filter(p => p.asset_class === assetClass)
|
||||||
|
if (category) list = list.filter(p => p.category === category)
|
||||||
// Available categories within current node (for filter pills)
|
if (horizon === 'short') list = list.filter(p => p.horizon_days < 30)
|
||||||
const availableCategories = useMemo(() => {
|
if (horizon === 'medium') list = list.filter(p => p.horizon_days >= 30 && p.horizon_days <= 90)
|
||||||
const cats = new Set(nodePatterns.map(p => p.category).filter(Boolean) as string[])
|
if (horizon === 'long') list = list.filter(p => p.horizon_days > 90)
|
||||||
return Array.from(cats).sort()
|
if (source) list = list.filter(p => p.source === source)
|
||||||
}, [nodePatterns])
|
|
||||||
|
|
||||||
// Apply category filter + sort
|
|
||||||
const filteredPatterns = useMemo(() => {
|
|
||||||
let list = nodePatterns
|
|
||||||
if (categoryFilter) list = list.filter(p => p.category === categoryFilter)
|
|
||||||
return [...list].sort((a, b) => {
|
return [...list].sort((a, b) => {
|
||||||
if (sortBy === 'ai_score') {
|
if (sortBy === 'ai_score') return (b.ai_quality_score ?? -1) - (a.ai_quality_score ?? -1)
|
||||||
const sa = a.ai_quality_score ?? -1
|
if (sortBy === 'probability') return b.probability - a.probability
|
||||||
const sb = b.ai_quality_score ?? -1
|
|
||||||
return sb - sa
|
|
||||||
}
|
|
||||||
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)
|
if (sortBy === 'expected_move') return Math.abs(b.expected_move_pct) - Math.abs(a.expected_move_pct)
|
||||||
// date
|
return ((b as any).created_at ?? '').localeCompare((a as any).created_at ?? '')
|
||||||
const da = (a as any).created_at ?? ''
|
|
||||||
const db = (b as any).created_at ?? ''
|
|
||||||
return db.localeCompare(da)
|
|
||||||
})
|
})
|
||||||
}, [nodePatterns, categoryFilter, sortBy])
|
}, [patterns, query, direction, assetClass, category, horizon, source, sortBy])
|
||||||
|
|
||||||
const breadcrumb = selectedPath.length > 0 ? selectedPath.join(' › ') : 'All Patterns'
|
const hasFilters = !!(query || direction || assetClass || category || horizon || source)
|
||||||
|
const toggle = (cur: string, val: string, set: (v: string) => void) => set(cur === val ? '' : val)
|
||||||
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'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 min-h-0 flex-1">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Left sidebar */}
|
{/* Search + sort */}
|
||||||
<div className="w-72 shrink-0 card flex flex-col gap-1 overflow-y-auto max-h-[calc(100vh-200px)]">
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-[10px] uppercase tracking-widest text-slate-500 font-semibold px-2 pt-1 pb-2">
|
<div className="relative flex-1 max-w-lg">
|
||||||
Taxonomy
|
<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>
|
</div>
|
||||||
|
<select
|
||||||
{/* Root "All" entry */}
|
value={sortBy}
|
||||||
<button
|
onChange={e => setSortBy(e.target.value as typeof sortBy)}
|
||||||
className={clsx(
|
className="input text-xs py-2 pr-6 pl-2 shrink-0"
|
||||||
'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('') }}
|
|
||||||
>
|
>
|
||||||
<BookOpen className="w-3.5 h-3.5 shrink-0 text-slate-500" />
|
<option value="ai_score">AI Score ↓</option>
|
||||||
<span className="flex-1">All Patterns</span>
|
<option value="probability">Probability ↓</option>
|
||||||
<span className="text-[10px] font-mono bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded-full">
|
<option value="expected_move">Expected Move ↓</option>
|
||||||
{patterns.length}
|
<option value="date">Date Added ↓</option>
|
||||||
</span>
|
</select>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel */}
|
{/* Filter panel */}
|
||||||
<div className="flex-1 flex flex-col gap-3 min-w-0">
|
<div className="flex flex-col gap-2.5 bg-dark-700/30 border border-slate-700/30 rounded-lg px-4 py-3">
|
||||||
{/* Row 1: breadcrumb + search + count */}
|
{/* Direction */}
|
||||||
<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 */}
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
{availableCategories.length > 0 && (
|
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Direction</span>
|
||||||
<>
|
<FilterChip active={!direction} label="All" onClick={() => setDirection('')} />
|
||||||
<span className="text-[10px] text-slate-600 uppercase tracking-wider shrink-0">Category:</span>
|
{allDirections.map(d => (
|
||||||
<button
|
<FilterChip key={d} active={direction === d}
|
||||||
onClick={() => setCategoryFilter('')}
|
label={d === 'bullish' || d === 'up' ? `↑ ${d}` : d === 'bearish' || d === 'down' ? `↓ ${d}` : `⚡ ${d}`}
|
||||||
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors',
|
onClick={() => toggle(direction, d, setDirection)}
|
||||||
categoryFilter === ''
|
color={DIR_COLORS[d]} />
|
||||||
? '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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pattern grid */}
|
{/* Asset class */}
|
||||||
{filteredPatterns.length === 0 ? (
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<div className="flex items-center justify-center flex-1 text-slate-600 text-sm py-16">
|
<span className="text-[10px] uppercase tracking-widest text-slate-600 w-20 shrink-0">Asset</span>
|
||||||
No patterns match the selected filter
|
<FilterChip active={!assetClass} label="All" onClick={() => setAssetClass('')} />
|
||||||
</div>
|
{allAssets.map(a => (
|
||||||
) : (
|
<FilterChip key={a} active={assetClass === a}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3 overflow-y-auto max-h-[calc(100vh-260px)] pr-1">
|
label={ASSET_CLASS_LABELS[a] ?? a}
|
||||||
{filteredPatterns.map(p => (
|
onClick={() => toggle(assetClass, a, setAssetClass)} />
|
||||||
<PatternCard key={p.id} pattern={p} />
|
))}
|
||||||
|
</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>
|
</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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -883,10 +817,10 @@ function RegimeView({ patterns }: { patterns: Pattern[] }) {
|
|||||||
|
|
||||||
// ── Page root ─────────────────────────────────────────────────────────────────
|
// ── Page root ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ViewMode = 'tree' | 'instrument' | 'regime'
|
type ViewMode = 'search' | 'instrument' | 'regime'
|
||||||
|
|
||||||
export default function PatternExplorer() {
|
export default function PatternExplorer() {
|
||||||
const [view, setView] = useState<ViewMode>('tree')
|
const [view, setView] = useState<ViewMode>('search')
|
||||||
const { data: patternsData, isLoading } = useAllPatterns()
|
const { data: patternsData, isLoading } = useAllPatterns()
|
||||||
const { data: _scores } = useLastScores()
|
const { data: _scores } = useLastScores()
|
||||||
|
|
||||||
@@ -921,14 +855,14 @@ export default function PatternExplorer() {
|
|||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium transition-colors',
|
'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'
|
? 'bg-blue-600/30 text-blue-300 border border-blue-500/30'
|
||||||
: 'text-slate-400 hover:text-slate-200'
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
)}
|
)}
|
||||||
onClick={() => setView('tree')}
|
onClick={() => setView('search')}
|
||||||
>
|
>
|
||||||
<TreePine className="w-3.5 h-3.5" />
|
<Search className="w-3.5 h-3.5" />
|
||||||
Tree
|
Search
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -965,7 +899,7 @@ export default function PatternExplorer() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{view === 'tree' && <TreeView patterns={patterns} />}
|
{view === 'search' && <FacetedSearch patterns={patterns} />}
|
||||||
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
{view === 'instrument' && <InstrumentLens patterns={patterns} />}
|
||||||
{view === 'regime' && <RegimeView patterns={patterns} />}
|
{view === 'regime' && <RegimeView patterns={patterns} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user