shadimage
Data Table Filters
blockdata-table-chartsource: ok · refreshed

Timeline Chart

Stacked timeline bar chart above the table: one bar per time bucket, one series per level, drag to select a range and zoom the table's time filter to it. Ships the bucketing helper that turns rows into chart data with the same semantics as the Drizzle handler.

npx shadcn add "https://logs.run/r/data-table-chart.json"
free

Where it goes

data-table

Interaction

dragdropdown

Use cases

dashboard

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
date-fns@^4.1.0lucide-react@^0.469.0

Registry dependencies

https://data-table.openstatus.dev/r/data-table.jsonchartbutton

Files3 · source cached 10/2/2026, 8:55:57 PM

src/components/data-table/data-table-chart/timeline-chart.tsx
"use client";

import { useDataTable } from "@/components/data-table/data-table-provider";
import { Button } from "@/components/ui/button";
import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart";
import type { BaseChartSchema } from "@/lib/data-table/types";
import { boxRadiusClassName } from "@/lib/style";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { ZoomIn } from "lucide-react";
import * as React from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Bar, BarChart, CartesianGrid, ReferenceArea, XAxis } from "recharts";
import type { Box } from "./timeline-chart-utils";
import {
  formatAxisTick,
  formatSelectionRange,
  getSelectionBounds,
  getSelectionCardLeft,
  getSelectionEdges,
  getSelectionLabels,
  getSelectionScrim,
  isPointerEvent,
  orderSelectionLabels,
} from "./timeline-chart-utils";

/**
 * The chart's own mouse handler type, read off `BarChart` rather than imported
 * from a deep `recharts/types/...` path: those paths moved between recharts 2
 * and 3, and the consumer gets whichever version shadcn's `chart` installs.
 */
type ChartMouseHandler = NonNullable<
  React.ComponentProps<typeof BarChart>["onMouseDown"]
>;

/** Shared by every control floating over the chart. */
const PILL_BUTTON =
  "flex-1 h-5 px-1.5! py-1! font-mono text-[10px] shadow-none";

/**
 * One stacked series: a key in every chart point, what the tooltip calls it,
 * and its colour. The same shape as a table manifest's `chart.series`, so a
 * headless table can hand the endpoint's config straight through.
 */
export type TimelineChartSeries = {
  /** The numeric key in each `BaseChartSchema` point. */
  key: string;
  /** Tooltip label. Defaults to the key. */
  label?: React.ReactNode;
  /**
   * Any CSS colour. Defaults to `var(--<key>)`, which the core block defines
   * for `success`, `warning`, `error` and `info`.
   */
  color?: string;
};

/**
 * Every numeric key the first point carries, in the order it carries them —
 * the fallback when the caller states no series.
 */
function inferSeries(data: BaseChartSchema[]): TimelineChartSeries[] {
  const first = data[0];
  if (!first) return [];
  return Object.keys(first)
    .filter((key) => key !== "timestamp" && typeof first[key] === "number")
    .map((key) => ({ key }));
}

function toChartConfig(series: TimelineChartSeries[]): ChartConfig {
  const config: ChartConfig = {};
  for (const entry of series) {
    config[entry.key] = {
      label: entry.label ?? entry.key,
      color: entry.color ?? `var(--${entry.key})`,
    };
  }
  return config;
}

/** A selected range, both ends kept apart so the separator can be styled. */
type SelectionRange = { start: string; end: string };

interface TimelineChartProps<TChart extends BaseChartSchema> {
  className?: string;
  /**
   * The table column id to filter by - needs to be a type of `timerange` (e.g. "date").
   * TBD: if using keyof TData to be closer to the data table props
   */
  columnId: string;
  /**
   * Same data as of the InfiniteQueryMeta.
   */
  data: TChart[];
  /**
   * The stacked series, bottom-up; the tooltip lists them in the same order.
   * Omitted, every numeric key of the first point becomes a series.
   */
  series?: TimelineChartSeries[];
}

