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:
222
frontend/src/components/TimelineFrise.tsx
Normal file
222
frontend/src/components/TimelineFrise.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user