이 페이지는 아직 번역되지 않았습니다. 영어 원문 보기

요소

Plot

A plot is the frame of a chart: the rows of data it shows, the scales of its x and y axes, the pointer over it and its motion. Its marks draw what it holds: a Line through a series, an Area between a series and a baseline, a Bar per row in the bands of a band scale, a Rule per row from a start to an end (a candle’s wick), a Dot per row where two of its values meet (a scatter’s points, a bubble chart’s bubbles), a Cell per row in the bands of two categories, colored by its value (a heatmap), a Grid of lines across, a Pie of the rows’ shares (a donut with a hole), an Arc between two angles, and a RadialLine around the center with the rings and spokes of a RadialGrid behind it (a radar), a Sankey of flows between the rows, and a Treemap of nested rectangles, each leaf’s area its value (a market map); a PlotAxis labels an edge with the ticks of its scale, and a PlotLabel writes texts at points of the scales. Each mark is one canvas filling the plot, drawn natively from the rows: the app’s rows are read once when it sets them, then scaled, recorded and drawn in Rust, so a chart of a thousand points runs no JS per point. The pointer is followed in Rust too: the hover’s cross line, dots and tooltip move and fade on the platform’s animator, and the app hears of a new row, nothing more. The look is the app’s: marks paint with CSS’s paint properties of SVG shapes (stroke-*, fill-*), currentColor where none is set, an area or a bar shows a background (a gradient, bg-linear-*) inside its shape, and the hover’s parts (each mark’s dot among them) and the axes’ labels, tick marks and lines are the app’s to style and fill.

el-plot.tsx
el-plot.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Area, Bar, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// A price chart built from the plot's parts: the rows are the app's, the
// look is the palette's (paint as styles: `stroke`, `fill`,
// `strokeDasharray`). The y domain is the app's, and its axis labels the
// grid's values; the axes lay their labels along the plot's edges. The row
// under the pointer drives the value in the header, and the plot's cross
// line, dot and tooltip follow it natively. Grid lines
// are solid hairlines and the bottom one a firmer baseline; the one dashed
// line is a reference, the opening price the change is measured from. The
// area is a wash of the line's color that fades out toward the baseline
// (its background, a gradient inside its shape), and the line has the
// hover's dot, in its color. The tooltip is the time of the row, a tag
// over the x axis' labels, so it never covers the line, and it stays
// within the plot's width at the first and last rows. Numbers are tabular
// and at the trailing edge.
//
// The other chart is sales per month as bars in the bands of a band scale,
// side by side or stacked: tips rounded and bases square, a hair between
// the bars of a month, a value over each (the total over a stack), the
// month under the pointer a faint band while the other months' bars fade,
// and the bars growing in one month after another.
type Row = { minute: number; price: number };
type Range = "1D" | "1W" | "1M";
type Curve = "natural" | "linear" | "stepAfter";

// A repeatable walk, so every visit shows the same chart.
const walk = (count: number, seed: number, start: number, step: number): Row[] => {
  let price = start;
  let s = seed;
  return Array.from({ length: count }, (_, minute) => {
    s = (s * 16807) % 2147483647;
    price += ((s % 1000) / 1000 - 0.47) * step;
    return { minute, price: Math.round(price * 100) / 100 };
  });
};
// The time of each row: every five minutes of a session, seven an hour
// apart each day of a week, and the trading days of October 2026.
const clock = (minutes: number) => `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}`;
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
const OCTOBER = Array.from({ length: 31 }, (_, d) => d + 1).filter((d) => (d + 3) % 7 !== 0 && (d + 3) % 7 !== 6);
const RANGES: Record<Range, { rows: Row[]; ticks: string[]; period: string; time: (row: number) => string }> = {
  "1D": { rows: walk(78, 7, 188, 1.2), ticks: ["9:30", "12:45", "16:00"], period: "Today", time: (i) => clock(570 + i * 5) },
  "1W": { rows: walk(35, 11, 181, 2.4), ticks: ["Mon", "Wed", "Fri"], period: "Past week", time: (i) => `${DAYS[Math.floor(i / 7)]} ${clock(600 + (i % 7) * 60)}` },
  "1M": { rows: walk(22, 5, 172, 3.6), ticks: ["Oct 1", "Oct 15", "Oct 30"], period: "Past month", time: (i) => `Oct ${OCTOBER[i]}` },
};

type Sale = { month: string; online: number; store: number };
type Mode = "grouped" | "stacked";
/** Sales per month, in thousands. */
const SALES: Sale[] = [
  [42, 31],
  [51, 28],
  [48, 36],
  [63, 33],
  [58, 40],
  [71, 38],
  [66, 44],
].map(([online, store], i) => ({ month: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul"][i], online, store }));
const total = (d: Sale) => d.online + d.store;
/** The top of the sales' y scale: room above the highest stack for its
 * label. */
const SALES_TOP = 130;

// The y domain: the rows' extent, rounded out to whole steps of 5.
const domain = (rows: Row[]): [number, number] => {
  const prices = rows.map((r) => r.price);
  return [Math.floor(Math.min(...prices) / 5) * 5, Math.ceil(Math.max(...prices) / 5) * 5];
};

const num = { fontVariant: ["tabular-nums" as const], textAlign: "right" as const };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;

/** A series' key: a swatch in its color, its name and the shown month's value. */
function Key(props: { color: string; label: string; value: string }) {
  return (
    <View class="flex-row items-center gap-1.5">
      <View style={{ width: 8, height: 8, borderRadius: 2, backgroundColor: props.color }} />
      <Text style={{ fontSize: size.detail, color: ui().muted }}>{props.label}</Text>
      <Text style={{ fontSize: size.detail, color: ui().text, ...num }}>{props.value}</Text>
    </View>
  );
}

/** Sales per month as bars, side by side or stacked. */
function SalesCard(props: { mode: Mode; onHover: (index: number | null) => void }) {
  const hovered = signal<number | null>(null);
  const shown = () => SALES[hovered() ?? SALES.length - 1];
  const stacked = () => props.mode === "stacked";
  const height = desktop ? 200 : 180;
  // Tips rounded, bases square; a stack only at its top.
  const tip = { topLeft: 3, topRight: 3 };
  const labelStyle = () => ({ fontSize: 11, paddingBottom: 3, color: ui().muted, fontVariant: ["tabular-nums" as const] });
  return (
    <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
      <View class="flex-row items-baseline gap-3">
        <View style={{ flex: 1, gap: 2 }}>
          <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Sales</Text>
          <Text style={{ fontSize: size.detail, color: ui().muted }}>{`${shown().month} · thousands`}</Text>
        </View>
        <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{`${total(shown())}k`}</Text>
      </View>
      <View class="flex-row gap-4">
        <Key color={ui().info} label="Online" value={`${shown().online}k`} />
        <Key color={ui().violet} label="In store" value={`${shown().store}k`} />
      </View>
      <View>
        <Plot
          data={SALES}
          // Half the inner gap at the ends: the bands' middles divide the
          // width evenly.
          xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15, maxBandWidth: 56, groupPadding: 0.12 }}
          yScale={{ type: "linear", domain: [0, SALES_TOP] }}
          accessibilityLabel={`Sales per month, online and in store, highest in Jul at ${total(SALES[6])}k`}
          accessibilityValue={{ text: hovered() == null ? undefined : `${shown().month}: ${shown().online}k online, ${shown().store}k in store` }}
          appearGeneration={stacked() ? 1 : 0}
          motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 150 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } }}
          // The month under the pointer: a faint band over its bars.
          crossLineClass="w-full h-full"
          crossLineStyle={{ backgroundColor: tint(ui().text, 0.05) }}
          onHoverChange={({ index }) => {
            hovered.set(index);
            props.onHover(index);
          }}
          style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
        >
          <Grid y={[40, 80, 120]} style={{ stroke: tint(ui().text, 0.08) }} />
          <Grid y={[0]} style={{ stroke: tint(ui().text, 0.2) }} />
          <Bar
            value="online"
            stack={stacked() ? "sales" : undefined}
            cornerRadii={stacked() ? 0 : tip}
            stagger={0.4}
            inactiveOpacity={0.45}
            label={stacked() ? undefined : "online"}
            labelStyle={labelStyle()}
            style={{ fill: ui().info }}
          />
          <Bar
            value="store"
            stack={stacked() ? "sales" : undefined}
            cornerRadii={tip}
            stagger={0.4}
            inactiveOpacity={0.45}
            // Stacked, the top bar's label is the month's total.
            label={stacked() ? (d: Sale) => String(total(d)) : "store"}
            labelStyle={labelStyle()}
            style={{ fill: ui().violet }}
          />
          {/* The grid's values right of the plot, a month centered under each band. */}
          <PlotAxis
            placement="right"
            tickValues={[0, 40, 80, 120]}
            labelStyle={{ width: 36, paddingLeft: 8, textAlign: "right", fontSize: 11, color: ui().muted, ...num }}
          />
          <PlotAxis format={(i) => SALES[i].month} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
        </Plot>
      </View>
    </View>
  );
}

function App() {
  const chart = signal<"price" | "sales">("price");
  const mode = signal<Mode>("grouped");
  const range = signal<Range>("1D");
  const curve = signal<Curve>("natural");
  const hovered = signal<number | null>(null);
  // The last row hovered: the tooltip keeps it while it fades out.
  const tipRow = signal(0);
  const events = signal<string[]>([]);
  const rows = () => RANGES[range()].rows;
  const [lo, hi] = [() => domain(rows())[0], () => domain(rows())[1]];
  // The hovered row, or the last one; a row of the range before a switch
  // reads as the last one of the new range.
  const shown = () => {
    const all = rows();
    const at = hovered();
    return all[at != null && at < all.length ? at : all.length - 1];
  };
  const open = () => rows()[0].price;
  const change = () => shown().price - open();
  const color = () => (change() >= 0 ? ui().up : ui().down);
  // Signed figures: an explicit plus and a true minus.
  const signed = (v: number, digits: number) => `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(digits)}`;
  const label = (v: number) => v.toFixed(0);
  const height = desktop ? 200 : 180;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? "Move the pointer over the chart to read a row"
          : chart() === "price"
            ? "Pick a range to draw other rows"
            : "Stack the bars to read each month's total"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["price", "sales"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
            </Panel>
            <If
              when={chart() === "price"}
              else={
                <Panel title="bars">
                  <Segmented options={["grouped", "stacked"] as const} value={mode()} onChange={(m) => mode.set(m)} />
                  <Text style={{ fontSize: 13, color: ui().text, ...num, textAlign: "left" }}>
                    {`${SALES.length} bands · ${mode() === "stacked" ? "one stack" : "two bars"} each`}
                  </Text>
                </Panel>
              }
            >
              <Panel title="data · curve">
                <Segmented options={["1D", "1W", "1M"] as const} value={range()} onChange={(r) => (range.set(r), hovered.set(null))} />
                <Segmented options={["natural", "linear", "stepAfter"] as const} value={curve()} onChange={(c) => curve.set(c)} />
                <Text style={{ fontSize: 13, color: ui().text, ...num, textAlign: "left" }}>
                  {`${rows().length} rows · domain ${lo()} to ${hi()}`}
                </Text>
              </Panel>
            </If>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <If
          when={chart() === "price"}
          else={
            <SalesCard
              mode={mode()}
              onHover={(index) => {
                const line = index == null ? "null: left the chart" : `${index}: ${SALES[index].month}, ${total(SALES[index])}k`;
                events.set([line, ...events()].slice(0, 5));
              }}
            />
          }
        >
          <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>NSH Labs</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted }}>
                  {`${RANGES[range()].period} · open ${open().toFixed(2)}`}
                </Text>
              </View>
              <View style={{ alignItems: "flex-end", gap: 2 }}>
                <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{shown().price.toFixed(2)}</Text>
                <Text style={{ fontSize: size.detail, color: color(), ...num }}>
                  {`${signed(change(), 2)} (${signed((change() / open()) * 100, 2)}%)`}
                </Text>
              </View>
            </View>

            <View>
              <Plot
                data={rows()}
                y="price"
                yScale={{ type: "linear", domain: [lo(), hi()] }}
                accessibilityLabel={`NSH Labs, ${range()}: from ${rows()[0].price.toFixed(2)} to ${rows()[rows().length - 1].price.toFixed(2)}`}
                accessibilityValue={{ text: hovered() == null ? undefined : `${RANGES[range()].time(hovered()!)}: ${shown().price.toFixed(2)}` }}
                // Motion of the app's: the marks draw in for each range, the
                // hover fades and glides; reduced motion shows it all at once.
                appearGeneration={["1D", "1W", "1M"].indexOf(range())}
                motion={{ appear: { duration: 700, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 300, damping: 30 } }}
                crossLineClass="w-px h-full"
                crossLineStyle={{ backgroundColor: ui().muted }}
                dotClass="size-2.5 rounded-full border-2"
                // The time of the row, at the foot of the cross line, kept
                // within the plot's width.
                tooltipOffset={height + 9}
                tooltipContent={
                  <View style={{ ...ui().raised, height: 20, paddingHorizontal: 6, borderRadius: 6, justifyContent: "center" }}>
                    <Text style={{ fontSize: 11, color: ui().text, ...num }}>{RANGES[range()].time(Math.min(tipRow(), rows().length - 1))}</Text>
                  </View>
                }
                onHoverChange={({ index }) => {
                  hovered.set(index);
                  if (index != null) tipRow.set(index);
                  const line = index == null ? "null: left the chart" : `${index}: ${rows()[index].price.toFixed(2)}`;
                  events.set([line, ...events()].slice(0, 5));
                }}
                style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
              >
                <Grid y={[(lo() + hi()) / 2, hi()]} style={{ stroke: tint(ui().text, 0.08) }} />
                {/* A wash that fades out toward the baseline; the line has the dot. */}
                <Area
                  curve={curve()}
                  backgroundStyle={{ backgroundImage: `linear-gradient(to bottom, ${tint(color(), 0.16)}, ${tint(color(), 0)})` }}
                  hoverDotClass="hidden"
                />
                <Grid y={[lo()]} style={{ stroke: tint(ui().text, 0.2) }} />
                <Grid y={[open()]} style={{ stroke: ui().faint, strokeDasharray: "3 3" }} />
                {/* Its dot in its color, ringed in the card's: it cuts out of the line. */}
                <Line
                  curve={curve()}
                  style={{ stroke: color(), strokeWidth: 2 }}
                  hoverDotStyle={{ backgroundColor: color(), borderColor: ui().card.backgroundColor }}
                />
                {/* The grid's values right of the plot, the range's times under it. */}
                <PlotAxis
                  placement="right"
                  tickValues={[lo(), (lo() + hi()) / 2, hi()]}
                  format={label}
                  labelStyle={{ fontSize: 11, paddingLeft: 8, color: ui().muted, ...num }}
                />
                <PlotAxis
                  ticks={RANGES[range()].ticks.length}
                  format={(_, i) => RANGES[range()].ticks[i]}
                  labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }}
                />
              </Plot>
            </View>
          </View>
        </If>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{
    plot::{area, grid, line, plot},
    theme,
};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};

fn week(visitors: Vec<f64>, hovered: Signal<Option<u32>>) -> impl IntoElement {
    let rows = Rows::from_columns(visitors.len(), [("visitors".into(), visitors)]);
    plot()
        .data(rows)
        .y(RowAccessor::key("visitors"))
        .accessibility_label("Visitors per day")
        .h(px(160.0))
        .on_hover_change(move |index: &Option<u32>| hovered.set(*index))
        .child(grid().y(vec![0.0, 1500.0, 3000.0]).stroke_color(move || theme().border))
        .child(area().fill_color(move || theme().chart_1.opacity(0.2)))
        .child(line().stroke_color(move || theme().chart_1).stroke_width(px(2.0)))
}

Plot, Line, Area, Bar, Rule, Dot, Cell, Grid, Pie, Arc, RadialLine, RadialGrid, Sankey, Treemap, PlotAxis and PlotLabel come from nsh/base/plot, a module of nsh/base of their own (in Rust, nsh_base::plot). In TypeScript a plot’s props are generic over the type of its rows: field names are checked against them, and a function of a row gets the row’s type. In Rust, plot() makes one with a setter per prop, the rows a Rows (columns of numbers by key), the values a RowAccessor (a key or a function of the row’s index), and the marks take the typed paint methods (.stroke_color(..), .fill_color(..), .stroke_width(..)).

#Plot

PropRustTypeDefaultDescription
data.data(..)T[](required)The rows, one object per point. The marks read their values once, when the app sets the rows; a row without a value (a field that is not a number, null) is skipped.
x.x(..)keyof T | (d: T, index: number) => numberthe row’s indexWhat places a row along the x axis on a linear x scale. A point scale places the rows in order.
y.y(..)keyof T | (d: T, index: number) => numbernoneWhat the marks draw unless they name their own.
xScale.x_scale(..)Scale{ type: "point" }How the x axis places rows: point (in order, evenly from the left edge to the right one; one row in the middle), linear with an optional domain, or band (a band per row, for bars, Bar; or a band per category, for cells, Cell). With a band y scale it defaults to linear, for bars across. reverse: true runs it from the right edge (Reversed scales).
yScale.y_scale(..)Scale{ type: "linear" }How the y axis places values: the domain’s smallest value at the bottom edge, its largest at the top. Without a domain, the extent of what the marks draw. band lays a band per row from the top edge down, for bars across. reverse: true runs it the other way: the smallest value at the top edge, the rows from the bottom edge up (Reversed scales).
y2Scale.y2_scale(..)LinearScalenoneA second y scale, { type: "linear", domain?, reverse? }, for the marks that name it (scale="y2"): a dual-axis chart’s right-hand scale (Dual axes). Without a domain, the extent of the values on it.
alignTicks.align_ticks(..)booleantrueWhile neither y scale names a domain and marks are on both, both are widened to the same round steps so their ticks fall on the same lines (Dual axes). false keeps each the extent of its values.
interactive.interactive(..)booleantrueThe plot follows the pointer and the keyboard: it reports the row under it and shows the hover’s parts, and it is a stop of Tab whose arrow keys move the hover (Keyboard). false draws the marks only (a sparkline in a list), and presses on it go to the views around it. Whether the plot offers the keyboard at all is set when it is made.
motion.motion(..)PlotMotionnoneThe timing of the appear, the hover’s fade in and out, and the spring the hover glides on when the keys move it (below).
appearGeneration.appear_generation(..)number | stringnoneOpts in to the appear: the marks draw in when the plot is first shown, and again whenever this changes (another symbol, another period). Inside a PlotAppearScope, a generation that finished there shows whole at once.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say of the chart: what it shows and what stands out.
accessibilityValue.accessibility_value(..){ min?, max?, now?, text? }noneWhat screen readers hear as its value: the app’s words for the row under the pointer.
crossLineClass, crossLineStyle.cross_line_class(..), .cross_line_style(..)string, StyleProp<ViewStyle>noneThe cross line: a view as tall as the plot, centered on the row under the pointer. On a band scale it sits in the row’s band, as wide as the band (across the plot on a band y scale).
dotClass, dotStyle.dot_class(..), .dot_style(..)string, StyleProp<ViewStyle>noneThe hover’s dots: a view per Line, Area and RadialLine, centered on its point at the row under the pointer, and a Dot’s as big as its dot under the pointer. A mark’s hoverDotClass / hoverDotStyle replace them for its own dot.
tooltipContent.tooltip_content(..)JSX.ElementnoneThe app’s tooltip, placed by tooltipPlacement and moved and faded with the cross line.
tooltipPlacement.tooltip_placement(..)"top" | "pointer""top"Where the tooltip goes: at the top of the plot, centered on the cross line; or beside the pointer, on the side of the cross line toward the plot’s middle and below the pointer in the upper half, above it in the lower one. At the top it stays between the plot’s left and right edges (its offset from the top edge is the app’s: a tag under the plot takes the plot’s height and more); beside the pointer it stays inside the plot. With the keyboard, it goes beside the first line’s or area’s point at the row (the middle of a bar’s band, the middle of a pie’s slice).
tooltipOffset.tooltip_offset(..)number0The distance between the tooltip and what places it: the top edge for "top", the cross line and the pointer for "pointer".
colorScale.color_scale(..)ColorScalenoneThe marks’ colors: of the categories their color reads, as d3’s scaleOrdinal ({ type: "ordinal", domain?, range, unknown? }, Colors by category), or of the numbers a Cell’s value reads along a ramp, as d3’s scaleSequential and scaleDiverging ({ type: "sequential" | "diverging", … }, Colors along a ramp).
children.child(..)JSX.ElementnoneThe marks, drawn in order over each other, and whatever the app lays over them.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe plot’s size and look; the marks inherit its paint properties, as CSS inherits them. focus: / focus-visible: classes and focusStyle draw its focus ring.
EventRustPayloadWhen
onHoverChange.on_hover_change(..){ index: number | null }The pointer came over another row: the index of the row nearest to it along the x axis (along the y axis when only it has bands), of the pie’s slice under it, of the dot whose center is nearest to it, of the cell under it (across the gap between two cells, the nearer one), or of a radial line’s row nearest to it by angle; null once it leaves the plot (over a pie, its slices; over a radial line, its outer radius).

