Charts

Trace Waterfall

A four-state request waterfall — nested spans on one shared relative clock, phase segments inside each bar, self-placing duration labels, an interval-attributed critical path ruled over exactly the stretch each span was blocking, and a collapsible tree the arrow keys can walk.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ListTree, RefreshCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import {
  buildTraceLayout,
  type ChartTraceWaterfallData,
  type TraceLayoutSpan,
} from "./chart-trace-waterfall.contract"

export interface ChartTraceWaterfallProps

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-trace-waterfall.json

Prompt

Build a React + TypeScript + Tailwind "ChartTraceWaterfall" card — a devtools
style request waterfall — as hand-rolled SVG over a zod contract. Recharts is
the wrong tool here: the picture is a tree of offset bars sliced into phases,
not a cartesian series.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    description?: string; budget?: number;
    spans: { id: string; label: string; parent?: string;
             start: number; duration: number;
             phases?: { label: string; duration: number }[];
             outcome?: "ok" | "warning" | "error";
             detail?: string }[] }
- start and duration are MILLISECONDS ON THE TRACE CLOCK, not timestamps: a
  trace is read relative to its own zero, and relative numbers survive being
  logged, replayed and diffed. Nesting is by parent id, never by array index —
  an index re-points silently the moment a span is inserted.
- Those numeric fields are z.union([z.number(), z.nan()]), not z.number(): zod 4
  refuses NaN, so one span arriving with duration: NaN would fail the parse and
  blank the whole card. A trace with one broken span is still a trace. Admit it
  at the door and drop it — counted — in the layout.
- budget is a performance budget on the same clock, drawn as a dashed vertical
  line. It is INJECTED. The component never calls Date.now(): a chart that reads
  the clock while rendering draws one picture on the server and another in the
  browser, and can never be snapshot-tested.
- Component props = z.infer of the schema plus density ("comfortable" |
  "compact"), labelWidth (96-360), criticalPath (default true),
  collapseBelowDepth, minBarWidth (default 2, clamped 1-12), locale
  (default "en-US"), onSpanSelect, onRetry, emptyState and className, and it
  forwardRefs onto the card with the rest of the native div props spread.
- Ship the layout maths as PURE EXPORTED FUNCTIONS beside the schema, so a test
  can print the same numbers the picture is made of: buildTraceLayout()
  returning { ok: true, layout } | { ok: false, issue }, plus mergeIntervals(),
  coveredLength(), layoutPhases(), buildDurationTicks(), findCriticalPath(),
  and, beside the component, placeDurationLabel() and phaseAppearance().

Behavior
- THE CRITICAL PATH IS AN INTERVAL ATTRIBUTION, not a chain of boxes. Walk
  backwards from the root end: whatever ran last was the blocker; before it
  started, whatever ended nearest that instant was the blocker; every gap the
  children do not cover is the parent doing its own work. Recurse into each
  blocker over exactly the window it blocked, visiting children latest-end
  first so the walk can stop as soon as one ends before the window opens.
  Two properties fall out and both are the point: the emitted stretches TILE
  THE ROOT EXACTLY, so each span's share is a real percentage; and a slow span
  that ran concurrently with a slower one gets nothing, which is the correct
  answer, because shortening it would not have shortened the trace. Do NOT
  ship the tempting shortcut — "descend into the last-finishing child" — it
  cannot produce a percentage and it hands the crown to whichever
  three-millisecond fire-and-forget call happened to finish last.
- SELF TIME per span is its length minus the union of its children clipped to
  it. Union, not sum: two children that overlap must be counted once. Clip
  first, or a child that escapes its parent drives self time negative.
- PHASES are lengths, not timestamps, laid end to end from the span start and
  CLIPPED at the span end. A tracer measuring dns + tls + ttfb + download off
  four clocks routinely reports a sum a few ms longer than the span it belongs
  to; scaling the slices to fit silently rewrites every number, and letting
  them run on draws a phase outside the span containing it. Clip, and report
  the excess as overrunMs for the card to state out loud. The opposite gap —
  phases summing to LESS than the span — is not an error and is never filled
  in: the remainder stays bare track, which is exactly what "nobody attributed
  this time" should look like.
