← ui

user-flows

ui

where people go on a site: top paths, what next after landing, before and after, funnel

npx shadcn add @ag/user-flows
Already installed? Preview an update
npx shadcn add @ag/user-flows --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-13
  • New: where people go on a site, from visits (each the places it went, in order, the referrer it landed from, and its page count when a store merges moves within a place, as ts-utils' readVisits does). TopPaths: the common paths, a visit's first four distinct places as coloured chips, a return being no new stop and what comes after a +n, with a bar, visits, share and pages per visit. PathTree: the paths folded by where visits landed, on ui's tree (landings open, the rest folded), each place with its visits, their share of the place before and how many left there. BeforeAndAfter: one place in the middle as a flow (a Sankey), each place on either side joined to it by a ribbon as thick as its visits, the middle node exactly as tall as the ribbons gathering into it; leaving is drawn as leaving, a faint ribbon of sparse crosses that bend with its curve, fading toward the side it leaves by with no place at its end; an arrival from outside the site (a referrer, a direct visit) as its mirror, chevrons on the same grid pointing in toward the place. Arriving is light (a ground of the text's ink, bright chevrons), leaving dark (a shadow below the page, dim crosses). The ribbons are drawn at their column's measured width, never stretched, and a visit is at most 24 px thick, so a handful of visits draws a handful of thin ribbons into a node just tall enough for its name, never a wall. A press on a place centres it, back returns to the one before, and a referrer, a direct arrival or the exit takes no press. A row under the hand is followed through the middle: on the other side each ribbon's part that is the followed visits stays at full strength in its own look (its colour, or its chevrons and crosses) while the rest of it recedes, and its number says how many of its own ("29 of 132" left after arriving from google.com), so where one source's visits went next, or where one destination's came from, reads at a glance (through and followed in the lib). Nothing moves while following: the counts keep the room "n of m" needs and the caption stands above the node. On the new charts shelf, with bar-list and sparkline. Funnel: steps in order, each step's visits and share of the first, and where the ones that dropped went instead. A step may be a named event in a place (lore:download, as ts-utils' eventStep writes it, read with stepOf): it is drawn apart from every page, a solid outline in its place's colour with a click mark, the place quiet and the event strong, and it takes its place's colour where colorOf gives it none. A place wears the colour colorOf gives it in every view, and faviconOf puts a site's icon before its name (a referrer's above all, so where people came from is known at a look), an image the consumer serves itself rather than asking a third party while someone reads. lib/user-flows-core works the views out of the visits, framework-free, and marks what is outside the site.
Updated 2026-10-13 · release notes

also called path analysis, funnel, navigation flow, journeys

shelf charts

uses tree

the demo above, verbatim

user-flows.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import {
  beforeAndAfter,
  funnel,
  pathTree,
  topPaths,
  type Visit,
} from "@/registry/base-nova/lib/user-flows-core"
import {
  BeforeAndAfter,
  Funnel,
  PathTree,
  TopPaths,
} from "@/registry/base-nova/ui/user-flows"

// #region visits
// Two weeks of a studio's site, made up but shaped like real traffic: visits land on
// the studio or a project (from search, a forum, or nowhere), most leave after a page
// or two, some wander project to project, one reads eighty pages. Seeded, so every
// render draws the same visits.
const PLACES = ["studio", "lore", "chill", "xtldr", "trash", "awake", "hollow"]
/** Where a visit lands, and how often. */
const LANDS: [string, number][] = [
  ["studio", 0.45],
  ["lore", 0.25],
  ["chill", 0.12],
  ["xtldr", 0.08],
  ["trash", 0.06],
  ["awake", 0.04],
]
/** From a place, where the next page is (what is left of 1 is leaving). */
const NEXT: Record<string, [string, number][]> = {
  studio: [
    ["lore", 0.32],
    ["chill", 0.1],
    ["trash", 0.08],
    ["xtldr", 0.05],
  ],
  lore: [
    ["chill", 0.12],
    ["studio", 0.1],
    ["xtldr", 0.06],
    ["lore:download", 0.08],
  ],
  chill: [
    ["xtldr", 0.14],
    ["lore", 0.1],
    ["studio", 0.06],
  ],
  xtldr: [
    ["trash", 0.12],
    ["lore", 0.08],
    ["studio", 0.05],
  ],
  trash: [
    ["awake", 0.15],
    ["lore", 0.08],
    ["chill", 0.06],
  ],
  awake: [
    ["lore", 0.12],
    ["chill", 0.08],
    ["hollow", 0.05],
  ],
  hollow: [["studio", 0.1]],
  "lore:download": [["lore", 0.2]],
}
const REFERRERS: [string, number][] = [
  ["google.com", 0.3],
  ["reddit.com", 0.12],
  ["news.ycombinator.com", 0.08],
  ["x.com", 0.05],
]