Scale is { type: "point"; reverse? } | { type: "linear"; domain?: [number, number]; reverse? } | { type: "band"; domain?: string[]; paddingInner?; paddingOuter?; maxBandWidth?; bandCount?; groupPadding?; reverse? }. A linear scale’s domain is the extent of its two values, in either order; a domain of one value puts it in the middle. A band scale’s domain is its bands’ categories, in order (Cell); its other options are in Bar.

A function of a row (y={(d) => d.high - d.low}) is called once per row when the rows or the function change, in the next turn, never while the plot draws or follows the pointer: keep it pure (the same row, the same number). A field name is read with no JS at all.

#Reversed scales

reverse: true runs a scale from its other edge, as visx’s scales take reverse and d3’s take a range written backwards. On the x axis the first row, or the domain’s smallest value, is at the right edge; on the y axis the domain’s smallest value is at the top edge and the rows run from the bottom edge up. Everything placed on the scale follows it: the marks, the bars’ base and the way they grow, an area’s baseline, the grid lines, the PlotAxis ticks, the row the hover finds, the arrow keys (Keyboard) and the moves to a new domain. Bars hang from the top edge on a reversed linear y scale, and bars across grow leftward from the right edge on a reversed linear x scale:

<Plot
  data={depths}
  y="depth"
  xScale={{ type: "band", paddingInner: 0.2 }}
  yScale={{ type: "linear", domain: [0, 40], reverse: true }}
  class="h-32"
>
  <Bar class="fill-chart-2" cornerRadii={{ bottomLeft: 3, bottomRight: 3 }} />
</Plot>

With the plot’s appear, lines and areas draw in from the edge the x scale starts at, the right one when it is reversed.

#Dual axes

el-plot-dual.tsx
el-plot-dual.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Bar, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Two y scales in one plot: the volume bars on the plot's `yScale`, the
// close on its second one (`y2Scale`), which the line names with
// `scale="y2"` and the right-hand `PlotAxis` labels. Neither scale names a
// domain, so the plot widens both to the same round steps (`alignTicks`,
// on by default) and the one `<Grid />`, which follows the ticks, serves
// both axes. Switching the symbol moves both scales to their new domains;
// turning alignment off shows each scale on its own extent.
type Day = { day: number; volume: number; close: number; dir: "up" | "down" };

/** A repeatable walk of a stock's days: its close and its volume in
 * millions of shares, heavier on the days that move more. */
function walk(seed: number, start: number, volume: number): Day[] {
  let s = seed;
  const next = () => {
    s = (s * 16807) % 2147483647;
    return (s % 1000) / 1000;
  };
  let close = start;
  return Array.from({ length: 20 }, (_, day) => {
    const before = close;
    close = Math.round(close * (1 + (next() - 0.47) * 0.04) * 100) / 100;
    const move = Math.abs(close / before - 1);
    return { day, close, volume: Math.round(volume * (0.7 + next() * 0.5) * (1 + move * 18) * 10) / 10, dir: close >= before ? "up" : "down" };
  });
}

const SYMBOLS = {
  ORBX: walk(23, 184, 42),
  QBIT: walk(71, 36.5, 160),
} as const;
type Symbol = keyof typeof SYMBOLS;

const num = { fontVariant: ["tabular-nums" as const] };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;
const SIDE = 40;
const shares = (m: number) => (m === 0 ? "0" : m >= 1000 ? `${(m / 1000).toFixed(1)}B` : `${m.toFixed(0)}M`);

function App() {
  const symbol = signal<Symbol>("ORBX");
  const aligned = signal(true);
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const height = desktop ? 200 : 180;
  const rows = () => SYMBOLS[symbol()];
  const day = () => rows()[hovered() ?? rows().length - 1];
  const tone = (dir: string) => (dir === "down" ? ui().down : ui().up);
  const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot dual axes</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Move the pointer over the chart to read both values" : "Volume on the left scale, the close on the right"}</Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="symbol">
              <Segmented options={["ORBX", "QBIT"] as const} value={symbol()} onChange={(s) => symbol.set(s)} />
            </Panel>
            <Panel title="alignTicks">
              <Segmented options={["on", "off"] as const} value={aligned() ? "on" : "off"} onChange={(v) => aligned.set(v === "on")} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>{aligned() ? "Both scales in the same steps" : "Each scale its own extent"}</Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <View class="flex-row items-baseline gap-3">
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{symbol()}</Text>
              <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>{`Day ${day().day + 1} · ${shares(day().volume)} shares`}</Text>
            </View>
            <Text style={{ fontSize: 20, fontWeight: "600", color: tone(day().dir), ...num }}>{`$${day().close.toFixed(2)}`}</Text>
          </View>
          <View class="flex-row justify-between">
            <Text style={{ fontSize: 10, letterSpacing: 0.8, color: ui().muted }}>SHARES</Text>
            <Text style={{ fontSize: 10, letterSpacing: 0.8, color: ui().muted }}>USD</Text>
          </View>
          <Plot
            data={rows()}
            xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15 }}
            alignTicks={aligned()}
            colorScale={{ type: "ordinal", domain: ["up", "down"], range: [tint(ui().up, 0.45), tint(ui().down, 0.45)] }}
            accessibilityLabel={`${symbol()}, ${rows().length} days: volume and close`}
            accessibilityValue={{ text: hovered() == null ? undefined : `Day ${day().day + 1}: close ${day().close}, ${shares(day().volume)} shares` }}
            appearGeneration="dual"
            motion={{ appear: { duration: 800, easing: "easeOut" }, update: { duration: 600, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 180 } }}
            crossLineClass="w-full h-full"
            crossLineStyle={{ backgroundColor: tint(ui().text, 0.05) }}
            dotStyle={{ width: 9, height: 9, borderRadius: 4.5, borderWidth: 2, borderColor: ui().info, backgroundColor: ui().page }}
            onHoverChange={({ index }) => {
              hovered.set(index);
              const d = index == null ? undefined : rows()[index];
              log(d == null ? "null: left the chart" : `${index}: $${d.close} · ${shares(d.volume)}`);
            }}
            style={{ height, marginLeft: SIDE, marginRight: SIDE, marginBottom: AXIS_BELOW }}
          >
            {/* No values: lines at the ticks of the y scale, which the right axis' share. */}
            <Grid style={{ stroke: tint(ui().text, 0.08) }} />
            <Bar value="volume" color="dir" cornerRadii={{ topLeft: 1.5, topRight: 1.5 }} stagger={0.5} inactiveOpacity={0.4} />
            <Line y="close" scale="y2" curve="linear" style={{ stroke: ui().info, strokeWidth: 2 }} />
            <PlotAxis placement="left" format={shares} labelStyle={{ ...axisLabel(), width: SIDE, paddingRight: 8, textAlign: "right" }} />
            <PlotAxis placement="right" scale="y2" format={(v) => v.toFixed(0)} labelStyle={{ ...axisLabel(), width: SIDE, paddingLeft: 8 }} />
            <PlotAxis tickValues={[0, 9, 19]} format={(i) => `Day ${i + 1}`} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
          </Plot>
        </View>
      </Split>
    </View>
  );
}

mount(App);

A plot has a second y scale for values of other units: volumes beside a price, revenue beside a margin. Every part that places values along y takes scale: "y" (the default, the plot’s yScale) or "y2" (its y2Scale). A PlotAxis on the right with scale="y2" labels it:

<Plot data={days} xScale={{ type: "band", paddingInner: 0.3 }} class="mx-10 mb-6 h-44">
  <Grid class="stroke-border" />
  <Bar value="volume" class="fill-chart-2/50" />
  <Line y="close" scale="y2" class="stroke-chart-1 stroke-2" />
  <PlotAxis placement="left" format={(v) => `${v}M`} labelClass="pr-2 text-xs text-muted-foreground" />
  <PlotAxis placement="right" scale="y2" labelClass="pl-2 text-xs text-muted-foreground" />
</Plot>

Each scale’s domain is its own: the app’s domain, or the extent of the values on it, and each moves to a new one with motion.update. While neither scale names a domain, the plot lays both out in the same number of round steps (1, 2 or 5 times a power of ten, a step for about every 32 points of height): when both hold zero, zero falls on the same line on both; otherwise a scale that starts at zero (volumes) gains its extra steps upward and another (a price) on both sides. The axes’ ticks are those steps, and a Grid with neither x nor y draws its lines at them, so one grid serves both axes. alignTicks={false} turns this off; a domain the app names leaves both scales as they are.

The hover finds one row; each mark’s dot sits on its own scale, and the read-out is the app’s, which reads both values of the row onHoverChange reports. Bars on both scales stand side by side in each band, and a stack piles only the bars on its scale. y2Scale is linear: a plot whose y axis has bands has no second y scale.

#Line, Area and Grid

MarkPropTypeDefaultDescription
Lineystring | (d, index) => numberthe plot’s yWhat it draws: one point per row with a value, the rows without one skipped.
Line, Areadata, xT[], string | (d, index) => numberthe plot’s rowsRows of a series of its own (other x values), placed on the plot’s scales by its own x (a row’s index without).
Line, Areacurve"natural" | "linear" | "stepAfter""natural"How it connects its points: a smooth curve, straight segments, or steps that hold each value until the next row. An area’s bottom edge follows the same curve.
Linedot, dotClass, dotStyleboolean, string, StyleProp<ViewStyle>falseA dot on every point: a view centered on it, its size, background and border the app’s.
Line, AreahoverDotClass, hoverDotStylestring, StyleProp<ViewStyle>the plot’s dotClass, dotStyleIts hover dot, on its point at the row under the pointer: each series’ dot in its own color. Each replaces the plot’s for this mark (a mark’s color over the plot’s size and ring); hidden shows none.
AreabackgroundClass, backgroundStylestring, StyleProp<ViewStyle>noneIts background: a box around its shape (from its first point to its last, from its highest point to its baseline, or to its bottom edge’s lowest point on a band) whose background (bg-*, bg-linear-to-b from-… to-…, backgroundImage) shows only inside the shape, under its fill. With one, fill is none where none is set.
Areay1string | (d, index) => numberthe plot’s yWhat makes its top edge.
Areay0number | string | (d, index) => numberthe y scale’s low edgeIts bottom edge: a number is a baseline on the y scale; a field or a function gives each row its own bottom value, which makes the area a band (a day’s low to its high, a forecast’s range). Without one, the area closes to the edge where the y scale’s smallest values are: the bottom edge, the top one on a reversed scale (an area hanging from the top). A row without a value at either edge is skipped.
Line, Area, Gridscale"y" | "y2""y"The y scale its values go along: the plot’s yScale or its second one, y2Scale (Dual axes).
Gridxnumber[]noneWhere its vertical lines go, as values of the x scale (row indices on a point scale).
Gridynumber[]noneWhere its horizontal lines go, as values of the y scale. With neither x nor y, the lines follow the ticks of its scale, where a PlotAxis without ticks puts them (along x for bars across).
Gridticksnumberthe axis’ defaultAbout how many lines it draws with neither x nor y, as a PlotAxis’s ticks.
Line, Areacolorstring | (d, index) => stringnoneThe category of its rows, its color from the plot’s colorScale: its first row’s category gives the line’s stroke (the area’s fill) where nothing sets one (Colors by category).

Each mark fills the plot and paints with its SVG paint properties, its own over those of the plot, as CSS inherits them:

MarkPaints withWhere none is set
Linestroke, strokeWidth, strokeDasharraycurrentColor, 1 wide, solid
Areafill (over its background part); its top edge stroke, strokeWidth, strokeDasharrayfill currentColor (none beside a background); no stroke
Gridstroke, strokeWidth, strokeDasharraycurrentColor, 1 wide, solid; each line on whole device pixels, so a thin one stays sharp

The values a Line or an Area draws, an area’s y0 too (its baseline, or each row’s bottom value), make the y scale’s domain when the plot has none; a grid’s do not. A mark draws again only when what it draws changes (its rows, values, curve, the plot’s size or scales, its paint); the pointer moves the hover’s parts and never draws a mark again.

A band is an Area whose y0 reads the rows: its top edge runs through y1, its bottom edge back through y0, both with the area’s curve, and the appear draws both in from the leading edge. Its hover dot sits on the top edge; a Line over it marks the middle value:

<Plot data={days} y="close">
  <Area y1="high" y0="low" class="fill-chart-1/15" hoverDotClass="hidden" />
  <Line class="stroke-chart-1 stroke-2" />
</Plot>

fill is CSS’s, as in a browser: a color. A gradient under a line is an area’s background, the way a page shows a gradient in a shape (a box’s background-image under clip-path): backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" fades from the area’s highest point to its baseline, in the theme’s tokens and the appearance’s colors like any view’s background.

#Bar

A Bar draws a bar per row in the row’s band of a band scale: upright on a band x scale, across on a band y scale (the x scale then linear). Each bar runs from base to the row’s value on the other scale, so a negative value grows the other way, and a row without a value has no bar. On a reversed value scale the bars grow down from the top edge or leftward from the right one, and a reversed band scale puts the first row’s band at the right or the bottom (Reversed scales).

import { Bar, Grid, Plot } from "nsh/base/plot";

const sales = [
  { month: "Jan", online: 42, store: 31 },
  { month: "Feb", online: 51, store: 28 },
  { month: "Mar", online: 48, store: 36 },
];

<Plot
  data={sales}
  xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15, groupPadding: 0.1 }}
  yScale={{ type: "linear", domain: [0, 60] }}
  class="h-40"
>
  <Grid y={[20, 40, 60]} class="stroke-border" />
  <Bar value="online" class="fill-chart-1" cornerRadii={{ topLeft: 4, topRight: 4 }} />
  <Bar value="store" class="fill-chart-2" cornerRadii={{ topLeft: 4, topRight: 4 }} />
</Plot>

Bars outside a stack stand side by side in each band, in the order of the marks; bars with the same stack pile up, each row’s bar starting where the one before it in the stack ends:

<Bar value="online" stack="sales" class="fill-chart-1" />
<Bar value="store" stack="sales" class="fill-chart-2" cornerRadii={{ topLeft: 4, topRight: 4 }} label={(d) => `${d.online + d.store}`} />
PropTypeDefaultDescription
valuestring | (d, index) => numberthe plot’s yWhat each bar shows.
basenumber | string | (d, index) => number0The value its bars grow from, on the value scale: one for every row, or a field or function giving each row its own, so each bar spans its row’s base to its value (a row without one has no bar). It counts in the domain. A stack grows from its first bar’s, row by row.
stackstringnoneThe stack it joins: the bars with the same stack pile up in order (a row without a value adds nothing).
scale"y" | "y2""y"The y scale its upright bars’ values go along (Dual axes); bars on both stand side by side, and a stack piles only the bars on its scale.
cornerRadiinumber | { topLeft?, topRight?, bottomRight?, bottomLeft? }0The radius of each corner of its bars; { topLeft: 4, topRight: 4 } rounds the tips of upright bars. A radius larger than half a bar fits it.
staggernumber0With the plot’s appear, the bars start one after another, row by row, over this share of it (0 to 0.95), each growing for the rest; 0 grows them together.
inactiveOpacitynumbernoneThe opacity of its bars away from the row under the pointer while the pointer is over the plot, so that row stands out: it fades in and out with the hover and passes from bar to bar with it.
labelstring | (d, index) => stringnoneThe text at each bar’s value end: a field (its text, or its number) or a function returning the text the app formats.
labelClass, labelStylestring, StyleProp<TextStyle>noneThe label part: a text centered on the bar, above an upright bar’s end (below one growing down), after a bar across (before one growing back). Its padding is its distance from the bar.
backgroundClass, backgroundStylestring, StyleProp<ViewStyle>noneIts background: each bar’s box, whose background (bg-linear-to-t from-… to-…, from the bottom of the bar to its top) shows only inside the bar, under its fill. With one, fill is none where none is set.
colorstring | (d, index) => stringnoneEach bar’s category, its color from the plot’s colorScale: the bar’s fill where nothing sets one (Colors by category).

The band scale lays out a band per row of the plot, in order, or a band per category of its domain, as d3’s scaleBand does:

OptionDefaultDescription
domainnoneThe categories of its bands, in order (d3’s scaleBand().domain()): a Cell goes in the band of its row’s category, and a PlotAxis writes each band’s category; a category written twice keeps its first place. Without one, the bands are the rows, or over cells the categories the cells name along the axis, in the order the rows bring them.
paddingInner0The gap between neighbouring bands, a share of each band’s step (0 to 1).
paddingOuter0The gap before the first band and after the last, a share of a step. Half of paddingInner puts the bands’ middles at equal steps across the plot.
maxBandWidthnoneThe widest a band may be, in points: a few bands across a wide plot stay narrow, each in the middle of its place.
bandCountthe rowsThe bands the axis is laid out for when more than the rows: the rows take the leading ones and keep their width and place as rows come.
groupPadding0The gap between the bars side by side in a band, a share of each bar’s place.
reversefalseThe bands run from the other edge: the first row’s at the right of the x axis, at the bottom of the y axis; the bars side by side in a band keep their order.

A bar paints with its fill (fill-*; inherited from the plot as CSS inherits it), currentColor where none is set (its category’s color with color), and shows no hover dot. On a band scale, the hover finds the band under the pointer, and the plot’s cross line sits in that band, as wide as it: crossLineClass="w-full h-full bg-foreground/5" tints the band, w-px keeps a line in its middle. The bars’ values and base make the value scale’s domain when the plot has none. With the plot’s appear, each bar grows out of its stack’s base; the motion, the fading and the gliding run on the platform’s animator, and the pointer draws no bar again.

#Ranges

