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}`;
+}