Tooltip

What a hover reports (a hover card), and the values as a table for a reader who wants them all at once.

import { Tooltip, DataTable } from "@umriss-ui/charts";

The crosshair snaps to a reading, never between two. The data table's key stands at the end of the legend.

useChart(rows, { onSelect }) hears a click or tap on a hit and Enter on the Active point, with the hit a custom render receives - once, never at the end of a drag, and for a double click only on its first click. The chart keeps and draws no selection; it needs a Tooltip for its hits and does not send the hit over syncId.

Examples

Report every series or the nearest

mode="x" lists every series at the pointer's x; mode="nearest" names only the closest point - the question when lines cross.

import { Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const SEARCH = metrics("search");
const SIGN_IN = metrics("sign-in");

function Latencies({ mode }: { mode: "x" | "nearest" }) {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH);
  return (
    <Chart height={220} ariaLabel={`Search and sign-in latency, tooltip mode ${mode}`}>
      <XAxis value="t" time tickCount={4} />
      <YAxis label="ms" />
      <Line value="p95" name="Search" />
      <Line data={SIGN_IN} value="p95" name="Sign-in" />
      <Tooltip mode={mode} />
    </Chart>
  );
}

export default function XOrNearest() {
  return (
    <div className="pair">
      <div>
        <p className="pair-caption">mode="x" - every series at the x position</p>
        <Latencies mode="x" />
      </div>
      <div>
        <p className="pair-caption">mode="nearest" - only the nearest point</p>
        <Latencies mode="nearest" />
      </div>
    </div>
  );
}

Write the tooltip yourself

render receives the hit - the x value and each series' point with its row - and returns the box's content, in your screen's language.

import { Legend, Tooltip, useChart } from "@umriss-ui/charts";
import type { TooltipHit } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const CHECKOUT = metrics("checkout");
const BILLING = metrics("billing");
const OBJECTIVE: Record<string, number> = Object.fromEntries(SERVICES.map((one) => [one.name, one.latencySlo]));

const timeOfDay = (v: number) =>
  new Date(v).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });

function AgainstObjective({ hit }: { hit: TooltipHit<MetricPoint> }) {
  return (
    <div className="custom-tooltip">
      <strong>{timeOfDay(hit.xValue)}</strong>
      {hit.points.map((point) => {
        const off = point.datum.p95 - (OBJECTIVE[point.seriesName] ?? 0);
        return (
          <span key={point.seriesName}>
            {point.seriesName} - {point.datum.p95} ms, {Math.abs(off)} ms {off > 0 ? "over" : "under"} objective
          </span>
        );
      })}
    </div>
  );
}

export default function OwnContent() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <Chart height={280} ariaLabel="Checkout and billing latency, the tooltip against their objectives">
      <XAxis value="t" time />
      <YAxis label="ms" />
      <Line value="p95" name="Checkout" />
      <Line data={BILLING} value="p95" name="Billing" />
      <Legend placement="top" />
      <Tooltip<MetricPoint> mode="x" render={(hit) => <AgainstObjective hit={hit} />} />
    </Chart>
  );
}

Write each value in its unit

format on a series writes its value in the tooltip; without it the y axis' tickFormat does, and without that a bare number.