export function TimelineChart<TChart extends BaseChartSchema>({
  data,
  className,
  columnId,
  series,
}: TimelineChartProps<TChart>) {
  const { table } = useDataTable();
  const chartSeries = useMemo(
    () => series ?? inferSeries(data),
    [series, data],
  );
  const chartConfig = useMemo(() => toChartConfig(chartSeries), [chartSeries]);
  // state, not a ref: the card is portaled into it, so a render has to follow
  // the element being attached
  const [container, setContainer] = useState<HTMLDivElement | null>(null);
  const [chartWidth, setChartWidth] = useState(0);
  const [refAreaLeft, setRefAreaLeft] = useState<string | null>(null);
  const [refAreaRight, setRefAreaRight] = useState<string | null>(null);
  const [isSelecting, setIsSelecting] = useState(false);

  // REMINDER: the scrim and edges are SVG without a layout engine - we need the
  // pixel width to keep them inside the plot near either edge
  useEffect(() => {
    if (!container) return;

    setChartWidth(container.clientWidth);
    const observer = new ResizeObserver(([entry]) =>
      setChartWidth(entry.contentRect.width),
    );
    observer.observe(container);
    return () => observer.disconnect();
  }, [container]);

  // REMINDER: date has to be a string for tooltip label to work - don't ask me why
  const chart = useMemo(
    () =>
      data.map((item) => ({
        ...item,
        [columnId]: new Date(item.timestamp).toString(),
      })),
    [data, columnId],
  );

  const timerange = useMemo(() => {
    if (data.length === 0) return { interval: 0, period: undefined };
    const first = data[0].timestamp;
    const last = data[data.length - 1].timestamp;
    const interval = Math.abs(first - last); // in ms
    return { interval, period: calculatePeriod(interval) };
  }, [data]);

  /**
   * How the selection describes itself, off the same bounds we commit as the
   * filter - so nothing on screen can disagree with what the table ends up
   * showing.
   */
  const selection = useMemo(() => {
    if (!refAreaLeft || !refAreaRight) return null;

    const bounds = getSelectionBounds(data, refAreaLeft, refAreaRight);
    if (!bounds) return null;

    const { from, displayEnd } = bounds;

    return {
      range: formatSelectionRange(from, displayEnd, timerange.period),
      // the instants the two edges sit on: `from` opens the first bucket and
      // `displayEnd` is where the last one runs out, which is the boundary the
      // right edge is drawn at
      axis: {
        start: formatAxisTick(from, timerange.period),
        end: formatAxisTick(displayEnd, timerange.period),
      },
    };
  }, [refAreaLeft, refAreaRight, data, timerange.period]);

  /**
   * The selection as `ReferenceArea` wants it: `x1` is the rect's start edge
   * and `x2` its end, so a backwards drag has to be flipped or the highlight
   * comes out a bucket short on either side.
   */
  const refArea = useMemo(
    () =>
      refAreaLeft && refAreaRight
        ? orderSelectionLabels(refAreaLeft, refAreaRight)
        : null,
    [refAreaLeft, refAreaRight],
  );

  const handleMouseDown: ChartMouseHandler = (e) => {
    if (e.activeLabel) {
      // a new drag replaces whatever was still awaiting confirmation
      // (recharts 3 types the label as string | number; ours are the date
      // strings `chart` was built with)
      setRefAreaLeft(String(e.activeLabel));
      setRefAreaRight(null);
      setIsSelecting(true);
    }
  };

  const handleMouseMove: ChartMouseHandler = (e, event) => {
    // only a moving pointer widens the selection - taking the a11y layer's
    // spoofed move too (see `isPointerEvent`) would make a click select from
    // the first bucket to the bar it landed on
    if (!isPointerEvent(event)) return;
    if (isSelecting && e.activeLabel) {
      setRefAreaRight(String(e.activeLabel));
    }
  };

  const clearSelection = () => {
    setRefAreaLeft(null);
    setRefAreaRight(null);
    setIsSelecting(false);
  };

  const applySelection = () => {
    if (!refAreaLeft || !refAreaRight) return;
    // same bounds the readout was computed from, so the row count the user
    // just saw is exactly what the table ends up showing
    const bounds = getSelectionBounds(data, refAreaLeft, refAreaRight);
    if (bounds) {
      table
        .getColumn(columnId)
        ?.setFilterValue([new Date(bounds.from), new Date(bounds.filterEnd)]);
    }
    clearSelection();
  };

  const isPending = Boolean(!isSelecting && refAreaLeft && refAreaRight);

  // the drag ends on the window, not on the chart: wandering off the plot keeps
  // the range live and releasing anywhere parks it. it only ever parks - the
  // filter is applied from the zoom button, so an imprecise drag can be
  // corrected instead of refetching the table twice.
  // both listener effects run without a dep array: they close over the current
  // selection, and re-subscribing beats memoizing every callback to stay stable
  useEffect(() => {
    if (!isSelecting) return;
    const onMouseUp = () => {
      setIsSelecting(false);
      // a click without a drag is the shortest range there is: the one bucket
      // under the cursor. `x1 === x2` still spans a full band
      if (!refAreaRight) setRefAreaRight(refAreaLeft);
    };
    // dragging across the page would otherwise select the table's text
    document.body.classList.add("select-none");
    window.addEventListener("mouseup", onMouseUp);
    return () => {
      document.body.classList.remove("select-none");
      window.removeEventListener("mouseup", onMouseUp);
    };
  });

  useEffect(() => {
    if (!isPending) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") clearSelection();
      // the browser already activates a focused Cancel - zooming here too would
      // do both at once
      if (event.key === "Enter" && !hasInteractiveFocus()) applySelection();
    };
    // anywhere but the chart dismisses it. the card is portaled into the
    // container, so its own buttons count as inside
    const onPointerDown = (event: PointerEvent) => {
      const target = event.target;
      if (target instanceof Node && container?.contains(target)) return;
      clearSelection();
    };
    document.addEventListener("keydown", onKeyDown);
    document.addEventListener("pointerdown", onPointerDown);
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.removeEventListener("pointerdown", onPointerDown);
    };
  });

  return (
    <div ref={setContainer} className="relative">
      <ChartContainer
        config={chartConfig}
        className={cn(
          "aspect-auto h-[60px] w-full",
          "[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted/50", // otherwise same color as 200
          "select-none", // disable text selection
          "touch-pan-y", // capture horizontal drags, let vertical page scroll through
          className,
        )}
      >
        <BarChart
          accessibilityLayer
          data={chart}
          margin={{ top: 0, left: 0, right: 0, bottom: 0 }}
          onMouseDown={handleMouseDown}
          onMouseMove={handleMouseMove}
          style={{ cursor: "crosshair" }}
        >
          <CartesianGrid vertical={false} />
          <XAxis
            dataKey={columnId}
            tickLine={false}
            minTickGap={32}
            axisLine={false}
            // interval="preserveStartEnd"
            // the selection labels the axis itself while it's up, so the ticks
            // step aside rather than compete with it - but only once there is
            // something to replace them with. the axis keeps its height either way
            tick={!selection}
            tickFormatter={(value) => formatAxisTick(value, timerange.period)}
          />
          <ChartTooltip
            // defaultIndex={10}
            // no hover while a selection is on the chart: mid-drag it fights the
            // band being dragged, and once parked it covers the actions.
            // recharts reads `active` before its own hover state, and only when
            // it's defined - `undefined` hands control back
            active={isSelecting || isPending ? false : undefined}
            content={
              <ChartTooltipContent
                labelFormatter={(value) => {
                  // recharts 3 types the label as a ReactNode; the axis feeds it
                  // the date string, anything else is not a date
                  const date = new Date(
                    typeof value === "string" || typeof value === "number"
                      ? value
                      : NaN,
                  );
                  if (isNaN(date.getTime())) return "N/A";
                  if (timerange.period === "10m") {
                    return format(date, "LLL dd, HH:mm:ss");
                  }
                  return format(date, "LLL dd, y HH:mm");
                }}
              />
            }
          />
          {chartSeries.map(({ key }) => (
            <Bar
              key={key}
              dataKey={key}
              stackId="a"
              fill={`var(--color-${key})`}
            />
          ))}
          {refArea && (
            <ReferenceArea
              x1={refArea[0]}
              x2={refArea[1]}
              stroke="none"
              fill="var(--foreground)"
              fillOpacity={0.05}
              label={
                <SelectionOverlay
                  chartWidth={chartWidth}
                  container={container}
                  axisLabels={selection?.axis ?? null}
                  // nothing floats over the chart mid-drag: the shaded band and
                  // its edges already describe the range, and a card chasing the
                  // cursor only reads as the tooltip refusing to go away. it
                  // comes back once the drag parks, where it carries the actions
                  range={isPending ? (selection?.range ?? null) : null}
                  actions={
                    isPending
                      ? { onZoom: applySelection, onCancel: clearSelection }
                      : null
                  }
                />
              }
            />
          )}
        </BarChart>
      </ChartContainer>
    </div>
  );
}