function seeded(seed: number) {
  let s = seed
  return () => {
    s = (s + 0x6d2b79f5) | 0
    let t = Math.imul(s ^ (s >>> 15), 1 | s)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}

/** One of `choices` by its weight, or null for the share none of them takes. */
function pick(r: number, choices: readonly [string, number][]): string | null {
  let at = 0
  for (const [name, weight] of choices) {
    at += weight
    if (r < at) return name
  }
  return null
}

function sampleVisits(count: number): Visit[] {
  const random = seeded(12)
  const out: Visit[] = []
  for (let i = 0; i < count; i++) {
    const places = [pick(random(), LANDS) ?? "studio"]
    for (;;) {
      const next = pick(
        random(),
        NEXT[places[places.length - 1] as string] ?? [],
      )
      if (!next || places.length > 30) break
      places.push(next)
    }
    const referrer = pick(random(), REFERRERS)
    out.push(referrer ? { places, referrer } : { places })
  }
  // One reader who went everywhere, eighty pages.
  out.push({
    places: Array.from(
      { length: 80 },
      (_, i) => PLACES[(i * 3 + (i >> 2)) % PLACES.length] as string,
    ),
  })
  return out
}

const VISITS = sampleVisits(412)

// Each place's colour, as the site knows it (a project's accent); an event step
// (lore:download) takes its place's.
const COLORS: Record<string, string> = {
  studio: "#8fbf8f",
  lore: "#e0a458",
  chill: "#5fb4e8",
  xtldr: "#a98bf5",
  trash: "#f07a8f",
  awake: "#f2d06b",
  hollow: "#4fc9b0",
}
const colorOf = (place: string) => COLORS[place]

// The referrers' favicons, served by the site itself (fetched once, kept beside the
// visits), never asked of a third party while someone reads.
const FAVICONS: Record<string, string> = {
  "google.com": "/user-flows/google.com.png",
  "reddit.com": "/user-flows/reddit.com.png",
  "news.ycombinator.com": "/user-flows/news.ycombinator.com.svg",
  "x.com": "/user-flows/x.com.png",
}
const faviconOf = (place: string) => FAVICONS[place]
// #endregion

function Around() {
  // The places shown, in order: the last is in the middle, the one before it the way
  // back.
  const [trail, setTrail] = React.useState(["lore"])
  const place = trail[trail.length - 1] as string
  const previous = trail[trail.length - 2]
  return (
    <BeforeAndAfter
      data={beforeAndAfter(VISITS, place)}
      onPlace={(next) => setTrail([...trail, next])}
      back={
        previous
          ? { place: previous, onBack: () => setTrail(trail.slice(0, -1)) }
          : undefined
      }
      colorOf={colorOf}
      faviconOf={faviconOf}
    />
  )
}

export default function UserFlowsDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="top-paths"
        title="top paths"
        description="each visit's first four distinct places, a return to one already seen being no new stop; the places after the fourth are a +n, and the pages column keeps the long visits visible."
        with="visits"
      >
        <TopPaths
          rows={topPaths(VISITS).slice(0, 12)}
          total={VISITS.length}
          colorOf={colorOf}
        />
      </Sample>
      <Sample
        name="path-tree"
        title="after landing, what next"
        description="the same paths folded by where visits landed, three places deep, (exit) where they stopped."
        with="visits"
      >
        <PathTree nodes={pathTree(VISITS).slice(0, 4)} colorOf={colorOf} />
      </Sample>
      <Sample
        name="before-and-after"
        title="before and after one place"
        description="where the visits that reached lore came from and where they went next. Press a place on either side to put it in the middle."
        with="visits"
      >
        <Around />
      </Sample>
      <Sample
        name="funnel"
        title="funnel"
        description="the studio, then lore, then lore's download: how many reached each step, and where the ones that dropped went instead."
        with="visits"
        glance="wide"
      >
        <Funnel
          steps={funnel(VISITS, ["studio", "lore", "lore:download"])}
          colorOf={colorOf}
        />
      </Sample>
    </div>
  )
}

the source, every file an install writes

user-flows.tsx
"use client"

// USER FLOWS, DRAWN: where people go on a site, four ways over the same visits
// (lib/user-flows-core works them out; these only draw what it gives them).
//
//   TopPaths        the common paths, each a row: its places as chips with arrows
//                   between, a "+3" for the places after the fourth, a bar of its
//                   visits, the visits, their share of all, and pages per visit.
//   PathTree        the same paths folded by where they landed: each place with its
//                   visits, and under it where they went next, down to where they left.
//   BeforeAndAfter  one place in the middle: where its visits came from on the left,
//                   where they went next on the right; a press on either side puts that
//                   place in the middle.
//   Funnel          steps in order, the visits reaching each and their share of the
//                   first, and under each step the ones that dropped and where they went.
//
// A PLACE IS ITS COLOUR, the same in every view: `colorOf` says it (the consumer knows
// a place's colour: a project's accent), a chip tinted by it with its name at full
// contrast, so a path reads as a run of stops and lore is the same amber everywhere.
// With no colour a place is the text's own tint. `faviconOf` puts a site's icon before
// its name, a referrer's above all (google.com, a forum), so where people came from is
// known at a look. An exit and an arrival with no referrer are no places: outlined,
// quieter. Bars stand in a column of their own, solid
// in the place's colour on a faint track, never a slab under the words. Numbers are
// mono with tabular figures, so a column lines its digits up.

import { ArrowLeft, ArrowRight, LogOut, MousePointerClick } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  type BeforeAndAfter as Around,
  DIRECT,
  EXIT,
  type FunnelStep,
  followed,
  type PathNode,
  type PathRow,
  stepOf,
} from "@/registry/base-nova/lib/user-flows-core"
import { Tree } from "@/registry/base-nova/ui/tree"

/** A place's colour, any CSS colour; undefined for none. */
export type PlaceColor = (place: string) => string | undefined
/** A place's or a referrer's favicon, an image's URL; undefined for none. The consumer
 *  serves it (stored once beside its visits): a view never asks a third party for a
 *  site's icon, which would tell it who is reading. */
export type PlaceFavicon = (place: string) => string | undefined

const plain = new Intl.NumberFormat("en-US")
const perVisit = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 })
const percent = (part: number, whole: number) =>
  whole === 0 ? "" : `${Math.round((part / whole) * 100)}%`

/** The consumer's colours, an event step taking its place's where it has none of its
 *  own (`lore:download` is lore's), so colouring the places colours their events. */
const stepColors =
  (given?: PlaceColor): PlaceColor =>
  (name) =>
    given?.(name) ?? given?.(stepOf(name).place)

/** A step as a chip. A place in its colour; a named event (`lore:download`) a solid
 *  outline in its place's colour, a mark, its place quiet and its name strong, so it
 *  never reads as one more page; what is no place of the site (a referrer, a direct
 *  arrival, the exit) dashed and quieter. The lib says which is outside (`Counted`'s
 *  `outside`); a bare name is outside when it is the exit or a direct arrival. */
