feat: instrument analysis

This commit is contained in:
OpenSquared
2026-06-28 16:36:28 +02:00
parent 0693d41d91
commit e4e17330b4
6 changed files with 715 additions and 649 deletions

View File

@@ -771,99 +771,246 @@ function EventTimeline({
)
}
// ── EventGraphCards ───────────────────────────────────────────────────────────
// ── CausalFrise ───────────────────────────────────────────────────────────────
function EventGraphCards({
events, templates, selectedDate, causalInsts,
const FRISE_LANE_H = 24 // px par lane
const FRISE_CHIP_H = 18 // hauteur d'un chip
const FRISE_CHIP_PAD = (FRISE_LANE_H - FRISE_CHIP_H) / 2
const FRISE_AXIS_H = 20 // axe temporel en bas
const FRISE_MIN_W = 44 // largeur minimale d'un chip (en px)
const FRISE_POPUP_W = 220 // largeur du popup
function CausalFrise({
events, templates, priceData, selectedDate, causalInsts,
}: {
events: SnapshotEvent[]
templates: CausalTemplate[]
events: SnapshotEvent[]
templates: CausalTemplate[]
priceData: PriceCandle[]
selectedDate: string | null
causalInsts: string[]
causalInsts: string[]
}) {
const navigate = useNavigate()
const navigate = useNavigate()
const containerRef = useRef<HTMLDivElement>(null)
const [contWidth, setContWidth] = useState(400)
const [activeChip, setActiveChip] = useState<{
tmpl: CausalTemplate; ev: SnapshotEvent; chipX: number; chipY: number
} | null>(null)
// Events that have an instantiated causal analysis for this instrument
const linkedEvents = events.filter(ev => {
if (!ev.analyzed_instruments) return false
useEffect(() => {
const el = containerRef.current; if (!el) return
setContWidth(el.getBoundingClientRect().width)
const obs = new ResizeObserver(e => setContWidth(e[0].contentRect.width))
obs.observe(el)
return () => obs.disconnect()
}, [])
// Close popup on outside click (tiny delay avoids self-close)
useEffect(() => {
if (!activeChip) return
let tid: ReturnType<typeof setTimeout>
const close = () => setActiveChip(null)
tid = setTimeout(() => document.addEventListener('mousedown', close), 60)
return () => { clearTimeout(tid); document.removeEventListener('mousedown', close) }
}, [activeChip])
const linked = events.filter(ev => {
if (!ev.analyzed_instruments || ev.template_id == null) return false
const insts = ev.analyzed_instruments.split(',')
return causalInsts.some(ci => insts.includes(ci))
})
// Unique templates from those linked events
const seenIds = new Set<number>()
const relevant: { tmpl: CausalTemplate; eventsForTmpl: SnapshotEvent[] }[] = []
for (const ev of linkedEvents) {
if (!ev.template_id || seenIds.has(ev.template_id)) continue
const tmpl = templates.find(t => t.id === ev.template_id)
if (!tmpl) continue
seenIds.add(ev.template_id)
relevant.push({
tmpl,
eventsForTmpl: linkedEvents.filter(e => e.template_id === ev.template_id),
})
}
if (!relevant.length) {
if (!priceData.length || !linked.length) {
return (
<div className="text-xs text-slate-600 italic py-3">
Aucun graphe causal lié aux événements de cette période pour cet instrument
<div ref={containerRef} className="h-12 flex items-center justify-center text-xs text-slate-600 italic">
Aucun graphe causal lié pour cet instrument
</div>
)
}
const minDate = priceData[0].time
const maxDate = priceData[priceData.length - 1].time
const minTs = new Date(minDate).getTime()
const maxTs = new Date(maxDate).getTime()
const usable = Math.max(contWidth, 1)
const clampTs = (d: string) => Math.min(Math.max(new Date(d).getTime(), minTs), maxTs)
const dateToX = (d: string) => ((clampTs(d) - minTs) / (maxTs - minTs)) * usable
// Build chips (one per event × template)
type Chip = {
ev: SnapshotEvent; tmpl: CausalTemplate
x1: number; x2: number; w: number; active: boolean
}
const chips: Chip[] = linked
.filter(ev => ev.date <= maxDate)
.map(ev => {
const tmpl = templates.find(t => t.id === ev.template_id)
if (!tmpl) return null
const endDate = ev.end_date ?? (() => {
const d = new Date(ev.date); d.setDate(d.getDate() + 30)
return d.toISOString().slice(0, 10)
})()
const x1 = dateToX(ev.date)
const raw = dateToX(endDate) - x1
const w = Math.max(raw, FRISE_MIN_W)
return { ev, tmpl, x1, x2: x1 + w, w, active: isActiveAt(ev, selectedDate) }
})
.filter((c): c is Chip => c !== null)
.sort((a, b) => a.x1 - b.x1)
// Greedy lane assignment — no overlap
const laneEnds: number[] = []
const placed = chips.map(chip => {
const GAP = 3
let lane = laneEnds.findIndex(end => end + GAP <= chip.x1)
if (lane === -1) { lane = laneEnds.length; laneEnds.push(0) }
laneEnds[lane] = chip.x2
return { ...chip, lane }
})
const numLanes = Math.max(laneEnds.length, 1)
const containerH = numLanes * FRISE_LANE_H + FRISE_AXIS_H + 4
// Month tick marks — skip some if too crowded
const ticks: { label: string; x: number }[] = []
{
const start = new Date(minDate)
let cur = new Date(start.getFullYear(), start.getMonth() + 1, 1)
while (cur.toISOString().slice(0, 10) <= maxDate) {
ticks.push({
label: cur.toLocaleDateString('fr-FR', {
month: 'short',
...(cur.getFullYear() !== start.getFullYear() ? { year: '2-digit' } : {}),
}),
x: dateToX(cur.toISOString().slice(0, 10)),
})
cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1)
}
}
// Keep 1 label every ~70px to avoid crowding
const tickStep = Math.max(1, Math.ceil(ticks.length / (usable / 70)))
const visTicks = ticks.filter((_, i) => i % tickStep === 0)
const crossX = selectedDate && selectedDate >= minDate && selectedDate <= maxDate
? dateToX(selectedDate) : null
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{relevant.map(({ tmpl, eventsForTmpl }) => {
const active = eventsForTmpl.some(ev => isActiveAt(ev, selectedDate))
<div
ref={containerRef}
className="relative w-full select-none"
style={{ height: containerH }}
onClick={() => setActiveChip(null)}
>
{/* Month grid lines */}
{visTicks.map((t, i) => (
<div key={i} className="absolute top-0 pointer-events-none"
style={{ left: t.x, bottom: FRISE_AXIS_H, width: 1, background: 'rgba(100,116,139,0.12)' }} />
))}
{/* Crosshair */}
{crossX !== null && (
<div className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: crossX, width: 1, background: 'rgba(96,165,250,0.3)' }} />
)}
{/* Chips */}
{placed.map(({ ev, tmpl, x1, w, lane, active }) => {
const catTw = CAUSAL_CAT_TW[tmpl.category] ?? 'text-slate-400 border-slate-700/30 bg-slate-800/40'
const chipY = lane * FRISE_LANE_H + FRISE_CHIP_PAD
const isOpen = activeChip?.ev.id === ev.id && activeChip?.tmpl.id === tmpl.id
const charsFit = Math.floor((w - 18) / 5.5)
const label = charsFit < 3 ? '' : tmpl.name.length > charsFit
? tmpl.name.slice(0, charsFit - 1) + '…' : tmpl.name
return (
<div
key={tmpl.id}
onClick={() => navigate(`/causal-lab?template=${tmpl.id}`)}
key={`${tmpl.id}-${ev.id ?? ev.date}`}
className={clsx(
'rounded-lg border p-2.5 cursor-pointer transition-colors group',
'absolute rounded border cursor-pointer transition-all flex items-center gap-1 overflow-hidden px-1.5',
'text-[9px] font-medium leading-none',
active
? 'border-emerald-600/50 bg-emerald-900/20 hover:bg-emerald-900/30'
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
? catTw + (isOpen ? ' ring-1 ring-current/40 brightness-125' : ' hover:brightness-110')
: clsx('text-slate-500 border-slate-700/40 bg-slate-800/25',
'hover:text-slate-300 hover:border-slate-600/60 hover:bg-slate-800/50'),
)}
style={{ left: x1, top: chipY, width: w, height: FRISE_CHIP_H }}
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.stopPropagation()
setActiveChip(isOpen ? null : { tmpl, ev, chipX: x1, chipY })
}}
title={`${tmpl.name} · ${ev.title}\n${fmtDateFR(ev.date)}${ev.end_date ? fmtDateFR(ev.end_date) : '+30j'}`}
>
<div className="flex items-start justify-between gap-1 mb-1.5">
<span className={clsx(
'text-xs font-semibold leading-tight group-hover:underline',
active ? 'text-emerald-300' : 'text-slate-500'
)}>
{tmpl.name}
</span>
<span className={clsx(
'shrink-0 text-[9px] px-1 py-0.5 rounded border',
CAUSAL_CAT_TW[tmpl.category] ?? 'text-slate-500 border-slate-700/30'
)}>
{tmpl.category}
</span>
</div>
{/* Event titles linked to this template */}
<div className="space-y-0.5 mb-1.5">
{eventsForTmpl.slice(0, 2).map(ev => (
<div key={ev.id} className={clsx(
'text-[9px] truncate',
isActiveAt(ev, selectedDate) ? 'text-amber-400' : 'text-slate-600'
)}>
{fmtDateFR(ev.date)} · {ev.title.length > 28 ? ev.title.slice(0, 28) + '…' : ev.title}
</div>
))}
{eventsForTmpl.length > 2 && (
<div className="text-[9px] text-slate-700">+{eventsForTmpl.length - 2} événements</div>
)}
</div>
<div className="flex items-center justify-between">
<span className={clsx('text-[9px]', active ? 'text-emerald-500' : 'text-slate-700')}>
{active ? '● actif' : '○ période'}
</span>
<span className={clsx('w-2 h-2 rounded-full', active ? 'bg-emerald-400' : 'bg-slate-700')} />
</div>
<span className={clsx('w-1.5 h-1.5 rounded-full bg-current shrink-0', !active && 'opacity-40')} />
{label && <span className="truncate">{label}</span>}
</div>
)
})}
{/* Popup */}
{activeChip && (() => {
const { tmpl, ev, chipX, chipY } = activeChip
const popH = 108
const popTop = chipY - popH - 6 >= 0 ? chipY - popH - 6 : chipY + FRISE_CHIP_H + 4
const popLeft = Math.max(0, Math.min(chipX, contWidth - FRISE_POPUP_W - 4))
return (
<div
className="absolute z-30 bg-dark-800 border border-slate-600/40 rounded-lg shadow-2xl p-3"
style={{ left: popLeft, top: popTop, width: FRISE_POPUP_W }}
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<button
onClick={() => setActiveChip(null)}
className="absolute top-1.5 right-1.5 w-4 h-4 flex items-center justify-center text-slate-600 hover:text-slate-300 text-xs leading-none"
>×</button>
<div className="flex items-start gap-2 mb-1.5 pr-4">
<span className="flex-1 text-xs font-semibold text-slate-200 leading-tight line-clamp-2">{tmpl.name}</span>
<span className={clsx('text-[9px] px-1 py-0.5 rounded border shrink-0',
CAUSAL_CAT_TW[tmpl.category] ?? 'text-slate-500 border-slate-700/30'
)}>{tmpl.category}</span>
</div>
<div className="text-[10px] text-slate-400 truncate mb-1.5">{ev.title}</div>
<div className="flex items-center gap-3 mb-2.5 text-[9px] text-slate-600">
<span className="flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{fmtDateFR(ev.date)} {ev.end_date ? fmtDateFR(ev.end_date) : '+30j'}
</span>
{ev.impact_score != null && (
<span className="text-amber-600"> {ev.impact_score.toFixed(1)}</span>
)}
{ev.surprise_pct != null && (
<span className={ev.surprise_pct > 0 ? 'text-emerald-600' : 'text-red-600'}>
{ev.surprise_pct > 0 ? '+' : ''}{ev.surprise_pct.toFixed(1)}%
</span>
)}
</div>
<div className="flex gap-1.5">
<button
onClick={() => { if (ev.id) navigate(`/market-events?event=${ev.id}`); setActiveChip(null) }}
className="flex-1 text-[10px] text-slate-400 hover:text-slate-200 bg-slate-700/40 hover:bg-slate-700/60 rounded px-2 py-1 transition-colors"
>Événement </button>
<button
onClick={() => { navigate(`/causal-lab?template=${tmpl.id}`); setActiveChip(null) }}
className="flex-1 text-[10px] text-violet-400 hover:text-violet-200 bg-violet-900/20 hover:bg-violet-900/40 rounded px-2 py-1 transition-colors"
>CausalLab </button>
</div>
</div>
)
})()}
{/* X-axis */}
<div className="absolute left-0 right-0 border-t border-slate-700/30" style={{ bottom: FRISE_AXIS_H }}>
{visTicks.map((t, i) => (
<span key={i} className="absolute text-[9px] text-slate-600 -translate-x-1/2 pt-0.5" style={{ left: t.x }}>
{t.label}
</span>
))}
</div>
</div>
)
}
@@ -1233,16 +1380,17 @@ export default function InstrumentDashboard() {
/>
</div>
{/* Zone basse — graphes causaux liés aux événements */}
{/* Zone basse — frise des graphes causaux */}
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="flex items-center gap-2 mb-3">
<BarChart2 className="w-4 h-4 text-violet-400" />
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Graphes causaux</span>
<span className="text-xs text-slate-600 ml-auto">vert = actif · clic = bibliothèque</span>
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wide">Frise des graphes</span>
<span className="text-xs text-slate-600 ml-auto">largeur durée · clic = détail</span>
</div>
<EventGraphCards
<CausalFrise
events={snapshot.events}
templates={templates}
priceData={snapshot.price_data}
selectedDate={effectiveDate}
causalInsts={causalInsts}
/>