New grammar panel replaces free-text AI prompt with a two-step workflow: 1. NL to Grammar: GPT-4o converts description to patch ops (new endpoint ai-to-grammar) 2. Apply: frontend parser applies grammar locally without AI Grammar syntax: (+|-|~)(node|edge|coef|input|instruments) key=val key=val Sign shortcuts: + positive, - negative, = neutral Auto-positions new nodes when x/y omitted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2327 lines
114 KiB
TypeScript
2327 lines
114 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react'
|
||
import { useSearchParams } from 'react-router-dom'
|
||
import {
|
||
FlaskConical, Library, Zap, BarChart3, RefreshCw, Edit3,
|
||
Sliders, Brain, Search, CheckCircle, XCircle, Minus, AlertCircle,
|
||
Plus, Trash2, Save, Copy, Info, Wand2, ExternalLink, Filter, X,
|
||
} from 'lucide-react'
|
||
import clsx from 'clsx'
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
export interface CausalNode {
|
||
id: string; label: string
|
||
type: 'macro_event' | 'observable' | 'latent' | 'market_asset' | 'input' | 'intermediate' | 'output'
|
||
x: number; y: number
|
||
formula?: string; unit?: string; instrument?: string; description?: string
|
||
}
|
||
export interface CausalEdge {
|
||
from: string; to: string
|
||
style: 'solid' | 'dashed'
|
||
type?: string
|
||
strength?: 1 | 2 | 3
|
||
sign?: 'positive' | 'negative' | 'neutral'
|
||
label?: string
|
||
lag_min?: number // délai avant onset (minutes) — mode intraday 5m
|
||
lag_days?: number // délai avant onset (jours) — mode journalier
|
||
diffusion_min?: number // durée jusqu'à absorption complète (minutes)
|
||
decay_days?: number | null // demi-vie (jours), null = shift permanent
|
||
}
|
||
interface Coefficient { value: number; calibrated: number | null; description: string }
|
||
interface InputMapping {
|
||
source: 'user_input' | 'market_watchlist' | 'economic_events' | 'ff_calendar' | 'surprise' | 'actual_value' | 'impact_score_scaled' | 'impact_score_pct'
|
||
key?: string
|
||
field?: string
|
||
unit?: string
|
||
range?: number[]
|
||
}
|
||
interface DataSources {
|
||
prices: { key: string; label: string }[]
|
||
macro_series: { key: string; label: string }[]
|
||
ff_events: { key: string; label: string }[]
|
||
}
|
||
export interface GraphJson {
|
||
nodes: CausalNode[]; edges: CausalEdge[]
|
||
coefficients: Record<string, Coefficient>
|
||
instruments: string[]
|
||
input_mapping: Record<string, InputMapping>
|
||
}
|
||
interface Template {
|
||
id: number; name: string; category: string; sub_type: string
|
||
instruments: string[]; description: string; graph_json: GraphJson
|
||
ai_rationale: string; calibration_json: Record<string, unknown>
|
||
heuristic_ver: number; created_by?: string; created_at?: string
|
||
}
|
||
interface MarketEvent {
|
||
id: number; name: string; category: string; sub_type: string
|
||
start_date: string; end_date: string | null; level: string; created_at: string | null
|
||
impact_score: number; market_impact: string; affected_assets: string
|
||
actual_value: number | null; expected_value: number | null; surprise_pct: number | null
|
||
analysis_id: number | null; template_id: number | null; activation_score: number | null
|
||
}
|
||
interface NodeResult { pred: number | null; act: number | null; status: string; label: string }
|
||
interface AnalysisResult {
|
||
event: MarketEvent; template_id: number; template_name: string; instrument: string
|
||
inputs: Record<string, number>; node_values: Record<string, number>
|
||
actual_moves: Record<string, number>; yields: Record<string, number | null>
|
||
activation: { score: number | null; nodes: Record<string, NodeResult>; correct: number; total: number }
|
||
drift: { pre_pips: number | null; post_pips: number | null; drift_ratio: number | null; leak: string }
|
||
prices_mode: string; analyzed_at: string; effective_lag_min?: number
|
||
}
|
||
interface Recommendation {
|
||
template_id: number | null; template_name: string; confidence: number
|
||
rationale: string; coefficient_suggestions: Record<string, number>
|
||
instrument_focus: string[]; notes: string; error?: string
|
||
}
|
||
interface CalibRow {
|
||
id: number; name: string; category: string
|
||
n_analyses: number; avg_activation: number | null
|
||
calibration: Record<string, { n?: number; avg_pred?: number; avg_actual?: number; coef_ratio?: number }>
|
||
}
|
||
|
||
// ── API ───────────────────────────────────────────────────────────────────────
|
||
|
||
const api = async (path: string, opts?: RequestInit) => {
|
||
const r = await fetch(path, opts)
|
||
if (!r.ok) { const t = await r.text(); throw new Error(t) }
|
||
return r.json()
|
||
}
|
||
|
||
// ── Visuels ───────────────────────────────────────────────────────────────────
|
||
|
||
/** Couleur de nœud selon son type sémantique */
|
||
const nodeColor = (type: string): string => ({
|
||
macro_event: '#3b82f6', // blue — événements source (NFP, CPI, FOMC)
|
||
observable: '#06b6d4', // cyan — variables marché observables (OIS, 2Y, spreads)
|
||
latent: '#8b5cf6', // violet — variables latentes (anticipations, sentiment)
|
||
market_asset: '#10b981', // green — instruments tradés (EUR/USD, S&P)
|
||
// compat backward
|
||
input: '#3b82f6',
|
||
intermediate: '#8b5cf6',
|
||
output: '#10b981',
|
||
}[type] ?? '#64748b')
|
||
|
||
/** Couleur de l'arête selon son signe */
|
||
const edgeColor = (sign?: string): string => ({
|
||
positive: '#34d399', // teal
|
||
negative: '#f87171', // rouge-rose
|
||
neutral: '#475569', // gris ardoise
|
||
}[sign ?? 'neutral'] ?? '#475569')
|
||
|
||
/** Épaisseur selon strength */
|
||
const edgeWidth = (s?: number) => s === 1 ? 1 : s === 3 ? 3.5 : 2
|
||
|
||
/** Format lag minutes → "+5m" / "+2h" */
|
||
function fmtLag(min: number): string {
|
||
if (min < 60) return `+${min}m`
|
||
return `+${(min / 60).toFixed(0)}h`
|
||
}
|
||
/** Format decay days → "↩3j" */
|
||
const fmtDecay = (d: number) => `↩${d}j`
|
||
|
||
const CAT_LABELS: Record<string, string> = {
|
||
// Template categories
|
||
macro_us: 'Macro US',
|
||
macro_eu: 'Macro EU',
|
||
geopolitical: 'Géopolitique',
|
||
commodity: 'Matières 1ères',
|
||
report: 'Report',
|
||
sentiment: 'Sentiment',
|
||
// Market event categories (aligned)
|
||
event_calendar: 'Calendrier',
|
||
calendar_event: 'Calendrier',
|
||
fundamental: 'Fondamental',
|
||
}
|
||
|
||
// Chip color per category (inline styles to survive Tailwind purge)
|
||
const CAT_CHIP: Record<string, { bg: string; color: string; border: string }> = {
|
||
macro_us: { bg: '#1e3a5f', color: '#93c5fd', border: '#3b82f6' },
|
||
macro_eu: { bg: '#2d1b69', color: '#c4b5fd', border: '#8b5cf6' },
|
||
event_calendar: { bg: '#0c3535', color: '#67e8f9', border: '#06b6d4' },
|
||
calendar_event: { bg: '#0c3535', color: '#67e8f9', border: '#06b6d4' },
|
||
geopolitical: { bg: '#422006', color: '#fcd34d', border: '#d97706' },
|
||
commodity: { bg: '#14352a', color: '#6ee7b7', border: '#10b981' },
|
||
report: { bg: '#1e293b', color: '#94a3b8', border: '#475569' },
|
||
sentiment: { bg: '#4a1942', color: '#f9a8d4', border: '#ec4899' },
|
||
fundamental: { bg: '#1e1b4b', color: '#a5b4fc', border: '#6366f1' },
|
||
}
|
||
const chipStyle = (cat: string, active: boolean) => {
|
||
const c = CAT_CHIP[cat] ?? { bg: '#1e293b', color: '#94a3b8', border: '#475569' }
|
||
return {
|
||
background: active ? c.bg : 'transparent',
|
||
color: active ? c.color : '#64748b',
|
||
border: `1px solid ${active ? c.border : '#334155'}`,
|
||
}
|
||
}
|
||
const NODE_TYPES = ['macro_event', 'observable', 'latent', 'market_asset'] as const
|
||
const NODE_TYPE_LABELS: Record<string, string> = {
|
||
macro_event: 'Macro event', observable: 'Observable', latent: 'Latent', market_asset: 'Market asset',
|
||
}
|
||
const fmtPips = (v: number | null | undefined) =>
|
||
v == null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(0)} pip`
|
||
|
||
// ── SVG Graph renderer (partagé) ──────────────────────────────────────────────
|
||
|
||
export function GraphSVG({
|
||
graph, nodeValues, activationNodes, compact = false,
|
||
selectedNodeId, onNodeClick, onBgClick, editorMode = false,
|
||
}: {
|
||
graph: GraphJson
|
||
nodeValues?: Record<string, number>
|
||
activationNodes?: Record<string, NodeResult>
|
||
compact?: boolean
|
||
selectedNodeId?: string | null
|
||
onNodeClick?: (id: string, e: React.MouseEvent) => void
|
||
onBgClick?: (e: React.MouseEvent<SVGSVGElement>) => void
|
||
editorMode?: boolean
|
||
}) {
|
||
const svgRef = useRef<SVGSVGElement>(null)
|
||
const nodes = graph.nodes || []
|
||
const edges = graph.edges || []
|
||
|
||
// Scale positions to reduce overlap between nodes and edge labels
|
||
const POS_SCALE = compact ? 1 : 1.55
|
||
const scaledNodes = nodes.map(n => ({ ...n, x: n.x * POS_SCALE, y: n.y * POS_SCALE }))
|
||
const nodeMap = Object.fromEntries(scaledNodes.map(n => [n.id, n]))
|
||
|
||
const xs = scaledNodes.map(n => n.x)
|
||
const ys = scaledNodes.map(n => n.y)
|
||
const minX = (xs.length ? Math.min(...xs) : 0) - 80
|
||
const minY = (ys.length ? Math.min(...ys) : 0) - 50
|
||
const maxX = (xs.length ? Math.max(...xs) : 400) + 160
|
||
const maxY = (ys.length ? Math.max(...ys) : 400) + 90
|
||
const W = maxX - minX; const H = maxY - minY
|
||
|
||
// Nodes larger in editor mode to accommodate type badge
|
||
const NW = compact ? 92 : editorMode ? 140 : 130
|
||
const NH = compact ? 32 : editorMode ? 54 : 48
|
||
|
||
const statusColor = (s: string) =>
|
||
s === 'correct' ? '#10b981' : s === 'wrong' ? '#ef4444' : '#64748b'
|
||
|
||
function getNodeColor(n: CausalNode) {
|
||
if (activationNodes?.[n.id]) return statusColor(activationNodes[n.id].status)
|
||
return nodeColor(n.type)
|
||
}
|
||
|
||
function edgePath(e: CausalEdge) {
|
||
const s = nodeMap[e.from]; const t = nodeMap[e.to]
|
||
if (!s || !t) return ''
|
||
const x1 = s.x; const y1 = s.y + NH / 2
|
||
const x2 = t.x; const y2 = t.y - NH / 2
|
||
const my = (y1 + y2) / 2
|
||
return `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`
|
||
}
|
||
|
||
function bezierPt(e: CausalEdge, t0: number): { x: number; y: number } | null {
|
||
const s = nodeMap[e.from]; const t = nodeMap[e.to]
|
||
if (!s || !t) return null
|
||
const x1 = s.x; const y1 = s.y + NH / 2
|
||
const x2 = t.x; const y2 = t.y - NH / 2
|
||
const my = (y1 + y2) / 2
|
||
const dx = x2 - x1; const dy = y2 - y1; const len = Math.sqrt(dx*dx + dy*dy) || 1
|
||
const bx = (1-t0)**3*x1 + 3*(1-t0)**2*t0*x1 + 3*(1-t0)*t0**2*x2 + t0**3*x2
|
||
const by = (1-t0)**3*y1 + 3*(1-t0)**2*t0*my + 3*(1-t0)*t0**2*my + t0**3*y2
|
||
return { x: bx + (-dy/len)*16, y: by + (dx/len)*16 }
|
||
}
|
||
|
||
// Label position along bezier path with perpendicular offset to avoid the line
|
||
function edgeLabelPos(e: CausalEdge): { x: number; y: number } | null {
|
||
return bezierPt(e, 0.42)
|
||
}
|
||
|
||
function handleSvgClick(e: React.MouseEvent<SVGSVGElement>) {
|
||
if (e.target === svgRef.current || (e.target as SVGElement).tagName === 'svg') {
|
||
onBgClick?.(e)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<svg ref={svgRef} viewBox={`${minX} ${minY} ${W} ${H}`}
|
||
style={{ width: '100%', height: compact ? 280 : editorMode ? 620 : 560 }}
|
||
className={clsx('overflow-visible', editorMode && 'cursor-crosshair')}
|
||
onClick={handleSvgClick}>
|
||
<defs>
|
||
{(['positive', 'negative', 'neutral'] as const).map(sign => (
|
||
<marker key={sign} id={`arrow-${sign}`} markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||
<path d="M0,0 L0,6 L8,3 z" fill={edgeColor(sign)} />
|
||
</marker>
|
||
))}
|
||
</defs>
|
||
|
||
{/* Background grid in editor mode */}
|
||
{editorMode && <>
|
||
<defs>
|
||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" strokeWidth="0.5" />
|
||
</pattern>
|
||
</defs>
|
||
<rect x={minX} y={minY} width={W} height={H} fill="url(#grid)" />
|
||
</>}
|
||
|
||
{/* Edges */}
|
||
{edges.map((e, i) => {
|
||
const s = nodeMap[e.from]; const t = nodeMap[e.to]
|
||
if (!s || !t) return null
|
||
const sign = e.sign ?? 'neutral'
|
||
const color = edgeColor(sign)
|
||
const sw = edgeWidth(e.strength)
|
||
const lpos = e.label ? edgeLabelPos(e) : null
|
||
const labelText = e.label ?? ''
|
||
const labelW = labelText.length * 5.8
|
||
const lagTxtMin = (!compact && e.lag_min) ? fmtLag(e.lag_min) : null
|
||
const lagTxtDay = (!compact && e.lag_days) ? `+${e.lag_days}j` : null
|
||
const lagTxt = lagTxtMin && lagTxtDay ? `${lagTxtMin}/${lagTxtDay}` : (lagTxtMin ?? lagTxtDay)
|
||
const decayTxt = (!compact && e.decay_days != null) ? fmtDecay(e.decay_days) : null
|
||
const lagPos = lagTxt ? bezierPt(e, 0.13) : null
|
||
const decayPos = decayTxt ? bezierPt(e, 0.84) : null
|
||
return (
|
||
<g key={i}>
|
||
<path d={edgePath(e)} fill="none"
|
||
stroke={color} strokeWidth={sw}
|
||
strokeDasharray={e.style === 'dashed' ? '6,3' : undefined}
|
||
markerEnd={`url(#arrow-${sign})`} opacity={0.85} />
|
||
{lpos && (
|
||
<g>
|
||
<rect x={lpos.x - 3} y={lpos.y - 10} width={labelW + 6} height={13}
|
||
rx={3} fill="#0c1220" opacity={0.88} />
|
||
<text x={lpos.x} y={lpos.y} fill={color} fontSize={9.5} fontWeight="500"
|
||
letterSpacing="0.2">{labelText}</text>
|
||
</g>
|
||
)}
|
||
{lagPos && lagTxt && (
|
||
<g>
|
||
<rect x={lagPos.x - 3} y={lagPos.y - 10} width={lagTxt.length * 5.8 + 6} height={13}
|
||
rx={3} fill="#0c1220" opacity={0.88} />
|
||
<text x={lagPos.x} y={lagPos.y} fill="#fbbf24" fontSize={9} fontWeight="600">{lagTxt}</text>
|
||
</g>
|
||
)}
|
||
{decayPos && decayTxt && (
|
||
<g>
|
||
<rect x={decayPos.x - 3} y={decayPos.y - 10} width={decayTxt.length * 5.8 + 6} height={13}
|
||
rx={3} fill="#0c1220" opacity={0.88} />
|
||
<text x={decayPos.x} y={decayPos.y} fill="#94a3b8" fontSize={9} fontWeight="600">{decayTxt}</text>
|
||
</g>
|
||
)}
|
||
</g>
|
||
)
|
||
})}
|
||
|
||
{/* Nodes */}
|
||
{scaledNodes.map(n => {
|
||
const col = getNodeColor(n)
|
||
const val = nodeValues?.[n.id]
|
||
const isSel = selectedNodeId === n.id
|
||
const maxLen = editorMode ? 22 : 21
|
||
const lFS = compact ? 9 : 12
|
||
// vertical layout inside node
|
||
const labelY = compact ? 12 : editorMode ? 17 : 16
|
||
const valY = compact ? 22 : 34
|
||
const typeY = NH - 7 // type badge near bottom
|
||
return (
|
||
<g key={n.id} transform={`translate(${n.x - NW / 2},${n.y - NH / 2})`}
|
||
onClick={e => { e.stopPropagation(); onNodeClick?.(n.id, e) }}
|
||
style={{ cursor: editorMode ? 'pointer' : 'default' }}>
|
||
{isSel && (
|
||
<rect width={NW + 8} height={NH + 8} x={-4} y={-4} rx={9}
|
||
fill="none" stroke="#facc15" strokeWidth={2} opacity={0.85} />
|
||
)}
|
||
<rect width={NW} height={NH} rx={7}
|
||
fill={col} fillOpacity={isSel ? 0.32 : 0.16}
|
||
stroke={col} strokeWidth={isSel ? 2 : 1.5} />
|
||
{/* Label */}
|
||
<text x={NW / 2} y={labelY} textAnchor="middle"
|
||
fill="#e2e8f0" fontSize={lFS} fontWeight="600">
|
||
{n.label.length > maxLen ? n.label.slice(0, maxLen - 1) + '…' : n.label}
|
||
</text>
|
||
{/* Value (analyze mode) */}
|
||
{val != null && (
|
||
<text x={NW / 2} y={valY} textAnchor="middle"
|
||
fill={col} fontSize={lFS} fontWeight="700">
|
||
{val > 0 ? '+' : ''}{val.toFixed(1)}{n.unit ? ` ${n.unit}` : ''}
|
||
</text>
|
||
)}
|
||
{/* Type badge (editor only) — inside node at bottom */}
|
||
{editorMode && (
|
||
<>
|
||
<rect x={4} y={typeY - 8} width={NW - 8} height={10} rx={3}
|
||
fill={col} fillOpacity={0.18} />
|
||
<text x={NW / 2} y={typeY} textAnchor="middle"
|
||
fill={col} fontSize={8} fontWeight="500" opacity={0.9}>
|
||
{n.type?.replace(/_/g, ' ')}
|
||
</text>
|
||
</>
|
||
)}
|
||
</g>
|
||
)
|
||
})}
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
// ── Légende des conventions ───────────────────────────────────────────────────
|
||
|
||
function GraphLegend() {
|
||
return (
|
||
<div className="bg-dark-800/60 border border-slate-700/30 rounded-lg p-3 text-xs space-y-2">
|
||
<div className="text-slate-400 font-semibold uppercase tracking-wider text-xs mb-2">Conventions</div>
|
||
<div className="space-y-1.5">
|
||
{[
|
||
{ color: nodeColor('macro_event'), label: 'Macro event (source)' },
|
||
{ color: nodeColor('observable'), label: 'Observable (OIS, taux…)' },
|
||
{ color: nodeColor('latent'), label: 'Latent (anticipations…)' },
|
||
{ color: nodeColor('market_asset'), label: 'Market asset (FX, S&P…)' },
|
||
].map(({ color, label }) => (
|
||
<div key={label} className="flex items-center gap-2">
|
||
<div className="w-3 h-3 rounded-sm flex-shrink-0" style={{ background: color, opacity: 0.7 }} />
|
||
<span className="text-slate-400">{label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="border-t border-slate-700/30 pt-2 space-y-1">
|
||
{[
|
||
{ style: 'solid 3px', color: '#34d399', label: 'Causalité directe forte +' },
|
||
{ style: 'solid 1.5px', color: '#f87171', label: 'Effet négatif direct' },
|
||
{ style: 'dashed 2px', color: '#94a3b8', label: 'Transmission indirecte' },
|
||
].map(({ style, color, label }) => (
|
||
<div key={label} className="flex items-center gap-2">
|
||
<div className="w-8 h-0 flex-shrink-0" style={{ borderTop: `${style} ${color}` }} />
|
||
<span className="text-slate-400">{label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Graph Patch Grammar ───────────────────────────────────────────────────────
|
||
// Syntax: (+|-|~)(node|edge|coef|input|instruments) key=val key="val with spaces"
|
||
// sign shortcuts: + positive - negative = neutral
|
||
|
||
interface GrammarOp {
|
||
op: '+' | '-' | '~'
|
||
entity: 'node' | 'edge' | 'coef' | 'input' | 'instruments'
|
||
args: Record<string, string>
|
||
raw: string
|
||
}
|
||
|
||
function parseGrammarArgs(s: string): Record<string, string> {
|
||
const res: Record<string, string> = {}
|
||
const re = /(\w+)=(?:"([^"]*)"|([\S]*))/g
|
||
let m: RegExpExecArray | null
|
||
while ((m = re.exec(s)) !== null) res[m[1]] = m[2] !== undefined ? m[2] : m[3]
|
||
return res
|
||
}
|
||
|
||
function parseGrammar(text: string): { ops: GrammarOp[]; errors: string[] } {
|
||
const ops: GrammarOp[] = []; const errors: string[] = []
|
||
for (const rawLine of text.split('\n')) {
|
||
const line = rawLine.trim()
|
||
if (!line || line.startsWith('#')) continue
|
||
const m = line.match(/^([+\-~])(node|edge|coef|input|instruments)\s*(.*)/i)
|
||
if (!m) { errors.push(`Ligne non reconnue: "${line}"`); continue }
|
||
const entity = m[2].toLowerCase() as GrammarOp['entity']
|
||
const args = entity === 'instruments' ? { list: m[3].trim() } : parseGrammarArgs(m[3])
|
||
ops.push({ op: m[1] as '+' | '-' | '~', entity, args, raw: line })
|
||
}
|
||
return { ops, errors }
|
||
}
|
||
|
||
function resolveSign(s?: string): 'positive' | 'negative' | 'neutral' {
|
||
if (!s) return 'neutral'
|
||
if (s === '+' || s === 'positive') return 'positive'
|
||
if (s === '-' || s === 'negative') return 'negative'
|
||
return 'neutral'
|
||
}
|
||
|
||
function applyGrammarOps(state: EditorState, ops: GrammarOp[]): { state: EditorState; errors: string[] } {
|
||
let s: EditorState = {
|
||
...state,
|
||
nodes: [...state.nodes], edges: [...state.edges],
|
||
coefficients: { ...state.coefficients }, inputMapping: { ...state.inputMapping },
|
||
instruments: [...state.instruments],
|
||
}
|
||
const errors: string[] = []
|
||
|
||
for (const op of ops) {
|
||
try {
|
||
if (op.entity === 'node') {
|
||
const { id, label, type, x, y, formula, unit, instrument } = op.args
|
||
if (op.op === '+') {
|
||
if (!id) { errors.push('+node: id manquant'); continue }
|
||
if (s.nodes.find(n => n.id === id)) { errors.push(`+node: "${id}" existe déjà`); continue }
|
||
const autoY = s.nodes.length ? Math.max(...s.nodes.map(n => n.y)) + 120 : 200
|
||
s.nodes = [...s.nodes, {
|
||
id, label: label ?? id,
|
||
type: (type ?? 'observable') as CausalNode['type'],
|
||
x: x ? +x : 300, y: y ? +y : autoY,
|
||
...(formula ? { formula } : {}), ...(unit ? { unit } : {}), ...(instrument ? { instrument } : {}),
|
||
}]
|
||
} else if (op.op === '-') {
|
||
const tid = id ?? Object.values(op.args)[0]
|
||
if (!tid) { errors.push('-node: id manquant'); continue }
|
||
s.nodes = s.nodes.filter(n => n.id !== tid)
|
||
s.edges = s.edges.filter(e => e.from !== tid && e.to !== tid)
|
||
const im = { ...s.inputMapping }; delete im[tid]; s.inputMapping = im
|
||
} else {
|
||
if (!id) { errors.push('~node: id manquant'); continue }
|
||
s.nodes = s.nodes.map(n => n.id !== id ? n : {
|
||
...n,
|
||
...(label !== undefined ? { label } : {}),
|
||
...(type !== undefined ? { type: type as CausalNode['type'] } : {}),
|
||
...(x !== undefined ? { x: +x } : {}),
|
||
...(y !== undefined ? { y: +y } : {}),
|
||
...(formula !== undefined ? { formula } : {}),
|
||
...(unit !== undefined ? { unit } : {}),
|
||
...(instrument !== undefined ? { instrument } : {}),
|
||
})
|
||
}
|
||
} else if (op.entity === 'edge') {
|
||
const { from, to, sign, s: str, style, label, lag, decay } = op.args
|
||
if (op.op === '+') {
|
||
if (!from || !to) { errors.push('+edge: from/to manquants'); continue }
|
||
s.edges = [...s.edges, {
|
||
from, to, sign: resolveSign(sign),
|
||
strength: (str ? +str : 2) as 1|2|3,
|
||
style: (style ?? 'solid') as 'solid'|'dashed',
|
||
...(label ? { label } : {}),
|
||
...(lag !== undefined ? { lag_days: +lag } : {}),
|
||
...(decay !== undefined ? { decay_days: decay === 'null' ? null : +decay } : {}),
|
||
}]
|
||
} else if (op.op === '-') {
|
||
if (!from || !to) { errors.push('-edge: from/to manquants'); continue }
|
||
s.edges = s.edges.filter(e => !(e.from === from && e.to === to))
|
||
} else {
|
||
if (!from || !to) { errors.push('~edge: from/to manquants'); continue }
|
||
s.edges = s.edges.map(e => (e.from !== from || e.to !== to) ? e : {
|
||
...e,
|
||
...(sign !== undefined ? { sign: resolveSign(sign) } : {}),
|
||
...(str !== undefined ? { strength: +str as 1|2|3 } : {}),
|
||
...(style !== undefined ? { style: style as 'solid'|'dashed' } : {}),
|
||
...(label !== undefined ? { label } : {}),
|
||
...(lag !== undefined ? { lag_days: +lag } : {}),
|
||
...(decay !== undefined ? { decay_days: decay === 'null' ? null : +decay } : {}),
|
||
})
|
||
}
|
||
} else if (op.entity === 'coef') {
|
||
const { key, value, desc } = op.args
|
||
if (op.op === '+') {
|
||
if (!key || value === undefined) { errors.push('+coef: key/value manquants'); continue }
|
||
s.coefficients = { ...s.coefficients, [key]: { value: +value, calibrated: null, description: desc ?? '' } }
|
||
} else if (op.op === '-') {
|
||
if (!key) { errors.push('-coef: key manquant'); continue }
|
||
const cc = { ...s.coefficients }; delete cc[key]; s.coefficients = cc
|
||
} else {
|
||
if (!key) { errors.push('~coef: key manquant'); continue }
|
||
const ex = s.coefficients[key] ?? { value: 0, calibrated: null, description: '' }
|
||
s.coefficients = { ...s.coefficients, [key]: {
|
||
...ex,
|
||
...(value !== undefined ? { value: +value } : {}),
|
||
...(desc !== undefined ? { description: desc } : {}),
|
||
}}
|
||
}
|
||
} else if (op.entity === 'input') {
|
||
const { node, source, key } = op.args
|
||
if (op.op === '-') {
|
||
if (!node) { errors.push('-input: node manquant'); continue }
|
||
const im = { ...s.inputMapping }; delete im[node]; s.inputMapping = im
|
||
} else {
|
||
if (!node || !source) { errors.push(`${op.op}input: node/source manquants`); continue }
|
||
s.inputMapping = { ...s.inputMapping, [node]: { source: source as InputMapping['source'], ...(key ? { key } : {}) } }
|
||
}
|
||
} else if (op.entity === 'instruments') {
|
||
const list = op.args.list?.split(',').map(x => x.trim()).filter(Boolean)
|
||
if (list?.length) s.instruments = list
|
||
}
|
||
} catch (e) { errors.push(`Erreur "${op.raw}": ${e}`) }
|
||
}
|
||
return { state: s, errors }
|
||
}
|
||
|
||
const GRAMMAR_REFERENCE = `# Nœuds
|
||
+node id=<id> label="<texte>" type=<macro_event|observable|latent|market_asset> [x=<n>] [y=<n>] [instrument=<str>]
|
||
~node id=<id> [label="..."] [type=...] [x=<n>] [y=<n>] [instrument=<str>]
|
||
-node id=<id>
|
||
|
||
# Arêtes (sign: + positif - négatif = neutre)
|
||
+edge from=<id> to=<id> [sign=+|-|=] [s=1|2|3] [style=solid|dashed] [label="..."] [lag=<j>] [decay=<j|null>]
|
||
~edge from=<id> to=<id> [...mêmes champs...]
|
||
-edge from=<id> to=<id>
|
||
|
||
# Coefficients
|
||
+coef key=<str> value=<float> [desc="..."]
|
||
~coef key=<str> [value=<float>] [desc="..."]
|
||
-coef key=<str>
|
||
|
||
# Mapping d'entrée
|
||
~input node=<id> source=<surprise|actual_value|user_input|impact_score_scaled> [key=<str>]
|
||
-input node=<id>
|
||
|
||
# Instruments de sortie
|
||
~instruments EURUSD,SP500,...`
|
||
|
||
// ── Tab: Bibliothèque ─────────────────────────────────────────────────────────
|
||
|
||
function TabLibrary({
|
||
initialTemplateId,
|
||
onOpenEditor,
|
||
}: {
|
||
initialTemplateId?: number | null
|
||
onOpenEditor: (id: number) => void
|
||
}) {
|
||
const [templates, setTemplates] = useState<Template[]>([])
|
||
const [selected, setSelected] = useState<Template | null>(null)
|
||
const [catFilter, setCatFilter] = useState('')
|
||
const [subFilter, setSubFilter] = useState('')
|
||
const [instFilter, setInstFilter] = useState('')
|
||
const [dateFrom, setDateFrom] = useState('')
|
||
const [dateTo, setDateTo] = useState('')
|
||
const [search, setSearch] = useState('')
|
||
const [saving, setSaving] = useState(false)
|
||
const [deleting, setDeleting] = useState(false)
|
||
const [savingLag, setSavingLag] = useState(false)
|
||
const [savingTheory, setSavingTheory] = useState(false)
|
||
const [genTheory, setGenTheory] = useState(false)
|
||
const [editCoefs, setEditCoefs] = useState<Record<string, number>>({})
|
||
const [editEdgesLag, setEdgesLag] = useState<CausalEdge[]>([])
|
||
const [lagDirty, setLagDirty] = useState(false)
|
||
const [loading, setLoading] = useState(true)
|
||
const [apiError, setApiError] = useState<string | null>(null)
|
||
const [debugInfo, setDebugInfo] = useState<Record<string, unknown> | null>(null)
|
||
|
||
useEffect(() => {
|
||
setLoading(true); setApiError(null)
|
||
api('/api/causal-lab/templates')
|
||
.then(data => {
|
||
setTemplates(data)
|
||
if (data.length === 0) api('/api/causal-lab/debug').then(setDebugInfo).catch(() => {})
|
||
})
|
||
.catch(e => setApiError(String(e)))
|
||
.finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
// Auto-select template when arriving from a deep-link (?template=ID)
|
||
useEffect(() => {
|
||
if (!initialTemplateId || !templates.length || selected) return
|
||
const t = templates.find(t => t.id === initialTemplateId)
|
||
if (t) selectTemplate(t)
|
||
}, [templates, initialTemplateId])
|
||
|
||
function selectTemplate(t: Template) {
|
||
setSelected(t)
|
||
setEditCoefs(Object.fromEntries(
|
||
Object.entries(t.graph_json?.coefficients || {}).map(([k, v]) => [k, v.value])
|
||
))
|
||
setEdgesLag(JSON.parse(JSON.stringify(t.graph_json?.edges || [])))
|
||
setLagDirty(false)
|
||
}
|
||
|
||
async function saveCoefs() {
|
||
if (!selected) return
|
||
setSaving(true)
|
||
try {
|
||
await api(`/api/causal-lab/template/${selected.id}`, {
|
||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ coefficients: editCoefs }),
|
||
})
|
||
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
|
||
setSelected(fresh)
|
||
} finally { setSaving(false) }
|
||
}
|
||
|
||
async function saveTemporalParams() {
|
||
if (!selected) return
|
||
setSavingLag(true)
|
||
try {
|
||
const updatedGraph = { ...selected.graph_json, edges: editEdgesLag }
|
||
await api(`/api/causal-lab/template/${selected.id}`, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ graph_json: updatedGraph }),
|
||
})
|
||
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
|
||
setSelected(fresh)
|
||
setEdgesLag(JSON.parse(JSON.stringify(fresh.graph_json?.edges || [])))
|
||
setLagDirty(false)
|
||
} finally { setSavingLag(false) }
|
||
}
|
||
|
||
async function generateTheory() {
|
||
if (!selected) return
|
||
setGenTheory(true)
|
||
try {
|
||
await api(`/api/causal-lab/template/${selected.id}/generate-theory`, { method: 'POST' })
|
||
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
|
||
setSelected(fresh)
|
||
} finally { setGenTheory(false) }
|
||
}
|
||
|
||
async function saveTheoryParams(updates: Record<string, unknown>) {
|
||
if (!selected) return
|
||
setSavingTheory(true)
|
||
try {
|
||
const newCalib = { ...(selected.calibration_json || {}), ...updates }
|
||
await api(`/api/causal-lab/template/${selected.id}`, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ calibration_json: newCalib }),
|
||
})
|
||
const fresh = await api(`/api/causal-lab/template/${selected.id}`)
|
||
setSelected(fresh)
|
||
} finally { setSavingTheory(false) }
|
||
}
|
||
|
||
function updateEdgeLag(i: number, field: 'lag_min' | 'lag_days' | 'diffusion_min' | 'decay_days', val: string) {
|
||
setEdgesLag(prev => prev.map((e, idx) => idx !== i ? e : {
|
||
...e,
|
||
[field]: val === '' ? (field === 'decay_days' ? null : undefined)
|
||
: (field === 'decay_days' ? parseFloat(val) : parseInt(val)),
|
||
}))
|
||
setLagDirty(true)
|
||
}
|
||
|
||
async function deleteTemplate() {
|
||
if (!selected) return
|
||
if (!window.confirm(`Supprimer "${selected.name}" ? Cette action est irréversible.`)) return
|
||
setDeleting(true)
|
||
try {
|
||
await api(`/api/causal-lab/template/${selected.id}`, { method: 'DELETE' })
|
||
setSelected(null)
|
||
const data = await api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`)
|
||
setTemplates(data)
|
||
} catch (e: unknown) {
|
||
alert(String(e))
|
||
} finally { setDeleting(false) }
|
||
}
|
||
|
||
// Derive filter options from loaded templates
|
||
const cats = [...new Set(templates.map(t => t.category))].sort()
|
||
const subs = catFilter
|
||
? [...new Set(templates.filter(t => t.category === catFilter).map(t => t.sub_type).filter(Boolean))].sort()
|
||
: []
|
||
const insts = [...new Set(templates.flatMap(t => t.instruments))].sort()
|
||
|
||
// Client-side filtered list
|
||
const visible = templates.filter(t => {
|
||
if (catFilter && t.category !== catFilter) return false
|
||
if (subFilter && t.sub_type !== subFilter) return false
|
||
if (instFilter && !t.instruments.includes(instFilter)) return false
|
||
if (dateFrom && t.created_at && t.created_at < dateFrom) return false
|
||
if (dateTo && t.created_at && t.created_at > dateTo + 'T23:59') return false
|
||
if (search && !t.name.toLowerCase().includes(search.toLowerCase())
|
||
&& !(t.description ?? '').toLowerCase().includes(search.toLowerCase())) return false
|
||
return true
|
||
})
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
{/* ── Filter bar ── */}
|
||
<div className="bg-dark-700/60 rounded-lg border border-slate-700/30 p-3 space-y-2">
|
||
{/* Category chips */}
|
||
<div className="flex flex-wrap gap-1.5 items-center">
|
||
<Filter className="w-3 h-3 text-slate-500 shrink-0" />
|
||
<button
|
||
onClick={() => { setCatFilter(''); setSubFilter('') }}
|
||
className="text-xs px-2.5 py-1 rounded-full border transition-all"
|
||
style={chipStyle('', catFilter === '')}>
|
||
Tout
|
||
</button>
|
||
{cats.map(c => (
|
||
<button key={c}
|
||
onClick={() => { setCatFilter(catFilter === c ? '' : c); setSubFilter('') }}
|
||
className="text-xs px-2.5 py-1 rounded-full border transition-all"
|
||
style={chipStyle(c, catFilter === c)}>
|
||
{CAT_LABELS[c] ?? c}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Sub-type chips — only if category selected and has sub-types */}
|
||
{subs.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 items-center pl-4">
|
||
<span className="text-xs text-slate-600">↳</span>
|
||
<button
|
||
onClick={() => setSubFilter('')}
|
||
className="text-xs px-2 py-0.5 rounded-full border transition-all"
|
||
style={{ background: subFilter === '' ? '#1e293b' : 'transparent', color: subFilter === '' ? '#94a3b8' : '#475569', border: '1px solid ' + (subFilter === '' ? '#475569' : '#1e293b') }}>
|
||
Tous
|
||
</button>
|
||
{subs.map(s => (
|
||
<button key={s}
|
||
onClick={() => setSubFilter(subFilter === s ? '' : s)}
|
||
className="text-xs px-2 py-0.5 rounded-full border transition-all"
|
||
style={{ background: subFilter === s ? '#1e293b' : 'transparent', color: subFilter === s ? '#e2e8f0' : '#475569', border: '1px solid ' + (subFilter === s ? '#64748b' : '#1e293b') }}>
|
||
{s}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Instrument + date row */}
|
||
<div className="flex flex-wrap gap-2 items-center">
|
||
{/* Instrument chips */}
|
||
<div className="flex flex-wrap gap-1 items-center">
|
||
<span className="text-xs text-slate-600">Inst:</span>
|
||
{insts.map(i => (
|
||
<button key={i}
|
||
onClick={() => setInstFilter(instFilter === i ? '' : i)}
|
||
className="text-xs px-2 py-0.5 rounded border transition-all"
|
||
style={{ background: instFilter === i ? '#14352a' : 'transparent', color: instFilter === i ? '#6ee7b7' : '#475569', border: '1px solid ' + (instFilter === i ? '#10b981' : '#1e293b') }}>
|
||
{i}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{/* Date range */}
|
||
<div className="flex items-center gap-1.5 ml-auto">
|
||
<span className="text-xs text-slate-600">Créé:</span>
|
||
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
|
||
className="bg-dark-800 border border-slate-700 rounded px-1.5 py-0.5 text-xs text-slate-400 w-32" />
|
||
<span className="text-slate-600 text-xs">→</span>
|
||
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
|
||
className="bg-dark-800 border border-slate-700 rounded px-1.5 py-0.5 text-xs text-slate-400 w-32" />
|
||
{(dateFrom || dateTo) && (
|
||
<button onClick={() => { setDateFrom(''); setDateTo('') }}
|
||
className="text-slate-600 hover:text-slate-400">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Search */}
|
||
<div className="relative">
|
||
<Search className="w-3 h-3 absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-600" />
|
||
<input value={search} onChange={e => setSearch(e.target.value)}
|
||
placeholder="Rechercher par nom / description…"
|
||
className="w-full bg-dark-800 border border-slate-700 rounded pl-7 pr-3 py-1.5 text-xs text-slate-300 placeholder:text-slate-600" />
|
||
{search && (
|
||
<button onClick={() => setSearch('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400">
|
||
<X className="w-3 h-3" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Main content: list + detail ── */}
|
||
<div className="flex gap-4">
|
||
<div className="w-64 flex-shrink-0">
|
||
{apiError && (
|
||
<div className="mb-2 p-3 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300 break-all">
|
||
<div className="font-bold mb-1">Erreur API</div>{apiError}
|
||
</div>
|
||
)}
|
||
{debugInfo && (
|
||
<div className="mb-2 p-3 bg-yellow-900/20 border border-yellow-700/40 rounded text-xs text-yellow-200">
|
||
<div className="font-bold mb-1">Diagnostic</div>
|
||
<pre className="whitespace-pre-wrap">{JSON.stringify(debugInfo, null, 2)}</pre>
|
||
</div>
|
||
)}
|
||
|
||
{loading
|
||
? <div className="text-slate-500 text-xs p-4">Chargement…</div>
|
||
: <>
|
||
<div className="text-xs text-slate-600 mb-1.5">{visible.length} graphe{visible.length !== 1 ? 's' : ''}</div>
|
||
<div className="space-y-1 overflow-y-auto max-h-[calc(100vh-400px)]">
|
||
{visible.map(t => (
|
||
<button key={t.id} onClick={() => selectTemplate(t)}
|
||
className={clsx('w-full text-left px-3 py-2.5 rounded border text-xs transition-all',
|
||
selected?.id === t.id
|
||
? 'bg-blue-900/30 border-blue-500/50 text-blue-200'
|
||
: 'bg-dark-700 border-slate-700/50 text-slate-300 hover:border-slate-500')}>
|
||
<div className="font-medium truncate">{t.name}</div>
|
||
<div className="text-slate-500 mt-0.5 flex items-center gap-1.5 flex-wrap">
|
||
<span className="px-1.5 py-px rounded-sm text-[10px]"
|
||
style={chipStyle(t.category, true)}>
|
||
{CAT_LABELS[t.category] ?? t.category}
|
||
</span>
|
||
{t.sub_type && <span className="text-slate-600 text-[10px]">{t.sub_type}</span>}
|
||
<span className="text-slate-600">{t.instruments.join(', ')}</span>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</>
|
||
}
|
||
</div>
|
||
|
||
{selected ? (
|
||
<div className="flex-1 overflow-y-auto space-y-4 min-w-0 max-h-[calc(100vh-380px)]">
|
||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||
<div className="flex items-start justify-between mb-2">
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="text-white font-semibold">{selected.name}</h3>
|
||
<p className="text-slate-400 text-xs mt-1">{selected.description}</p>
|
||
{selected.created_at && (
|
||
<p className="text-slate-600 text-xs mt-1">
|
||
Créé le {new Date(selected.created_at).toLocaleDateString('fr-FR')}
|
||
{selected.created_by && ` · par ${selected.created_by}`}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||
<button
|
||
onClick={() => onOpenEditor(selected.id)}
|
||
title="Modifier dans l'éditeur"
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded bg-violet-800/40 hover:bg-violet-700/50 text-violet-300 border border-violet-700/40 text-xs font-medium transition-all">
|
||
<ExternalLink className="w-3 h-3" /> Éditer
|
||
</button>
|
||
<span className="text-xs px-2 py-1 rounded bg-purple-900/30 text-purple-300 border border-purple-700/40">
|
||
v{selected.heuristic_ver}
|
||
</span>
|
||
<button
|
||
onClick={deleteTemplate}
|
||
disabled={deleting}
|
||
title="Supprimer ce template"
|
||
className="p-1 rounded transition-colors text-slate-500 hover:bg-red-900/30 hover:text-red-400 disabled:opacity-40">
|
||
<Trash2 className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{selected.ai_rationale && (
|
||
<div className="mt-3 p-3 bg-blue-900/10 border border-blue-800/30 rounded text-xs text-blue-200">
|
||
<Brain className="w-3 h-3 inline mr-1.5 text-blue-400" />
|
||
{selected.ai_rationale}
|
||
</div>
|
||
)}
|
||
<div className="mt-3 flex gap-2 flex-wrap items-center">
|
||
<span className="text-xs px-2 py-0.5 rounded border" style={chipStyle(selected.category, true)}>
|
||
{CAT_LABELS[selected.category] ?? selected.category}
|
||
</span>
|
||
{selected.sub_type && (
|
||
<span className="text-xs px-2 py-0.5 rounded bg-slate-700/40 text-slate-400 border border-slate-600/40">
|
||
{selected.sub_type}
|
||
</span>
|
||
)}
|
||
{selected.instruments.map(i => (
|
||
<span key={i} className="text-xs px-2 py-0.5 rounded bg-emerald-900/20 text-emerald-400 border border-emerald-700/30">{i}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-[1fr_200px] gap-4">
|
||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider">Graphe causal</h4>
|
||
<GraphSVG graph={selected.graph_json} />
|
||
</div>
|
||
<GraphLegend />
|
||
</div>
|
||
|
||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||
<Sliders className="w-3.5 h-3.5" /> Coefficients
|
||
</h4>
|
||
<div className="space-y-2">
|
||
{Object.entries(selected.graph_json?.coefficients || {}).map(([k, c]) => (
|
||
<div key={k} className="grid grid-cols-[1fr_auto] gap-3 items-center">
|
||
<div>
|
||
<div className="text-xs text-slate-300 font-medium">{k}</div>
|
||
<div className="text-xs text-slate-500">{c.description}</div>
|
||
</div>
|
||
<input type="number" step="0.001"
|
||
value={editCoefs[k] ?? c.value}
|
||
onChange={e => setEditCoefs(p => ({ ...p, [k]: parseFloat(e.target.value) }))}
|
||
className="w-28 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 text-right" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button onClick={saveCoefs} disabled={saving}
|
||
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 rounded text-xs font-medium text-white">
|
||
{saving ? 'Sauvegarde…' : 'Sauvegarder les coefficients'}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Paramètres temporels */}
|
||
{editEdgesLag.some(e => e.from && e.to) && (
|
||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||
<Zap className="w-3.5 h-3.5 text-yellow-400" /> Paramètres temporels
|
||
</h4>
|
||
<div className="space-y-2">
|
||
<div className="grid grid-cols-[1fr_52px_52px_52px_52px] gap-1.5 text-xs text-slate-500 pb-1 border-b border-slate-700/30">
|
||
<span>Arête</span>
|
||
<span className="text-center" title="Délai intraday (mode 5m)">Lag 5m</span>
|
||
<span className="text-center" title="Délai journalier (mode daily)">Lag j</span>
|
||
<span className="text-center">Diff. m</span>
|
||
<span className="text-center">Decay j</span>
|
||
</div>
|
||
{editEdgesLag.map((e, i) => (
|
||
<div key={i} className="grid grid-cols-[1fr_52px_52px_52px_52px] gap-1.5 items-center">
|
||
<span className="text-xs font-mono text-slate-400 truncate">{e.from} → {e.to}</span>
|
||
<input type="number" min={0} step={5}
|
||
value={e.lag_min ?? ''}
|
||
onChange={ev => updateEdgeLag(i, 'lag_min', ev.target.value)}
|
||
placeholder="0"
|
||
title="Délai en minutes (intraday 5m)"
|
||
className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" />
|
||
<input type="number" min={0} step={1}
|
||
value={e.lag_days ?? ''}
|
||
onChange={ev => updateEdgeLag(i, 'lag_days', ev.target.value)}
|
||
placeholder="0"
|
||
title="Délai en jours (mode journalier)"
|
||
className="w-full bg-dark-800 border border-amber-600/40 rounded px-1 py-1 text-xs text-amber-200 text-center" />
|
||
<input type="number" min={0} step={15}
|
||
value={e.diffusion_min ?? ''}
|
||
onChange={ev => updateEdgeLag(i, 'diffusion_min', ev.target.value)}
|
||
placeholder="60"
|
||
className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" />
|
||
<input type="number" min={0} step={1}
|
||
value={e.decay_days ?? ''}
|
||
onChange={ev => updateEdgeLag(i, 'decay_days', ev.target.value)}
|
||
placeholder="∞"
|
||
className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button onClick={saveTemporalParams} disabled={savingLag}
|
||
className={clsx(
|
||
'mt-4 px-4 py-2 rounded text-xs font-medium text-white disabled:opacity-50 transition-all',
|
||
lagDirty ? 'bg-amber-600 hover:bg-amber-500 ring-2 ring-amber-400/50 animate-pulse'
|
||
: 'bg-slate-700 hover:bg-slate-600'
|
||
)}>
|
||
{savingLag ? 'Sauvegarde…' : lagDirty ? '⚠ Sauvegarder les paramètres temporels' : 'Paramètres temporels sauvegardés'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Paramètres théoriques */}
|
||
<div className="bg-dark-700 rounded-lg p-4 border border-violet-800/30">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<h4 className="text-slate-300 text-xs font-semibold uppercase tracking-wider flex items-center gap-2 flex-1">
|
||
<span className="text-violet-400">⟁</span> Paramètres théoriques
|
||
</h4>
|
||
<button
|
||
onClick={generateTheory}
|
||
disabled={genTheory}
|
||
className="px-3 py-1 bg-violet-700 hover:bg-violet-600 disabled:opacity-50 rounded text-xs font-medium text-white flex items-center gap-1.5"
|
||
>
|
||
{genTheory ? (
|
||
<><span className="animate-spin inline-block">↻</span> Génération…</>
|
||
) : (
|
||
<><span>✦</span> Générer IA</>
|
||
)}
|
||
</button>
|
||
</div>
|
||
|
||
{(() => {
|
||
const calib = selected.calibration_json || {}
|
||
const absorption = calib.absorption_days as number | undefined
|
||
const decay = calib.decay_type as string | undefined
|
||
const conf = calib.confidence as number | undefined
|
||
const rationale = calib.theory_rationale as string | undefined
|
||
const genAt = calib.theory_generated_at as string | undefined
|
||
return (
|
||
<div className="space-y-3">
|
||
{rationale && (
|
||
<p className="text-xs text-violet-300 italic border-l-2 border-violet-700/60 pl-2">{rationale}</p>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Durée absorption (j)</div>
|
||
<input
|
||
type="number" min={1} max={60} step={1}
|
||
value={absorption ?? ''}
|
||
placeholder="—"
|
||
onChange={e => saveTheoryParams({ absorption_days: parseInt(e.target.value) || 7 })}
|
||
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 text-center"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<div className="text-xs text-slate-500 mb-1">Type de décroissance</div>
|
||
<select
|
||
value={decay ?? 'exp'}
|
||
onChange={e => saveTheoryParams({ decay_type: e.target.value })}
|
||
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200"
|
||
>
|
||
<option value="step">step — tout ou rien</option>
|
||
<option value="linear">linear — déclin linéaire</option>
|
||
<option value="exp">exp — déclin exponentiel</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-3 text-xs text-slate-500">
|
||
{conf !== undefined && (
|
||
<span className="flex items-center gap-1">
|
||
Confiance IA :
|
||
<span className={`font-mono font-semibold ${conf >= 0.7 ? 'text-emerald-400' : conf >= 0.4 ? 'text-yellow-400' : 'text-red-400'}`}>
|
||
{Math.round(conf * 100)}%
|
||
</span>
|
||
</span>
|
||
)}
|
||
{genAt && (
|
||
<span className="ml-auto opacity-60">
|
||
{new Date(genAt).toLocaleDateString('fr-FR')}
|
||
</span>
|
||
)}
|
||
{savingTheory && <span className="text-violet-400 animate-pulse">Sauvegarde…</span>}
|
||
</div>
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex-1 flex items-center justify-center text-slate-600 text-sm">
|
||
Sélectionnez un template dans la liste
|
||
</div>
|
||
)}
|
||
</div>{/* end flex list+detail */}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Tab: Editeur de graphe ────────────────────────────────────────────────────
|
||
|
||
interface EditorState {
|
||
templateId: number | null; name: string; category: string; subType: string
|
||
description: string; instruments: string[]; aiRationale: string
|
||
nodes: CausalNode[]; edges: CausalEdge[]
|
||
coefficients: Record<string, Coefficient>
|
||
inputMapping: Record<string, InputMapping>
|
||
}
|
||
|
||
const BLANK: EditorState = {
|
||
templateId: null, name: 'Nouveau template', category: 'macro_us', subType: '',
|
||
description: '', instruments: ['EURUSD'], aiRationale: '',
|
||
nodes: [], edges: [], coefficients: {}, inputMapping: {},
|
||
}
|
||
|
||
const INSTRUMENTS_LIST = ['EURUSD', 'XAUUSD', 'SP500', 'BRENT', 'US2Y', 'US10Y']
|
||
|
||
function TabEditor({ initialId }: { initialId?: number | null }) {
|
||
const [templates, setTemplates] = useState<Template[]>([])
|
||
const [state, setState] = useState<EditorState>(BLANK)
|
||
const [selNode, setSelNode] = useState<string | null>(null)
|
||
const [editEdgeIdx, setEditEdge] = useState<number | null>(null)
|
||
const [addNodeOpen, setAddNode] = useState(false)
|
||
const [addEdgeOpen, setAddEdge] = useState(false)
|
||
const [newNode, setNewNode] = useState<Partial<CausalNode>>({ type: 'observable', x: 200, y: 200 })
|
||
const [newEdge, setNewEdge] = useState<Partial<CausalEdge>>({ style: 'solid', strength: 2, sign: 'positive' })
|
||
const [editEdge, setEditEdgeVal] = useState<Partial<CausalEdge>>({})
|
||
const [saving, setSaving] = useState(false)
|
||
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null)
|
||
const [dataSources, setDS] = useState<DataSources>({ prices: [], macro_series: [], ff_events: [] })
|
||
const [grammarText, setGrammarText] = useState('')
|
||
const [grammarErrors, setGrammarErrors] = useState<string[]>([])
|
||
const [grammarApplied, setGrammarApplied] = useState(0) // count of applied ops
|
||
const [aiLoading, setAiLoading] = useState(false)
|
||
const [aiError, setAiError] = useState<string | null>(null)
|
||
const [showGrammarRef, setShowGrammarRef] = useState(false)
|
||
const svgRef = useRef<SVGSVGElement>(null)
|
||
|
||
useEffect(() => {
|
||
api('/api/causal-lab/templates').then(setTemplates)
|
||
api('/api/causal-lab/data-sources').then(setDS).catch(() => {})
|
||
}, [])
|
||
|
||
// Load template when arriving from Library "Éditer" button
|
||
useEffect(() => {
|
||
if (!initialId) return
|
||
api(`/api/causal-lab/template/${initialId}`).then(applyTemplateToState).catch(() => {})
|
||
}, [initialId])
|
||
|
||
function applyTemplateToState(t: Template) {
|
||
setState({
|
||
templateId: t.id, name: t.name, category: t.category, subType: t.sub_type,
|
||
description: t.description, instruments: t.instruments, aiRationale: t.ai_rationale,
|
||
nodes: JSON.parse(JSON.stringify(t.graph_json.nodes || [])),
|
||
edges: JSON.parse(JSON.stringify(t.graph_json.edges || [])),
|
||
coefficients: JSON.parse(JSON.stringify(t.graph_json.coefficients || {})),
|
||
inputMapping: JSON.parse(JSON.stringify(t.graph_json.input_mapping || {})),
|
||
})
|
||
setSelNode(null)
|
||
}
|
||
|
||
function loadTemplate(id: number) {
|
||
const t = templates.find(x => x.id === id)
|
||
if (t) applyTemplateToState(t)
|
||
}
|
||
|
||
// Compile natural language → grammar (replaces textarea content)
|
||
async function compileNlToGrammar() {
|
||
if (!grammarText.trim()) return
|
||
setAiLoading(true); setAiError(null)
|
||
try {
|
||
const r = await api(`/api/causal-lab/template/${state.templateId ?? 0}/ai-to-grammar`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ prompt: grammarText, current_graph: graphJson }),
|
||
})
|
||
setGrammarText(r.grammar)
|
||
setGrammarErrors([])
|
||
setGrammarApplied(0)
|
||
} catch (e) {
|
||
setAiError(String(e))
|
||
} finally {
|
||
setAiLoading(false)
|
||
}
|
||
}
|
||
|
||
// Apply grammar patch directly to editor state (no AI)
|
||
function applyGrammar() {
|
||
const { ops, errors: parseErrors } = parseGrammar(grammarText)
|
||
if (parseErrors.length && ops.length === 0) { setGrammarErrors(parseErrors); return }
|
||
const { state: newState, errors: applyErrors } = applyGrammarOps(state, ops)
|
||
setGrammarErrors([...parseErrors, ...applyErrors])
|
||
setState(newState)
|
||
setGrammarApplied(ops.length - applyErrors.length)
|
||
if (ops.length > 0) setMsg({ ok: true, text: `${ops.length - applyErrors.length}/${ops.length} opérations appliquées.` })
|
||
}
|
||
|
||
function resetNew() { setState(BLANK); setSelNode(null) }
|
||
|
||
const graphJson: GraphJson = {
|
||
nodes: state.nodes, edges: state.edges,
|
||
coefficients: state.coefficients,
|
||
instruments: state.instruments,
|
||
input_mapping: state.inputMapping,
|
||
}
|
||
|
||
// Click SVG background → reposition selected node
|
||
function handleBgClick(e: React.MouseEvent<SVGSVGElement>) {
|
||
if (!selNode) return
|
||
const svg = (e.currentTarget as SVGSVGElement)
|
||
const pt = svg.createSVGPoint()
|
||
pt.x = e.clientX; pt.y = e.clientY
|
||
const sp = pt.matrixTransform(svg.getScreenCTM()!.inverse())
|
||
setState(s => ({
|
||
...s, nodes: s.nodes.map(n =>
|
||
n.id === selNode ? { ...n, x: Math.round(sp.x), y: Math.round(sp.y) } : n
|
||
),
|
||
}))
|
||
}
|
||
|
||
function addNode() {
|
||
if (!newNode.id || !newNode.label) return
|
||
const n: CausalNode = {
|
||
id: newNode.id!, label: newNode.label!, type: (newNode.type ?? 'observable') as CausalNode['type'],
|
||
x: newNode.x ?? 200, y: newNode.y ?? 200,
|
||
...(newNode.formula ? { formula: newNode.formula } : {}),
|
||
...(newNode.unit ? { unit: newNode.unit } : {}),
|
||
...(newNode.instrument ? { instrument: newNode.instrument } : {}),
|
||
}
|
||
setState(s => ({ ...s, nodes: [...s.nodes, n] }))
|
||
setNewNode({ type: 'observable', x: 200, y: 200 })
|
||
setAddNode(false)
|
||
autoDetectCoefs([...state.nodes, n], state.coefficients)
|
||
}
|
||
|
||
function updateNode(id: string, changes: Partial<CausalNode>) {
|
||
setState(s => ({ ...s, nodes: s.nodes.map(n => n.id === id ? { ...n, ...changes } : n) }))
|
||
}
|
||
|
||
function removeNode(id: string) {
|
||
setState(s => {
|
||
const im = { ...s.inputMapping }; delete im[id]
|
||
return {
|
||
...s,
|
||
nodes: s.nodes.filter(n => n.id !== id),
|
||
edges: s.edges.filter(e => e.from !== id && e.to !== id),
|
||
inputMapping: im,
|
||
}
|
||
})
|
||
if (selNode === id) setSelNode(null)
|
||
}
|
||
|
||
function addEdge() {
|
||
if (!newEdge.from || !newEdge.to) return
|
||
const e: CausalEdge = {
|
||
from: newEdge.from!, to: newEdge.to!,
|
||
style: newEdge.style ?? 'solid',
|
||
strength: newEdge.strength ?? 2,
|
||
sign: newEdge.sign ?? 'neutral',
|
||
...(newEdge.label ? { label: newEdge.label } : {}),
|
||
}
|
||
setState(s => ({ ...s, edges: [...s.edges, e] }))
|
||
setNewEdge({ style: 'solid', strength: 2, sign: 'positive' })
|
||
setAddEdge(false)
|
||
}
|
||
|
||
function removeEdge(i: number) {
|
||
setState(s => ({ ...s, edges: s.edges.filter((_, idx) => idx !== i) }))
|
||
if (editEdgeIdx === i) setEditEdge(null)
|
||
}
|
||
|
||
function startEditEdge(i: number) {
|
||
const e = state.edges[i]
|
||
setEditEdgeVal({ ...e })
|
||
setEditEdge(i)
|
||
}
|
||
|
||
function applyEdgeEdit() {
|
||
if (editEdgeIdx === null) return
|
||
setState(s => ({
|
||
...s,
|
||
edges: s.edges.map((e, i) => i === editEdgeIdx ? { ...e, ...editEdge } : e),
|
||
}))
|
||
setEditEdge(null)
|
||
}
|
||
|
||
function autoDetectCoefs(nodes: CausalNode[], existing: Record<string, Coefficient>) {
|
||
const pattern = /\{\{(\w+)\}\}/g
|
||
const found = new Set<string>()
|
||
nodes.forEach(n => { let m; while ((m = pattern.exec(n.formula ?? '')) !== null) found.add(m[1]) })
|
||
const newCoefs = { ...existing }
|
||
found.forEach(k => { if (!(k in newCoefs)) newCoefs[k] = { value: 1.0, calibrated: null, description: '' } })
|
||
setState(s => ({ ...s, coefficients: newCoefs }))
|
||
}
|
||
|
||
async function saveAsNew() {
|
||
setSaving(true); setMsg(null)
|
||
try {
|
||
const body = {
|
||
name: state.name, category: state.category, sub_type: state.subType,
|
||
description: state.description, instruments: state.instruments,
|
||
graph_json: graphJson,
|
||
ai_rationale: state.aiRationale,
|
||
}
|
||
const r = await api('/api/causal-lab/template', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
})
|
||
setMsg({ ok: true, text: `Template créé (id=${r.id})` })
|
||
setState(s => ({ ...s, templateId: r.id }))
|
||
api('/api/causal-lab/templates').then(setTemplates)
|
||
} catch (e: unknown) {
|
||
setMsg({ ok: false, text: String(e) })
|
||
} finally { setSaving(false) }
|
||
}
|
||
|
||
async function updateExisting() {
|
||
if (!state.templateId) return
|
||
setSaving(true); setMsg(null)
|
||
try {
|
||
await api(`/api/causal-lab/template/${state.templateId}`, {
|
||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: state.name, category: state.category, sub_type: state.subType,
|
||
description: state.description, instruments: state.instruments,
|
||
graph_json: graphJson,
|
||
ai_rationale: state.aiRationale,
|
||
}),
|
||
})
|
||
setMsg({ ok: true, text: 'Template mis à jour ✓' })
|
||
api('/api/causal-lab/templates').then(setTemplates)
|
||
} catch (e: unknown) {
|
||
setMsg({ ok: false, text: String(e) })
|
||
} finally { setSaving(false) }
|
||
}
|
||
|
||
const nodeIds = state.nodes.map(n => n.id)
|
||
const selectedNodeObj = selNode ? state.nodes.find(n => n.id === selNode) : null
|
||
|
||
return (
|
||
<div className="flex gap-4">
|
||
{/* Panneau gauche — formulaires */}
|
||
<div className="w-80 flex-shrink-0 space-y-3 overflow-y-auto max-h-[calc(100vh-200px)]">
|
||
|
||
{/* Charger existant */}
|
||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40 space-y-2">
|
||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Base de départ</div>
|
||
<div className="flex gap-2">
|
||
<select onChange={e => loadTemplate(Number(e.target.value))} defaultValue=""
|
||
className="flex-1 bg-dark-800 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-300">
|
||
<option value="">Charger un template existant…</option>
|
||
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||
</select>
|
||
<button onClick={resetNew} title="Nouveau vide"
|
||
className="shrink-0 px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-xs text-slate-300 whitespace-nowrap">
|
||
✕ Nouveau
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Métadonnées */}
|
||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40 space-y-2">
|
||
<div className="text-xs text-slate-400 font-semibold uppercase tracking-wider">Métadonnées</div>
|
||
{[
|
||
{ label: 'Nom', key: 'name' as const },
|
||
{ label: 'Description', key: 'description' as const },
|
||
].map(({ label, key }) => (
|
||
<div key={key}>
|
||
<label className="text-xs text-slate-500">{label}</label>
|
||
<input value={state[key] as string}
|
||
onChange={e => setState(s => ({ ...s, [key]: e.target.value }))}
|
||
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 mt-0.5" />
|
||
</div>
|
||
))}
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div>
|
||
<label className="text-xs text-slate-500">Catégorie</label>
|
||
<select value={state.category}
|
||
onChange={e => setState(s => ({ ...s, category: e.target.value }))}
|
||
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300 mt-0.5">
|
||
{Object.entries(CAT_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500">Sub-type</label>
|
||
<input value={state.subType}
|
||
onChange={e => setState(s => ({ ...s, subType: e.target.value }))}
|
||
className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 mt-0.5" />
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500">Instruments</label>
|
||
<div className="flex flex-wrap gap-1 mt-1">
|
||
{INSTRUMENTS_LIST.map(i => (
|
||
<button key={i} onClick={() => setState(s => ({
|
||
...s, instruments: s.instruments.includes(i)
|
||
? s.instruments.filter(x => x !== i)
|
||
: [...s.instruments, i],
|
||
}))}
|
||
className={clsx('text-xs px-2 py-0.5 rounded border',
|
||
state.instruments.includes(i)
|
||
? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/40'
|
||
: 'bg-dark-800 text-slate-500 border-slate-700')}>
|
||
{i}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Nœuds */}
|
||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider">
|
||
Nœuds ({state.nodes.length})
|
||
</span>
|
||
<button onClick={() => setAddNode(v => !v)}
|
||
className="flex items-center gap-1 text-xs px-2 py-1 bg-blue-700/40 hover:bg-blue-700 rounded text-blue-300 border border-blue-700/40">
|
||
<Plus className="w-3 h-3" /> Ajouter
|
||
</button>
|
||
</div>
|
||
|
||
{addNodeOpen && (
|
||
<div className="mb-2 p-2 bg-dark-800 rounded border border-slate-600 space-y-1.5 text-xs">
|
||
<div className="grid grid-cols-2 gap-1">
|
||
<div>
|
||
<label className="text-slate-500">ID</label>
|
||
<input placeholder="ex: ois_pricing"
|
||
value={newNode.id ?? ''} onChange={e => setNewNode(p => ({ ...p, id: e.target.value }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Type</label>
|
||
<select value={newNode.type} onChange={e => setNewNode(p => ({ ...p, type: e.target.value as CausalNode['type'] }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
{NODE_TYPES.map(t => <option key={t} value={t}>{NODE_TYPE_LABELS[t]}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Label</label>
|
||
<input placeholder="ex: Fed OIS / probabilités"
|
||
value={newNode.label ?? ''} onChange={e => setNewNode(p => ({ ...p, label: e.target.value }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Formule (optionnel)</label>
|
||
<input placeholder="ex: input_a * {{coef_x}}"
|
||
value={newNode.formula ?? ''} onChange={e => setNewNode(p => ({ ...p, formula: e.target.value }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5 font-mono" />
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-1">
|
||
<div>
|
||
<label className="text-slate-500">Unité</label>
|
||
<input value={newNode.unit ?? ''} onChange={e => setNewNode(p => ({ ...p, unit: e.target.value }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">X</label>
|
||
<input type="number" value={newNode.x ?? 200} onChange={e => setNewNode(p => ({ ...p, x: parseInt(e.target.value) }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Y</label>
|
||
<input type="number" value={newNode.y ?? 200} onChange={e => setNewNode(p => ({ ...p, y: parseInt(e.target.value) }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
</div>
|
||
<button onClick={addNode}
|
||
className="w-full py-1 bg-blue-600 hover:bg-blue-500 rounded text-xs text-white">
|
||
+ Ajouter le nœud
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-1 max-h-52 overflow-y-auto">
|
||
{state.nodes.map(n => (
|
||
<div key={n.id}
|
||
className={clsx('flex items-center gap-2 px-2 py-1.5 rounded border text-xs cursor-pointer',
|
||
selNode === n.id
|
||
? 'border-yellow-500/60 bg-yellow-900/10 text-yellow-200'
|
||
: 'border-slate-700/40 bg-dark-800/50 text-slate-300 hover:border-slate-500')}
|
||
onClick={() => setSelNode(selNode === n.id ? null : n.id)}>
|
||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: nodeColor(n.type) }} />
|
||
<span className="font-mono flex-1 truncate">{n.id}</span>
|
||
<span className="text-slate-500 truncate max-w-[80px]">{n.label}</span>
|
||
<button onClick={e => { e.stopPropagation(); removeNode(n.id) }}
|
||
className="text-slate-600 hover:text-red-400 flex-shrink-0">
|
||
<Trash2 className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
{state.nodes.length === 0 && (
|
||
<div className="text-slate-600 text-xs text-center py-3">Aucun nœud — cliquez "+ Ajouter"</div>
|
||
)}
|
||
</div>
|
||
|
||
{selNode && (
|
||
<div className="mt-2 p-2 bg-yellow-900/10 border border-yellow-700/30 rounded text-xs text-yellow-300">
|
||
<Info className="w-3 h-3 inline mr-1" />
|
||
Nœud <strong>{selNode}</strong> sélectionné — cliquez sur le graphe (droite) pour le repositionner
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Mapping observable */}
|
||
{selectedNodeObj?.type === 'observable' && selNode && (() => {
|
||
const mapping: InputMapping = state.inputMapping[selNode] || { source: 'user_input' }
|
||
const updateMapping = (changes: Partial<InputMapping>) =>
|
||
setState(s => ({
|
||
...s,
|
||
inputMapping: { ...s.inputMapping, [selNode]: { ...s.inputMapping[selNode], ...changes } },
|
||
}))
|
||
const clearMapping = () =>
|
||
setState(s => { const m = { ...s.inputMapping }; delete m[selNode]; return { ...s, inputMapping: m } })
|
||
const fieldOptions: Record<string, { key: string; label: string }[]> = {
|
||
market_watchlist: [{ key: 'close', label: 'Prix (close)' }, { key: 'change_pct', label: 'Variation %' }],
|
||
economic_events: [{ key: 'actual', label: 'Valeur actuelle' }, { key: 'surprise', label: 'Surprise (actual−forecast)' }],
|
||
ff_calendar: [{ key: 'actual', label: 'Valeur actuelle' }, { key: 'surprise', label: 'Surprise' }],
|
||
}
|
||
const dsKeys = mapping.source === 'market_watchlist' ? dataSources.prices
|
||
: mapping.source === 'economic_events' ? dataSources.macro_series
|
||
: mapping.source === 'ff_calendar' ? dataSources.ff_events : []
|
||
return (
|
||
<div className="bg-dark-700 rounded-lg p-3 border border-cyan-700/40 space-y-2">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs text-cyan-400 font-semibold uppercase tracking-wider">
|
||
Mapping — {selNode}
|
||
</span>
|
||
{mapping.source && mapping.source !== 'user_input' && (
|
||
<button onClick={clearMapping} className="text-xs text-slate-500 hover:text-red-400">Effacer</button>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500">Source</label>
|
||
<select value={mapping.source || 'user_input'}
|
||
onChange={e => updateMapping({ source: e.target.value as InputMapping['source'], key: '', field: 'close' })}
|
||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300">
|
||
<option value="user_input">Manuel (saisie Analyser)</option>
|
||
<option value="market_watchlist">Prix marché — yfinance</option>
|
||
<option value="economic_events">Série macro — BD</option>
|
||
<option value="ff_calendar">Calendrier Forex Factory</option>
|
||
</select>
|
||
</div>
|
||
{mapping.source && mapping.source !== 'user_input' && (<>
|
||
<div>
|
||
<label className="text-xs text-slate-500">Clé</label>
|
||
<input list={`ds-${selNode}`}
|
||
value={mapping.key || ''}
|
||
onChange={e => updateMapping({ key: e.target.value })}
|
||
placeholder={
|
||
mapping.source === 'market_watchlist' ? 'ex: EURUSD' :
|
||
mapping.source === 'economic_events' ? 'ex: NFP_US' : 'ex: Non-Farm Payrolls'
|
||
}
|
||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||
<datalist id={`ds-${selNode}`}>
|
||
{dsKeys.map(ds => <option key={ds.key} value={ds.key}>{ds.label}</option>)}
|
||
</datalist>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-slate-500">Champ</label>
|
||
<select value={mapping.field || (mapping.source === 'market_watchlist' ? 'close' : 'actual')}
|
||
onChange={e => updateMapping({ field: e.target.value })}
|
||
className="w-full mt-0.5 bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-300">
|
||
{(fieldOptions[mapping.source] || []).map(f => (
|
||
<option key={f.key} value={f.key}>{f.label}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</>)}
|
||
</div>
|
||
)
|
||
})()}
|
||
|
||
{/* Arêtes */}
|
||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider">
|
||
Arêtes ({state.edges.length})
|
||
</span>
|
||
<button onClick={() => setAddEdge(v => !v)} disabled={state.nodes.length < 2}
|
||
className="flex items-center gap-1 text-xs px-2 py-1 bg-purple-700/40 hover:bg-purple-700 disabled:opacity-40 rounded text-purple-300 border border-purple-700/40">
|
||
<Plus className="w-3 h-3" /> Ajouter
|
||
</button>
|
||
</div>
|
||
|
||
{addEdgeOpen && (
|
||
<div className="mb-2 p-2 bg-dark-800 rounded border border-slate-600 space-y-1.5 text-xs">
|
||
<div className="grid grid-cols-2 gap-1">
|
||
<div>
|
||
<label className="text-slate-500">De</label>
|
||
<select value={newEdge.from ?? ''} onChange={e => setNewEdge(p => ({ ...p, from: e.target.value }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
<option value="">— source —</option>
|
||
{nodeIds.map(id => <option key={id} value={id}>{id}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Vers</label>
|
||
<select value={newEdge.to ?? ''} onChange={e => setNewEdge(p => ({ ...p, to: e.target.value }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
<option value="">— cible —</option>
|
||
{nodeIds.map(id => <option key={id} value={id}>{id}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-3 gap-1">
|
||
<div>
|
||
<label className="text-slate-500">Style</label>
|
||
<select value={newEdge.style} onChange={e => setNewEdge(p => ({ ...p, style: e.target.value as 'solid' | 'dashed' }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
<option value="solid">Direct ──</option>
|
||
<option value="dashed">Indirect ╌╌</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Force</label>
|
||
<select value={newEdge.strength} onChange={e => setNewEdge(p => ({ ...p, strength: parseInt(e.target.value) as 1|2|3 }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
<option value={1}>1 — fin</option>
|
||
<option value={2}>2 — normal</option>
|
||
<option value={3}>3 — fort</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Signe</label>
|
||
<select value={newEdge.sign} onChange={e => setNewEdge(p => ({ ...p, sign: e.target.value as 'positive'|'negative'|'neutral' }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
<option value="positive">+ positif</option>
|
||
<option value="negative">− négatif</option>
|
||
<option value="neutral">∅ neutre</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Label</label>
|
||
<input placeholder="ex: canal court terme"
|
||
value={newEdge.label ?? ''} onChange={e => setNewEdge(p => ({ ...p, label: e.target.value }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-1">
|
||
<div>
|
||
<label className="text-slate-500" title="Délai en minutes (intraday 5m)">Lag (min)</label>
|
||
<input type="number" min={0} step={5} placeholder="0"
|
||
value={newEdge.lag_min ?? ''}
|
||
onChange={e => setNewEdge(p => ({ ...p, lag_min: e.target.value === '' ? undefined : parseInt(e.target.value) }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-amber-400" title="Délai en jours (mode journalier)">Lag (j)</label>
|
||
<input type="number" min={0} step={1} placeholder="0"
|
||
value={newEdge.lag_days ?? ''}
|
||
onChange={e => setNewEdge(p => ({ ...p, lag_days: e.target.value === '' ? undefined : parseInt(e.target.value) }))}
|
||
className="w-full bg-dark-700 border border-amber-600/40 rounded px-1.5 py-1 text-amber-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Diff. (min)</label>
|
||
<input type="number" min={0} step={15} placeholder="60"
|
||
value={newEdge.diffusion_min ?? ''}
|
||
onChange={e => setNewEdge(p => ({ ...p, diffusion_min: e.target.value === '' ? undefined : parseInt(e.target.value) }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Decay (j)</label>
|
||
<input type="number" min={0} step={1} placeholder="∞"
|
||
value={newEdge.decay_days ?? ''}
|
||
onChange={e => setNewEdge(p => ({ ...p, decay_days: e.target.value === '' ? null : parseFloat(e.target.value) }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
</div>
|
||
<button onClick={addEdge}
|
||
className="w-full py-1 bg-purple-600 hover:bg-purple-500 rounded text-xs text-white">
|
||
+ Ajouter l'arête
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-1 max-h-52 overflow-y-auto">
|
||
{state.edges.map((e, i) => (
|
||
<div key={i}>
|
||
<div className={clsx('flex items-center gap-2 px-2 py-1.5 rounded border text-xs cursor-pointer',
|
||
editEdgeIdx === i
|
||
? 'border-yellow-500/60 bg-yellow-900/10'
|
||
: 'border-slate-700/40 bg-dark-800/50 hover:border-slate-500')}
|
||
onClick={() => editEdgeIdx === i ? setEditEdge(null) : startEditEdge(i)}>
|
||
<div className="w-8 h-0 flex-shrink-0"
|
||
style={{ borderTop: `${edgeWidth(e.strength)}px ${e.style === 'dashed' ? 'dashed' : 'solid'} ${edgeColor(e.sign)}` }} />
|
||
<span className="font-mono text-slate-400 truncate flex-1">
|
||
{e.from} → {e.to}
|
||
</span>
|
||
{e.label && <span className="text-slate-500 truncate max-w-[60px]">{e.label}</span>}
|
||
<button onClick={ev => { ev.stopPropagation(); removeEdge(i) }}
|
||
className="text-slate-600 hover:text-red-400 flex-shrink-0">
|
||
<Trash2 className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
{editEdgeIdx === i && (
|
||
<div className="mt-1 p-2 bg-dark-800 rounded border border-yellow-700/30 space-y-1.5 text-xs">
|
||
<div className="grid grid-cols-3 gap-1">
|
||
<div>
|
||
<label className="text-slate-500">Style</label>
|
||
<select value={editEdge.style ?? e.style}
|
||
onChange={ev => setEditEdgeVal(p => ({ ...p, style: ev.target.value as 'solid'|'dashed' }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
<option value="solid">Direct ──</option>
|
||
<option value="dashed">Indirect ╌╌</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Force</label>
|
||
<select value={editEdge.strength ?? e.strength}
|
||
onChange={ev => setEditEdgeVal(p => ({ ...p, strength: parseInt(ev.target.value) as 1|2|3 }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
<option value={1}>1 — fin</option>
|
||
<option value={2}>2 — normal</option>
|
||
<option value={3}>3 — fort</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Signe</label>
|
||
<select value={editEdge.sign ?? e.sign}
|
||
onChange={ev => setEditEdgeVal(p => ({ ...p, sign: ev.target.value as 'positive'|'negative'|'neutral' }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-300 text-xs mt-0.5">
|
||
<option value="positive">+ positif</option>
|
||
<option value="negative">− négatif</option>
|
||
<option value="neutral">∅ neutre</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Label</label>
|
||
<input value={editEdge.label ?? e.label ?? ''}
|
||
onChange={ev => setEditEdgeVal(p => ({ ...p, label: ev.target.value }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-1">
|
||
<div>
|
||
<label className="text-slate-500" title="Délai en minutes (intraday 5m)">Lag (min)</label>
|
||
<input type="number" min={0} step={5}
|
||
value={editEdge.lag_min ?? e.lag_min ?? ''}
|
||
onChange={ev => setEditEdgeVal(p => ({ ...p, lag_min: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-amber-400" title="Délai en jours (mode journalier)">Lag (j)</label>
|
||
<input type="number" min={0} step={1}
|
||
value={editEdge.lag_days ?? e.lag_days ?? ''}
|
||
onChange={ev => setEditEdgeVal(p => ({ ...p, lag_days: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}
|
||
className="w-full bg-dark-700 border border-amber-600/40 rounded px-1.5 py-1 text-amber-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Diff. (min)</label>
|
||
<input type="number" min={0} step={15}
|
||
value={editEdge.diffusion_min ?? e.diffusion_min ?? ''}
|
||
onChange={ev => setEditEdgeVal(p => ({ ...p, diffusion_min: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
<div>
|
||
<label className="text-slate-500">Decay (j)</label>
|
||
<input type="number" min={0} step={1}
|
||
value={editEdge.decay_days ?? e.decay_days ?? ''}
|
||
onChange={ev => setEditEdgeVal(p => ({ ...p, decay_days: ev.target.value === '' ? null : parseFloat(ev.target.value) }))}
|
||
className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" />
|
||
</div>
|
||
</div>
|
||
<button onClick={applyEdgeEdit}
|
||
className="w-full py-1 bg-yellow-700 hover:bg-yellow-600 rounded text-xs text-white">
|
||
Appliquer
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
{state.edges.length === 0 && (
|
||
<div className="text-slate-600 text-xs text-center py-3">Aucune arête</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Coefficients */}
|
||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider">
|
||
Coefficients ({Object.keys(state.coefficients).length})
|
||
</span>
|
||
<button onClick={() => autoDetectCoefs(state.nodes, state.coefficients)}
|
||
className="text-xs px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-slate-300">
|
||
Détecter auto
|
||
</button>
|
||
</div>
|
||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||
{Object.entries(state.coefficients).map(([k, c]) => (
|
||
<div key={k} className="flex gap-2 items-center">
|
||
<span className="text-xs text-slate-400 font-mono flex-1 truncate">{k}</span>
|
||
<input type="number" step="0.001" value={c.value}
|
||
onChange={e => setState(s => ({
|
||
...s, coefficients: { ...s.coefficients, [k]: { ...c, value: parseFloat(e.target.value) || 0 } }
|
||
}))}
|
||
className="w-20 bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-right" />
|
||
<button onClick={() => setState(s => {
|
||
const cc = { ...s.coefficients }; delete cc[k]; return { ...s, coefficients: cc }
|
||
})} className="text-slate-600 hover:text-red-400">
|
||
<Trash2 className="w-3 h-3" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sauvegarde */}
|
||
<div className="space-y-2">
|
||
{state.templateId && (
|
||
<button onClick={updateExisting} disabled={saving || !state.name}
|
||
className="w-full flex items-center justify-center gap-2 py-2 bg-blue-700 hover:bg-blue-600 disabled:opacity-40 rounded text-xs font-medium text-white">
|
||
<Save className="w-3.5 h-3.5" />
|
||
{saving ? 'Sauvegarde…' : 'Mettre à jour le template'}
|
||
</button>
|
||
)}
|
||
<button onClick={saveAsNew} disabled={saving || !state.name}
|
||
className="w-full flex items-center justify-center gap-2 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded text-xs font-medium text-white">
|
||
<Save className="w-3.5 h-3.5" />
|
||
{saving ? 'Sauvegarde…' : 'Créer ce template (copie)'}
|
||
</button>
|
||
{msg && (
|
||
<div className={clsx('p-2 rounded text-xs', msg.ok ? 'bg-emerald-900/20 text-emerald-300' : 'bg-red-900/20 text-red-300')}>
|
||
{msg.text}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Panneau droit — SVG live */}
|
||
<div className="flex-1 space-y-3">
|
||
<div className="flex items-center gap-3 mb-1">
|
||
<span className="text-xs text-slate-500">
|
||
{selNode
|
||
? <span className="text-yellow-400">Cliquez sur le graphe pour repositionner <strong>{selNode}</strong></span>
|
||
: 'Cliquez sur un nœud dans le graphe pour le sélectionner et le repositionner'
|
||
}
|
||
</span>
|
||
</div>
|
||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||
<GraphSVG
|
||
graph={graphJson}
|
||
editorMode={true}
|
||
selectedNodeId={selNode}
|
||
onNodeClick={(id) => setSelNode(selNode === id ? null : id)}
|
||
onBgClick={handleBgClick}
|
||
/>
|
||
</div>
|
||
<GraphLegend />
|
||
|
||
{/* Grammar patch panel */}
|
||
<div className="bg-dark-700 rounded-lg p-4 border border-violet-900/40 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h4 className="text-slate-300 text-xs font-semibold uppercase tracking-wider flex items-center gap-2">
|
||
<Wand2 className="w-3.5 h-3.5 text-violet-400" /> Patch Grammaire
|
||
</h4>
|
||
<button onClick={() => setShowGrammarRef(v => !v)}
|
||
className="text-xs text-slate-600 hover:text-slate-400 flex items-center gap-1">
|
||
<Info className="w-3 h-3" /> {showGrammarRef ? 'Masquer' : 'Référence'}
|
||
</button>
|
||
</div>
|
||
|
||
{showGrammarRef && (
|
||
<pre className="text-[10px] font-mono text-slate-500 bg-dark-900/60 rounded p-2 overflow-x-auto leading-relaxed whitespace-pre">
|
||
{GRAMMAR_REFERENCE}
|
||
</pre>
|
||
)}
|
||
|
||
<textarea
|
||
value={grammarText}
|
||
onChange={e => { setGrammarText(e.target.value); setGrammarErrors([]); setGrammarApplied(0) }}
|
||
disabled={aiLoading}
|
||
placeholder={
|
||
"Écrivez la grammaire directement, ou décrivez en français puis cliquez « Compiler »\n\n" +
|
||
"+node id=risk_aversion label=\"Risk Aversion\" type=latent x=450 y=300\n" +
|
||
"+edge from=us_10y to=risk_aversion sign=- lag=2 decay=10\n" +
|
||
"~edge from=ois_pricing to=us_2y lag=3\n" +
|
||
"-edge from=rate_diff to=eurusd_pip"
|
||
}
|
||
rows={7}
|
||
className="w-full bg-dark-900/70 border border-slate-700 rounded px-3 py-2 text-xs font-mono text-slate-200 placeholder:text-slate-600 resize-none focus:border-violet-600 focus:outline-none"
|
||
/>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{/* Compile NL → grammar */}
|
||
<button
|
||
onClick={compileNlToGrammar}
|
||
disabled={aiLoading || !grammarText.trim() || !state.templateId}
|
||
title={!state.templateId ? "Sauvegardez d'abord le template" : undefined}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 disabled:opacity-40 rounded text-xs text-slate-300 border border-slate-600 transition-all">
|
||
{aiLoading
|
||
? <><RefreshCw className="w-3 h-3 animate-spin" /> Compilation…</>
|
||
: <><Brain className="w-3 h-3 text-violet-400" /> NL → Grammaire</>}
|
||
</button>
|
||
|
||
{/* Apply grammar locally */}
|
||
<button
|
||
onClick={applyGrammar}
|
||
disabled={!grammarText.trim() || aiLoading}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded text-xs font-medium text-white transition-all">
|
||
<Zap className="w-3 h-3" /> Appliquer
|
||
</button>
|
||
|
||
{/* Live parse count */}
|
||
{grammarText.trim() && (() => {
|
||
const { ops } = parseGrammar(grammarText)
|
||
return ops.length > 0 ? (
|
||
<span className="text-xs text-slate-500">{ops.length} opération{ops.length > 1 ? 's' : ''} détectée{ops.length > 1 ? 's' : ''}</span>
|
||
) : null
|
||
})()}
|
||
|
||
{grammarApplied > 0 && (
|
||
<span className="text-xs text-emerald-500">✓ {grammarApplied} appliquée{grammarApplied > 1 ? 's' : ''}</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Errors */}
|
||
{grammarErrors.length > 0 && (
|
||
<div className="p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300 space-y-0.5">
|
||
{grammarErrors.map((e, i) => <div key={i}>⚠ {e}</div>)}
|
||
</div>
|
||
)}
|
||
{aiError && (
|
||
<div className="p-2 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">
|
||
{aiError}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Tab: Analyser ─────────────────────────────────────────────────────────────
|
||
|
||
function TabAnalyze({ initialEventId }: { initialEventId?: number | null }) {
|
||
const [events, setEvents] = useState<MarketEvent[]>([])
|
||
const [templates, setTemplates] = useState<Template[]>([])
|
||
const [selEvent, setSelEvent] = useState<MarketEvent | null>(null)
|
||
const [selTmpl, setSelTmpl] = useState<number>(0)
|
||
const [instrument, setInstr] = useState('EURUSD')
|
||
const [manualInputs, setManual] = useState<Record<string, number>>({})
|
||
const [result, setResult] = useState<AnalysisResult | null>(null)
|
||
const [rec, setRec] = useState<Recommendation | null>(null)
|
||
const [loadingRec, setLoadRec] = useState(false)
|
||
const [loadingAn, setLoadAn] = useState(false)
|
||
const [q, setQ] = useState('')
|
||
const [catF, setCatF] = useState('')
|
||
const [error, setError] = useState('')
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
api(`/api/causal-lab/market-events?limit=200${catF ? `&category=${catF}` : ''}${q ? `&q=${encodeURIComponent(q)}` : ''}`),
|
||
api('/api/causal-lab/templates'),
|
||
]).then(([evs, tmps]) => { setEvents(evs); setTemplates(tmps) })
|
||
}, [catF, q])
|
||
|
||
useEffect(() => {
|
||
if (!initialEventId || !events.length || selEvent) return
|
||
const found = events.find(e => e.id === initialEventId)
|
||
if (found) setSelEvent(found)
|
||
}, [events, initialEventId])
|
||
|
||
const currentTemplate = templates.find(t => t.id === selTmpl)
|
||
const manualNodes = currentTemplate?.graph_json?.input_mapping
|
||
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
|
||
!c.source || c.source === 'user_input')
|
||
: []
|
||
const autoNodes = currentTemplate?.graph_json?.input_mapping
|
||
? Object.entries(currentTemplate.graph_json.input_mapping).filter(([, c]) =>
|
||
c.source && c.source !== 'user_input')
|
||
: []
|
||
|
||
async function recommend() {
|
||
if (!selEvent) return
|
||
setLoadRec(true); setRec(null); setError('')
|
||
try {
|
||
const r = await api('/api/causal-lab/recommend', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ market_event_id: selEvent.id }),
|
||
})
|
||
setRec(r.recommendation)
|
||
if (r.recommendation?.template_id) setSelTmpl(r.recommendation.template_id)
|
||
} catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) }
|
||
finally { setLoadRec(false) }
|
||
}
|
||
|
||
async function analyze() {
|
||
if (!selEvent || !selTmpl) return
|
||
setLoadAn(true); setResult(null); setError('')
|
||
try {
|
||
const r = await api('/api/causal-lab/analyze', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ market_event_id: selEvent.id, template_id: selTmpl, instrument, inputs: manualInputs, coef_overrides: {} }),
|
||
})
|
||
setResult(r)
|
||
} catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) }
|
||
finally { setLoadAn(false) }
|
||
}
|
||
|
||
return (
|
||
<div className="flex gap-4">
|
||
<div className="w-72 flex-shrink-0 space-y-3">
|
||
<div className="flex gap-2">
|
||
<div className="relative flex-1">
|
||
<Search className="w-3.5 h-3.5 absolute left-2 top-2 text-slate-500" />
|
||
<input value={q} onChange={e => setQ(e.target.value)} placeholder="Rechercher…"
|
||
className="w-full bg-dark-700 border border-slate-700 rounded pl-7 pr-2 py-1.5 text-xs text-slate-300" />
|
||
</div>
|
||
<select value={catF} onChange={e => setCatF(e.target.value)}
|
||
className="bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-xs text-slate-300">
|
||
<option value="">Tous</option>
|
||
<option value="event_calendar">Calendrier éco</option>
|
||
<option value="fundamental">Fondamental</option>
|
||
<option value="geopolitical">Géopolitique</option>
|
||
<option value="report">Report</option>
|
||
<option value="sentiment">Sentiment</option>
|
||
<option value="technical">Technique</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="space-y-1 overflow-y-auto max-h-60 border border-slate-700/30 rounded-lg p-1">
|
||
{events.length === 0
|
||
? <div className="text-slate-600 text-xs p-3 text-center">Aucun événement</div>
|
||
: events.map(ev => (
|
||
<button key={ev.id} onClick={() => { setSelEvent(ev); setResult(null); setRec(null) }}
|
||
className={clsx('w-full text-left px-3 py-2 rounded border text-xs transition-all',
|
||
selEvent?.id === ev.id
|
||
? 'bg-blue-900/30 border-blue-500/50 text-blue-200'
|
||
: 'bg-dark-700 border-slate-700/50 text-slate-300 hover:border-slate-500')}>
|
||
<div className="font-medium truncate">{ev.name}</div>
|
||
<div className="text-slate-500 flex justify-between">
|
||
<span>{ev.start_date?.slice(0, 10)}</span>
|
||
{ev.activation_score != null && (
|
||
<span className={clsx('font-semibold',
|
||
ev.activation_score >= 0.7 ? 'text-emerald-400' :
|
||
ev.activation_score >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
|
||
{(ev.activation_score * 100).toFixed(0)}%
|
||
</span>
|
||
)}
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<select value={selTmpl} onChange={e => setSelTmpl(Number(e.target.value))}
|
||
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-2 text-xs text-slate-300">
|
||
<option value={0}>— Choisir un template —</option>
|
||
{templates.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||
</select>
|
||
|
||
<select value={instrument} onChange={e => setInstr(e.target.value)}
|
||
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-2 text-xs text-slate-300">
|
||
{['EURUSD', 'XAUUSD', 'SP500', 'BRENT'].map(i => <option key={i} value={i}>{i}</option>)}
|
||
</select>
|
||
|
||
{autoNodes.length > 0 && (
|
||
<div className="bg-dark-800/60 rounded p-3 space-y-1.5 border border-cyan-700/20">
|
||
<div className="text-xs text-cyan-500 font-semibold flex items-center gap-1.5">
|
||
<Zap className="w-3 h-3" /> Auto-récupéré depuis BD
|
||
</div>
|
||
{autoNodes.map(([id, cfg]) => (
|
||
<div key={id} className="flex items-center gap-2 text-xs">
|
||
<span className="text-slate-500 flex-1 capitalize">{id.replace(/_/g, ' ')}</span>
|
||
<span className="text-cyan-400/70 text-xs font-mono">{cfg.key ?? cfg.source}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{manualNodes.length > 0 && (
|
||
<div className="bg-dark-800 rounded p-3 space-y-2 border border-slate-700/30">
|
||
<div className="text-xs text-slate-400 font-semibold">Inputs manuels</div>
|
||
{manualNodes.map(([id, cfg]) => {
|
||
const range = cfg.range as [number, number] | undefined
|
||
return (
|
||
<div key={id} className="flex items-center gap-2">
|
||
<span className="text-xs text-slate-400 flex-1 capitalize">{id.replace(/_/g, ' ')}</span>
|
||
<input type="range" min={range?.[0] ?? -3} max={range?.[1] ?? 3} step={0.5}
|
||
value={manualInputs[id] ?? 0}
|
||
onChange={e => setManual(p => ({ ...p, [id]: parseFloat(e.target.value) }))}
|
||
className="w-24" />
|
||
<span className="text-xs text-slate-300 w-8 text-right">
|
||
{(manualInputs[id] ?? 0) > 0 ? '+' : ''}{manualInputs[id] ?? 0}
|
||
</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<button onClick={recommend} disabled={!selEvent || loadingRec}
|
||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-purple-700/50 hover:bg-purple-700 disabled:opacity-40 rounded text-xs font-medium text-purple-200 border border-purple-700/40">
|
||
<Brain className="w-3.5 h-3.5" />
|
||
{loadingRec ? 'GPT-4o analyse…' : 'Recommandation GPT-4o'}
|
||
</button>
|
||
<button onClick={analyze} disabled={!selEvent || !selTmpl || loadingAn}
|
||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 rounded text-xs font-medium text-white">
|
||
<Zap className="w-3.5 h-3.5" />
|
||
{loadingAn ? 'Analyse…' : 'Lancer l\'analyse'}
|
||
</button>
|
||
{error && <div className="p-3 bg-red-900/20 border border-red-700/40 rounded text-xs text-red-300">{error}</div>}
|
||
</div>
|
||
|
||
<div className="flex-1 space-y-4 min-w-0">
|
||
{rec && (
|
||
<div className="bg-purple-900/10 border border-purple-700/30 rounded-lg p-4">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<Brain className="w-4 h-4 text-purple-400" />
|
||
<span className="text-sm font-semibold text-purple-200">Recommandation GPT-4o</span>
|
||
{rec.confidence != null && (
|
||
<span className="ml-auto text-xs text-purple-400 font-bold">
|
||
{(rec.confidence * 100).toFixed(0)}% confiance
|
||
</span>
|
||
)}
|
||
</div>
|
||
{rec.error
|
||
? <div className="text-red-400 text-xs">{rec.error}</div>
|
||
: <>
|
||
<div className="text-sm text-white font-medium mb-1">Template : {rec.template_name}</div>
|
||
<p className="text-xs text-purple-200/80">{rec.rationale}</p>
|
||
{rec.notes && <p className="text-xs text-slate-400 mt-1">{rec.notes}</p>}
|
||
{Object.keys(rec.coefficient_suggestions || {}).length > 0 && (
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{Object.entries(rec.coefficient_suggestions).map(([k, v]) => (
|
||
<span key={k} className="text-xs px-2 py-0.5 bg-purple-900/30 text-purple-300 rounded border border-purple-700/30">
|
||
{k} = {v}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
}
|
||
</div>
|
||
)}
|
||
|
||
{result && (
|
||
<>
|
||
{Object.keys(result.inputs).length > 0 && (
|
||
<div className="bg-dark-700 rounded-lg p-3 border border-slate-700/40">
|
||
<div className="text-xs text-slate-500 font-semibold uppercase tracking-wider mb-2">Inputs utilisés</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{Object.entries(result.inputs).map(([k, v]) => {
|
||
const isAuto = autoNodes.some(([id]) => id === k)
|
||
return (
|
||
<span key={k} className={clsx('text-xs px-2 py-0.5 rounded border font-mono',
|
||
isAuto
|
||
? 'bg-cyan-900/20 text-cyan-300 border-cyan-700/30'
|
||
: 'bg-slate-800 text-slate-300 border-slate-700/40')}>
|
||
{k}: {typeof v === 'number' ? (v > 0 ? '+' : '') + v.toFixed(3) : String(v)}
|
||
{isAuto && <span className="ml-1 text-cyan-600 text-xs">auto</span>}
|
||
</span>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<h3 className="text-white font-semibold text-sm">{result.event.name}</h3>
|
||
<span className={clsx('text-sm font-bold px-3 py-1 rounded',
|
||
result.activation.score == null ? 'text-slate-400 bg-slate-800' :
|
||
result.activation.score >= 0.7 ? 'text-emerald-400 bg-emerald-900/30' :
|
||
result.activation.score >= 0.4 ? 'text-yellow-400 bg-yellow-900/30' :
|
||
'text-red-400 bg-red-900/30')}>
|
||
{result.activation.score != null ? `${(result.activation.score * 100).toFixed(0)}% activation` : 'N/A'}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-4 gap-3 mb-3">
|
||
{[
|
||
{ label: 'Pré-drift', val: fmtPips(result.drift?.pre_pips) },
|
||
{ label: 'Réel post', val: fmtPips(result.drift?.post_pips) },
|
||
{ label: 'Mode prix', val: result.prices_mode },
|
||
{ label: 'Fuite info', val: result.drift?.leak ?? '—' },
|
||
{ label: 'Lag mesuré', val: result.effective_lag_min != null ? `${result.effective_lag_min}min` : '0min' },
|
||
].map(m => (
|
||
<div key={m.label} className="bg-dark-800 rounded p-3 text-center">
|
||
<div className="text-xs text-slate-500">{m.label}</div>
|
||
<div className="text-sm font-bold text-slate-200 mt-0.5">{m.val}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
{Object.entries(result.activation.nodes).map(([id, node]) => {
|
||
const Icon = node.status === 'correct' ? CheckCircle : node.status === 'wrong' ? XCircle : Minus
|
||
const col =
|
||
node.status === 'correct' ? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/10' :
|
||
node.status === 'wrong' ? 'text-red-400 border-red-700/40 bg-red-900/10' :
|
||
'text-slate-500 border-slate-700/40 bg-slate-800/30'
|
||
return (
|
||
<div key={id} className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded border text-xs', col)}>
|
||
<Icon className="w-3.5 h-3.5" />
|
||
<span>{node.label}</span>
|
||
{node.pred != null && (
|
||
<span className="opacity-70">
|
||
P:{node.pred > 0 ? '+' : ''}{node.pred.toFixed(1)} / A:{node.act != null ? (node.act > 0 ? '+' : '') + node.act.toFixed(1) : '—'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="bg-dark-700 rounded-lg p-4 border border-slate-700/40">
|
||
<h4 className="text-slate-300 text-xs font-semibold mb-3 uppercase tracking-wider">Graphe — valeurs calculées</h4>
|
||
{currentTemplate && (
|
||
<GraphSVG graph={currentTemplate.graph_json}
|
||
nodeValues={result.node_values}
|
||
activationNodes={result.activation.nodes} />
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{(loadingAn || loadingRec) && (
|
||
<div className="flex items-center justify-center h-48 text-slate-500 gap-3">
|
||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||
<span className="text-sm">{loadingAn ? 'Récupération des prix (yfinance)…' : 'GPT-4o en cours…'}</span>
|
||
</div>
|
||
)}
|
||
{!result && !loadingAn && !loadingRec && !rec && (
|
||
<div className="flex flex-col items-center justify-center h-64 text-slate-600 space-y-3">
|
||
<FlaskConical className="w-12 h-12 opacity-30" />
|
||
<div className="text-sm text-center">Sélectionnez un événement, choisissez un template et lancez l'analyse</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Tab: Calibration ──────────────────────────────────────────────────────────
|
||
|
||
function TabCalibration() {
|
||
const [data, setData] = useState<CalibRow[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
useEffect(() => {
|
||
api('/api/causal-lab/calibration').then(setData).finally(() => setLoading(false))
|
||
}, [])
|
||
|
||
if (loading) return <div className="text-slate-500 text-sm p-6">Chargement…</div>
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="text-xs text-slate-500 flex items-center gap-2">
|
||
<AlertCircle className="w-3.5 h-3.5" />
|
||
Statistiques agrégées par template. Le ratio pred/réel converge vers 1.0 quand les coefficients sont bien calibrés.
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b border-slate-700/50">
|
||
{['Template', 'Catégorie', 'Nb analyses', 'Activation moy.', 'Pred. moy.', 'Réel moy.', 'Ratio'].map(h => (
|
||
<th key={h} className="px-3 py-2 text-left text-slate-500 uppercase tracking-wider font-medium">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.map(row => {
|
||
const calib = row.calibration || {}
|
||
const instKey = Object.keys(calib).find(k => !['n_events', 'avg_activation', 'last_analyzed'].includes(k))
|
||
const instData = instKey ? calib[instKey] : null
|
||
return (
|
||
<tr key={row.id} className="border-b border-slate-800/50 hover:bg-dark-700/30 transition-colors">
|
||
<td className="px-3 py-2.5 text-slate-200 font-medium">{row.name}</td>
|
||
<td className="px-3 py-2.5 text-slate-400">{CAT_LABELS[row.category] ?? row.category}</td>
|
||
<td className="px-3 py-2.5 text-slate-300">{row.n_analyses}</td>
|
||
<td className="px-3 py-2.5">
|
||
{row.avg_activation != null
|
||
? <span className={clsx('font-bold',
|
||
row.avg_activation >= 0.7 ? 'text-emerald-400' :
|
||
row.avg_activation >= 0.4 ? 'text-yellow-400' : 'text-red-400')}>
|
||
{(row.avg_activation * 100).toFixed(0)}%
|
||
</span>
|
||
: <span className="text-slate-600">—</span>}
|
||
</td>
|
||
<td className="px-3 py-2.5 text-slate-300">{instData?.avg_pred?.toFixed(1) ?? '—'}</td>
|
||
<td className="px-3 py-2.5 text-slate-300">{instData?.avg_actual?.toFixed(1) ?? '—'}</td>
|
||
<td className="px-3 py-2.5">
|
||
{instData?.coef_ratio != null
|
||
? <span className={clsx('font-bold',
|
||
Math.abs(instData.coef_ratio - 1) < 0.2 ? 'text-emerald-400' :
|
||
Math.abs(instData.coef_ratio - 1) < 0.5 ? 'text-yellow-400' : 'text-red-400')}>
|
||
{instData.coef_ratio.toFixed(2)}x
|
||
</span>
|
||
: <span className="text-slate-600">—</span>}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
{data.length === 0 && (
|
||
<div className="text-center py-12 text-slate-600 text-sm">
|
||
Aucune analyse effectuée. Lancez des analyses dans l'onglet "Analyser".
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Page principale ───────────────────────────────────────────────────────────
|
||
|
||
const TABS = [
|
||
{ id: 'library', label: 'Bibliothèque', icon: Library },
|
||
{ id: 'editor', label: 'Editeur', icon: Edit3 },
|
||
{ id: 'analyze', label: 'Analyser', icon: FlaskConical },
|
||
{ id: 'calib', label: 'Calibration', icon: BarChart3 },
|
||
]
|
||
|
||
export default function CausalLab() {
|
||
const [searchParams] = useSearchParams()
|
||
const initialTemplateId = searchParams.get('template') ? parseInt(searchParams.get('template')!) : null
|
||
const initialEventId = searchParams.get('event') ? parseInt(searchParams.get('event')!) : null
|
||
const [tab, setTab] = useState(initialEventId ? 'analyze' : 'library')
|
||
const [editorInitId, setEditorInitId] = useState<number | null>(null)
|
||
|
||
function openInEditor(id: number) {
|
||
setEditorInitId(id)
|
||
setTab('editor')
|
||
}
|
||
|
||
return (
|
||
<div className="p-6 min-h-screen">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<FlaskConical className="w-6 h-6 text-blue-400" />
|
||
<div>
|
||
<h1 className="text-xl font-bold text-white">Lab Causal</h1>
|
||
<p className="text-slate-400 text-xs mt-0.5">
|
||
Bibliothèque · Editeur visuel · Analyse empirique · Calibration IA
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-1 mb-6 bg-dark-800 p-1 rounded-lg w-fit border border-slate-700/30">
|
||
{TABS.map(({ id, label, icon: Icon }) => (
|
||
<button key={id} onClick={() => setTab(id)}
|
||
className={clsx('flex items-center gap-2 px-4 py-2 rounded text-sm font-medium transition-all',
|
||
tab === id ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-slate-200')}>
|
||
<Icon className="w-4 h-4" />
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="bg-dark-800 rounded-xl p-5 border border-slate-700/30">
|
||
{tab === 'library' && <TabLibrary initialTemplateId={initialTemplateId} onOpenEditor={openInEditor} />}
|
||
{tab === 'editor' && <TabEditor initialId={editorInitId} />}
|
||
{tab === 'analyze' && <TabAnalyze initialEventId={initialEventId} />}
|
||
{tab === 'calib' && <TabCalibration />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|