function Place({
  name,
  colorOf,
  faviconOf,
  outside = name === EXIT || name === DIRECT,
  size = "sm",
}: {
  name: string
  colorOf?: PlaceColor
  faviconOf?: PlaceFavicon
  outside?: boolean
  size?: "sm" | "lg"
}) {
  const color = stepColors(colorOf)(name)
  const favicon = faviconOf?.(name)
  const step = stepOf(name)
  if (step.event !== undefined && !outside)
    return (
      <span
        data-slot="user-flows-place"
        data-event=""
        className={cn(
          "inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-(--ag-flow-edge) text-current",
          size === "lg"
            ? "px-2.5 py-1 font-medium text-[15px]"
            : "px-1.5 py-0.5",
        )}
        style={
          {
            "--ag-flow-edge": `color-mix(in oklab, ${color ?? "currentColor"} ${color ? 70 : 35}%, transparent)`,
          } as React.CSSProperties
        }
      >
        <MousePointerClick
          aria-hidden
          className="size-3 shrink-0 text-(--ag-flow-edge)"
        />
        <span className="text-current/55">{step.place}</span>
        {step.event}
      </span>
    )
  return (
    <span
      data-slot="user-flows-place"
      className={cn(
        "inline-flex items-center gap-1.5 whitespace-nowrap rounded-md",
        size === "lg" ? "px-2.5 py-1 font-medium text-[15px]" : "px-1.5 py-0.5",
        outside
          ? "border border-current/25 border-dashed text-current/60"
          : "bg-(--ag-flow-tint) text-current",
        name === EXIT && "gap-1",
      )}
      style={
        outside
          ? undefined
          : ({
              "--ag-flow-tint": `color-mix(in oklab, ${color ?? "currentColor"} ${color ? 26 : 10}%, transparent)`,
            } as React.CSSProperties)
      }
    >
      {/* Leaving, said by its own mark: no place, no colour. */}
      {name === EXIT && <LogOut aria-hidden className="size-3" />}
      {favicon && (
        // biome-ignore lint/performance/noImgElement: an item cannot assume next/image
        <img
          src={favicon}
          alt=""
          width={14}
          height={14}
          className="size-3.5 shrink-0 rounded-[3px]"
        />
      )}
      {name}
    </span>
  )
}

/** A bar in its own column: `share` of its track, filled in `color` (the text's,
 *  strong, with none), growing from `from`. */
function Meter({
  share,
  color,
  from = "left",
  className,
}: {
  share: number
  color?: string
  from?: "left" | "right"
  className?: string
}) {
  return (
    <span
      aria-hidden
      data-slot="user-flows-meter"
      className={cn(
        "relative block h-2 overflow-hidden rounded-full bg-current/8",
        className,
      )}
    >
      <span
        className={cn(
          "absolute inset-y-0 w-(--ag-flow-share) rounded-full bg-(--ag-flow-fill)",
          from === "left" ? "left-0" : "right-0",
        )}
        style={
          {
            "--ag-flow-share": `${Math.max(0, Math.min(1, share)) * 100}%`,
            "--ag-flow-fill": color
              ? `color-mix(in oklab, ${color} 85%, transparent)`
              : "color-mix(in oklab, currentColor 55%, transparent)",
          } as React.CSSProperties
        }
      />
    </span>
  )
}

const NUMBER = "text-right font-mono text-current/85 text-xs tabular-nums"
const QUIET = "text-right font-mono text-current/50 text-xs tabular-nums"
const HEAD = "font-mono text-[11px] text-current/50"

/** The common paths, most visits first. `total` is every visit (for the share);
 *  default the rows' own sum. */
export function TopPaths({
  rows,
  total,
  colorOf,
  faviconOf,
  className,
}: {
  rows: readonly PathRow[]
  total?: number
  colorOf?: PlaceColor
  faviconOf?: PlaceFavicon
  className?: string
}) {
  const most = Math.max(1, ...rows.map((r) => r.visits))
  const all = total ?? rows.reduce((n, r) => n + r.visits, 0)
  return (
    <div
      data-slot="user-flows-paths"
      className={cn(
        "grid grid-cols-[minmax(0,1fr)_minmax(4rem,9rem)_auto_auto_auto] items-center gap-x-4 text-[13px]",
        className,
      )}
    >
      <span className={HEAD}>path</span>
      <span />
      <span className={cn(HEAD, "text-right")}>visits</span>
      <span className={cn(HEAD, "text-right")}>share</span>
      <span className={cn(HEAD, "text-right")}>pages/visit</span>
      {rows.map((r) => (
        <div
          key={r.steps.join("\u0000")}
          data-slot="user-flows-path"
          className="col-span-5 grid min-h-10 grid-cols-subgrid items-center border-current/8 border-t"
        >
          <span className="flex min-w-0 flex-wrap items-center gap-x-1 gap-y-1 py-1.5">
            {r.steps.map((s, i) => (
              <span key={s} className="flex items-center gap-1">
                {i > 0 && (
                  <ArrowRight
                    aria-label="then"
                    className="size-3.5 shrink-0 text-current/45"
                  />
                )}
                <Place name={s} colorOf={colorOf} faviconOf={faviconOf} />
              </span>
            ))}
            {r.more > 0 && (
              <span
                title={`and ${r.more} more place${r.more === 1 ? "" : "s"} after`}
                className="ml-1 font-mono text-[11px] text-current/55"
              >
                +{r.more}
              </span>
            )}
          </span>
          <Meter
            share={r.visits / most}
            color={stepColors(colorOf)(r.steps[0] as string)}
          />
          <span className={NUMBER}>{plain.format(r.visits)}</span>
          <span className={QUIET}>{percent(r.visits, all)}</span>
          <span className={QUIET}>{perVisit.format(r.pages)}</span>
        </div>
      ))}
    </div>
  )
}

/** A place on the drawn tree: the path node, the visits of the place it came from
 *  (null at a landing), and its places next as children. */
type Branch = PathNode & { from: number | null; children: Branch[] }

const branches = (nodes: readonly PathNode[], from: number | null): Branch[] =>
  nodes.map((n) => ({ ...n, from, children: branches(n.next, n.visits) }))

/** The paths folded by where they landed, as ui's tree: each place with its visits,
 *  their share of the place it came from and how many left there, where the rest went
 *  next under it. The landings open, everything deeper folded until opened. */
export function PathTree({
  nodes,
  colorOf,
  faviconOf,
  className,
}: {
  nodes: readonly PathNode[]
  colorOf?: PlaceColor
  faviconOf?: PlaceFavicon
  className?: string
}) {
  const items = branches(nodes, null)
  const most = Math.max(1, ...nodes.map((n) => n.visits))
  return (
    <Tree<Branch>
      aria-label="where visits went after landing"
      data-slot="user-flows-tree"
      items={items}
      label={(b) => b.place}
      content={(b) => (
        <Place name={b.place} colorOf={colorOf} faviconOf={faviconOf} />
      )}
      meta={(b) => (
        <span className="grid grid-cols-[5.5rem_6rem_2.5rem_2.5rem] items-center gap-x-3">
          <span className="text-right font-mono text-[11px] text-current/50 tabular-nums">
            {b.exited > 0 ? `${plain.format(b.exited)} left here` : ""}
          </span>
          <Meter share={b.visits / most} color={stepColors(colorOf)(b.place)} />
          <span className={NUMBER}>{plain.format(b.visits)}</span>
          <span className={QUIET}>
            {b.from === null ? "" : percent(b.visits, b.from)}
          </span>
        </span>
      )}
      defaultExpanded={items.map((b) => b.id)}
      selection="none"
      density="comfortable"
      guides
      className={className}
    />
  )
}