el-plot-ranges.tsx
el-plot-ranges.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Bar, Grid, Plot, PlotAxis, Rule } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Bars that span a range of their own row: `Bar`'s `base` takes a field
// name or a function of the row as well as a number, so each bar runs
// from its row's base to its value. `Rule` draws a straight line per row
// the same way, along the value axis.
//
// The candlestick is two marks in the same bands: a `Rule` from each day's
// low to its high (the wick) under a `Bar` from its open to its close (the
// body), both colored by the day's direction through the plot's ordinal
// `colorScale`. The bridge is one `Bar` whose base is where the running
// total stood before each step: totals stand on zero, costs hang down from
// the level before them, each labeled at its end with its signed change
// (above a rise, below a fall). The marks grow from their bases one after
// another, and the row under the pointer stays lit while the others fade.
type Candle = { day: number; open: number; high: number; low: number; close: number; dir: "up" | "down" };
type Step = { name: string; kind: "total" | "up" | "down"; start: number; end: number };

/** A repeatable walk of daily candles, so every visit shows the same chart. */
const CANDLES: Candle[] = (() => {
  let s = 17;
  const next = () => {
    s = (s * 16807) % 2147483647;
    return (s % 1000) / 1000;
  };
  let close = 92;
  return Array.from({ length: 24 }, (_, day) => {
    const open = close + (next() - 0.5) * 0.8;
    close = open + (next() - 0.46) * 3.2;
    const high = Math.max(open, close) + next() * 1.2;
    const low = Math.min(open, close) - next() * 1.2;
    const r = (v: number) => Math.round(v * 100) / 100;
    return { day, open: r(open), high: r(high), low: r(low), close: r(close), dir: close >= open ? "up" : "down" };
  });
})();
const LOW = Math.floor(Math.min(...CANDLES.map((c) => c.low)) - 1);
const HIGH = Math.ceil(Math.max(...CANDLES.map((c) => c.high)) + 1);

/** A quarter's revenue down to net profit, in millions: each step from
 * where the total stood to where it leaves it. */
const STEPS: Step[] = (() => {
  const lines: [string, number][] = [
    ["Cost", -54.1],
    ["R&D", -22.8],
    ["SG&A", -27.4],
    ["Other", 3.9],
    ["Tax", -9.2],
  ];
  let level = 142.7;
  const steps: Step[] = [{ name: "Revenue", kind: "total", start: 0, end: level }];
  for (const [name, v] of lines) {
    const end = Math.round((level + v) * 10) / 10;
    steps.push({ name, kind: v >= 0 ? "up" : "down", start: level, end });
    level = end;
  }
  steps.push({ name: "Net", kind: "total", start: 0, end: level });
  return steps;
})();

const num = { fontVariant: ["tabular-nums" as const] };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;
const signed = (v: number, digits = 1) => `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(digits)}`;

function App() {
  const chart = signal<"candles" | "bridge">("candles");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const height = desktop ? 200 : 180;
  const candle = () => CANDLES[hovered() != null && chart() === "candles" ? Math.min(hovered()!, CANDLES.length - 1) : CANDLES.length - 1];
  const step = () => STEPS[hovered() != null && chart() === "bridge" ? Math.min(hovered()!, STEPS.length - 1) : STEPS.length - 1];
  const tone = (dir: string) => (dir === "down" ? ui().down : dir === "up" ? ui().up : ui().text);
  const labelStyle = () => ({ fontSize: 11, paddingVertical: 3, color: ui().muted, ...num });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot ranges</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over the chart to read a row" : chart() === "candles" ? "Each candle spans its day: wick low to high, body open to close" : "Each step starts where the total stood"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["candles", "bridge"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>
                {chart() === "candles" ? `Rule high ← low · Bar close ← open` : `Bar end ← start · ${STEPS.length} steps`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <If
            when={chart() === "candles"}
            else={
              <>
                <View class="flex-row items-baseline gap-3">
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Profit bridge</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted }}>{`${step().name} · millions`}</Text>
                  </View>
                  <Text style={{ fontSize: 20, fontWeight: "600", color: tone(step().kind), ...num }}>
                    {step().kind === "total" ? `$${step().end.toFixed(1)}M` : `${signed(step().end - step().start)}M`}
                  </Text>
                </View>
                <Plot
                  data={STEPS}
                  xScale={{ type: "band", paddingInner: 0.32, paddingOuter: 0.16 }}
                  yScale={{ type: "linear", domain: [0, 160] }}
                  colorScale={{ type: "ordinal", domain: ["total", "up", "down"], range: [ui().info, ui().up, ui().down] }}
                  accessibilityLabel={`Profit bridge: $142.7M revenue to $${STEPS[STEPS.length - 1].end.toFixed(1)}M net profit`}
                  accessibilityValue={{ text: hovered() == null ? undefined : `${step().name}: ${step().end.toFixed(1)}` }}
                  appearGeneration="bridge"
                  motion={{ appear: { duration: 800, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } }}
                  crossLineClass="w-full h-full"
                  crossLineStyle={{ backgroundColor: tint(ui().text, 0.05) }}
                  onHoverChange={({ index }) => {
                    hovered.set(index);
                    log(index == null ? "null: left the chart" : `${index}: ${STEPS[index].name}, ${STEPS[index].start} → ${STEPS[index].end}`);
                  }}
                  style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
                >
                  <Grid y={[40, 80, 120]} style={{ stroke: tint(ui().text, 0.08) }} />
                  <Grid y={[0]} style={{ stroke: tint(ui().text, 0.2) }} />
                  {/* From each row's start to its end, colored by its kind. */}
                  <Bar
                    value="end"
                    base="start"
                    color="kind"
                    cornerRadii={2}
                    stagger={0.5}
                    inactiveOpacity={0.4}
                    label={(d: Step) => (d.kind === "total" ? d.end.toFixed(1) : signed(d.end - d.start))}
                    labelStyle={labelStyle()}
                  />
                  <PlotAxis placement="right" tickValues={[0, 40, 80, 120]} labelStyle={{ width: 36, paddingLeft: 8, textAlign: "right", fontSize: 11, color: ui().muted, ...num }} />
                  <PlotAxis format={(i) => STEPS[i].name} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
                </Plot>
              </>
            }
          >
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>NSH Labs</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                  {`Day ${candle().day + 1} · O ${candle().open.toFixed(2)} H ${candle().high.toFixed(2)} L ${candle().low.toFixed(2)}`}
                </Text>
              </View>
              <Text style={{ fontSize: 20, fontWeight: "600", color: tone(candle().dir), ...num }}>{candle().close.toFixed(2)}</Text>
            </View>
            <Plot
              data={CANDLES}
              xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15 }}
              yScale={{ type: "linear", domain: [LOW, HIGH] }}
              colorScale={{ type: "ordinal", domain: ["up", "down"], range: [ui().up, ui().down] }}
              accessibilityLabel={`NSH Labs, ${CANDLES.length} days: from ${CANDLES[0].open.toFixed(2)} to ${CANDLES[CANDLES.length - 1].close.toFixed(2)}`}
              accessibilityValue={{ text: hovered() == null ? undefined : `Day ${candle().day + 1}: open ${candle().open}, high ${candle().high}, low ${candle().low}, close ${candle().close}` }}
              appearGeneration="candles"
              motion={{ appear: { duration: 800, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } }}
              crossLineClass="w-full h-full"
              crossLineStyle={{ backgroundColor: tint(ui().text, 0.06) }}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const c = index == null ? undefined : CANDLES[index];
                log(c == null ? "null: left the chart" : `${index}: ${c.low} – ${c.high}, ${c.open} → ${c.close}`);
              }}
              style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
            >
              <Grid y={[LOW + (HIGH - LOW) / 3, LOW + ((HIGH - LOW) * 2) / 3]} style={{ stroke: tint(ui().text, 0.08) }} />
              {/* The wick, low to high, under the body, open to close. */}
              <Rule value="high" base="low" color="dir" stagger={0.5} inactiveOpacity={0.3} style={{ strokeWidth: 1 }} />
              <Bar value="close" base="open" color="dir" cornerRadii={1} stagger={0.5} inactiveOpacity={0.3} />
              <PlotAxis
                placement="right"
                tickValues={[LOW + (HIGH - LOW) / 3, LOW + ((HIGH - LOW) * 2) / 3]}
                format={(v) => v.toFixed(0)}
                labelStyle={{ paddingLeft: 8, fontSize: 11, color: ui().muted, ...num }}
              />
              <PlotAxis tickValues={[0, 11, 23]} format={(i) => `Day ${i + 1}`} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
            </Plot>
          </If>
        </View>
      </Split>
    </View>
  );
}

mount(App);

With base a field or a function, each bar spans two values of its row: a waterfall’s step from the running total before it to the one after, a candle’s body from its open to its close, a task from its start to its end. A bar grows from its own base as it appears, and its label sits at its value end, above a bar that rises and below one that falls.

const bridge = [
  { step: "Revenue", from: 0, to: 120, kind: "total" },
  { step: "Costs", from: 120, to: 72, kind: "down" },
  { step: "Other", from: 72, to: 81, kind: "up" },
  { step: "Profit", from: 0, to: 81, kind: "total" },
];

<Plot
  data={bridge}
  xScale={{ type: "band", paddingInner: 0.3 }}
  colorScale={{ type: "ordinal", domain: ["total", "up", "down"], range: ["#64748b", "#16a34a", "#dc2626"] }}
  class="h-40"
>
  <Bar value="to" base="from" color="kind" label={(d) => `${d.to - d.from}`} labelClass="text-xs pb-1" />
</Plot>

A funnel is bars across, centered: base={(d) => -d.users / 2} and value={(d) => d.users / 2} on a band y scale.

#Rule

A Rule draws a straight line per row along the value axis, from base to the row’s value: in the middle of the row’s band on a band scale (across the plot on a band y scale), at the row’s place along the x axis on a point or linear one. A candle’s wick, a box plot’s whisker, an error bar and a lollipop’s stem are rules; a candlestick is a Bar over a Rule:

import { Bar, Plot, Rule } from "nsh/base/plot";

<Plot
  data={days}
  xScale={{ type: "band", paddingInner: 0.4 }}
  colorScale={{ type: "ordinal", domain: ["up", "down"], range: ["#16a34a", "#dc2626"] }}
  class="h-48"
>
  <Rule value="high" base="low" color={(d) => (d.close >= d.open ? "up" : "down")} />
  <Bar value="close" base="open" color={(d) => (d.close >= d.open ? "up" : "down")} />
</Plot>
PropTypeDefaultDescription
valuestring | (d, index) => numberthe plot’s yWhere each line ends. A row without a value has no line.
basenumber | string | (d, index) => number0Where each line starts: one value for every row (a lollipop’s stem from the zero line), or a field or function giving each row its own (a candle’s low). It counts in the domain.
scale"y" | "y2""y"The y scale its values go along (Dual axes).
staggernumber0With the plot’s appear, the lines start one after another, row by row, over this share of it (0 to 0.95), each growing from its start for the rest.
inactiveOpacitynumbernoneThe opacity of its lines away from the row under the pointer while the pointer is over the plot, as Bar’s.
colorstring | (d, index) => stringnoneEach line’s category, its color from the plot’s colorScale: the line’s stroke where nothing sets one.

A rule strokes with its stroke (stroke-*, strokeWidth, strokeDasharray; inherited from the plot as CSS inherits it), currentColor where none is set, and shows no hover dot. Its values and base make the value scale’s domain when the plot has none; it grows from its start with the plot’s appear and moves with the scales’ domains, on the platform’s animator.

#Dot

el-plot-dots.tsx
el-plot-dots.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Dot, Grid, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Dots placed by two values of each row: a `Dot` per row at its `x` on a
// linear x scale and its `value` up the y axis. Its `color` names each
// row's category, which the plot's ordinal `colorScale` turns into the
// dot's fill; a stroke in the card's color rings each dot, so dots that
// overlap stay apart.
//
// The scatter is one session a dot, minutes in the app against trades
// placed, colored by platform. The bubbles are countries by income and
// life expectancy, each bubble's radius from its population through a
// square-root `rScale`, so its area is in proportion to it; the bigger
// bubbles draw first and the smaller stay in sight over them. The dots
// grow in one after another, the dot under the pointer (or the arrow
// keys) is ringed by its hover dot and read out in the header, and the
// others fade.
type Session = { minutes: number; trades: number; platform: "iOS" | "Android" | "Web" };
type Country = { name: string; gdp: number; life: number; pop: number; region: Region };
type Region = "Asia" | "Africa" | "Americas" | "Europe" | "Oceania";

/** A repeatable stream of numbers in [0, 1), so every visit shows the same chart. */
const random = (seed: number) => {
  let s = seed;
  return () => {
    s = (s * 16807) % 2147483647;
    return (s % 10000) / 10000;
  };
};
const PLATFORMS = ["iOS", "Android", "Web"] as const;
/** Fifteen sessions per platform around its own habits, in the order of their length. */
const SESSIONS: Session[] = (() => {
  const r = random(23);
  const centers = { iOS: [14, 6], Android: [9, 4], Web: [24, 10] } as const;
  return PLATFORMS.flatMap((platform) =>
    Array.from({ length: 15 }, () => {
      const [m, t] = centers[platform];
      const minutes = Math.max(1, Math.round(m + (r() - 0.5) * m * 1.1));
      const trades = Math.max(0, Math.round(t * (minutes / m) + (r() - 0.5) * 5));
      return { minutes, trades, platform };
    }),
  ).sort((a, b) => a.minutes - b.minutes);
})();

/** Countries' GDP per person (thousands of dollars), life expectancy (years) and population (millions), in the order of income. */
const COUNTRIES: Country[] = (
  [
    ["Ethiopia", 1.3, 66.6, 127, "Africa"],
    ["Nigeria", 1.6, 53.6, 224, "Africa"],
    ["India", 2.5, 70.8, 1430, "Asia"],
    ["Egypt", 3.5, 70.4, 113, "Africa"],
    ["Vietnam", 4.3, 74.6, 99, "Asia"],
    ["Indonesia", 4.9, 71.3, 278, "Asia"],
    ["South Africa", 6.0, 63.2, 60, "Africa"],
    ["Brazil", 10.0, 75.8, 216, "Americas"],
    ["China", 12.6, 78.6, 1410, "Asia"],
    ["Mexico", 13.9, 74.8, 129, "Americas"],
    ["South Korea", 33.1, 83.7, 52, "Asia"],
    ["Japan", 33.8, 84.7, 124, "Asia"],
    ["France", 44.5, 82.8, 68, "Europe"],
    ["United Kingdom", 48.9, 81.3, 68, "Europe"],
    ["Germany", 52.7, 81.4, 84, "Europe"],
    ["Australia", 64.7, 83.5, 27, "Oceania"],
    ["United States", 80.4, 77.5, 335, "Americas"],
  ] as const
).map(([name, gdp, life, pop, region]) => ({ name, gdp, life, pop, region }));
const REGIONS: Region[] = ["Asia", "Africa", "Americas", "Europe", "Oceania"];

const num = { fontVariant: ["tabular-nums" as const] };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;
const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });

function App() {
  const chart = signal<"scatter" | "bubbles">("scatter");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const height = desktop ? 220 : 200;
  const platformColors = () => [ui().accent, ui().info, ui().warn];
  const regionColors = () => [ui().info, ui().warn, ui().accent, ui().violet, ui().muted];
  const session = () => (hovered() == null || chart() !== "scatter" ? undefined : SESSIONS[hovered()!]);
  const country = () => (hovered() == null || chart() !== "bubbles" ? undefined : COUNTRIES[hovered()!]);
  // A ring around the dot under the pointer, in the text's color, a little out from its edge.
  const ring = () => ({ outlineWidth: 1.5, outlineOffset: 1.5, outlineColor: ui().text });
  const key = (color: string, label: string) => (
    <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
      <View style={{ width: 9, height: 9, borderRadius: 4.5, backgroundColor: color }} />
      <Text style={{ fontSize: 12, color: ui().muted }}>{label}</Text>
    </View>
  );
  const motion = { appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } } as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot dots</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over a dot to read it" : chart() === "scatter" ? "Each dot is a session, colored by platform" : "Each bubble's area is a country's population"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["scatter", "bubbles"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>
                {chart() === "scatter" ? `Dot x minutes · value trades · ${SESSIONS.length} rows` : `Dot r pop · rScale sqrt · ${COUNTRIES.length} rows`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <If
            when={chart() === "scatter"}
            else={
              <>
                <View class="flex-row items-baseline gap-3">
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{country()?.name ?? "Income and lifespan"}</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                      {country() ? `${country()!.region} · ${country()!.pop.toLocaleString("en-US")}M people` : "GDP per person against life expectancy"}
                    </Text>
                  </View>
                  <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{country() ? `${country()!.life.toFixed(1)} yrs` : ""}</Text>
                </View>
                <View class="flex-row flex-wrap gap-x-3 gap-y-1">{REGIONS.map((r, i) => key(regionColors()[i], r))}</View>
                <Plot
                  data={COUNTRIES}
                  x="gdp"
                  xScale={{ type: "linear", domain: [-10, 95] }}
                  yScale={{ type: "linear", domain: [48, 90] }}
                  colorScale={{ type: "ordinal", domain: REGIONS, range: regionColors().map((c) => tint(c, 0.75)) }}
                  accessibilityLabel={`${COUNTRIES.length} countries by GDP per person and life expectancy, sized by population`}
                  accessibilityValue={{ text: country() && `${country()!.name}: $${country()!.gdp}k per person, ${country()!.life} years` }}
                  appearGeneration="bubbles"
                  motion={motion}
                  onHoverChange={({ index }) => {
                    hovered.set(index);
                    const c = index == null ? undefined : COUNTRIES[index];
                    log(c == null ? "null: left the chart" : `${index}: ${c.name}, $${c.gdp}k, ${c.life} yrs, ${c.pop}M`);
                  }}
                  style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
                >
                  <Grid x={[20, 40, 60, 80]} y={[60, 70, 80]} style={{ stroke: tint(ui().text, 0.08) }} />
                  {/* A bubble's area is its population: radii from 3 to the largest's, over 0 to the most people. */}
                  <Dot
                    value="life"
                    r="pop"
                    rScale={{ type: "sqrt", range: [3, desktop ? 26 : 18] }}
                    color="region"
                    stagger={0.5}
                    inactiveOpacity={0.25}
                    style={{ stroke: ui().card.backgroundColor, strokeWidth: 1 }}
                    hoverDotClass="rounded-full"
                    hoverDotStyle={ring()}
                  />
                  <PlotAxis placement="right" tickValues={[60, 70, 80]} format={(v) => `${v}`} labelStyle={{ ...axisLabel(), width: 36, paddingLeft: 8, textAlign: "right" }} />
                  <PlotAxis tickValues={[0, 20, 40, 60, 80]} format={(v) => `$${v}k`} labelStyle={{ ...axisLabel(), lineHeight: 14, paddingTop: 12 }} />
                </Plot>
              </>
            }
          >
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{session() ? `${session()!.platform} session` : "Sessions"}</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                  {session() ? `${session()!.minutes} minutes in the app` : `${SESSIONS.length} sessions · minutes against trades`}
                </Text>
              </View>
              <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{session() ? `${session()!.trades} trades` : ""}</Text>
            </View>
            <View class="flex-row flex-wrap gap-x-3 gap-y-1">{PLATFORMS.map((p, i) => key(platformColors()[i], p))}</View>
            <Plot
              data={SESSIONS}
              x="minutes"
              xScale={{ type: "linear", domain: [0, 42] }}
              yScale={{ type: "linear", domain: [0, 22] }}
              colorScale={{ type: "ordinal", domain: [...PLATFORMS], range: platformColors() }}
              accessibilityLabel={`${SESSIONS.length} sessions by minutes in the app and trades placed, longest on the web`}
              accessibilityValue={{ text: session() && `${session()!.platform}: ${session()!.minutes} minutes, ${session()!.trades} trades` }}
              appearGeneration="scatter"
              motion={motion}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const s = index == null ? undefined : SESSIONS[index];
                log(s == null ? "null: left the chart" : `${index}: ${s.platform}, ${s.minutes} min, ${s.trades} trades`);
              }}
              style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
            >
              <Grid x={[10, 20, 30, 40]} y={[5, 10, 15, 20]} style={{ stroke: tint(ui().text, 0.08) }} />
              <Grid y={[0]} style={{ stroke: tint(ui().text, 0.2) }} />
              <Dot
                value="trades"
                r={desktop ? 4 : 4.5}
                color="platform"
                stagger={0.6}
                inactiveOpacity={0.3}
                style={{ stroke: ui().card.backgroundColor, strokeWidth: 1.5 }}
                hoverDotClass="rounded-full"
                hoverDotStyle={ring()}
              />
              <PlotAxis placement="right" tickValues={[5, 10, 15, 20]} labelStyle={{ ...axisLabel(), width: 36, paddingLeft: 8, textAlign: "right" }} />
              <PlotAxis tickValues={[0, 10, 20, 30, 40]} format={(v) => `${v} min`} labelStyle={{ ...axisLabel(), lineHeight: 14, paddingTop: 12 }} />
            </Plot>
          </If>
        </View>
      </Split>
    </View>
  );
}