- NOTHING IS DROPPED IN SILENCE. Count, and print under the header: spans whose
  offset or length is not finite, repeated ids, spans naming a parent nobody
  sent, parent chains that close a loop, and phase lengths that are not
  numbers. Re-root the orphans and the loop members rather than discarding
  their subtrees — a loop left in place is an infinite descent in every
  consumer of the tree. A negative duration is clamped to an instant and
  counted; a child that runs outside its parent is drawn where its numbers put
  it and flagged, because tidying it away hides the bug the trace was opened to
  find.
- REFUSE, don't fake it: if spans arrive but not one of them can be placed on a
  clock, return { ok: false } and name the causes. That is neither "empty" nor
  a failed fetch and must not be drawn as either.
- The four states are first-class branches of one bg-card panel: an indented
  pulsing skeleton (aria-hidden, deterministic geometry, no timers), an empty
  state, an error state carrying either the transport message or the layout's
  refusal plus a "Try again" button only when onRetry exists, and ready.
- COLLAPSE is a real tree. Rows are depth-first; collapsing a span hides its
  subtree, and the row keeps a dashed bracket spanning the whole hidden subtree
  plus a +N count in the gutter, so folding never folds away the fact that work
  happened out there. The critical-path rules of hidden spans roll up onto the
  nearest visible ancestor. collapseBelowDepth is an INITIAL value, re-applied
  when the span set changes — key that off the list of span ids, not off the
  layout object, because the layout is rebuilt on every resize and object
  identity would throw the reader's open/closed state away every time the card
  changed size.
- Degenerate geometry must not break the picture: an empty span list is the
  empty state; a single instantaneous span opens a one-millisecond window
  rather than dividing by a zero-wide domain; a 0.4 ms span inside a 1.84 s
  trace is 0.13px and is painted at minBarWidth, counted in a footnote that
  admits its width no longer matches its duration.

Rendering & styling
- The time axis sits ON TOP, devtools style, with ticks on the 1 / 2 / 5 ladder
  built as k · step from an integer k (repeated addition drifts to
  1.2000000000000002 and prints fourteen decimals on a sub-millisecond trace),
  and k = 0 normalised because Math.ceil hands back -0 and Intl prints that as
  "-0". The axis keeps ONE unit for its whole length — ticks reading 900 ms,
  1.00 s, 1.10 s are three units on one ruler — with precision derived from the
  step. Bar durations do the opposite and follow their own magnitude: µs, ms
  then s, so 420 µs and 1.84 s can sit in one card.
- PHASE COLOUR AND TEXTURE ADVANCE ON DIFFERENT CYCLES: colour is
  var(--chart-1..5) by slot % 5, texture is one of five weaves by
  floor(slot / 5) % 5. Twenty-five phases stay distinct, and any two stay
  distinct with no hue at all. Cut the texture in var(--card) — the surface
  every bar sits on, and the one colour guaranteed to contrast with all five
  tokens in both themes; a --foreground weave vanishes on the dark end of the
  light palette and the light end of the dark one.
- COLOUR IS NEVER THE ONLY CHANNEL: the phase name is printed inside any
  segment wide enough to hold it (with a paint-order:stroke halo in --card, so
  it reads over any fill without the chart guessing which foreground that fill
  wants), every phase is named in the readout and the sr-only table, the
  outcome is a shape before it is a colour (filled triangle for a failure,
  hollow square for a warning), and the critical path is a rule, not a tint.
- The critical path is drawn as a foreground rule ABOVE each blocking stretch,
  not as a highlight on the whole bar. The stretches tile the root, so the
  ruled length IS the share; a bar with no rule at all is the finding.
- DURATION LABELS place themselves: after the bar, before it, or — when neither
  gap holds the text — inside it, right-aligned against the bar end. One bar per
  row means the only bounds are the plot edges, so a duration can never collide
  with a neighbour. Estimate text width at 0.62 × font size, rounded UP from
  the real average: an underestimate spills a label with no ellipsis to say so.
- INTERACTION, keyboard first. role="tree" on the svg with one tab stop and
  aria-activedescendant rather than a tabindex per row (focus on an SVG child
  is unevenly supported, and a 200-span trace would otherwise put 200 stops
  between this card and whatever follows it). Each row is a role="treeitem"
  with aria-level / aria-posinset / aria-setsize, because the DOM is flat.
  ↑ / ↓ move a row through what is on screen, → opens a closed span then steps
  into its first child, ← closes an open one then climbs to its parent, Home /
  End jump to the ends, Enter and Space fire onSpanSelect. The disclosure
  triangle is clickable AND stops propagation so it never fires the row's
  drill-down — but a pointer is never the only path to it.