/** One place in the middle: where its visits came from, where they went next. A press
 *  on a place on either side asks to put it in the middle (`onPlace`); a referrer, a
 *  direct arrival and the exit are no places and take no press. `back` is the place
 *  shown before this one, a way back to it above the middle. */
export function BeforeAndAfter({
  data,
  onPlace,
  back,
  colorOf,
  faviconOf,
  className,
}: {
  data: Around
  onPlace?: (place: string) => void
  back?: { place: string; onBack: () => void }
  colorOf?: PlaceColor
  faviconOf?: PlaceFavicon
  className?: string
}) {
  const [lit, setLit] = React.useState<string | null>(null)
  // The ribbons' visits to px: all of a side's ribbons together are at most the middle
  // node's height, FLOW_BAND, and a visit is at most FLOW_VISIT thick, so a handful of
  // visits draws a handful of thin ribbons, never a wall; a ribbon of one visit is
  // still seen (FLOW_MIN).
  const scale =
    data.visits === 0 ? 0 : Math.min(FLOW_BAND / data.visits, FLOW_VISIT)
  const thick = (visits: number) => Math.max(FLOW_MIN, visits * scale)
  /** A row's height: its ribbon's, or a label's where the ribbon is thinner. */
  const slot = (t: number) => Math.max(t, FLOW_LABEL)
  const sideHeight = (list: Around["before"]) =>
    list.reduce((n, x) => n + slot(thick(x.visits)), 0) +
    Math.max(0, list.length - 1) * FLOW_GAP
  const ribbonsOf = (list: Around["before"]) =>
    list.reduce((n, x) => n + thick(x.visits), 0)
  // The node is as tall as the ribbons gathering into it, or its name and count where
  // they are fewer; the ribbons then meet its middle.
  const band = Math.max(
    ribbonsOf(data.before),
    ribbonsOf(data.after),
    FLOW_NODE,
  )
  const height = Math.max(sideHeight(data.before), sideHeight(data.after), band)
  /** One side's rows, stacked and centred: each row's top and height, its ribbon's
   *  top at the row (centred in it) and at the middle node (stacked, no gap). */
  const placed = (list: Around["before"]) => {
    let row = (height - sideHeight(list)) / 2
    let node = (height - ribbonsOf(list)) / 2
    return list.map((x) => {
      const t = thick(x.visits)
      const h = slot(t)
      const out = { x, t, top: row, h, end: row + (h - t) / 2, node }
      row += h + FLOW_GAP
      node += t
      return out
    })
  }
  const left = placed(data.before)
  const right = placed(data.after)
  const centre = stepColors(colorOf)(data.place)
  const key = (side: "before" | "after", place: string) => `${side}:${place}`
  // FOLLOWING: a row under the hand is followed through the middle. Its own side
  // dims the rest; on the other side each ribbon shows, as a band at its top, the
  // part of it that is the followed visits, and its number says how many of its own.
  const litSide = lit?.startsWith("before:") ? "before" : "after"
  const litPlace = lit === null ? null : lit.slice(lit.indexOf(":") + 1)
  const litRow =
    litPlace === null
      ? undefined
      : (litSide === "before" ? data.before : data.after).find(
          (x) => x.place === litPlace,
        )
  const follow =
    litPlace === null
      ? null
      : followed(
          data.through,
          litSide === "before" ? { from: litPlace } : { to: litPlace },
        )
  /** Of the visits on `side`'s row `place`, how many are the followed ones; null when
   *  nothing is followed or the row is on the followed side. */
  const sharedOf = (side: "before" | "after", place: string) =>
    follow === null || side === litSide ? null : (follow.get(place) ?? 0)
  // A place wears its colour; an arrival from outside the text's grey. Leaving is drawn
  // apart (EXIT_RIBBON): it arrives nowhere.
  const fillOf = (x: Around["before"][number]) =>
    (x.outside ? undefined : stepColors(colorOf)(x.place)) ?? "currentColor"
  // Ids for the exit's stripes and fade, one per instance.
  const id = React.useId().replace(/:/g, "")
  const [leftCol, leftWidth] = useWidth()
  const [rightCol, rightWidth] = useWidth()

  /** The ribbons on one side, drawn at their column's own width (`width`, measured), so
   *  nothing in them is stretched: from each row's bar to its slot on the node. */
  const ribbons = (
    list: ReturnType<typeof placed>,
    side: "before" | "after",
    width: number,
  ) => (
    <svg
      aria-hidden
      data-slot="user-flows-ribbons"
      viewBox={`0 0 ${Math.max(1, width)} ${height}`}
      className="block w-full"
      style={{ height }}
    >
      {/* THE EXIT: a faint ground of the text's ink, sparse crosses on it that follow
          the ribbon's curve, fading toward the side it leaves by, so leaving reads as
          leaving, never as one more place or as an error. */}
      <defs>
        <linearGradient id={`${id}-exit-fade`} x1={0} x2={1} y1={0} y2={0}>
          <stop offset={0} stopColor="white" stopOpacity={1} />
          <stop offset={1} stopColor="white" stopOpacity={0.15} />
        </linearGradient>
        <mask id={`${id}-exit-mask`}>
          <rect width={width} height={height} fill={`url(#${id}-exit-fade)`} />
        </mask>
      </defs>
      {width > 0 &&
        list.map(({ x, t, end, node }) => {
          // On the left a ribbon runs from its row's bar (x 0) into the node (x width); on
          // the right from the node (x 0) out to its row's bar (x width).
          const [y0, y1] = side === "before" ? [end, node] : [node, end]
          const w = width
          const d = ribbonPath(y0, y1, t, w)
          const on = lit === key(side, x.place)
          const hover = {
            onPointerEnter: () => setLit(key(side, x.place)),
            onPointerLeave: () => setLit(null),
          }
          const shared = sharedOf(side, x.place)
          // Every ribbon keeps its own look whatever is followed, only its strength
          // changes: the row under the hand full, the rest of its side receding; on
          // the other side every ribbon recedes less, under its band.
          const strength =
            lit === null || on ? 1 : shared === null ? 0.18 : FOLLOW_GROUND
          const leaving = x.place === EXIT
          return (
            <g key={x.place} {...hover}>
              <g
                // Leaving fades out toward where it goes; arriving is light all along.
                mask={leaving ? `url(#${id}-exit-mask)` : undefined}
                className="transition-opacity duration-150 motion-reduce:transition-none"
                style={{ opacity: strength }}
              >
                {x.outside ? (
                  // What is no place of the site is marked, never coloured: leaving with
                  // crosses fading out, arriving from outside with chevrons pointing in,
                  // on one grid. Arriving is light (a ground of the text's ink, bright
                  // chevrons), leaving dark (a shadow deeper than the page, dim crosses).
                  <>
                    <path
                      d={d}
                      fill={leaving ? "black" : "currentColor"}
                      opacity={leaving ? (on ? 0.5 : 0.38) : on ? 0.34 : 0.24}
                    />
                    <path
                      d={marksAlong(
                        y0,
                        y1,
                        t,
                        w,
                        leaving ? "cross" : "chevron",
                      )}
                      stroke="currentColor"
                      strokeWidth={1}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      fill="none"
                      opacity={leaving ? (on ? 0.5 : 0.3) : on ? 1 : 0.85}
                    />
                  </>
                ) : (
                  <path d={d} fill={fillOf(x)} opacity={on ? 1 : 0.5} />
                )}
              </g>
              {/* The followed visits within it: the part of the ribbon that is them, at
                  its top, drawn as the ribbon itself at full strength (its colour, or
                  its marks), as thick as their share and never thinner than a ribbon
                  of one visit. The rest of it recedes around them. */}
              {shared !== null &&
                shared > 0 &&
                (() => {
                  const part = Math.max(FLOW_MIN, (t * shared) / x.visits)
                  const d = ribbonPath(y0, y1, part, w)
                  return x.outside ? (
                    <g mask={leaving ? `url(#${id}-exit-mask)` : undefined}>
                      <path
                        d={d}
                        fill={leaving ? "black" : "currentColor"}
                        opacity={leaving ? 0.55 : 0.4}
                      />
                      <path
                        d={marksAlong(
                          y0,
                          y1,
                          part,
                          w,
                          leaving ? "cross" : "chevron",
                        )}
                        stroke="currentColor"
                        strokeWidth={1}
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        fill="none"
                        opacity={leaving ? 0.6 : 1}
                      />
                    </g>
                  ) : (
                    <path d={d} fill={fillOf(x)} />
                  )
                })()}
            </g>
          )
        })}
    </svg>
  )

  /** One side's rows, in the page's flow (the column is as wide as its widest label):
   *  each as tall as its slot, its label at its ribbon's middle and its bar, the node
   *  its ribbon leaves from, at the ribbons' edge. A place takes a press. */
  const labels = (
    list: ReturnType<typeof placed>,
    side: "before" | "after",
  ) => {
    // The longest count this side can show, in the numbers' own (monospaced) width.
    const most = String(Math.max(0, ...list.map(({ x }) => x.visits))).length
    const reserve = `${most * 2 + 4}ch`
    return (
      <ol
        className="m-0 flex list-none flex-col p-0"
        style={{ height, paddingTop: list[0]?.top ?? 0, gap: FLOW_GAP }}
      >
        {list.map(({ x, t, h }) => {
          const go = onPlace && !x.outside
          const k = key(side, x.place)
          const shared = sharedOf(side, x.place)
          const label = (
            <span
              className={cn(
                "flex items-center gap-2.5",
                side === "before" && "flex-row-reverse",
              )}
            >
              <Place
                name={x.place}
                colorOf={colorOf}
                faviconOf={faviconOf}
                outside={x.outside}
              />
              {/* As wide as "n of m" can be on this side, the room on the outer edge, so
                following a row never moves anything. */}
              <span
                className={cn(
                  NUMBER,
                  "whitespace-nowrap",
                  side === "after" && "text-left",
                )}
                style={{ minWidth: reserve }}
              >
                {shared === null ? (
                  plain.format(x.visits)
                ) : (
                  <>
                    {plain.format(shared)}
                    <span className="text-current/45">
                      {" "}
                      of {plain.format(x.visits)}
                    </span>
                  </>
                )}
              </span>
            </span>
          )
          return (
            <li
              key={x.place}
              onPointerEnter={() => setLit(k)}
              onPointerLeave={() => setLit(null)}
              className={cn(
                "flex shrink-0 items-center gap-2 transition-opacity duration-150 motion-reduce:transition-none",
                side === "before" && "flex-row-reverse",
                lit !== null &&
                  (shared === null ? lit !== k : shared === 0) &&
                  "opacity-40",
              )}
              style={{ height: h }}
            >
              {/* The row's node: its ribbon's end, flush with the ribbons. The exit has
                none: its ribbon fades out before it, arriving nowhere. */}
              <span
                aria-hidden
                className="w-1 shrink-0 bg-(--ag-flow-bar)"
                style={
                  {
                    height: t,
                    "--ag-flow-bar":
                      x.place === EXIT
                        ? "transparent"
                        : `color-mix(in oklab, ${fillOf(x)} ${x.outside ? 65 : 90}%, transparent)`,
                  } as React.CSSProperties
                }
              />
              {go ? (
                <button
                  type="button"
                  title={`put ${x.place} in the middle`}
                  onClick={() => onPlace(x.place)}
                  className="cursor-pointer rounded-md p-0.5 hover:bg-current/6"
                >
                  {label}
                </button>
              ) : (
                <span className="block p-0.5">{label}</span>
              )}
            </li>
          )
        })}
      </ol>
    )
  }

  return (
    <div
      data-slot="user-flows-around"
      className={cn(
        "grid grid-cols-[auto_minmax(4rem,1fr)_auto_minmax(4rem,1fr)_auto] gap-y-3 text-[13px]",
        className,
      )}
    >
      <span className={cn(HEAD, "text-right")}>came from</span>
      <span />
      {/* Above the node: the way back, or, while a row is followed, what is followed.
          Out of the flow, so a caption never widens the node and moves the rest. */}
      <span className="relative flex justify-center">
        {litRow !== undefined ? (
          <span className="absolute top-0 left-1/2 -translate-x-1/2 whitespace-nowrap font-mono text-current/80 text-xs tabular-nums">
            following {plain.format(litRow.visits)}{" "}
            {litSide === "before" ? "from" : "to"} {litRow.place}
          </span>
        ) : (
          back && (
            <button
              type="button"
              onClick={back.onBack}
              className="flex cursor-pointer items-center gap-1 rounded-md px-1.5 text-current/60 text-xs hover:bg-current/6 hover:text-current"
            >
              <ArrowLeft aria-hidden className="size-3" />
              back to {back.place}
            </button>
          )
        )}
      </span>
      <span />
      <span className={HEAD}>went to</span>
      {labels(left, "before")}
      <div ref={leftCol} className="min-w-0">
        {ribbons(left, "before", leftWidth)}
      </div>
      {/* The node the ribbons gather into: exactly as tall as their band and square,
          so they meet it flush, in its colour, its name inside. */}
      <span className="relative flex items-center" style={{ height }}>
        <span
          data-slot="user-flows-node"
          className="flex w-full flex-col items-center justify-center gap-1 bg-(--ag-flow-node) px-4"
          style={
            {
              height: band,
              "--ag-flow-node": `color-mix(in oklab, ${centre ?? "currentColor"} ${centre ? 45 : 14}%, transparent)`,
            } as React.CSSProperties
          }
        >
          <span className="font-medium text-[15px]">{data.place}</span>
          <span className="font-mono text-current/65 text-xs tabular-nums">
            {plain.format(data.visits)} {data.visits === 1 ? "visit" : "visits"}
          </span>
        </span>
      </span>
      <div ref={rightCol} className="min-w-0">
        {ribbons(right, "after", rightWidth)}
      </div>
      {labels(right, "after")}
    </div>
  )
}

