Skip to content

Commit ac45b08

Browse files
Feat: compact, centered carousel styled slot detail navigation
1 parent 83804b2 commit ac45b08

File tree

10 files changed

+450
-291
lines changed

10 files changed

+450
-291
lines changed

src/atoms.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export const setSlotStatusAtom = atom(
147147
},
148148
);
149149

150-
const selectedSlotNearbyOffset = 2;
150+
const selectedSlotNearbyOffset = 10;
151151
const selectedSlotNearbyYouLeadersAtom = atom<number[] | undefined>((get) => {
152152
const leaderSlots = get(leaderSlotsAtom);
153153
const selectedSlot = get(selectedSlotAtom);

src/colors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ export const circularProgressTrailColor = "#666666";
163163
export const circularProgressPathColor = slotStatusBlue;
164164

165165
// slot details
166-
export const slotDetailsMySlotsColor = "#0080e6";
166+
export const slotDetailsMySlotsNotSelectedColor = "#19457A";
167167
export const slotDetailsSearchLabelColor = "#FFF";
168168
export const slotDetailsQuickSearchTextColor = "var(--gray-10)";
169169
export const slotDetailsEarliestSlotColor = "var(--teal-9)";
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { useEffect, type PropsWithChildren } from "react";
2+
import { Portal } from "radix-ui";
3+
import { useAtomValue } from "jotai";
4+
import { containerElAtom } from "../atoms";
5+
import { useMeasure } from "react-use";
6+
import type { UseMeasureRect } from "react-use/lib/useMeasure";
7+
8+
interface OffscreenProbeProps {
9+
onMeasured: (measureRect: UseMeasureRect) => void;
10+
}
11+
12+
export default function MeasureOffscreen({
13+
onMeasured,
14+
children,
15+
}: PropsWithChildren<OffscreenProbeProps>) {
16+
const containerEl = useAtomValue(containerElAtom);
17+
const [measureRef, measureRect] = useMeasure<HTMLDivElement>();
18+
19+
useEffect(() => onMeasured(measureRect), [measureRect, onMeasured]);
20+
21+
return (
22+
<Portal.Root
23+
container={containerEl}
24+
style={{
25+
position: "fixed",
26+
left: "-100000px",
27+
top: "-100000px",
28+
visibility: "hidden",
29+
}}
30+
ref={measureRef}
31+
aria-hidden="true"
32+
>
33+
{children}
34+
</Portal.Root>
35+
);
36+
}

src/consts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export const txnErrorCodeMap: Record<string, string> = {
5757
export const nonBreakingSpace = "\u00A0";
5858

5959
export const clusterIndicatorHeight = 5;
60-
export const slotNavHeight = 36.5;
60+
export const slotNavHeight = 29;
6161

6262
export const headerHeight = 48;
6363
export const headerSpacing = 13;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Flex, Text } from "@radix-ui/themes";
2+
import { useAtomValue } from "jotai";
3+
import { selectedSlotAtom } from "../Overview/SlotPerformance/atoms";
4+
import { useSlotInfo } from "../../hooks/useSlotInfo";
5+
import styles from "./slotDetailsHeader.module.css";
6+
import PeerIcon from "../../components/PeerIcon";
7+
import SlotClient from "../../components/SlotClient";
8+
9+
export default function SlotDetailsHeader() {
10+
const slot = useAtomValue(selectedSlotAtom) ?? -1;
11+
const { peer, isLeader, name } = useSlotInfo(slot);
12+
13+
return (
14+
<Flex
15+
gap="3"
16+
wrap="wrap"
17+
className={styles.header}
18+
align="center"
19+
justify="start"
20+
>
21+
<PeerIcon url={peer?.info?.icon_url} size={22} isYou={isLeader} />
22+
<Text className={styles.slotName}>{name}</Text>
23+
<Flex gap="1">
24+
<SlotClient slot={slot} size="large" />
25+
</Flex>
26+
</Flex>
27+
);
28+
}
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { Box, Flex, Text } from "@radix-ui/themes";
2+
import { useAtomValue } from "jotai";
3+
import type React from "react";
4+
import {
5+
useCallback,
6+
useMemo,
7+
useRef,
8+
useState,
9+
type PropsWithChildren,
10+
} from "react";
11+
import { selectedSlotAtom } from "../Overview/SlotPerformance/atoms";
12+
import {
13+
firstProcessedSlotAtom,
14+
lastProcessedLeaderAtom,
15+
leaderSlotsAtom,
16+
} from "../../atoms";
17+
import {
18+
clusterIndicatorHeight,
19+
headerHeight,
20+
maxZIndex,
21+
slotNavHeight,
22+
slotsPerLeader,
23+
} from "../../consts";
24+
import { useMeasure } from "react-use";
25+
import { Link } from "@tanstack/react-router";
26+
import styles from "./slotNavigation.module.css";
27+
import clsx from "clsx";
28+
import MeasureOffscreen from "../../components/MeasureOffscreen";
29+
import { skippedSlotsAtom } from "../../api/atoms";
30+
import { SkippedIcon, StatusIcon } from "../../components/StatusIcon";
31+
import { useSlotQueryPublish } from "../../hooks/useSlotQuery";
32+
import { getSlotGroupLeader } from "../../utils";
33+
import { clamp } from "lodash";
34+
35+
const navigationTop = clusterIndicatorHeight + headerHeight;
36+
const itemGroupContainerGap = 4;
37+
38+
function getSpacerWidth(groupCount: number, itemGroupWidth: number) {
39+
return (
40+
groupCount * itemGroupWidth +
41+
// Adds n-1 gaps for n groups as itemGroupWidth only measures the width of the groups themselves
42+
Math.max(0, groupCount - 1) * itemGroupContainerGap
43+
);
44+
}
45+
46+
export default function SlotNavigation() {
47+
const selectedSlot = useAtomValue(selectedSlotAtom);
48+
const leaderSlots = useAtomValue(leaderSlotsAtom);
49+
const [itemWidth, setItemWidth] = useState(0);
50+
const [itemGroupWidth, setItemGroupWidth] = useState(0);
51+
const [measureRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
52+
const trackElRef = useRef<HTMLDivElement>(null);
53+
const [offset, setOffset] = useState(0);
54+
55+
const centerSelectedSlotItem = useCallback(
56+
(el: HTMLAnchorElement | null) => {
57+
if (!el) return;
58+
requestAnimationFrame(() => {
59+
const offset =
60+
containerWidth / 2 - (el.offsetLeft + el.offsetWidth / 2);
61+
trackElRef.current?.style.setProperty("--offset", `${offset}px`);
62+
setOffset(offset);
63+
});
64+
},
65+
[containerWidth],
66+
);
67+
68+
const slotGroupLeaderIdx = useMemo(() => {
69+
if (selectedSlot === undefined || !leaderSlots) return -1;
70+
return leaderSlots.indexOf(getSlotGroupLeader(selectedSlot));
71+
}, [leaderSlots, selectedSlot]);
72+
73+
const calcs = useMemo(() => {
74+
if (slotGroupLeaderIdx < 0 || !leaderSlots) return;
75+
76+
const viewportGroupsEachSide = Math.max(
77+
1,
78+
Math.ceil(containerWidth / 2 / itemGroupWidth),
79+
);
80+
const overscanGroupsEachSide = clamp(viewportGroupsEachSide, 1, 10);
81+
const itemGroupsEachSide = viewportGroupsEachSide + overscanGroupsEachSide;
82+
const totalItemGroups = leaderSlots.length;
83+
84+
const startItemGroupIdx = Math.max(
85+
0,
86+
slotGroupLeaderIdx - itemGroupsEachSide,
87+
);
88+
const endItemGroupIdx = Math.min(
89+
totalItemGroups - 1,
90+
slotGroupLeaderIdx + itemGroupsEachSide,
91+
);
92+
93+
const rightGroups = totalItemGroups - 1 - endItemGroupIdx;
94+
95+
return {
96+
leftSpacerWidth: getSpacerWidth(startItemGroupIdx, itemGroupWidth),
97+
rightSpacerWidth: getSpacerWidth(rightGroups, itemGroupWidth),
98+
startItemGroupIdx,
99+
endItemGroupIdx,
100+
};
101+
}, [containerWidth, itemGroupWidth, leaderSlots, slotGroupLeaderIdx]);
102+
103+
const { showFadeLeft, showFadeRight } = useMemo(() => {
104+
const showFadeLeft = offset < 0;
105+
const showFadeRight =
106+
(trackElRef.current?.offsetWidth ?? 0) - containerWidth + offset > 0;
107+
return { showFadeLeft, showFadeRight };
108+
}, [containerWidth, offset]);
109+
110+
if (!leaderSlots || !calcs || selectedSlot === undefined) return;
111+
112+
const navItemGroups = [];
113+
114+
if (itemGroupWidth && itemWidth) {
115+
for (let i = calcs.startItemGroupIdx; i <= calcs.endItemGroupIdx; i++) {
116+
const navItemGroup = [];
117+
const leaderSlot = leaderSlots[i];
118+
119+
for (let j = 0; j < slotsPerLeader; j++) {
120+
const slot = leaderSlot + j;
121+
const isSelected = slot === selectedSlot;
122+
navItemGroup.push(
123+
<SlotNavItem
124+
key={slot}
125+
slot={slot}
126+
isSelected={isSelected}
127+
onSelectedSlotRef={isSelected ? centerSelectedSlotItem : undefined}
128+
/>,
129+
);
130+
}
131+
132+
const isGroupSelected =
133+
leaderSlot <= selectedSlot &&
134+
selectedSlot < leaderSlot + slotsPerLeader;
135+
136+
navItemGroups.push(
137+
<SlotNavItemGroup
138+
key={leaderSlot}
139+
slot={leaderSlot}
140+
isSelected={isGroupSelected}
141+
>
142+
{navItemGroup}
143+
</SlotNavItemGroup>,
144+
);
145+
}
146+
}
147+
148+
return (
149+
<>
150+
<Flex
151+
className="sticky"
152+
top={`${navigationTop}px`}
153+
overflow="hidden"
154+
position="relative"
155+
ref={measureRef}
156+
style={{
157+
zIndex: maxZIndex - 3,
158+
height: `${slotNavHeight}px`,
159+
}}
160+
>
161+
<Flex
162+
ref={trackElRef}
163+
style={
164+
{
165+
willChange: "transform",
166+
transition: "transform 300ms ease",
167+
transform: `translateX(var(--offset, ${offset}px))`,
168+
gap: `${itemGroupContainerGap}px`,
169+
["--item-width"]: `${itemWidth}px`,
170+
} as React.CSSProperties
171+
}
172+
>
173+
<Box width={`${calcs.leftSpacerWidth}px`} />
174+
{navItemGroups}
175+
<Box width={`${calcs.rightSpacerWidth}px`} />
176+
</Flex>
177+
{showFadeLeft && (
178+
<div
179+
className={clsx(styles.fade, styles.fadeLeft)}
180+
aria-hidden="true"
181+
/>
182+
)}
183+
{showFadeRight && (
184+
<div
185+
className={clsx(styles.fade, styles.fadeRight)}
186+
aria-hidden="true"
187+
/>
188+
)}
189+
</Flex>
190+
<MeasureItems
191+
leaderSlots={leaderSlots}
192+
setItemWidth={setItemWidth}
193+
setItemGroupWidth={setItemGroupWidth}
194+
/>
195+
</>
196+
);
197+
}
198+
199+
interface MeasureItemsProps {
200+
leaderSlots: number[];
201+
setItemWidth: (width: number) => void;
202+
setItemGroupWidth: (width: number) => void;
203+
}
204+
205+
function MeasureItems({
206+
leaderSlots,
207+
setItemWidth,
208+
setItemGroupWidth,
209+
}: MeasureItemsProps) {
210+
const lastEpochSlot = leaderSlots[leaderSlots.length - 1];
211+
return (
212+
<>
213+
<MeasureOffscreen onMeasured={(rect) => setItemWidth(rect.width)}>
214+
<SlotNavItem slot={leaderSlots[leaderSlots.length - 1]} isSelected />
215+
</MeasureOffscreen>
216+
<MeasureOffscreen onMeasured={(rect) => setItemGroupWidth(rect.width)}>
217+
<SlotNavItemGroup slot={lastEpochSlot}>
218+
{new Array(slotsPerLeader).fill(0).map((_, idx) => (
219+
<SlotNavItem key={idx} slot={lastEpochSlot - idx} isSelected />
220+
))}
221+
</SlotNavItemGroup>
222+
</MeasureOffscreen>
223+
</>
224+
);
225+
}
226+
227+
interface SlotNavItemGroupProps extends PropsWithChildren {
228+
slot: number;
229+
isSelected?: boolean;
230+
}
231+
232+
function SlotNavItemGroup({
233+
slot,
234+
isSelected,
235+
children,
236+
}: SlotNavItemGroupProps) {
237+
const isBeforeFirstProcessed =
238+
slot < (useAtomValue(firstProcessedSlotAtom) ?? -1);
239+
const isAfterLastProcessed =
240+
slot > (useAtomValue(lastProcessedLeaderAtom) ?? Infinity);
241+
const isDisabled = isBeforeFirstProcessed || isAfterLastProcessed;
242+
243+
return (
244+
<Flex
245+
className={clsx(styles.slotItemGroup, {
246+
[styles.disabled]: isDisabled,
247+
[styles.isSelected]: isSelected,
248+
})}
249+
>
250+
{children}
251+
</Flex>
252+
);
253+
}
254+
255+
interface SlotNavItemProps {
256+
slot: number;
257+
isSelected: boolean;
258+
onSelectedSlotRef?: (el: HTMLAnchorElement) => void;
259+
}
260+
261+
function SlotNavItem({
262+
slot,
263+
isSelected,
264+
onSelectedSlotRef,
265+
}: SlotNavItemProps) {
266+
// TODO: refactor after fixing querying caching mechanism
267+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
268+
const queryPublish = useSlotQueryPublish(slot);
269+
const isSkipped = useAtomValue(skippedSlotsAtom)?.includes(slot);
270+
const isBeforeFirstProcessed =
271+
slot < (useAtomValue(firstProcessedSlotAtom) ?? -1);
272+
const isAfterLastProcessed =
273+
slot >=
274+
(useAtomValue(lastProcessedLeaderAtom) ?? Infinity) + slotsPerLeader;
275+
const isDisabled = isBeforeFirstProcessed || isAfterLastProcessed;
276+
277+
return (
278+
<Link
279+
to="/slotDetails"
280+
search={{ slot }}
281+
key={slot}
282+
className={clsx(styles.slotItem, {
283+
[styles.selectedSlot]: isSelected,
284+
[styles.skippedSlot]: isSkipped,
285+
})}
286+
ref={onSelectedSlotRef}
287+
disabled={isDisabled}
288+
>
289+
<Text>{slot}</Text>
290+
{isSkipped ? (
291+
<SkippedIcon size="large" />
292+
) : (
293+
<StatusIcon isCurrent={false} slot={slot} size="large" />
294+
)}
295+
</Link>
296+
);
297+
}

0 commit comments

Comments
 (0)