Elements

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 Grid of lines across. 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 shows a background (a gradient, bg-linear-*) inside its shape, and the hover’s parts (each mark’s dot among them) and the axis labels are the app’s to style and fill.

el-plot.tsx
el-plot.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Area, Grid, Line, Plot } 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, so its labels sit at the
// grid's values; 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.
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]}` },
};

// 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 };

function App() {
  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" : "Pick a range to draw other rows"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <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>
            <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 }}>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>

          {/* Above the x axis' labels, so the tooltip covers them. */}
          <View class="flex-row gap-2" style={{ zIndex: 1 }}>
            <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={{ flex: 1, height }}
            >
              <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 }}
              />
            </Plot>
            <View style={{ justifyContent: "space-between", marginVertical: -7, width: 28 }}>
              {[hi(), (lo() + hi()) / 2, lo()].map((v) => (
                <Text style={{ fontSize: 11, color: ui().muted, ...num }}>{label(v)}</Text>
              ))}
            </View>
          </View>
          <View class="flex-row justify-between" style={{ marginRight: 36 }}>
            {RANGES[range()].ticks.map((t) => (
              <Text style={{ fontSize: 11, color: ui().muted }}>{t}</Text>
            ))}
          </View>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{
    plot::{area, grid, line, plot},
    theme,
};
use nsh_core::ui::{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 and Grid 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) or linear with an optional domain.
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.
interactive.interactive(..)booleantrueThe plot follows the pointer: it reports the row under it and shows the hover’s parts. false draws the marks only (a sparkline in a list).
motion.motion(..)PlotMotionnoneThe timing of the appear, the hover’s fade in and out, and the spring the hover follows the pointer with (below).
appearGeneration.appear_generation(..)numbernoneOpts in to the appear: the marks draw in when the plot is first shown, and again whenever this changes (another symbol, another period).
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.
dotClass, dotStyle.dot_class(..), .dot_style(..)string, StyleProp<ViewStyle>noneThe hover’s dots: a view per Line and Area, centered on its point at the row 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.
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".
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.
EventRustPayloadWhen
onHoverChange.on_hover_change(..)index: number | nullThe pointer came over another row: the index of the row nearest to it along the x axis; null once it leaves the plot.

Scale is { type: "point" } | { type: "linear"; domain?: [number, number] }. A domain is the extent of its two values, in either order; a domain of one value puts it in the middle.

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.

#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.
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) 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.
Areay0numberthe bottom edgeThe value of its baseline on the y scale.
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.

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, 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.

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.

#Motion

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

KeyTypeWhat moves
appeartimingThe marks draw in from the leading edge, with an appearGeneration.
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 while the pointer moves over the plot.

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; give it the row’s key as its appearGeneration.

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.

#Building charts

A chart is a plot, its marks and the app’s own elements around them. Give the y scale a domain when the app labels it: the labels then sit at the grid’s values. 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 } 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>
      <View class="flex-row gap-2">
        <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="h-48 flex-1"
        >
          <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" />
        </Plot>
        {/* The y axis' labels, at the grid's values (Text until an axis mark lands). */}
        <View class="-my-2 w-8 justify-between">
          {[props.high, middle(), props.low].map((v) => (
            <Text class="text-right text-xs tabular-nums text-muted-foreground">{v.toFixed(0)}</Text>
          ))}
        </View>
      </View>
      <View class="mr-10 flex-row justify-between">
        <Text class="text-xs text-muted-foreground">{props.rows[0].time}</Text>
        <Text class="text-xs text-muted-foreground">{props.rows[props.rows.length - 1].time}</Text>
      </View>
    </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 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.
  • A plot takes no presses and no keyboard focus.

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 the marks with a value the platform’s animator moves; 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.