/** An element's width as it is laid out, px, followed as it resizes (0 until laid
 *  out): what a drawing needs to be drawn at 1:1 instead of stretched. */
function useWidth(): [(el: HTMLElement | null) => void, number] {
  const [width, setWidth] = React.useState(0)
  const watch = React.useRef<ResizeObserver | null>(null)
  const ref = React.useCallback((el: HTMLElement | null) => {
    watch.current?.disconnect()
    watch.current = null
    if (!el) return
    const observer = new ResizeObserver(([entry]) => {
      if (entry) setWidth(Math.round(entry.contentRect.width))
    })
    observer.observe(el)
    watch.current = observer
  }, [])
  return [ref, width]
}

/** A ribbon `t` thick whose top edge runs from y0 at x 0 to y1 at x w, both handles of
 *  each edge at the middle (edgeOf reads the same curve). */
const ribbonPath = (y0: number, y1: number, t: number, w: number) => {
  const m = w / 2
  return `M0,${y0} C${m},${y0} ${m},${y1} ${w},${y1} L${w},${y1 + t} C${m},${y1 + t} ${m},${y0 + t} 0,${y0 + t} Z`
}

/** The exit's crosses: one every EXIT_STEP px along and across its ribbon, each
 *  EXIT_ARM px from its centre to a tip. */