/**
 * The selection: brush-style ends, the buckets outside it faded, and the card
 * describing the range.
 *
 * Rendered through `ReferenceArea`'s `label` prop - recharts clones it with the
 * selection rect as `viewBox`, which is the exact band-snapped geometry. The
 * scrim and edges stay in the SVG; the card is portaled out of it (see
 * `SelectionCard`).
 */
function SelectionOverlay({
  viewBox,
  chartWidth,
  container,
  axisLabels,
  range,
  actions,
}: {
  /** injected by recharts, not passed by the parent */
  viewBox?: { x?: number; y?: number; width?: number; height?: number };
  chartWidth: number;
  container: HTMLElement | null;
  axisLabels?: SelectionRange | null;
  range?: SelectionRange | null;
  actions?: { onZoom: () => void; onCancel: () => void } | null;
}) {
  if (!viewBox) return null;

  const selection = {
    x: viewBox.x ?? 0,
    y: viewBox.y ?? 0,
    width: viewBox.width ?? 0,
    height: viewBox.height ?? 0,
  };

  return (
    <g className="pointer-events-none">
      {/* fade the buckets outside the selection so the range reads at a glance */}
      {getSelectionScrim(selection, chartWidth).map((rect) => (
        <rect
          key={rect.x}
          {...rect}
          fill="var(--background)"
          fillOpacity={0.6}
        />
      ))}
      {getSelectionEdges(selection, chartWidth).map(({ line, grip }, index) => (
        <g key={index} fill="var(--foreground)">
          <rect {...line} />
          <rect {...grip} />
        </g>
      ))}
      {axisLabels ? (
        <SelectionAxisLabels
          selection={selection}
          chartWidth={chartWidth}
          labels={axisLabels}
        />
      ) : null}
      {range && actions && container
        ? createPortal(
            <SelectionCard
              selection={selection}
              chartWidth={chartWidth}
              range={range}
              actions={actions}
            />,
            container,
          )
        : null}
    </g>
  );
}

