feat: frise chronologique + page Snapshot Externe 4 piliers

- TimelineFrise.tsx : visualisation horizontale scrollable COVID→aujourd'hui
  3 lanes (long/medium/short), événements cliquables, marqueur aujourd'hui/date, axe années
- ExternalSnapshot.tsx (/snapshot) : dashboard 4 piliers du contexte externe
  Pilier 1 Géopolitique : 3 badges L/M/C avec J+ depuis événement + lien /timeline?date=
  Pilier 2 Prix & Marchés : quotes SPY/QQQ/GLD/USO/TLT/UUP live + lien Specialist Desks
  Pilier 3 Régimes Macro : régime dominant + scores scénarios + jauges VIX/DXY/10Y
  Pilier 4 Calendrier Économique : prochains événements + récents, liens Calendar/Institutional
- Timeline.tsx : remplace mini-strip par TimelineFrise, lit ?date= query param
- Sidebar : Snapshot Externe (ScanEye) + Timeline (Layers)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-24 18:36:12 +02:00
parent 37f770a09a
commit 8afa06118c
5 changed files with 663 additions and 65 deletions

View File

@@ -0,0 +1,222 @@
import { useRef, useEffect } from 'react'
import clsx from 'clsx'
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 = 2.2 // pixels per day
const LANE_H = 30 // height of each event lane
const AXIS_H = 24 // height of year axis
const PADDING_TOP = 8
const FRISE_START = '2020-02-01'
const LEVEL_COLORS: Record<string, { bg: string; border: string; text: string; track: string }> = {
long: { bg: '#4c1d95', border: '#7c3aed', text: '#ddd6fe', track: '#1e1b4b' },
medium: { bg: '#1e3a5f', border: '#3b82f6', text: '#bfdbfe', track: '#0f172a' },
short: { bg: '#064e3b', border: '#10b981', text: '#a7f3d0', track: '#022c22' },
}
const LEVEL_ORDER: Record<string, number> = { long: 0, medium: 1, short: 2 }
function toDay(date: string): number {
const origin = new Date(FRISE_START).getTime()
const d = new Date(date).getTime()
return Math.floor((d - origin) / 86_400_000)
}
function fmtYear(d: Date): string {
return d.getFullYear().toString()
}
function buildYearMarkers(totalDays: number): { x: number; label: string }[] {
const origin = new Date(FRISE_START)
const markers = []
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 + 30)
markers.push({ x: day * SCALE, label: fmtYear(jan1) })
}
return markers
}
export default function TimelineFrise({ events, refDate, onDateChange }: Props) {
const scrollRef = useRef<HTMLDivElement>(null)
const totalDays = toDay(new Date().toISOString().split('T')[0]) + 60
const totalWidth = totalDays * SCALE
const totalHeight = PADDING_TOP + 3 * LANE_H + AXIS_H + 8
const yearMarkers = buildYearMarkers(totalDays)
const todayX = toDay(new Date().toISOString().split('T')[0]) * SCALE
const refX = toDay(refDate) * SCALE
// Scroll so selected date is centred on mount / when refDate changes
useEffect(() => {
const el = scrollRef.current
if (!el) return
const target = refX - el.clientWidth / 2
el.scrollLeft = Math.max(0, target)
}, [refDate, refX])
// Group events by level, resolve overlaps within a level by stacking sub-rows
const grouped: Record<string, MarketEvent[]> = { long: [], medium: [], short: [] }
for (const ev of events) {
if (grouped[ev.level]) grouped[ev.level].push(ev)
}
function laneY(level: string): number {
return PADDING_TOP + LEVEL_ORDER[level] * LANE_H
}
function evWidth(ev: MarketEvent): number {
const endDay = ev.end_date ? toDay(ev.end_date) : totalDays
const startDay = toDay(ev.start_date)
const days = Math.max(endDay - startDay, 14)
return days * SCALE
}
return (
<div className="bg-dark-800 rounded-xl border border-slate-700/40 overflow-hidden">
{/* Level legend */}
<div className="flex items-center gap-5 px-4 pt-3 pb-2">
{(['long', 'medium', 'short'] as const).map(l => (
<div key={l} className="flex items-center gap-1.5 text-xs">
<div className="w-3 h-2 rounded-sm" style={{ background: LEVEL_COLORS[l].border }} />
<span className="text-slate-400 capitalize">
{l === 'long' ? 'Long terme' : l === 'medium' ? 'Moyen terme' : 'Court terme'}
</span>
</div>
))}
<span className="ml-auto text-xs text-slate-600">cliquer sur un événement pour naviguer</span>
</div>
{/* Scrollable frise */}
<div ref={scrollRef} className="overflow-x-auto overflow-y-hidden cursor-grab" style={{ height: totalHeight + 'px' }}>
<div className="relative" style={{ width: totalWidth + 'px', height: totalHeight + 'px' }}>
{/* Year grid lines */}
{yearMarkers.map(m => (
<div key={m.label} className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: m.x + 'px', borderLeft: '1px dashed rgba(255,255,255,0.07)', top: 0, bottom: 0, height: '100%' }}
/>
))}
{/* Lane backgrounds */}
{(['long', 'medium', 'short'] as const).map(l => (
<div key={l}
className="absolute"
style={{
top: laneY(l) + 'px',
left: 0, right: 0,
height: LANE_H + 'px',
background: LEVEL_COLORS[l].track,
opacity: 0.5,
}}
/>
))}
{/* Events */}
{events.map(ev => {
const x = toDay(ev.start_date) * SCALE
const w = evWidth(ev)
const y = laneY(ev.level)
const c = LEVEL_COLORS[ev.level]
const isSelected = refDate >= ev.start_date && (!ev.end_date || refDate <= ev.end_date)
return (
<div
key={ev.id}
onClick={() => onDateChange(ev.start_date)}
title={`${ev.name}\n${ev.start_date}${ev.end_date ? ' → ' + ev.end_date : ' (en cours)'}`}
className="absolute flex items-center overflow-hidden rounded cursor-pointer transition-all"
style={{
left: x + 'px',
top: y + 3 + 'px',
width: w + 'px',
height: LANE_H - 6 + 'px',
background: isSelected
? c.border
: c.bg,
border: `1px solid ${c.border}`,
opacity: isSelected ? 1 : 0.8,
zIndex: isSelected ? 10 : 1,
}}
>
<span
className="px-1.5 text-xs font-medium whitespace-nowrap select-none"
style={{ color: c.text, fontSize: '10px' }}
>
{ev.name}
</span>
</div>
)
})}
{/* Today line */}
<div
className="absolute top-0 pointer-events-none z-20"
style={{
left: todayX + 'px',
top: 0,
height: PADDING_TOP + 3 * LANE_H + 'px',
width: '2px',
background: 'rgba(239,68,68,0.8)',
}}
/>
{/* Selected date line (if not today) */}
{refDate !== new Date().toISOString().split('T')[0] && (
<div
className="absolute pointer-events-none z-20"
style={{
left: refX + 'px',
top: 0,
height: PADDING_TOP + 3 * LANE_H + 'px',
width: '1px',
background: 'rgba(255,255,255,0.5)',
borderLeft: '1px dashed rgba(255,255,255,0.5)',
}}
/>
)}
{/* Year axis */}
<div
className="absolute left-0 right-0 flex"
style={{ top: PADDING_TOP + 3 * LANE_H + 2 + 'px', height: AXIS_H + 'px' }}
>
{yearMarkers.map(m => (
<div
key={m.label}
className="absolute text-xs text-slate-500 select-none"
style={{ left: m.x + 4 + 'px', top: '4px' }}
>
{m.label}
</div>
))}
</div>
{/* Today label */}
<div
className="absolute text-xs text-red-400 font-medium select-none pointer-events-none z-20"
style={{ left: todayX + 4 + 'px', top: PADDING_TOP + 3 * LANE_H + 6 + 'px' }}
>
aujourd'hui
</div>
</div>
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye
} from 'lucide-react'
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
import clsx from 'clsx'
@@ -27,6 +27,7 @@ const nav = [
{ to: '/calendar', icon: Calendar, label: 'Calendar' },
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
{ to: '/timeline', icon: Layers, label: 'Timeline' },
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
{ to: '/config', icon: Settings, label: 'Configuration' },