const EXIT_STEP = 12
const EXIT_ARM = 2

/** A ribbon's top edge (from y0 at x 0 to y1 at x w): its height and slope at an x.
 *  The edge is the cubic with both handles at the middle, so at parameter u its top is
 *  the smoothstep between y0 and y1; the u of an x is found by halving. */
function edgeOf(y0: number, y1: number, w: number) {
  const m = w / 2
  const xAt = (u: number) =>
    3 * (1 - u) ** 2 * u * m + 3 * (1 - u) * u ** 2 * m + u ** 3 * w
  return (x: number) => {
    let lo = 0
    let hi = 1
    for (let i = 0; i < 24; i++) {
      const mid = (lo + hi) / 2
      if (xAt(mid) < x) lo = mid
      else hi = mid
    }
    const u = (lo + hi) / 2
    return {
      top: y0 + (y1 - y0) * (3 * u * u - 2 * u * u * u),
      angle: Math.atan2(
        (y1 - y0) * 6 * u * (1 - u),
        3 * m * (1 - u) ** 2 + 3 * (w - m) * u * u,
      ),
    }
  }
}

/** Sparse marks filling a ribbon (`t` thick, its top edge from y0 to y1 across w), in
 *  columns along it: each column at the ribbon's height there and each mark turned
 *  with its slope, so the marks bend through the curve as the ribbon does. One grid,
 *  two marks: a cross for leaving, a chevron pointing along the flow for arriving. */
function marksAlong(
  y0: number,
  y1: number,
  t: number,
  w: number,
  mark: "cross" | "chevron",
): string {
  const edge = edgeOf(y0, y1, w)
  const rows = Math.max(1, Math.floor(t / EXIT_STEP))
  const first = (t - (rows - 1) * EXIT_STEP) / 2
  const at = (x: number, y: number) => `${x.toFixed(1)},${y.toFixed(1)}`
  const out: string[] = []
  for (let x = EXIT_STEP / 2; x < w; x += EXIT_STEP) {
    const { top, angle } = edge(x)
    for (let k = 0; k < rows; k++) {
      const cy = top + first + k * EXIT_STEP
      const arm = (turn: number, length: number) => ({
        dx: length * Math.cos(angle + turn),
        dy: length * Math.sin(angle + turn),
      })
      if (mark === "cross")
        for (const turn of [Math.PI / 4, -Math.PI / 4]) {
          const { dx, dy } = arm(turn, EXIT_ARM)
          out.push(`M${at(x - dx, cy - dy)}L${at(x + dx, cy + dy)}`)
        }
      else {
        // The tip ahead of the centre along the flow, both arms trailing back.
        const tip = arm(0, EXIT_ARM * 0.7)
        const tx = x + tip.dx
        const ty = cy + tip.dy
        const a = arm(Math.PI * 0.75, EXIT_ARM * 1.4)
        const b = arm(-Math.PI * 0.75, EXIT_ARM * 1.4)
        out.push(
          `M${at(tx + a.dx, ty + a.dy)}L${at(tx, ty)}L${at(tx + b.dx, ty + b.dy)}`,
        )
      }
    }
  }
  return out.join("")
}