import { Legend, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

/** A service's latency and error rate every five minutes of today, from
    midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
    climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(1000 + index * 37);
  const points: MetricPoint[] = [];
  let drift = 0;
  for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
    const hour = (t - at(17, 0)) / (60 * MINUTE);
    const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
    drift = 0.8 * drift + (r() - 0.5) * 0.08;
    const base = service.latencySlo * (0.45 + 0.2 * load + drift);
    const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
    const p95 = Math.round(base * surge * (1 + r() * 0.08));
    points.push({
      t,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    });
  }
  return points;
}

const CHECKOUT = metrics("checkout");

const milliseconds = (v: number) => `${v.toFixed(0)} ms`;
const percent = (v: number) => `${v.toFixed(2)} %`;

export default function ValueFormat() {
  const { Chart, XAxis, YAxis, Line } = useChart(CHECKOUT);
  return (
    <Chart height={260} ariaLabel="Checkout's latency and error rate with their units">
      <XAxis value="t" time />
      <YAxis label="ms" />
      <YAxis id="errors" position="right" label="Errors %" />
      <Line value="p95" name="p95" format={milliseconds} />
      <Line value="errorRate" yAxisId="errors" name="Error rate" format={percent} />
      <Legend />
      <Tooltip mode="x" />
    </Chart>
  );
}

Show the values as a table

DataTable adds a key to the legend that lays a table of what the chart shows over the plot, and takes it back.

import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the planning world, written out here so the example runs on its own. */
const at = (day: number, hours = 9, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();

/** A work item, shaped as `@umriss-ui/schedule`'s `Subtask`. */
interface WorkItem {
  id: string;
  /** The project. */
  task: string;
  /** The person. */
  lane: string;
  from: number;
  to: number;
  name: string;
  sprint: string;
  /** Hours, as estimated at planning. */
  estimate: number;
  status: "to do" | "in progress" | "in review" | "done";
}

const item = (id: string, task: string, lane: string, from: number, to: number, name: string, estimate: number, status: WorkItem["status"]): WorkItem =>
  ({ id, task, lane, from, to, name, sprint: "sprint-14", estimate, status });

const WORK: readonly WorkItem[] = [
  item("w-101", "portal", "arjun", at(9), at(11, 17), "Sign-in with e-mail code", 18, "done"),
  item("w-102", "portal", "arjun", at(12), at(17, 17), "Profile page", 26, "in progress"),
  item("w-103", "portal", "chloe", at(9), at(10, 17), "Session handling", 12, "done"),
  item("w-104", "shop", "chloe", at(11), at(13, 17), "Basket keeps items across devices", 20, "in review"),
  item("w-105", "portal", "chloe", at(16), at(19, 17), "Change of address form", 24, "in progress"),
  item("w-106", "portal", "noah", at(9), at(12, 13), "Profile page design", 14, "done"),
  item("w-107", "shop", "noah", at(16), at(18, 17), "Search results layout", 12, "to do"),
  item("w-108", "portal", "eva", at(12), at(13, 17), "Test plan for sign-in", 10, "done"),
  item("w-109", "portal", "eva", at(17), at(20, 17), "Regression run", 20, "to do"),
  item("w-110", "booking", "hana", at(9), at(13, 17), "Reminder scheduling service", 32, "done"),
  item("w-111", "booking", "hana", at(16), at(20, 17), "Push notifications", 30, "in progress"),
  item("w-112", "booking", "kofi", at(10), at(12, 17), "Calendar sync", 18, "done"),
  item("w-113", "intranet", "kofi", at(16), at(18, 17), "News feed", 16, "in progress"),
  item("w-114", "booking", "freya", at(9), at(11, 17), "Reminder settings screen", 16, "done"),
  item("w-115", "booking", "freya", at(18), at(20, 17), "Store screenshots", 12, "to do"),
  item("w-116", "booking", "david", at(16), at(19, 13), "Device test matrix", 14, "to do"),
];

interface BurndownPoint {
  /** The working day, at 17:00. */
  t: number;
  /** Where the line would be, burning evenly. */
  ideal: number;
  /** Hours left, `null` for the days still to come. */
  remaining: number | null;
}

/** Sprint 14's ten working days. The team fell behind in the first week, when
    two were away, and is catching up. */
const BURNDOWN: readonly BurndownPoint[] = (() => {
  const total = WORK.reduce((sum, one) => sum + one.estimate, 0);
  const days = [9, 10, 11, 12, 13, 16, 17, 18, 19, 20];
  const burnt = [0, 22, 38, 60, 71, 98];
  return days.map((day, i) => ({
    t: at(day, 17),
    ideal: Math.round(total * (1 - i / (days.length - 1))),
    remaining: burnt[i] === undefined ? null : total - burnt[i]!,
  }));
})();

const day = (v: number) => new Date(v).toLocaleDateString("en-GB", { weekday: "short", day: "numeric" });
const hours = (v: number) => `${v.toFixed(0)} h`;

export default function ValuesAsTable() {
  const { Chart, XAxis, YAxis, Line } = useChart(BURNDOWN);
  return (
    <Chart height={260} ariaLabel="Sprint 14: hours left against the ideal">
      <XAxis value="t" ticks={BURNDOWN.map((d) => d.t)} tickFormat={day} />
      <YAxis label="Hours left" />
      <Line value="ideal" name="Ideal" format={hours} />
      <Line value="remaining" name="Remaining" format={hours} />
      <Legend />
      <Tooltip mode="x" />
      {/* The table lists what the chart shows: hide a series and it follows. */}
      <DataTable />
    </Chart>
  );
}

Table a long series

Above 500 rows the table lists each stretch's first, lowest, highest and last value and says so; zoom in and every reading returns.

import { DataTable, Tooltip, useChart } from "@umriss-ui/charts";

/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
  let s = seed >>> 0;
  return () => {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}

const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface MetricPoint {
  t: number;
  /** Median and 95th percentile latency, in ms. */
  p50: number;
  p95: number;
  /** Failed requests, in per cent. */
  errorRate: number;
  /** Requests per minute. */
  requests: number;
}

/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;

const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;

/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);

/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;

/** A service's last week, Monday to Monday, one point every `step`
    milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
    95th percentile more than doubles and its errors climb. The hour is counted,
    not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): MetricPoint[] {
  const index = SERVICES.findIndex((one) => one.id === serviceId);
  const service = SERVICES[index];
  if (service === undefined) throw new Error(`No service "${serviceId}".`);
  const r = random(2000 + index * 37);
  const n = Math.floor((7 * DAY) / step);
  const points = new Array<MetricPoint>(n);
  /* The drift wanders at the same pace whatever the step. */
  const keep = Math.pow(0.8, step / (5 * MINUTE));
  const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
  let drift = 0;
  for (let i = 0; i < n; i++) {
    const offset = i * step;
    const day = Math.floor(offset / DAY);
    const hour = (offset % DAY) / HOUR;
    const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
    drift = keep * drift + (r() - 0.5) * kick;
    const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
    const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
    points[i] = {
      t: LAST_WEEK + offset,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    };
  }
  return points;
}