/**
 * The two instants the selection runs between, drawn where the axis ticks would
 * have been - the ticks are off while a selection is up, so the only dates on
 * the axis are the ones the selection is about.
 *
 * The end label is dropped when the two would touch: a narrow selection only
 * gets to say where it starts. It stays mounted and merely hidden, because
 * unmounting it would throw away the width that decision is made from - and the
 * decision would flip back on the next frame.
 */
function SelectionAxisLabels({
  selection,
  chartWidth,
  labels,
}: {
  selection: Box;
  chartWidth: number;
  labels: { start: string; end: string };
}) {
  const startRef = useRef<SVGTextElement>(null);
  const endRef = useRef<SVGTextElement>(null);
  const [widths, setWidths] = useState({ start: 0, end: 0 });

  // before paint, so a label never shows up off-center for a frame
  useLayoutEffect(() => {
    setWidths({
      start: startRef.current?.getComputedTextLength() ?? 0,
      end: endRef.current?.getComputedTextLength() ?? 0,
    });
  }, [labels.start, labels.end]);

  const { start, end } = getSelectionLabels(selection, chartWidth, widths);

  return (
    // `dy` is what recharts hangs its own tick text from (`capHeight`), so these
    // sit on the same line the ticks would have
    <g className="fill-muted-foreground" textAnchor="middle">
      <text ref={startRef} x={start.x} y={start.y} dy="0.71em">
        {labels.start}
      </text>
      <text
        ref={endRef}
        x={end?.x ?? start.x}
        y={start.y}
        dy="0.71em"
        visibility={end ? undefined : "hidden"}
      >
        {labels.end}
      </text>
    </g>
  );
}

/**
 * The range description and the actions to confirm it, shown once the drag is
 * released. One card rather than two: the range is what you're confirming.
 *
 * Portaled to the chart container instead of drawn in the SVG. An `<svg>` clips
 * to its viewport, so a card this tall lost its shadow and its bottom edge to
 * the plot boundary; as a DOM sibling it can overflow the chart the same way
 * the recharts tooltip does. It measures itself, so nothing here estimates text
 * width.
 */