/** The middle node's height at most, px: all of a side's ribbons together. */
const FLOW_BAND = 220
/** The thickest a single visit is drawn, px: until FLOW_BAND / FLOW_VISIT visits
 *  (about nine) the band is only as tall as its visits. */
const FLOW_VISIT = 24
/** The node's least height, px: its name and its count, one line each. */
const FLOW_NODE = 56
/** A row's least height, px: a label's, where its ribbon is thinner. */
const FLOW_LABEL = 30
/** Between two rows on a side, px. */
const FLOW_GAP = 6
/** The thinnest ribbon, px: one visit is still seen. */
const FLOW_MIN = 2
/** How strongly the other side's ribbons stay drawn while a row is followed: enough to
 *  keep each one's colour and marks, under the followed band at full strength. */
const FOLLOW_GROUND = 0.4

/** Steps in order: the visits reaching each and their share of the first, and under
 *  each step past the first the ones that dropped and where they went instead (the
 *  top three, the rest counted). */
export function Funnel({
  steps,
  colorOf,
  faviconOf,
  className,
}: {
  steps: readonly FunnelStep[]
  colorOf?: PlaceColor
  faviconOf?: PlaceFavicon
  className?: string
}) {
  const first = steps[0]?.visits ?? 0
  return (
    <ol
      data-slot="user-flows-funnel"
      className={cn(
        "m-0 grid list-none grid-cols-[auto_minmax(4rem,1fr)_auto_auto] items-center gap-x-4 gap-y-1 p-0 text-[13px]",
        className,
      )}
    >
      {steps.map((s, i) => {
        const before = steps[i - 1]?.visits ?? s.visits
        const dropped = before - s.visits
        const shown = s.droppedTo.slice(0, 3)
        const others = s.droppedTo.slice(3).reduce((n, d) => n + d.visits, 0)
        return (
          <li
            // biome-ignore lint/suspicious/noArrayIndexKey: a funnel may name a step twice; its place in the order is part of it
            key={`${i}:${s.step}`}
            className="col-span-4 grid grid-cols-subgrid items-center"
          >
            {i > 0 && dropped > 0 && (
              <p className="col-span-4 m-0 flex flex-wrap items-center gap-x-1.5 gap-y-1 py-1 pl-1 text-[12px] text-current/60">
                <span>{plain.format(dropped)} dropped:</span>
                {shown.map((d) => (
                  <span key={d.place} className="flex items-center gap-1">
                    <Place
                      name={d.place}
                      colorOf={colorOf}
                      faviconOf={faviconOf}
                      outside={d.outside}
                    />
                    <span className="font-mono tabular-nums">
                      {plain.format(d.visits)}
                    </span>
                  </span>
                ))}
                {others > 0 && <span>· elsewhere {plain.format(others)}</span>}
              </p>
            )}
            <span className="flex min-h-9 items-center">
              <Place name={s.step} colorOf={colorOf} faviconOf={faviconOf} />
            </span>
            <Meter
              share={first === 0 ? 0 : s.visits / first}
              color={stepColors(colorOf)(s.step)}
            />
            <span className={NUMBER}>{plain.format(s.visits)}</span>
            <span className={QUIET}>{percent(s.visits, first)}</span>
          </li>
        )
      })}
    </ol>
  )
}
user-flows-core.ts
// USER FLOWS: where people go on a site. Each visit is the places it went, in order
// (a place: a page, or a group of pages read as one, like a project's); every view of
// where people go is worked out from those lists, framework-free (the `-core`: the
// views' file shares the name). Proven by
// scripts/examples/user-flows.ts; drawn by ui/user-flows.
//
// TOP PATHS (the rule that keeps an eighty-page visit from swamping the list): a path
// is a visit's first four distinct places, in the order first seen; going back to a
// place already on it is no new stop, and whatever comes after the fourth is counted,
// never listed. How many pages its visits saw is a number beside it, so a long visit is
// still seen. THE PATH TREE is the same paths folded by where they landed ("after
// landing on X, what next"), each branch ending in EXIT for the visits that stopped.
//
// BEFORE AND AFTER ONE PLACE: where the visits that reached it came from (the place
// before their first time there, or the referrer they landed from) and where they went
// next (the place after, or EXIT). THE FUNNEL: the visits reaching each step in order,
// and for those that dropped, where they went instead.

/** One visit: the places it went, in order (a place twice is two visits to it), the
 *  outside site it landed from (a referrer's host), if any, and how many pages it
 *  saw, when that is more than its places (a store that keeps a move within a place
 *  as no new place: lib/visits); unsaid, a place is a page. */
export interface Visit {
  places: readonly string[]
  referrer?: string
  pages?: number
}

/** Where a visit went after its last place. */
export const EXIT = "(exit)"
/** Where a visit with no referrer came from. */
export const DIRECT = "(direct)"

/** A step of a visit is a place, or a named event that happened in one: `lore:download`,
 *  the place, a colon, the event (lib/visits' `eventStep` writes them so). A place's name
 *  holds no colon. */
export function stepOf(name: string): { place: string; event?: string } {
  const at = name.indexOf(":")
  return at < 0
    ? { place: name }
    : { place: name.slice(0, at), event: name.slice(at + 1) }
}

/** The distinct places of a visit, in the order first seen. */
export const distinct = (places: readonly string[]): string[] => [
  ...new Set(places),
]

/** One of the top paths: its first distinct places, how many more distinct places its
 *  visits went on to (the most any of them did), how many visits, and the median
 *  pages a visit on it saw. */
export interface PathRow {
  steps: readonly string[]
  more: number
  visits: number
  pages: number
}

function median(values: readonly number[]): number {
  const sorted = [...values].sort((a, b) => a - b)
  const mid = sorted.length / 2
  return sorted.length % 2
    ? (sorted[Math.floor(mid)] as number)
    : ((sorted[mid - 1] as number) + (sorted[mid] as number)) / 2
}

/** Every visit's path, its first `depth` distinct places, most visits first (ties: the
 *  shorter path first). */
