Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import type { JSX } from 'react';
import { useMemo } from 'react';
import clsx from 'clsx';
import type { SlotProgressTimelineProps, PhaseData } from './SlotProgressTimeline.types';
import { PhaseNode } from './components/PhaseNode';
import { PhaseConnection } from './components/PhaseConnection';

/**
* SlotProgressTimeline - Visualizes Ethereum slot phases as a timeline
*
* Displays the progression of phases within a 12-second Ethereum slot.
* Supports two modes:
* - 'live': Shows real-time progress with animations
* - 'static': Shows all phases as completed
*
* Responsive:
* - Desktop (>=768px): Horizontal timeline with axis below
* - Mobile (<768px): Vertical stack
*
* @example
* ```tsx
* import { CubeIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
*
* const phases = [
* {
* id: 'builders',
* label: 'Builders',
* icon: CubeIcon,
* color: 'primary',
* timestamp: 500,
* description: 'MEV builders bidding',
* stats: '43 builders bidded'
* },
* {
* id: 'relaying',
* label: 'Relaying',
* icon: ArrowPathIcon,
* color: 'accent',
* timestamp: 2000,
* duration: 1000,
* description: 'Relay connection',
* stats: '2 relays'
* }
* ];
*
* // Live mode
* <SlotProgressTimeline
* phases={phases}
* mode="live"
* currentTime={2500}
* />
*
* // Static mode
* <SlotProgressTimeline
* phases={phases}
* mode="static"
* />
* ```
*/
export function SlotProgressTimeline({
phases,
mode,
currentTime = 0,
showStats = true,
onPhaseClick,
className,
}: SlotProgressTimelineProps): JSX.Element {
// Validate required props
if (mode === 'live' && currentTime === undefined) {
console.warn('SlotProgressTimeline: currentTime is required for live mode');
}

// Compute phase statuses and connection progress
const enrichedPhases = useMemo(() => {
if (mode === 'static') {
// In static mode, all phases are completed
return phases.map(phase => ({
...phase,
isActive: false,
isCompleted: true,
}));
}

// Live mode - compute statuses based on currentTime
return phases.map((phase, index) => {
const phaseTime = phase.timestamp ?? 0;
const nextPhase = phases[index + 1];
const nextPhaseTime = nextPhase?.timestamp;

// Determine if this phase is completed
let isCompleted = false;
if (phase.duration !== undefined) {
// Has explicit duration
isCompleted = currentTime >= phaseTime + phase.duration;
} else if (nextPhaseTime !== undefined) {
// No duration, use next phase timestamp
isCompleted = currentTime >= nextPhaseTime;
} else {
// Last phase with no duration - consider completed if we're past it
isCompleted = currentTime > phaseTime;
}

// Determine if this phase is active
const isActive = currentTime >= phaseTime && !isCompleted;

return {
...phase,
isActive,
isCompleted,
};
});
}, [phases, mode, currentTime]);

// Compute connection progress between phases
const connectionProgress = useMemo(() => {
if (mode === 'static') {
// All connections are 100% in static mode
return phases.map(() => 100);
}

return phases.map((phase, index) => {
const nextPhase = phases[index + 1];
if (!nextPhase) {
// No connection after the last phase
return 100;
}

const phaseTime = phase.timestamp ?? 0;
const nextPhaseTime = nextPhase.timestamp ?? 0;
const duration = nextPhaseTime - phaseTime;

// If current time hasn't reached this phase yet
if (currentTime < phaseTime) {
return 0;
}

// If current time is past the next phase
if (currentTime >= nextPhaseTime) {
return 100;
}

// Calculate progress between phases
const elapsed = currentTime - phaseTime;
return Math.min(100, Math.max(0, (elapsed / duration) * 100));
});
}, [phases, mode, currentTime]);

// Handler for phase clicks
const handlePhaseClick = (phase: PhaseData): void => {
if (onPhaseClick) {
onPhaseClick(phase);
}
};

return (
<div className={clsx('w-full', className)}>
{/* Desktop: Horizontal layout */}
<div className="hidden h-32 md:block">
<div className="flex h-full w-full items-center px-4">
{enrichedPhases.map((phase, index) => {
const status = phase.isActive ? 'active' : phase.isCompleted ? 'completed' : 'pending';
const hasConnection = index < enrichedPhases.length - 1;
const isConnectionActive =
mode === 'live' && connectionProgress[index] > 0 && connectionProgress[index] < 100;

return (
<>
{/* Phase node */}
<div key={phase.id} className="shrink-0">
<PhaseNode
phase={phase}
status={status}
showStats={showStats}
onClick={onPhaseClick ? () => handlePhaseClick(phase) : undefined}
/>
</div>

{/* Connection to next phase */}
{hasConnection && (
<div key={`connection-${phase.id}`} className="mx-4 flex-1">
<PhaseConnection
progress={connectionProgress[index]}
orientation="horizontal"
isActive={isConnectionActive}
/>
</div>
)}
</>
);
})}
</div>
</div>

{/* Mobile: Vertical layout */}
<div className="md:hidden">
<div className="flex flex-col gap-4" style={{ minHeight: '50vh' }}>
{enrichedPhases.map((phase, index) => {
const status = phase.isActive ? 'active' : phase.isCompleted ? 'completed' : 'pending';
const hasConnection = index < enrichedPhases.length - 1;
const isConnectionActive =
mode === 'live' && connectionProgress[index] > 0 && connectionProgress[index] < 100;

return (
<div key={phase.id} className="flex flex-col items-center">
{/* Phase node */}
<PhaseNode
phase={phase}
status={status}
showStats={showStats}
onClick={onPhaseClick ? () => handlePhaseClick(phase) : undefined}
/>

{/* Connection to next phase */}
{hasConnection && (
<div className="my-2 h-12">
<PhaseConnection
progress={connectionProgress[index]}
orientation="vertical"
isActive={isConnectionActive}
/>
</div>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { ComponentType } from 'react';

/**
* Phase data for the slot progress timeline
*/
export interface PhaseData {
/** Unique identifier for this phase */
id: string;
/** Display name for this phase */
label: string;
/** Heroicon component for this phase */
icon: ComponentType<{ className?: string }>;
/** Semantic color token (e.g., 'primary', 'success', 'accent') */
color: string;
/** When this phase occurred (milliseconds from slot start, 0-12000) */
timestamp?: number;
/** How long this phase took (milliseconds) */
duration?: number;
/** Description of what happens in this phase */
description: string;
/** Optional data to display (e.g., "43 builders bidded") */
stats?: string;
/** Computed: whether this phase is currently active (calculated based on currentTime) */
isActive?: boolean;
/** Computed: whether this phase has completed (calculated based on currentTime) */
isCompleted?: boolean;
}

/**
* Timeline mode configuration
*/
export type TimelineMode = 'live' | 'static';

/**
* Props for the SlotProgressTimeline component
*/
export interface SlotProgressTimelineProps {
/** Array of phase data to display */
phases: PhaseData[];
/** Timeline mode: 'live' shows progress animation, 'static' shows all completed */
mode: TimelineMode;
/** Current time in milliseconds from slot start (0-12000). Required for live mode. */
currentTime?: number;
/** Whether to show statistics below each phase node (default: true) */
showStats?: boolean;
/** Optional callback when a phase is clicked */
onPhaseClick?: (phase: PhaseData) => void;
/** Optional CSS class name */
className?: string;
}

/**
* Props for the PhaseNode sub-component
*/
export interface PhaseNodeProps {
/** Phase data to display */
phase: PhaseData;
/** Current state of this phase node */
status: 'pending' | 'active' | 'completed';
/** Whether to show stats below the node */
showStats?: boolean;
/** Optional click handler */
onClick?: () => void;
/** Optional CSS class name */
className?: string;
}

/**
* Props for the PhaseConnection sub-component
*/
export interface PhaseConnectionProps {
/** Progress percentage (0-100) for this connection */
progress: number;
/** Layout orientation */
orientation: 'horizontal' | 'vertical';
/** Whether this connection is active (for styling) */
isActive?: boolean;
/** Optional CSS class name */
className?: string;
}

/**
* Props for the TimelineAxis sub-component
*/
export interface TimelineAxisProps {
/** Layout orientation */
orientation: 'horizontal' | 'vertical';
/** Total duration in milliseconds (default: 12000) */
totalDuration?: number;
/** Number of tick marks to show (default: 7 for 0s, 2s, 4s, 6s, 8s, 10s, 12s) */
tickCount?: number;
/** Optional CSS class name */
className?: string;
}
Loading