mount(App);

A Dot draws a dot per row where two values of the row meet: its x along a linear x scale and its value up the y axis make a scatter plot; on a band x scale each dot sits in the middle of its row’s band; across a band y scale (the x scale linear), its value runs along x and its row’s band down the plot, a dot plot. Its radius r is one number for every dot, or a value of each row that rScale turns into a radius, a bubble chart. A row without a value has no dot.

import { Dot, Grid, Plot } from "nsh/base/plot";
import { token } from "nsh";

const sessions = [
  { minutes: 12, trades: 5, platform: "iOS" },
  { minutes: 8, trades: 3, platform: "Android" },
  { minutes: 26, trades: 11, platform: "Web" },
  { minutes: 17, trades: 8, platform: "iOS" },
];

<Plot
  data={sessions}
  x="minutes"
  xScale={{ type: "linear", domain: [0, 40] }}
  yScale={{ type: "linear", domain: [0, 20] }}
  colorScale={{ type: "ordinal", domain: ["iOS", "Android", "Web"], range: [token("chart-1"), token("chart-2"), token("chart-3")] }}
  class="h-48"
>
  <Grid x={[10, 20, 30]} y={[5, 10, 15]} class="stroke-border" />
  <Dot
    value="trades"
    r={4}
    color="platform"
    inactiveOpacity={0.3}
    class="stroke-card stroke-2"
    hoverDotClass="rounded-full outline-2 outline-offset-1 outline-foreground"
  />
</Plot>

With r a field or a function, each row’s value sizes its dot. rScale={{ type: "sqrt", range: [min, max] }} maps the square roots of the values onto radii in points, as d3’s scaleSqrt does, so with a range from 0 a bubble’s area is in proportion to its value; a range from more than 0 keeps the smallest bubbles in sight. The domain is 0 to the largest value unless the scale names one. The bigger dots draw first, so the smaller ones stay in sight over them:

<Plot
  data={countries}
  x="gdp"
  xScale={{ type: "linear", domain: [-10, 95] }}
  yScale={{ type: "linear", domain: [48, 90] }}
  colorScale={{ type: "ordinal", range: [token("chart-1"), token("chart-2"), token("chart-3"), token("chart-4")] }}
  class="h-56"
>
  <Dot value="life" r="population" rScale={{ type: "sqrt", range: [3, 24] }} color="region" class="stroke-card" />
</Plot>

A dot’s values make the value scale’s domain when the plot has none, without reaching for 0, so a scatter’s axes fit its points. Give the plot domains with some room around the values when the dots are big: a bubble at the edge of the domain is drawn across the plot’s edge.

Two marks in the same bands make the charts that compare two values of a row: a lollipop is a Rule from 0 under a Dot at its value, and a dumbbell is a Rule from one value to the other with a Dot at each end, across a band y scale:

<Plot data={stocks} yScale={{ type: "band", paddingInner: 0.3 }} xScale={{ type: "linear", domain: [-50, 80] }} class="h-48">
  <Rule value="high" base="low" class="stroke-border stroke-2" />
  <Dot value="before" r={3.5} class="fill-card stroke-muted-foreground" hoverDotClass="hidden" />
  <Dot value="now" r={5} class="fill-chart-1 stroke-card stroke-2" hitRadius={24} />
</Plot>
PropTypeDefaultDescription
valuestring | (d, index) => numberthe plot’s yWhere each dot goes along the value axis: up the y axis, or along x across a band y scale. A row without a value has no dot.
rnumber | string | (d, index) => number3The dots’ radius in points, or a field or function giving each row a value that rScale makes a radius (the value is the radius without one). A row whose value is missing has no dot.
rScale{ type: "sqrt"; domain?: [number, number]; range: [number, number] }noneHow the values of r become radii: their square roots over the domain (0 to the largest value without one) onto the range of radii, so a range from 0 makes each dot’s area in proportion to its value. Values outside the domain take the range’s ends.
staggernumber0With the plot’s appear, the dots start growing one after another, row by row, over this share of it (0 to 0.95); 0 grows them together.
inactiveOpacitynumbernoneThe opacity of its other dots while one is under the pointer or the keys: they fade in and out with the hover.
colorstring | (d, index) => stringnoneEach dot’s category, its color from the plot’s colorScale: the dot’s fill where nothing sets one (Colors by category).
hitRadiusnumber8How far past a dot’s edge the pointer still finds it: of the dots within this distance, the pointer finds the one whose center is nearest to it (Voronoi’s cells), so a small bubble over a big one is found around its own center. A dot plot’s row is found from anywhere along it with a radius as large as a row.
scale"y" | "y2""y"The y scale its values go along (Dual axes); dots across a band y scale put their values along x.
hoverDotClass, hoverDotStylestring, StyleProp<ViewStyle>the plot’s dotClass, dotStyleThe hoverDot part: a view as wide and tall as the dot under the pointer, centered on it. rounded-full outline-2 outline-offset-1 rings it; hidden shows none for this mark.

A dot fills with its fill (fill-*; inherited from the plot as CSS inherits it), currentColor where none is set (its category’s color with color), and strokes with its stroke where one is set (stroke-*, strokeWidth): a ring in the card’s color keeps overlapping dots apart. Over a plot with dots, the plot reports the row of the dot whose center is nearest to the pointer (null away from every dot), the keys step through the rows in their order (sort the rows by x, and the keys walk the dots from left to right), and the hoverDot part marks the dot. With the plot’s appear, the dots grow from nothing; with motion.update, they move to their new values and sizes, a new row’s dot grows in and the dot of a row that is gone shrinks away; all on the platform’s animator, with no mark drawn again.

#Cell

el-plot-cells.tsx
el-plot-cells.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Cell, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Heatmaps: a `Cell` per row in the band of its `x` category across and
// of its `y` category down, both band scales, colored by its `value`
// through the plot's `colorScale`.
//
// The returns are a stock's months: the months across in the order of the
// x scale's `domain` (this year's months to come stay blank), the years
// down in the order the rows bring them, a loss red and a gain green along
// a diverging ramp about 0, its domain the largest move either way, each
// return written in its cell, white on the dark ones (`data-dark:`). The
// activity is a market's trades per hour of a week along a sequential
// ramp from the card's line color to the accent; the hour of its lunch
// break has no rows and stays blank. The cells grow in one after another,
// the cell under the pointer (or the arrow keys, which step across the
// grid) is ringed and read out in the header, and the others fade.
type Month = { year: string; month: string; ret: number };
type Hour = { day: string; hour: string; trades: number };

/** A repeatable stream of numbers in [0, 1), so every visit shows the same chart. */
const random = (seed: number) => {
  let s = seed;
  return () => {
    s = (s * 16807) % 2147483647;
    return (s % 10000) / 10000;
  };
};
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Four years of a stock's monthly returns in percent, this year's up to October. */
const RETURNS: Month[] = (() => {
  const r = random(41);
  const drift: Record<string, number> = { "2023": 2.4, "2024": 1.9, "2025": 0.6, "2026": 1.2 };
  return Object.keys(drift).flatMap((year) =>
    MONTHS.slice(0, year === "2026" ? 10 : 12).map((month) => ({ year, month, ret: Math.round((drift[year] + (r() - 0.5) * 12) * 10) / 10 })),
  );
})();
const HOURS = ["9:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"];
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
/** A week of a market's trades per hour, in thousands: busy at the open and the close, closed from 12:00 to 13:00. */
const ACTIVITY: Hour[] = (() => {
  const r = random(17);
  const shape = [1, 0.62, 0.5, null, 0.58, 0.55, 0.86];
  return DAYS.flatMap((day, d) =>
    HOURS.flatMap((hour, h) => (shape[h] == null ? [] : [{ day, hour, trades: Math.round(40 * shape[h]! * (0.92 + d * 0.04) * (0.9 + r() * 0.2) * 10) / 10 }])),
  );
})();

const num = { fontVariant: ["tabular-nums" as const] };
const signed = (v: number) => `${v < 0 ? "−" : "+"}${Math.abs(v).toFixed(1)}%`;
const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });
/** The largest move either way: the ends of the diverging scale's own domain. */
const REACH = Math.max(...RETURNS.map((m) => Math.abs(m.ret)));

function App() {
  const chart = signal<"returns" | "activity">("returns");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const month = () => (hovered() == null || chart() !== "returns" ? undefined : RETURNS[hovered()!]);
  const hour = () => (hovered() == null || chart() !== "activity" ? undefined : ACTIVITY[hovered()!]);
  // A ring around the cell under the pointer, in the text's color, a little out from its edge.
  const ring = () => ({ outlineWidth: 1.5, outlineOffset: 1.5, outlineColor: ui().text });
  const returnsRamp = () => [ui().down, ui().line, ui().up];
  const activityRamp = () => [ui().line, ui().accent];
  const key = (colors: string[], labels: string[]) => (
    <View style={{ gap: 4 }}>
      <View style={{ height: 6, borderRadius: 3, backgroundImage: `linear-gradient(to right, ${colors.join(", ")})` }} />
      <View class="flex-row justify-between">
        {labels.map((l) => (
          <Text style={{ fontSize: 11, color: ui().muted, ...num }}>{l}</Text>
        ))}
      </View>
    </View>
  );
  const motion = { appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 380, damping: 34 } } as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot cells</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over a cell to read it" : chart() === "returns" ? "Each cell is a month's return" : "Each cell is an hour's trades"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["returns", "activity"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>
                {chart() === "returns" ? `colorScale diverging · ${RETURNS.length} rows` : `colorScale sequential · ${ACTIVITY.length} rows`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <If
            when={chart() === "returns"}
            else={
              <>
                <View class="flex-row items-baseline gap-3">
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{hour() ? `${hour()!.day} ${hour()!.hour}` : "Trades per hour"}</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted }}>{hour() ? "thousand trades" : "A week, closed from 12:00 to 13:00"}</Text>
                  </View>
                  <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{hour() ? `${hour()!.trades.toFixed(1)}k` : ""}</Text>
                </View>
                {key(activityRamp(), ["Fewer", "More"])}
                <Plot
                  data={ACTIVITY}
                  xScale={{ type: "band", domain: HOURS, paddingInner: 0.12 }}
                  yScale={{ type: "band", domain: DAYS, paddingInner: 0.12 }}
                  colorScale={{ type: "sequential", range: activityRamp() }}
                  accessibilityLabel="Trades per hour over a week, busiest at the open"
                  accessibilityValue={{ text: hour() && `${hour()!.day} ${hour()!.hour}: ${hour()!.trades} thousand trades` }}
                  appearGeneration="activity"
                  motion={motion}
                  onHoverChange={({ index }) => {
                    hovered.set(index);
                    const h = index == null ? undefined : ACTIVITY[index];
                    log(h == null ? "null: left the chart" : `${index}: ${h.day} ${h.hour}, ${h.trades}k`);
                  }}
                  style={{ height: DAYS.length * 30, marginLeft: 36, marginTop: 20 }}
                >
                  <Cell x="hour" y="day" value="trades" cornerRadii={4} stagger={0.6} inactiveOpacity={0.4} hoverCellClass="rounded-[5px]" hoverCellStyle={ring()} />
                  <PlotAxis placement="top" labelStyle={{ ...axisLabel(), lineHeight: 14, paddingBottom: 6 }} />
                  <PlotAxis placement="left" labelStyle={{ ...axisLabel(), width: 36, paddingRight: 8, textAlign: "right" }} />
                </Plot>
              </>
            }
          >
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{month() ? `${month()!.month} ${month()!.year}` : "Monthly returns"}</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted }}>{month() ? "the month's return" : "Red for a loss, green for a gain"}</Text>
              </View>
              <Text style={{ fontSize: 20, fontWeight: "600", color: month() ? (month()!.ret < 0 ? ui().down : ui().up) : ui().text, ...num }}>
                {month() ? signed(month()!.ret) : ""}
              </Text>
            </View>
            {key(returnsRamp(), [signed(-REACH), "0", signed(REACH)])}
            <Plot
              data={RETURNS}
              xScale={{ type: "band", domain: MONTHS, paddingInner: 0.1 }}
              yScale={{ type: "band", paddingInner: 0.12 }}
              colorScale={{ type: "diverging", range: returnsRamp() }}
              accessibilityLabel={`Monthly returns since 2023, ${RETURNS.filter((m) => m.ret > 0).length} of ${RETURNS.length} months up`}
              accessibilityValue={{ text: month() && `${month()!.month} ${month()!.year}: ${signed(month()!.ret)}` }}
              appearGeneration="returns"
              motion={motion}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const m = index == null ? undefined : RETURNS[index];
                log(m == null ? "null: left the chart" : `${index}: ${m.month} ${m.year}, ${signed(m.ret)}`);
              }}
              style={{ height: 4 * 30, marginLeft: 36, marginTop: 20 }}
            >
              <Cell
                x="month"
                y="year"
                value="ret"
                cornerRadii={3}
                stagger={0.6}
                inactiveOpacity={0.4}
                label={(d: Month) => (d.ret < 0 ? `−${(-d.ret).toFixed(1)}` : d.ret.toFixed(1))}
                labelClass="text-black/70 data-dark:text-white/90"
                labelStyle={{ fontSize: 10, ...num }}
                hoverCellClass="rounded-[4px]"
                hoverCellStyle={ring()}
              />
              <PlotAxis placement="top" labelStyle={{ ...axisLabel(), lineHeight: 14, paddingBottom: 6 }} />
              <PlotAxis placement="left" labelStyle={{ ...axisLabel(), width: 36, paddingRight: 8, textAlign: "right" }} />
            </Plot>
            <Text style={{ fontSize: 12, color: tint(ui().text, 0.5) }}>Each return shows where its cell has room for it.</Text>
          </If>
        </View>
      </Split>
    </View>
  );
}

mount(App);

A Cell draws a rectangle per row in two bands at once: the band of its x category along a band x scale and the band of its y category along a band y scale, so the rows make a grid, a heatmap. A correlation matrix, a calendar of monthly returns and a week of trades per hour are cells. Each cell fills its two bands, so the scales’ paddingInner and paddingOuter are the gaps between and around the cells. Its color is its value along the plot’s colorScale, a ramp of colors (Colors along a ramp), or its color category’s through an ordinal one. Cells sit on the plot’s x and y scales.

import { Cell, Plot, PlotAxis } from "nsh/base/plot";
import { token } from "nsh";

const months = [
  { year: "2025", month: "Jan", ret: 2.7 },
  { year: "2025", month: "Feb", ret: -1.4 },
  { year: "2025", month: "Mar", ret: -5.8 },
  { year: "2026", month: "Jan", ret: 1.9 },
  { year: "2026", month: "Feb", ret: 3.2 },
];

<Plot
  data={months}
  xScale={{ type: "band", domain: ["Jan", "Feb", "Mar"], paddingInner: 0.08 }}
  yScale={{ type: "band", paddingInner: 0.08 }}
  colorScale={{ type: "diverging", range: [token("destructive"), token("muted"), token("chart-1")] }}
  class="ml-10 mt-6 h-24"
>
  <Cell
    x="month"
    y="year"
    value="ret"
    cornerRadii={3}
    inactiveOpacity={0.4}
    label={(d) => d.ret.toFixed(1)}
    labelClass="text-xs tabular-nums text-black/70 data-dark:text-white"
    hoverCellClass="rounded-sm outline-2 outline-offset-1 outline-foreground"
  />
  <PlotAxis placement="top" labelClass="pb-1.5 text-xs text-muted-foreground" />
  <PlotAxis placement="left" labelClass="w-10 pr-2 text-right text-xs text-muted-foreground" />
</Plot>

A band scale’s domain names its bands’ categories in order, as d3’s scaleBand().domain() does: the months of a calendar from January to December, each with its band whether its rows have come or not, so March 2026 above is a blank place in the grid. Without a domain, the bands are the categories the cells name along the axis, in the order the rows bring them (2025 above 2026). A PlotAxis along a band scale of categories writes each band’s category, with no format.

A correlation matrix names the same holdings along both axes, its domain the coefficients’ whole range, so a coefficient of 0 takes the ramp’s middle whatever the holdings:

const tickers = ["NVDA", "AMD", "MSFT", "GLD"];

<Plot
  data={pairs}
  xScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
  yScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
  colorScale={{ type: "diverging", domain: [-1, 1], range: [token("chart-2"), token("muted"), token("chart-1")] }}
  class="aspect-square"
>
  <Cell x="b" y="a" value="r" label={(d) => d.r.toFixed(2)} labelClass="text-xs text-black/70 data-dark:text-white" />
</Plot>
PropTypeDefaultDescription
xstring | (d, index) => stringnoneThe column of each row: a field (its text, or its number) or a function returning the category. The row’s cell goes in that category’s band of the x scale; a row without one has no cell.
ystring | (d, index) => stringnoneThe line of each row, as x along the y scale.
valuestring | (d, index) => numbernoneThe number of each cell, which a sequential or diverging colorScale makes its color. A row without one takes the scale’s unknown color, and without that has no cell.
colorstring | (d, index) => stringnoneEach cell’s category, its color from an ordinal colorScale (Colors by category).
cornerRadiinumber | { topLeft?, topRight?, bottomRight?, bottomLeft? }0The radius of each corner of its cells.
staggernumber0With the plot’s appear, the cells start growing one after another, row by row, over this share of it (0 to 0.95), each from its middle for the rest; 0 grows them together.
inactiveOpacitynumbernoneThe opacity of its other cells and their labels while one is under the pointer or the keys: they fade in and out with the hover.
labelstring | (d, index) => stringnoneThe label part: a text in the middle of each cell, a field (its text, or its number) or a function returning the text the app formats. Each label shows where its laid-out size fits inside its cell, so a narrow plot shows the colors alone.
labelClass, labelStylestring, StyleProp<TextStyle>noneThe labels’ font, size and color. A label on a cell whose colorScale color is dark has the attribute data-dark (where light text reads better), so data-dark:text-white writes it light there.
hoverCellClass, hoverCellStylestring, StyleProp<ViewStyle>noneThe hoverCell part: a view as big as the cell under the pointer or the keys, over it; rounded-sm outline-2 outline-foreground rings it. It moves with the pointer in the same frame and glides on the pointer spring when the keys move it.