function SelectionCard({
  selection,
  chartWidth,
  range,
  actions,
}: {
  selection: Box;
  chartWidth: number;
  range: SelectionRange;
  actions: { onZoom: () => void; onCancel: () => void };
}) {
  const ref = useRef<HTMLDivElement>(null);
  const [width, setWidth] = useState(0);

  // before paint, so the card never shows up off-center for a frame
  useLayoutEffect(() => {
    setWidth(ref.current?.offsetWidth ?? 0);
  }, [range.start, range.end]);

  return (
    // same shell as `ChartTooltipContent` - the card replaces the tooltip
    <div
      ref={ref}
      style={{ left: getSelectionCardLeft(selection, width, chartWidth) }}
      className={cn(
        "border-border/50 bg-background pointer-events-none absolute top-0 grid w-max min-w-[8rem] items-start gap-1.5 border px-2.5 py-1.5 text-xs shadow-xl",
        boxRadiusClassName,
      )}
    >
      <div className="font-medium">
        {range.start}
        <span className="text-muted-foreground mx-1 font-normal">→</span>
        {range.end}
      </div>
      <div
        className="border-border/50 pointer-events-auto -mx-2.5 -mb-1.5 flex items-center gap-1 border-t px-2.5 py-1.5"
        // a portal bubbles through the React tree, not the DOM one - without
        // this a click on either button reaches the chart and starts a drag
        onMouseDown={(event) => event.stopPropagation()}
        onMouseUp={(event) => event.stopPropagation()}
      >
        <Button
          variant="outline"
          className={PILL_BUTTON}
          onClick={actions.onCancel}
        >
          Cancel
        </Button>
        <Button className={cn(PILL_BUTTON, "gap-1")} onClick={actions.onZoom}>
          <ZoomIn className="size-2.5!" />
          <span>Zoom</span>
        </Button>
      </div>
    </div>
  );
}

/** Whether the focused element handles Enter itself - the shortcut defers to it. */
function hasInteractiveFocus(): boolean {
  return Boolean(
    document.activeElement?.closest(
      "button, a, input, textarea, select, [contenteditable], [role='button']",
    ),
  );
}

// TODO: check what's a good abbreviation for month vs. minutes
function calculatePeriod(interval: number): "10m" | "1d" | "1w" | "1mo" {
  if (interval <= 1000 * 60 * 10) {
    // less than 10 minutes
    return "10m";
  } else if (interval <= 1000 * 60 * 60 * 24) {
    // less than 1 day
    return "1d";
  } else if (interval <= 1000 * 60 * 60 * 24 * 7) {
    // less than 1 week
    return "1w";
  }
  return "1mo"; // defaults to 1 month
}
src/components/data-table/data-table-chart/timeline-chart-utils.ts
import type { BaseChartSchema } from "@/lib/data-table/types";
import { format, isSameDay } from "date-fns";

/**
 * Sums every numeric bucket value for the buckets falling inside the inclusive
 * `[from, to]` timestamp range.
 *
 * Stays generic over the bucket shape on purpose: `TimelineChart` is rendered
 * with different schemas across the demos (e.g. `success`/`warning`/`error` for
 * logs), so every key but `timestamp` is treated as a count.
 */
export function sumBucketRows<TChart extends BaseChartSchema>(
  data: TChart[],
  from: number,
  to: number,
): number {
  return Object.values(sumBucketValues(data, from, to)).reduce(
    (total, value) => total + value,
    0,
  );
}

/**
 * The same sum, kept per key, so the selection can break its total down the way
 * the tooltip does (`success`/`warning`/`error` for logs).
 *
 * Keys absent from every bucket in range are absent from the result - the
 * caller decides whether that reads as `0` or as "not a series".
 */
export function sumBucketValues<TChart extends BaseChartSchema>(
  data: TChart[],
  from: number,
  to: number,
): Record<string, number> {
  const [start, end] = from <= to ? [from, to] : [to, from];

  const totals: Record<string, number> = {};
  for (const bucket of data) {
    if (bucket.timestamp < start || bucket.timestamp > end) continue;
    for (const [key, value] of Object.entries(bucket)) {
      if (key === "timestamp") continue;
      // the index signature promises `number`, runtime data doesn't
      if (typeof value === "number" && Number.isFinite(value)) {
        totals[key] = (totals[key] ?? 0) + value;
      }
    }
  }
  return totals;
}

/**
 * The time each bucket covers, derived from the gap between the first two
 * buckets. Returns `0` when there aren't enough buckets to tell.
 */
export function getBucketInterval<TChart extends BaseChartSchema>(
  data: TChart[],
): number {
  if (data.length < 2) return 0;
  return Math.abs(data[1].timestamp - data[0].timestamp);
}

/**
 * The timestamp range a drag between two bucket labels covers.
 *
 * A drag selects whole buckets, so the last one contributes its full interval
 * rather than zero time. That leaves two different ends:
 *
 * - `displayEnd` is exclusive — the instant the last bucket runs out, which is
 *   what a time range reads as ("14:02 → 14:09") and what the duration measures.
 * - `filterEnd` is inclusive, because `inDateRange` compares with `<=`. It stops
 *   one millisecond short so a row landing exactly on the next bucket's first
 *   instant isn't filtered in while `sumBucketRows` never counted it.
 *
 * Returns `null` when either label isn't a parseable date.
 */
