Skip to content

Commit b1d0f58

Browse files
authored
feat: add block propagation CDF chart by node classification (#280)
Adds a new chart to the propagation tab of the slot detail page that visualizes the cumulative distribution of block arrival times grouped by node classification (individual, corporate, internal). Includes shared classification descriptions for consistent usage across the application.
1 parent 98b42bf commit b1d0f58

File tree

5 files changed

+225
-0
lines changed

5 files changed

+225
-0
lines changed

src/pages/ethereum/slots/DetailPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { AttestationArrivalsChart } from './components/AttestationArrivalsChart'
2323
import { AttestationVotesBreakdownTable } from './components/AttestationVotesBreakdownTable';
2424
import { AttestationsByEntity } from '@/components/Ethereum/AttestationsByEntity';
2525
import { BlockPropagationChart } from './components/BlockPropagationChart';
26+
import { BlockClassificationCDFChart } from './components/BlockClassificationCDFChart';
2627
import { BlobPropagationChart } from './components/BlobPropagationChart';
2728
import { BlobDataColumnSpreadChart } from './components/BlobDataColumnSpreadChart';
2829
import { MevBiddingTimelineChart } from './components/MevBiddingTimelineChart';
@@ -767,6 +768,9 @@ export function DetailPage(): JSX.Element {
767768
<BlobPropagationChart blobPropagationData={blobPropagationData} />
768769
</div>
769770
)}
771+
{blockPropagationData.length > 0 && (
772+
<BlockClassificationCDFChart blockPropagationData={blockPropagationData} />
773+
)}
770774
{blobPropagationData.length > 0 && (
771775
<BlobDataColumnSpreadChart blobPropagationData={blobPropagationData} slot={slot} />
772776
)}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { type JSX, useMemo } from 'react';
2+
import { PopoutCard } from '@/components/Layout/PopoutCard';
3+
import { MultiLineChart } from '@/components/Charts/MultiLine';
4+
import type { SeriesData } from '@/components/Charts/MultiLine/MultiLine.types';
5+
import { useThemeColors } from '@/hooks/useThemeColors';
6+
import { getClassificationLabel, CLASSIFICATION_DESCRIPTIONS } from '@/utils/classification';
7+
import type { BlockClassificationCDFChartProps } from './BlockClassificationCDFChart.types';
8+
9+
/**
10+
* BlockClassificationCDFChart - Visualizes cumulative distribution of block propagation by node classification
11+
*
12+
* Shows CDF (Cumulative Distribution Function) curves for each classification type:
13+
* - individual
14+
* - corporate
15+
* - internal
16+
*
17+
* @example
18+
* ```tsx
19+
* <BlockClassificationCDFChart
20+
* blockPropagationData={[
21+
* { seen_slot_start_diff: 145, classification: 'individual' },
22+
* { seen_slot_start_diff: 230, classification: 'corporate' },
23+
* ...
24+
* ]}
25+
* />
26+
* ```
27+
*/
28+
export function BlockClassificationCDFChart({ blockPropagationData }: BlockClassificationCDFChartProps): JSX.Element {
29+
const themeColors = useThemeColors();
30+
31+
// Process data into CDF series by classification
32+
const cdfSeries = useMemo(() => {
33+
if (blockPropagationData.length === 0) {
34+
return [];
35+
}
36+
37+
// Group data by classification
38+
const classificationGroups = new Map<string, number[]>();
39+
blockPropagationData.forEach(point => {
40+
const classification = point.classification || 'unclassified';
41+
if (!classificationGroups.has(classification)) {
42+
classificationGroups.set(classification, []);
43+
}
44+
classificationGroups.get(classification)!.push(point.seen_slot_start_diff);
45+
});
46+
47+
// Define classification colors to match the badge colors in classification.ts
48+
const classificationColors: Record<string, string> = {
49+
individual: themeColors.primary,
50+
corporate: '#a855f7', // purple-500
51+
internal: themeColors.success,
52+
unclassified: themeColors.muted,
53+
};
54+
55+
// Create CDF series for each classification
56+
const series: SeriesData[] = [];
57+
classificationGroups.forEach((times, classification) => {
58+
// Sort times for CDF calculation
59+
const sortedTimes = [...times].sort((a, b) => a - b);
60+
const totalNodes = sortedTimes.length;
61+
62+
// Calculate CDF: [time in seconds, cumulative percentage]
63+
const cdfData: Array<[number, number]> = sortedTimes.map((time, index) => [
64+
time / 1000, // Convert ms to seconds
65+
((index + 1) / totalNodes) * 100,
66+
]);
67+
68+
series.push({
69+
name: getClassificationLabel(classification),
70+
data: cdfData,
71+
color: classificationColors[classification] || themeColors.muted,
72+
smooth: true,
73+
lineWidth: 2,
74+
showSymbol: false,
75+
});
76+
});
77+
78+
// Sort series by name for consistent legend order
79+
return series.sort((a, b) => a.name.localeCompare(b.name));
80+
}, [blockPropagationData, themeColors]);
81+
82+
// Handle empty data
83+
if (blockPropagationData.length === 0) {
84+
return (
85+
<PopoutCard title="Block Propagation by Classification (CDF)" anchorId="block-classification-cdf" modalSize="xl">
86+
{({ inModal }) => (
87+
<div
88+
className={
89+
inModal
90+
? 'flex h-96 items-center justify-center text-muted'
91+
: 'flex h-72 items-center justify-center text-muted'
92+
}
93+
>
94+
<p>No block propagation data available</p>
95+
</div>
96+
)}
97+
</PopoutCard>
98+
);
99+
}
100+
101+
const subtitle = `Cumulative distribution of block arrival times by node classification`;
102+
103+
return (
104+
<PopoutCard
105+
title="Block Propagation by Classification (CDF)"
106+
anchorId="block-classification-cdf"
107+
subtitle={subtitle}
108+
modalSize="xl"
109+
>
110+
{({ inModal }) => (
111+
<div className="space-y-4">
112+
{/* Chart */}
113+
<MultiLineChart
114+
series={cdfSeries}
115+
xAxis={{
116+
type: 'value',
117+
name: 'Slot Time (s)',
118+
min: 0,
119+
max: 12,
120+
}}
121+
yAxis={{
122+
name: 'Cumulative %',
123+
min: 0,
124+
max: 100,
125+
}}
126+
height={inModal ? 384 : 288}
127+
showLegend={true}
128+
legendPosition="bottom"
129+
useNativeLegend={true}
130+
tooltipTrigger="axis"
131+
syncGroup="slot-time"
132+
/>
133+
134+
{/* Classification Legend */}
135+
<div className="rounded-sm bg-muted/10 px-4 py-3">
136+
<p className="mb-2 text-xs font-medium text-muted">Node Classifications:</p>
137+
<div className="space-y-1.5">
138+
{(['individual', 'corporate', 'internal'] as const).map(cls => (
139+
<div key={cls} className="flex items-start gap-2 text-xs">
140+
<span className="mt-0.5 shrink-0 font-semibold text-foreground">
141+
{CLASSIFICATION_DESCRIPTIONS[cls].label}:
142+
</span>
143+
<span className="text-muted">{CLASSIFICATION_DESCRIPTIONS[cls].description}</span>
144+
</div>
145+
))}
146+
</div>
147+
</div>
148+
</div>
149+
)}
150+
</PopoutCard>
151+
);
152+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Block propagation data point from the first seen by node API endpoint
3+
*/
4+
export interface BlockClassificationCDFDataPoint {
5+
/**
6+
* Milliseconds from slot start when node first saw the block
7+
*/
8+
seen_slot_start_diff: number;
9+
/**
10+
* Classification of the node (e.g., "individual", "corporate", "internal")
11+
*/
12+
classification?: string;
13+
}
14+
15+
/**
16+
* Props for BlockClassificationCDFChart component
17+
*/
18+
export interface BlockClassificationCDFChartProps {
19+
/**
20+
* Array of block propagation data points with classification info
21+
*/
22+
blockPropagationData: BlockClassificationCDFDataPoint[];
23+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export { BlockClassificationCDFChart } from './BlockClassificationCDFChart';
2+
export type {
3+
BlockClassificationCDFChartProps,
4+
BlockClassificationCDFDataPoint,
5+
} from './BlockClassificationCDFChart.types';

src/utils/classification.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,44 @@ export function getClassificationLabel(classification: string): string {
4343
return 'Unclassified';
4444
}
4545
}
46+
47+
/**
48+
* Get the description for a contributor classification
49+
*
50+
* @param classification - The classification type
51+
* @returns Description of the classification
52+
*/
53+
export function getClassificationDescription(classification: string): string {
54+
switch (classification) {
55+
case 'individual':
56+
return 'Public contributors (likely home stakers)';
57+
case 'corporate':
58+
return 'Public contributors (likely running in datacenters)';
59+
case 'internal':
60+
return 'Nodes run by the ethPandaOps team in datacenters';
61+
default:
62+
return 'Nodes with unknown classification';
63+
}
64+
}
65+
66+
/**
67+
* Classification descriptions map with all known classifications
68+
*/
69+
export const CLASSIFICATION_DESCRIPTIONS: Record<string, { label: string; description: string }> = {
70+
individual: {
71+
label: 'Individual',
72+
description: 'Public contributors (likely home stakers)',
73+
},
74+
corporate: {
75+
label: 'Corporate',
76+
description: 'Public contributors (likely running in datacenters)',
77+
},
78+
internal: {
79+
label: 'Internal (ethPandaOps)',
80+
description: 'Nodes run by the ethPandaOps team in datacenters',
81+
},
82+
unclassified: {
83+
label: 'Unclassified',
84+
description: 'Nodes with unknown classification',
85+
},
86+
};

0 commit comments

Comments
 (0)