feat: instrument analysis
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user