diff --git a/.github/screenshots/hover-card-aggregated.png b/.github/screenshots/hover-card-aggregated.png new file mode 100644 index 0000000..d0fa7ef Binary files /dev/null and b/.github/screenshots/hover-card-aggregated.png differ diff --git a/.github/screenshots/hover-card-weighted-dark.png b/.github/screenshots/hover-card-weighted-dark.png new file mode 100644 index 0000000..d1ebde3 Binary files /dev/null and b/.github/screenshots/hover-card-weighted-dark.png differ diff --git a/.github/screenshots/legend-desktop.png b/.github/screenshots/legend-desktop.png new file mode 100644 index 0000000..ac42873 Binary files /dev/null and b/.github/screenshots/legend-desktop.png differ diff --git a/.github/screenshots/legend-mobile.png b/.github/screenshots/legend-mobile.png new file mode 100644 index 0000000..81bd845 Binary files /dev/null and b/.github/screenshots/legend-mobile.png differ diff --git a/web/package.json b/web/package.json index 4bde556..0668460 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "build-storybook": "storybook build" }, "dependencies": { + "@floating-ui/react": "0.26.28", "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@headlessui/react": "2.2.10", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 2e50de1..ba2a77e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@floating-ui/react': + specifier: 0.26.28 + version: 0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@fontsource-variable/inter': specifier: ^5.2.8 version: 5.2.8 diff --git a/web/src/components/AggregatedNode.tsx b/web/src/components/AggregatedNode.tsx index ff263bf..bd073fa 100644 --- a/web/src/components/AggregatedNode.tsx +++ b/web/src/components/AggregatedNode.tsx @@ -3,56 +3,10 @@ import { memo } from 'react'; import { EyeIcon, CheckIcon, FlagIcon } from '@heroicons/react/20/solid'; import classNames from 'clsx'; +import HoverCard from '@components/HoverCard'; +import { AggregatedNodeCard } from '@components/NodeCards'; import ProgressCircle from '@components/ProgressCircle'; - -/* Mirrors the WeightedNode status recipe: dim core, bright rim, soft glow. */ -const NODE_STYLES: Record< - string, - { fill: string; arc: string; track: string; label: string; glow: string } -> = { - canonical: { - fill: 'bg-canonical-deep', - arc: 'text-canonical-ring', - track: 'text-canonical-edge', - label: 'text-canonical', - glow: 'shadow-[0_0_90px_-15px_var(--color-canonical-ring)]', - }, - fork: { - fill: 'bg-fork-deep', - arc: 'text-fork-ring', - track: 'text-fork-edge', - label: 'text-fork', - glow: 'shadow-[0_0_90px_-15px_var(--color-fork-ring)]', - }, - finalized: { - fill: 'bg-finalized-deep', - arc: 'text-finalized-ring', - track: 'text-finalized-edge', - label: 'text-finalized', - glow: 'shadow-[0_0_90px_-15px_var(--color-finalized-ring)]', - }, - justified: { - fill: 'bg-justified-deep', - arc: 'text-justified-ring', - track: 'text-justified-edge', - label: 'text-justified', - glow: 'shadow-[0_0_90px_-15px_var(--color-justified-ring)]', - }, - invalid: { - fill: 'bg-invalid-deep', - arc: 'text-invalid-ring', - track: 'text-invalid-edge', - label: 'text-invalid', - glow: 'shadow-[0_0_90px_-15px_var(--color-invalid-ring)]', - }, - optimistic: { - fill: 'bg-optimistic-deep', - arc: 'text-optimistic-ring', - track: 'text-optimistic-edge', - label: 'text-optimistic', - glow: 'shadow-[0_0_90px_-15px_var(--color-optimistic-ring)]', - }, -}; +import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles'; function AggregatedNode({ id, @@ -69,6 +23,8 @@ function AggregatedNode({ x, y, radius, + slot, + epoch, className, onClick, }: { @@ -86,10 +42,12 @@ function AggregatedNode({ x: number; y: number; radius: number; + slot?: number; + epoch?: number; className?: string; onClick?: (hash: string) => void; }) { - const [styleKey, title] = ((): [string, string] => { + const [styleKey, title] = ((): [NodeStyleKey, string] => { if (valid !== seen) return ['invalid', `${seen - valid} NOT VALID`]; if (orphans > 0) return ['invalid', `${orphans} DETACHED`]; if (finalizedCheckpoints > 0) return ['finalized', 'FINALIZED']; @@ -105,60 +63,97 @@ function AggregatedNode({ return ['canonical', type]; } })(); - const styles = NODE_STYLES[styleKey] ?? NODE_STYLES.canonical; + const styles = NODE_STYLES[styleKey]; + + const handleActivate = () => onClick?.(hash); return ( -
+ } + referenceProps={{ + onClick: handleActivate, + onKeyDown: event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleActivate(); + } + }, }} - onClick={() => onClick?.(hash)} > - -

