diff --git a/src/features/Overview/ShredsProgression/ShredsChart.tsx b/src/features/Overview/ShredsProgression/ShredsChart.tsx index ba3d0c90..d39dc394 100644 --- a/src/features/Overview/ShredsProgression/ShredsChart.tsx +++ b/src/features/Overview/ShredsProgression/ShredsChart.tsx @@ -5,9 +5,13 @@ import type uPlot from "uplot"; import { chartAxisColor, gridLineColor, gridTicksColor } from "../../../colors"; import type { AlignedData } from "uplot"; import { xRangeMs } from "./const"; -import { shredsProgressionPlugin } from "./shredsProgressionPlugin"; import { useMedia, useRafLoop } from "react-use"; -import { Box } from "@radix-ui/themes"; +import { + shredsProgressionPlugin, + shredsXScaleKey, +} from "./shredsProgressionPlugin"; +import { Box, Flex } from "@radix-ui/themes"; +import ShredsSlotLabels from "./ShredsSlotLabels"; const REDRAW_INTERVAL_MS = 40; @@ -89,24 +93,25 @@ export default function ShredsChart({ width: 0, height: 0, scales: { - x: { time: false }, + [shredsXScaleKey]: { time: false }, y: { time: false, range: [0, 1], }, }, - series: [{}, {}], + series: [{ scale: shredsXScaleKey }, {}], cursor: { show: false, drag: { // disable zoom - x: false, + [shredsXScaleKey]: false, y: false, }, }, legend: { show: false }, axes: [ { + scale: shredsXScaleKey, incrs: xIncrs, size: 30, ticks: { @@ -152,21 +157,24 @@ export default function ShredsChart({ }); return ( - - - {({ height, width }) => { - options.width = width; - options.height = height; - return ( - - ); - }} - - + + {!isOnStartupScreen && } + + + {({ height, width }) => { + options.width = width; + options.height = height; + return ( + + ); + }} + + + ); } diff --git a/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx new file mode 100644 index 00000000..bf1d7823 --- /dev/null +++ b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx @@ -0,0 +1,113 @@ +import { useAtomValue } from "jotai"; +import { Flex, Text } from "@radix-ui/themes"; +import { getSlotGroupLabelId, getSlotLabelId } from "./utils"; +import styles from "./shreds.module.css"; +import { useMemo } from "react"; +import { slotsPerLeader } from "../../../consts"; +import { shredsAtoms } from "./atoms"; +import { useSlotInfo } from "../../../hooks/useSlotInfo"; +import clsx from "clsx"; +import PeerIcon from "../../../components/PeerIcon"; +import { skippedClusterSlotsAtom } from "../../../atoms"; +import { isStartupProgressVisibleAtom } from "../../StartupProgress/atoms"; + +/** + * Labels for shreds slots. + * Don't render during startup, because there will be multiple overlapping slots + * during the catching up phase. + */ +export default function ShredsSlotLabels() { + const isStartup = useAtomValue(isStartupProgressVisibleAtom); + const groupLeaderSlots = useAtomValue(shredsAtoms.groupLeaderSlots); + + if (isStartup) return; + + return ( + + {groupLeaderSlots.map((slot) => ( + + ))} + + ); +} + +interface SlotGroupLabelProps { + firstSlot: number; +} +function SlotGroupLabel({ firstSlot }: SlotGroupLabelProps) { + const { peer, name, isLeader } = useSlotInfo(firstSlot); + const slots = useMemo(() => { + return Array.from({ length: slotsPerLeader }, (_, i) => firstSlot + i); + }, [firstSlot]); + + const skippedClusterSlots = useAtomValue(skippedClusterSlotsAtom); + const skippedSlots = useMemo(() => { + const skipped = new Set(); + for (const slot of slots) { + if (skippedClusterSlots.has(slot)) { + skipped.add(slot); + } + } + return skipped; + }, [slots, skippedClusterSlots]); + + return ( + + 0, + })} + > + + + {name} + + + + + {slots.map((slot) => ( +
+ ))} + + + ); +} diff --git a/src/features/Overview/ShredsProgression/__tests__/atoms.test.tsx b/src/features/Overview/ShredsProgression/__tests__/atoms.test.tsx index 084f9109..c3037f0e 100644 --- a/src/features/Overview/ShredsProgression/__tests__/atoms.test.tsx +++ b/src/features/Overview/ShredsProgression/__tests__/atoms.test.tsx @@ -120,7 +120,7 @@ describe("live shreds atoms with reference ts and ts deltas", () => { }); }); - it("for non-startup: deletes slot numbers before max completed slot number that was completed after chart min X", () => { + it("for non-startup: deletes slot numbers before max completed slot number that was completed before chart min X", () => { vi.useFakeTimers({ toFake: ["Date"], }); @@ -161,13 +161,13 @@ describe("live shreds atoms with reference ts and ts deltas", () => { { slot: 2, // this will be deleted even if it has an event in chart range, - // because a slot number larger than it is marked as completed and being deleted + // because a slot number larger than it is marked as completed and before chart min x ts: chartRangeNs + 1_000_000, e: ShredEvent.shred_repair_request, }, { // max slot number that is complete before chart min X - // delete this and all slot numbers before it + // keep this and delete all slot numbers before it slot: 3, ts: chartRangeNs - 1_000_000, e: ShredEvent.slot_complete, @@ -252,6 +252,15 @@ describe("live shreds atoms with reference ts and ts deltas", () => { expect(result.current.slotsShreds).toEqual({ referenceTs: 0, slots: new Map([ + [ + 3, + { + shreds: [], + minEventTsDelta: -1, + maxEventTsDelta: -1, + completionTsDelta: -1, + }, + ], [ 4, { @@ -272,7 +281,7 @@ describe("live shreds atoms with reference ts and ts deltas", () => { ]), }); expect(result.current.range).toEqual({ - min: 4, + min: 3, max: 6, }); diff --git a/src/features/Overview/ShredsProgression/atoms.ts b/src/features/Overview/ShredsProgression/atoms.ts index c0f53d1c..ef18dc18 100644 --- a/src/features/Overview/ShredsProgression/atoms.ts +++ b/src/features/Overview/ShredsProgression/atoms.ts @@ -2,7 +2,8 @@ import { atom } from "jotai"; import type { LiveShreds } from "../../../api/types"; import { maxShredEvent, ShredEvent } from "../../../api/entities"; import { delayMs, xRangeMs } from "./const"; -import { nsPerMs } from "../../../consts"; +import { nsPerMs, slotsPerLeader } from "../../../consts"; +import { getSlotGroupLeader } from "../../../utils"; type ShredEventTsDeltaMs = number | undefined; /** @@ -12,8 +13,11 @@ type ShredEventTsDeltaMs = number | undefined; */ export type ShredEventTsDeltas = ShredEventTsDeltaMs[]; -type Slot = { +export type Slot = { shreds: (ShredEventTsDeltas | undefined)[]; + /** + * earliest event (start) of the slot + */ minEventTsDelta?: number; maxEventTsDelta?: number; completionTsDelta?: number; @@ -42,6 +46,18 @@ export function createLiveShredsAtoms() { */ minCompletedSlot: atom((get) => get(_minCompletedSlotAtom)), range: atom((get) => get(_slotRangeAtom)), + groupLeaderSlots: atom((get) => { + const range = get(_slotRangeAtom); + if (!range) return []; + + const slots = [getSlotGroupLeader(range.min)]; + while (slots[slots.length - 1] + slotsPerLeader - 1 < range.max) { + slots.push( + getSlotGroupLeader(slots[slots.length - 1] + slotsPerLeader), + ); + } + return slots; + }), slotsShreds: atom((get) => get(_liveShredsAtom)), addShredEvents: atom( null, @@ -187,8 +203,10 @@ export function createLiveShredsAtoms() { slot.completionTsDelta != null && isBeforeChartX(slot.completionTsDelta, now, prev.referenceTs) ) { - // once we find a slot that is complete and far enough in the past, delete all slot numbers less it + // once we find a slot that is complete and far enough in the past, + // delete all slot numbers less it but keep this one for label spacing reference shouldDeleteSlot = true; + continue; } if (shouldDeleteSlot) { @@ -203,8 +221,10 @@ export function createLiveShredsAtoms() { if (!prevRange || !prev.slots.size) { return; } - prevRange.min = Math.min(...remainingSlotNumbers); - return prevRange; + return { + min: Math.min(...remainingSlotNumbers), + max: prevRange.max, + }; }); return prev; diff --git a/src/features/Overview/ShredsProgression/shreds.module.css b/src/features/Overview/ShredsProgression/shreds.module.css new file mode 100644 index 00000000..f80cc10c --- /dev/null +++ b/src/features/Overview/ShredsProgression/shreds.module.css @@ -0,0 +1,68 @@ +.slot-group-label { + --group-x: -100000px; + --group-name-opacity: 0; + + background-color: #080b13; + border-radius: 2px; + border: 1px solid #3c4652; + will-change: transform; + transform: translate(var(--group-x)); + + &.you { + border: 1px solid #2a7edf; + } + + .slot-group-top-container { + width: 100%; + background-color: #15181e; + + &.skipped { + background-color: var(--red-2); + } + + .slot-group-name-container { + opacity: var(--group-name-opacity); + transition: opacity 0.8s; + will-change: opacity; + + .name { + font-size: 14px; + line-height: normal; + color: #ccc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .slot-bars-container { + flex-shrink: 0; + .slot-bar { + --slot-x: 0; + + position: absolute; + will-change: transform; + transform: translate(var(--slot-x)); + + height: 100%; + border-radius: 3px; + &:nth-child(1) { + background-color: var(--blue-7); + } + &:nth-child(2) { + background-color: var(--blue-6); + } + &:nth-child(3) { + background-color: var(--blue-5); + } + &:nth-child(4) { + background-color: var(--blue-4); + } + + &.skipped { + background-color: var(--red-7); + } + } + } +} diff --git a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts index 01edf313..bf7cf5e0 100644 --- a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts +++ b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts @@ -20,13 +20,24 @@ import { import { skippedClusterSlotsAtom } from "../../../atoms"; import { clamp } from "lodash"; import { ShredEvent } from "../../../api/entities"; +import { getSlotGroupLabelId, getSlotLabelId } from "./utils"; +import { slotsPerLeader } from "../../../consts"; const store = getDefaultStore(); -const xScaleKey = "x"; +export const shredsXScaleKey = "shredsXScaleKey"; type EventsByFillStyle = { [fillStyle: string]: Array<[x: number, y: number, width: number]>; }; +export type Position = [xPos: number, cssWidth: number | undefined]; +export type LabelPositions = { + groups: { + [leaderSlotNumber: number]: Position; + }; + slots: { + [slotNumber: number]: Position; + }; +}; export function shredsProgressionPlugin( isOnStartupScreen: boolean, @@ -42,7 +53,9 @@ export function shredsProgressionPlugin( const minCompletedSlot = store.get(atoms.minCompletedSlot); const skippedSlotsCluster = store.get(skippedClusterSlotsAtom); - if (!liveShreds || !slotRange) { + const maxX = u.scales[shredsXScaleKey].max; + + if (!liveShreds || !slotRange || maxX == null) { return; } @@ -69,13 +82,14 @@ export function shredsProgressionPlugin( u.ctx.clip(); // helper to get x pos - const getXPos = (xVal: number) => u.valToPos(xVal, xScaleKey, true); + const getXPos = (xVal: number) => + u.valToPos(xVal, shredsXScaleKey, true); const { maxShreds, orderedSlotNumbers } = getDrawInfo( minSlot, maxSlot, liveShreds, - u.scales[xScaleKey], + u.scales[shredsXScaleKey], tsXValueOffset, ); @@ -122,7 +136,7 @@ export function shredsProgressionPlugin( }; const slot = liveShreds.slots.get(slotNumber); - if (!slot) continue; + if (slot?.minEventTsDelta == null) continue; const isSlotSkipped = skippedSlotsCluster.has(slotNumber); @@ -149,7 +163,7 @@ export function shredsProgressionPlugin( y: (rowPxHeight + gapPxHeight) * rowIdx + u.bbox.top, getYOffset, dotWidth: rowPxHeight, - scaleX: u.scales[xScaleKey], + scaleX: u.scales[shredsXScaleKey], getXPos, }); } @@ -166,6 +180,17 @@ export function shredsProgressionPlugin( } u.ctx.restore(); + + if (!isOnStartupScreen) { + updateLabels( + slotRange, + liveShreds.slots, + skippedSlotsCluster, + u, + maxX, + tsXValueOffset, + ); + } }, ], }, @@ -281,8 +306,7 @@ function addEventsForRow({ slotCompletionTsDelta == null ? // event goes to max x maxXPos - : // event goes to slot completion or max x - Math.min(getXPos(slotCompletionTsDelta - tsXValueOffset), maxXPos); + : getXPos(slotCompletionTsDelta - tsXValueOffset); const eventPositions = new Map< Exclude, @@ -379,3 +403,347 @@ function findShredIdx( } return -1; } + +function updateLabels( + slotRange: { + min: number; + max: number; + }, + slots: SlotsShreds["slots"], + skippedSlotsCluster: Set, + u: uPlot, + maxX: number, + tsXValueOffset: number, +) { + const slotBlocks = getSlotBlocks(slotRange, slots); + const slotTsDeltas = estimateSlotTsDeltas(slotBlocks, skippedSlotsCluster); + const groupLeaderSlots = store.get(shredsAtoms.groupLeaderSlots); + const groupTsDeltas = getGroupTsDeltas(slotTsDeltas, groupLeaderSlots); + + const xValToCssPos = (xVal: number) => + u.valToPos(xVal, shredsXScaleKey, false); + const maxXPos = xValToCssPos(maxX); + + for (let groupIdx = 0; groupIdx < groupLeaderSlots.length; groupIdx++) { + const leaderSlot = groupLeaderSlots[groupIdx]; + const leaderElId = getSlotGroupLabelId(leaderSlot); + const leaderEl = document.getElementById(leaderElId); + if (!leaderEl) continue; + + const groupRange = groupTsDeltas[leaderSlot]; + + const groupPos = getPosFromTsDeltaRange( + groupRange, + tsXValueOffset, + xValToCssPos, + ); + moveLabelPosition(true, groupPos, maxXPos, leaderEl); + + for ( + let slotNumber = leaderSlot; + slotNumber < leaderSlot + slotsPerLeader; + slotNumber++ + ) { + const slotElId = getSlotLabelId(slotNumber); + const slotEl = document.getElementById(slotElId); + if (!slotEl) continue; + + const slotRange = slotTsDeltas[slotNumber]; + const slotPos = getPosFromTsDeltaRange( + slotRange, + tsXValueOffset, + xValToCssPos, + ); + + // position slot relative to its slot group + const relativeSlotPos = + slotPos && groupPos + ? ([slotPos[0] - groupPos[0], slotPos[1]] satisfies Position) + : undefined; + + moveLabelPosition(false, relativeSlotPos, maxXPos, slotEl); + } + } +} + +interface CompleteBlock { + type: "complete"; + startTsDelta: number; + endTsDelta: number; + slotNumber: number; +} +interface IncompleteBlock { + type: "incomplete"; + startTsDelta: number; + endTsDelta: number | undefined; + slotNumbers: number[]; +} +/** + * Group ordered slots into blocks that are complete / incomplete. + * Each block has a slot or array of slots sharing the same + * start and end ts + */ +function getSlotBlocks( + slotRange: { + min: number; + max: number; + }, + slots: SlotsShreds["slots"], +): Array { + const blocks: Array = []; + let incompleteBlockSlotNumbers: number[] = []; + + for ( + let slotNumber = slotRange.min; + slotNumber <= slotRange.max; + slotNumber++ + ) { + const slot = slots.get(slotNumber); + + if (slot?.minEventTsDelta == null) { + // We don't want incomplete blocks with unknown start ts, so + // don't collect incomplete blocks until we have at least one block stored + if (blocks.length === 0) continue; + + // add missing slot to incomplete block + incompleteBlockSlotNumbers.push(slotNumber); + continue; + } + + // mark incomplete block's end with current slot's start + if (incompleteBlockSlotNumbers.length) { + const blockStart = getIncompleteBlockStart( + incompleteBlockSlotNumbers, + slots, + blocks[blocks.length - 1], + ); + if (!blockStart) break; + + blocks.push({ + type: "incomplete", + startTsDelta: blockStart, + endTsDelta: slot.minEventTsDelta, + slotNumbers: incompleteBlockSlotNumbers, + }); + + // reset current incomplete block + incompleteBlockSlotNumbers = []; + } + + if (slot.completionTsDelta != null) { + blocks.push({ + type: "complete", + startTsDelta: slot.minEventTsDelta, + endTsDelta: slot.completionTsDelta, + slotNumber, + }); + } else { + // incomplete + incompleteBlockSlotNumbers.push(slotNumber); + } + } + + // add final incomplete block + if (incompleteBlockSlotNumbers.length) { + const blockStart = getIncompleteBlockStart( + incompleteBlockSlotNumbers, + slots, + blocks[blocks.length - 1], + ); + if (!blockStart) return blocks; + + blocks.push({ + type: "incomplete", + startTsDelta: blockStart, + endTsDelta: undefined, + slotNumbers: incompleteBlockSlotNumbers, + }); + } + return blocks; +} + +/** + * + * incomplete block starts at either start of first + * slot in the block, or end of the previous block + */ +function getIncompleteBlockStart( + blockSlotNumbers: number[], + slots: SlotsShreds["slots"], + previousBlock: CompleteBlock | IncompleteBlock, +) { + const firstSlotNumber = blockSlotNumbers[0]; + const startFirstSlotNumber = slots.get(firstSlotNumber)?.minEventTsDelta; + + const prevBlockEnd = + previousBlock.type === "complete" + ? previousBlock.endTsDelta + : previousBlock.endTsDelta; + + // incomplete block started at either start of first + // slot, or end of previous block + const blockStart = startFirstSlotNumber ?? prevBlockEnd; + if (blockStart == null) { + console.error( + `Missing block start ts for incomplete block beginning at ${firstSlotNumber}`, + ); + return; + } + + return blockStart; +} + +type TsDeltaRange = [startTsDelta: number, endTsDelta: number | undefined]; + +/** + * Get each slot's start and end ts deltas. + * Some slots will not have end ts deltas, and would extend to the max X axis value + * Incomplete blocks: + * - split the range (incomplete block start ts to next start ts) equally among the slots + * - skipped slots will have the above range, offset by its index in the incomplete block + * - non-skipped slots will extend from the incomplete block start to the max X axis value + * - if there is no next start ts, only include the first slot in the block, ending at max X ts + */ +function estimateSlotTsDeltas( + slotBlocks: Array, + skippedSlotsCluster: Set, +) { + const slotTsDeltas: { + [slotNumber: number]: TsDeltaRange; + } = {}; + + for (const block of slotBlocks) { + if (block.type === "complete") { + slotTsDeltas[block.slotNumber] = [block.startTsDelta, block.endTsDelta]; + continue; + } + + if (block.endTsDelta == null) { + // unknown incomplete block end time + // only include first slot, because we don't have a good estimate for when the others would have started + slotTsDeltas[block.slotNumbers[0]] = [block.startTsDelta, undefined]; + continue; + } + + // known block end time + // split block range equally to determine slot start ts + const singleSlotTsRange = + (block.endTsDelta - block.startTsDelta) / block.slotNumbers.length; + for (let i = 0; i < block.slotNumbers.length; i++) { + const slotNumber = block.slotNumbers[i]; + const slotStart = block.startTsDelta + i * singleSlotTsRange; + + const slotEnd = skippedSlotsCluster.has(slotNumber) + ? slotStart + singleSlotTsRange + : undefined; + slotTsDeltas[slotNumber] = [slotStart, slotEnd]; + } + } + + return slotTsDeltas; +} + +/** + * Get start and end ts deltas for group, from its slots ts deltas + * Undefined end indicates the group extends to max X + */ +function getGroupTsDeltas( + slotTsDeltas: { + [slotNumber: number]: TsDeltaRange; + }, + groupLeaderSlots: number[], +) { + const tsDeltasByGroup: { + [leaderSlotNumber: number]: TsDeltaRange; + } = {}; + + for (const leaderSlot of groupLeaderSlots) { + let minStart = Infinity; + let maxEnd = -Infinity; + for (let slot = leaderSlot; slot < leaderSlot + slotsPerLeader; slot++) { + const slotStart = slotTsDeltas[slot]?.[0]; + const slotEnd = slotTsDeltas[slot]?.[1]; + + if (slotStart !== undefined) { + minStart = Math.min(slotStart, minStart); + } + + // don't track end times for initial undefined slots + const hasSeenDefinedSlot = minStart !== Infinity; + if (!hasSeenDefinedSlot) continue; + + // undefind slotEnd means the slot extends to the max X + maxEnd = Math.max(slotEnd ?? Infinity, maxEnd); + } + + // no defined slots + if (minStart === Infinity || maxEnd === -Infinity) { + continue; + } + + tsDeltasByGroup[leaderSlot] = [ + minStart, + // convert back to undefined + maxEnd === Infinity ? undefined : maxEnd, + ]; + } + return tsDeltasByGroup; +} + +/** + * If missing range end, set width as undefined + */ +function getPosFromTsDeltaRange( + tsDeltaRange: TsDeltaRange, + tsXValueOffset: number, + valToCssPos: (val: number) => number, +): Position | undefined { + if (!tsDeltaRange) return undefined; + const xStartVal = tsDeltaRange[0] - tsXValueOffset; + const xStartPos = valToCssPos(xStartVal); + + if (tsDeltaRange[1] == null) { + return [xStartPos, undefined]; + } + + const xEndVal = tsDeltaRange[1] - tsXValueOffset; + const xEndPos = valToCssPos(xEndVal); + return [xStartPos, xEndPos - xStartPos]; +} + +/** + * Update label element styles + */ +function moveLabelPosition( + isGroup: boolean, + position: Position | undefined, + maxXPos: number, + el: HTMLElement, +) { + const groupBorderOffset = 1; + const xPosProp = isGroup ? "--group-x" : "--slot-x"; + + const isVisible = !!position; + if (!isVisible) { + // hide + el.style.setProperty(xPosProp, "-100000px"); + return; + } + + const [xPos, width] = position; + el.style.setProperty( + xPosProp, + `${xPos + (isGroup ? groupBorderOffset : 0)}px`, + ); + + // If missing width, extend to max width (with extra px to hide right border) + const newWidth = width ?? maxXPos - xPos + 1; + el.style.width = `${newWidth + (isGroup ? groupBorderOffset * 2 : 0)}px`; + + const isExtended = width == null; + if (isGroup) { + // Extended groups don't have a defined end, so we don't know where to center the name text. + // Set to opacity 0, and transition to 1 when the group end becomes defined. + el.style.setProperty("--group-name-opacity", isExtended ? "0" : "1"); + } +} diff --git a/src/features/Overview/ShredsProgression/utils.ts b/src/features/Overview/ShredsProgression/utils.ts new file mode 100644 index 00000000..4551d2ee --- /dev/null +++ b/src/features/Overview/ShredsProgression/utils.ts @@ -0,0 +1,9 @@ +import { getSlotGroupLeader } from "../../../utils"; + +export function getSlotGroupLabelId(slot: number) { + return `slot-group-label-${getSlotGroupLeader(slot)}`; +} + +export function getSlotLabelId(slot: number) { + return `slot-label-${slot}`; +}