요소
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.
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)))
}import { mount, signal, Text, View } from "nsh";
import { Area, Grid, Line, Plot } 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 },
];
function App() {
const hovered = signal<number | null>(null);
return (
<View class="gap-2 p-4">
<Text class="text-sm text-muted-foreground tabular-nums">
{hovered() == null ? "Visitors" : `${rows[hovered()!].day}: ${rows[hovered()!].visitors}`}
</Text>
<Plot
data={rows}
y="visitors"
yScale={{ type: "linear", domain: [0, 3000] }}
accessibilityLabel="Visitors per day, highest on Thursday"
onHoverChange={({ index }) => hovered.set(index)}
crossLineClass="w-px h-full bg-border"
dotClass="size-2.5 rounded-full border-2 border-background bg-chart-1"
class="h-40"
>
<Grid y={[0, 1500, 3000]} class="stroke-border" />
<Area class="fill-chart-1/20" />
<Line class="stroke-chart-1 stroke-2" />
</Plot>
</View>
);
}
mount(App);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
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
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) => number | the row’s index | What 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) => number | none | What 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(..) | LinearScale | none | A 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(..) | boolean | true | While 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(..) | boolean | true | The 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(..) | PlotMotion | none | The 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 | string | none | Opts 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(..) | string | none | What screen readers say of the chart: what it shows and what stands out. |
accessibilityValue | .accessibility_value(..) | { min?, max?, now?, text? } | none | What 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> | none | The 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> | none | The 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.Element | none | The 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(..) | number | 0 | The distance between the tooltip and what places it: the top edge for "top", the cross line and the pointer for "pointer". |
colorScale | .color_scale(..) | ColorScale | none | The 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.Element | none | The marks, drawn in order over each other, and whatever the app lays over them. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The 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. |
| Event | Rust | Payload | When |
|---|---|---|---|
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
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
| Mark | Prop | Type | Default | Description |
|---|---|---|---|---|
Line | y | string | (d, index) => number | the plot’s y | What it draws: one point per row with a value, the rows without one skipped. |
Line, Area | data, x | T[], string | (d, index) => number | the plot’s rows | Rows 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, Area | curve | "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. |
Line | dot, dotClass, dotStyle | boolean, string, StyleProp<ViewStyle> | false | A dot on every point: a view centered on it, its size, background and border the app’s. |
Line, Area | hoverDotClass, hoverDotStyle | string, StyleProp<ViewStyle> | the plot’s dotClass, dotStyle | Its 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. |
Area | backgroundClass, backgroundStyle | string, StyleProp<ViewStyle> | none | Its 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. |
Area | y1 | string | (d, index) => number | the plot’s y | What makes its top edge. |
Area | y0 | number | string | (d, index) => number | the y scale’s low edge | Its 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, Grid | scale | "y" | "y2" | "y" | The y scale its values go along: the plot’s yScale or its second one, y2Scale (Dual axes). |
Grid | x | number[] | none | Where its vertical lines go, as values of the x scale (row indices on a point scale). |
Grid | y | number[] | none | Where 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). |
Grid | ticks | number | the axis’ default | About how many lines it draws with neither x nor y, as a PlotAxis’s ticks. |
Line, Area | color | string | (d, index) => string | none | The 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:
| Mark | Paints with | Where none is set |
|---|---|---|
Line | stroke, strokeWidth, strokeDasharray | currentColor, 1 wide, solid |
Area | fill (over its background part); its top edge stroke, strokeWidth, strokeDasharray | fill currentColor (none beside a background); no stroke |
Grid | stroke, strokeWidth, strokeDasharray | currentColor, 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}`} />
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | (d, index) => number | the plot’s y | What each bar shows. |
base | number | string | (d, index) => number | 0 | The 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. |
stack | string | none | The 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. |
cornerRadii | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 0 | The 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. |
stagger | number | 0 | With 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. |
inactiveOpacity | number | none | The 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. |
label | string | (d, index) => string | none | The text at each bar’s value end: a field (its text, or its number) or a function returning the text the app formats. |
labelClass, labelStyle | string, StyleProp<TextStyle> | none | The 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, backgroundStyle | string, StyleProp<ViewStyle> | none | Its 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. |
color | string | (d, index) => string | none | Each 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:
| Option | Default | Description |
|---|---|---|
domain | none | The 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. |
paddingInner | 0 | The gap between neighbouring bands, a share of each band’s step (0 to 1). |
paddingOuter | 0 | The 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. |
maxBandWidth | none | The widest a band may be, in points: a few bands across a wide plot stay narrow, each in the middle of its place. |
bandCount | the rows | The 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. |
groupPadding | 0 | The gap between the bars side by side in a band, a share of each bar’s place. |
reverse | false | The 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
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>
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | (d, index) => number | the plot’s y | Where each line ends. A row without a value has no line. |
base | number | string | (d, index) => number | 0 | Where 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). |
stagger | number | 0 | With 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. |
inactiveOpacity | number | none | The opacity of its lines away from the row under the pointer while the pointer is over the plot, as Bar’s. |
color | string | (d, index) => string | none | Each 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
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>
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | (d, index) => number | the plot’s y | Where 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. |
r | number | string | (d, index) => number | 3 | The 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] } | none | How 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. |
stagger | number | 0 | With 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. |
inactiveOpacity | number | none | The opacity of its other dots while one is under the pointer or the keys: they fade in and out with the hover. |
color | string | (d, index) => string | none | Each dot’s category, its color from the plot’s colorScale: the dot’s fill where nothing sets one (Colors by category). |
hitRadius | number | 8 | How 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, hoverDotStyle | string, StyleProp<ViewStyle> | the plot’s dotClass, dotStyle | The 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
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>
| Prop | Type | Default | Description |
|---|---|---|---|
x | string | (d, index) => string | none | The 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. |
y | string | (d, index) => string | none | The line of each row, as x along the y scale. |
value | string | (d, index) => number | none | The 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. |
color | string | (d, index) => string | none | Each cell’s category, its color from an ordinal colorScale (Colors by category). |
cornerRadii | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 0 | The radius of each corner of its cells. |
stagger | number | 0 | With 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. |
inactiveOpacity | number | none | The 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. |
label | string | (d, index) => string | none | The 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, labelStyle | string, StyleProp<TextStyle> | none | The 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, hoverCellStyle | string, StyleProp<ViewStyle> | none | The 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.
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())),
)
}
| Mark | Prop | Rust | Type | Default | Description |
|---|---|---|---|---|---|
Pie | value | .value(..) | keyof T | (d: T, index: number) => number | the plot’s y | What each row is worth. A row worth nothing, less, or no number has no slice. |
Pie, Arc | startAngle, endAngle | .start_angle(..), .end_angle(..) | number (radians) | 0, 2 * Math.PI | Where 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. |
Pie | padAngle | .pad_angle(..) | number (radians) | 0 | The 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, Arc | innerRadius, 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, Arc | cornerRadius | .corner_radius(..) | number | 0 | The 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. |
Pie | sliceClass | .slice_class(..) | string | (d: T, index: number) => string | none | The 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. |
Pie | sliceStyle | .slice_style(..) | StyleProp<ViewStyle> | none | The style of every slice, over its classes (fill, stroke, strokeWidth). |
Pie | hoverLift | .hover_lift(..) | number | 0 | How 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. |
Pie | hoverDim | .hover_dim(..) | number (0 to 1) | 0 | How much the other slices fade while one is under the pointer, as a share of their opacity, with the hover’s fade. |
Pie | color | .color(..) | keyof T | (d: T, index: number) => string | none | Each slice’s category, its color from the plot’s colorScale: the slice’s fill where nothing sets one (Colors by category). |
Pie | label | .label(..) | string | (d: T, index: number) => string | none | The 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. |
Pie | labelClass, labelStyle | .label_class(..), .label_style(..) | string, StyleProp<TextStyle> | none | The 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. |
Pie | labelGap | .label_gap(..) | number | 15 | How far past the rim the labels sit, in points. |
Pie | labelLineClass, labelLineStyle | .label_line_class(..), .label_line_style(..) | string | (d: T, index: number) => string, StyleProp<ViewStyle> | none | The 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.
OrdinalScale | Type | Default | Description |
|---|---|---|---|
type | "ordinal" | (required) | The kind of scale. |
domain | string[] | none | The categories, in order. A category written twice keeps its first place. |
range | string[] | (required) | The colors, in order (CSS colors); category i takes color i % range.length. |
unknown | string | none | The 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, DivergingScale | Type | Default | Description |
|---|---|---|---|
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. |
pivot | number | 0 | diverging only: the value of the ramp’s middle color. |
range | string[] | (required) | The colors along the ramp, from the low end to the high one (CSS colors), mixed in OKLab between them. |
unknown | string | none | The 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>
);
}
| Mark | Prop | Rust | Type | Default | Description |
|---|---|---|---|---|---|
RadialLine | radius | .radius(..) | keyof T | (d: T, index: number) => number | the plot’s y | What 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. |
RadialLine | angle | .angle(..) | keyof T | (d: T, index: number) => number | the rows evenly round the turn | Where each row goes round the center: radians from 12 o’clock, clockwise. |
RadialLine, RadialGrid | startAngle, endAngle | .start_angle(..), .end_angle(..) | number (radians) | 0, 2 * Math.PI | Where 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, RadialGrid | innerRadius, 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. |
RadialLine | curve | .curve(..) | "linear" | "natural" | "stepAfter" | "linear" | How it connects its points: straight segments, a smooth curve (closed, a smooth loop), or steps. |
RadialLine | closed | .closed(..) | boolean | false | It 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). |
RadialLine | dot, dotClass, dotStyle | .dot(..), .dot_class(..), .dot_style(..) | boolean, string, StyleProp<ViewStyle> | false | A 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. |
RadialLine | backgroundClass, backgroundStyle | .background_class(..), .background_style(..) | string, StyleProp<ViewStyle> | none | Its background: a box around its outer circle whose background (bg-*, a gradient) shows only inside its area, under its fill, as an area’s. |
RadialLine | hoverDotClass, hoverDotStyle | .hover_dot_class(..), .hover_dot_style(..) | string, StyleProp<ViewStyle> | the plot’s dotClass, dotStyle | Its 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. |
RadialGrid | rings | .rings(..) | number[] | none | Where its rings go, as values of the y scale: each as far out as a radial line puts that value. |
RadialGrid | spokes | .spokes(..) | boolean | false | A line from innerRadius to outerRadius at each row’s angle. |
RadialGrid | circular | .circular(..) | boolean | false | Its 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")),
)
}
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
links | .links(..) | SankeyLink[] ({ source: number; target: number; value: number }) | required | The 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(..) | number | 24 | The width of each node, in points. |
nodePadding | .node_padding(..) | number | 8 | The 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(..) | number | 6 | How 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(..) | number | 1 | The thinnest a ribbon draws, in points, so a small flow stays visible. |
nodeCornerRadius | .node_corner_radius(..) | number | 0 | The radius of each node’s corners, in points. |
nodeClass | .node_class(..) | string | (d: T, index: number) => string | none | The 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> | none | The style of every node, over its classes. |
linkClass | .link_class(..) | string | (link: SankeyLink, index: number) => string | none | The 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> | none | The style of every ribbon, over its classes. |
linkBackgroundClass | .link_background_class(..) | string | (link: SankeyLink, index: number) => string | none | The 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> | none | The style of every ribbon’s background, over its classes. |
hoverDim | .hover_dim(..) | number (0 to 1) | 0 | How 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) => string | none | The 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) => string | none | The 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(..) | string | none | The 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> | none | The style of each label, over its classes. |
valueLabelClass | .value_label_class(..) | string | none | The classes of the value line, the valueLabel part, over the label’s. |
valueLabelStyle | .value_label_style(..) | StyleProp<TextStyle> | none | The 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.
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())),
)
}
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
id | .id(..) | keyof T | (d: T, index: number) => string | the row’s index | The 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) => string | none: every row is a top-level node | The 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) => number | required | The 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(..) | number | the golden ratio | The aspect ratio the tiles approach, at least 1. |
paddingInner | .padding_inner(..) | number | number[] | 0 | The gap between siblings, in points, per level from the top one down (the last for those below). |
paddingOuter | .padding_outer(..) | number | number[] | 0 | The room inside each group around its children, per level. |
paddingTop | .padding_top(..) | number | number[] | 0 | The 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? } | square | The radius of each corner of its rectangles. |
color | .color(..) | keyof T | (d: T, index: number) => string | none | The category of each node, which an ordinal colorScale gives a color. |
colorValue | .color_value(..) | keyof T | (d: T, index: number) => number | none | The number of each node, which a sequential or diverging colorScale makes its color. |
leafClass | .leaf_class(..) | string | (d: T, index: number) => string | none | The classes of each leaf (fill-*, stroke-*), the same on every leaf or a function of its row. |
leafStyle | .leaf_style(..) | StyleProp<ViewStyle> | none | The style of every leaf, over its classes. |
groupClass | .group_class(..) | string | (d: T, index: number) => string | none | The classes of each group, a rectangle under its children; groups are drawn only with a class or a style. |
groupStyle | .group_style(..) | StyleProp<ViewStyle> | none | The style of every group, over its classes. |
inactiveOpacity | .inactive_opacity(..) | number (0 to 1) | none | The 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) => string | none | The name in each leaf, on one line, cut with an ellipsis. |
valueLabel | .value_label(..) | (d: T, value: number, index: number) => string | none | The 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(..) | string | none | The 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> | none | The style of each leaf’s label, over its classes. |
valueLabelClass | .value_label_class(..) | string | none | The classes of the value line, over the label’s. |
valueLabelStyle | .value_label_style(..) | StyleProp<TextStyle> | none | The style of the value line, over its classes. |
groupLabel | .group_label(..) | keyof T | (d: T, index: number) => string | none | The name of each group, in its band, cut with an ellipsis. |
groupLabelClass | .group_label_class(..) | string | none | The classes of each group’s label, a box as big as the band. |
groupLabelStyle | .group_label_style(..) | StyleProp<TextStyle> | none | The style of each group’s label, over its classes. |
stagger | .stagger(..) | number (0 to 0.95) | 0 | How the top-level nodes appear one after another: the share of the appear over which they start. |
hoverClass | .hover_class(..) | string | none | The 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> | none | The 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>
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
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(..) | number | 10 on a linear scale (every step of aligned scales), every row on the others | About 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[] | none | The 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) => string | a band’s category on a band scale of categories, else the value, with the decimals the step between ticks needs, thousands grouped | The 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> | none | The 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> | none | The 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> | none | The 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>
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
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:
| Field | Type | Default | Description |
|---|---|---|---|
text | string | What it reads. | |
x | number | Where 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). | |
y | number | Where 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, style | string, StyleProp<TextStyle> | none | The 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 }):
| Key | Type | What moves |
|---|---|---|
appear | timing | The 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. |
update | timing | Marks 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. |
enter | timing | The hover’s parts fade in when the pointer comes over the plot. |
exit | timing | They fade out after it leaves. |
pointer | spring | They 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>
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
id | .id(..) | number | string | none | What 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.Element | none | What 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).
| Keys | What they do |
|---|---|
| Right, Left | Move 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, End | Move to the first or the last row. |
| Up, Down | Over 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. |
| Escape | Clears 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, itsPlotLabels 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 giveaccessibilityValuethe 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
onHoverChangeon 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
accessibilityValueits 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
incrementanddecrementto step through the rows (VoiceOver reads it as adjustable); a plot withinteractive={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
pointerspring 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.