A cell fills with its color of the plot’s colorScale where the app sets no fill (fill-* classes and styles come first, inherited from the plot as CSS inherits them), currentColor where nothing sets one, and strokes with its stroke where one is set. Over a plot with cells, the plot reports the row of the cell under the pointer, and across the gap between two cells the nearer one; the arrow keys step across the grid on the screen (Keyboard), and screen readers through the rows in their order. With the plot’s appear, the cells grow from their middles; with motion.update, a cell’s color glides to its new one, a new row’s cell grows in, the cell of a row that is gone shrinks away, and cells move to their new bands; all on the platform’s animator, with no mark drawn again.

#Pie and Arc

A Pie splits a turn into a slice per row, each its share of the rows’ values, clockwise from 12 o’clock in the rows’ order, around the plot’s center. An innerRadius makes it a donut. Each slice is a canvas of its own, the slice part, so each paints with its own classes: sliceClass is the same classes on every slice, or a function of the slice’s row that gives each its own. Over a pie, the plot reports the row of the slice under the pointer (none in the hole or outside the ring), its tooltip follows the pointer, and that slice grows by hoverLift while the others fade by hoverDim, on the platform’s animator with the plot’s motion. An Arc draws one sector between two angles: a gauge’s track and its value, or a ring behind a pie.

el-plot-pie.tsx
el-plot-pie.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Arc, Pie, Plot } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui, wide } from "./palette";

// A donut built from the plot's parts: a `Pie` of the rows' shares, each
// slice in a series color of the theme (`fill-chart-*`, a class per row),
// 2-point gaps between them (the pad is as wide at the hole as at the rim),
// the largest share first, clockwise from 12 o'clock. The slice under the
// pointer lifts and the others fade, natively; its value is in the hole and
// its legend row stands out while the others fade too. The legend's values
// and shares are tabular and at the trailing edge. A breakdown with nothing
// in it keeps a faint ring, an `Arc`, so the chart still reads as one.
type Part = { name: string; revenue: number };
type Breakdown = "Region" | "Channel" | "None";
type Shape = "donut" | "pie";

const BREAKDOWNS: Record<Breakdown, Part[]> = {
  Region: [
    { name: "North America", revenue: 482 },
    { name: "EMEA", revenue: 312 },
    { name: "APAC", revenue: 268 },
    { name: "Latin America", revenue: 104 },
    { name: "Other", revenue: 74 },
  ],
  Channel: [
    { name: "Direct", revenue: 540 },
    { name: "Partners", revenue: 330 },
    { name: "Marketplace", revenue: 250 },
    { name: "Resellers", revenue: 120 },
  ],
  None: [
    { name: "North America", revenue: 0 },
    { name: "EMEA", revenue: 0 },
  ],
};

/** The theme's series colors, as a slice's fill and a legend key's
 * background. */
const FILLS = ["fill-chart-1", "fill-chart-2", "fill-chart-3", "fill-chart-4", "fill-chart-5"];
const KEYS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"];

const money = (k: number) => (k >= 1000 ? `$${(k / 1000).toFixed(2)}M` : `$${k}k`);
const percent = (share: number) => `${(share * 100).toFixed(1)}%`;
const num = { fontVariant: ["tabular-nums" as const], textAlign: "right" as const };

function App() {
  const breakdown = signal<Breakdown>("Region");
  const shape = signal<Shape>("donut");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const parts = () => BREAKDOWNS[breakdown()];
  const total = () => parts().reduce((sum, p) => sum + p.revenue, 0);
  const shown = () => {
    const at = hovered();
    return at == null ? undefined : parts()[at];
  };
  const share = (p: Part) => (total() > 0 ? p.revenue / total() : 0);
  // The ring's size: its rim is half of it, the gaps 2 points wide there.
  const ring = desktop ? 200 : 184;
  const donut = () => shape() === "donut";
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>pie</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over a slice to read it" : "Pick a breakdown to draw other slices"}
      </Text>

      <Split
        width={520}
        inspector={
          <>
            <Panel title="shape · data">
              <Segmented options={["donut", "pie"] as const} value={shape()} onChange={(s) => shape.set(s)} />
              <Segmented
                options={["Region", "Channel", "None"] as const}
                value={breakdown()}
                onChange={(b) => (breakdown.set(b), hovered.set(null))}
              />
              <Text style={{ fontSize: 13, color: ui().text }}>
                {`${parts().filter((p) => p.revenue > 0).length} slices · ${money(total())} in all`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 16 }}>
          <View class="flex-row items-baseline gap-3">
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Revenue</Text>
              <Text style={{ fontSize: size.detail, color: ui().muted }}>{`Q3 2026 · by ${breakdown() === "Channel" ? "channel" : "region"}`}</Text>
            </View>
            <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{money(total())}</Text>
          </View>

          <View style={{ flexDirection: wide() ? "row" : "column", alignItems: "center", gap: wide() ? 28 : 18 }}>
            <Plot
              data={parts()}
              accessibilityLabel={
                total() > 0
                  ? `Revenue by ${breakdown().toLowerCase()}, ${money(total())} in all; largest ${parts()[0].name} at ${percent(share(parts()[0]))}`
                  : "Revenue: nothing this quarter"
              }
              accessibilityValue={{ text: shown() && `${shown()!.name}: ${money(shown()!.revenue)}, ${percent(share(shown()!))}` }}
              // Motion of the app's: the ring sweeps in for each breakdown,
              // the hole opens and closes between donut and pie, the slice
              // under the pointer lifts on the spring; reduced motion shows
              // it all at once.
              appearGeneration={["Region", "Channel", "None"].indexOf(breakdown())}
              motion={{ appear: { duration: 900, easing: "easeOut" }, update: { duration: 450, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 420, damping: 40 } }}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const p = index == null ? undefined : parts()[index];
                const line = p ? `${index}: ${p.name}, ${money(p.revenue)} (${percent(share(p))})` : "null: off the slices";
                events.set([line, ...events()].slice(0, 5));
              }}
              style={{ width: ring, height: ring }}
            >
              {/* Nothing to split: a faint ring keeps the chart's shape. */}
              <If when={total() === 0}>
                <Arc innerRadius={donut() ? "62%" : 0} outerRadius={ring / 2 - 8} style={{ fill: tint(ui().text, 0.06) }} />
              </If>
              <Pie
                value="revenue"
                innerRadius={donut() ? "62%" : 0}
                // The rim 8 points in, room for the lift.
                outerRadius={ring / 2 - 8}
                padAngle={2 / (ring / 2 - 8)}
                sliceClass={(_, i) => FILLS[i % FILLS.length]}
                hoverLift={6}
                hoverDim={0.64}
              />
              {/* The hovered part, or the total, in the hole: it fades with
                  the hole opening and closing. */}
              <View
                class="transition-opacity duration-300 motion-reduce:transition-none"
                style={{ pointerEvents: "none", position: "absolute", left: 0, top: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center", gap: 4, opacity: donut() ? 1 : 0 }}
              >
                <Text style={{ fontSize: size.detail, color: ui().muted }}>{shown()?.name ?? "Total"}</Text>
                <Text style={{ fontSize: 22, fontWeight: "600", color: ui().text, ...num, textAlign: "center" }}>
                  {money(shown()?.revenue ?? total())}
                </Text>
                <Text style={{ fontSize: size.detail, color: ui().muted, ...num, textAlign: "center" }}>
                  {shown() ? percent(share(shown()!)) : `${parts().filter((p) => p.revenue > 0).length} parts`}
                </Text>
              </View>
            </Plot>

            {/* The legend: a key in each slice's color, the value and the
                share at the trailing edge; the hovered part's row stands out. */}
            <View style={{ alignSelf: "stretch", flex: wide() ? 1 : undefined, gap: 2 }}>
              <View style={{ flexDirection: "row", gap: 12, paddingHorizontal: 8, paddingBottom: 4 }}>
                <View style={{ width: 8 }} />
                <Text style={{ flex: 1, fontSize: 11, color: ui().faint }}>{breakdown() === "Channel" ? "Channel" : "Region"}</Text>
                <Text style={{ fontSize: 11, color: ui().faint, ...num }}>Revenue</Text>
                <Text style={{ width: 52, fontSize: 11, color: ui().faint, ...num }}>Share</Text>
              </View>
              {parts().map((p, i) => (
                <View
                  class="transition-opacity duration-150 motion-reduce:transition-none"
                  style={{
                    flexDirection: "row",
                    alignItems: "center",
                    gap: 12,
                    height: size.rowSm,
                    paddingHorizontal: 8,
                    borderRadius: 6,
                    backgroundColor: hovered() === i ? tint(ui().text, 0.05) : undefined,
                    opacity: hovered() == null || hovered() === i ? 1 : 0.45,
                  }}
                >
                  <View class={KEYS[i % KEYS.length]} style={{ width: 8, height: 8, borderRadius: 2 }} />
                  <Text style={{ numberOfLines: 1, flex: 1, fontSize: size.label - 1, color: ui().text }}>{p.name}</Text>
                  <Text style={{ fontSize: size.label - 1, color: ui().text, ...num }}>{money(p.revenue)}</Text>
                  <Text style={{ width: 52, fontSize: size.label - 1, color: ui().muted, ...num }}>{percent(share(p))}</Text>
                </View>
              ))}
            </View>
          </View>
        </View>
      </Split>
    </View>
  );
}

mount(App);
import { signal, Text, View } from "nsh";
import { Pie, Plot } from "nsh/base/plot";

type Plan = { name: string; seats: number };

const FILLS = ["fill-chart-1", "fill-chart-2", "fill-chart-3", "fill-chart-4"];
const KEYS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4"];

export function Seats(props: { plans: Plan[] }) {
  const hovered = signal<number | null>(null);
  const total = () => props.plans.reduce((sum, p) => sum + p.seats, 0);
  const shown = () => (hovered() == null ? undefined : props.plans[hovered()!]);
  const share = (n: number) => `${((n / total()) * 100).toFixed(1)}%`;
  return (
    <View class="flex-row items-center gap-6 rounded-xl border border-border bg-card p-4">
      <Plot
        data={props.plans}
        accessibilityLabel={`Seats by plan, ${total()} in all`}
        accessibilityValue={{ text: shown() && `${shown()!.name}, ${shown()!.seats} seats, ${share(shown()!.seats)}` }}
        appearGeneration={1}
        motion={{ appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 400, damping: 32 } }}
        onHoverChange={({ index }) => hovered.set(index)}
        class="size-44"
      >
        {/* A donut with 2-point gaps (0.025 of a turn at its 80-point rim). */}
        <Pie
          value="seats"
          innerRadius="62%"
          padAngle={0.025}
          sliceClass={(_, i) => FILLS[i % FILLS.length]}
          hoverLift={6}
          hoverDim={0.64}
        />
        {/* The hovered plan, or the total, in the hole. */}
        <View class="absolute inset-0 items-center justify-center gap-1">
          <Text class="text-xs text-muted-foreground">{shown()?.name ?? "Total"}</Text>
          <Text class="text-2xl font-semibold tabular-nums text-foreground">{shown()?.seats ?? total()}</Text>
        </View>
      </Plot>
      {/* The legend: a key in each slice's color, the value and the share at the end. */}
      <View class="flex-1 gap-1">
        {props.plans.map((plan, i) => (
          <View class="flex-row items-center gap-3 rounded-md px-2 py-1.5" style={{ opacity: hovered() == null || hovered() === i ? 1 : 0.45 }}>
            <View class={`size-2 rounded-sm ${KEYS[i % KEYS.length]}`} />
            <Text class="flex-1 text-sm text-foreground">{plan.name}</Text>
            <Text class="text-right text-sm tabular-nums text-foreground">{plan.seats}</Text>
            <Text class="w-14 text-right text-sm tabular-nums text-muted-foreground">{share(plan.seats)}</Text>
          </View>
        ))}
      </View>
    </View>
  );
}
use nsh_base::plot::{SliceClass, pie, plot};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};

fn seats(seats: Vec<f64>) -> impl IntoElement {
    let rows = Rows::from_columns(seats.len(), [("seats".into(), seats)]);
    plot()
        .data(rows)
        .accessibility_label("Seats by plan")
        .w(px(176.0))
        .h(px(176.0))
        .child(
            pie()
                .value(RowAccessor::key("seats"))
                .inner_radius(relative(0.62))
                .slice_class(SliceClass::from_fn(|i| format!("fill-chart-{}", i % 4 + 1).into())),
        )
}
MarkPropRustTypeDefaultDescription
Pievalue.value(..)keyof T | (d: T, index: number) => numberthe plot’s yWhat each row is worth. A row worth nothing, less, or no number has no slice.
Pie, ArcstartAngle, endAngle.start_angle(..), .end_angle(..)number (radians)0, 2 * Math.PIWhere the first slice starts and the last one ends (an arc’s ends): radians from 12 o’clock, clockwise. An endAngle below startAngle lays a pie’s slices out the other way.
PiepadAngle.pad_angle(..)number (radians)0The angle between neighbouring slices at the rim. Each gap keeps that width (about padAngle * outerRadius points) at every radius, its edges parallel, so it is as wide at the center or the hole as at the rim; a slice too narrow to reach the center ends where its edges meet. A pie of one slice has none.
Pie, ArcinnerRadius, outerRadius.inner_radius(..), .outer_radius(..)number | `${number}%` 0, "100%"The radii of the hole and of the rim: points, or a percentage of the largest circle the plot holds (half its smaller side).
Pie, ArccornerRadius.corner_radius(..)number0The radius of each slice’s corners (an arc’s), in points, as d3’s arc.cornerRadius: each corner a circle touching the edge and the rim or the hole, the gaps’ parallel edges included. It is at most half the ring’s thickness, which gives an arc round caps, and on a slice too narrow for two whole corners they meet on its middle. A whole ring keeps its round edges only.
PiesliceClass.slice_class(..)string | (d: T, index: number) => stringnoneThe classes of each slice (fill-*, stroke-*): the same on every slice, or a function of the slice’s row and its index, called once per row when the rows or the function change.
PiesliceStyle.slice_style(..)StyleProp<ViewStyle>noneThe style of every slice, over its classes (fill, stroke, strokeWidth).
PiehoverLift.hover_lift(..)number0How far the slice under the pointer grows past the others, in points: on the plot’s pointer spring, faded in and out with the hover.
PiehoverDim.hover_dim(..)number (0 to 1)0How much the other slices fade while one is under the pointer, as a share of their opacity, with the hover’s fade.
Piecolor.color(..)keyof T | (d: T, index: number) => stringnoneEach slice’s category, its color from the plot’s colorScale: the slice’s fill where nothing sets one (Colors by category).
Pielabel.label(..)string | (d: T, index: number) => stringnoneThe text beside each slice: a field (its number) or a function returning the text the app formats. A row whose text is empty, or a slice narrower than half a degree, has none.
PielabelClass, labelStyle.label_class(..), .label_style(..)string, StyleProp<TextStyle>noneThe label part: a text outside the ring at the end of its leader line, centered on it, after it on the right half of the pie and before it on the left. Its padding is its distance from the line’s end.
PielabelGap.label_gap(..)number15How far past the rim the labels sit, in points.
PielabelLineClass, labelLineStyle.label_line_class(..), .label_line_style(..)string | (d: T, index: number) => string, StyleProp<ViewStyle>noneThe labelLine part: each label’s leader line, from the rim at the slice’s middle out to the label’s radius, then across to the text. Its stroke (currentColor where none is set), strokeWidth and strokeDasharray, the same on every line or a function of the slice’s row; shown once styled.

A slice and an arc fill with fill (currentColor where none is set, a slice’s category’s color with color) and stroke their edges with stroke (none where none is set), their own over the pie’s and the plot’s as CSS inherits them: a stroke in the surface’s color (stroke-card stroke-2) cuts gaps between the slices too. Their values take no part in the plot’s scales. A pie’s slices are drawn again only when the rows, their values, the angles, the radii or the plot’s size change; the pointer moves the lift and the fade on the animator.

Plots with a pie usually place the tooltip by the pointer (tooltipPlacement="pointer"), since a pie’s rows have no place along an axis: the cross line and the tooltip follow the pointer over it, and it shows no hover dots. A donut’s center is the app’s own element laid over the plot (the hovered slice’s value, or the total), and so is its legend, with each key in its slice’s color.

A pie’s labels name its slices outside the ring: each text sits labelGap past the rim toward its slice’s middle (the slice’s centroid at that radius), then across to that radius on the slice’s side of the pie, and a styled leader line joins it to the rim. The labels sweep in with their slices on the plot’s appear and fade in over its end, move with them on motion.update, and lift with the hovered slice; all of it runs on the platform’s animator. Give the plot room for them: an outerRadius below "100%", or space around the plot. Content of the app’s own at a slice is an element laid over the plot, as a donut’s center is.

<Pie
  value="seats"
  outerRadius="64%"
  sliceClass={(_, i) => FILLS[i % FILLS.length]}
  label={(d) => share(d.seats)}
  labelGap={12}
  labelClass="px-1 text-xs tabular-nums text-muted-foreground"
  labelLineClass="stroke-border"
/>

#Colors by category

A plot’s colorScale gives categories their colors, as d3’s scaleOrdinal does, and a mark’s color names the category of each row: a field (its text, or its number) or a function of the row. The domain’s categories take the range’s colors in order, and the range starts again from its first color when the domain is longer. A category outside the domain takes unknown; without one, it joins the domain the first time a mark meets it (the marks in order, each its rows in order) and takes the next color, so with no domain at all the categories take the colors in the order the rows bring them. The colors are the app’s: CSS colors, the theme’s tokens among them.

import { Pie, Plot } from "nsh/base/plot";
import { token } from "nsh";

const regions = [
  { name: "North America", revenue: 482 },
  { name: "EMEA", revenue: 312 },
  { name: "APAC", revenue: 268 },
];

<Plot data={regions} colorScale={{ type: "ordinal", range: [token("chart-1"), token("chart-2"), token("chart-3")] }} class="size-44">
  <Pie value="revenue" color="name" innerRadius="62%" />
</Plot>

A slice, a bar and a dot take their row’s color; a Line and an Area are one series each and take their first row’s, so a line of rows of its own (data) is in its rows’ color, and one scale keeps a category’s color the same across a pie, bars and lines. The color is the mark’s fill (a line’s stroke) where nothing sets one, in the place of currentColor: the app’s classes and styles come first, the part’s (sliceClass, sliceStyle), the mark’s and those it inherits from the plot, so sliceClass={(d) => (d.name === "Other" ? "fill-muted" : "")} sets one slice apart while the scale colors the others. Beside a background part, a bar’s and an area’s fill stays none.

OrdinalScaleTypeDefaultDescription
type"ordinal"(required)The kind of scale.
domainstring[]noneThe categories, in order. A category written twice keeps its first place.
rangestring[](required)The colors, in order (CSS colors); category i takes color i % range.length.
unknownstringnoneThe color of every category outside the domain; without one, each takes the next color.