- {title} -

-

- {hash.substring(0, 6)}…{hash.substring(hash.length - 4)} -

-

- - {finalizedCheckpoints > 0 || justifiedCheckpoints > 0 ? ( - <> - {finalizedCheckpoints || justifiedCheckpoints}/{total} - - ) : ( - <> - {canonical}/{total} - + {referenceProps => ( +

- - {seen}/{total} - -

-
+ style={{ + left: `${x}px`, + top: `${y}px`, + width: `${radius * 2}px`, + height: `${radius * 2}px`, + }} + > + +

+ {title} +

+

+ {hash.substring(0, 6)}…{hash.substring(hash.length - 4)} +

+

+ + {finalizedCheckpoints > 0 || justifiedCheckpoints > 0 ? ( + <> + {finalizedCheckpoints || justifiedCheckpoints}/ + {total} + + ) : ( + <> + {canonical}/{total} + + )} + + + {seen}/{total} + +

+
+ )} + ); } diff --git a/web/src/components/GraphPrimitives.stories.tsx b/web/src/components/GraphPrimitives.stories.tsx index 3a7d1ed..dac6aa8 100644 --- a/web/src/components/GraphPrimitives.stories.tsx +++ b/web/src/components/GraphPrimitives.stories.tsx @@ -1,8 +1,11 @@ import type { Meta, StoryObj } from '@storybook/tanstack-react'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import AggregatedNode from '@components/AggregatedNode'; import ConcatNode from '@components/ConcatNode'; import Edge from '@components/Edge'; +import { HoverCardPanel } from '@components/HoverCard'; +import { AggregatedNodeCard, TruncationCard, WeightedNodeCard } from '@components/NodeCards'; import ProgressCircle from '@components/ProgressCircle'; import SlotBoundary from '@components/SlotBoundary'; import TruncationMarker from '@components/TruncationMarker'; @@ -221,6 +224,325 @@ export const AggregatedNodeStates: Story = { ), }; +/* Hover-card content for every weighted node state, rendered statically in + * the real card chrome (HoverCardPanel) so each variant is reviewable + * without pointer choreography. The styleKey/status pairs mirror what + * WeightedNode derives for the matching disc states above. */ +export const WeightedNodeCards: Story = { + render: () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + {/* mainnet-scale numbers: 8-digit slot, 6-digit epoch, 15-digit + * weight — chips wrap below the status and the weight tile shows + * compact notation instead of overflowing */} + + + +
+ ), +}; + +/* Same coverage for the aggregated card: the styleKey/status pairs mirror + * AggregatedNode's priority cascade (not valid → detached → finalized → + * justified → optimistic → canonical/fork). */ +export const AggregatedNodeCards: Story = { + render: () => ( +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ ), +}; + +/* Real pointer choreography: hover a node, the details card appears in a + * portal (outside the canvas element); Escape dismisses it. */ +export const WeightedNodeHoverInteraction: Story = { + render: () => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.hover(canvas.getByRole('button')); + // the card fades in (opacity 0 → 1), so retry until the entrance + // transition lands + await waitFor(() => expect(body.getByText('Click the block for full details')).toBeVisible()); + await expect(body.getByText('Branch share')).toBeVisible(); + await expect(body.getByText('45%')).toBeVisible(); + // slot/epoch render as chips with the number highlighted in a nested + // span, so match on each chip's assembled text content + await expect(body.getByText((_, element) => element?.textContent === 'slot 105')).toBeVisible(); + await expect(body.getByText((_, element) => element?.textContent === 'epoch 3')).toBeVisible(); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => + expect(body.queryByText('Click the block for full details')).not.toBeInTheDocument(), + ); + }, +}; + +export const TruncationMarkerHoverInteraction: Story = { + render: () => ( +
+ +
+ ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + + await userEvent.hover(canvas.getByText(/slots\s+hidden/i)); + await waitFor(() => expect(body.getByText('Hidden range')).toBeVisible()); + }, +}; + export const EdgesBoundariesAndProgress: Story = { render: () => (
diff --git a/web/src/components/HoverCard.tsx b/web/src/components/HoverCard.tsx new file mode 100644 index 0000000..9fb6b2d --- /dev/null +++ b/web/src/components/HoverCard.tsx @@ -0,0 +1,151 @@ +import { HTMLAttributes, HTMLProps, ReactNode, Ref, useRef, useState } from 'react'; + +import { + FloatingArrow, + FloatingPortal, + arrow, + autoUpdate, + flip, + offset, + safePolygon, + shift, + useDismiss, + useFloating, + useFocus, + useHover, + useInteractions, + useRole, + useTransitionStyles, +} from '@floating-ui/react'; +import classNames from 'clsx'; + +import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles'; + +/* The card chrome, exported on its own so Storybook can render every card + * variant statically without pointer choreography. Content panels stay + * solid (not glass) for readability, matching the design language. An + * accent ties the card to its node's status family: a hairline bar along + * the top edge and a soft halo in the status ring color. */ +export function HoverCardPanel({ + ref, + accent, + className, + children, + ...rest +}: HTMLAttributes & { ref?: Ref; accent?: NodeStyleKey }) { + return ( +
+ {accent && ( +
+ ); +} + +/* Hover/keyboard-focus details card for graph elements. Renders through a + * portal so it escapes the zoom-pan transform (it would scale and blur with + * the graph otherwise) and re-anchors every animation frame so it follows + * its node while the canvas pans. Enters with a quick ease-out fade/rise + * from the anchor side; an arrow ties it to the node. WCAG 1.4.13: + * hoverable (safePolygon lets the pointer travel onto the card), + * dismissible (Escape) and persistent (no auto-timeout). Pointer-only by + * design — tapping a node already opens the full details slide-over. */ +function HoverCard({ + content, + accent, + referenceProps, + children, +}: { + content: ReactNode; + accent?: NodeStyleKey; + referenceProps?: HTMLProps; + children: (props: Record) => ReactNode; +}) { + const [open, setOpen] = useState(false); + const arrowRef = useRef(null); + + const { refs, floatingStyles, context } = useFloating({ + open, + onOpenChange: setOpen, + placement: 'top', + middleware: [ + offset(16), + flip({ padding: 12 }), + shift({ padding: 12 }), + // react-hooks/refs: the middleware reads the ref during positioning + // (floating-ui's documented pattern), not during render + // eslint-disable-next-line react-hooks/refs + arrow({ element: arrowRef }), + ], + whileElementsMounted: (reference, floating, update) => + autoUpdate(reference, floating, update, { animationFrame: true }), + }); + + const hover = useHover(context, { + delay: { open: 150, close: 75 }, + handleClose: safePolygon(), + mouseOnly: true, + }); + const focus = useFocus(context); + const dismiss = useDismiss(context, { referencePress: true }); + const role = useRole(context, { role: 'tooltip' }); + const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]); + + const { isMounted, styles: transitionStyles } = useTransitionStyles(context, { + duration: { open: 180, close: 120 }, + initial: ({ side }) => ({ + opacity: 0, + transform: side === 'bottom' ? 'translateY(-8px) scale(0.96)' : 'translateY(8px) scale(0.96)', + }), + }); + + /* react-hooks/refs: floating-ui's refs.setReference/refs.setFloating are + * stable callback refs meant to be attached during render — not ref + * reads, so the rule's heuristic misfires here. */ + return ( + <> + {} + {children({ ref: refs.setReference, ...getReferenceProps(referenceProps) })} + {isMounted && ( + +
+
+ {content} + +
+
+
+ )} + + ); +} + +export default HoverCard; diff --git a/web/src/components/Legend.stories.tsx b/web/src/components/Legend.stories.tsx new file mode 100644 index 0000000..27b01e8 --- /dev/null +++ b/web/src/components/Legend.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/tanstack-react'; +import { expect, userEvent, within } from 'storybook/test'; + +import Legend from '@components/Legend'; +import { StorySurface } from '@app/stories/storybook'; + +const meta = { + title: 'Components/Legend', + component: Legend, + parameters: { + layout: 'fullscreen', + }, + render: args => ( + + + + ), +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const WeightedExpanded: Story = { + args: { + mode: 'weighted', + defaultExpanded: true, + }, +}; + +export const AggregatedExpanded: Story = { + args: { + mode: 'aggregated', + defaultExpanded: true, + }, +}; + +export const CollapsedPill: Story = { + args: { + mode: 'weighted', + }, +}; + +export const ExpandAndCollapse: Story = { + args: { + mode: 'weighted', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: 'Show legend' })); + await expect(canvas.getByText('Canonical')).toBeVisible(); + await expect(canvas.getByText('Detached')).toBeVisible(); + await expect( + canvas.getByText('Ring: weight relative to the heaviest competing branch.'), + ).toBeVisible(); + await userEvent.click(canvas.getByRole('button', { name: 'Hide legend' })); + await expect(canvas.getByRole('button', { name: 'Show legend' })).toBeVisible(); + }, +}; + +export const AggregatedCounters: Story = { + args: { + mode: 'aggregated', + defaultExpanded: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText('Ring: share of sources reporting the block canonical.'), + ).toBeVisible(); + await expect(canvas.getByText('Sources that have seen the block')).toBeVisible(); + }, +}; + +export const MobileExpanded: Story = { + globals: { + viewport: { value: 'iphone14', isRotated: false }, + }, + args: { + mode: 'aggregated', + defaultExpanded: true, + }, +}; + +export const MobileCollapsed: Story = { + globals: { + viewport: { value: 'iphone14', isRotated: false }, + }, + args: { + mode: 'weighted', + }, +}; diff --git a/web/src/components/Legend.tsx b/web/src/components/Legend.tsx new file mode 100644 index 0000000..5e3f222 --- /dev/null +++ b/web/src/components/Legend.tsx @@ -0,0 +1,198 @@ +import { memo, useState } from 'react'; + +import { CheckIcon, EyeIcon, FlagIcon } from '@heroicons/react/20/solid'; +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/solid'; +import classNames from 'clsx'; + +import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles'; + +/* Stage legend for the fork-choice graph's visual vocabulary. Collapsed to + * a compact pill by default (mobile stays clean); expands into a glass + * panel mirroring the Sources panel chrome. Swatches render from the same + * NODE_STYLES table as the real nodes, so the legend cannot drift. Rows are + * mode-aware: the progress ring and the footer counters mean different + * things in weighted vs aggregated views. */ + +type LegendMode = 'weighted' | 'aggregated'; + +const BLOCK_ENTRIES: { + styleKey: NodeStyleKey; + name: string; + description: Record; +}[] = [ + { + styleKey: 'canonical', + name: 'Canonical', + description: { + weighted: 'On the heaviest (head) chain', + aggregated: 'Head chain across the sources', + }, + }, + { + styleKey: 'fork', + name: 'Fork', + description: { + weighted: 'Competing branch with less weight', + aggregated: 'Competing branch on some sources', + }, + }, + { + styleKey: 'optimistic', + name: 'Optimistic', + description: { + weighted: 'Execution payload not yet verified', + aggregated: 'Optimistic on some sources', + }, + }, + { + styleKey: 'justified', + name: 'Justified', + description: { + weighted: 'Latest justified checkpoint', + aggregated: 'Justified checkpoint on the sources', + }, + }, + { + styleKey: 'finalized', + name: 'Finalized', + description: { + weighted: 'Latest finalized checkpoint', + aggregated: 'Finalized checkpoint on the sources', + }, + }, + { + styleKey: 'invalid', + name: 'Invalid', + description: { + weighted: 'Failed (or unknown) validity', + aggregated: 'Not valid on some sources', + }, + }, + { + styleKey: 'invalid', + name: 'Detached', + description: { + weighted: 'Parent block missing from the view', + aggregated: 'Parent block missing on some sources', + }, + }, +]; + +const RING_NOTE: Record = { + weighted: 'Ring: weight relative to the heaviest competing branch.', + aggregated: 'Ring: share of sources reporting the block canonical.', +}; + +const COUNTER_ENTRIES = [ + { Icon: CheckIcon, description: 'Sources with the block on their head chain' }, + { Icon: FlagIcon, description: 'Sources reporting the checkpoint' }, + { Icon: EyeIcon, description: 'Sources that have seen the block' }, +]; + +function StatusSwatch({ styleKey, className }: { styleKey: NodeStyleKey; className?: string }) { + const styles = NODE_STYLES[styleKey]; + return ( + + ); +} + +function Legend({ + mode, + defaultExpanded = false, + className, +}: { + mode: LegendMode; + defaultExpanded?: boolean; + className?: string; +}) { + const [expanded, setExpanded] = useState(defaultExpanded); + + if (!expanded) { + return ( + + ); + } + + return ( +
+
+ + Legend + + +
+
+
    + {BLOCK_ENTRIES.map(({ styleKey, name, description }) => ( +
  • + + + {name} + + {/* swatch + name carry the legend on phones; prose stays on sm+ */} + {description[mode]} +
  • + ))} +
  • + + Hidden + + Older slots collapsed; checkpoints stay pinned + +
  • +
+

+ {RING_NOTE[mode]} +

+ {mode === 'aggregated' && ( +
    + {COUNTER_ENTRIES.map(({ Icon, description }) => ( +
  • + + {description} +
  • + ))} +
+ )} +
+ ); +} + +export default memo(Legend); diff --git a/web/src/components/NodeCards.tsx b/web/src/components/NodeCards.tsx new file mode 100644 index 0000000..db3caaf --- /dev/null +++ b/web/src/components/NodeCards.tsx @@ -0,0 +1,407 @@ +import { ReactNode, useState } from 'react'; + +import { CheckIcon, DocumentDuplicateIcon, EyeIcon, FlagIcon } from '@heroicons/react/20/solid'; +import classNames from 'clsx'; + +import { truncateHash } from '@app/utils/strings'; +import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles'; + +/* Read-only content for the graph hover cards (see HoverCard). One variant + * per graph primitive; all of them stay presentational so Storybook can + * render every state without pointer choreography. Hierarchy follows the + * dashboard-tooltip playbook: the hero numbers live in stat tiles, the + * supporting rows stay small and muted, and key values carry their status + * color. */ + +/* Mainnet weights run to 15+ digits — too wide for a stat tile. Past a + * billion, display compact notation (e.g. 393.23T) and keep the exact + * value reachable via the title attribute. */ +function formatWeight(weight: string): { display: string; full: string } { + try { + const value = BigInt(weight); + const full = value.toLocaleString(); + if (value >= 1_000_000_000n) { + return { + display: new Intl.NumberFormat('en', { + notation: 'compact', + maximumFractionDigits: 2, + }).format(Number(value)), + full, + }; + } + return { display: full, full }; + } catch { + return { display: weight, full: weight }; + } +} + +function validityStyleKey(validity: string): NodeStyleKey { + const normalized = validity.toLowerCase(); + if (normalized === 'valid') return 'canonical'; + if (normalized === 'optimistic') return 'optimistic'; + return 'invalid'; +} + +function ValidityBadge({ validity }: { validity: string }) { + const styles = NODE_STYLES[validityStyleKey(validity)]; + return ( + + {validity.toUpperCase()} + + ); +} + +/* Inline weight-share meter echoing the node's progress ring. */ +function ShareMeter({ percentage, styleKey }: { percentage: number; styleKey: NodeStyleKey }) { + const styles = NODE_STYLES[styleKey]; + const rounded = Math.round(percentage); + return ( + + {rounded}% + + + + + ); +} + +function SourceCount({ + count, + total, + className, +}: { + count: number; + total: number; + className?: string; +}) { + return ( + <> + + {count}/{total} + {' '} + sources + + ); +} + +function CopyButton({ value, label }: { value: string; label: string }) { + const [copied, setCopied] = useState(false); + + return ( + + ); +} + +/* Inset stat tile for the card's hero numbers. */ +function CardStat({ + label, + children, + valueClassName, +}: { + label: ReactNode; + children: ReactNode; + valueClassName?: string; +}) { + return ( +
+ + {label} + + + {children} + +
+ ); +} + +function CardRow({ label, children }: { label: ReactNode; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function CardShell({ + styleKey, + status, + slot, + epoch, + blockRoot, + children, +}: { + styleKey: NodeStyleKey; + status: string; + slot?: number; + epoch?: number; + blockRoot: string; + children: ReactNode; +}) { + const styles = NODE_STYLES[styleKey]; + + return ( +
+ {/* the chips share the row with short statuses and wrap below on + * mainnet-scale slot/epoch numbers instead of overflowing */} +
+ + + + {status} + + + {slot !== undefined && ( + + + slot{' '} + {slot.toLocaleString()} + + {epoch !== undefined && ( + + epoch{' '} + + {epoch.toLocaleString()} + + + )} + + )} +
+
+

+ {blockRoot} +

+ +
+
{children}
+

+ Click the block for full details + + + esc + + to dismiss + +

+
+ ); +} + +export function WeightedNodeCard({ + styleKey, + status, + blockRoot, + parentRoot, + slot, + epoch, + validity, + weight, + weightPercentage, +}: { + styleKey: NodeStyleKey; + status: string; + blockRoot: string; + parentRoot?: string; + slot?: number; + epoch?: number; + validity: string; + weight: string; + weightPercentage: number; +}) { + const formattedWeight = weight && weight !== '0' ? formatWeight(weight) : undefined; + + return ( + +
+ + {formattedWeight ? ( + {formattedWeight.display} + ) : ( + '—' + )} + + + + +
+
+ + + + {parentRoot && {truncateHash(parentRoot)}} +
+
+ ); +} + +export function AggregatedNodeCard({ + styleKey, + status, + blockRoot, + slot, + epoch, + seen, + canonical, + finalizedCheckpoints, + justifiedCheckpoints, + orphans, + valid, + optimistic, + total, +}: { + styleKey: NodeStyleKey; + status: string; + blockRoot: string; + slot?: number; + epoch?: number; + seen: number; + canonical: number; + finalizedCheckpoints: number; + justifiedCheckpoints: number; + orphans: number; + valid: number; + optimistic: number; + total: number; +}) { + const notValid = seen - valid; + + return ( + +
+ + Canonical + + } + valueClassName="text-canonical" + > + {canonical}/{total} + + + Seen by + + } + > + {seen}/{total} + +
+ {(finalizedCheckpoints > 0 || + justifiedCheckpoints > 0 || + notValid > 0 || + optimistic > 0 || + orphans > 0) && ( +
+ {finalizedCheckpoints > 0 && ( + + Finalized votes + + } + > + + {finalizedCheckpoints}/{total} + + + )} + {justifiedCheckpoints > 0 && ( + + Justified votes + + } + > + + {justifiedCheckpoints}/{total} + + + )} + {notValid > 0 && ( + + + + )} + {optimistic > 0 && ( + + + + )} + {orphans > 0 && ( + + + + )} +
+ )} +
+ ); +} + +export function TruncationCard({ slots }: { slots: number }) { + return ( +
+

+ Hidden range +

+

+ + {slots.toLocaleString()} + {' '} + earlier slots are collapsed to keep the graph readable. The{' '} + finalized and{' '} + justified checkpoints stay pinned just + left of this gap. +

+
+ ); +} diff --git a/web/src/components/TruncationMarker.tsx b/web/src/components/TruncationMarker.tsx index 987d2dc..6cb4cc1 100644 --- a/web/src/components/TruncationMarker.tsx +++ b/web/src/components/TruncationMarker.tsx @@ -2,6 +2,9 @@ import { memo } from 'react'; import classNames from 'clsx'; +import HoverCard from '@components/HoverCard'; +import { TruncationCard } from '@components/NodeCards'; + /* Bridges a hidden range of the graph when the tail is truncated: rendered * in the same circular footprint as graph nodes, in the neutral "gap" * language (dashed ring, no status color). */ @@ -19,29 +22,33 @@ function TruncationMarker({ className?: string; }) { return ( -
}> + {referenceProps => ( +
+
+

+ {slots.toLocaleString()} +

+

+ slots hidden +

+
+
)} - style={{ - left: `${x}px`, - top: `${y}px`, - width: `${radius * 2}px`, - height: `${radius * 2}px`, - }} - title={`${slots.toLocaleString()} earlier slots hidden`} - > -
-

- {slots.toLocaleString()} -

-

- slots hidden -

-
-
+ ); } diff --git a/web/src/components/WeightedNode.tsx b/web/src/components/WeightedNode.tsx index f84f4f1..2405e8c 100644 --- a/web/src/components/WeightedNode.tsx +++ b/web/src/components/WeightedNode.tsx @@ -2,57 +2,10 @@ import { memo } from 'react'; import classNames from 'clsx'; +import HoverCard from '@components/HoverCard'; +import { WeightedNodeCard } from '@components/NodeCards'; import ProgressCircle from '@components/ProgressCircle'; - -/* Status visual recipe: dim status-tinted core, bright status rim (the - * progress arc) and a soft glow so state reads at any zoom level. */ -const NODE_STYLES: Record< - string, - { fill: string; arc: string; track: string; label: string; glow: string } -> = { - canonical: { - fill: 'bg-canonical-deep', - arc: 'text-canonical-ring', - track: 'text-canonical-edge', - label: 'text-canonical', - glow: 'shadow-[0_0_90px_-15px_var(--color-canonical-ring)]', - }, - fork: { - fill: 'bg-fork-deep', - arc: 'text-fork-ring', - track: 'text-fork-edge', - label: 'text-fork', - glow: 'shadow-[0_0_90px_-15px_var(--color-fork-ring)]', - }, - finalized: { - fill: 'bg-finalized-deep', - arc: 'text-finalized-ring', - track: 'text-finalized-edge', - label: 'text-finalized', - glow: 'shadow-[0_0_90px_-15px_var(--color-finalized-ring)]', - }, - justified: { - fill: 'bg-justified-deep', - arc: 'text-justified-ring', - track: 'text-justified-edge', - label: 'text-justified', - glow: 'shadow-[0_0_90px_-15px_var(--color-justified-ring)]', - }, - invalid: { - fill: 'bg-invalid-deep', - arc: 'text-invalid-ring', - track: 'text-invalid-edge', - label: 'text-invalid', - glow: 'shadow-[0_0_90px_-15px_var(--color-invalid-ring)]', - }, - optimistic: { - fill: 'bg-optimistic-deep', - arc: 'text-optimistic-ring', - track: 'text-optimistic-edge', - label: 'text-optimistic', - glow: 'shadow-[0_0_90px_-15px_var(--color-optimistic-ring)]', - }, -}; +import { NODE_STYLES, NodeStyleKey } from '@utils/nodeStyles'; function WeightedNode({ id, @@ -64,6 +17,9 @@ function WeightedNode({ y, radius, weightPercentageComparedToHeaviestNeighbor = 100, + slot, + epoch, + parentRoot, className, onClick, }: { @@ -76,61 +32,96 @@ function WeightedNode({ y: number; radius: number; weightPercentageComparedToHeaviestNeighbor?: number; + slot?: number; + epoch?: number; + parentRoot?: string; className?: string; onClick?: (hash: string) => void; }) { - const styleKey = (() => { + const styleKey = ((): NodeStyleKey => { if (!['valid', 'optimistic'].includes(validity.toLowerCase())) return 'invalid'; if (type === 'detached') return 'invalid'; if (type === 'canonical' && validity.toLowerCase() === 'optimistic') return 'optimistic'; return type; })(); - const styles = NODE_STYLES[styleKey] ?? NODE_STYLES.canonical; + const styles = NODE_STYLES[styleKey]; const label = type === 'finalized' || type === 'justified' || type === 'detached' ? type.toUpperCase() : validity.toUpperCase(); + const handleActivate = () => onClick?.(hash); + return ( -
+ } + referenceProps={{ + onClick: handleActivate, + onKeyDown: event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + handleActivate(); + } + }, }} - onClick={() => onClick?.(hash)} > - -

- {label} -

-

- {hash.substring(0, 6)}…{hash.substring(hash.length - 4)} -

-

- {weight && weight !== '0' ? weight : ' '} -

-
+ {referenceProps => ( +
+ +

+ {label} +

+

+ {hash.substring(0, 6)}…{hash.substring(hash.length - 4)} +

+

+ {weight && weight !== '0' ? weight : ' '} +

+
+ )} + ); } diff --git a/web/src/parts/Graph.stories.tsx b/web/src/parts/Graph.stories.tsx index fab206a..51924c0 100644 --- a/web/src/parts/Graph.stories.tsx +++ b/web/src/parts/Graph.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/tanstack-react'; -import { expect, userEvent, within } from 'storybook/test'; +import { expect, userEvent, waitFor, within } from 'storybook/test'; import type { ForkChoice, ForkChoiceNode, Frame, FrameMetadata } from '@api'; import type { ProcessedData } from '@app/types/graph'; @@ -480,6 +480,52 @@ export const AggregatedTruncatedLongTail: Story = { }, }; +export const SingleNodeLegendExpanded: Story = { + ...singleNodeStory(graphScenarios.chaosForks, 'legend-expanded'), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: 'Show legend' })); + await expect(canvas.getByText('Competing branch with less weight')).toBeVisible(); + await expect( + canvas.getByText('Ring: weight relative to the heaviest competing branch.'), + ).toBeVisible(); + // weighted views get no aggregated counter rows + await expect(canvas.queryByText('Sources that have seen the block')).not.toBeInTheDocument(); + }, +}; + +export const AggregatedLegendExpanded: Story = { + ...aggregatedStory(graphScenarios.chaosForks, 'legend-expanded'), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole('button', { name: 'Show legend' })); + await expect( + canvas.getByText('Ring: share of sources reporting the block canonical.'), + ).toBeVisible(); + await expect(canvas.getByText('Sources that have seen the block')).toBeVisible(); + await userEvent.click(canvas.getByRole('button', { name: 'Hide legend' })); + await expect(canvas.getByRole('button', { name: 'Show legend' })).toBeVisible(); + }, +}; + +export const SingleNodeHoverCard: Story = { + ...singleNodeStory(graphScenarios.happyPath, 'hover-card'), + play: async ({ canvasElement }) => { + // wait for the graph to mount and zoom to the head + await new Promise(resolve => setTimeout(resolve, 400)); + const head = canvasElement.querySelector('#head'); + if (!head) throw new Error('head node not rendered'); + + await userEvent.hover(head); + // the card renders through a portal outside the canvas element + const body = within(document.body); + // the card fades in (opacity 0 → 1), so retry until the entrance + // transition lands + await waitFor(() => expect(body.getByText('Click the block for full details')).toBeVisible()); + await expect(body.getByText('Branch share')).toBeVisible(); + }, +}; + export const AggregatedWithStuckNode: Story = { ...aggregatedStory(graphScenarios.happyPath, 'stuck-node'), args: { diff --git a/web/src/parts/Graph.tsx b/web/src/parts/Graph.tsx index 4268792..8297a3e 100644 --- a/web/src/parts/Graph.tsx +++ b/web/src/parts/Graph.tsx @@ -19,10 +19,12 @@ import { getFrameSlotsBehind } from '@app/utils/graph'; import { truncateHash } from '@app/utils/strings'; import AggregatedNode from '@components/AggregatedNode'; import Edge from '@components/Edge'; +import Legend from '@components/Legend'; import Share from '@components/Share'; import TruncationMarker from '@components/TruncationMarker'; import WeightedNode from '@components/WeightedNode'; import useEthereum from '@contexts/ethereum'; +import useFocus from '@contexts/focus'; import useSelection from '@contexts/selection'; import useGraph from '@hooks/useGraph'; import useWindowSize from '@hooks/useWindowSize'; @@ -53,6 +55,7 @@ function Graph({ const { setFrameId, setAggregatedFrameIds, setFrameBlock, setAggregatedFramesBlock } = useSelection(); const { slotsPerEpoch } = useEthereum(); + const { byo, frameId } = useFocus(); const ref = useRef(null); const { nodes, edges, type, slotEnd, slotStart, head, truncation } = useGraph({ data, @@ -191,6 +194,7 @@ function Graph({ checkpoints, orphaned, validities, + slot, } = node.attributes as AggregatedNodeAttributes; const type: 'canonical' | 'fork' = canonical ? 'canonical' : 'fork'; @@ -223,6 +227,8 @@ function Graph({ total={data.length} type={type} hash={blockRoot} + slot={slot} + epoch={Math.floor(slot / slotsPerEpoch)} radius={RADIUS} onClick={() => { setAggregatedFramesBlock({ @@ -243,6 +249,8 @@ function Graph({ weight, weightPercentageComparedToHeaviestNeighbor, blockRoot, + parentRoot, + slot, } = node.attributes as WeightedNodeAttributes; let type: 'canonical' | 'fork' | 'finalized' | 'justified' | 'detached' | 'invalid' = @@ -260,6 +268,9 @@ function Graph({ type={type} validity={validity} hash={blockRoot} + parentRoot={parentRoot} + slot={slot} + epoch={Math.floor(slot / slotsPerEpoch)} radius={RADIUS} onClick={() => { setFrameBlock({ @@ -355,6 +366,23 @@ function Graph({ )}
)} + {(type === 'weighted' || type === 'aggregated') && ( + + )} = { + canonical: { + fill: 'bg-canonical-deep', + arc: 'text-canonical-ring', + track: 'text-canonical-edge', + label: 'text-canonical', + glow: 'shadow-[0_0_90px_-15px_var(--color-canonical-ring)]', + cardGlow: 'shadow-[0_0_44px_-10px_var(--color-canonical-ring)]', + }, + fork: { + fill: 'bg-fork-deep', + arc: 'text-fork-ring', + track: 'text-fork-edge', + label: 'text-fork', + glow: 'shadow-[0_0_90px_-15px_var(--color-fork-ring)]', + cardGlow: 'shadow-[0_0_44px_-10px_var(--color-fork-ring)]', + }, + finalized: { + fill: 'bg-finalized-deep', + arc: 'text-finalized-ring', + track: 'text-finalized-edge', + label: 'text-finalized', + glow: 'shadow-[0_0_90px_-15px_var(--color-finalized-ring)]', + cardGlow: 'shadow-[0_0_44px_-10px_var(--color-finalized-ring)]', + }, + justified: { + fill: 'bg-justified-deep', + arc: 'text-justified-ring', + track: 'text-justified-edge', + label: 'text-justified', + glow: 'shadow-[0_0_90px_-15px_var(--color-justified-ring)]', + cardGlow: 'shadow-[0_0_44px_-10px_var(--color-justified-ring)]', + }, + invalid: { + fill: 'bg-invalid-deep', + arc: 'text-invalid-ring', + track: 'text-invalid-edge', + label: 'text-invalid', + glow: 'shadow-[0_0_90px_-15px_var(--color-invalid-ring)]', + cardGlow: 'shadow-[0_0_44px_-10px_var(--color-invalid-ring)]', + }, + optimistic: { + fill: 'bg-optimistic-deep', + arc: 'text-optimistic-ring', + track: 'text-optimistic-edge', + label: 'text-optimistic', + glow: 'shadow-[0_0_90px_-15px_var(--color-optimistic-ring)]', + cardGlow: 'shadow-[0_0_44px_-10px_var(--color-optimistic-ring)]', + }, +};