feat: vertical 3-column timeline + MA regime bootstrap engine
- TimelineVertical: replace horizontal frise with Y=time vertical layout, 3 columns (Long/Medium/Short), auto-scroll to selected date, sub-columns for overlapping events, today/selected-date horizontal lines - ma_analyzer.py: detect MA50/MA200 crossovers + MA100 slope changes + MA20 direction swings on EUR/USD, Brent, Gold, S&P500, US10Y (5y history) with 5-bar confirmation, dedup, GPT-4o-mini enrichment, idempotent DB save - POST /api/timeline/bootstrap-ma endpoint to trigger analysis - Bootstrap MA button in Timeline page with loading state + result count Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
308
frontend/src/components/TimelineVertical.tsx
Normal file
308
frontend/src/components/TimelineVertical.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useRef, useEffect, useMemo } from 'react'
|
||||
|
||||
interface MarketEvent {
|
||||
id: number
|
||||
name: string
|
||||
start_date: string
|
||||
end_date: string | null
|
||||
level: 'long' | 'medium' | 'short'
|
||||
category: string
|
||||
impact_score: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: MarketEvent[]
|
||||
refDate: string
|
||||
onDateChange: (d: string) => void
|
||||
}
|
||||
|
||||
const SCALE = 1.8 // px per day
|
||||
const MARGIN_LEFT = 52 // year label area
|
||||
const LEVEL_W = 190 // fixed px per level column
|
||||
const SUBCOL_GAP = 2
|
||||
const COL_GAP = 8
|
||||
const CONTAINER_H = 560
|
||||
const MIN_EV_H = 20
|
||||
const FRISE_START = '2020-02-01'
|
||||
const LEVEL_ORDER = ['long', 'medium', 'short'] as const
|
||||
|
||||
type Level = typeof LEVEL_ORDER[number]
|
||||
|
||||
const COLORS: Record<Level, { bg: string; border: string; text: string; track: string }> = {
|
||||
long: { bg: '#2e1065', border: '#7c3aed', text: '#ede9fe', track: 'rgba(109,40,217,0.07)' },
|
||||
medium: { bg: '#1e3a5f', border: '#3b82f6', text: '#dbeafe', track: 'rgba(59,130,246,0.07)' },
|
||||
short: { bg: '#064e3b', border: '#10b981', text: '#d1fae5', track: 'rgba(16,185,129,0.07)' },
|
||||
}
|
||||
|
||||
const LEVEL_LABELS: Record<Level, string> = {
|
||||
long: 'Long Terme',
|
||||
medium: 'Moyen Terme',
|
||||
short: 'Court Terme',
|
||||
}
|
||||
|
||||
function toDay(date: string): number {
|
||||
const origin = new Date(FRISE_START).getTime()
|
||||
return Math.floor((new Date(date + 'T00:00:00Z').getTime() - origin) / 86_400_000)
|
||||
}
|
||||
|
||||
function assignSubCols(evs: MarketEvent[]): Map<number, number> {
|
||||
const sorted = [...evs].sort((a, b) => a.start_date.localeCompare(b.start_date))
|
||||
const colEnds: string[] = []
|
||||
const result = new Map<number, number>()
|
||||
for (const ev of sorted) {
|
||||
const end = ev.end_date ?? '2099-12-31'
|
||||
let placed = false
|
||||
for (let i = 0; i < colEnds.length; i++) {
|
||||
if (ev.start_date >= colEnds[i]) {
|
||||
result.set(ev.id, i); colEnds[i] = end; placed = true; break
|
||||
}
|
||||
}
|
||||
if (!placed) { result.set(ev.id, colEnds.length); colEnds.push(end) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default function TimelineVertical({ events, refDate, onDateChange }: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const today = useMemo(() => new Date().toISOString().split('T')[0], [])
|
||||
const todayDay = toDay(today)
|
||||
const totalDays = todayDay + 60
|
||||
const totalHeight = totalDays * SCALE
|
||||
const refY = toDay(refDate) * SCALE
|
||||
const todayY = todayDay * SCALE
|
||||
|
||||
const yearMarkers = useMemo(() => {
|
||||
const origin = new Date(FRISE_START)
|
||||
const out: { day: number; label: string }[] = []
|
||||
for (let y = origin.getFullYear(); y <= origin.getFullYear() + Math.ceil(totalDays / 365) + 1; y++) {
|
||||
const jan1 = new Date(y, 0, 1)
|
||||
const day = Math.floor((jan1.getTime() - origin.getTime()) / 86_400_000)
|
||||
if (day >= 0 && day <= totalDays) out.push({ day, label: String(y) })
|
||||
}
|
||||
return out
|
||||
}, [totalDays])
|
||||
|
||||
const byLevel = useMemo(() => {
|
||||
const g: Record<Level, MarketEvent[]> = { long: [], medium: [], short: [] }
|
||||
for (const ev of events) {
|
||||
const l = ev.level as Level
|
||||
if (g[l]) g[l].push(ev)
|
||||
}
|
||||
return g
|
||||
}, [events])
|
||||
|
||||
const subColsMap = useMemo(() => {
|
||||
const m = {} as Record<Level, Map<number, number>>
|
||||
for (const l of LEVEL_ORDER) m[l] = assignSubCols(byLevel[l])
|
||||
return m
|
||||
}, [byLevel])
|
||||
|
||||
const maxSubCols = useMemo(() => {
|
||||
const m = { long: 1, medium: 1, short: 1 } as Record<Level, number>
|
||||
for (const l of LEVEL_ORDER) {
|
||||
const vals = [...subColsMap[l].values()]
|
||||
if (vals.length) m[l] = Math.min(Math.max(...vals) + 1, 4) // cap at 4 sub-cols
|
||||
}
|
||||
return m
|
||||
}, [subColsMap])
|
||||
|
||||
const colX = useMemo(() => {
|
||||
const x = {} as Record<Level, number>
|
||||
let cur = MARGIN_LEFT
|
||||
for (const l of LEVEL_ORDER) { x[l] = cur; cur += LEVEL_W + COL_GAP }
|
||||
return x
|
||||
}, [])
|
||||
|
||||
const totalWidth = MARGIN_LEFT + LEVEL_ORDER.length * (LEVEL_W + COL_GAP)
|
||||
|
||||
function subColW(level: Level): number {
|
||||
const n = maxSubCols[level]
|
||||
return Math.max((LEVEL_W - (n - 1) * SUBCOL_GAP) / n, 30)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
el.scrollTop = Math.max(0, refY - el.clientHeight / 2)
|
||||
}, [refDate, refY])
|
||||
|
||||
return (
|
||||
<div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden">
|
||||
{/* Column headers */}
|
||||
<div className="flex border-b border-slate-700/30" style={{ paddingLeft: MARGIN_LEFT + 'px' }}>
|
||||
{LEVEL_ORDER.map((l, i) => (
|
||||
<div
|
||||
key={l}
|
||||
className="flex items-center justify-center text-xs font-semibold py-2.5 shrink-0"
|
||||
style={{
|
||||
width: LEVEL_W + 'px',
|
||||
marginRight: i < LEVEL_ORDER.length - 1 ? COL_GAP + 'px' : 0,
|
||||
color: COLORS[l].text,
|
||||
borderBottom: `2px solid ${COLORS[l].border}`,
|
||||
}}
|
||||
>
|
||||
{LEVEL_LABELS[l]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scrollable area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{ height: CONTAINER_H + 'px', overflowY: 'auto', overflowX: 'hidden', position: 'relative' }}
|
||||
className="scrollbar-thin"
|
||||
>
|
||||
<div style={{ position: 'relative', height: totalHeight + 'px', width: totalWidth + 'px', minWidth: '100%' }}>
|
||||
|
||||
{/* Column track backgrounds */}
|
||||
{LEVEL_ORDER.map(l => (
|
||||
<div key={l} style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
height: totalHeight + 'px',
|
||||
left: colX[l],
|
||||
width: LEVEL_W + 'px',
|
||||
background: COLORS[l].track,
|
||||
borderLeft: `1px solid ${COLORS[l].border}18`,
|
||||
borderRight: `1px solid ${COLORS[l].border}18`,
|
||||
}} />
|
||||
))}
|
||||
|
||||
{/* Year lines + labels */}
|
||||
{yearMarkers.map(m => (
|
||||
<div key={m.label} style={{
|
||||
position: 'absolute',
|
||||
top: m.day * SCALE,
|
||||
left: 0,
|
||||
width: totalWidth + 'px',
|
||||
height: 1,
|
||||
background: 'rgba(255,255,255,0.055)',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
left: 3,
|
||||
top: 2,
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: 'rgba(100,116,139,0.9)',
|
||||
userSelect: 'none',
|
||||
}}>
|
||||
{m.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Events */}
|
||||
{events.map(ev => {
|
||||
const l = ev.level as Level
|
||||
const c = COLORS[l]
|
||||
const sc = subColsMap[l].get(ev.id) ?? 0
|
||||
// cap sub-col index to max allowed
|
||||
const cappedSc = Math.min(sc, maxSubCols[l] - 1)
|
||||
const scw = subColW(l)
|
||||
const x = colX[l] + cappedSc * (scw + SUBCOL_GAP)
|
||||
const topY = toDay(ev.start_date) * SCALE
|
||||
const endDay = ev.end_date ? toDay(ev.end_date) : totalDays
|
||||
const h = Math.max((endDay - toDay(ev.start_date)) * SCALE, MIN_EV_H)
|
||||
const isActive = refDate >= ev.start_date && (!ev.end_date || refDate <= ev.end_date)
|
||||
const isOngoing = !ev.end_date
|
||||
const daysSinceStart = Math.max(0, Math.floor(
|
||||
(new Date(refDate).getTime() - new Date(ev.start_date).getTime()) / 86_400_000
|
||||
))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ev.id}
|
||||
onClick={() => onDateChange(ev.start_date)}
|
||||
title={`[${ev.level.toUpperCase()}] ${ev.name}\n${ev.start_date}${ev.end_date ? ' → ' + ev.end_date : ' → en cours'}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: topY + 1,
|
||||
left: x + 1,
|
||||
width: scw - 2,
|
||||
height: h - 2,
|
||||
background: isActive ? c.border : c.bg,
|
||||
border: `1px solid ${isActive ? c.border : c.border + '88'}`,
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
opacity: isActive ? 1 : 0.7,
|
||||
zIndex: isActive ? 10 : 2,
|
||||
backgroundImage: isOngoing && !isActive
|
||||
? `linear-gradient(180deg, ${c.bg} 80%, ${c.border}30 100%)`
|
||||
: undefined,
|
||||
transition: 'opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
padding: '2px 4px',
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: c.text,
|
||||
lineHeight: 1.25,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}>
|
||||
{ev.name}
|
||||
</div>
|
||||
{h > 46 && isActive && (
|
||||
<div style={{ padding: '0 4px', fontSize: 8, color: c.text + 'bb' }}>
|
||||
J+{daysSinceStart}
|
||||
</div>
|
||||
)}
|
||||
{isOngoing && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 1,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: 8,
|
||||
color: c.border + 'cc',
|
||||
}}>▼</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Today line */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: todayY,
|
||||
left: 0,
|
||||
width: totalWidth + 'px',
|
||||
height: 2,
|
||||
background: 'rgba(239,68,68,0.75)',
|
||||
zIndex: 20,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<span style={{
|
||||
position: 'absolute',
|
||||
right: 4,
|
||||
top: 3,
|
||||
fontSize: 9,
|
||||
color: '#f87171',
|
||||
userSelect: 'none',
|
||||
}}>aujourd'hui</span>
|
||||
</div>
|
||||
|
||||
{/* Selected date line */}
|
||||
{refDate !== today && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: refY,
|
||||
left: 0,
|
||||
width: totalWidth + 'px',
|
||||
height: 0,
|
||||
borderTop: '1.5px dashed rgba(255,255,255,0.38)',
|
||||
zIndex: 20,
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List } from 'lucide-react'
|
||||
import { ChevronLeft, ChevronRight, Sparkles, Calendar, Clock, Layers, RefreshCw, List, Cpu } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import clsx from 'clsx'
|
||||
import TimelineFrise from '../components/TimelineFrise'
|
||||
import TimelineVertical from '../components/TimelineVertical'
|
||||
import EventManager from '../components/EventManager'
|
||||
|
||||
const api = axios.create({ baseURL: '/api' })
|
||||
@@ -191,6 +191,8 @@ export default function Timeline() {
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [bootstrapped, setBootstrapped] = useState(false)
|
||||
const [showManager, setShowManager] = useState(false)
|
||||
const [bootstrappingMA, setBootstrappingMA] = useState(false)
|
||||
const [maResult, setMaResult] = useState<{ detected: number; saved: number } | null>(null)
|
||||
|
||||
const fetchDay = useCallback(async (d: string) => {
|
||||
setLoading(true)
|
||||
@@ -250,6 +252,18 @@ export default function Timeline() {
|
||||
}
|
||||
}, [allEvents.length, bootstrapped, bootstrap])
|
||||
|
||||
const bootstrapMA = useCallback(async () => {
|
||||
setBootstrappingMA(true)
|
||||
setMaResult(null)
|
||||
try {
|
||||
const res = await api.post('/timeline/bootstrap-ma')
|
||||
setMaResult({ detected: res.data.detected, saved: res.data.saved })
|
||||
await fetchEvents()
|
||||
} catch { /* ignore */ } finally {
|
||||
setBootstrappingMA(false)
|
||||
}
|
||||
}, [fetchEvents])
|
||||
|
||||
const navigate = (days: number) => setRefDate(prev => offsetDate(prev, days))
|
||||
|
||||
return (
|
||||
@@ -278,6 +292,20 @@ export default function Timeline() {
|
||||
<List className="w-3 h-3" />
|
||||
Gérer événements
|
||||
</button>
|
||||
<button
|
||||
onClick={bootstrapMA}
|
||||
disabled={bootstrappingMA}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-emerald-400 border border-emerald-700/40 rounded-lg hover:bg-emerald-900/20 transition-colors disabled:opacity-40"
|
||||
title="Analyser EUR/USD, Brent, Gold, S&P500, US10Y via MAs → générer les périodes via GPT"
|
||||
>
|
||||
<Cpu className="w-3 h-3" />
|
||||
{bootstrappingMA ? 'Analyse MA...' : 'Bootstrap MA'}
|
||||
</button>
|
||||
{maResult && (
|
||||
<span className="text-xs text-emerald-400/70">
|
||||
{maResult.saved} périodes ajoutées
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => bootstrap()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-400 border border-slate-700/40 rounded-lg hover:bg-dark-700 transition-colors"
|
||||
@@ -342,9 +370,9 @@ export default function Timeline() {
|
||||
|
||||
</div>
|
||||
|
||||
{/* Frise chronologique */}
|
||||
{/* Vertical timeline */}
|
||||
{allEvents.length > 0 && (
|
||||
<TimelineFrise events={allEvents} refDate={refDate} onDateChange={setRefDate} />
|
||||
<TimelineVertical events={allEvents} refDate={refDate} onDateChange={setRefDate} />
|
||||
)}
|
||||
|
||||
{/* 3 context panels */}
|
||||
|
||||
Reference in New Issue
Block a user