#Colors along a ramp

A colorScale of type: "sequential" or "diverging" gives numbers their colors along a ramp, as d3’s scaleSequential and scaleDiverging do, for the value of a Cell. Its range is two or more colors spread evenly along the ramp from its low end to its high one and mixed in OKLab between them: the steps look even to the eye, and two hues meet through their own mix. A value past the domain takes the ramp’s end, and a row without a value takes unknown.

sequential spreads its domain over the whole ramp, the domain the extent of the cells’ values unless the app names one: counts, volumes, activity. diverging puts its pivot (0 by default) at the ramp’s middle color, the values below it along the first half and those above it along the second: losses in one hue, gains in another, a flat month in the neutral middle. Unless the app names a domain, it is the extent of the values made even on both sides of the pivot (Observable Plot’s symmetric), so a loss and a gain of the same size are as strong; a correlation matrix names domain: [-1, 1].

colorScale={{ type: "sequential", range: [token("muted"), token("chart-1")] }}
colorScale={{ type: "diverging", pivot: 0, range: [token("destructive"), token("muted"), token("chart-1")] }}
SequentialScale, DivergingScaleTypeDefaultDescription
type"sequential" | "diverging"(required)The kind of ramp.
domain[number, number]the values’ extent (made even about the pivot for diverging)The values of the ramp’s two ends.
pivotnumber0diverging only: the value of the ramp’s middle color.
rangestring[](required)The colors along the ramp, from the low end to the high one (CSS colors), mixed in OKLab between them.
unknownstringnoneThe color of a row without a value; without one, that row has no cell.

#RadialLine and RadialGrid

A RadialLine draws a series around the plot’s center: a radar’s polygon, or a line round a dial. Each row with a value is a point at the row’s angle, as far out as the plot’s y scale puts its value, from innerRadius (the domain’s smallest value) to outerRadius (its largest). Without an angle, the rows go evenly round the turn from 12 o’clock, clockwise, in order, so six rows are a hexagon; closed joins the last point to the first. A line strokes its points and fills its area down to innerRadius (the center by default), so one mark is a radar’s outline and its wash. A RadialGrid draws the radar’s grid behind it: rings at values of the y scale, polygons through the rows’ angles or circular, and spokes out to each row. Over a radial line, the plot reports the row whose angle is nearest to the pointer’s within the line’s outer radius, and each line’s hover dot sits on its point of that row.

import { signal, Text, View } from "nsh";
import { Plot, RadialGrid, RadialLine } from "nsh/base/plot";

type Metric = { name: string; ours: number; theirs: number };

export function Compare(props: { metrics: Metric[] }) {
  const hovered = signal<number | null>(null);
  const shown = () => (hovered() == null ? undefined : props.metrics[hovered()!]);
  return (
    <View class="items-center gap-3 rounded-xl border border-border bg-card p-4">
      <Text class="text-sm tabular-nums text-muted-foreground">
        {shown() ? `${shown()!.name}: ${shown()!.ours} against ${shown()!.theirs}` : "Ours against theirs"}
      </Text>
      <Plot
        data={props.metrics}
        yScale={{ type: "linear", domain: [0, 100] }}
        accessibilityLabel="Ours against theirs on six metrics"
        accessibilityValue={{ text: shown() && `${shown()!.name}: ours ${shown()!.ours}, theirs ${shown()!.theirs}` }}
        appearGeneration={1}
        motion={{ appear: { duration: 700, easing: "easeOut" }, update: { duration: 400, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 380, damping: 34 } }}
        onHoverChange={({ index }) => hovered.set(index)}
        class="size-56"
      >
        <RadialGrid rings={[25, 50, 75, 100]} spokes outerRadius="80%" class="stroke-border" />
        <RadialLine radius="theirs" closed outerRadius="80%" class="fill-chart-2/15 stroke-chart-2" hoverDotClass="size-2 rounded-full border-2 border-card bg-chart-2" />
        <RadialLine radius="ours" closed outerRadius="80%" class="fill-chart-1/25 stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
        {/* Each metric's name past the rim at its row's angle, growing away from the center. */}
        {props.metrics.map((m, i) => {
          const angle = (2 * Math.PI * i) / props.metrics.length;
          const away = (v: number) => (v > 0.01 ? "flex-start" : v < -0.01 ? "flex-end" : "center");
          const align = (v: number) => (v > 0.01 ? "left" : v < -0.01 ? "right" : "center");
          return (
            <View
              class="absolute size-0"
              style={{
                left: `${50 + 44 * Math.sin(angle)}%`,
                top: `${50 - 44 * Math.cos(angle)}%`,
                alignItems: away(Math.sin(angle)),
                justifyContent: away(-Math.cos(angle)),
                pointerEvents: "none",
              }}
            >
              <Text class="w-24 text-xs text-muted-foreground" style={{ textAlign: align(Math.sin(angle)) }}>{m.name}</Text>
            </View>
          );
        })}
      </Plot>
    </View>
  );
}
MarkPropRustTypeDefaultDescription
RadialLineradius.radius(..)keyof T | (d: T, index: number) => numberthe plot’s yWhat places each row out from the center: a value of the y scale, from innerRadius at the domain’s smallest value to outerRadius at its largest. A row without one is skipped.
RadialLineangle.angle(..)keyof T | (d: T, index: number) => numberthe rows evenly round the turnWhere each row goes round the center: radians from 12 o’clock, clockwise.
RadialLine, RadialGridstartAngle, endAngle.start_angle(..), .end_angle(..)number (radians)0, 2 * Math.PIWhere the rows go without an angle: from startAngle, evenly. Round a whole turn they are a step of a turn’s 1 / rows apart, the last a step before the first; over a part of a turn the first is on startAngle and the last on endAngle.
RadialLine, RadialGridinnerRadius, outerRadius.inner_radius(..), .outer_radius(..)number | `${number}%` 0, "100%"Where the y domain’s smallest and largest values go: points, or a percentage of the largest circle the plot holds (half its smaller side), as a pie’s radii. A line’s area fills down to innerRadius; a grid’s spokes run between the two.
RadialLinecurve.curve(..)"linear" | "natural" | "stepAfter""linear"How it connects its points: straight segments, a smooth curve (closed, a smooth loop), or steps.
RadialLineclosed.closed(..)booleanfalseIt goes on from its last point back to its first: a radar’s polygon. Open, its area closes to the center (or along its inner edge).
RadialLinedot, dotClass, dotStyle.dot(..), .dot_class(..), .dot_style(..)boolean, string, StyleProp<ViewStyle>falseA dot on every point: a view centered on it, its size, background and border the app’s. The dots grow out and move with the line.
RadialLinebackgroundClass, backgroundStyle.background_class(..), .background_style(..)string, StyleProp<ViewStyle>noneIts background: a box around its outer circle whose background (bg-*, a gradient) shows only inside its area, under its fill, as an area’s.
RadialLinehoverDotClass, hoverDotStyle.hover_dot_class(..), .hover_dot_style(..)string, StyleProp<ViewStyle>the plot’s dotClass, dotStyleIts hover dot, on its point of the row under the pointer, with the pointer in the same frame and gliding to the next row on the plot’s pointer spring when the keys step; hidden shows none.
RadialGridrings.rings(..)number[]noneWhere its rings go, as values of the y scale: each as far out as a radial line puts that value.
RadialGridspokes.spokes(..)booleanfalseA line from innerRadius to outerRadius at each row’s angle.
RadialGridcircular.circular(..)booleanfalseIts rings are circles (arcs over a part of a turn). By default they are polygons through the rows’ angles, and circles while the plot has fewer than three rows.

A radial line strokes with stroke, strokeWidth and strokeDasharray (currentColor, 1 wide and solid where none is set) and fills its area with fill (none where none is set); a radial grid strokes as a grid does. Their paint inherits from the plot. A line’s values, with 0, make the y scale’s domain when the plot has none, so a radar of positive values grows from its center; a grid’s rings do not. Give the plot a y domain to keep the rings and the radar’s scale fixed while the values change.

Labels around a radar are the app’s elements laid over the plot, as a donut’s center is. Without an angle, row i of n sits at a = 2π·i / n from 12 o’clock, so in a square plot a box with no size at left: 50% + r·sin(a), top: 50% − r·cos(a) (r a percentage of the plot’s side a little past half the outer radius’s share) marks the place past the rim, and the name grows from it away from the center: right of it on the right half, left of it on the left, above it at the top and below it at the bottom, as above.

use nsh_base::plot::{plot, radial_grid, radial_line};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};

fn compare(ours: Vec<f64>, theirs: Vec<f64>) -> impl IntoElement {
    let rows = Rows::from_columns(ours.len(), [("ours".into(), ours), ("theirs".into(), theirs)]);
    plot()
        .data(rows)
        .accessibility_label("Ours against theirs")
        .w(px(224.0))
        .h(px(224.0))
        .child(radial_grid().rings(vec![25.0, 50.0, 75.0, 100.0]).spokes(true))
        .child(radial_line().radius(RowAccessor::key("theirs")).closed(true))
        .child(radial_line().radius(RowAccessor::key("ours")).closed(true))
}

#Sankey

A Sankey draws flows between the plot’s rows: each row is a node and links names the flows between them, { source, target, value } with the indices of two rows in the plot’s data. It lays them out as d3-sankey does, in the plot’s size: the nodes in columns (nodeAlign), each as tall as the most that flows into or out of it, the nodes of a column nodePadding apart, relaxed toward their flows over iterations passes; and a ribbon per link from its source’s right edge to its target’s left edge, as wide at each end as its share of that node’s side, so both sides of every node are covered even where its flows in and out differ. Each column is centered in the plot, and a run of equal single-node columns is staggered a little so the ribbons between them curve. valueScale="sqrt" sizes by the square root of the values, so one dominant flow does not dwarf the small ones.

Each node and each ribbon is a canvas of its own, the node and link parts, so each paints with its own classes: nodeClass is the same classes on every node or a function of the node’s row, and linkClass the same on every ribbon or a function of the link. The linkBackground part shows a background inside each ribbon, a box from its source to its target: a function of the link gives each ribbon a gradient from its source’s color to its target’s. The ribbons draw under the nodes, and both draw in from the leading edge with the plot’s appear.

Over a sankey, the plot reports the node under the pointer (onHoverChange, the node’s row; none over a ribbon), its tooltip follows the pointer, and the links not attached to that node fade by hoverDim, on the platform’s animator with the plot’s motion. The keys step through the nodes in the rows’ order, the tooltip beside each node’s middle. nodeLabel names each node and valueLabel gives its value, the value over the name when both are set: left of a node in the first column, right of one in the last, centered above one between them, as gpui-component’s chart places them; a label beside a node stays between the plot’s top and bottom, and one past the plot’s edge sits in the room the app leaves around it (its margin). The nodeLabel part’s padding is the gap to its node and its classes its text (max-w-20 truncate cuts a long name short); the labels fade in with the appear. When the app changes the flows, every node and ribbon moves from its old place to its new one with the plot’s motion.update, its label with it: a node new to the flows grows from its middle, a link new to them from its least width, and the ones that go leave at once; a change while they move goes on from where they are drawn. Links are told apart by their source and target, so reordering links moves nothing.

import { signal } from "nsh";
import { Plot, Sankey } from "nsh/base/plot";

type Step = { name: string };

const steps: Step[] = [{ name: "Visits" }, { name: "Sign-ups" }, { name: "Bounced" }, { name: "Trials" }, { name: "Paid" }, { name: "Churned" }];
const flows = [
  { source: 0, target: 1, value: 4200 },
  { source: 0, target: 2, value: 5800 },
  { source: 1, target: 3, value: 2600 },
  { source: 3, target: 4, value: 1100 },
  { source: 3, target: 5, value: 1500 },
];
// Each step's color: its node's fill, and its ribbons' gradient ends.
const FILLS = ["fill-chart-1", "fill-chart-2", "fill-muted", "fill-chart-3", "fill-chart-4", "fill-muted"];
const FROM = ["from-chart-1/40", "from-chart-2/40", "from-muted/40", "from-chart-3/40", "from-chart-4/40", "from-muted/40"];
const TO = ["to-chart-1/40", "to-chart-2/40", "to-muted/40", "to-chart-3/40", "to-chart-4/40", "to-muted/40"];

export function Funnel() {
  const hovered = signal<number | null>(null);
  return (
    <Plot
      data={steps}
      accessibilityLabel="Visits to paid: 10,000 visits, 1,100 paid"
      accessibilityValue={{ text: hovered() == null ? undefined : steps[hovered()!].name }}
      onHoverChange={({ index }) => hovered.set(index)}
      appearGeneration={1}
      motion={{ appear: { duration: 900, easing: "easeOut" }, update: { duration: 500 }, enter: { duration: 120 }, exit: { duration: 160 } }}
      class="mx-20 h-64"
    >
      <Sankey
        links={flows}
        nodeWidth={8}
        nodePadding={16}
        nodeCornerRadius={2}
        nodeClass={(_, i) => FILLS[i]}
        linkBackgroundClass={(link) => `bg-linear-to-r ${FROM[link.source]} ${TO[link.target]}`}
        hoverDim={0.7}
        nodeLabel={(d: Step) => d.name}
        valueLabel={(_: Step, value: number) => value.toLocaleString("en-US")}
        nodeLabelClass="px-2 py-1 text-xs text-muted-foreground"
        valueLabelClass="font-medium text-foreground"
      />
    </Plot>
  );
}
use nsh_base::plot::{RowClass, SankeyLinkClass, SankeyLinks, plot, sankey};
use nsh_core::{element::Rows, prelude::*};

fn funnel() -> impl IntoElement {
    plot()
        .data(Rows::from_columns(4, []))
        .accessibility_label("Visits to paid")
        .h(px(256.0))
        .child(
            sankey()
                .links(SankeyLinks::new([(0, 1, 4200.0), (1, 2, 2600.0), (2, 3, 1100.0)]))
                .node_width(px(8.0))
                .node_class(RowClass::from_fn(|i| format!("fill-chart-{}", i + 1).into()))
                .link_class(SankeyLinkClass::from("fill-foreground/10")),
        )
}
PropRustTypeDefaultDescription
links.links(..)SankeyLink[] ({ source: number; target: number; value: number })requiredThe flows: from the row source to the row target (their indices in the plot’s data), value its size. A value that is no number or below zero carries nothing and draws no ribbon.
nodeWidth.node_width(..)number24The width of each node, in points.
nodePadding.node_padding(..)number8The gap between the nodes of a column, in points; less in a column too crowded for it.
nodeAlign.node_align(..)"justify" | "left" | "right" | "center""justify"Which column each node goes in, as d3-sankey’s: its depth from the first column ("left"), as far right as its flows let it ("right"), its depth with a node that only starts flows next to its targets ("center"), or its depth with the nodes that end flows in the last column ("justify").
iterations.iterations(..)number6How many passes relax the nodes toward their flows.
valueScale.value_scale(..)"linear" | "sqrt""linear"Heights and widths in proportion to the values, or to their square roots.
minLinkWidth.min_link_width(..)number1The thinnest a ribbon draws, in points, so a small flow stays visible.
nodeCornerRadius.node_corner_radius(..)number0The radius of each node’s corners, in points.
nodeClass.node_class(..)string | (d: T, index: number) => stringnoneThe classes of each node (fill-*, stroke-*): the same on every node, or a function of the node’s row and its index, called once per row when the rows or the function change.
nodeStyle.node_style(..)StyleProp<ViewStyle>noneThe style of every node, over its classes.
linkClass.link_class(..)string | (link: SankeyLink, index: number) => stringnoneThe classes of each ribbon: the same on every ribbon, or a function of the link and its index in links.
linkStyle.link_style(..)StyleProp<ViewStyle>noneThe style of every ribbon, over its classes.
linkBackgroundClass.link_background_class(..)string | (link: SankeyLink, index: number) => stringnoneThe classes of each ribbon’s background (bg-*, a gradient: bg-linear-to-r from-… to-…), shown inside the ribbon over a box from its source to its target, under its fill. With a background, a ribbon’s fill is none where none is set.
linkBackgroundStyle.link_background_style(..)StyleProp<ViewStyle>noneThe style of every ribbon’s background, over its classes.
hoverDim.hover_dim(..)number (0 to 1)0How much the links not attached to the node under the pointer fade, as a share of their opacity, on the plot’s pointer spring and with the hover’s fade.
nodeLabel.node_label(..)keyof T | (d: T, index: number) => stringnoneThe name beside each node: a field, or a function of the node’s row and its index.
valueLabel.value_label(..)(d: T, value: number, index: number) => stringnoneThe value beside each node, over its name: a function of its row and the most that flows into or out of it (in the links’ values, whatever the valueScale).
nodeLabelClass.node_label_class(..)stringnoneThe classes of each label, the nodeLabel part: its padding the gap to its node, its text styles both lines’.
nodeLabelStyle.node_label_style(..)StyleProp<TextStyle>noneThe style of each label, over its classes.
valueLabelClass.value_label_class(..)stringnoneThe classes of the value line, the valueLabel part, over the label’s.
valueLabelStyle.value_label_style(..)StyleProp<TextStyle>noneThe style of the value line, over its classes.

A node and a ribbon fill with fill (currentColor where none is set) and stroke with stroke (none where none is set), their own over the sankey’s and the plot’s as CSS inherits them. A node with nothing through it is a point tall. The layout runs again only when the links, the options or the plot’s size change, never for the pointer or the appear; the plot’s size changing moves nothing. Links that make a cycle, or name a row the plot does not have, draw nothing and log a warning. Its values take no part in the plot’s scales.

#Treemap

A Treemap draws the plot’s rows as a hierarchy of nested rectangles: each row is a node, its id and the parent it names make the tree (d3’s stratify), each leaf’s area is in proportion to its value, and each group is the box of what is under it, its value the sum of its leaves’ (whatever its own row holds). A row without a parent, or whose parent names no row, is a top-level node; without an id a row’s id is its index, so parent can name a row by its index too. A tree in the nested form gpt-vis writes ({ name, value, children }) is a few lines of the app’s to make rows of; there is no limit to the levels.

The tiles are squarified, as d3’s treemapSquarify makes them: each level’s nodes in order of value, largest first from the top left (sort="value"), near the aspect ratio ratio (the golden ratio); sort="data" keeps the rows’ order, so the nodes keep their places as their values change. paddingInner is the gap between siblings, along no outer edge of their parent; paddingOuter is the room inside a group around its children; paddingTop is a band at the top of each group, where its groupLabel is written. Each takes a number for every level or one per level from the top one down ([3, 1]: 3 between the top-level nodes, 1 below them). A level’s band goes on all the siblings of a parent or on none, left off where one is too small for it, so the groups of a level read alike.

A leaf whose value is 0, below 0 or not a number takes no area: it is not drawn, has no label and is not found by the pointer, and neither is a group whose leaves all are. A treemap’s areas are shares of a whole, so the app shows what is left out in its own words (a note under the map). Negative numbers are for colors: a market map’s change of the day.

A node’s color is its row’s of the plot’s colorScale: colorValue is a number along a ramp (sequential, diverging: losses red, gains green, flat neutral, over the extent of the values without a domain), color a category (ordinal: "parent" colors a group’s leaves alike). The leaves are the leaf part (leafClass, leafStyle) and the groups the group part (groupClass, groupStyle), drawn only with a class or a style: a rectangle under its children, showing in the gaps, its padding and its band. Each part is the same on every node or a function of the node’s row. cornerRadii rounds every rectangle.