- The readout line under the plot is aria-hidden on purpose: the focused row
  already announces itself through aria-activedescendant and a live region
  would say every number twice. Pointer moves and disclosure clicks do not move
  focus, so those go to a polite sr-only status line — and pointing at a row
  clears the last disclosure message, or a stale "collapsed" sits on top of
  every hover for the rest of the session.
- Put sr-only on the WRAPPER DIV of the summary and the span table, never on
  the table: CSS width is only a lower bound for a table box, so width:1px does
  not hold one back and a 375px viewport picks up hundreds of px of horizontal
  scroll.
- Axis and tick text is fill-muted-foreground at the density's font size,
  gridlines are stroke-border, bars sit on a fill-muted track with a
  stroke-muted-foreground outline. Only transitions are motion-safe by way of
  motion-reduce:transition-none, and the chart is complete with animation off.
- Measure the container with a ResizeObserver and fall back to a fixed width
  before the first measurement, so SSR and the first client render agree; the
  tick target follows the measured width so a narrow card thins its axis
  instead of stacking labels.

Customization levers
- density: "compact" halves the row rhythm and drops the in-segment phase names
  for a sidebar; "comfortable" is the reading view. labelWidth overrides the
  gutter independently, which is the knob to reach for when span names are
  long — indentation is capped at half the gutter so a deep tree never pushes
  the names out entirely.
- criticalPath={false} removes the rules and every sentence about them, for a
  card that is only meant to show shape and order.
- collapseBelowDepth: 1 or 2 turns a 200-span trace into a readable outline;
  omit it for traces small enough to read whole. Wire onSpanSelect to open your
  own span detail — nothing is pinned in here.
- minBarWidth: 0-1 for strictly proportional widths (accept that sub-millisecond
  spans disappear), 4-8 when the tail matters more than the arithmetic. It only
  changes paint, never a number.
- budget: point it at your SLO to get the dashed line and the over / under
  sentence; omit it and the chart says so rather than inventing a target.
- Palette: phases take var(--chart-1..5) by slot in first-appearance order.
  Sort the phase slots by total time instead if you want the biggest phase to
  own slot 1, or pin a fixed slot per known phase name so "db" is the same
  colour across every card in a dashboard.
- Density of information: the phase legend, the drop note, the footnote and the
  readout are four independent blocks — drop any of them for a compact card, as
  long as the sr-only table stays, since it is the only place the exact numbers
  are guaranteed to survive.

Concepts

  • Relative trace clock — every offset is milliseconds from the trace's own zero, so one shared ruler runs across all the rows and a bar starting further right started later. Nothing here is a wall-clock timestamp, which is what lets the same payload draw the same picture on the server, in the browser and in a snapshot next year; the only vertical reference, the budget line, is injected too.
  • Interval-attributed critical path — walking backwards from the root end and recursing into each blocker over exactly the window it blocked. The stretches tile the root, so each span's contribution is a genuine percentage, and a span that ran alongside something slower gets nothing at all — which is the whole finding: shortening it would not have shortened the trace.
  • Self time — a span's length minus the union of its children clipped to it. Union rather than sum, so two children that overlap are counted once; clipped first, so a child that escapes its parent cannot drive it negative. It is the answer to "did this layer wait, or did it work?".
  • Phase segments and bare track — the ordered slices inside one bar, laid end to end from its start and clipped at its end. Phases that claim more than the span lasted are clipped and the excess is stated; phases that claim less leave the remainder as bare track, because "nobody attributed this time" is a real answer and painting over it would be a lie.
  • Counted, never silent — a non-numeric offset, a repeated id, a parent nobody sent, a parent chain that loops, a negative length, a phase length that is not a number. Each one is counted and printed under the header; loops and orphans are re-rooted rather than discarded, since dropping them would take their subtrees with them.
  • Roll-up on collapse — folding a subtree keeps its extent as a dashed bracket, its size as a +N in the gutter, and its critical stretches as rules on the nearest visible ancestor. Collapsing is a change of detail, never a change of total.

On This Page