# Benchmark

> Package `@umriss-ui/schedule`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/schedule/benchmark/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/schedule/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/schedule/llms-full.txt).

A plan of up to five thousand subtasks and as many dependencies, measured in this browser: the first render, and the time and frames of a scripted pointer walk, pan and wheel zoom.

```ts
import { useSchedule } from "@umriss-ui/schedule";
```

Demo page: https://romanhaendler.github.io/umriss-ui/schedule/benchmark/

The figures in `packages/schedule/docs/capabilities.md` come from here. The page dispatches the pointer and wheel events itself, on the schedule's canvas, and adds no prop to the schedule.

## Examples

### Measure a plan

Pick a plan size and run the scripted pointer walk, pan and wheel zoom: the times and frame counts say what drawing and moving it cost here.

```tsx
/* A plan whose size is set by a button, and the figures of what drawing and
   moving it cost. Everything is measured from outside: React's profiler, the
   clock and the frame callbacks; the pointer walk, the pan and the wheel zoom
   are dispatched on the schedule's canvas by the page itself, so a script only
   presses a button and waits for the status to read `done`. Not photographed:
   it measures the moment it runs. */

import { useEffect, useMemo, useRef, useState } from "react";
import { Button, Text } from "@umriss-ui/core";
import { useSchedule } from "@umriss-ui/schedule";
import type { Dependency, ScheduleInteraction, Subtask, Task } from "@umriss-ui/schedule";

const MINUTE = 60_000;
const HOUR = 60 * MINUTE;
const T0 = Date.UTC(2026, 2, 16, 6);
const VIEW: readonly [number, number] = [T0, T0 + 8 * HOUR];
const HEIGHT = 440;
const LANE_HEIGHT = 44;
/* Events per scripted run, one to a frame. */
const EVENTS = 240;

const SIZES = {
  small: { lanes: 20, subtasks: 200 },
  medium: { lanes: 50, subtasks: 1_000 },
  large: { lanes: 100, subtasks: 5_000 },
} as const;
type Size = keyof typeof SIZES;

interface Plan {
  lanes: readonly string[];
  tasks: readonly Task[];
  subtasks: readonly Subtask[];
  dependencies: readonly Dependency[];
}

/** Lane 0 holds one long bar, the probe the pointer walk runs along. The other
    lanes share the remaining subtasks in 20-minute bars every half hour, each
    lane one task, and every bar after the first on a lane hangs on the bar
    before it - as many dependencies as there are such pairs. */
function plan({ lanes, subtasks }: { lanes: number; subtasks: number }): Plan {
  const laneIds = Array.from({ length: lanes }, (_, i) => `lane-${i}`);
  const tasks = laneIds.map((_, i): Task => ({ id: `task-${i}`, name: `Task ${i}`, color: `hsl(${(i * 37) % 360} 60% 50%)` }));
  const work: Subtask[] = [{ id: "probe", task: "task-0", lane: "lane-0", from: T0 + HOUR, to: T0 + 7 * HOUR, name: "Probe" }];
  const dependencies: Dependency[] = [];
  const others = lanes - 1;
  for (let j = 0; j < subtasks; j++) {
    const lane = 1 + (j % others);
    const from = T0 + Math.floor(j / others) * 30 * MINUTE;
    work.push({ id: `s-${j}`, task: `task-${lane}`, lane: `lane-${lane}`, from, to: from + 20 * MINUTE, name: `Work ${j}` });
    if (j >= others) dependencies.push({ id: `d-${j}`, from: `s-${j - others}`, to: `s-${j}`, lag: 0 });
  }
  return { lanes: laneIds, tasks, subtasks: work, dependencies };
}

type Figures = Record<string, number | undefined>;
const FIGURES = [
  ["first-render-ms", "First render, click to frame"],
  ["walk-bar-ms", "Pointer walk along one bar, ms per event"],
  ["walk-bar-renders", "Pointer walk along one bar, renders"],
  ["walk-bar-frame-ms", "Pointer walk along one bar, ms from event to end of its frame"],
  ["walk-many-ms", "Pointer walk across many bars, ms per event"],
  ["walk-many-renders", "Pointer walk across many bars, renders"],
  ["walk-many-frame-ms", "Pointer walk across many bars, ms from event to end of its frame"],
  ["pan-ms", "Pan, ms per event"],
  ["pan-frame-ms", "Pan, ms from event to end of its frame"],
  ["zoom-ms", "Wheel zoom, ms per event"],
  ["zoom-frame-ms", "Wheel zoom, ms from event to end of its frame"],
] as const;

/** What the page keeps between renders without drawing it: when the size was
    asked for, the the target last under the pointer. */
interface Meter {
  clickAt: number;
  hover: ScheduleInteraction | null;
}

/* One page, one meter: a module value, since no render may read or write it. */
const meter: Meter = { clickAt: 0, hover: null };

const frame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const elapsed = (meter: Meter) => performance.now() - meter.clickAt;
const remember = (e: ScheduleInteraction) => {
  meter.hover = e;
};
const stamp = (meter: Meter) => {
  meter.clickAt = performance.now();
};

const pointer = (type: string, target: Element, x: number, y: number) =>
  target.dispatchEvent(
    new PointerEvent(type, { bubbles: true, cancelable: true, pointerId: 1, pointerType: "mouse", button: 0, buttons: type === "pointerup" ? 0 : 1, clientX: x, clientY: y }),
  );

/* The y of lane 0's row: moving down the probe's column until the schedule
   reports the probe under the pointer. */
function probeY(meter: Meter, el: Element, rect: DOMRect): number {
  for (let y = 0; y < HEIGHT; y += 3) {
    pointer("pointermove", el, rect.left + rect.width / 2, rect.top + y);
    const h = meter.hover;
    if (h?.hit.kind === "subtask" && h.hit.subtask.id === "probe") return rect.top + y;
  }
  return rect.top + LANE_HEIGHT;
}

interface Sinks {
  put: (values: Figures) => void;
  status: (s: "running" | "done") => void;
}

type Step = (el: Element, rect: DOMRect, y0: number, i: number) => void;

/* The end of the next frame: a task queued from a frame callback runs after
   that frame has been drawn, and the schedule's own draw callback, registered
   by the event before ours, has run by then. */
const frameEnd = () =>
  new Promise<void>((resolve) => {
    const channel = new MessageChannel();
    channel.port1.onmessage = () => {
      channel.port1.close();
      resolve();
    };
    requestAnimationFrame(() => channel.port2.postMessage(null));
  });

/* A scripted measurement: EVENTS events, one to a frame. It reports the time
   the dispatch itself took per event, the time from the dispatch to the end of
   the frame that drew it, and the batches of markup changes counted meanwhile.
   The synthetic pointer id is unknown to the browser, so the plot's pointer
   capture is switched off for the run. */
async function measure(name: string, root: HTMLElement | null, meter: Meter, sinks: Sinks, step: Step, drag: boolean): Promise<void> {
  const el = root?.querySelector("[data-schedule-plot] canvas");
  const plot = el?.parentElement;
  if (el === null || el === undefined || plot === null || plot === undefined) return;
  sinks.status("running");
  plot.setPointerCapture = () => undefined;
  const rect = el.getBoundingClientRect();
  const y0 = probeY(meter, el, rect);
  const middle = rect.left + rect.width / 2;
  if (drag) pointer("pointerdown", el, middle, y0);
  await frame();
  /* The production build has no profiler; a commit shows as a batch of changes
     to the schedule's markup, which is what is counted. */
  let renders = 0;
  const observer = new MutationObserver(() => renders++);
  observer.observe(root!, { subtree: true, childList: true, attributes: true, characterData: true });
  let busy = 0;
  let drawn = 0;
  for (let i = 0; i < EVENTS; i++) {
    await frame();
    const t = performance.now();
    step(el, rect, y0, i);
    busy += performance.now() - t;
    await frameEnd();
    drawn += performance.now() - t;
  }
  if (drag) pointer("pointerup", el, middle, y0);
  await frame();
  observer.disconnect();
  delete (plot as Partial<HTMLElement>).setPointerCapture;
  sinks.put({
    [`${name}-ms`]: busy / EVENTS,
    [`${name}-frame-ms`]: drawn / EVENTS,
    ...(name.startsWith("walk") ? { [`${name}-renders`]: renders } : {}),
  });
  sinks.status("done");
}

const rows = Math.max(1, Math.floor(HEIGHT / LANE_HEIGHT) - 1);
const STEPS: Record<string, { step: Step; drag: boolean }> = {
  "walk-bar": { step: (el, r, y0, i) => pointer("pointermove", el, r.left + r.width * (0.12 + (0.76 * i) / EVENTS), y0), drag: false },
  "walk-many": { step: (el, r, y0, i) => pointer("pointermove", el, r.left + r.width * ((i * 0.618) % 1), y0 + (i % rows) * LANE_HEIGHT), drag: false },
  /* The pan goes left for half the events and back again, so the view ends where it began. */
  pan: { step: (el, r, y0, i) => pointer("pointermove", el, r.left + r.width / 2 + (i < EVENTS / 2 ? -3 * i : -3 * (EVENTS - i)), y0), drag: true },
  /* Ctrl held, as a pinch arrives; in and out in turn. */
  zoom: {
    step: (el, r, y0, i) =>
      el.dispatchEvent(
        new WheelEvent("wheel", { bubbles: true, cancelable: true, ctrlKey: true, deltaY: i % 2 === 0 ? -120 : 120, clientX: r.left + r.width / 2, clientY: y0 }),
      ),
    drag: false,
  },
};
const RUNS = [
  ["walk-bar", "Walk along one bar"],
  ["walk-many", "Walk across many"],
  ["pan", "Pan"],
  ["zoom", "Wheel zoom"],
] as const;

function Plot({ data, onInteraction }: { data: Plan; onInteraction: (e: ScheduleInteraction) => void }) {
  const { Schedule, Lane, Subtasks, Dependencies } = useSchedule({ initialView: { domain: VIEW } });
  return (
    <Schedule ariaLabel="Benchmark plan" height={HEIGHT} laneHeight={LANE_HEIGHT} onInteraction={onInteraction}>
        {data.lanes.map((id) => (
          <Lane key={id} id={id} />
        ))}
        <Dependencies data={data.dependencies} />
        <Subtasks data={data.subtasks} tasks={data.tasks} />
    </Schedule>
  );
}

export default function Benchmark() {
  const [size, setSize] = useState<Size>("small");
  /* Pressing a size again draws the plan anew, so the first render can be taken twice. */
  const [round, setRound] = useState(0);
  const [status, setStatus] = useState<"idle" | "running" | "done">("running");
  const [figures, setFigures] = useState<Figures>({});
  const data = useMemo(() => plan(SIZES[size]), [size]);
  const host = useRef<HTMLDivElement>(null);

  const sinks: Sinks = {
    put: (values) => setFigures((f) => ({ ...f, ...values })),
    status: setStatus,
  };

  /* The second frame after the commit has the canvases drawn. */
  useEffect(() => {
    let live = true;
    void frame()
      .then(frame)
      .then(() => {
        if (!live) return;
        setFigures((f) => ({ ...f, "first-render-ms": elapsed(meter) }));
        setStatus("done");
      });
    return () => {
      live = false;
    };
  }, [size, round]);

  const show = (next: Size) => {
    stamp(meter);
    setStatus("running");
    setFigures({});
    setSize(next);
    setRound((r) => r + 1);
  };

  const busy = status === "running";
  return (
    <>
      <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
        {(Object.keys(SIZES) as Size[]).map((s) => (
          <Button key={s} size="sm" data-role={`size-${s}`} disabled={busy} onClick={() => show(s)}>
            {SIZES[s].subtasks.toLocaleString("en-US")} subtasks
          </Button>
        ))}
        {RUNS.map(([name, label]) => (
          <Button
            key={name}
            size="sm"
            data-role={`run-${name}`}
            disabled={busy}
            onClick={() => void measure(name, host.current, meter, sinks, STEPS[name]!.step, STEPS[name]!.drag)}
          >
            {label}
          </Button>
        ))}
      </div>
      <dl style={{ display: "flex", flexWrap: "wrap", gap: "1.5rem", margin: 0 }}>
        <div>
          <Text as="dt">Status</Text>
          <Text as="dd" mono style={{ margin: 0 }} data-role="status">{status}</Text>
        </div>
        <div>
          <Text as="dt">Lanes</Text>
          <Text as="dd" mono style={{ margin: 0 }} data-role="plan-lanes">{data.lanes.length}</Text>
        </div>
        <div>
          <Text as="dt">Subtasks</Text>
          <Text as="dd" mono style={{ margin: 0 }} data-role="plan-subtasks">{data.subtasks.length}</Text>
        </div>
        <div>
          <Text as="dt">Dependencies</Text>
          <Text as="dd" mono style={{ margin: 0 }} data-role="plan-dependencies">{data.dependencies.length}</Text>
        </div>
        {FIGURES.map(([role, name]) => {
          const value = figures[role];
          return (
            <div key={role}>
              <Text as="dt">{name}</Text>
              <Text as="dd" mono style={{ margin: 0 }} data-role={role}>{value === undefined ? "-" : value.toFixed(role.endsWith("ms") ? 2 : 0)}</Text>
            </div>
          );
        })}
      </dl>
      <div ref={host}>
        <Plot
          key={`${size}-${round}`}
          data={data}
          onInteraction={remember}
        />
      </div>
    </>
  );
}
```