label writes each leaf’s name and valueLabel its value under it, a function of the row and its value (a group’s is its leaves’ sum): at the top left (labelPlacement="start") or in the middle ("center"). The label part is a box as big as its leaf: its padding is the room from the leaf’s edges and its text styles the lines’. The name is cut with an ellipsis where the leaf is narrower and shown only where it fits; the value is shown whole or not at all. A label whose leaf’s color is dark has the attribute data-dark, so data-dark:text-white keeps it readable on a deep red.

Over a treemap, the plot reports the node under the pointer (onHoverChange, the node’s row): a leaf, or a group in its band or padding; across a gap between siblings, one of them, so the hover does not flicker. The others fade to inactiveOpacity (a leaf keeps its groups, a group what is under it), and the hover part, a view as big as the node, outlines it. The arrow keys step to the leaf beside on the screen, Home and End to the first and the last. With the plot’s appear the top-level nodes grow from their middles, one after another with stagger; when the rows change, every node moves to its new box and color with motion.update, known by its id: a node new to the rows grows from its middle and one that is gone shrinks to its own. The plot’s x and y scales are not used.

el-plot-treemap.tsx
el-plot-treemap.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Plot, Treemap } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// A `Treemap` of the plot's rows: each row a node, its `id` and the
// `parent` it names making the tree, each leaf's area its `value` and each
// group's the sum of its leaves'. The squarified tiles go largest first
// from the top left (`sort="value"`), or in the rows' order
// (`sort="data"`); the gaps between siblings are `paddingInner` per level,
// and each group's name sits in a band at its top (`paddingTop`).
//
// The market map colors each stock by its change of the day along a
// diverging `colorScale` (losses red, gains green, flat neutral) and
// centers its ticker on it. The budget has three levels, a line of 0 and
// one below 0, which take no area: a treemap's areas are shares of a
// whole. The node under the pointer (or the arrow keys) is outlined and
// read out, the others fading.
type Node = { id: string; parent?: string; name: string; value?: number; chg?: number };

const MARKET: Node[] = [
  { id: "tech", name: "Technology" },
  { id: "comm", name: "Communication" },
  { id: "fin", name: "Financials" },
  { id: "health", name: "Healthcare" },
  ...(
    [
      ["NVDA", "tech", 4300, 2.4],
      ["MSFT", "tech", 3700, -0.6],
      ["AAPL", "tech", 3600, 0.4],
      ["AVGO", "tech", 1600, 3.1],
      ["ORCL", "tech", 720, -2.8],
      ["AMD", "tech", 380, 1.8],
      ["GOOGL", "comm", 3100, 1.1],
      ["META", "comm", 1800, -1.5],
      ["NFLX", "comm", 480, 0.2],
      ["BRK.B", "fin", 1050, 0.2],
      ["JPM", "fin", 830, 1.3],
      ["V", "fin", 670, -0.3],
      ["GS", "fin", 240, 2.7],
      ["LLY", "health", 820, -1.9],
      ["UNH", "health", 300, -4.1],
      ["JNJ", "health", 460, 0.4],
    ] as const
  ).map(([id, parent, value, chg]) => ({ id, parent, name: id, value, chg })),
];

/** A team's yearly budget in thousands: three levels, a line that is done (0) and one refunded (below 0). */
const BUDGET: Node[] = [
  { id: "eng", name: "Engineering" },
  { id: "ops", name: "Operations" },
  { id: "infra", parent: "eng", name: "Infrastructure" },
  { id: "product", parent: "eng", name: "Product" },
  { id: "cloud", parent: "infra", name: "Cloud", value: 420 },
  { id: "ci", parent: "infra", name: "CI runners", value: 96 },
  { id: "observability", parent: "infra", name: "Observability", value: 74 },
  { id: "salaries", parent: "product", name: "Contractors", value: 310 },
  { id: "design", parent: "product", name: "Design tools", value: 38 },
  { id: "research", parent: "product", name: "User research", value: 0 },
  { id: "office", parent: "ops", name: "Office", value: 180 },
  { id: "travel", parent: "ops", name: "Travel", value: 64 },
  { id: "legal", parent: "ops", name: "Legal", value: -12 },
];