export function getSelectionBounds<TChart extends BaseChartSchema>(
  data: TChart[],
  labelA: string,
  labelB: string,
): {
  from: number;
  toBucket: number;
  displayEnd: number;
  filterEnd: number;
} | null {
  const [from, toBucket] = [
    new Date(labelA).getTime(),
    new Date(labelB).getTime(),
  ].sort((a, b) => a - b);
  if (Number.isNaN(from) || Number.isNaN(toBucket)) return null;

  const interval = getBucketInterval(data);
  return {
    from,
    toBucket,
    displayEnd: toBucket + interval,
    // a single-bucket chart has no interval to add; don't invert the range
    filterEnd: toBucket + Math.max(interval - 1, 0),
  };
}

/**
 * The two bucket labels of a drag, oldest first.
 *
 * A drag can run right-to-left, and `ReferenceArea` reads `x1` as the rect's
 * start edge and `x2` as its end - handed the labels in the order they were
 * touched, a backwards drag renders a rect a bucket short on either side.
 *
 * Unparseable labels keep the order they came in: there is nothing to sort by,
 * and `getSelectionBounds` rejects them anyway.
 */
export function orderSelectionLabels(
  labelA: string,
  labelB: string,
): [string, string] {
  const a = new Date(labelA).getTime();
  const b = new Date(labelB).getTime();
  if (Number.isNaN(a) || Number.isNaN(b)) return [labelA, labelB];
  return a <= b ? [labelA, labelB] : [labelB, labelA];
}

/**
 * Whether a recharts callback came from a real pointer.
 *
 * `accessibilityLayer` spoofs a mouse move at its keyboard cursor - the first
 * bucket, until an arrow key moves it - whenever the chart takes focus, which a
 * mousedown does. The spoof passes a bare `{ pageX, pageY }`, a real event a `type`.
 */
export function isPointerEvent(event: unknown): boolean {
  return typeof (event as { type?: unknown } | null | undefined)?.type === "string"; // prettier-ignore
}

/** How much time the chart covers - it decides how every label is formatted. */
export type ChartPeriod = "10m" | "1d" | "1w" | "1mo";

/**
 * An x-axis tick, as coarse as the period the chart covers.
 *
 * Shared with the labels the selection puts on the axis, so a selection edge
 * can't print its instant in a different format than the ticks around it.
 */
export function formatAxisTick(
  value: number | string,
  period?: ChartPeriod,
): string {
  const date = new Date(value);
  if (isNaN(date.getTime())) return "N/A";
  switch (period) {
    case "10m":
      return format(date, "HH:mm:ss");
    case "1d":
      return format(date, "HH:mm");
    case "1w":
      return format(date, "LLL dd HH:mm");
    default:
      return format(date, "LLL dd, y");
  }
}

/**
 * Formats a selected range for the readout, both ends kept apart so the caller
 * can style the separator (e.g. `Jul 21 16:52` / `17:02`).
 *
 * The date always leads - a bare `16:52 → 17:02` reads as "today" when it may
 * well be last week - and repeats on the right only across midnight. Seconds
 * show up on the tightest period, where minutes would print both ends alike.
 */
export function formatSelectionRange(
  from: number,
  to: number,
  period?: ChartPeriod,
): { start: string; end: string } {
  const timePattern = period === "10m" ? "HH:mm:ss" : "HH:mm";
  return {
    start: format(from, `LLL dd ${timePattern}`),
    end: isSameDay(from, to)
      ? format(to, timePattern)
      : format(to, `LLL dd ${timePattern}`),
  };
}

/** An SVG rect - spreadable straight onto `<rect>` or `<foreignObject>`. */
export type Box = { x: number; y: number; width: number; height: number };

/**
 * The left edge that centers `width` on `center` without leaving the plot.
 * Every overlay is positioned against the selection but has to stay fully
 * visible, so a selection on a plot boundary pushes its overlays back inside.
 */
export function centerWithin(
  center: number,
  width: number,
  chartWidth: number,
) {
  // fall back to the element's own width so a not-yet-measured chart still clamps
  const max = Math.max((chartWidth || width) - width, 0);
  return Math.min(Math.max(center - width / 2, 0), max);
}

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}

/** The breathing room left between the card and the selection it belongs to. */
export const SELECTION_CARD_GAP = 8;