export function topPaths(visits: readonly Visit[], depth = 4): PathRow[] {
  if (!(depth >= 1)) throw new Error(`user-flows: a depth of ${depth}`)
  const rows = new Map<
    string,
    { steps: string[]; more: number; pages: number[] }
  >()
  for (const v of visits) {
    if (v.places.length === 0)
      throw new Error("user-flows: a visit with no place")
    const all = distinct(v.places)
    const steps = all.slice(0, depth)
    const key = steps.join("\u0000")
    const row = rows.get(key) ?? { steps, more: 0, pages: [] }
    row.more = Math.max(row.more, all.length - steps.length)
    if (v.pages !== undefined && v.pages < v.places.length)
      throw new Error(
        `user-flows: a visit of ${v.places.length} places saw ${v.pages} pages`,
      )
    row.pages.push(v.pages ?? v.places.length)
    rows.set(key, row)
  }
  return [...rows.values()]
    .map((r) => ({
      steps: r.steps,
      more: r.more,
      visits: r.pages.length,
      pages: median(r.pages),
    }))
    .sort((a, b) => b.visits - a.visits || a.steps.length - b.steps.length)
}

/** A place on the path tree: the visits whose path reached it this way, how many of
 *  them left there (its last distinct place), and where the rest went next, most
 *  visits first. `id` is the path to it, unique in the tree. */
export interface PathNode {
  id: string
  place: string
  visits: number
  exited: number
  next: PathNode[]
}

/** The visits' paths folded by where they landed, `depth` places deep (what comes
 *  after is cut: a visit that went on past the depth did not leave there). */
export function pathTree(visits: readonly Visit[], depth = 3): PathNode[] {
  type Grow = { visits: number; exited: number; next: Map<string, Grow> }
  const root = new Map<string, Grow>()
  for (const v of visits) {
    const all = distinct(v.places)
    const path = all.slice(0, depth)
    let level = root
    path.forEach((place, i) => {
      const node = level.get(place) ?? { visits: 0, exited: 0, next: new Map() }
      node.visits++
      // Its last distinct place: the visit left here.
      if (i === all.length - 1) node.exited++
      level.set(place, node)
      level = node.next
    })
  }
  const out = (level: Map<string, Grow>, at: string): PathNode[] =>
    [...level.entries()]
      .map(([place, g]) => {
        const id = at ? `${at} › ${place}` : place
        return {
          id,
          place,
          visits: g.visits,
          exited: g.exited,
          next: out(g.next, id),
        }
      })
      .sort((a, b) => b.visits - a.visits)
  return out(root, "")
}

/** Places with how many visits each, most first; `outside` when it is no place of the
 *  site (a referrer, a direct arrival, the exit), nowhere a view can go to. */
export type Counted = { place: string; visits: number; outside: boolean }[]

const tally = (
  names: readonly string[],
  outside: (name: string) => boolean,
): Counted =>
  [
    ...names.reduce(
      (m, n) => m.set(n, (m.get(n) ?? 0) + 1),
      new Map<string, number>(),
    ),
  ]
    .map(([place, visits]) => ({ place, visits, outside: outside(place) }))
    .sort((a, b) => b.visits - a.visits)

/** One place, with the visits that reached it, where they came from and where they
 *  went next, and `through`: how many visits came from each place before and went to
 *  each place after, so the visits of one side can be followed to the other. */
export interface BeforeAndAfter {
  place: string
  visits: number
  before: Counted
  after: Counted
  through: Through
}

/** Visits by where they came from, then where they went next. */
export type Through = ReadonlyMap<string, ReadonlyMap<string, number>>

/** Of the visits that came from `from` (or went to `to`), how many went to (came from)
 *  each place of the other side; with neither, none. */
export function followed(
  through: Through,
  by: { from: string } | { to: string },
): ReadonlyMap<string, number> {
  if ("from" in by) return through.get(by.from) ?? new Map()
  const out = new Map<string, number>()
  for (const [from, tos] of through) {
    const n = tos.get(by.to)
    if (n) out.set(from, n)
  }
  return out
}

export function beforeAndAfter(
  visits: readonly Visit[],
  place: string,
): BeforeAndAfter {
  const before: string[] = []
  const after: string[] = []
  // What came from outside the site: told by where it stood, never by its name (a
  // referrer could be named like a place).
  const arrivals = new Set<string>()
  const through = new Map<string, Map<string, number>>()
  for (const v of visits) {
    const at = v.places.indexOf(place)
    if (at < 0) continue
    const from =
      at === 0 ? (v.referrer ?? DIRECT) : (v.places[at - 1] as string)
    const to = v.places[at + 1] ?? EXIT
    if (at === 0) arrivals.add(from)
    before.push(from)
    after.push(to)
    const tos = through.get(from) ?? new Map<string, number>()
    tos.set(to, (tos.get(to) ?? 0) + 1)
    through.set(from, tos)
  }
  return {
    place,
    visits: before.length,
    before: tally(before, (n) => arrivals.has(n)),
    after: tally(after, (n) => n === EXIT),
    through,
  }
}

/** One step of a funnel: the visits that reached it (having reached every step before
 *  it, in order), and where those that reached the step before but not this one went
 *  instead. */
export interface FunnelStep {
  step: string
  visits: number
  droppedTo: Counted
}

/** Visits through `steps` in order (each step reached any time after the one before);
 *  for each step past the first, where the visits that dropped went next after the
 *  step before (EXIT when they stopped there). */
export function funnel(
  visits: readonly Visit[],
  steps: readonly string[],
): FunnelStep[] {
  const [first, ...rest] = steps
  if (first === undefined) throw new Error("user-flows: a funnel of no step")
  let at = visits
    .map((v) => ({ v, i: v.places.indexOf(first) }))
    .filter((a) => a.i >= 0)
  const out: FunnelStep[] = [{ step: first, visits: at.length, droppedTo: [] }]
  for (const step of rest) {
    const reached: typeof at = []
    const dropped: string[] = []
    for (const a of at) {
      const next = a.v.places.indexOf(step, a.i + 1)
      if (next >= 0) reached.push({ v: a.v, i: next })
      else dropped.push(a.v.places[a.i + 1] ?? EXIT)
    }
    out.push({
      step,
      visits: reached.length,
      droppedTo: tally(dropped, (n) => n === EXIT),
    })
    at = reached
  }
  return out
}