const num = { fontVariant: ["tabular-nums" as const] };
const pct = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(1)}%`;
/** A node's value: its own, or its leaves' sum (what the treemap gives `valueLabel`). */
const total = (rows: Node[], id: string): number => {
  const children = rows.filter((r) => r.parent === id);
  if (children.length === 0) return Math.max(0, rows.find((r) => r.id === id)?.value ?? 0);
  return children.reduce((s, c) => s + total(rows, c.id), 0);
};
const path = (rows: Node[], node: Node): string => {
  const parent = node.parent && rows.find((r) => r.id === node.parent);
  return parent ? `${path(rows, parent)} / ${node.name}` : node.name;
};

function App() {
  const chart = signal<"market" | "budget">("market");
  const sort = signal<"value" | "data">("value");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const rows = () => (chart() === "market" ? MARKET : BUDGET);
  const shown = () => (hovered() == null ? undefined : rows()[hovered()!]);
  const height = desktop ? 260 : 240;
  const motion = { appear: { duration: 800, easing: "easeOut" }, update: { duration: 500, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 380, damping: 36 } } as const;
  const outline = () => ({ borderRadius: 3, outlineWidth: 2, outlineColor: ui().text });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot treemap</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Move the pointer over a tile or a group's name" : "Each tile's area is its share of the whole"}</Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="data">
              <Segmented options={["market", "budget"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
            </Panel>
            <Panel title="sort">
              <Segmented options={["value", "data"] as const} value={sort()} onChange={(s) => sort.set(s)} />
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <View class="flex-row items-baseline gap-3">
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>
                {shown() ? path(rows(), shown()!) : chart() === "market" ? "Largest stocks by sector" : "Budget by team"}
              </Text>
              <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                {chart() === "market" ? "Area: market cap · color: change of the day" : "Thousands of dollars · a line of 0 and a refund take no area"}
              </Text>
            </View>
            <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>
              {shown() ? (chart() === "market" && shown()!.chg != null ? pct(shown()!.chg!) : `$${total(rows(), shown()!.id)}k`) : ""}
            </Text>
          </View>
          <Plot
            data={rows()}
            colorScale={
              chart() === "market"
                ? { type: "diverging", domain: [-3, 3], range: [ui().down, tint(ui().text, 0.1), ui().up] }
                : { type: "ordinal", domain: ["eng", "ops"], range: [ui().info, ui().warn] }
            }
            accessibilityLabel={chart() === "market" ? "Market map of 16 stocks in 4 sectors" : "Budget of two teams in three levels"}
            accessibilityValue={{ text: shown() && path(rows(), shown()!) }}
            appearGeneration={chart()}
            motion={motion}
            onHoverChange={({ index }) => {
              hovered.set(index);
              const n = index == null ? undefined : rows()[index];
              log(n == null ? "null: left the map" : `${index}: ${path(rows(), n)}`);
            }}
            style={{ height }}
          >
            <Treemap
              id="id"
              parent="parent"
              value="value"
              sort={sort()}
              colorValue={chart() === "market" ? "chg" : undefined}
              color={chart() === "budget" ? (d: Node) => (d.id === "ops" || d.parent === "ops" ? "ops" : "eng") : undefined}
              paddingInner={chart() === "market" ? [3, 1] : [4, 2, 1]}
              paddingOuter={chart() === "market" ? 0 : [2, 1]}
              paddingTop={18}
              cornerRadii={3}
              stagger={0.4}
              inactiveOpacity={0.35}
              groupClass={chart() === "budget" ? "fill-foreground/5" : undefined}
              label={(d: Node) => d.name}
              valueLabel={(d: Node, value: number) => (d.chg != null ? pct(d.chg) : `$${value}k`)}
              labelPlacement={chart() === "market" ? "center" : "start"}
              labelClass="p-1.5 text-foreground data-dark:text-white"
              labelStyle={{ fontSize: 11, fontWeight: "600" }}
              valueLabelStyle={{ fontSize: 10, fontWeight: "400", opacity: 0.85, ...num }}
              groupLabel="name"
              groupLabelClass="px-1.5 text-muted-foreground"
              groupLabelStyle={{ fontSize: 10.5, lineHeight: 18 }}
              hoverStyle={outline()}
            />
          </Plot>
        </View>
      </Split>
    </View>
  );
}

mount(App);
import { signal } from "nsh";
import { Plot, Treemap } from "nsh/base/plot";

type Row = { id: string; parent?: string; name: string; cap?: number; chg?: number };

const rows: Row[] = [
  { id: "tech", name: "Technology" },
  { id: "fin", name: "Financials" },
  { id: "NVDA", parent: "tech", name: "NVIDIA", cap: 4300, chg: 2.4 },
  { id: "MSFT", parent: "tech", name: "Microsoft", cap: 3700, chg: -0.6 },
  { id: "AVGO", parent: "tech", name: "Broadcom", cap: 1600, chg: 3.1 },
  { id: "JPM", parent: "fin", name: "JPMorgan", cap: 830, chg: 1.3 },
  { id: "V", parent: "fin", name: "Visa", cap: 670, chg: -0.3 },
];

export function MarketMap() {
  const hovered = signal<number | null>(null);
  return (
    <Plot
      data={rows}
      colorScale={{ type: "diverging", domain: [-3, 3], range: ["#dc2626", "#e5e5e5", "#16a34a"] }}
      accessibilityLabel="Market map of 5 stocks in 2 sectors"
      accessibilityValue={{ text: hovered() == null ? undefined : rows[hovered()!].name }}
      onHoverChange={({ index }) => hovered.set(index)}
      appearGeneration={1}
      motion={{ appear: { duration: 800 }, update: { duration: 500 }, enter: { duration: 120 }, exit: { duration: 160 } }}
      class="h-72"
    >
      <Treemap
        id="id"
        parent="parent"
        value="cap"
        colorValue="chg"
        paddingInner={[3, 1]}
        paddingTop={18}
        cornerRadii={2}
        inactiveOpacity={0.35}
        label="id"
        valueLabel={(d: Row) => `${d.chg! > 0 ? "+" : ""}${d.chg}%`}
        labelPlacement="center"
        labelClass="p-1 text-xs font-semibold text-foreground data-dark:text-white"
        groupLabel="name"
        groupLabelClass="px-1 text-xs text-muted-foreground"
        hoverClass="rounded-xs outline-2 outline-foreground"
      />
    </Plot>
  );
}
use nsh_base::plot::{ColorScale, DivergingScale, TreemapPadding, plot, treemap};
use nsh_core::{element::{RowAccessor, RowText, Rows}, prelude::*};

const IDS: [&str; 5] = ["tech", "fin", "NVDA", "MSFT", "JPM"];
const PARENTS: [&str; 5] = ["", "", "tech", "tech", "fin"];

fn market_map() -> impl IntoElement {
    let rows = Rows::from_columns(
        5,
        [
            ("cap".into(), vec![0.0, 0.0, 4300.0, 3700.0, 830.0]),
            ("chg".into(), vec![0.0, 0.0, 2.4, -0.6, 1.3]),
        ],
    );
    plot()
        .data(rows)
        .color_scale(ColorScale::from(
            DivergingScale::new([rgb(0xdc2626), rgb(0xe5e5e5), rgb(0x16a34a)]).domain([-3.0, 3.0]),
        ))
        .accessibility_label("Market map")
        .h(px(288.0))
        .child(
            treemap()
                .id(RowText::from_fn(|i| IDS[i].into()))
                .parent(RowText::from_fn(|i| PARENTS[i].into()))
                .value(RowAccessor::key("cap"))
                .color_value(RowAccessor::key("chg"))
                .padding_inner(TreemapPadding::from([px(3.0), px(1.0)]))
                .padding_top(TreemapPadding::from(px(18.0)))
                .label(RowText::from_fn(|i| IDS[i].into())),
        )
}
PropRustTypeDefaultDescription
id.id(..)keyof T | (d: T, index: number) => stringthe row’s indexThe id of each row’s node: what parent names, and what a node is known by when the rows change.
parent.parent(..)keyof T | (d: T, index: number) => stringnone: every row is a top-level nodeThe id of each row’s parent; empty, or no row’s id, for a top-level node.
value.value(..)keyof T | (d: T, index: number) => numberrequiredThe size of each leaf; a group’s is the sum of its children’s. A leaf of 0, below 0 or no number takes no area.
sort.sort(..)"value" | "data""value"Siblings largest first from the top left, or in the rows’ order.
ratio.ratio(..)numberthe golden ratioThe aspect ratio the tiles approach, at least 1.
paddingInner.padding_inner(..)number | number[]0The gap between siblings, in points, per level from the top one down (the last for those below).
paddingOuter.padding_outer(..)number | number[]0The room inside each group around its children, per level.
paddingTop.padding_top(..)number | number[]0The band at the top of each group, for its groupLabel, per level; on all the siblings of a parent or none.
cornerRadii.corner_radii(..)number | { topLeft?, topRight?, bottomRight?, bottomLeft? }squareThe radius of each corner of its rectangles.
color.color(..)keyof T | (d: T, index: number) => stringnoneThe category of each node, which an ordinal colorScale gives a color.
colorValue.color_value(..)keyof T | (d: T, index: number) => numbernoneThe number of each node, which a sequential or diverging colorScale makes its color.
leafClass.leaf_class(..)string | (d: T, index: number) => stringnoneThe classes of each leaf (fill-*, stroke-*), the same on every leaf or a function of its row.
leafStyle.leaf_style(..)StyleProp<ViewStyle>noneThe style of every leaf, over its classes.
groupClass.group_class(..)string | (d: T, index: number) => stringnoneThe classes of each group, a rectangle under its children; groups are drawn only with a class or a style.
groupStyle.group_style(..)StyleProp<ViewStyle>noneThe style of every group, over its classes.
inactiveOpacity.inactive_opacity(..)number (0 to 1)noneThe opacity of the nodes away from the hovered one, and of their labels, with the hover’s fade.
label.label(..)keyof T | (d: T, index: number) => stringnoneThe name in each leaf, on one line, cut with an ellipsis.
valueLabel.value_label(..)(d: T, value: number, index: number) => stringnoneThe value in each leaf, under its name, shown only where it fits whole.
labelPlacement.label_placement(..)"start" | "center""start"The labels at their leaves’ top left, or in their middle.
labelClass.label_class(..)stringnoneThe classes of each leaf’s label, a box as big as the leaf: its padding and text styles; data-dark: on dark leaves.
labelStyle.label_style(..)StyleProp<TextStyle>noneThe style of each leaf’s label, over its classes.
valueLabelClass.value_label_class(..)stringnoneThe classes of the value line, over the label’s.
valueLabelStyle.value_label_style(..)StyleProp<TextStyle>noneThe style of the value line, over its classes.
groupLabel.group_label(..)keyof T | (d: T, index: number) => stringnoneThe name of each group, in its band, cut with an ellipsis.
groupLabelClass.group_label_class(..)stringnoneThe classes of each group’s label, a box as big as the band.
groupLabelStyle.group_label_style(..)StyleProp<TextStyle>noneThe style of each group’s label, over its classes.
stagger.stagger(..)number (0 to 0.95)0How the top-level nodes appear one after another: the share of the appear over which they start.
hoverClass.hover_class(..)stringnoneThe classes of the hover part, a view as big as the hovered node over it (rounded-xs outline-2 outline-foreground).
hoverStyle.hover_style(..)StyleProp<ViewStyle>noneThe style of the hover part, over its classes.

A rectangle fills with fill (the colorScale’s color, else currentColor, where none is set) and strokes with stroke (none where none is set), its own over the treemap’s and the plot’s as CSS inherits them. The layout runs again only when the rows, the options or the plot’s size change, never for the pointer or the appear; the plot’s size changing moves nothing. A repeated id, a parent that names no row and a row that is its own ancestor log a warning once: the first row keeps the id, the orphan is a top-level node, the cycle is not drawn.

#PlotAxis

A PlotAxis labels an edge of the plot with the ticks of the scale that edge carries: the bottom and top edges the x scale’s, the left and right ones the y scale’s, as d3’s axisBottom, axisTop, axisLeft and axisRight. Its ticks come from the scale: round values on a linear one (1, 2 or 5 times a power of ten, inside the domain), rows on a point or band one. Its labels are texts the plot’s layout places, so the app says what they read and how they look, and the layout puts them in place: each label is centered on its tick along the edge. Along the bottom and top edges, the first and last move inward just as far as they need to stay within the plot’s ends; beside the left and right edges, every label stays on its tick, so it reads as its grid line’s, and where a label near an end would pass the plot’s top or bottom edge, the plot’s linear y scales start that far in from it (half the label’s height for a tick at the end), so every label stays within the plot, on its grid line.

import { Area, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";

const rows = [
  { day: "Mon", visitors: 1820 },
  { day: "Tue", visitors: 2140 },
  { day: "Wed", visitors: 1960 },
  { day: "Thu", visitors: 2480 },
  { day: "Fri", visitors: 2310 },
];

<Plot data={rows} y="visitors" yScale={{ type: "linear", domain: [0, 3000] }} class="mb-6 ml-10 h-40">
  <Grid y={[0, 1000, 2000, 3000]} class="stroke-border" />
  <Area class="fill-chart-1/20" />
  <Line class="stroke-chart-1 stroke-2" />
  {/* 0, 1,000, 2,000 and 3,000 left of the plot; a day under each row. */}
  <PlotAxis placement="left" ticks={3} labelClass="pr-2 text-xs tabular-nums text-muted-foreground" />
  <PlotAxis
    format={(i) => rows[i].day}
    labelClass="pt-2 text-xs text-muted-foreground"
    tickClass="h-1 w-px bg-border"
    lineClass="h-px bg-border"
  />
</Plot>
PropRustTypeDefaultDescription
placement.placement(..)"bottom" | "top" | "left" | "right""bottom"The edge it runs along: the bottom and top edges carry the x scale’s ticks, the left and right ones the y scale’s.
scale.scale(..)"y" | "y2""y"The y scale a left or right axis labels: the plot’s yScale or its second one, y2Scale (Dual axes).
labelPlacement.label_placement(..)"outside" | "inside""outside"Where its labels sit: past the edge, in the room the app leaves around the plot, where they stay clear of the marks; or over the plot along the edge, so the plot keeps its whole size (beside a left or right edge, each label sits on its grid line’s upper side).
ticks.ticks(..)number10 on a linear scale (every step of aligned scales), every row on the othersAbout how many ticks it takes: on a linear scale, round values inside the domain, about this many; on a point or band scale, this many rows spread evenly from the first to the last.
tickValues.tick_values(..)number[]noneThe values of its ticks, in place of ticks: values of the scale, row indices on a point or band scale ([0, 3, 6] labels every third row). The values inside the domain each get a tick.
format.format(..)(value: number, index: number) => stringa band’s category on a band scale of categories, else the value, with the decimals the step between ticks needs, thousands groupedThe text of each tick: the app’s function of its value (a row’s index on a point or band scale) and its index among the axis’ ticks.
labelClass, labelStyle.label_class(..), .label_style(..)string, StyleProp<TextStyle>noneThe label part: a text per tick, its font, color and padding the app’s; its padding toward the plot is its distance from the edge (pt-2 under a bottom axis).
tickClass, tickStyle.tick_class(..), .tick_style(..)string, StyleProp<ViewStyle>noneThe tick part: a view per tick on the edge, centered on its tick and reaching toward the labels (h-1 w-px bg-border under a bottom axis); shown once styled.
lineClass, lineStyle.line_class(..), .line_style(..)string, StyleProp<ViewStyle>noneThe line part: a view along the whole edge, on the labels’ side (h-px bg-border under a bottom axis); shown once styled.

Outside, the labels sit in the plot’s margin, as d3’s axes sit in a chart’s margins: the plot keeps the size the app gives it, and the app gives it a margin on the axis’ side as deep as the labels (mb-6 for pt-2 text-xs labels under it), or its parent padding there. Two axes on one edge each label their own values, in their own style: the ends of a range faint and its middle firmer.

format is called once per tick when the ticks or the function change, in the next turn, never while the plot draws or follows the pointer: keep it pure (the same value, the same text). Without it, a tick reads as d3 writes it: 1,500, 0.25, −2. In Rust, plot_axis() makes an axis, its format a TickFormat::from_fn(|value, index| ..). An axis takes no pointer, and screen readers hear the plot rather than its labels.

#PlotLabel

A PlotLabel writes texts at points of the plot: a caption over a peak, a tag at a line’s last point, a note at a grid line’s end. Each of its texts names its point with values of the plot’s scales, as the marks do: a row’s index on a point or band scale (the middle of the row’s band), a value on a linear one. The text’s top edge sits on the point, and its align starts it there, centers it on it or ends it there. The plot’s layout places the texts, so they follow the plot’s size; they glide with the scales’ domains (motion.update) and fade in with the appear.

import { Area, Line, Plot, PlotLabel } from "nsh/base/plot";

const rows = [
  { day: "Mon", visitors: 1820 },
  { day: "Tue", visitors: 2140 },
  { day: "Wed", visitors: 1960 },
  { day: "Thu", visitors: 2480 },
  { day: "Fri", visitors: 2310 },
];
const [peak, last] = [3, rows.length - 1];

<Plot data={rows} y="visitors" yScale={{ type: "linear", domain: [0, 3000] }} class="mr-12 h-40">
  <Area class="fill-chart-1/20" />
  <Line class="stroke-chart-1 stroke-2" />
  {/* The peak's value over it, and a tag right of the last point, its middle at the point's height. */}
  <PlotLabel
    class="text-xs tabular-nums text-muted-foreground"
    texts={[
      { text: `High ${rows[peak].visitors}`, x: peak, y: rows[peak].visitors, align: "center", class: "-translate-y-full pb-1" },
      { text: `${rows[last].visitors}`, x: last, y: rows[last].visitors, class: "-translate-y-1/2 ml-2 rounded bg-chart-1 px-1.5 text-white" },
    ]}
  />
</Plot>
PropRustTypeDefaultDescription
texts.texts(..)PlotText[][]The texts it writes, each at its own point.
scale.scale(..)"y" | "y2""y"The y scale its texts’ y are values of (Dual axes).

Each text is a PlotText:

FieldTypeDefaultDescription
textstringWhat it reads.
xnumberWhere it goes along the x axis: a value of the x scale, a row’s index on a point or band scale (0.5 is halfway between the first two rows).
ynumberWhere its top edge goes along the y axis: a value of the y scale.
align"left" | "center" | "right""left"How it sits along x from its point: starting at it, centered on it, or ending at it.
class, stylestring, StyleProp<TextStyle>noneThe text’s own classes and style, over what it inherits from the label: its box (a tag’s background and padding) and its distance from the point.

The look is the app’s: the label’s class gives every text the text styles they inherit (font, size, color), and a text’s own class and style style it alone. A text goes above its point with -translate-y-full, and its padding is its gap from the point (pb-1); a tag beside a line’s last point sits in the margin the app leaves on that side (mr-12), as an axis’ labels do. In Rust, plot_label() makes a label, each text a PlotText::new(text, x, y) with .align(PlotTextAlign::Center), .class(..) and .style(..). The pointer passes through the texts to the plot, and screen readers hear the plot, whose accessibilityLabel says what the texts point out.

#Motion

Motion is the app’s, and none by default: everything shows at once. motion takes the timings of nsh ({ duration, easing, delay } and { stiffness, damping, mass }):

KeyTypeWhat moves
appeartimingThe marks draw in, with an appearGeneration: lines and areas from the leading edge, bars growing out of their base (one after another with a bar’s stagger); dots growing from nothing (one after another with a dot’s stagger); cells growing from their middles (one after another with a cell’s stagger); a pie’s and an arc’s ring sweeps in clockwise from its start; a radial line grows out of its inner radius, its dots with it; a sankey’s nodes and ribbons from the leading edge, its labels fading in.
updatetimingMarks move to the new shape when the app changes it: dots to their new values and sizes, a new row’s dot growing in and a gone row’s shrinking away; cells to their new colors (mixed in OKLab) and bands, a new row’s cell growing in and a gone row’s shrinking away; a pie’s and an arc’s radii, so a donut’s hole opens and closes, and their angles, so a gauge or a slice sweeps round to its new value (d3’s arcTween: the angles move, not the outline; a new slice opens from where the one before it ends; a change while one moves goes on from where it is drawn); a radial line’s points to their new values, angles and radii (a new row growing out of the inner radius), a radial grid’s rings with them; and a linear scale’s domain, the app’s domain or the extent of the marks’ values as rows come, which glides from the old one to the new one, the marks, the grid lines, the PlotAxises and the PlotLabels with it; and a sankey’s nodes, ribbons and labels, from the old flows’ layout to the new one, a change while they move going on from where they are drawn.
entertimingThe hover’s parts fade in when the pointer comes over the plot.
exittimingThey fade out after it leaves.
pointerspringThey glide from one row to the next when the keys or a screen reader step the hover; a pie’s slice lifts on it.

The hover’s parts follow the pointer in the same frame: as it moves, the cross line, the dots and the tooltip are on the row under it at once, so they track the hand, while their fade in and out (enter, exit) still animates.

The motion runs on the platform’s animator, with no JS per frame, and is complete at once for people who ask for reduced motion. A plot that a list makes again for a row that comes back draws in again, unless a PlotAppearScope around the list saw its appear finish.

A domain moves as d3 transitions a scale’s domain: the scale in between places every value, so the marks keep their shapes, the grid lines and the hover’s parts move with them, bars keep their corners, and a row that comes in with the new domain moves with the rest. A live chart that fits its y scale to its rows now and then (with some room, so the grid holds still between fits) glides to each new fit with an update timing. When the plot’s size changes, the marks take the new size at once.

It is the plot’s own motion, the timings of what a chart moves: its marks’ clip and its hover’s parts. A view’s entrances, exits and attention seekers are createMotion’s presets and keyframes, which an app can still play on the plot’s view or on the elements around it.

#PlotAppearScope

A list makes a row’s plot again each time the row comes back into view, so a sparkline with an appear draws in again whenever the list scrolls back to it. A PlotAppearScope around the list remembers the appears that have finished in it: a plot made again there whose appearGeneration finished shows its marks whole at once. A plot is remembered by its generation, so each row’s plot gets its own, the row’s key with whatever should replay it (`${symbol} ${period}`).

import { List } from "nsh";
import { Line, Plot, PlotAppearScope } from "nsh/base/plot";

<PlotAppearScope id={watchlist().id} class="flex-1">
  <List each={watchlist().rows} keyExtractor={(row) => row.symbol} estimatedItemSize={48} class="flex-1">
    {(row) => (
      <Plot
        data={row().closes}
        y="close"
        interactive={false}
        appearGeneration={`${row().symbol} ${period()}`}
        motion={{ appear: { duration: 400, easing: "easeOut" } }}
        class="h-8 w-24"
      >
        <Line class="stroke-chart-1 stroke-[1.5]" />
      </Plot>
    )}
  </List>
</PlotAppearScope>
PropRustTypeDefaultDescription
id.id(..)number | stringnoneWhat the scope remembers for: when it changes, the scope forgets and its plots draw in afresh, where they are. Name it after the content, such as a watchlist or a conversation; without one, the scope remembers for as long as it is shown.
children.child(..)JSX.ElementnoneWhat it holds: the list, or whatever makes its plots again as they come and go.

A new generation still draws the plot in, and so does a plot taken away before its appear finished, from the start. Once a plot has finished a new generation, its scope forgets the one before, so switching back to an earlier period draws the plot in again. The memory goes with the scope: a scope that is no longer shown, or one with a new id, draws its plots in afresh, and a plot remembers in the innermost scope around it. Its look is the app’s: class and style lay it out as any view’s (flex-1 around a list that fills the screen). In Rust, plot_appear_scope() makes one.

#Keyboard

An interactive plot is a stop of Tab (macOS, the web), and while it has the keyboard focus its keys move the same hover the pointer does: the cross line, the dots and the tooltip glide on the pointer spring, a pie’s slice lifts, onHoverChange reports the row, and screen readers read the accessibilityValue the app gives for it. Its focus ring is the app’s: a focus-visible: class or a focusStyle (focus-visible:outline-2 focus-visible:outline-ring).

KeysWhat they do
Right, LeftMove to the row on that side: the next or the previous row, the other way round on a reversed x scale, whose first row is at the right (over a pie, its slices in the rows’ order; over a sankey, its nodes; over a treemap, the leaf beside on the screen, as Up and Down do); with no row hovered, Right starts at the row at the left end and Left at the one at the right end (over a pie, the first and the last slice).
Home, EndMove to the first or the last row.
Up, DownOver cells: move to the cell above or below in the same column. Over cells the keys follow the grid on the screen (WAI-ARIA’s data grid): Right and Left move to the next cell along the line, Home and End to the line’s first and last cell; with no cell hovered, the first key starts at the top left cell (Left, Up and End at the bottom right one). At an edge the hover stays.
EscapeClears the hover.

The plot answers its keys while it has the focus, and Escape while a row is hovered; the rest of the time they go to the app and the views around it. Moving the focus away clears a hover the keys made. The plot takes no presses, so a plot inside a pressable card leaves the card its presses. Screen readers step through the rows too: VoiceOver’s swipe up and down, TalkBack’s scroll forward and back, and every screen reader that offers an element’s increment and decrement actions move to the next and the previous row, in the rows’ order on a reversed scale as well.

#Building charts

A chart is a plot, its marks, its axes and the app’s own elements around them. Give the y scale a domain and its axis the grid’s values (tickValues): each label then sits at its grid line. The hover’s cross line, dots and tooltip are the plot’s parts, styled and filled by the app; the row’s values go where the app shows them.

Grids read best as solid hairlines with a firmer baseline, which leaves a dashed line free to mean a reference value (the opening price below). An area reads lighter as a wash that fades out toward its baseline than as a flat fill. Each series’ hover dot takes its series’ color, and the color of the surface the chart sits on as its ring, so it cuts out of its line, and the tooltip sits at the top of the plot, inside its edges at the first and last rows: a domain with room above the highest value keeps it off the line.

import { signal, Text, View } from "nsh";
import { Area, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";

type Row = { time: string; price: number };

export function PriceChart(props: { rows: Row[]; low: number; high: number }) {
  const hovered = signal<number | null>(null);
  const shown = () => props.rows[hovered() ?? props.rows.length - 1];
  const middle = () => (props.low + props.high) / 2;
  return (
    <View class="gap-3 rounded-xl border border-border bg-card p-4">
      {/* The value of the row under the pointer, or the last one. */}
      <View class="flex-row items-baseline">
        <Text class="flex-1 text-sm text-muted-foreground">{shown().time}</Text>
        <Text class="text-right text-xl font-semibold tabular-nums text-foreground">{shown().price.toFixed(2)}</Text>
      </View>
      <Plot
        data={props.rows}
        y="price"
        yScale={{ type: "linear", domain: [props.low, props.high] }}
        accessibilityLabel={`Price, from ${props.rows[0].price} to ${props.rows[props.rows.length - 1].price}`}
        accessibilityValue={{ text: `${shown().time}, ${shown().price.toFixed(2)}` }}
        appearGeneration={props.rows.length}
        motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 300, damping: 30 } }}
        crossLineClass="w-px h-full bg-muted-foreground/50"
        tooltipOffset={4}
        tooltipContent={
          <View class="flex-row items-center gap-1.5 rounded-lg border border-border bg-popover px-2 py-1 shadow-md">
            <View class="h-0.5 w-2.5 rounded-full bg-chart-1" />
            <Text class="text-xs tabular-nums text-popover-foreground">{shown().price.toFixed(2)}</Text>
          </View>
        }
        onHoverChange={({ index }) => hovered.set(index)}
        class="mr-10 mb-6 h-48"
      >
        <Grid y={[middle(), props.high]} class="stroke-border" />
        {/* A wash that fades out toward the baseline. */}
        <Area backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" />
        <Grid y={[props.low]} class="stroke-muted-foreground/40" />
        <Grid y={[props.rows[0].price]} class="stroke-muted-foreground" style={{ strokeDasharray: "3 3" }} />
        <Line class="stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
        {/* The grid's values right of the plot, and the first and last times under it. */}
        <PlotAxis
          placement="right"
          tickValues={[props.low, middle(), props.high]}
          format={(v) => v.toFixed(0)}
          labelClass="pl-2 text-xs tabular-nums text-muted-foreground"
        />
        <PlotAxis
          tickValues={[0, props.rows.length - 1]}
          format={(i) => props.rows[i].time}
          labelClass="pt-2 text-xs text-muted-foreground"
        />
      </Plot>
    </View>
  );
}

A bar chart reads best with the tips of its bars rounded and their bases square (cornerRadii={{ topLeft: 4, topRight: 4 }}; in a stack only on its top bar), a hair of room between the bars of a band (groupPadding), the bars of the other rows muted while one is hovered (inactiveOpacity) and its band tinted (crossLineClass), and the values in small tabular figures over the bars, with the total over a stack. With paddingOuter half of paddingInner, the bands’ middles fall at equal steps, and a PlotAxis centers each month under its band; a maxBandWidth keeps a few bars narrow in a wide card.

import { signal, Text, View } from "nsh";
import { Bar, Grid, Plot, PlotAxis } from "nsh/base/plot";

type Sale = { month: string; online: number; store: number };

export function SalesChart(props: { rows: Sale[]; stacked: boolean }) {
  const hovered = signal<number | null>(null);
  const shown = () => props.rows[hovered() ?? props.rows.length - 1];
  const tip = { topLeft: 4, topRight: 4 };
  return (
    <View class="gap-3 rounded-xl border border-border bg-card p-4">
      <View class="flex-row items-baseline">
        <Text class="flex-1 text-sm text-muted-foreground">{shown().month}</Text>
        <Text class="text-right text-sm tabular-nums text-foreground">{`${shown().online + shown().store}k`}</Text>
      </View>
      <Plot
        data={props.rows}
        xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15, maxBandWidth: 56, groupPadding: 0.1 }}
        yScale={{ type: "linear", domain: [0, 130] }}
        accessibilityLabel="Sales per month, online and in store"
        appearGeneration={props.stacked ? 1 : 0}
        motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 150 }, exit: { duration: 150 }, pointer: { stiffness: 320, damping: 32 } }}
        crossLineClass="w-full h-full bg-foreground/5"
        onHoverChange={({ index }) => hovered.set(index)}
        class="mb-6 h-44"
      >
        <Grid y={[40, 80, 120]} class="stroke-border" />
        <Bar
          value="online"
          stack={props.stacked ? "sales" : undefined}
          cornerRadii={props.stacked ? 0 : tip}
          stagger={0.4}
          inactiveOpacity={0.45}
          label={props.stacked ? undefined : "online"}
          labelClass="pb-1 text-[11px] tabular-nums text-muted-foreground"
          class="fill-chart-1"
        />
        <Bar
          value="store"
          stack={props.stacked ? "sales" : undefined}
          cornerRadii={tip}
          stagger={0.4}
          inactiveOpacity={0.45}
          label={props.stacked ? (d) => String(d.online + d.store) : "store"}
          labelClass="pb-1 text-[11px] tabular-nums text-muted-foreground"
          class="fill-chart-2"
        />
        {/* A month centered under each band. */}
        <PlotAxis format={(i) => props.rows[i].month} labelClass="pt-2 text-xs text-muted-foreground" />
      </Plot>
    </View>
  );
}

A sparkline in a list row is a plot with interactive={false}: it draws its marks and follows no pointer.

#Accessibility

  • A plot is one element for screen readers, with the image role and the app’s accessibilityLabel; its marks, its axes’ labels, its PlotLabels and the hover’s parts are parts of it and say nothing themselves. Say in the label what the chart shows and what stands out (the range, the highest value), and give accessibilityValue the words for the row under the pointer, so a screen reader says the value as it changes.
  • The row under the pointer is reported with onHoverChange on platforms with a pointer (desktops, the web, an iPad with a trackpad). Touch screens report no hover: show the values a touch user needs in the app’s own elements, or offer the rows as text or a list where the numbers matter.
  • Over a pie the row reported is the slice’s under the pointer: give accessibilityValue its name, value and share ("Team, 340 seats, 34.0%"), and say the total and the largest share in the label. Over a radar it is the axis nearest the pointer: give it the axis’ name and each series’ value there.
  • An interactive plot is a stop of the keyboard focus whose arrow keys move the hover (Keyboard), and it offers screen readers increment and decrement to step through the rows (VoiceOver reads it as adjustable); a plot with interactive={false} is neither, and it takes no presses either way.

See Accessibility for labels and roles in general.

#Platform notes

  • Every platform draws the marks the same way, from one drawing per mark recorded in Rust; a change of the rows draws the marks that read them again in one batch, and a change of a paint class (the appearance, dark:) sends the drawing again without recording it.
  • The appear clips lines and areas, and grows bars, with values the platform’s animator moves; a bar’s fading away from the hovered row is an opacity the animator computes from the hover’s place; the hover’s parts are views the animator moves and fades, and it keeps the tooltip inside the plot from the plot’s size and the tooltip’s.
  • An area’s background is drawn in its own canvas, clipped to the area’s shape, with the same gradient the platform draws for a view’s bg-linear-*; a change of its classes (the appearance, dark:) sends the drawing again without recording it.
  • A pie draws each slice in its own canvas; the slice under the pointer is found in Rust from the slices’ angles and radii, its lift is a radius and the others’ fade an opacity the animator moves, and the appear’s sweep moves the slices’ angles the same way, so neither records a drawing again.
  • A radial line’s row under the pointer is found in Rust from its points’ angles; its hover dot is a view the animator places on the row under the pointer (gliding on the pointer spring as the keys step), and while it appears or moves to new values its points are numbers the animator moves from where they were, so the line is recorded once per change.
  • A dot mark is drawn in one canvas; the dot under the pointer is found in Rust from the dots’ centers and radii, and its hover dot is a view the animator places on it, as big as it; the appear’s growth, a change’s moves and the hover’s fade are values the animator moves, so the dots are recorded once per change.
  • A cell mark is drawn in one canvas; the cell under the pointer is found in Rust from the cells’ boxes, and its hover cell is a view the animator places on it; the growth, a change’s colors and moves and the hover’s fade are values the animator moves, so the cells are recorded once per change. Each label is a text of the platform’s, shown where its laid-out size fits inside its cell.
  • An axis’ labels, tick marks and line are the platform’s own texts and views, placed by the layout in Rust from the plot’s size and scales: they move with the marks when the plot’s size changes, and a new domain writes the labels again.