/**
 * The left edge for the card describing a selection.
 *
 * A selection wider than the card keeps its ends visible with the card centered
 * on it, so that's where it goes. A narrow one would disappear underneath, so
 * the card steps aside - to whichever side has more room, and only if the card
 * fits there whole. When neither side does, being centered and readable beats
 * being pushed half out of the plot.
 */
export function getSelectionCardLeft(
  selection: { x: number; width: number },
  cardWidth: number,
  chartWidth: number,
  gap = SELECTION_CARD_GAP,
): number {
  const center = () =>
    centerWithin(selection.x + selection.width / 2, cardWidth, chartWidth);

  if (selection.width >= cardWidth) return center();

  const selectionEnd = selection.x + selection.width;
  const room = { left: selection.x, right: chartWidth - selectionEnd };
  const needed = cardWidth + gap;
  // the roomier side first, so the card leans away from the nearest plot edge
  const sides = room.right >= room.left ? ["right", "left"] : ["left", "right"];

  for (const side of sides) {
    if (side === "right" && room.right >= needed) return selectionEnd + gap;
    if (side === "left" && room.left >= needed) return selection.x - needed;
  }
  return center();
}

/** Width of the vertical line marking each end of the selection. */
export const SELECTION_EDGE_WIDTH = 1;
/** Width of the rounded grip sitting on the middle of each edge. */
export const SELECTION_GRIP_WIDTH = 2;
const SELECTION_GRIP_RATIO = 0.4;
const SELECTION_GRIP_MIN_HEIGHT = 8;
const SELECTION_GRIP_MAX_HEIGHT = 24;

/** Brush-style ends: a full-height line per side with a grip centered on it. */
export function getSelectionEdges(
  selection: Box,
  chartWidth: number,
): { line: Box; grip: Box & { rx: number } }[] {
  const gripHeight = clamp(
    selection.height * SELECTION_GRIP_RATIO,
    SELECTION_GRIP_MIN_HEIGHT,
    Math.min(SELECTION_GRIP_MAX_HEIGHT, selection.height),
  );

  return [selection.x, selection.x + selection.width].map((center) => ({
    line: {
      x: centerWithin(center, SELECTION_EDGE_WIDTH, chartWidth),
      y: selection.y,
      width: SELECTION_EDGE_WIDTH,
      height: selection.height,
    },
    grip: {
      x: centerWithin(center, SELECTION_GRIP_WIDTH, chartWidth),
      y: selection.y + (selection.height - gripHeight) / 2,
      width: SELECTION_GRIP_WIDTH,
      height: gripHeight,
      rx: SELECTION_GRIP_WIDTH / 2,
    },
  }));
}

/**
 * How far below the plot the selection's labels sit, matching where recharts
 * puts a tick: its `tickSize` (6) plus its `tickMargin` (2). They stand in for
 * the axis ticks while a selection is up, so they have to land on the same line.
 */
export const SELECTION_LABEL_OFFSET = 8;
/** The smallest gap left between the two labels before the end one is dropped. */
export const SELECTION_LABEL_GAP = 8;

/**
 * Where the labels for a selection's two edges go, each centered on its edge
 * and kept inside the plot.
 *
 * `end` is `null` when the two would touch: a narrow selection only gets to say
 * where it starts. The check runs on the clamped centers, not on the raw
 * selection width - a selection against a plot boundary has its labels pushed
 * inwards, which closes the gap the raw width says is there.
 */
export function getSelectionLabels(
  selection: Box,
  chartWidth: number,
  widths: { start: number; end: number },
): { start: { x: number; y: number }; end: { x: number; y: number } | null } {
  const y = selection.y + selection.height + SELECTION_LABEL_OFFSET;
  // `centerWithin` returns a left edge; these are drawn from their center
  const startX = centerWithin(selection.x, widths.start, chartWidth) + widths.start / 2; // prettier-ignore
  const endX = centerWithin(selection.x + selection.width, widths.end, chartWidth) + widths.end / 2; // prettier-ignore

  const gap = endX - widths.end / 2 - (startX + widths.start / 2);

  return {
    start: { x: startX, y },
    end: gap >= SELECTION_LABEL_GAP ? { x: endX, y } : null,
  };
}

/**
 * Rects covering everything left and right of the selection, to fade the
 * buckets outside it. Empty sides are dropped so a selection touching a plot
 * boundary doesn't emit a zero-width rect.
 */
