feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 17:20:22 +02:00
parent 2bb8109eb4
commit ff551deecd
2 changed files with 203 additions and 93 deletions

View File

@@ -73,6 +73,7 @@ interface ModelState {
output_node: string
regime: RegimeInfo
event_details: Record<string, EventDetail[]>
col_labels?: string[]
}
interface ActiveEvent {
@@ -463,7 +464,7 @@ function NodeEditModal({ node, instrument, onClose, onSaved }: {
// ── DAG View ──────────────────────────────────────────────────────────────────
const COL_LABELS = ['Events actifs', 'Variables manuelles', 'Couches intermédiaires', 'Résultat net']
const DEFAULT_COL_LABELS = ['Events actifs', 'Variables manuelles', 'Couches intermédiaires', 'Résultat net']
function NodeCard({ node, onEdit, eventDetails }: {
node: ModelNode; onEdit: (n: ModelNode) => void
@@ -551,9 +552,10 @@ interface EdgeDraw {
fromType: NodeType
}
function DagView({ nodes, instrument, onEdit, eventDetails }: {
function DagView({ nodes, instrument, onEdit, eventDetails, colLabels }: {
nodes: ModelNode[]; instrument: string; onEdit: (n: ModelNode) => void
eventDetails: Record<string, EventDetail[]>
colLabels?: string[]
}) {
const wrapperRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
@@ -561,8 +563,26 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
const [hoveredId, setHoveredId] = useState<string | null>(null)
const [svgH, setSvgH] = useState(600)
const cols: ModelNode[][] = [[], [], [], []]
for (const n of nodes) cols[Math.min(n.display_col ?? 0, 3)].push(n)
// Separate hidden nodes (display_col < 0 = event chocs) from visible nodes
const hiddenEvents = useMemo(() => nodes.filter(n => (n.display_col ?? 0) < 0), [nodes])
const visibleNodes = useMemo(() => nodes.filter(n => (n.display_col ?? 0) >= 0), [nodes])
// Build dynamic columns from visible nodes
const maxCol = useMemo(() =>
visibleNodes.reduce((m, n) => Math.max(m, n.display_col ?? 0), 0), [visibleNodes])
const cols = useMemo(() => {
const c: ModelNode[][] = Array.from({ length: maxCol + 1 }, () => [])
for (const n of visibleNodes) c[n.display_col ?? 0].push(n)
return c
}, [visibleNodes, maxCol])
const effectiveColLabels = useMemo(() => {
if (colLabels && colLabels.length >= cols.length) return colLabels
// Fall back: fill with defaults then generic labels
const base = [...DEFAULT_COL_LABELS]
while (base.length <= maxCol) base.push(`Couche ${base.length}`)
return base
}, [colLabels, cols.length, maxCol])
const nodeMap = useMemo(() => {
const m = new Map<string, ModelNode>()
@@ -575,8 +595,9 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
for (const node of nodes) {
if (!node.formula) continue
for (const term of node.formula.split('+')) {
const t = term.trim()
const m1 = t.match(/^([\d.+-]+)\s*\*\s*(\w+)$/)
const t = term.trim()
// Match: optional_sign digit.digit * identifier (e.g. "0.55*ecb_rate" or "0.04*usd_demand")
const m1 = t.match(/^(-?[\d.]+)\s*\*\s*(\w+)$/)
if (m1) {
result.push({ from: m1[2], to: node.id, coeff: parseFloat(m1[1]) })
} else if (/^\w+$/.test(t)) {
@@ -605,17 +626,8 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
const fromNode = nodeMap.get(conn.from)
const fromType = fromNode?.node_type ?? 'input_event'
// Weight to display on edge:
// • input_manual → intermediate: coefficient_to_pips of the source node
// • intermediate → output: formula coefficient (regime-adjusted)
// • input_event → *: nothing (already in pips)
let displayWeight: number | null = null
if (fromType === 'input_manual') {
const c = fromNode?.coefficient_to_pips ?? 1
if (c !== 0) displayWeight = c
} else if (fromType === 'intermediate' && conn.coeff !== 1.0) {
displayWeight = conn.coeff
}
// Weight to display on edge = formula coefficient (always, if not 1.0)
const displayWeight: number | null = conn.coeff !== 1.0 ? conn.coeff : null
edges.push({
x1: fr.right - rect0.left + sL,
@@ -642,9 +654,9 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
}, [recompute])
function edgeColor(e: EdgeDraw): string {
if (e.fromType === 'input_event') return '#38bdf8'
if (e.fromType === 'input_manual') return '#a78bfa'
return '#f59e0b'
if (e.fromType === 'intermediate') return '#f59e0b'
return '#38bdf8'
}
return (
@@ -656,38 +668,32 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
aria-hidden="true"
>
{svgEdges.map(e => {
const isHov = hoveredId === e.fromId || hoveredId === e.toId
const isToOutput = e.fromType === 'intermediate'
// Output edges always visible; input edges ghosted unless hovered
const opacity = isHov ? 0.90 : isToOutput ? 0.60 : 0.18
const color = edgeColor(e)
const sw = isHov ? 2 : isToOutput ? 1.5 : 1
const dx = Math.min(52, (e.x2 - e.x1) * 0.38)
const curve = `M ${e.x1} ${e.y1} C ${e.x1+dx} ${e.y1}, ${e.x2-dx} ${e.y2}, ${e.x2} ${e.y2}`
const mx = (e.x1 + e.x2) / 2
const my = (e.y1 + e.y2) / 2
// Show weight: always on output edges, on hover for input edges
const showWeight = e.displayWeight !== null && (isToOutput || isHov)
const wLabel = e.displayWeight !== null
? `w:${Math.abs(e.displayWeight) >= 10 ? Math.round(e.displayWeight) : e.displayWeight.toFixed(1)}`
const isHov = hoveredId === e.fromId || hoveredId === e.toId
const isLeafEdge = e.fromType === 'input_manual'
const color = edgeColor(e)
const sw = isHov ? 2.5 : isLeafEdge ? 1 : 1.5
const opacity = isHov ? 0.95 : isLeafEdge ? 0.25 : 0.65
const dx = Math.min(60, (e.x2 - e.x1) * 0.35)
const curve = `M ${e.x1} ${e.y1} C ${e.x1+dx} ${e.y1}, ${e.x2-dx} ${e.y2}, ${e.x2} ${e.y2}`
const mx = (e.x1 + e.x2) / 2
const my = (e.y1 + e.y2) / 2
const showWeight = e.displayWeight !== null && (!isLeafEdge || isHov)
const wLabel = e.displayWeight !== null
? (Math.abs(e.displayWeight) >= 10
? `${e.displayWeight > 0 ? '' : ''}${Math.round(Math.abs(e.displayWeight))}`
: `${e.displayWeight.toFixed(2)}`)
: ''
return (
<g key={e.key} opacity={opacity}>
<path d={curve} fill="none" stroke={color} strokeWidth={sw}/>
{/* Arrowhead */}
<polygon
points={`${e.x2},${e.y2} ${e.x2-5},${e.y2-2.5} ${e.x2-5},${e.y2+2.5}`}
fill={color} fillOpacity={0.8}
fill={color} fillOpacity={0.9}
/>
{/* Weight label */}
{showWeight && (
<text
x={mx} y={my - 5}
fill={color} fontSize="8" fontFamily="monospace"
textAnchor="middle" dominantBaseline="middle"
style={{ userSelect: 'none' }}
>
<text x={mx} y={my - 5} fill={color} fontSize="8" fontFamily="monospace"
textAnchor="middle" dominantBaseline="middle" style={{ userSelect:'none' }}>
{wLabel}
</text>
)}
@@ -696,14 +702,53 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
})}
</svg>
{/* Grille — z:10 */}
<div className="grid grid-cols-4 gap-x-4 min-w-[820px]" style={{ position: 'relative', zIndex: 10 }}>
{COL_LABELS.map((lbl, ci) => (
{/* Chocs CT (événements cachés du DAG) */}
{hiddenEvents.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-3 pb-2 border-b border-slate-700/20">
<span className="text-xs text-slate-600 uppercase tracking-wider self-center mr-1">Chocs CT</span>
{hiddenEvents.map(ev => {
const v = ev.pip_contribution
const hasVal = v !== 0 || ev.baseline_value
return (
<div
key={ev.id}
className={clsx(
'px-2 py-1 rounded border text-xs cursor-pointer transition-colors',
hasVal
? 'border-sky-700/50 bg-sky-900/20 text-sky-300'
: 'border-slate-700/30 bg-dark-800/30 text-slate-600',
)}
onClick={() => onEdit(ev)}
title={ev.description}
>
<span className="font-medium">{ev.label}</span>
{hasVal && (
<span className={clsx('ml-1 font-mono font-bold', pipColor(v))}>
{v > 0 ? '+' : ''}{fmt(v)}p
</span>
)}
</div>
)
})}
</div>
)}
{/* Grille dynamique — z:10 */}
<div
className="grid gap-x-3"
style={{
gridTemplateColumns: `repeat(${cols.length}, minmax(130px, 1fr))`,
minWidth: `${cols.length * 145}px`,
position: 'relative',
zIndex: 10,
}}
>
{effectiveColLabels.slice(0, cols.length).map((lbl, ci) => (
<div key={ci}>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 pb-1 border-b border-slate-700/30">
{lbl} <span className="font-normal normal-case text-slate-600">({cols[ci].length})</span>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 pb-1 border-b border-slate-700/30 truncate">
{lbl} <span className="font-normal normal-case text-slate-600">({cols[ci]?.length ?? 0})</span>
</div>
{cols[ci].map(n => (
{(cols[ci] ?? []).map(n => (
<div
key={n.id}
ref={el => { if (el) nodeRefs.current.set(n.id, el); else nodeRefs.current.delete(n.id) }}
@@ -719,17 +764,14 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
{/* Légende */}
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-slate-700/20 flex-wrap text-xs">
<div className="flex items-center gap-1 text-sky-400">
<span className="w-4 h-px bg-sky-400 inline-block"/> Events → couches
</div>
<div className="flex items-center gap-1 text-violet-400">
<span className="w-4 h-px bg-violet-400 inline-block"/> Manuel → couches <span className="text-slate-600 ml-0.5">(w: au survol)</span>
<span className="w-4 h-px bg-violet-400 inline-block"/> Macro → dérivé <span className="text-slate-600 ml-0.5">(w: survol)</span>
</div>
<div className="flex items-center gap-1 text-amber-400">
<span className="w-4 h-px bg-amber-400 inline-block"/> Couches → output <span className="text-slate-600 ml-0.5">(w: toujours)</span>
<span className="w-4 h-px bg-amber-400 inline-block"/> Dérivé → agrégat <span className="text-slate-600 ml-0.5">(w: visible)</span>
</div>
<div className="ml-auto flex items-center gap-1 text-slate-500">
<Edit3 className="w-3 h-3"/> Cliquer nœud pour éditer
<Edit3 className="w-3 h-3"/> Cliquer nœud pour éditer · <span className="text-sky-400">Chocs CT</span> = chocs court terme additifs
</div>
</div>
</div>
@@ -2442,7 +2484,7 @@ export default function InstrumentModels() {
{/* Main content */}
{state && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
{view === 'dag' && <DagView nodes={state.nodes} instrument={instrument} onEdit={setEditNode} eventDetails={state.event_details || {}}/>}
{view === 'dag' && <DagView nodes={state.nodes} instrument={instrument} onEdit={setEditNode} eventDetails={state.event_details || {}} colLabels={state.col_labels}/>}
{view === 'table' && <TableView nodes={state.nodes} onEdit={setEditNode}/>}
{view === 'timeline' && <TimelineView instrument={instrument}/>}
{view === 'calibration' && <CalibrationView instrument={instrument} eventDetails={state.event_details || {}}/>}