## Keyboard

The keys of [First schedule](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#keyboard-schedule) apply here.

## Props on this page

From `ScheduleProps` — the full table stands on [First schedule](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#type-ScheduleProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`height`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-height) | `number` | `400` | Height in CSS pixels, both bands included. The width is the host's. |
| [`laneHeight`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-laneHeight) | `number` | `44` | Height of one lane in pixels. |
| [`children`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-children) | `ReactNode` | — | `Lane`, `Dependencies` and `Subtasks`, in the order they are to stand and be drawn. |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| [`onInteraction`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-onInteraction) | `(interaction: `[`ScheduleInteraction`](https://romanhaendler.github.io/umriss-ui/schedule/interactions/#type-ScheduleInteraction)`) => void` | — | Click, context menu and hover, with what the pointer is on and where. While it is set, a right-click opens no browser menu. |

#### Accessibility

| Prop | Type | Default | Description |
|---|---|---|---|
| [`ariaLabel`](https://romanhaendler.github.io/umriss-ui/schedule/schedule/#ScheduleProps-ariaLabel) *required* | `string` | — | What the schedule shows, for a screen reader: "Plan of week 38". |

From `LaneProps` — the full table stands on [Lanes](https://romanhaendler.github.io/umriss-ui/schedule/lane/#type-LaneProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/lane/#LaneProps-id) *required* | `string` | — | The identity subtasks name in their `lane`. |

From `SubtasksProps` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-SubtasksProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`data`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#SubtasksProps-data) *required* | `readonly `[`Subtask`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask)`[]` | — | The subtasks, as the caller holds them. The schedule never changes one. |
| [`tasks`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#SubtasksProps-tasks) *required* | `readonly `[`Task`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Task)`[]` | — | The tasks the subtasks belong to - their identity and their colour. A subtask whose task is not among them is drawn in a muted colour. |

From `Subtask` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Subtask).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-id) *required* | `string` | — | The caller's identity of the subtask. |
| [`task`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-task) *required* | `string` | — | The id of the task it belongs to. |
| [`lane`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-lane) *required* | `string` | — | The id of the lane it sits on. |
| [`from`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-from) *required* | `number` | — | Start of the main time. |
| [`to`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-to) *required* | `number` | — | End of the main time. |
| [`name`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Subtask-name) | `string` | — | What the subtask is called, where the schedule names it. |

From `Task` — the full table stands on [Subtasks](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#type-Task).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-id) *required* | `string` | — | The caller's identity of the task. |
| [`color`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-color) *required* | `string` | — | Any CSS colour - a literal, a token, `light-dark(…)`. The colour means what the caller's domain needs it to mean. |
| [`name`](https://romanhaendler.github.io/umriss-ui/schedule/subtasks/#Task-name) | `string` | — | What the task is called, where the schedule names it. |

From `DependenciesProps` — the full table stands on [Dependencies](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#type-DependenciesProps).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`data`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#DependenciesProps-data) *required* | `readonly `[`Dependency`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#type-Dependency)`[]` | — | The dependencies, as the caller holds them. |

From `Dependency` — the full table stands on [Dependencies](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#type-Dependency).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`id`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#Dependency-id) *required* | `string` | — | The caller's identity of the dependency. |
| [`from`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#Dependency-from) *required* | `string` | — | The id of the subtask it leaves. |
| [`to`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#Dependency-to) *required* | `string` | — | The id of the subtask it arrives at. |
| [`lag`](https://romanhaendler.github.io/umriss-ui/schedule/dependencies/#Dependency-lag) *required* | `number` | — | The least time between the two anchors; zero where the successor may begin as soon as the predecessor ends. |

From `ScheduleInteraction` — the full table stands on [Interactions](https://romanhaendler.github.io/umriss-ui/schedule/interactions/#type-ScheduleInteraction).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`hit`](https://romanhaendler.github.io/umriss-ui/schedule/interactions/#ScheduleInteraction-hit) *required* | [`ScheduleHit`](https://romanhaendler.github.io/umriss-ui/schedule/interactions/#type-ScheduleHit) | — | What the pointer is on. |

From `ScheduleOptions` — the full table stands on [View](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleOptions).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`initialView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleOptions-initialView) | [`ScheduleView`](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleView) | — | The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed `defaultValue`. To go to a view later, call `setView`. The schedule remembers none: where a view is kept is the application's decision. |

From `ScheduleView` — the full table stands on [View](https://romanhaendler.github.io/umriss-ui/schedule/view/#type-ScheduleView).

| Prop | Type | Default | Description |
|---|---|---|---|
| [`domain`](https://romanhaendler.github.io/umriss-ui/schedule/view/#ScheduleView-domain) | `readonly [number, number]` | — | The span in view, as two wall-clock instants. Absent, the schedule shows the extent of its subtasks, within `zoomLimits`. |
