Elementi
Plot
Un plot è la cornice di un grafico: le righe di dati che mostra, le scale dei suoi assi x e y, il puntatore sopra di esso e il suo movimento. I suoi mark disegnano ciò che contiene: una Line attraverso una serie, un’Area tra una serie e una linea di base, una Bar per riga nelle bande di una scala a bande, un Rule per riga da un inizio a una fine (lo stoppino di una candela), un Dot per riga dove si incontrano due dei suoi valori (i punti di uno scatter, le bolle di un grafico a bolle), una Cell per riga nelle bande di due categorie, colorata in base al suo valore (una heatmap), una Grid di linee trasversali, una Pie delle quote delle righe (una ciambella con un foro), un Arc tra due angoli, e un RadialLine attorno al centro con gli anelli e i raggi di una RadialGrid dietro di esso (un radar), un Sankey di flussi tra le righe, e un Treemap di rettangoli annidati, con l’area di ogni foglia pari al suo valore (una market map); un PlotAxis etichetta un bordo con i tick della sua scala, e un PlotLabel scrive testi in punti delle scale. Ogni mark è un canvas che riempie il plot, disegnato nativamente a partire dalle righe: le righe dell’app vengono lette una sola volta, quando le imposta, poi scalate, registrate e disegnate in Rust, quindi un grafico di mille punti non esegue alcun JS per punto. Anche il puntatore è seguito in Rust: la linea di intersezione, i punti e il tooltip dell’hover si muovono e sfumano sull’animator della piattaforma, e l’app viene informata solo di una nuova riga, nient’altro. L’aspetto spetta all’app: i mark si dipingono con le proprietà di pittura CSS delle forme SVG (stroke-*, fill-*), currentColor dove nessuna è impostata, un’area o una barra mostra uno sfondo (un gradiente, bg-linear-*) all’interno della propria forma, e le parti dell’hover (tra cui il punto di ogni mark) e le etichette, i segni di graduazione e le linee degli assi sono a disposizione dell’app per stile e contenuto.
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 e PlotLabel provengono da nsh/base/plot, un modulo di nsh/base a sé stante (in Rust, nsh_base::plot). In TypeScript le prop di un plot sono generiche rispetto al tipo delle sue righe: i nomi dei campi vengono verificati rispetto ad esso, e una funzione di una riga riceve il tipo della riga. In Rust, plot() ne crea uno con un setter per ogni prop, le righe sono un Rows (colonne di numeri per chiave), i valori sono un RowAccessor (una chiave o una funzione dell’indice della riga), e i mark usano i metodi di pittura tipizzati (.stroke_color(..), .fill_color(..), .stroke_width(..)).
#Plot
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
data | .data(..) | T[] | (obbligatorio) | Le righe, un oggetto per punto. I mark leggono i loro valori una sola volta, quando l’app imposta le righe; una riga senza valore (un campo che non è un numero, null) viene saltata. |
x | .x(..) | keyof T | (d: T, index: number) => number | l’indice della riga | Ciò che colloca una riga lungo l’asse x in una scala x linear. Una scala point colloca le righe in ordine. |
y | .y(..) | keyof T | (d: T, index: number) => number | nessuno | Ciò che i mark disegnano, a meno che non indichino il proprio. |
xScale | .x_scale(..) | Scale | { type: "point" } | Come l’asse x colloca le righe: point (in ordine, in modo uniforme dal bordo sinistro a quello destro; una sola riga al centro), linear con un domain opzionale, oppure band (una banda per riga, per le barre, Bar; o una banda per categoria, per le celle, Cell). Con una scala y band il valore predefinito è linear, per le barre orizzontali. reverse: true la fa partire dal bordo destro (Scale invertite). |
yScale | .y_scale(..) | Scale | { type: "linear" } | Come l’asse y colloca i valori: il valore più piccolo del dominio sul bordo inferiore, il più grande su quello superiore. Senza un domain, l’estensione di ciò che i mark disegnano. band dispone una banda per riga dal bordo superiore verso il basso, per le barre orizzontali. reverse: true la fa correre nell’altro verso: il valore più piccolo sul bordo superiore, le righe dal bordo inferiore verso l’alto (Scale invertite). |
y2Scale | .y2_scale(..) | LinearScale | nessuno | Una seconda scala y, { type: "linear", domain?, reverse? }, per i mark che la nominano (scale="y2"): la scala di destra di un grafico a doppio asse (Assi doppi). Senza un domain, l’estensione dei valori su di essa. |
alignTicks | .align_ticks(..) | boolean | true | Finché nessuna delle due scale y indica un dominio e ci sono mark su entrambe, entrambe vengono ampliate negli stessi passi arrotondati, in modo che i loro tick cadano sulle stesse linee (Assi doppi). false lascia a ciascuna l’estensione dei propri valori. |
interactive | .interactive(..) | boolean | true | Il plot segue il puntatore e la tastiera: segnala la riga sotto di esso e mostra le parti dell’hover, ed è una tappa di Tab i cui tasti freccia spostano l’hover (Tastiera). false disegna solo i mark (una sparkline in una lista), e le pressioni su di esso vanno alle view circostanti. Se il plot offra o meno la tastiera viene deciso alla sua creazione. |
motion | .motion(..) | PlotMotion | nessuno | I tempi dell’apparizione, della dissolvenza in entrata e in uscita dell’hover, e della molla su cui l’hover scivola quando i tasti lo spostano (sotto). |
appearGeneration | .appear_generation(..) | number | string | nessuno | Attiva l’apparizione: i mark vengono disegnati progressivamente quando il plot viene mostrato per la prima volta, e di nuovo ogni volta che questo valore cambia (un altro simbolo, un altro periodo). All’interno di un PlotAppearScope, una generazione che vi si è conclusa viene mostrata subito per intero. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che gli screen reader dicono del grafico: cosa mostra e cosa spicca. |
accessibilityValue | .accessibility_value(..) | { min?, max?, now?, text? } | nessuno | Ciò che gli screen reader sentono come suo valore: le parole dell’app per la riga sotto il puntatore. |
crossLineClass, crossLineStyle | .cross_line_class(..), .cross_line_style(..) | string, StyleProp<ViewStyle> | nessuno | La linea di intersezione: una view alta quanto il plot, centrata sulla riga sotto il puntatore. In una scala a bande si colloca nella banda della riga, larga quanto la banda (attraverso il plot in una scala y a bande). |
dotClass, dotStyle | .dot_class(..), .dot_style(..) | string, StyleProp<ViewStyle> | nessuno | I punti dell’hover: una view per ogni Line, Area e RadialLine, centrata sul suo punto nella riga sotto il puntatore, e quella di un Dot grande quanto il suo punto sotto il puntatore. hoverDotClass / hoverDotStyle di un mark li sostituiscono per il suo punto. |
tooltipContent | .tooltip_content(..) | JSX.Element | nessuno | Il tooltip dell’app, posizionato da tooltipPlacement e mosso e sfumato insieme alla linea di intersezione. |
tooltipPlacement | .tooltip_placement(..) | "top" | "pointer" | "top" | Dove va il tooltip: in cima al plot, centrato sulla linea di intersezione; oppure accanto al puntatore, sul lato della linea di intersezione rivolto verso il centro del plot e sotto il puntatore nella metà superiore, sopra di esso in quella inferiore. In cima resta tra i bordi sinistro e destro del plot (il suo scostamento dal bordo superiore spetta all’app: un tag sotto il plot assume l’altezza del plot e oltre); accanto al puntatore resta all’interno del plot. Con la tastiera, va accanto al punto della prima linea o area nella riga (il centro della banda di una barra, il centro della fetta di un pie). |
tooltipOffset | .tooltip_offset(..) | number | 0 | La distanza tra il tooltip e ciò che lo posiziona: il bordo superiore per "top", la linea di intersezione e il puntatore per "pointer". |
colorScale | .color_scale(..) | ColorScale | nessuno | I colori dei mark: delle categorie che il loro color legge, come scaleOrdinal di d3 ({ type: "ordinal", domain?, range, unknown? }, Colori per categoria), oppure dei numeri che il value di un Cell legge lungo una rampa, come scaleSequential e scaleDiverging di d3 ({ type: "sequential" | "diverging", … }, Colori lungo una rampa). |
children | .child(..) | JSX.Element | nessuno | I mark, disegnati in ordine uno sopra l’altro, e tutto ciò che l’app sovrappone ad essi. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | La dimensione e l’aspetto del plot; i mark ereditano le sue proprietà di pittura, come CSS le eredita. Le classi focus: / focus-visible: e focusStyle disegnano il suo anello di focus. |
| Evento | Rust | Payload | Quando |
|---|---|---|---|
onHoverChange | .on_hover_change(..) | { index: number | null } | Il puntatore è passato su un’altra riga: l’indice della riga più vicina ad esso lungo l’asse x (lungo l’asse y quando solo questo ha bande), della fetta del pie sotto di esso, del dot il cui centro è più vicino ad esso, della cell sotto di esso (nello spazio tra due celle, la più vicina), oppure della riga di una radial line più vicina ad esso per angolo; null quando esce dal plot (su un pie, le sue fette; su una radial line, il suo raggio esterno). |
Scale is { type: "point"; reverse? } | { type: "linear"; domain?: [number, number]; reverse? } | { type: "band"; domain?: string[]; paddingInner?; paddingOuter?; maxBandWidth?; bandCount?; groupPadding?; reverse? }. A linear Il domain di una scala è l’estensione dei suoi due valori, in qualsiasi ordine; un dominio di un solo valore lo colloca al centro. Il domain di una scala band è costituito dalle categorie delle sue bande, in ordine (Cell); le sue altre opzioni sono in Bar.
Una funzione di una riga (y={(d) => d.high - d.low}) viene chiamata una volta per riga quando cambiano le righe o la funzione, nel turno successivo, mai mentre il plot disegna o segue il puntatore: mantienila pura (stessa riga, stesso numero). Il nome di un campo viene letto senza alcun JS.
#Scale invertite
reverse: true fa correre una scala dal suo altro bordo, come le scale di visx accettano reverse e quelle di d3 accettano un range scritto al contrario. Sull’asse x la prima riga, o il valore più piccolo del dominio, si trova sul bordo destro; sull’asse y il valore più piccolo del dominio si trova sul bordo superiore e le righe corrono dal bordo inferiore verso l’alto. Tutto ciò che è collocato sulla scala la segue: i mark, la base delle barre e il verso in cui crescono, la linea di base di un’area, le linee della griglia, i tick di PlotAxis, la riga che l’hover trova, i tasti freccia (Tastiera) e gli spostamenti verso un nuovo dominio. Le barre pendono dal bordo superiore su una scala y linear invertita, e le barre orizzontali crescono verso sinistra dal bordo destro su una scala x linear invertita:
<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>
Con l’apparizione del plot, linee e aree vengono disegnate progressivamente a partire dal bordo da cui parte la scala x, quello destro quando è invertita.
#Assi doppi
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);Un plot ha una seconda scala y per valori di altre unità: i volumi accanto a un prezzo, i ricavi accanto a un margine. Ogni parte che colloca valori lungo y accetta scale: "y" (il valore predefinito, il yScale del plot) oppure "y2" (il suo y2Scale). Un PlotAxis a destra con scale="y2" la etichetta:
<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>
Il dominio di ciascuna scala è proprio: il domain dell’app, oppure l’estensione dei valori su di essa, e ciascuna si sposta verso uno nuovo con motion.update. Finché nessuna delle due scale indica un dominio, il plot dispone entrambe nello stesso numero di passi arrotondati (1, 2 o 5 volte una potenza di dieci, un passo ogni circa 32 punti di altezza): quando entrambe contengono lo zero, lo zero cade sulla stessa linea in entrambe; altrimenti una scala che parte da zero (i volumi) acquisisce i passi aggiuntivi verso l’alto e un’altra (un prezzo) da entrambi i lati. I tick degli assi sono quei passi, e una Grid senza x né y disegna le sue linee su di essi, così una sola griglia serve entrambi gli assi. alignTicks={false} disattiva questo comportamento; un domain indicato dall’app lascia entrambe le scale così come sono.
L’hover trova una riga; il punto di ogni mark si trova sulla propria scala, e la lettura è dell’app, che legge entrambi i valori della riga che onHoverChange riporta. Le barre su entrambe le scale stanno affiancate in ogni banda, e un stack impila solo le barre sulla sua scala. y2Scale è lineare: un plot il cui asse y ha bande non ha una seconda scala y.
#Line, Area e Grid
| Mark | Prop | Tipo | Default | Descrizione |
|---|---|---|---|---|
Line | y | string | (d, index) => number | y del plot | Ciò che disegna: un punto per ogni riga con un valore; le righe senza valore vengono saltate. |
Line, Area | data, x | T[], string | (d, index) => number | le righe del plot | Righe di una serie propria (altri valori x), posizionate sulle scale del plot tramite il proprio x (senza, l’indice della riga). |
Line, Area | curve | "natural" | "linear" | "stepAfter" | "natural" | Come collega i suoi punti: una curva morbida, segmenti retti, oppure gradini che mantengono ogni valore fino alla riga successiva. Il bordo inferiore di un’area segue la stessa curva. |
Line | dot, dotClass, dotStyle | boolean, string, StyleProp<ViewStyle> | false | Un pallino su ogni punto: una view centrata su di esso, con dimensione, sfondo e bordo decisi dall’app. |
Line, Area | hoverDotClass, hoverDotStyle | string, StyleProp<ViewStyle> | dotClass, dotStyle del plot | Il suo pallino di hover, sul punto della riga sotto il puntatore: il pallino di ogni serie nel proprio colore. Ciascuno sostituisce quello del plot per questo mark (il colore del mark prevale su dimensione e anello del plot); hidden non ne mostra alcuno. |
Area | backgroundClass, backgroundStyle | string, StyleProp<ViewStyle> | nessuno | Il suo sfondo: un riquadro attorno alla sua forma (dal primo all’ultimo punto, dal punto più alto alla linea di base, oppure fino al punto più basso del suo bordo inferiore in una banda) il cui sfondo (bg-*, bg-linear-to-b from-… to-…, backgroundImage) si vede solo dentro la forma, sotto il suo fill. Se ce n’è uno, fill è none dove non ne è impostato nessuno. |
Area | y1 | string | (d, index) => number | y del plot | Ciò che forma il suo bordo superiore. |
Area | y0 | number | string | (d, index) => number | il bordo inferiore della scala y | Il suo bordo inferiore: un numero è una linea di base sulla scala y; un campo o una funzione assegna a ogni riga un proprio valore inferiore, che rende l’area una banda (dal minimo al massimo di un giorno, l’intervallo di una previsione). Senza, l’area si chiude sul bordo dove si trovano i valori più piccoli della scala y: il bordo inferiore, oppure quello superiore su una scala invertita (un’area che pende dall’alto). Una riga senza valore su uno dei due bordi viene saltata. |
Line, Area, Grid | scale | "y" | "y2" | "y" | La scala y lungo cui scorrono i suoi valori: il yScale del plot o la sua seconda, y2Scale (Dual axes). |
Grid | x | number[] | nessuno | Dove vanno le sue linee verticali, come valori della scala x (indici di riga su una scala point). |
Grid | y | number[] | nessuno | Dove vanno le sue linee orizzontali, come valori della scala y. Senza x né y, le linee seguono i tick della sua scala, dove li colloca un PlotAxis senza ticks (lungo x per le barre orizzontali). |
Grid | ticks | number | il default dell’asse | Circa quante linee disegna senza x né y, come il ticks di un PlotAxis. |
Line, Area | color | string | (d, index) => string | nessuno | La categoria delle sue righe, con il colore preso dal colorScale del plot: la categoria della sua prima riga determina il stroke della linea (il fill dell’area) dove nulla ne imposta uno (Colors by category). |
Ogni mark riempie il plot e disegna con le proprie SVG paint properties, che prevalgono su quelle del plot, come CSS le eredita:
| Mark | Disegna con | Dove non ne è impostata nessuna |
|---|---|---|
Line | stroke, strokeWidth, strokeDasharray | currentColor, spessore 1, continuo |
Area | fill (sulla sua parte background); il suo bordo superiore stroke, strokeWidth, strokeDasharray | fill currentColor (none accanto a uno sfondo); nessun tratto |
Grid | stroke, strokeWidth, strokeDasharray | currentColor, spessore 1, continuo; ogni linea su pixel di dispositivo interi, così una linea sottile resta nitida |
I valori che un Line o un Area disegna, anche il y0 di un’area (la sua linea di base, oppure il valore inferiore di ogni riga), formano il dominio della scala y quando il plot non ne ha uno; quelli di una griglia no. Un mark viene ridisegnato solo quando cambia ciò che disegna (le sue righe, i valori, la curva, la dimensione o le scale del plot, il suo paint); il puntatore sposta le parti dell’hover e non ridisegna mai un mark.
Una banda è un Area il cui y0 legge le righe: il suo bordo superiore passa per y1, il suo bordo inferiore torna indietro per y0, entrambi con il curve dell’area, e l’appear li disegna entrambi a partire dal bordo iniziale. Il suo pallino di hover si trova sul bordo superiore; un Line sopra di essa segna il valore centrale:
<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 è quello di CSS, come in un browser: un colore. Un gradiente sotto una linea è lo sfondo di un’area, nel modo in cui una pagina mostra un gradiente in una forma (il background-image di un riquadro sotto clip-path): backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" sfuma dal punto più alto dell’area fino alla sua linea di base, con i token del tema e i colori dell’aspetto come lo sfondo di qualsiasi view.
#Bar
Un Bar disegna una barra per riga nella banda della riga su una scala band: verticale su una scala x a bande, orizzontale su una scala y a bande (la scala x allora linear). Ogni barra va da base al valore della riga sull’altra scala, così un valore negativo cresce nel verso opposto, e una riga senza valore non ha barra. Su una scala dei valori invertita le barre crescono verso il basso dal bordo superiore o verso sinistra dal bordo destro, e una scala a bande invertita pone la banda della prima riga a destra o in basso (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>
Le barre fuori da uno stack stanno affiancate in ogni banda, nell’ordine dei mark; le barre con lo stesso stack si impilano, e la barra di ogni riga parte da dove finisce quella che la precede nello stack:
<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 | Tipo | Default | Descrizione |
|---|---|---|---|
value | string | (d, index) => number | y del plot | Ciò che mostra ogni barra. |
base | number | string | (d, index) => number | 0 | Il valore da cui crescono le sue barre, sulla scala dei valori: uno per tutte le righe, oppure un campo o una funzione che assegna a ogni riga il proprio, così ogni barra copre dalla base della riga al suo valore (una riga senza base non ha barra). Conta nel dominio. Uno stack cresce dalla base della sua prima barra, riga per riga. |
stack | string | nessuno | Lo stack a cui si unisce: le barre con lo stesso stack si impilano in ordine (una riga senza valore non aggiunge nulla). |
scale | "y" | "y2" | "y" | La scala y lungo cui scorrono i valori delle sue barre verticali (Dual axes); le barre su entrambe stanno affiancate, e uno stack impila solo le barre sulla sua scala. |
cornerRadii | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 0 | Il raggio di ogni angolo delle sue barre; { topLeft: 4, topRight: 4 } arrotonda le punte delle barre verticali. Un raggio maggiore di metà barra viene ridotto per adattarsi. |
stagger | number | 0 | Con l’appear del plot, le barre partono una dopo l’altra, riga per riga, in questa quota dell’animazione (da 0 a 0.95), e ciascuna cresce per il resto; 0 le fa crescere insieme. |
inactiveOpacity | number | nessuno | L’opacità delle sue barre lontane dalla riga sotto il puntatore mentre il puntatore è sopra il plot, così che quella riga risalti: sfuma in entrata e in uscita con l’hover e passa da barra a barra insieme ad esso. |
label | string | (d, index) => string | nessuno | Il testo all’estremità del valore di ogni barra: un campo (il suo testo, o il suo numero) oppure una funzione che restituisce il testo formattato dall’app. |
labelClass, labelStyle | string, StyleProp<TextStyle> | nessuno | La parte label: un testo centrato sulla barra, sopra l’estremità di una barra verticale (sotto una che cresce verso il basso), dopo una barra orizzontale (prima di una che cresce all’indietro). Il suo padding è la distanza dalla barra. |
backgroundClass, backgroundStyle | string, StyleProp<ViewStyle> | nessuno | Il suo sfondo: il riquadro di ogni barra, il cui sfondo (bg-linear-to-t from-… to-…, dalla base della barra alla sua cima) si vede solo dentro la barra, sotto il suo fill. Se ce n’è uno, fill è none dove non ne è impostato nessuno. |
color | string | (d, index) => string | nessuno | La categoria di ogni barra, con il colore preso dal colorScale del plot: il fill della barra dove nulla ne imposta uno (Colors by category). |
La scala a bande dispone una banda per ogni riga del plot, in ordine, oppure una banda per ogni categoria del suo domain, come fa scaleBand di d3:
| Opzione | Default | Descrizione |
|---|---|---|
domain | nessuno | Le categorie delle sue bande, in ordine (il scaleBand().domain() di d3): un Cell va nella banda della categoria della sua riga, e un PlotAxis scrive la categoria di ogni banda; una categoria scritta due volte mantiene il suo primo posto. Senza, le bande sono le righe, oppure, sulle celle, le categorie che le celle nominano lungo l’asse, nell’ordine in cui le righe le portano. |
paddingInner | 0 | Lo spazio tra bande vicine, una quota del passo di ogni banda (da 0 a 1). |
paddingOuter | 0 | Lo spazio prima della prima banda e dopo l’ultima, una quota di un passo. La metà di paddingInner pone i centri delle bande a passi uguali lungo il plot. |
maxBandWidth | nessuno | La larghezza massima di una banda, in punti: poche bande su un plot largo restano strette, ciascuna al centro del proprio posto. |
bandCount | le righe | Le bande per cui è disposto l’asse quando sono più delle righe: le righe prendono le prime e mantengono larghezza e posizione man mano che arrivano. |
groupPadding | 0 | Lo spazio tra le barre affiancate in una banda, una quota del posto di ogni barra. |
reverse | false | Le bande partono dall’altro bordo: quella della prima riga a destra dell’asse x, in basso nell’asse y; le barre affiancate in una banda mantengono il loro ordine. |
Una barra viene disegnata con il suo fill (fill-*; ereditato dal plot come CSS lo eredita), currentColor dove non ne è impostato nessuno (il colore della sua categoria con color), e non mostra alcun pallino di hover. Su una scala a bande, l’hover trova la banda sotto il puntatore, e la linea trasversale del plot si trova in quella banda, larga quanto essa: crossLineClass="w-full h-full bg-foreground/5" tinge la banda, w-px mantiene una linea al suo centro. I valori e la base delle barre formano il dominio della scala dei valori quando il plot non ne ha uno. Con l’appear del plot, ogni barra cresce dalla base del proprio stack; il movimento, la dissolvenza e lo scorrimento girano sull’animator della piattaforma, e il puntatore non ridisegna nessuna barra.
#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);Con base un campo o una funzione, ogni barra copre due valori della sua riga: il gradino di una waterfall dal totale parziale precedente a quello successivo, il corpo di una candela dall’apertura alla chiusura, un’attività dall’inizio alla fine. Una barra cresce dalla propria base mentre appare, e la sua etichetta si trova all’estremità del valore, sopra una barra che sale e sotto una che scende.
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>
Un funnel è fatto di barre orizzontali, centrate: base={(d) => -d.users / 2} e value={(d) => d.users / 2} su una scala y a bande.
#Rule
Un Rule disegna una linea retta per ogni riga lungo l’asse dei valori, da base al valore della riga: al centro della banda della riga su una scala a bande (attraverso il plot su una scala y a bande), nella posizione della riga lungo l’asse x su una scala point o lineare. Lo stelo di una candela, i baffi di un box plot, una barra di errore e lo stelo di un lollipop sono rule; un candlestick è un Bar sopra un 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 | Tipo | Default | Descrizione |
|---|---|---|---|
value | string | (d, index) => number | y del plot | Dove finisce ogni linea. Una riga senza valore non ha linea. |
base | number | string | (d, index) => number | 0 | Dove inizia ogni linea: un valore per tutte le righe (lo stelo di un lollipop dalla linea dello zero), oppure un campo o una funzione che assegna a ogni riga il proprio (il minimo di una candela). Conta nel dominio. |
scale | "y" | "y2" | "y" | La scala y lungo cui scorrono i suoi valori (Dual axes). |
stagger | number | 0 | Con l’appear del plot, le linee partono una dopo l’altra, riga per riga, in questa quota dell’animazione (da 0 a 0.95), e ciascuna cresce dal proprio inizio per il resto. |
inactiveOpacity | number | nessuno | L’opacità delle sue linee lontane dalla riga sotto il puntatore mentre il puntatore è sopra il plot, come quella di Bar. |
color | string | (d, index) => string | nessuno | La categoria di ogni linea, con il colore preso dal colorScale del plot: il stroke della linea dove nulla ne imposta uno. |
Una rule traccia con il suo stroke (stroke-*, strokeWidth, strokeDasharray; ereditato dal plot come CSS lo eredita), currentColor dove non ne è impostato nessuno, e non mostra alcun pallino di hover. I suoi valori e la sua base formano il dominio della scala dei valori quando il plot non ne ha uno; cresce dal proprio inizio con l’appear del plot e si muove con i domini delle scale, sull’animator della piattaforma.
#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);Un Dot disegna un punto per riga dove si incontrano due valori della riga: il suo x lungo una scala x linear e il suo value lungo l’asse y formano uno scatter plot; su una scala x di tipo band ogni punto si trova al centro della banda della sua riga; su una scala y di tipo band (con la scala x linear), il suo valore scorre lungo x e la banda della sua riga scende lungo il plot, un dot plot. Il suo raggio r è un unico numero per tutti i punti, oppure un valore di ogni riga che rScale trasforma in un raggio, un bubble chart. Una riga senza valore non ha alcun punto.
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>
Con r impostato a un campo o a una funzione, il valore di ogni riga determina la dimensione del suo punto. rScale={{ type: "sqrt", range: [min, max] }} mappa le radici quadrate dei valori sui raggi in punti, come fa scaleSqrt di d3, quindi con un range che parte da 0 l’area di una bolla è proporzionale al suo valore; un range che parte da più di 0 mantiene visibili le bolle più piccole. Il dominio va da 0 al valore più grande, a meno che la scala non ne indichi uno. I punti più grandi vengono disegnati per primi, così quelli più piccoli restano visibili sopra di essi:
<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>
I valori di un punto formano il dominio della scala dei valori quando il plot non ne ha uno, senza arrivare fino a 0, così gli assi di uno scatter si adattano ai suoi punti. Quando i punti sono grandi, assegna al plot dei domain con un po’ di margine attorno ai valori: una bolla al bordo del dominio viene disegnata oltre il bordo del plot.
Due mark nelle stesse bande formano i grafici che confrontano due valori di una riga: un lollipop è un Rule da 0 sotto un Dot al suo valore, e un dumbbell è un Rule da un valore all’altro con un Dot a ciascuna estremità, su una scala y di tipo band:
<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 | Tipo | Default | Descrizione |
|---|---|---|---|
value | string | (d, index) => number | il y del plot | Dove va ogni punto lungo l’asse dei valori: lungo l’asse y, oppure lungo x su una scala y di tipo band. Una riga senza valore non ha alcun punto. |
r | number | string | (d, index) => number | 3 | Il raggio dei punti in punti, oppure un campo o una funzione che dà a ogni riga un valore che rScale trasforma in un raggio (senza di essa il valore è il raggio). Una riga il cui valore manca non ha alcun punto. |
rScale | { type: "sqrt"; domain?: [number, number]; range: [number, number] } | nessuno | Come i valori di r diventano raggi: le loro radici quadrate sul domain (da 0 al valore più grande, se non ce n’è uno) sul range dei raggi, così un range che parte da 0 rende l’area di ogni punto proporzionale al suo valore. I valori fuori dal dominio assumono gli estremi del range. |
stagger | number | 0 | Con l’appear del plot, i punti iniziano a crescere uno dopo l’altro, riga per riga, in questa quota della durata (da 0 a 0.95); 0 li fa crescere tutti insieme. |
inactiveOpacity | number | nessuno | L’opacità degli altri punti mentre uno è sotto il puntatore o i tasti: sfumano in entrata e in uscita con l’hover. |
color | string | (d, index) => string | nessuno | La categoria di ogni punto, il suo colore dal colorScale del plot: il fill del punto dove nient’altro ne imposta uno (Colori per categoria). |
hitRadius | number | 8 | Quanto oltre il bordo di un punto il puntatore riesce ancora a trovarlo: tra i punti entro questa distanza, il puntatore trova quello il cui centro gli è più vicino (le celle di Voronoi), così una piccola bolla sopra una grande viene trovata attorno al proprio centro. La riga di un dot plot viene trovata da qualsiasi punto lungo di essa con un raggio grande quanto una riga. |
scale | "y" | "y2" | "y" | La scala y lungo cui vanno i suoi valori (Assi doppi); i punti su una scala y di tipo band mettono i loro valori lungo x. |
hoverDotClass, hoverDotStyle | string, StyleProp<ViewStyle> | il dotClass, dotStyle del plot | La parte hoverDot: una view larga e alta quanto il punto sotto il puntatore, centrata su di esso. rounded-full outline-2 outline-offset-1 lo cerchia; hidden non ne mostra alcuna per questo mark. |
Un punto si riempie con il suo fill (fill-*; ereditato dal plot come lo eredita il CSS), currentColor dove non ne è impostato nessuno (il colore della sua categoria con color), e ha il contorno con il suo stroke dove ne è impostato uno (stroke-*, strokeWidth): un anello del colore della card tiene separati i punti sovrapposti. Su un plot con punti, il plot segnala la riga del punto il cui centro è più vicino al puntatore (null da ogni punto), i tasti scorrono le righe nel loro ordine (ordina le righe per x, e i tasti percorrono i punti da sinistra a destra), e la parte hoverDot segna il punto. Con l’appear del plot, i punti crescono dal nulla; con motion.update, si spostano ai loro nuovi valori e dimensioni, il punto di una nuova riga cresce in entrata e il punto di una riga scomparsa si riduce fino a sparire; tutto sull’animator della piattaforma, senza che alcun mark venga ridisegnato.
#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);Un Cell disegna un rettangolo per riga in due bande contemporaneamente: la banda della sua categoria x lungo una scala x band e la banda della sua categoria y lungo una scala y band, così le righe formano una griglia, una heatmap. Una matrice di correlazione, un calendario dei rendimenti mensili e una settimana di operazioni per ora sono cell. Ogni cella riempie le sue due bande, quindi paddingInner e paddingOuter delle scale sono gli spazi tra le celle e attorno ad esse. Il suo colore è il suo value lungo il colorScale del plot, una rampa di colori (Colori lungo una rampa), oppure quello della sua categoria color attraverso una scala ordinale. Le celle stanno sulle scale x e y del plot.
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>
Il domain di una scala band elenca in ordine le categorie delle sue bande, come fa scaleBand().domain() di d3: i mesi di un calendario da gennaio a dicembre, ciascuno con la propria banda che le sue righe siano arrivate o no, così marzo 2026 qui sopra è un posto vuoto nella griglia. Senza un dominio, le bande sono le categorie che le celle nominano lungo l’asse, nell’ordine in cui le righe le portano (2025 sopra 2026). Un PlotAxis lungo una scala band di categorie scrive la categoria di ogni banda, senza format.
Una matrice di correlazione nomina gli stessi titoli lungo entrambi gli assi, con il suo domain pari all’intero intervallo dei coefficienti, così un coefficiente di 0 prende il centro della rampa qualunque siano i titoli:
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 | Tipo | Default | Descrizione |
|---|---|---|---|
x | string | (d, index) => string | nessuno | La colonna di ogni riga: un campo (il suo testo, o il suo numero) o una funzione che restituisce la categoria. La cella della riga va nella banda di quella categoria della scala x; una riga senza categoria non ha alcuna cella. |
y | string | (d, index) => string | nessuno | La riga di ogni riga, come x lungo la scala y. |
value | string | (d, index) => number | nessuno | Il numero di ogni cella, che un sequential o diverging colorScale trasforma nel suo colore. Una riga senza numero prende il colore unknown della scala, e se questo manca non ha alcuna cella. |
color | string | (d, index) => string | nessuno | La categoria di ogni cella, il suo colore da un ordinal colorScale (Colori per categoria). |
cornerRadii | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 0 | Il raggio di ogni angolo delle sue celle. |
stagger | number | 0 | Con l’appear del plot, le celle iniziano a crescere una dopo l’altra, riga per riga, in questa quota della durata (da 0 a 0.95), ciascuna dal proprio centro per il resto; 0 le fa crescere tutte insieme. |
inactiveOpacity | number | nessuno | L’opacità delle altre celle e delle loro etichette mentre una è sotto il puntatore o i tasti: sfumano in entrata e in uscita con l’hover. |
label | string | (d, index) => string | nessuno | La parte label: un testo al centro di ogni cella, un campo (il suo testo, o il suo numero) o una funzione che restituisce il testo formattato dall’app. Ogni etichetta compare dove la sua dimensione calcolata nel layout sta dentro la cella, così un plot stretto mostra solo i colori. |
labelClass, labelStyle | string, StyleProp<TextStyle> | nessuno | Il font, la dimensione e il colore delle etichette. Un’etichetta su una cella il cui colore colorScale è scuro ha l’attributo data-dark (dove un testo chiaro si legge meglio), così data-dark:text-white lo scrive chiaro in quel caso. |
hoverCellClass, hoverCellStyle | string, StyleProp<ViewStyle> | nessuno | La parte hoverCell: una view grande quanto la cella sotto il puntatore o i tasti, sopra di essa; rounded-sm outline-2 outline-foreground la cerchia. Si muove con il puntatore nello stesso frame e scivola sulla spring pointer quando i tasti la spostano. |
Una cella si riempie con il suo colore dal colorScale del plot dove l’app non imposta alcun fill (le classi e gli stili fill-* hanno la precedenza, ereditati dal plot come li eredita il CSS), currentColor dove nient’altro ne imposta uno, e ha il contorno con il suo stroke dove ne è impostato uno. Su un plot con celle, il plot segnala la riga della cella sotto il puntatore, e nello spazio tra due celle quella più vicina; i tasti freccia scorrono la griglia sullo schermo (Tastiera), e gli screen reader scorrono le righe nel loro ordine. Con l’appear del plot, le celle crescono dal loro centro; con motion.update, il colore di una cella scivola verso il nuovo, la cella di una nuova riga cresce in entrata, la cella di una riga scomparsa si riduce fino a sparire, e le celle si spostano alle loro nuove bande; tutto sull’animator della piattaforma, senza che alcun mark venga ridisegnato.
#Pie e Arc
Un Pie divide un giro in una fetta per riga, ciascuna in proporzione al valore della riga, in senso orario dalle ore 12 nell’ordine delle righe, attorno al centro del plot. Un innerRadius lo trasforma in un donut. Ogni fetta è un canvas a sé, la parte slice, quindi ciascuna si dipinge con le proprie classi: sliceClass sono le stesse classi su ogni fetta, oppure una funzione della riga della fetta che ne assegna di proprie a ciascuna. Su un pie, il plot riporta la riga della fetta sotto il puntatore (nessuna nel foro o fuori dall’anello), il suo tooltip segue il puntatore, e quella fetta cresce di hoverLift mentre le altre sfumano di hoverDim, sull’animator della piattaforma con il motion del plot. Un Arc disegna un settore tra due angoli: la traccia e il valore di un gauge, oppure un anello dietro un 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 | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|---|
Pie | value | .value(..) | keyof T | (d: T, index: number) => number | y del plot | Quanto vale ogni riga. Una riga che non vale nulla, che vale meno di zero o che non ha un numero non ha fetta. |
Pie, Arc | startAngle, endAngle | .start_angle(..), .end_angle(..) | number (radianti) | 0, 2 * Math.PI | Dove inizia la prima fetta e dove finisce l’ultima (le estremità di un arc): radianti dalle ore 12, in senso orario. Un endAngle inferiore a startAngle dispone le fette di un pie nell’altro verso. |
Pie | padAngle | .pad_angle(..) | number (radianti) | 0 | L’angolo tra fette vicine sul bordo esterno. Ogni spazio mantiene quella larghezza (circa padAngle * outerRadius punti) a ogni raggio, con i bordi paralleli, quindi è largo al centro o al foro quanto sul bordo; una fetta troppo stretta per raggiungere il centro termina dove i suoi bordi si incontrano. Un pie di una sola fetta non ne ha. |
Pie, Arc | innerRadius, outerRadius | .inner_radius(..), .outer_radius(..) | number | `${number}%` | 0, "100%" | I raggi del foro e del bordo esterno: in punti, oppure come percentuale del cerchio più grande che il plot contiene (metà del suo lato minore). |
Pie, Arc | cornerRadius | .corner_radius(..) | number | 0 | Il raggio degli angoli di ogni fetta (di un arc), in punti, come il arc.cornerRadius di d3: ogni angolo è un cerchio che tocca il lato e il bordo esterno o il foro, inclusi i bordi paralleli degli spazi. Al massimo è metà dello spessore dell’anello, il che dà a un arc estremità arrotondate, e su una fetta troppo stretta per due angoli interi questi si incontrano a metà. Un anello intero mantiene solo i suoi bordi arrotondati. |
Pie | sliceClass | .slice_class(..) | string | (d: T, index: number) => string | nessuno | Le classi di ogni fetta (fill-*, stroke-*): le stesse su ogni fetta, oppure una funzione della riga della fetta e del suo indice, chiamata una volta per riga quando cambiano le righe o la funzione. |
Pie | sliceStyle | .slice_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile di ogni fetta, sopra le sue classi (fill, stroke, strokeWidth). |
Pie | hoverLift | .hover_lift(..) | number | 0 | Di quanto la fetta sotto il puntatore cresce rispetto alle altre, in punti: sulla spring pointer del plot, con dissolvenza in entrata e in uscita insieme all’hover. |
Pie | hoverDim | .hover_dim(..) | number (da 0 a 1) | 0 | Quanto sfumano le altre fette mentre una è sotto il puntatore, come quota della loro opacità, con la dissolvenza dell’hover. |
Pie | color | .color(..) | keyof T | (d: T, index: number) => string | nessuno | La categoria di ogni fetta, il suo colore dal colorScale del plot: il fill della fetta dove nulla ne imposta uno (Colori per categoria). |
Pie | label | .label(..) | string | (d: T, index: number) => string | nessuno | Il testo accanto a ogni fetta: un campo (il suo numero) oppure una funzione che restituisce il testo formattato dall’app. Una riga con testo vuoto, o una fetta più stretta di mezzo grado, non lo ha. |
Pie | labelClass, labelStyle | .label_class(..), .label_style(..) | string, StyleProp<TextStyle> | nessuno | La parte label: un testo fuori dall’anello all’estremità della sua linea guida, centrato su di essa, dopo di essa nella metà destra del pie e prima nella metà sinistra. Il suo padding è la distanza dall’estremità della linea. |
Pie | labelGap | .label_gap(..) | number | 15 | Quanto oltre il bordo esterno si trovano le etichette, in punti. |
Pie | labelLineClass, labelLineStyle | .label_line_class(..), .label_line_style(..) | string | (d: T, index: number) => string, StyleProp<ViewStyle> | nessuno | La parte labelLine: la linea guida di ogni etichetta, dal bordo esterno a metà della fetta fino al raggio dell’etichetta, poi di lato fino al testo. Il suo stroke (currentColor dove non ne è impostato uno), strokeWidth e strokeDasharray, gli stessi su ogni linea oppure una funzione della riga della fetta; visibile una volta stilizzata. |
Una fetta e un arc si riempiono con fill (currentColor dove non ne è impostato uno, con color il colore della categoria della fetta) e tracciano i loro bordi con stroke (nessuno dove non ne è impostato uno), i propri sopra quelli del pie e del plot così come li eredita CSS: un tratto del colore della superficie (stroke-card stroke-2) crea anche spazi tra le fette. I loro valori non partecipano alle scale del plot. Le fette di un pie vengono ridisegnate solo quando cambiano le righe, i loro valori, gli angoli, i raggi o la dimensione del plot; il puntatore muove l’innalzamento e la dissolvenza sull’animator.
I plot con un pie di solito posizionano il tooltip vicino al puntatore (tooltipPlacement="pointer"), dato che le righe di un pie non hanno una posizione lungo un asse: la linea trasversale e il tooltip seguono il puntatore su di esso, e non vengono mostrati punti di hover. Il centro di un donut è un elemento proprio dell’app sovrapposto al plot (il valore della fetta in hover, oppure il totale), e lo stesso vale per la sua legenda, con ogni chiave nel colore della sua fetta.
Le etichette di un pie nominano le sue fette fuori dall’anello: ogni testo si trova a labelGap oltre il bordo esterno verso la metà della sua fetta (il centroide della fetta a quel raggio), poi di lato fino a quel raggio dalla parte della fetta nel pie, e una linea guida stilizzata lo collega al bordo. Le etichette si dispiegano con le loro fette all’apparizione del plot e compaiono in dissolvenza alla sua fine, si muovono con esse su motion.update e si sollevano con la fetta in hover; tutto viene eseguito sull’animator della piattaforma. Lascia al plot lo spazio per le etichette: un outerRadius inferiore a "100%", oppure spazio attorno al plot. Il contenuto proprio dell’app su una fetta è un elemento sovrapposto al plot, come il centro di un donut.
<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"
/>
#Colori per categoria
Il colorScale di un plot assegna i colori alle categorie, come fa il scaleOrdinal di d3, e il color di un mark indica la categoria di ogni riga: un campo (il suo testo o il suo numero) oppure una funzione della riga. Le categorie del domain prendono i colori del range in ordine, e il range ricomincia dal primo colore quando il dominio è più lungo. Una categoria fuori dal dominio prende unknown; senza di esso, entra nel dominio la prima volta che un mark la incontra (i mark in ordine, ciascuno con le sue righe in ordine) e prende il colore successivo, quindi senza alcun domain le categorie prendono i colori nell’ordine in cui le righe le portano. I colori sono quelli dell’app: colori CSS, compresi i token del tema.
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>
Una fetta, una barra e un punto prendono il colore della loro riga; un Line e un Area sono una serie ciascuno e prendono quello della loro prima riga, quindi una linea di righe proprie (data) ha il colore delle sue righe, e una sola scala mantiene lo stesso colore di una categoria in un pie, nelle barre e nelle linee. Il colore è il fill del mark (il stroke di una linea) dove nulla ne imposta uno, al posto di currentColor: vengono prima le classi e gli stili dell’app, poi quelli della parte (sliceClass, sliceStyle), poi quelli del mark e quelli che eredita dal plot, quindi sliceClass={(d) => (d.name === "Other" ? "fill-muted" : "")} imposta una fetta a parte mentre la scala colora le altre. Accanto a una parte background, il fill di una barra e di un’area resta none.
OrdinalScale | Tipo | Predefinito | Descrizione |
|---|---|---|---|
type | "ordinal" | (obbligatorio) | Il tipo di scala. |
domain | string[] | nessuno | Le categorie, in ordine. Una categoria scritta due volte mantiene il suo primo posto. |
range | string[] | (obbligatorio) | I colori, in ordine (colori CSS); la categoria i prende il colore i % range.length. |
unknown | string | nessuno | Il colore di ogni categoria fuori dal dominio; senza di esso, ciascuna prende il colore successivo. |
#Colori lungo una rampa
Un colorScale di type: "sequential" o "diverging" assegna ai numeri i loro colori lungo una rampa, come fanno scaleSequential e scaleDiverging di d3, per il value di un Cell. Il suo range è composto da due o più colori distribuiti uniformemente lungo la rampa dall’estremo basso a quello alto e mescolati in OKLab tra loro: i passaggi appaiono uniformi all’occhio, e due tonalità si incontrano attraverso la loro miscela. Un valore oltre il domain prende l’estremo della rampa, e una riga senza valore prende unknown.
sequential distribuisce il suo domain sull’intera rampa, con il dominio pari all’estensione dei valori delle celle a meno che l’app non ne indichi uno: conteggi, volumi, attività. diverging pone il suo pivot (0 per impostazione predefinita) al colore centrale della rampa, con i valori inferiori lungo la prima metà e quelli superiori lungo la seconda: perdite in una tonalità, guadagni in un’altra, un mese piatto nel centro neutro. A meno che l’app non indichi un dominio, questo è l’estensione dei valori resa simmetrica ai due lati del pivot (symmetric di Observable Plot), quindi una perdita e un guadagno della stessa entità hanno la stessa intensità; una matrice di correlazione indica 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")] }}
#RadialLine e RadialGrid
Un RadialLine disegna una serie attorno al centro del plot: il poligono di un radar, o una linea attorno a un quadrante. Ogni riga con un valore è un punto all’angolo della riga, tanto lontano dal centro quanto la scala y del plot colloca il suo valore, da innerRadius (il valore minimo del dominio) a outerRadius (il massimo). Senza un angle, le righe si dispongono uniformemente lungo il giro a partire dalle ore 12, in senso orario e in ordine, quindi sei righe formano un esagono; closed unisce l’ultimo punto al primo. Una linea traccia i suoi punti e riempie la sua area fino a innerRadius (per default il centro), così un solo mark è il contorno di un radar e la sua campitura. Un RadialGrid disegna dietro di esso la griglia del radar: rings a valori della scala y, poligoni attraverso gli angoli delle righe oppure circular, e spokes fino a ogni riga. Sopra una linea radiale, il plot riporta la riga il cui angolo è più vicino a quello del puntatore entro il raggio esterno della linea, e il punto di hover di ogni linea si posiziona sul suo punto di quella riga.
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 | Tipo | Default | Descrizione |
|---|---|---|---|---|---|
RadialLine | radius | .radius(..) | keyof T | (d: T, index: number) => number | y del plot | Ciò che colloca ogni riga a distanza dal centro: un valore della scala y, da innerRadius per il valore minimo del dominio a outerRadius per il massimo. Una riga che ne è priva viene saltata. |
RadialLine | angle | .angle(..) | keyof T | (d: T, index: number) => number | le righe distribuite uniformemente lungo il giro | Dove va ogni riga attorno al centro: radianti dalle ore 12, in senso orario. |
RadialLine, RadialGrid | startAngle, endAngle | .start_angle(..), .end_angle(..) | number (radianti) | 0, 2 * Math.PI | Dove vanno le righe senza angle: a partire da startAngle, in modo uniforme. Su un giro intero distano un passo pari a 1 / rows di giro, con l’ultima un passo prima della prima; su una parte di giro la prima si trova su startAngle e l’ultima su endAngle. |
RadialLine, RadialGrid | innerRadius, outerRadius | .inner_radius(..), .outer_radius(..) | number | `${number}%` | 0, "100%" | Dove vanno il valore minimo e quello massimo del dominio y: punti, oppure una percentuale del cerchio più grande che il plot contiene (metà del suo lato minore), come i raggi di un grafico a torta. L’area di una linea si riempie fino a innerRadius; i raggi di una griglia corrono tra i due. |
RadialLine | curve | .curve(..) | "linear" | "natural" | "stepAfter" | "linear" | Come collega i suoi punti: segmenti dritti, una curva morbida (se chiusa, un anello morbido) o gradini. |
RadialLine | closed | .closed(..) | boolean | false | Prosegue dall’ultimo punto tornando al primo: il poligono di un radar. Se aperta, la sua area si chiude sul centro (o lungo il suo bordo interno). |
RadialLine | dot, dotClass, dotStyle | .dot(..), .dot_class(..), .dot_style(..) | boolean, string, StyleProp<ViewStyle> | false | Un punto su ogni punto della linea: una view centrata su di esso, con dimensione, sfondo e bordo definiti dall’app. I punti crescono e si muovono insieme alla linea. |
RadialLine | backgroundClass, backgroundStyle | .background_class(..), .background_style(..) | string, StyleProp<ViewStyle> | nessuno | Il suo sfondo: un box attorno al suo cerchio esterno il cui sfondo (bg-*, un gradiente) è visibile solo all’interno della sua area, sotto il suo fill, come quello di un’area. |
RadialLine | hoverDotClass, hoverDotStyle | .hover_dot_class(..), .hover_dot_style(..) | string, StyleProp<ViewStyle> | dotClass, dotStyle del plot | Il suo punto di hover, sul suo punto della riga sotto il puntatore, con il puntatore nello stesso frame e che scivola verso la riga successiva con la molla pointer del plot quando i tasti avanzano; hidden non ne mostra alcuno. |
RadialGrid | rings | .rings(..) | number[] | nessuno | Dove vanno i suoi anelli, come valori della scala y: ciascuno tanto lontano quanto una linea radiale colloca quel valore. |
RadialGrid | spokes | .spokes(..) | boolean | false | Una linea da innerRadius a outerRadius all’angolo di ogni riga. |
RadialGrid | circular | .circular(..) | boolean | false | I suoi anelli sono cerchi (archi su una parte di giro). Per default sono poligoni attraverso gli angoli delle righe, e cerchi finché il plot ha meno di tre righe. |
Una linea radiale traccia il contorno con stroke, strokeWidth e strokeDasharray (currentColor, spessore 1 e continuo dove non è impostato nulla) e riempie la sua area con fill (nessuno dove non è impostato nulla); una griglia radiale traccia il contorno come fa una griglia. La loro vernice è ereditata dal plot. I valori di una linea, insieme allo 0, formano il dominio della scala y quando il plot non ne ha uno, così un radar di valori positivi cresce dal suo centro; gli anelli di una griglia no. Assegna al plot un domain y per mantenere fissi gli anelli e la scala del radar mentre i valori cambiano.
Le etichette attorno a un radar sono elementi dell’app sovrapposti al plot, come lo è il centro di una ciambella. Senza un angle, la riga i di n si trova a a = 2π·i / n dalle ore 12, quindi in un plot quadrato un box senza dimensione a left: 50% + r·sin(a), top: 50% − r·cos(a) (con r una percentuale del lato del plot di poco superiore alla metà della quota del raggio esterno) segna il punto oltre il bordo, e il nome cresce da lì allontanandosi dal centro: a destra di esso nella metà destra, a sinistra nella metà sinistra, sopra in alto e sotto in basso, come sopra.
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
Un Sankey disegna i flussi tra le righe del plot: ogni riga è un nodo e links nomina i flussi tra di essi, { source, target, value } with the indices of two rows in the plot’s data. Li dispone come fa d3-sankey, nelle dimensioni del plot: i nodi in colonne (nodeAlign), ciascuno alto quanto il massimo che entra o esce da esso, i nodi di una colonna distanziati di nodePadding, rilassati verso i loro flussi in iterations passate; e un nastro per ogni link dal bordo destro della sua sorgente al bordo sinistro della sua destinazione, largo a ciascuna estremità quanto la sua quota del lato di quel nodo, così entrambi i lati di ogni nodo sono coperti anche dove i flussi in entrata e in uscita differiscono. Ogni colonna è centrata nel plot, e una serie di colonne uguali a nodo singolo è sfalsata leggermente in modo che i nastri tra di esse si curvino. valueScale="sqrt" dimensiona in base alla radice quadrata dei valori, così un flusso dominante non schiaccia quelli piccoli.
Ogni nodo e ogni nastro è un canvas a sé, le parti node e link, quindi ciascuno si dipinge con le proprie classi: nodeClass sono le stesse classi su ogni nodo oppure una funzione della riga del nodo, e linkClass le stesse su ogni nastro oppure una funzione del link. La parte linkBackground mostra uno sfondo all’interno di ogni nastro, un box dalla sua sorgente alla sua destinazione: una funzione del link dà a ogni nastro un gradiente dal colore della sua sorgente a quello della sua destinazione. I nastri vengono disegnati sotto i nodi, ed entrambi si disegnano a partire dal bordo iniziale con l’apparizione del plot.
Sopra un sankey, il plot riporta il nodo sotto il puntatore (onHoverChange, la riga del nodo; nessuno sopra un nastro), il suo tooltip segue il puntatore, e i link non collegati a quel nodo sfumano di hoverDim, sull’animator della piattaforma con motion del plot. I tasti scorrono i nodi nell’ordine delle righe, con il tooltip accanto al centro di ciascun nodo. nodeLabel nomina ogni nodo e valueLabel ne fornisce il valore, con il valore sopra il nome quando entrambi sono impostati: a sinistra di un nodo nella prima colonna, a destra di uno nell’ultima, centrato sopra uno nel mezzo, come li colloca il grafico di gpui-component; un’etichetta accanto a un nodo resta tra il bordo superiore e quello inferiore del plot, e una oltre il bordo del plot si colloca nello spazio che l’app lascia attorno ad esso (il suo margin). Il padding della parte nodeLabel è la distanza dal suo nodo e le sue classi ne definiscono il testo (max-w-20 truncate tronca un nome lungo); le etichette sfumano in ingresso con l’apparizione. Quando l’app cambia i flussi, ogni nodo e ogni nastro si sposta dal vecchio posto al nuovo con motion.update del plot, con la sua etichetta al seguito: un nodo nuovo per i flussi cresce dal suo centro, un link nuovo dalla sua larghezza minima, e quelli che scompaiono se ne vanno subito; un cambiamento mentre si muovono prosegue da dove sono disegnati. I link si distinguono per sorgente e destinazione, quindi riordinare links non sposta nulla.
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 | Tipo | Default | Descrizione |
|---|---|---|---|---|
links | .links(..) | SankeyLink[] ({ source: number; target: number; value: number }) | obbligatorio | I flussi: dalla riga source alla riga target (i loro indici in data del plot), con value la sua dimensione. Un valore che non è un numero o è inferiore a zero non trasporta nulla e non disegna alcun nastro. |
nodeWidth | .node_width(..) | number | 24 | La larghezza di ogni nodo, in punti. |
nodePadding | .node_padding(..) | number | 8 | Lo spazio tra i nodi di una colonna, in punti; minore in una colonna troppo affollata per rispettarlo. |
nodeAlign | .node_align(..) | "justify" | "left" | "right" | "center" | "justify" | In quale colonna va ciascun nodo, come in d3-sankey: la sua profondità dalla prima colonna ("left"), il più a destra possibile compatibilmente con i suoi flussi ("right"), la sua profondità con un nodo che si limita ad avviare flussi posto accanto alle sue destinazioni ("center"), oppure la sua profondità con i nodi che terminano i flussi nell’ultima colonna ("justify"). |
iterations | .iterations(..) | number | 6 | Quante passate rilassano i nodi verso i loro flussi. |
valueScale | .value_scale(..) | "linear" | "sqrt" | "linear" | Altezze e larghezze in proporzione ai valori, o alle loro radici quadrate. |
minLinkWidth | .min_link_width(..) | number | 1 | Il minimo spessore con cui si disegna un nastro, in punti, così un flusso piccolo resta visibile. |
nodeCornerRadius | .node_corner_radius(..) | number | 0 | Il raggio degli angoli di ogni nodo, in punti. |
nodeClass | .node_class(..) | string | (d: T, index: number) => string | nessuno | Le classi di ogni nodo (fill-*, stroke-*): le stesse su ogni nodo, oppure una funzione della riga del nodo e del suo indice, chiamata una volta per riga quando cambiano le righe o la funzione. |
nodeStyle | .node_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile di ogni nodo, sopra le sue classi. |
linkClass | .link_class(..) | string | (link: SankeyLink, index: number) => string | nessuno | Le classi di ogni nastro: le stesse su ogni nastro, oppure una funzione del link e del suo indice in links. |
linkStyle | .link_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile di ogni nastro, sopra le sue classi. |
linkBackgroundClass | .link_background_class(..) | string | (link: SankeyLink, index: number) => string | nessuno | Le classi dello sfondo di ogni nastro (bg-*, un gradiente: bg-linear-to-r from-… to-…), mostrato all’interno del nastro su un box dalla sua sorgente alla sua destinazione, sotto il suo fill. Con uno sfondo, il fill di un nastro è none dove non è impostato nulla. |
linkBackgroundStyle | .link_background_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile dello sfondo di ogni nastro, sopra le sue classi. |
hoverDim | .hover_dim(..) | number (da 0 a 1) | 0 | Quanto sfumano i link non collegati al nodo sotto il puntatore, come quota della loro opacità, con la molla pointer del plot e con la dissolvenza dell’hover. |
nodeLabel | .node_label(..) | keyof T | (d: T, index: number) => string | nessuno | Il nome accanto a ogni nodo: un campo, oppure una funzione della riga del nodo e del suo indice. |
valueLabel | .value_label(..) | (d: T, value: number, index: number) => string | nessuno | Il valore accanto a ogni nodo, sopra il suo nome: una funzione della sua riga e del massimo che entra o esce da esso (nei valori dei link, qualunque sia valueScale). |
nodeLabelClass | .node_label_class(..) | string | nessuno | Le classi di ogni etichetta, la parte nodeLabel: il suo padding è la distanza dal suo nodo, gli stili del testo valgono per entrambe le righe. |
nodeLabelStyle | .node_label_style(..) | StyleProp<TextStyle> | nessuno | Lo stile di ogni etichetta, sopra le sue classi. |
valueLabelClass | .value_label_class(..) | string | nessuno | Le classi della riga del valore, la parte valueLabel, sopra quelle dell’etichetta. |
valueLabelStyle | .value_label_style(..) | StyleProp<TextStyle> | nessuno | Lo stile della riga del valore, sopra le sue classi. |
Un nodo e un nastro si riempiono con fill (currentColor dove non è impostato nulla) e tracciano il contorno con stroke (nessuno dove non è impostato nulla), con i propri valori sopra quelli del sankey e del plot, secondo l’ereditarietà CSS. Un nodo senza flussi che lo attraversano è alto un punto. Il layout viene eseguito di nuovo solo quando cambiano i link, le opzioni o la dimensione del plot, mai per il puntatore o per l’apparizione; il cambio di dimensione del plot non sposta nulla. I link che formano un ciclo, o che nominano una riga che il plot non ha, non disegnano nulla e registrano un avviso nel log. I suoi valori non partecipano alle scale del plot.
#Treemap
Un Treemap disegna le righe del plot come una gerarchia di rettangoli annidati: ogni riga è un nodo, il suo id e il parent che nomina formano l’albero (stratify di d3), l’area di ogni foglia è proporzionale al suo value, e ogni gruppo è il riquadro di ciò che contiene, con un valore pari alla somma di quelli delle sue foglie (qualunque cosa contenga la sua riga). Una riga senza parent, o il cui genitore non nomina alcuna riga, è un nodo di primo livello; senza id l’id di una riga è il suo indice, quindi parent può nominare una riga anche tramite il suo indice. Un albero nella forma annidata che gpt-vis scrive ({ name, value, children }) richiede poche righe di codice dell’app per ricavarne le righe; non c’è limite ai livelli.
Le tessere sono squarified, come le crea treemapSquarify di d3: i nodi di ogni livello in ordine di valore, dal più grande in alto a sinistra (sort="value"), vicino al rapporto d’aspetto ratio (la sezione aurea); sort="data" mantiene l’ordine delle righe, così i nodi conservano la loro posizione al variare dei valori. paddingInner è lo spazio tra fratelli, assente lungo i bordi esterni del loro genitore; paddingOuter è lo spazio all’interno di un gruppo attorno ai suoi figli; paddingTop è una fascia in cima a ogni gruppo, dove è scritto il suo groupLabel. Ognuno accetta un numero per tutti i livelli o uno per livello dal primo in giù ([3, 1]: 3 tra i nodi di primo livello, 1 sotto di essi). La fascia di un livello si applica a tutti i fratelli di un genitore o a nessuno, e viene omessa dove uno è troppo piccolo per contenerla, così i gruppi di un livello si leggono allo stesso modo.
Una foglia il cui valore è 0, minore di 0 o non numerico non occupa area: non viene disegnata, non ha etichetta e non viene trovata dal puntatore, e lo stesso vale per un gruppo le cui foglie sono tutte così. Le aree di un treemap sono quote di un intero, quindi l’app mostra ciò che è stato escluso con parole proprie (una nota sotto la mappa). I numeri negativi servono per i colori: la variazione giornaliera di una market map.
Il colore di un nodo è quello della sua riga tra i colorScale del plot: colorValue è un numero lungo una rampa (sequential, diverging: perdite rosse, guadagni verdi, valori piatti neutri, sull’intervallo dei valori senza domain), color una categoria (ordinal: "parent" colora allo stesso modo le foglie di un gruppo). Le foglie sono la parte leaf (leafClass, leafStyle) e i gruppi la parte group (groupClass, groupStyle), disegnati solo con una classe o uno stile: un rettangolo sotto i suoi figli, visibile negli spazi, nel padding e nella fascia. Ogni parte è uguale su ogni nodo oppure è una funzione della riga del nodo. cornerRadii arrotonda ogni rettangolo.
label scrive il nome di ogni foglia e valueLabel il suo valore sotto di esso, come funzione della riga e del suo valore (quello di un gruppo è la somma delle sue foglie): in alto a sinistra (labelPlacement="start") o al centro ("center"). La parte label è un riquadro grande quanto la sua foglia: il suo padding è lo spazio dai bordi della foglia e il suo testo dà lo stile alle righe. Il nome viene troncato con un’ellissi dove la foglia è più stretta e mostrato solo dove entra; il valore è mostrato per intero o per niente. Un’etichetta la cui foglia ha un colore scuro ha l’attributo data-dark, così data-dark:text-white la mantiene leggibile su un rosso intenso.
Sopra un treemap, il plot riporta il nodo sotto il puntatore (onHoverChange, la riga del nodo): una foglia, oppure un gruppo nella sua fascia o nel suo padding; attraverso uno spazio tra fratelli, uno di essi, così l’hover non sfarfalla. Gli altri sfumano fino a inactiveOpacity (una foglia mantiene i suoi gruppi, un gruppo ciò che sta sotto di esso), e la parte hover, una view grande quanto il nodo, ne traccia il contorno. I tasti freccia passano alla foglia accanto sullo schermo, Home e End alla prima e all’ultima. Con l’appear del plot i nodi di primo livello crescono dal loro centro, uno dopo l’altro con stagger; quando le righe cambiano, ogni nodo si sposta nel suo nuovo riquadro e colore con motion.update, riconosciuto dal suo id: un nodo nuovo tra le righe cresce dal suo centro e uno scomparso si riduce verso il proprio. Le scale x e y del plot non vengono usate.
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 | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
id | .id(..) | keyof T | (d: T, index: number) => string | l’indice della riga | L’id del nodo di ogni riga: ciò che parent nomina, e con cui un nodo è riconosciuto quando le righe cambiano. |
parent | .parent(..) | keyof T | (d: T, index: number) => string | nessuno: ogni riga è un nodo di primo livello | L’id del genitore di ogni riga; vuoto, o l’id di nessuna riga, per un nodo di primo livello. |
value | .value(..) | keyof T | (d: T, index: number) => number | obbligatorio | La dimensione di ogni foglia; quella di un gruppo è la somma di quelle dei suoi figli. Una foglia pari a 0, minore di 0 o non numerica non occupa area. |
sort | .sort(..) | "value" | "data" | "value" | I fratelli dal più grande in alto a sinistra, oppure nell’ordine delle righe. |
ratio | .ratio(..) | number | la sezione aurea | Il rapporto d’aspetto a cui si avvicinano le tessere, almeno 1. |
paddingInner | .padding_inner(..) | number | number[] | 0 | Lo spazio tra fratelli, in punti, per livello dal primo in giù (l’ultimo vale per quelli sotto). |
paddingOuter | .padding_outer(..) | number | number[] | 0 | Lo spazio all’interno di ogni gruppo attorno ai suoi figli, per livello. |
paddingTop | .padding_top(..) | number | number[] | 0 | La fascia in cima a ogni gruppo, per il suo groupLabel, per livello; su tutti i fratelli di un genitore o su nessuno. |
cornerRadii | .corner_radii(..) | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | squadrato | Il raggio di ogni angolo dei suoi rettangoli. |
color | .color(..) | keyof T | (d: T, index: number) => string | nessuna | La categoria di ogni nodo, a cui un ordinal colorScale assegna un colore. |
colorValue | .color_value(..) | keyof T | (d: T, index: number) => number | nessuno | Il numero di ogni nodo, che un sequential o diverging colorScale trasforma nel suo colore. |
leafClass | .leaf_class(..) | string | (d: T, index: number) => string | nessuna | Le classi di ogni foglia (fill-*, stroke-*), uguali su ogni foglia o come funzione della sua riga. |
leafStyle | .leaf_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile di ogni foglia, sopra le sue classi. |
groupClass | .group_class(..) | string | (d: T, index: number) => string | nessuna | Le classi di ogni gruppo, un rettangolo sotto i suoi figli; i gruppi sono disegnati solo con una classe o uno stile. |
groupStyle | .group_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile di ogni gruppo, sopra le sue classi. |
inactiveOpacity | .inactive_opacity(..) | number (da 0 a 1) | nessuna | L’opacità dei nodi lontani da quello in hover, e delle loro etichette, con la dissolvenza dell’hover. |
label | .label(..) | keyof T | (d: T, index: number) => string | nessuno | Il nome in ogni foglia, su una riga, troncato con un’ellissi. |
valueLabel | .value_label(..) | (d: T, value: number, index: number) => string | nessuno | Il valore in ogni foglia, sotto il suo nome, mostrato solo dove entra per intero. |
labelPlacement | .label_placement(..) | "start" | "center" | "start" | Le etichette in alto a sinistra delle loro foglie, oppure al centro. |
labelClass | .label_class(..) | string | nessuna | Le classi dell’etichetta di ogni foglia, un riquadro grande quanto la foglia: il suo padding e gli stili del testo; data-dark: sulle foglie scure. |
labelStyle | .label_style(..) | StyleProp<TextStyle> | nessuno | Lo stile dell’etichetta di ogni foglia, sopra le sue classi. |
valueLabelClass | .value_label_class(..) | string | nessuna | Le classi della riga del valore, sopra quelle dell’etichetta. |
valueLabelStyle | .value_label_style(..) | StyleProp<TextStyle> | nessuno | Lo stile della riga del valore, sopra le sue classi. |
groupLabel | .group_label(..) | keyof T | (d: T, index: number) => string | nessuno | Il nome di ogni gruppo, nella sua fascia, troncato con un’ellissi. |
groupLabelClass | .group_label_class(..) | string | nessuna | Le classi dell’etichetta di ogni gruppo, un riquadro grande quanto la fascia. |
groupLabelStyle | .group_label_style(..) | StyleProp<TextStyle> | nessuno | Lo stile dell’etichetta di ogni gruppo, sopra le sue classi. |
stagger | .stagger(..) | number (da 0 a 0.95) | 0 | Come appaiono uno dopo l’altro i nodi di primo livello: la quota dell’appear su cui iniziano. |
hoverClass | .hover_class(..) | string | nessuna | Le classi della parte hover, una view grande quanto il nodo in hover sopra di esso (rounded-xs outline-2 outline-foreground). |
hoverStyle | .hover_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile della parte hover, sopra le sue classi. |
Un rettangolo si riempie con fill (il colore di colorScale, altrimenti currentColor, dove non ne è impostato nessuno) e ne traccia il contorno con stroke (nessuno dove non è impostato), il proprio sopra quello del treemap e del plot, come li eredita CSS. Il layout viene rieseguito solo quando cambiano le righe, le opzioni o la dimensione del plot, mai per il puntatore o l’appear; il cambio di dimensione del plot non sposta nulla. Un id ripetuto, un parent che non nomina alcuna riga e una riga che è antenata di se stessa registrano un avviso una sola volta: la prima riga mantiene l’id, l’orfano è un nodo di primo livello, il ciclo non viene disegnato.
#PlotAxis
Un PlotAxis etichetta un bordo del plot con i tick della scala che quel bordo porta: i bordi inferiore e superiore quelli della scala x, quelli sinistro e destro quelli della scala y, come axisBottom, axisTop, axisLeft e axisRight di d3. I suoi tick provengono dalla scala: valori tondi su una scala linear (1, 2 o 5 volte una potenza di dieci, dentro il dominio), righe su una scala point o band. Le sue etichette sono testi che il layout del plot posiziona, quindi l’app dice cosa leggono e che aspetto hanno, e il layout li mette a posto: ogni etichetta è centrata sul suo tick lungo il bordo. Lungo i bordi inferiore e superiore, la prima e l’ultima si spostano verso l’interno quanto basta per restare entro le estremità del plot; accanto ai bordi sinistro e destro, ogni etichetta resta sul suo tick, così si legge come quella della sua linea della griglia, e dove un’etichetta vicina a un’estremità supererebbe il bordo superiore o inferiore del plot, le scale y lineari del plot iniziano a quella distanza da esso (metà dell’altezza dell’etichetta per un tick all’estremità), così ogni etichetta resta dentro il plot, sulla sua linea della griglia.
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 | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
placement | .placement(..) | "bottom" | "top" | "left" | "right" | "bottom" | Il bordo lungo cui corre: i bordi inferiore e superiore portano i tick della scala x, quelli sinistro e destro quelli della scala y. |
scale | .scale(..) | "y" | "y2" | "y" | La scala y che un asse sinistro o destro etichetta: il yScale del plot oppure il secondo, y2Scale (Dual axes). |
labelPlacement | .label_placement(..) | "outside" | "inside" | "outside" | Dove si trovano le sue etichette: oltre il bordo, nello spazio che l’app lascia attorno al plot, dove restano distanti dai mark; oppure sopra il plot lungo il bordo, così il plot mantiene la sua dimensione intera (accanto a un bordo sinistro o destro, ogni etichetta si trova sul lato superiore della sua linea della griglia). |
ticks | .ticks(..) | number | 10 su una scala linear (ogni passo delle scale allineate), ogni riga sulle altre | Circa quanti tick prende: su una scala linear, valori tondi dentro il dominio, circa questo numero; su una scala point o band, questo numero di righe distribuite uniformemente dalla prima all’ultima. |
tickValues | .tick_values(..) | number[] | nessuno | I valori dei suoi tick, al posto di ticks: valori della scala, indici di riga su una scala point o band ([0, 3, 6] etichetta una riga ogni tre). I valori dentro il dominio ottengono ciascuno un tick. |
format | .format(..) | (value: number, index: number) => string | la categoria di una band su una scala band di categorie, altrimenti il valore, con i decimali che il passo tra i tick richiede, con le migliaia raggruppate | Il testo di ogni tick: la funzione dell’app del suo valore (l’indice di una riga su una scala point o band) e del suo indice tra i tick dell’asse. |
labelClass, labelStyle | .label_class(..), .label_style(..) | string, StyleProp<TextStyle> | nessuno | La parte label: un testo per tick, con font, colore e padding dell’app; il suo padding verso il plot è la sua distanza dal bordo (pt-2 sotto un asse inferiore). |
tickClass, tickStyle | .tick_class(..), .tick_style(..) | string, StyleProp<ViewStyle> | nessuno | La parte tick: una view per tick sul bordo, centrata sul suo tick e che si estende verso le etichette (h-1 w-px bg-border sotto un asse inferiore); mostrata una volta che ha uno stile. |
lineClass, lineStyle | .line_class(..), .line_style(..) | string, StyleProp<ViewStyle> | nessuno | La parte line: una view lungo l’intero bordo, dal lato delle etichette (h-px bg-border sotto un asse inferiore); mostrata una volta che ha uno stile. |
All’esterno, le etichette stanno nel margine del plot, come gli assi di d3 stanno nei margini di un grafico: il plot mantiene la dimensione che l’app gli dà, e l’app gli dà un margine sul lato dell’asse profondo quanto le etichette (mb-6 per etichette pt-2 text-xs sotto di esso), oppure il padding del suo genitore in quel punto. Due assi sullo stesso bordo etichettano ciascuno i propri valori, nel proprio stile: gli estremi di un intervallo tenui e il suo centro più deciso.
format viene chiamato una volta per tick quando i tick o la funzione cambiano, nel turno successivo, mai mentre il plot disegna o segue il puntatore: mantienilo puro (stesso valore, stesso testo). Senza di esso, un tick si legge come lo scrive d3: 1,500, 0.25, −2. In Rust, plot_axis() crea un asse, il suo format un TickFormat::from_fn(|value, index| ..).
Un asse non accetta il puntatore, e gli screen reader leggono il plot anziché le sue etichette.
#PlotLabel
Un PlotLabel scrive testi in punti del plot: una didascalia sopra un picco, un’etichetta sull’ultimo punto di una linea, una nota all’estremità di una linea della griglia. Ciascuno dei suoi texts indica il proprio punto con valori delle scale del plot, come fanno i mark: l’indice di una riga su una scala point o band (il centro della banda della riga), un valore su una scala linear. Il bordo superiore del testo poggia sul punto, e il suo align lo fa iniziare lì, lo centra su di esso o lo fa terminare lì. Il layout del plot posiziona i testi, quindi seguono le dimensioni del plot; scorrono insieme ai domini delle scale (motion.update) e compaiono in dissolvenza con l’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 | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
texts | .texts(..) | PlotText[] | [] | I testi che scrive, ciascuno nel proprio punto. |
scale | .scale(..) | "y" | "y2" | "y" | La scala y di cui i y dei suoi testi sono valori (Assi doppi). |
Ogni testo è un PlotText:
| Campo | Tipo | Predefinito | Descrizione |
|---|---|---|---|
text | string | Ciò che legge. | |
x | number | Dove va lungo l’asse x: un valore della scala x, l’indice di una riga su una scala point o band (0.5 è a metà tra le prime due righe). | |
y | number | Dove va il suo bordo superiore lungo l’asse y: un valore della scala y. | |
align | "left" | "center" | "right" | "left" | Come si dispone lungo x rispetto al suo punto: iniziando da esso, centrato su di esso o terminando in esso. |
class, style | string, StyleProp<TextStyle> | nessuno | Le classi e lo stile propri del testo, sopra ciò che eredita dall’etichetta: il suo box (lo sfondo e il padding di un tag) e la sua distanza dal punto. |
L’aspetto è quello dell’app: il class dell’etichetta dà a ogni testo gli stili di testo che ereditano (font, dimensione, colore), mentre class e lo stile style propri di un testo lo stilizzano da solo. Un testo va sopra il suo punto con -translate-y-full, e il suo padding è la distanza dal punto (pb-1); un tag accanto all’ultimo punto di una linea si trova nel margine che l’app lascia su quel lato (mr-12), come fanno le etichette di un asse. In Rust, plot_label() crea un’etichetta, ogni testo è un PlotText::new(text, x, y) con .align(PlotTextAlign::Center), .class(..) e .style(..). Il puntatore attraversa i testi fino al plot, e gli screen reader leggono il plot, la cui accessibilityLabel dice che cosa indicano i testi.
#Animazione
L’animazione è quella dell’app, e per impostazione predefinita non c’è: tutto appare subito. motion accetta i timing di nsh ({ duration, easing, delay } e { stiffness, damping, mass }):
| Chiave | Tipo | Cosa si muove |
|---|---|---|
appear | timing | I mark si disegnano progressivamente, con un appearGeneration: linee e aree dal bordo d’ingresso, barre che crescono dalla loro base (una dopo l’altra con il stagger di una barra); punti che crescono dal nulla (uno dopo l’altro con il stagger di un punto); celle che crescono dal loro centro (una dopo l’altra con il stagger di una cella); l’anello di un pie e di un arc si apre in senso orario dal suo inizio; una linea radiale cresce dal suo raggio interno, con i suoi punti; i nodi e i nastri di un sankey dal bordo d’ingresso, con le sue etichette in dissolvenza. |
update | timing | I mark si spostano verso la nuova forma quando l’app la cambia: i punti verso i nuovi valori e le nuove dimensioni, il punto di una nuova riga che cresce e quello di una riga eliminata che si riduce fino a sparire; le celle verso i nuovi colori (mescolati in OKLab) e le nuove bande, la cella di una nuova riga che cresce e quella di una riga eliminata che si riduce fino a sparire; i raggi di un pie e di un arc, così il foro di un donut si apre e si chiude, e i loro angoli, così un gauge o una fetta scorre fino al nuovo valore (arcTween di d3: si muovono gli angoli, non il contorno; una nuova fetta si apre da dove finisce quella precedente; una modifica durante un movimento prosegue da dove è disegnato); i punti di una linea radiale verso i nuovi valori, angoli e raggi (una nuova riga che cresce dal raggio interno), insieme agli anelli di una griglia radiale; il dominio di una scala lineare, ossia il domain dell’app o l’estensione dei valori dei mark man mano che arrivano le righe, che scorre dal vecchio al nuovo, con i mark, le linee della griglia, i PlotAxis e i PlotLabel al seguito; e i nodi, i nastri e le etichette di un sankey, dal layout dei vecchi flussi a quello nuovo, con una modifica durante il movimento che prosegue da dove sono disegnati. |
enter | timing | Le parti dell’hover compaiono in dissolvenza quando il puntatore arriva sul plot. |
exit | timing | Scompaiono in dissolvenza dopo che il puntatore se ne va. |
pointer | spring | Scorrono da una riga alla successiva quando i tasti o uno screen reader spostano l’hover; la fetta di un pie si solleva. |
Le parti dell’hover seguono il puntatore nello stesso frame: mentre si muove, la linea di intersezione, i punti e il tooltip si trovano subito sulla riga sotto di esso, quindi seguono la mano, mentre la loro dissolvenza in entrata e in uscita (enter, exit) resta animata.
L’animazione viene eseguita dall’animator della piattaforma, senza JS per frame, e si completa subito per chi richiede movimento ridotto. Un plot che una lista ricrea per una riga che ritorna si disegna di nuovo, a meno che un PlotAppearScope attorno alla lista non abbia visto terminare il suo appear.
Un dominio si muove come d3 fa transitare il dominio di una scala: la scala intermedia posiziona ogni valore, quindi i mark mantengono la loro forma, le linee della griglia e le parti dell’hover si muovono con essi, le barre mantengono i loro angoli, e una riga che entra con il nuovo dominio si muove insieme alle altre. Un grafico live che adatta la sua scala y alle righe di tanto in tanto (con un po’ di margine, così la griglia resta ferma tra un adattamento e l’altro) scorre verso ogni nuovo adattamento con un timing update. Quando le dimensioni del plot cambiano, i mark assumono subito le nuove dimensioni.
È l’animazione propria del plot, i timing di ciò che un grafico muove: il clip dei suoi mark e le parti del suo hover. Le entrate, le uscite e gli attira-attenzione di una vista sono i preset e i keyframe di createMotion, che un’app può comunque riprodurre sulla vista del plot o sugli elementi che lo circondano.
#PlotAppearScope
Una lista ricrea il plot di una riga ogni volta che la riga torna in vista, quindi una sparkline con un appear si disegna di nuovo ogni volta che la lista torna a scorrere fino a essa. Un PlotAppearScope attorno alla lista ricorda gli appear che sono terminati al suo interno: un plot ricreato lì il cui appearGeneration è terminato mostra subito i suoi mark per intero. Un plot è ricordato tramite la sua generazione, quindi il plot di ogni riga ha la propria, composta dalla chiave della riga con ciò che dovrebbe farlo ripetere (`${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 | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
id | .id(..) | number | string | nessuno | Per che cosa lo scope ricorda: quando cambia, lo scope dimentica e i suoi plot si disegnano di nuovo, dove si trovano. Va chiamato in base al contenuto, come una watchlist o una conversazione; senza di esso, lo scope ricorda finché è mostrato. |
children | .child(..) | JSX.Element | nessuno | Ciò che contiene: la lista, o qualunque cosa ricrei i suoi plot man mano che vanno e vengono. |
Una nuova generazione disegna comunque il plot da capo, così come un plot rimosso prima che il suo appear terminasse. Quando un plot ha terminato una nuova generazione, il suo scope dimentica quella precedente, quindi tornare a un periodo precedente disegna di nuovo il plot. La memoria va con lo scope: uno scope che non è più mostrato, o uno con un nuovo id, disegna i suoi plot da capo, e un plot ricorda nello scope più interno che lo circonda. Il suo aspetto è quello dell’app: class e style lo dispongono come qualsiasi vista (flex-1 attorno a una lista che riempie lo schermo). In Rust, plot_appear_scope() ne crea uno.
#Tastiera
Un plot interattivo è una tappa del Tab (macOS, web), e finché ha il focus della tastiera i suoi tasti muovono lo stesso hover del puntatore: la linea di intersezione, i punti e il tooltip scorrono con la spring pointer, la fetta di un pie si solleva, onHoverChange segnala la riga, e gli screen reader leggono il accessibilityValue che l’app fornisce per essa. Il suo anello di focus è quello dell’app: una classe focus-visible: o un focusStyle (focus-visible:outline-2 focus-visible:outline-ring).
| Tasti | Cosa fanno |
|---|---|
| Destra, Sinistra | Si spostano sulla riga da quel lato: la riga successiva o quella precedente, al contrario su una scala x invertita, la cui prima riga è a destra (su un pie, le sue fette nell’ordine delle righe; su un sankey, i suoi nodi; su un treemap, la foglia accanto sullo schermo, come fanno Su e Giù); senza alcuna riga in hover, Destra parte dalla riga all’estremità sinistra e Sinistra da quella all’estremità destra (su un pie, la prima e l’ultima fetta). |
| Home, End | Si spostano sulla prima o sull’ultima riga. |
| Su, Giù | Sulle celle: si spostano sulla cella sopra o sotto nella stessa colonna. Sulle celle i tasti seguono la griglia sullo schermo (la data grid di WAI-ARIA): Destra e Sinistra si spostano sulla cella successiva lungo la linea, Home e End sulla prima e sull’ultima cella della linea; senza alcuna cella in hover, il primo tasto parte dalla cella in alto a sinistra (Sinistra, Su e End da quella in basso a destra). Su un bordo l’hover resta dov’è. |
| Escape | Cancella l’hover. |
Il plot risponde ai suoi tasti finché ha il focus, e a Escape mentre una riga è in hover; il resto del tempo vanno all’app e alle viste che lo circondano. Spostare il focus altrove cancella un hover creato dai tasti. Il plot non accetta pressioni, quindi un plot dentro una card premibile lascia alla card le sue pressioni. Anche gli screen reader scorrono le righe: lo swipe su e giù di VoiceOver, lo scorrimento avanti e indietro di TalkBack, e ogni screen reader che offre le azioni increment e decrement di un elemento si spostano sulla riga successiva e su quella precedente, nell’ordine delle righe anche su una scala invertita.
#Costruire grafici
Un grafico è un plot, i suoi mark, i suoi assi e gli elementi propri dell’app attorno a essi. Dai alla scala y un domain e al suo asse i valori della griglia (tickValues): ogni etichetta si troverà così sulla sua linea della griglia. La linea di intersezione, i punti e il tooltip dell’hover sono parti del plot, stilizzate e riempite dall’app; i valori della riga vanno dove l’app li mostra.
Le griglie si leggono meglio come linee sottili continue con una linea di base più marcata, il che lascia libera una linea tratteggiata per indicare un valore di riferimento (il prezzo di apertura qui sotto). Un’area si legge meglio come una velatura che sfuma verso la sua linea di base che come un riempimento uniforme. Il punto di hover di ogni serie prende il colore della sua serie, e il colore della superficie su cui poggia il grafico come suo anello, così si stacca dalla sua linea, e il tooltip sta in cima al plot, all’interno dei suoi bordi sulla prima e sull’ultima riga: un domain con spazio sopra il valore più alto lo tiene lontano dalla linea.
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>
);
}
Un grafico a barre si legge meglio con le punte delle barre arrotondate e le basi squadrate (cornerRadii={{ topLeft: 4, topRight: 4 }}; in a stack only on its top bar), a hair of room between the bars of a band (groupPadding), le barre delle altre righe attenuate mentre una è in hover (inactiveOpacity) e la sua banda colorata (crossLineClass), e i valori in piccole cifre tabulari sopra le barre, con il totale sopra una pila. Con paddingOuter pari alla metà di paddingInner, i centri delle bande cadono a intervalli uguali, e un PlotAxis centra ogni mese sotto la sua banda; un maxBandWidth mantiene strette alcune barre in una card larga.
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>
);
}
Una sparkline in una riga di lista è un plot con interactive={false}: disegna i suoi mark e non segue alcun puntatore.
#Accessibilità
- Un plot è un unico elemento per gli screen reader, con il ruolo image e il
accessibilityLabeldell’app; i suoi mark, le etichette dei suoi assi, i suoiPlotLabele le parti dell’hover ne fanno parte e non dicono nulla da soli. Indica nell’etichetta che cosa mostra il grafico e che cosa spicca (l’intervallo, il valore più alto), e dai aaccessibilityValuele parole per la riga sotto il puntatore, così uno screen reader dice il valore mentre cambia. - La riga sotto il puntatore viene segnalata con
onHoverChangesulle piattaforme con un puntatore (desktop, web, un iPad con trackpad). Gli schermi touch non segnalano l’hover: mostra i valori di cui un utente touch ha bisogno negli elementi propri dell’app, oppure offri le righe come testo o come lista dove i numeri contano. - Su un pie la riga segnalata è quella della fetta sotto il puntatore: dai a
accessibilityValueil suo nome, valore e quota ("Team, 340 seats, 34.0%"), e indica nell’etichetta il totale e la quota maggiore. Su un radar è l’asse più vicino al puntatore: dagli il nome dell’asse e il valore di ciascuna serie in quel punto. - Un plot interattivo è una tappa del focus della tastiera i cui tasti freccia muovono l’hover (Tastiera), e offre agli screen reader
incrementedecrementper scorrere le righe (VoiceOver lo legge come regolabile); un plot coninteractive={false}non è nessuna delle due cose, e in entrambi i casi non accetta pressioni.
Vedi Accessibilità per etichette e ruoli in generale.
#Note sulle piattaforme
- Ogni piattaforma disegna i mark allo stesso modo, a partire da un disegno per mark registrato in Rust; una modifica delle righe ridisegna in un unico batch i mark che le leggono, mentre una modifica di una classe di vernice (l’aspetto,
dark:) invia di nuovo il disegno senza registrarlo. - L’apparizione ritaglia linee e aree e fa crescere le barre, con valori che l’animator della piattaforma muove; la dissolvenza di una barra rispetto alla riga in hover è un’opacità che l’animator calcola a partire dalla posizione dell’hover; le parti dell’hover sono view che l’animator muove e dissolve, e mantiene il tooltip all’interno del plot a partire dalle dimensioni del plot e da quelle del tooltip.
- Lo sfondo di un’area viene disegnato nel proprio canvas, ritagliato secondo la forma dell’area, con lo stesso gradiente che la piattaforma disegna per il
bg-linear-*di una view; una modifica delle sue classi (l’aspetto,dark:) invia di nuovo il disegno senza registrarlo. - Un grafico a torta disegna ogni fetta nel proprio canvas; la fetta sotto il puntatore viene individuata in Rust a partire dagli angoli e dai raggi delle fette, il suo sollevamento è un raggio e la dissolvenza delle altre è un’opacità che l’animator muove, e lo sweep dell’apparizione muove allo stesso modo gli angoli delle fette, quindi nessuno dei due registra di nuovo un disegno.
- La riga di un grafico a linee radiale sotto il puntatore viene individuata in Rust a partire dagli angoli dei suoi punti; il suo punto di hover è una view che l’animator posiziona sulla riga sotto il puntatore (scivolando sulla spring
pointermentre le chiavi avanzano), e mentre appare o si sposta verso nuovi valori i suoi punti sono numeri che l’animator muove a partire da dove si trovavano, quindi la linea viene registrata una sola volta per modifica. - Un dot mark viene disegnato in un unico canvas; il punto sotto il puntatore viene individuato in Rust a partire dai centri e dai raggi dei punti, e il suo punto di hover è una view che l’animator posiziona su di esso, grande quanto lui; la crescita dell’apparizione, gli spostamenti di una modifica e la dissolvenza dell’hover sono valori che l’animator muove, quindi i punti vengono registrati una sola volta per modifica.
- Un cell mark viene disegnato in un unico canvas; la cella sotto il puntatore viene individuata in Rust a partire dai riquadri delle celle, e la sua cella di hover è una view che l’animator posiziona su di essa; la crescita, i colori e gli spostamenti di una modifica e la dissolvenza dell’hover sono valori che l’animator muove, quindi le celle vengono registrate una sola volta per modifica. Ogni etichetta è un testo della piattaforma, mostrato dove la sua dimensione calcolata dal layout rientra nella cella.
- Le etichette, i segni di graduazione e la linea di un asse sono testi e view propri della piattaforma, posizionati dal layout in Rust a partire dalle dimensioni e dalle scale del plot: si spostano insieme ai mark quando cambiano le dimensioni del plot, e un nuovo dominio scrive di nuovo le etichette.