From b54404c273a5f6aaca3c1e4a2157296c68038319 Mon Sep 17 00:00:00 2001 From: Ami Suzuki Date: Fri, 14 Nov 2025 11:41:58 -0600 Subject: [PATCH 1/5] feat: shreds slot labels --- .../ShredsProgression/ShredsChart.tsx | 46 +- .../ShredsProgression/ShredsSlotLabels.tsx | 107 +++++ .../__tests__/atoms.test.tsx | 17 +- .../Overview/ShredsProgression/atoms.ts | 25 +- .../ShredsProgression/shreds.module.css | 64 +++ .../shredsProgressionPlugin.ts | 417 +++++++++++++++++- .../Overview/ShredsProgression/utils.ts | 9 + 7 files changed, 654 insertions(+), 31 deletions(-) create mode 100644 src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx create mode 100644 src/features/Overview/ShredsProgression/shreds.module.css create mode 100644 src/features/Overview/ShredsProgression/utils.ts diff --git a/src/features/Overview/ShredsProgression/ShredsChart.tsx b/src/features/Overview/ShredsProgression/ShredsChart.tsx index ba3d0c90..54153153 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, + type LabelPositions, +} from "./shredsProgressionPlugin"; +import { Box, Flex } from "@radix-ui/themes"; +import ShredsSlotLabels from "./ShredsSlotLabels"; const REDRAW_INTERVAL_MS = 40; @@ -65,6 +69,7 @@ export default function ShredsChart({ const uplotRef = useRef(); const lastRedrawRef = useRef(0); + const labelPositionsRef = useRef(); const handleCreate = useCallback((u: uPlot) => { uplotRef.current = u; @@ -136,7 +141,7 @@ export default function ShredsChart({ }, }, ], - plugins: [shredsProgressionPlugin(isOnStartupScreen)], + plugins: [shredsProgressionPlugin(isOnStartupScreen, labelPositionsRef)], }; }, [isOnStartupScreen, xIncrs]); @@ -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..13cdc629 --- /dev/null +++ b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx @@ -0,0 +1,107 @@ +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..46c3eb53 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; /** @@ -42,6 +43,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 +200,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 +218,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..016e48b5 --- /dev/null +++ b/src/features/Overview/ShredsProgression/shreds.module.css @@ -0,0 +1,64 @@ +.slot-group-label { + --group-x: 0; + --group-y: -150%; + --group-name-opacity: 0; + + background-color: #15181e; + border-radius: 2px; + border: 1px solid #3c4652; + will-change: transform, width; + transform: translate(var(--group-x), var(--group-y)); + + &.you { + border: 1px solid #2a7edf; + } + + &.skipped { + background-color: var(--red-2); + } + + .slot-group-name-container { + opacity: var(--group-name-opacity); + transition: opacity 1s; + will-change: opacity; + + .name { + font-size: 14px; + line-height: normal; + color: #ccc; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + .slot-bars-container { + .slot-bar { + --slot-x: 0; + --slot-opacity: -150%; + + position: absolute; + will-change: transform, width; + transform: translate(var(--slot-x), var(--slot-y)); + + height: 100%; + border-radius: 3px; + &:nth-child(1) { + background-color: var(--blue-9); + } + &:nth-child(2) { + background-color: var(--blue-7); + } + &:nth-child(3) { + background-color: var(--blue-5); + } + &:nth-child(4) { + background-color: var(--blue-3); + } + + &.skipped { + background-color: var(--red-7); + } + } + } +} diff --git a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts index 01edf313..6cfcf146 100644 --- a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts +++ b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts @@ -20,6 +20,8 @@ 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"; @@ -27,9 +29,19 @@ const xScaleKey = "x"; 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, + labelPositionsRef: React.MutableRefObject, ): uPlot.Plugin { return { hooks: { @@ -41,8 +53,9 @@ export function shredsProgressionPlugin( const slotRange = store.get(atoms.range); const minCompletedSlot = store.get(atoms.minCompletedSlot); const skippedSlotsCluster = store.get(skippedClusterSlotsAtom); + const maxX = u.scales[xScaleKey].max; - if (!liveShreds || !slotRange) { + if (!liveShreds || !slotRange || maxX == null) { return; } @@ -122,7 +135,7 @@ export function shredsProgressionPlugin( }; const slot = liveShreds.slots.get(slotNumber); - if (!slot) continue; + if (slot?.minEventTsDelta == null) continue; const isSlotSkipped = skippedSlotsCluster.has(slotNumber); @@ -166,6 +179,18 @@ export function shredsProgressionPlugin( } u.ctx.restore(); + + if (!isOnStartupScreen) { + updateLabels( + slotRange, + liveShreds.slots, + skippedSlotsCluster, + u, + maxX, + tsXValueOffset, + labelPositionsRef, + ); + } }, ], }, @@ -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,388 @@ function findShredIdx( } return -1; } + +function updateLabels( + slotRange: { + min: number; + max: number; + }, + slots: SlotsShreds["slots"], + skippedSlotsCluster: Set, + u: uPlot, + maxX: number, + tsXValueOffset: number, + labelPositionsRef: React.MutableRefObject, +) { + const slotBlocks = getSlotBlocks(slotRange, slots); + const slotTsDeltas = estimateSlotTsDeltas(slotBlocks, skippedSlotsCluster); + const groupLeaderSlots = store.get(shredsAtoms.groupLeaderSlots); + const groupTsDeltas = getGroupTsDeltas(slotTsDeltas, groupLeaderSlots); + + const newLabelPositions: LabelPositions = { + groups: {}, + slots: {}, + }; + + const xValToCssPos = (xVal: number) => u.valToPos(xVal, xScaleKey, 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, + leaderSlot, + groupPos, + maxXPos, + leaderEl, + labelPositionsRef.current, + newLabelPositions, + ); + + 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, + slotNumber, + relativeSlotPos, + maxXPos, + slotEl, + labelPositionsRef.current, + newLabelPositions, + ); + } + } + + // update stored positions + labelPositionsRef.current = newLabelPositions; +} + +interface CompleteBlock { + type: "complete"; + startTsDelta: number; + completionTsDelta: number; + slotNumber: number; +} +interface IncompleteBlock { + type: "incomplete"; + startTsDelta: number; + nextCompletionTsDelta: number | undefined; + nextStartTsDelta: number | undefined; + slotNumbers: number[]; +} +/** + * Group ordered slots into blocks that are compelted / incomplete + */ +function getSlotBlocks( + slotRange: { + min: number; + max: number; + }, + slots: SlotsShreds["slots"], +): Array { + // skip to the first defined slot + let firstDefinedSlotNumber = undefined; + for ( + let slotNumber = slotRange.min; + slotNumber <= slotRange.max; + slotNumber++ + ) { + const slot = slots.get(slotNumber); + if (slot?.minEventTsDelta != null) { + firstDefinedSlotNumber = slotNumber; + break; + } + } + + if (firstDefinedSlotNumber === undefined) return []; + + // Collect all blocks based on shared start and completion ts + // For incomplete blocks, include next start ts but not next completion ts yet + const blocks: Array = []; + let incompleteBlockSlotNumbers: number[] = []; + let incompleteBlockStart: number | undefined = undefined; + + for ( + let slotNumber = firstDefinedSlotNumber; + slotNumber <= slotRange.max; + slotNumber++ + ) { + const slot = slots.get(slotNumber); + + // add missing slot to incomplete block + if (slot?.minEventTsDelta == null) { + incompleteBlockSlotNumbers.push(slotNumber); + continue; + } + + // mark potential end for incomplete block + if (incompleteBlockSlotNumbers.length && incompleteBlockStart != null) { + blocks.push({ + type: "incomplete", + startTsDelta: incompleteBlockStart, + nextStartTsDelta: slot.minEventTsDelta, + nextCompletionTsDelta: undefined, + slotNumbers: incompleteBlockSlotNumbers, + }); + + // reset current incomplete block + incompleteBlockSlotNumbers = []; + } + + if (slot.completionTsDelta != null) { + blocks.push({ + type: "complete", + startTsDelta: slot.minEventTsDelta, + completionTsDelta: slot.completionTsDelta, + slotNumber, + }); + incompleteBlockStart = slot.completionTsDelta; + } else { + // incomplete + incompleteBlockStart = slot.minEventTsDelta; + incompleteBlockSlotNumbers.push(slotNumber); + } + } + + // add final incomplete block + if (incompleteBlockSlotNumbers.length && incompleteBlockStart != null) { + blocks.push({ + type: "incomplete", + startTsDelta: incompleteBlockStart, + nextStartTsDelta: undefined, + nextCompletionTsDelta: undefined, + slotNumbers: incompleteBlockSlotNumbers, + }); + } + + // iterate backwards to populate incomplete blocks with next completion ts deltas + let nextCompletionTsDelta = undefined; + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block.type === "complete") { + nextCompletionTsDelta = block.completionTsDelta; + continue; + } + block.nextCompletionTsDelta = nextCompletionTsDelta; + } + + return blocks; +} + +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 next completion start + * (when a slot is completed, all previous slots are considered completed too) + * - if there is no next start ts or next completion 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.completionTsDelta, + ]; + continue; + } + + if (block.nextStartTsDelta == 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.nextStartTsDelta - 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 + : block.nextCompletionTsDelta; + slotTsDeltas[slotNumber] = [slotStart, slotEnd]; + } + } + + return slotTsDeltas; +} + +function getGroupTsDeltas( + slotTsDeltas: { + [slotNumber: number]: TsDeltaRange; + }, + groupLeaderSlots: number[], +) { + const groupTsDeltas: { + [leaderSlotNumber: number]: TsDeltaRange; + } = {}; + + for (const leaderSlot of groupLeaderSlots) { + // get first defined slot + // ignore missing slots at the start when positioning group + let firstDefinedSlot = undefined; + for ( + let slotNumber = leaderSlot; + slotNumber < leaderSlot + slotsPerLeader; + slotNumber++ + ) { + if (slotNumber in slotTsDeltas) { + firstDefinedSlot = slotNumber; + break; + } + } + + if (firstDefinedSlot === undefined) { + continue; + } + + let groupStart = Infinity; + let groupEnd = -Infinity; + + for ( + let slotNumber = firstDefinedSlot; + slotNumber < leaderSlot + slotsPerLeader; + slotNumber++ + ) { + const slotTsDeltaRange = slotTsDeltas[slotNumber]; + const slotStart = slotTsDeltaRange?.[0]; + const slotEnd = slotTsDeltaRange?.[1]; + + if (slotStart != null) { + groupStart = Math.min(groupStart, slotStart); + } + // undefined slot end will extend to max x + groupEnd = Math.max(groupEnd, slotEnd ?? Infinity); + } + + // replace Infinity with undefined for end + const end = groupEnd === Infinity ? undefined : groupEnd; + groupTsDeltas[leaderSlot] = [groupStart, end]; + } + return groupTsDeltas; +} + +/** + * 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 changed styles and store new positions in labelPositions + */ +function moveLabelPosition( + isGroup: boolean, + slotNumber: number, + position: Position | undefined, + maxXPos: number, + el: HTMLElement, + prevLabelPositions: LabelPositions | undefined, + labelPositionsToMutate: LabelPositions, +) { + // force all updates if previously, nothing was positioned + const forceUpdates = !prevLabelPositions; + + const key = isGroup ? "groups" : "slots"; + const positionsToMutate = labelPositionsToMutate[key]; + + const isVisible = !!position; + const prevPositions = prevLabelPositions?.[key]; + const prevVisible = !!prevPositions?.[slotNumber]; + + if (forceUpdates || isVisible !== prevVisible) { + // hide / show + const prop = isGroup ? "--group-y" : "--slot-y"; + el.style.setProperty(prop, isVisible ? "0" : "-150%"); + } + + if (!isVisible) return; + positionsToMutate[slotNumber] = position; + + const xPos = position[0]; + const prevXPos = prevPositions?.[slotNumber]?.[0]; + + if (forceUpdates || xPos !== prevXPos) { + const prop = isGroup ? "--group-x" : "--slot-x"; + el.style.setProperty(prop, `${xPos}px`); + } + + // no width data -- extend to max width + const width = position[1]; + const isExtended = position[1] == null; + const extendedWidth = width ?? maxXPos - xPos; + const prevWidth = prevPositions?.[slotNumber]?.[1]; + + if (forceUpdates || extendedWidth !== prevWidth) { + el.style.width = `${extendedWidth}px`; + } + + const wasPrevExtended = + prevPositions?.[slotNumber] && prevPositions[slotNumber][1] == null; + + if (isGroup && (forceUpdates || isExtended !== wasPrevExtended)) { + 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}`; +} From 0d343046d3efd327225310ba77b589ff97aaf033 Mon Sep 17 00:00:00 2001 From: Ami Suzuki Date: Mon, 24 Nov 2025 14:46:58 -0600 Subject: [PATCH 2/5] chore: color changes --- .../ShredsProgression/ShredsSlotLabels.tsx | 46 ++++++++++------- .../ShredsProgression/shreds.module.css | 50 ++++++++++--------- .../shredsProgressionPlugin.ts | 15 +++--- 3 files changed, 65 insertions(+), 46 deletions(-) diff --git a/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx index 13cdc629..af3ba79c 100644 --- a/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx +++ b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx @@ -11,6 +11,8 @@ import PeerIcon from "../../../components/PeerIcon"; import { skippedClusterSlotsAtom } from "../../../atoms"; import { isStartupProgressVisibleAtom } from "../../StartupProgress/atoms"; +const height = 30; + /** * Labels for shreds slots. * Don't render during startup, because there will be multiple overlapping slots @@ -23,7 +25,12 @@ export default function ShredsSlotLabels() { if (isStartup) return; return ( - + {groupLeaderSlots.map((slot) => ( ))} @@ -53,36 +60,41 @@ function SlotGroupLabel({ firstSlot }: SlotGroupLabelProps) { return ( 0, })} > 0, + })} > - - {name} + + + {name} + Date: Tue, 25 Nov 2025 13:39:33 -0600 Subject: [PATCH 3/5] chore: address feedback --- .../ShredsProgression/ShredsSlotLabels.tsx | 1 + .../shredsProgressionPlugin.ts | 67 +++++++------------ 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx index af3ba79c..99d10d5e 100644 --- a/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx +++ b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx @@ -30,6 +30,7 @@ export default function ShredsSlotLabels() { position="relative" // extra space for borders height={`${height + 2}px`} + style={{ opacity: 0.8 }} > {groupLeaderSlots.map((slot) => ( diff --git a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts index 27714230..8e3a4d50 100644 --- a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts +++ b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts @@ -667,46 +667,28 @@ function getGroupTsDeltas( } = {}; for (const leaderSlot of groupLeaderSlots) { - // get first defined slot - // ignore missing slots at the start when positioning group - let firstDefinedSlot = undefined; - for ( - let slotNumber = leaderSlot; - slotNumber < leaderSlot + slotsPerLeader; - slotNumber++ - ) { - if (slotNumber in slotTsDeltas) { - firstDefinedSlot = slotNumber; - break; - } - } + const slotNumbers = Array.from( + { length: slotsPerLeader }, + (_, i) => i + leaderSlot, + ); + const definedSlotTsDeltas = slotNumbers + .map((slotNumber) => slotTsDeltas[slotNumber]) + .filter((v) => v !== undefined); - if (firstDefinedSlot === undefined) { + if (definedSlotTsDeltas.length === 0) { continue; } - let groupStart = Infinity; - let groupEnd = -Infinity; - - for ( - let slotNumber = firstDefinedSlot; - slotNumber < leaderSlot + slotsPerLeader; - slotNumber++ - ) { - const slotTsDeltaRange = slotTsDeltas[slotNumber]; - const slotStart = slotTsDeltaRange?.[0]; - const slotEnd = slotTsDeltaRange?.[1]; + // ignore missing slots at the start when positioning group + const minStart = Math.min(...definedSlotTsDeltas.map(([start]) => start)); + const ends = definedSlotTsDeltas.map(([, end]) => end); + const definedEnds = ends.filter((end) => end !== undefined); - if (slotStart != null) { - groupStart = Math.min(groupStart, slotStart); - } - // undefined slot end will extend to max x - groupEnd = Math.max(groupEnd, slotEnd ?? Infinity); - } + // undefined slot end will extend to max x + const hasUndefinedEnd = ends.length > definedEnds.length; + const maxEnd = hasUndefinedEnd ? undefined : Math.max(...definedEnds); - // replace Infinity with undefined for end - const end = groupEnd === Infinity ? undefined : groupEnd; - groupTsDeltas[leaderSlot] = [groupStart, end]; + groupTsDeltas[leaderSlot] = [minStart, maxEnd]; } return groupTsDeltas; } @@ -749,6 +731,7 @@ function moveLabelPosition( const key = isGroup ? "groups" : "slots"; const positionsToMutate = labelPositionsToMutate[key]; + const xPosProp = isGroup ? "--group-x" : "--slot-x"; const isVisible = !!position; const prevPositions = prevLabelPositions?.[key]; @@ -757,8 +740,7 @@ function moveLabelPosition( if (!isVisible) { if (forceUpdates || prevVisible) { // hide - const prop = isGroup ? "--group-x" : "--slot-x"; - el.style.setProperty(prop, isVisible ? "0" : "-100000px"); + el.style.setProperty(xPosProp, "-100000px"); } return; } @@ -769,25 +751,26 @@ function moveLabelPosition( const prevXPos = prevPositions?.[slotNumber]?.[0]; if (forceUpdates || xPos !== prevXPos) { - const prop = isGroup ? "--group-x" : "--slot-x"; - el.style.setProperty(prop, `${xPos}px`); + el.style.setProperty(xPosProp, `${xPos}px`); } // no width data -- extend to max width const width = position[1]; const isExtended = position[1] == null; - // extend past maxXPos to hide right border - const extendedWidth = width ?? maxXPos + 1 - xPos; const prevWidth = prevPositions?.[slotNumber]?.[1]; + // extend past maxXPos to hide right border + const newWidth = isExtended ? maxXPos - xPos + 1 : width; - if (forceUpdates || extendedWidth !== prevWidth) { - el.style.width = `${extendedWidth}px`; + if (forceUpdates || newWidth !== prevWidth) { + el.style.width = `${newWidth}px`; } const wasPrevExtended = prevPositions?.[slotNumber] && prevPositions[slotNumber][1] == null; if (isGroup && (forceUpdates || isExtended !== wasPrevExtended)) { + // 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"); } } From c528ff6586d390db44a316c9ff3467c748f38498 Mon Sep 17 00:00:00 2001 From: Ami Suzuki Date: Tue, 25 Nov 2025 14:52:50 -0600 Subject: [PATCH 4/5] fix: opacity flicker --- .../ShredsProgression/shreds.module.css | 4 +- .../shredsProgressionPlugin.ts | 42 +++++++++++-------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/features/Overview/ShredsProgression/shreds.module.css b/src/features/Overview/ShredsProgression/shreds.module.css index 73552e77..f80cc10c 100644 --- a/src/features/Overview/ShredsProgression/shreds.module.css +++ b/src/features/Overview/ShredsProgression/shreds.module.css @@ -5,7 +5,7 @@ background-color: #080b13; border-radius: 2px; border: 1px solid #3c4652; - will-change: transform, width; + will-change: transform; transform: translate(var(--group-x)); &.you { @@ -42,7 +42,7 @@ --slot-x: 0; position: absolute; - will-change: transform, width; + will-change: transform; transform: translate(var(--slot-x)); height: 100%; diff --git a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts index 8e3a4d50..ca56794f 100644 --- a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts +++ b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts @@ -662,35 +662,43 @@ function getGroupTsDeltas( }, groupLeaderSlots: number[], ) { - const groupTsDeltas: { + const tsDeltasByGroup: { [leaderSlotNumber: number]: TsDeltaRange; } = {}; for (const leaderSlot of groupLeaderSlots) { - const slotNumbers = Array.from( - { length: slotsPerLeader }, - (_, i) => i + leaderSlot, + const fullGroupTsDeltas = Array.from({ length: slotsPerLeader }, (_, i) => { + const slotNumber = i + leaderSlot; + return slotTsDeltas[slotNumber]; + }); + + const firstDefinedSlotIdx = fullGroupTsDeltas.findIndex( + (v) => v !== undefined, ); - const definedSlotTsDeltas = slotNumbers - .map((slotNumber) => slotTsDeltas[slotNumber]) - .filter((v) => v !== undefined); - if (definedSlotTsDeltas.length === 0) { - continue; - } + // no slots, no group + if (firstDefinedSlotIdx === -1) continue; + + // ignore missing slots at the start when positioning group. + // handles the case where some we sometimes miss early slot completion events, + // depending on the connection timing + const groupTsDeltas = fullGroupTsDeltas.slice(firstDefinedSlotIdx); + + const minStart = Math.min( + ...groupTsDeltas.filter((v) => v !== undefined).map(([start]) => start), + ); - // ignore missing slots at the start when positioning group - const minStart = Math.min(...definedSlotTsDeltas.map(([start]) => start)); - const ends = definedSlotTsDeltas.map(([, end]) => end); - const definedEnds = ends.filter((end) => end !== undefined); + const definedEnds = groupTsDeltas + .map((pos) => pos?.[1] ?? undefined) + .filter((end) => end !== undefined); // undefined slot end will extend to max x - const hasUndefinedEnd = ends.length > definedEnds.length; + const hasUndefinedEnd = definedEnds.length < groupTsDeltas.length; const maxEnd = hasUndefinedEnd ? undefined : Math.max(...definedEnds); - groupTsDeltas[leaderSlot] = [minStart, maxEnd]; + tsDeltasByGroup[leaderSlot] = [minStart, maxEnd]; } - return groupTsDeltas; + return tsDeltasByGroup; } /** From 13ac8ff31f0c4930410faea0ce1f8c0b6c479302 Mon Sep 17 00:00:00 2001 From: Ami Suzuki Date: Sat, 29 Nov 2025 15:31:21 -0600 Subject: [PATCH 5/5] chore: address comments --- .../ShredsProgression/ShredsChart.tsx | 12 +- .../ShredsProgression/ShredsSlotLabels.tsx | 19 +- .../Overview/ShredsProgression/atoms.ts | 5 +- .../shredsProgressionPlugin.ts | 277 ++++++++---------- 4 files changed, 137 insertions(+), 176 deletions(-) diff --git a/src/features/Overview/ShredsProgression/ShredsChart.tsx b/src/features/Overview/ShredsProgression/ShredsChart.tsx index 54153153..d39dc394 100644 --- a/src/features/Overview/ShredsProgression/ShredsChart.tsx +++ b/src/features/Overview/ShredsProgression/ShredsChart.tsx @@ -8,7 +8,7 @@ import { xRangeMs } from "./const"; import { useMedia, useRafLoop } from "react-use"; import { shredsProgressionPlugin, - type LabelPositions, + shredsXScaleKey, } from "./shredsProgressionPlugin"; import { Box, Flex } from "@radix-ui/themes"; import ShredsSlotLabels from "./ShredsSlotLabels"; @@ -69,7 +69,6 @@ export default function ShredsChart({ const uplotRef = useRef(); const lastRedrawRef = useRef(0); - const labelPositionsRef = useRef(); const handleCreate = useCallback((u: uPlot) => { uplotRef.current = u; @@ -94,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: { @@ -141,7 +141,7 @@ export default function ShredsChart({ }, }, ], - plugins: [shredsProgressionPlugin(isOnStartupScreen, labelPositionsRef)], + plugins: [shredsProgressionPlugin(isOnStartupScreen)], }; }, [isOnStartupScreen, xIncrs]); diff --git a/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx index 99d10d5e..bf1d7823 100644 --- a/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx +++ b/src/features/Overview/ShredsProgression/ShredsSlotLabels.tsx @@ -11,8 +11,6 @@ import PeerIcon from "../../../components/PeerIcon"; import { skippedClusterSlotsAtom } from "../../../atoms"; import { isStartupProgressVisibleAtom } from "../../StartupProgress/atoms"; -const height = 30; - /** * Labels for shreds slots. * Don't render during startup, because there will be multiple overlapping slots @@ -26,10 +24,10 @@ export default function ShredsSlotLabels() { return ( {groupLeaderSlots.map((slot) => ( @@ -61,31 +59,28 @@ function SlotGroupLabel({ firstSlot }: SlotGroupLabelProps) { return ( 0, })} > {slots.map((slot) => ( diff --git a/src/features/Overview/ShredsProgression/atoms.ts b/src/features/Overview/ShredsProgression/atoms.ts index 46c3eb53..ef18dc18 100644 --- a/src/features/Overview/ShredsProgression/atoms.ts +++ b/src/features/Overview/ShredsProgression/atoms.ts @@ -13,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; diff --git a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts index ca56794f..bf7cf5e0 100644 --- a/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts +++ b/src/features/Overview/ShredsProgression/shredsProgressionPlugin.ts @@ -24,7 +24,7 @@ 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]>; @@ -41,7 +41,6 @@ export type LabelPositions = { export function shredsProgressionPlugin( isOnStartupScreen: boolean, - labelPositionsRef: React.MutableRefObject, ): uPlot.Plugin { return { hooks: { @@ -53,7 +52,8 @@ export function shredsProgressionPlugin( const slotRange = store.get(atoms.range); const minCompletedSlot = store.get(atoms.minCompletedSlot); const skippedSlotsCluster = store.get(skippedClusterSlotsAtom); - const maxX = u.scales[xScaleKey].max; + + const maxX = u.scales[shredsXScaleKey].max; if (!liveShreds || !slotRange || maxX == null) { return; @@ -82,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, ); @@ -162,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, }); } @@ -188,7 +189,6 @@ export function shredsProgressionPlugin( u, maxX, tsXValueOffset, - labelPositionsRef, ); } }, @@ -414,19 +414,14 @@ function updateLabels( u: uPlot, maxX: number, tsXValueOffset: number, - labelPositionsRef: React.MutableRefObject, ) { const slotBlocks = getSlotBlocks(slotRange, slots); const slotTsDeltas = estimateSlotTsDeltas(slotBlocks, skippedSlotsCluster); const groupLeaderSlots = store.get(shredsAtoms.groupLeaderSlots); const groupTsDeltas = getGroupTsDeltas(slotTsDeltas, groupLeaderSlots); - const newLabelPositions: LabelPositions = { - groups: {}, - slots: {}, - }; - - const xValToCssPos = (xVal: number) => u.valToPos(xVal, xScaleKey, false); + const xValToCssPos = (xVal: number) => + u.valToPos(xVal, shredsXScaleKey, false); const maxXPos = xValToCssPos(maxX); for (let groupIdx = 0; groupIdx < groupLeaderSlots.length; groupIdx++) { @@ -442,15 +437,7 @@ function updateLabels( tsXValueOffset, xValToCssPos, ); - moveLabelPosition( - true, - leaderSlot, - groupPos, - maxXPos, - leaderEl, - labelPositionsRef.current, - newLabelPositions, - ); + moveLabelPosition(true, groupPos, maxXPos, leaderEl); for ( let slotNumber = leaderSlot; @@ -474,37 +461,27 @@ function updateLabels( ? ([slotPos[0] - groupPos[0], slotPos[1]] satisfies Position) : undefined; - moveLabelPosition( - false, - slotNumber, - relativeSlotPos, - maxXPos, - slotEl, - labelPositionsRef.current, - newLabelPositions, - ); + moveLabelPosition(false, relativeSlotPos, maxXPos, slotEl); } } - - // update stored positions - labelPositionsRef.current = newLabelPositions; } interface CompleteBlock { type: "complete"; startTsDelta: number; - completionTsDelta: number; + endTsDelta: number; slotNumber: number; } interface IncompleteBlock { type: "incomplete"; startTsDelta: number; - nextCompletionTsDelta: number | undefined; - nextStartTsDelta: number | undefined; + endTsDelta: number | undefined; slotNumbers: number[]; } /** - * Group ordered slots into blocks that are compelted / incomplete + * 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: { @@ -513,48 +490,39 @@ function getSlotBlocks( }, slots: SlotsShreds["slots"], ): Array { - // skip to the first defined slot - let firstDefinedSlotNumber = undefined; - for ( - let slotNumber = slotRange.min; - slotNumber <= slotRange.max; - slotNumber++ - ) { - const slot = slots.get(slotNumber); - if (slot?.minEventTsDelta != null) { - firstDefinedSlotNumber = slotNumber; - break; - } - } - - if (firstDefinedSlotNumber === undefined) return []; - - // Collect all blocks based on shared start and completion ts - // For incomplete blocks, include next start ts but not next completion ts yet const blocks: Array = []; let incompleteBlockSlotNumbers: number[] = []; - let incompleteBlockStart: number | undefined = undefined; for ( - let slotNumber = firstDefinedSlotNumber; + let slotNumber = slotRange.min; slotNumber <= slotRange.max; slotNumber++ ) { const slot = slots.get(slotNumber); - // add missing slot to incomplete block 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 potential end for incomplete block - if (incompleteBlockSlotNumbers.length && incompleteBlockStart != null) { + // 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: incompleteBlockStart, - nextStartTsDelta: slot.minEventTsDelta, - nextCompletionTsDelta: undefined, + startTsDelta: blockStart, + endTsDelta: slot.minEventTsDelta, slotNumbers: incompleteBlockSlotNumbers, }); @@ -566,40 +534,63 @@ function getSlotBlocks( blocks.push({ type: "complete", startTsDelta: slot.minEventTsDelta, - completionTsDelta: slot.completionTsDelta, + endTsDelta: slot.completionTsDelta, slotNumber, }); - incompleteBlockStart = slot.completionTsDelta; } else { // incomplete - incompleteBlockStart = slot.minEventTsDelta; incompleteBlockSlotNumbers.push(slotNumber); } } // add final incomplete block - if (incompleteBlockSlotNumbers.length && incompleteBlockStart != null) { + if (incompleteBlockSlotNumbers.length) { + const blockStart = getIncompleteBlockStart( + incompleteBlockSlotNumbers, + slots, + blocks[blocks.length - 1], + ); + if (!blockStart) return blocks; + blocks.push({ type: "incomplete", - startTsDelta: incompleteBlockStart, - nextStartTsDelta: undefined, - nextCompletionTsDelta: undefined, + startTsDelta: blockStart, + endTsDelta: undefined, slotNumbers: incompleteBlockSlotNumbers, }); } + return blocks; +} - // iterate backwards to populate incomplete blocks with next completion ts deltas - let nextCompletionTsDelta = undefined; - for (let i = blocks.length - 1; i >= 0; i--) { - const block = blocks[i]; - if (block.type === "complete") { - nextCompletionTsDelta = block.completionTsDelta; - continue; - } - block.nextCompletionTsDelta = nextCompletionTsDelta; +/** + * + * 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 blocks; + return blockStart; } type TsDeltaRange = [startTsDelta: number, endTsDelta: number | undefined]; @@ -610,9 +601,8 @@ type TsDeltaRange = [startTsDelta: number, endTsDelta: number | undefined]; * 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 next completion start - * (when a slot is completed, all previous slots are considered completed too) - * - if there is no next start ts or next completion ts, only include the first slot in the block, ending at max X ts + * - 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, @@ -624,14 +614,11 @@ function estimateSlotTsDeltas( for (const block of slotBlocks) { if (block.type === "complete") { - slotTsDeltas[block.slotNumber] = [ - block.startTsDelta, - block.completionTsDelta, - ]; + slotTsDeltas[block.slotNumber] = [block.startTsDelta, block.endTsDelta]; continue; } - if (block.nextStartTsDelta == null) { + 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]; @@ -641,14 +628,14 @@ function estimateSlotTsDeltas( // known block end time // split block range equally to determine slot start ts const singleSlotTsRange = - (block.nextStartTsDelta - block.startTsDelta) / block.slotNumbers.length; + (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 - : block.nextCompletionTsDelta; + : undefined; slotTsDeltas[slotNumber] = [slotStart, slotEnd]; } } @@ -656,6 +643,10 @@ function estimateSlotTsDeltas( 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; @@ -667,36 +658,34 @@ function getGroupTsDeltas( } = {}; for (const leaderSlot of groupLeaderSlots) { - const fullGroupTsDeltas = Array.from({ length: slotsPerLeader }, (_, i) => { - const slotNumber = i + leaderSlot; - return slotTsDeltas[slotNumber]; - }); - - const firstDefinedSlotIdx = fullGroupTsDeltas.findIndex( - (v) => v !== undefined, - ); - - // no slots, no group - if (firstDefinedSlotIdx === -1) continue; - - // ignore missing slots at the start when positioning group. - // handles the case where some we sometimes miss early slot completion events, - // depending on the connection timing - const groupTsDeltas = fullGroupTsDeltas.slice(firstDefinedSlotIdx); + 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); + } - const minStart = Math.min( - ...groupTsDeltas.filter((v) => v !== undefined).map(([start]) => start), - ); + // don't track end times for initial undefined slots + const hasSeenDefinedSlot = minStart !== Infinity; + if (!hasSeenDefinedSlot) continue; - const definedEnds = groupTsDeltas - .map((pos) => pos?.[1] ?? undefined) - .filter((end) => end !== undefined); + // undefind slotEnd means the slot extends to the max X + maxEnd = Math.max(slotEnd ?? Infinity, maxEnd); + } - // undefined slot end will extend to max x - const hasUndefinedEnd = definedEnds.length < groupTsDeltas.length; - const maxEnd = hasUndefinedEnd ? undefined : Math.max(...definedEnds); + // no defined slots + if (minStart === Infinity || maxEnd === -Infinity) { + continue; + } - tsDeltasByGroup[leaderSlot] = [minStart, maxEnd]; + tsDeltasByGroup[leaderSlot] = [ + minStart, + // convert back to undefined + maxEnd === Infinity ? undefined : maxEnd, + ]; } return tsDeltasByGroup; } @@ -723,60 +712,36 @@ function getPosFromTsDeltaRange( } /** - * Update changed styles and store new positions in labelPositions + * Update label element styles */ function moveLabelPosition( isGroup: boolean, - slotNumber: number, position: Position | undefined, maxXPos: number, el: HTMLElement, - prevLabelPositions: LabelPositions | undefined, - labelPositionsToMutate: LabelPositions, ) { - // force all updates if previously, nothing was positioned - const forceUpdates = !prevLabelPositions; - - const key = isGroup ? "groups" : "slots"; - const positionsToMutate = labelPositionsToMutate[key]; + const groupBorderOffset = 1; const xPosProp = isGroup ? "--group-x" : "--slot-x"; const isVisible = !!position; - const prevPositions = prevLabelPositions?.[key]; - const prevVisible = !!prevPositions?.[slotNumber]; - if (!isVisible) { - if (forceUpdates || prevVisible) { - // hide - el.style.setProperty(xPosProp, "-100000px"); - } + // hide + el.style.setProperty(xPosProp, "-100000px"); return; } - positionsToMutate[slotNumber] = position; - - const xPos = position[0]; - const prevXPos = prevPositions?.[slotNumber]?.[0]; - - if (forceUpdates || xPos !== prevXPos) { - el.style.setProperty(xPosProp, `${xPos}px`); - } - - // no width data -- extend to max width - const width = position[1]; - const isExtended = position[1] == null; - const prevWidth = prevPositions?.[slotNumber]?.[1]; - // extend past maxXPos to hide right border - const newWidth = isExtended ? maxXPos - xPos + 1 : width; - - if (forceUpdates || newWidth !== prevWidth) { - el.style.width = `${newWidth}px`; - } + const [xPos, width] = position; + el.style.setProperty( + xPosProp, + `${xPos + (isGroup ? groupBorderOffset : 0)}px`, + ); - const wasPrevExtended = - prevPositions?.[slotNumber] && prevPositions[slotNumber][1] == null; + // 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`; - if (isGroup && (forceUpdates || isExtended !== wasPrevExtended)) { + 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");