const SEARCH_WEEK = week("search", 60_000);

const milliseconds = (v: number) => `${v.toFixed(0)} ms`;

export default function WeekAsTable() {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
  return (
    <Chart height={240} ariaLabel="Search latency over last week, a reading a minute">
      <XAxis value="t" time domain="data" zoomable label="Time" />
      <YAxis label="ms" />
      <Line value="p95" name="p95" format={milliseconds} />
      <Tooltip mode="x" />
      {/* Without a legend the key stands on a line of its own. */}
      <DataTable />
    </Chart>
  );
}

Read the limits at the hit

limits adds a row for every labelled limit on the hit's y axis, its label and value in the axis' format; a key reads the same out, and the chart's summary names them without it.

import { LimitBand, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";

/* Checkout's 95th percentile latency every ten minutes of a morning, in ms, as the monitor exports it. */
const LATENCY = [
  { time: "09:00", p95: 212 }, { time: "09:10", p95: 224 }, { time: "09:20", p95: 231 },
  { time: "09:30", p95: 246 }, { time: "09:40", p95: 318 }, { time: "09:50", p95: 402 },
  { time: "10:00", p95: 387 }, { time: "10:10", p95: 291 }, { time: "10:20", p95: 238 },
  { time: "10:30", p95: 229 },
].map((row, x) => ({ ...row, x }));

export default function Limits() {
  const { Chart, XAxis, YAxis, Line } = useChart(LATENCY);
  return (
    <Chart height={260} ariaLabel="Checkout's latency against its objective and its warning band">
      <XAxis value="x" tickFormat={(v) => LATENCY[Math.round(v)]?.time ?? ""} />
      <YAxis label="ms" tickFormat={(v) => `${v} ms`} />
      <LimitBand from={250} to={300} severity="warning" label="Warning band" />
      <LimitLine value={300} severity="alarm" label="Objective" />
      <Line value="p95" name="p95" />
      <Tooltip mode="x" limits />
    </Chart>
  );
}

Choose a hit

onSelect hears a click on a hit, a tap, or Enter on the Active point, with the same hit a custom tooltip receives. The chart keeps no choice and draws none: it is your state, here shown below the chart.

import { useState } from "react";
import { Tooltip, useChart, type TooltipHit } from "@umriss-ui/charts";

/* Orders taken per hour of a shop's day, written out here so the example runs on its own. */
const ORDERS = [
  { hour: 8, web: 14, phone: 6 }, { hour: 9, web: 22, phone: 9 }, { hour: 10, web: 31, phone: 11 },
  { hour: 11, web: 38, phone: 8 }, { hour: 12, web: 52, phone: 14 }, { hour: 13, web: 47, phone: 10 },
  { hour: 14, web: 35, phone: 7 }, { hour: 15, web: 29, phone: 9 },
];

type Order = (typeof ORDERS)[number];

export default function ChooseAHit() {
  const [chosen, setChosen] = useState<TooltipHit<Order> | null>(null);
  const { Chart, XAxis, YAxis, Line } = useChart(ORDERS, { onSelect: setChosen });
  return (
    <>
      <Chart height={260} ariaLabel="Orders per hour, by channel">
        <XAxis value="hour" tickFormat={(v) => `${v}:00`} />
        <YAxis label="orders" />
        <Line value="web" name="Web" />
        <Line value="phone" name="Phone" />
        <Tooltip mode="x" />
      </Chart>
      <dl className="metrics">
        <div>
          <dt>Chosen</dt>
          <dd>{chosen === null ? "nothing yet - click a point, or press Enter on one" : `${chosen.xValue}:00`}</dd>
        </div>
        {chosen?.points.map((p) => (
          <div key={p.seriesName}>
            <dt>{p.seriesName}</dt>
            <dd>{p.yValue} orders</dd>
          </div>
        ))}
      </dl>
    </>
  );
}

Read a stack's total in the table

The table of a stacked chart ends the stack's members with a Total column - the sum the tooltip shows at the same place, so nobody adds the row up by hand.

import { DataTable, Legend, Tooltip, useChart } from "@umriss-ui/charts";

/* Support tickets opened per working day, by channel - one week, 2 to 6 March. */
const TICKETS = [
  { day: "Mon 2", mail: 34, chat: 21, phone: 9 },
  { day: "Tue 3", mail: 29, chat: 25, phone: 12 },
  { day: "Wed 4", mail: 31, chat: 18, phone: 7 },
  { day: "Thu 5", mail: 27, chat: 30, phone: 11 },
  { day: "Fri 6", mail: 22, chat: 16, phone: 6 },
];

export default function TotalOfAStack() {
  const { Chart, XAxis, YAxis, Bar } = useChart(TICKETS);
  return (
    <Chart height={260} ariaLabel="Tickets opened per working day, by channel">
      <XAxis value="day" />
      <YAxis label="Tickets" />
      <Bar value="mail" name="Mail" stack="tickets" />
      <Bar value="chat" name="Chat" stack="tickets" />
      <Bar value="phone" name="Phone" stack="tickets" />
      <Legend placement="top" />
      <Tooltip mode="x" />
      <DataTable />
    </Chart>
  );
}

Keyboard

The keys of Chart apply here.

API

TooltipProps<T>

NameTypeDefaultDescription
mode"x" | "nearest""x""x": every series at the x position; "nearest": only the nearest point.

Shown in: Report every series or the nearest, Write the tooltip yourself, Write each value in its unit and 61 more

limitsbooleanfalseAdds a row for every labelled LimitLine and LimitBand on the hit's y axis - its label and value in that axis' format - to the built-in tooltip, and the same to what a key reads out. Limits are not series and stay out of the tooltip unless asked for.

Shown in: Read the limits at the hit

render(hit: TooltipHit<T>) => ReactNodethe built-in tooltipRender prop for content of one's own. Compared by its source text, like tickFormat - with the same closure limit.

Shown in: Write the tooltip yourself, Fix the domain and name the ticks (Axis)

ChartOptions<Z = unknown>

NameTypeDefaultDescription
initialViewChartView—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 chart remembers none: where a view is kept is the application's decision.

Shown in: Fit the y axis to what is visible (Zoom and pan), Hide a series from the legend (Legend)

Events

NameTypeDefaultDescription
onViewChange(view: ChartView) => void—Every change the reader makes, once, always the whole view - the one to keep, or to hand another chart's setView. A pan or zoom is reported at most once per frame. Not called for the view the chart starts with, nor for one setView applies.

Shown in: Keep charts in step (Zoom and pan), Read it by keyboard and screen reader (Chart)

onSelect(hit: TooltipHit<Z>) => void—The reader chose a point: a click on a hit (a tap too), or Enter on the Active point - the same hit the tooltip's render receives. Once per click; the end of a drag does not report, and the second click of a double click does not either. The chart holds no selection and draws none: what is chosen is the application's state. Needs a <Tooltip>, which makes the hits; the hit does not travel over syncId.

Shown in: Choose a hit

Types on this page

TooltipHit<T = unknown>

What the pointer hit: the x value under it and one point per series there. A custom tooltip's render receives it.

NameTypeDefaultDescription
xValuerequirednumber—

Shown in: Write the tooltip yourself, Choose a hit, Fix the domain and name the ticks (Axis)

pointsrequiredreadonly TooltipPoint<T>[]—

Shown in: Write the tooltip yourself, Choose a hit, Fix the domain and name the ticks (Axis)

xPxrequirednumber—Pixel position of the crosshair (snapped to the data point).
yPxrequirednumber—Pixel position of the primary hit.

TooltipPoint<T = unknown>

One hit per series in the tooltip.

NameTypeDefaultDescription
seriesNamerequiredstring—

Shown in: Write the tooltip yourself, Choose a hit, Fix the domain and name the ticks (Axis)

colorrequiredstring—
xValuerequirednumber—The point's x value on its own x axis - with several x axes it can differ from the hit's.
yValuerequirednumber—The point's y value; in a stack its own value, not its top - in a normalised stack its share in percent.

Shown in: Choose a hit, Fix the domain and name the ticks (Axis)

datumrequiredT—

Shown in: Write the tooltip yourself

indexrequirednumber—
levelnumber—Only for the matrix: its level, out of the level channel. It stands here and not in yValue, because yValue is the position on the y axis - for a matrix therefore the row. Putting both into one field would be exactly the overloading ADR-0011 is written against.
segment{ from: number; to: number; label: string }—Only for state series: the section being pointed at. A duration does not stand here - it is the difference of two numbers the entry already carries, and formatting it would presuppose knowing what the x axis means. The caller knows that, not this library.
boxBoxNumbers & Partial<BoxExtras> & { /** As the caller gave them; only where the series has `outliers`. */ outliers?: readonly number[]; }—Only for a box: its further numbers; yValue is its median. Fields of their own for the reason level has one (ADR-0011).

BoxNumbers<V = number>

A box's further numbers beside its median, by name.

NameTypeDefaultDescription
lowerQuartilerequiredV—
upperQuartilerequiredV—
lowerWhiskerrequiredV—
upperWhiskerrequiredV—

BoxExtras<V = number>

The optional numbers of a box by name.

NameTypeDefaultDescription
meanrequiredV—
notchLowerrequiredV—
notchUpperrequiredV—
countrequiredV—

Every page of @umriss-ui/charts

Getting started

Chart

Series

Limits and alarms

Around the chart

API index