export function getSelectionScrim(selection: Box, chartWidth: number): Box[] {
  const end = selection.x + selection.width;
  return [
    { x: 0, width: Math.max(selection.x, 0) },
    { x: end, width: Math.max(chartWidth - end, 0) },
  ]
    .filter((rect) => rect.width > 0)
    .map((rect) => ({ ...rect, y: selection.y, height: selection.height }));
}
src/lib/data-table/chart-data.ts
// Not `chart.ts`: the block depends on shadcn's `chart` component, and the CLI
// rewrites any import whose last segment matches a ui item's name to the ui
// alias — `@/lib/data-table/chart` came out as `@/components/ui/chart`.
import { evaluateIntervalMs } from "./interval";
import type { BaseChartSchema } from "./types";

export type BucketChartDataOptions<TRow> = {
  /** The instant a row belongs to. */
  timestamp: (row: TRow) => Date | number;
  /** Which series a row counts towards — a log level, a status class. */
  series: (row: TRow) => string;
  /** Every series, so each bucket carries a zero for the ones with no rows. */
  keys: readonly string[];
  /**
   * The span to bucket, as a time-range filter value: two instants, or one
   * (read as that day). Omitted or empty, the rows' own extent is used.
   */
  range?: readonly (Date | null | undefined)[] | null;
  /** Bucket width. Omitted, the ladder in `evaluateIntervalMs` picks one. */
  intervalMs?: number;
};

/**
 * The span a chart covers: the range filter when one is set, else the oldest
 * and newest row. `null` when neither says anything.
 */
export function chartRange<TRow>(
  rows: readonly TRow[],
  timestamp: (row: TRow) => Date | number,
  range?: BucketChartDataOptions<TRow>["range"],
): [number, number] | null {
  const dates = (range ?? []).filter(
    (date): date is Date => date instanceof Date,
  );
  // One date is that whole local day — the same bounds the timerange filter
  // selects rows by, so the chart covers exactly the rows in the table.
  if (dates.length === 1) {
    return [startOfDay(dates[0]).getTime(), endOfDay(dates[0]).getTime()];
  }
  if (dates.length >= 2) {
    const a = dates[0].getTime();
    const b = dates[1].getTime();
    return [Math.min(a, b), Math.max(a, b)];
  }
  if (rows.length === 0) return null;

  let min = Infinity;
  let max = -Infinity;
  for (const row of rows) {
    const time = toTime(timestamp(row));
    if (time < min) min = time;
    if (time > max) max = time;
  }
  return [min, max];
}

/**
 * Rows → the `meta.chartData` the timeline chart draws: one point per bucket,
 * each carrying a count per series.
 *
 * The buckets start at the range's beginning and stop at the last whole
 * interval inside it, so a row past that edge is dropped rather than counted
 * in a bucket the chart does not draw. Same semantics as the Drizzle handler's
 * SQL aggregation, which is what makes the in-memory and database examples
 * interchangeable behind one client.
 */
export function bucketChartData<TRow>(
  rows: readonly TRow[],
  options: BucketChartDataOptions<TRow>,
): BaseChartSchema[] {
  const span = chartRange(rows, options.timestamp, options.range);
  if (!span) return [];

  const [start, end] = span;
  const duration = end - start;
  const interval = options.intervalMs ?? evaluateIntervalMs(duration);
  if (interval <= 0) return [];

  // At least one bucket: a span shorter than the interval — a single row, a
  // tight zoom — still has rows to show, and an empty array hides the chart.
  const steps = Math.max(1, Math.floor(duration / interval));
  const buckets: BaseChartSchema[] = Array.from({ length: steps }, (_, i) => {
    const bucket: BaseChartSchema = { timestamp: start + i * interval };
    for (const key of options.keys) bucket[key] = 0;
    return bucket;
  });

  for (const row of rows) {
    const offset = toTime(options.timestamp(row)) - start;
    if (offset < 0 || offset > duration) continue;
    const bucket = buckets[Math.floor(offset / interval)];
    if (!bucket) continue;
    const key = options.series(row);
    if (key in bucket) bucket[key] += 1;
  }

  return buckets;
}

function toTime(value: Date | number): number {
  return value instanceof Date ? value.getTime() : value;
}

function startOfDay(date: Date): Date {
  const result = new Date(date);
  result.setHours(0, 0, 0, 0);
  return result;
}

function endOfDay(date: Date): Date {
  const result = new Date(date);
  result.setHours(23, 59, 59, 999);
  return result;
}

More from Data Table Filters

Browse all components in this library →