Elemente
Plot
Ein Plot ist der Rahmen eines Diagramms: die Zeilen an Daten, die er zeigt, die Skalen seiner x- und y-Achse, der Zeiger darüber und dessen Bewegung. Seine Marks zeichnen, was er enthält: ein Line durch eine Serie, ein Area zwischen einer Serie und einer Basislinie, ein Bar pro Zeile in den Bändern einer Band-Skala, ein Rule pro Zeile von einem Anfang bis zu einem Ende (der Docht einer Kerze), ein Dot pro Zeile, wo sich zwei ihrer Werte treffen (die Punkte eines Streudiagramms, die Blasen eines Blasendiagramms), ein Cell pro Zeile in den Bändern zweier Kategorien, eingefärbt nach seinem Wert (eine Heatmap), ein Grid aus Linien quer darüber, ein Pie aus den Anteilen der Zeilen (ein Donut mit einem Loch), ein Arc zwischen zwei Winkeln und ein RadialLine um den Mittelpunkt, mit den Ringen und Speichen eines RadialGrid dahinter (ein Radar), ein Sankey aus Flüssen zwischen den Zeilen und ein Treemap aus verschachtelten Rechtecken, wobei die Fläche jedes Blatts seinem Wert entspricht (eine Market Map); ein PlotAxis beschriftet einen Rand mit den Ticks seiner Skala, und ein PlotLabel schreibt Texte an Punkte der Skalen. Jeder Mark ist ein Canvas, das den Plot ausfüllt und nativ aus den Zeilen gezeichnet wird: Die Zeilen der App werden einmal gelesen, wenn sie sie setzt, und dann in Rust skaliert, aufgezeichnet und gezeichnet, sodass ein Diagramm mit tausend Punkten kein JS pro Punkt ausführt. Auch der Zeiger wird in Rust verfolgt: Die Cross Line, die Punkte und der Tooltip des Hovers bewegen sich und blenden auf dem Animator der Plattform ein und aus, und die App erfährt nur von einer neuen Zeile, mehr nicht. Das Aussehen bestimmt die App: Marks malen mit den Paint-Properties von CSS für SVG-Formen (stroke-*, fill-*), currentColor, wo keine gesetzt ist; eine Fläche oder ein Balken zeigt innerhalb ihrer Form einen Hintergrund (einen Verlauf, bg-linear-*), und die Teile des Hovers (darunter der Punkt jedes Marks) sowie die Beschriftungen, Tick-Marken und Linien der Achsen gestaltet und befüllt die App.
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 und PlotLabel stammen aus nsh/base/plot, einem eigenen Modul von nsh/base (in Rust nsh_base::plot). In TypeScript sind die Props eines Plots generisch über den Typ seiner Zeilen: Feldnamen werden dagegen geprüft, und eine Funktion einer Zeile erhält den Typ der Zeile. In Rust erzeugt plot() einen Plot mit einem Setter pro Prop, die Zeilen sind ein Rows (Spalten von Zahlen nach Schlüssel), die Werte ein RowAccessor (ein Schlüssel oder eine Funktion des Zeilenindex), und die Marks nehmen die typisierten Paint-Methoden entgegen (.stroke_color(..), .fill_color(..), .stroke_width(..)).
#Plot
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
data |
.data(..) |
T[] |
(erforderlich) | Die Zeilen, ein Objekt pro Punkt. Die Marks lesen ihre Werte einmal, wenn die App die Zeilen setzt; eine Zeile ohne Wert (ein Feld, das keine Zahl ist, null) wird übersprungen. |
x |
.x(..) |
keyof T | (d: T, index: number) => number |
der Index der Zeile | Was eine Zeile bei einer linear-x-Skala entlang der x-Achse platziert. Eine point-Skala platziert die Zeilen der Reihe nach. |
y |
.y(..) |
keyof T | (d: T, index: number) => number |
keiner | Was die Marks zeichnen, sofern sie nicht ihren eigenen Wert angeben. |
xScale |
.x_scale(..) |
Scale |
{ type: "point" } |
Wie die x-Achse Zeilen platziert: point (der Reihe nach, gleichmäßig vom linken zum rechten Rand; eine einzelne Zeile in der Mitte), linear mit einer optionalen domain oder band (ein Band pro Zeile, für Balken, Bar; oder ein Band pro Kategorie, für Zellen, Cell). Mit einer band-y-Skala ist der Standard linear, für querliegende Balken. reverse: true lässt sie vom rechten Rand aus laufen (Umgekehrte Skalen). |
yScale |
.y_scale(..) |
Scale |
{ type: "linear" } |
Wie die y-Achse Werte platziert: der kleinste Wert der Domain am unteren Rand, der größte am oberen. Ohne domain gilt der Umfang dessen, was die Marks zeichnen. band legt ein Band pro Zeile vom oberen Rand abwärts an, für querliegende Balken. reverse: true lässt sie andersherum laufen: der kleinste Wert am oberen Rand, die Zeilen vom unteren Rand aufwärts (Umgekehrte Skalen). |
y2Scale |
.y2_scale(..) |
LinearScale |
keine | Eine zweite y-Skala, { type: "linear", domain?, reverse? }, für die Marks, die sie angeben (scale="y2"): die rechte Skala eines Diagramms mit zwei Achsen (Duale Achsen). Ohne domain gilt der Umfang der Werte darauf. |
alignTicks |
.align_ticks(..) |
boolean |
true |
Solange keine der beiden y-Skalen eine Domain angibt und auf beiden Marks liegen, werden beide auf dieselben runden Schritte erweitert, sodass ihre Ticks auf denselben Linien liegen (Duale Achsen). false belässt jeder Skala den Umfang ihrer Werte. |
interactive |
.interactive(..) |
boolean |
true |
Der Plot folgt dem Zeiger und der Tastatur: Er meldet die Zeile darunter und zeigt die Teile des Hovers, und er ist ein Tab-Stopp, dessen Pfeiltasten den Hover bewegen (Tastatur). false zeichnet nur die Marks (eine Sparkline in einer Liste), und Klicks darauf gehen an die umgebenden Views. Ob der Plot die Tastatur überhaupt anbietet, wird beim Erstellen festgelegt. |
motion |
.motion(..) |
PlotMotion |
keine | Das Timing des Appear, das Ein- und Ausblenden des Hovers und die Feder, auf der der Hover gleitet, wenn die Tasten ihn bewegen (siehe unten). |
appearGeneration |
.appear_generation(..) |
number | string |
keiner | Aktiviert das Appear: Die Marks werden eingezeichnet, wenn der Plot erstmals angezeigt wird, und erneut, sobald sich dieser Wert ändert (ein anderes Symbol, ein anderer Zeitraum). Innerhalb eines PlotAppearScope wird eine Generation, die dort abgeschlossen wurde, sofort vollständig angezeigt. |
accessibilityLabel |
.accessibility_label(..) |
string |
keiner | Was Screenreader über das Diagramm sagen: was es zeigt und was hervorsticht. |
accessibilityValue |
.accessibility_value(..) |
{ min?, max?, now?, text? } |
keiner | Was Screenreader als seinen Wert hören: die Worte der App für die Zeile unter dem Zeiger. |
crossLineClass, crossLineStyle |
.cross_line_class(..), .cross_line_style(..) |
string, StyleProp<ViewStyle> |
keine | Die Cross Line: eine View, so hoch wie der Plot, zentriert auf der Zeile unter dem Zeiger. Bei einer Band-Skala liegt sie im Band der Zeile und ist so breit wie das Band (bei einer Band-y-Skala quer über den Plot). |
dotClass, dotStyle |
.dot_class(..), .dot_style(..) |
string, StyleProp<ViewStyle> |
keine | Die Punkte des Hovers: eine View pro Line, Area und RadialLine, zentriert auf ihrem Punkt bei der Zeile unter dem Zeiger; die eines Dot ist so groß wie sein Punkt unter dem Zeiger. hoverDotClass / hoverDotStyle eines Marks ersetzen sie für seinen eigenen Punkt. |
tooltipContent |
.tooltip_content(..) |
JSX.Element |
keiner | Der Tooltip der App, platziert durch tooltipPlacement und mit der Cross Line bewegt und ein- und ausgeblendet. |
tooltipPlacement |
.tooltip_placement(..) |
"top" | "pointer" |
"top" |
Wohin der Tooltip kommt: an den oberen Rand des Plots, zentriert auf der Cross Line; oder neben den Zeiger, auf die Seite der Cross Line zur Mitte des Plots hin und in der oberen Hälfte unter den Zeiger, in der unteren darüber. Am oberen Rand bleibt er zwischen dem linken und dem rechten Rand des Plots (sein Abstand vom oberen Rand ist Sache der App: ein Tag unter dem Plot nimmt die Höhe des Plots und mehr); neben dem Zeiger bleibt er innerhalb des Plots. Mit der Tastatur kommt er neben den Punkt der ersten Linie oder Fläche bei der Zeile (die Mitte des Bands eines Balkens, die Mitte des Segments eines Kreisdiagramms). |
tooltipOffset |
.tooltip_offset(..) |
number |
0 |
Der Abstand zwischen dem Tooltip und dem, was ihn platziert: der obere Rand bei "top", die Cross Line und der Zeiger bei "pointer". |
colorScale |
.color_scale(..) |
ColorScale |
keine | Die Farben der Marks: nach den Kategorien, die ihr color liest, wie bei d3s scaleOrdinal ({ type: "ordinal", domain?, range, unknown? }, Farben nach Kategorie), oder nach den Zahlen, die das value eines Cell liest, entlang einer Rampe, wie bei d3s scaleSequential und scaleDiverging ({ type: "sequential" | "diverging", … }, Farben entlang einer Rampe). |
children |
.child(..) |
JSX.Element |
keine | Die Marks, der Reihe nach übereinander gezeichnet, und alles, was die App darüberlegt. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keine | Größe und Aussehen des Plots; die Marks erben seine Paint-Properties, so wie CSS sie vererbt. Die Klassen focus: / focus-visible: und focusStyle zeichnen seinen Fokusring. |
| Event | Rust | Payload | Zeitpunkt |
|---|---|---|---|
onHoverChange |
.on_hover_change(..) |
{ index: number | null } |
Der Zeiger kam über eine andere Zeile: der Index der Zeile, die ihm entlang der x-Achse am nächsten liegt (entlang der y-Achse, wenn nur sie Bänder hat), des Segments des pie darunter, des dot, dessen Mittelpunkt ihm am nächsten liegt, der cell darunter (über der Lücke zwischen zwei Zellen die nähere) oder der Zeile einer radial line, die ihm nach Winkel am nächsten liegt; null, sobald er den Plot verlässt (über einem Pie seine Segmente; über einer Radial Line ihr äußerer Radius). |
Scale is { type: "point"; reverse? } | { type: "linear"; domain?: [number, number]; reverse? } | { type: "band"; domain?: string[]; paddingInner?; paddingOuter?; maxBandWidth?; bandCount?; groupPadding?; reverse? }. A linear
Die domain einer Skala ist der Umfang ihrer beiden Werte, in beliebiger Reihenfolge; eine Domain aus einem Wert setzt ihn in die Mitte. Die domain einer band-Skala sind die Kategorien ihrer Bänder, der Reihe nach (Cell); ihre anderen Optionen stehen unter Bar.
Eine Funktion einer Zeile (y={(d) => d.high - d.low}) wird einmal pro Zeile aufgerufen, wenn sich die Zeilen oder die Funktion ändern, im nächsten Turn, nie während der Plot zeichnet oder dem Zeiger folgt: Halte sie pur (dieselbe Zeile, dieselbe Zahl). Ein Feldname wird ganz ohne JS gelesen.
#Umgekehrte Skalen
reverse: true lässt eine Skala von ihrem anderen Rand aus laufen, so wie die Skalen von visx reverse und die von d3 einen rückwärts geschriebenen Range annehmen. Auf der x-Achse liegt die erste Zeile oder der kleinste Wert der Domain am rechten Rand; auf der y-Achse liegt der kleinste Wert der Domain am oberen Rand, und die Zeilen laufen vom unteren Rand aufwärts. Alles, was auf der Skala platziert wird, folgt ihr: die Marks, die Basis der Balken und die Richtung, in die sie wachsen, die Basislinie einer Fläche, die Gitterlinien, die PlotAxis-Ticks, die Zeile, die der Hover findet, die Pfeiltasten (Tastatur) und die Übergänge zu einer neuen Domain. Balken hängen bei einer umgekehrten linear-y-Skala vom oberen Rand herab, und querliegende Balken wachsen bei einer umgekehrten linear-x-Skala vom rechten Rand nach links:
<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>
Beim Appear des Plots werden Linien und Flächen von dem Rand aus eingezeichnet, an dem die x-Skala beginnt, bei einer umgekehrten also vom rechten.
#Duale Achsen
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);Ein Plot hat eine zweite y-Skala für Werte anderer Einheiten: Volumen neben einem Preis, Umsatz neben einer Marge. Jeder Teil, der Werte entlang y platziert, nimmt scale an: "y" (der Standard, die yScale des Plots) oder "y2" (seine y2Scale). Eine PlotAxis rechts beschriftet sie mit scale="y2":
<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>
Die Domain jeder Skala gehört ihr allein: die domain der App oder der Umfang der Werte darauf, und jede wechselt mit motion.update zu einer neuen. Solange keine der beiden Skalen eine Domain angibt, legt der Plot beide in derselben Anzahl runder Schritte aus (1, 2 oder 5 mal eine Zehnerpotenz, ein Schritt für etwa je 32 Punkte Höhe): Enthalten beide die Null, liegt die Null auf beiden auf derselben Linie; andernfalls gewinnt eine Skala, die bei null beginnt (Volumen), ihre zusätzlichen Schritte nach oben und eine andere (ein Preis) auf beiden Seiten. Die Ticks der Achsen sind diese Schritte, und ein Grid ohne x und y zeichnet seine Linien bei ihnen, sodass ein Gitter beiden Achsen dient. alignTicks={false} schaltet das ab; eine domain, die die App angibt, belässt beide Skalen, wie sie sind.
Der Hover findet eine Zeile; der Punkt jedes Marks sitzt auf seiner eigenen Skala, und die Anzeige ist Sache der App, die beide Werte der Zeile liest, die onHoverChange meldet. Balken auf beiden Skalen stehen in jedem Band nebeneinander, und ein stack stapelt nur die Balken auf seiner Skala. y2Scale ist linear: Ein Plot, dessen y-Achse Bänder hat, hat keine zweite y-Skala.
#Line, Area und Grid
| Mark | Prop | Typ | Standard | Beschreibung |
|---|---|---|---|---|
Line |
y |
string | (d, index) => number |
y des Plots |
Was gezeichnet wird: ein Punkt pro Zeile mit Wert; Zeilen ohne Wert werden übersprungen. |
Line, Area |
data, x |
T[], string | (d, index) => number |
die Zeilen des Plots | Zeilen einer eigenen Serie (andere x-Werte), die über das eigene x auf den Skalen des Plots platziert werden (ohne: der Index der Zeile). |
Line, Area |
curve |
"natural" | "linear" | "stepAfter" |
"natural" |
Wie die Punkte verbunden werden: als glatte Kurve, als gerade Segmente oder als Stufen, die jeden Wert bis zur nächsten Zeile halten. Die untere Kante einer Area folgt derselben Kurve. |
Line |
dot, dotClass, dotStyle |
boolean, string, StyleProp<ViewStyle> |
false |
Ein Punkt auf jedem Datenpunkt: eine auf ihm zentrierte View, deren Größe, Hintergrund und Rahmen von der App bestimmt werden. |
Line, Area |
hoverDotClass, hoverDotStyle |
string, StyleProp<ViewStyle> |
dotClass, dotStyle des Plots |
Der Hover-Punkt auf dem Datenpunkt der Zeile unter dem Zeiger: der Punkt jeder Serie in ihrer eigenen Farbe. Jeder ersetzt den des Plots für dieses Mark (die Farbe des Marks über Größe und Ring des Plots); hidden zeigt keinen. |
Area |
backgroundClass, backgroundStyle |
string, StyleProp<ViewStyle> |
keiner | Sein Hintergrund: eine Box um seine Form (vom ersten bis zum letzten Punkt, vom höchsten Punkt bis zur Basislinie oder bei einem Band bis zum tiefsten Punkt der unteren Kante), deren Hintergrund (bg-*, bg-linear-to-b from-… to-…, backgroundImage) nur innerhalb der Form sichtbar ist, unter ihrem fill. Ist einer gesetzt, ist fill standardmäßig none. |
Area |
y1 |
string | (d, index) => number |
y des Plots |
Was die obere Kante bildet. |
Area |
y0 |
number | string | (d, index) => number |
der untere Rand der y-Skala | Die untere Kante: Eine Zahl ist eine Basislinie auf der y-Skala; ein Feld oder eine Funktion liefert für jede Zeile einen eigenen unteren Wert, wodurch die Area zu einem Band wird (Tagestief bis Tageshoch, Spanne einer Prognose). Ohne Angabe schließt die Area an dem Rand ab, an dem die kleinsten Werte der y-Skala liegen: unten, bei einer umgekehrten Skala oben (eine Area, die von oben hängt). Eine Zeile ohne Wert an einer der beiden Kanten wird übersprungen. |
Line, Area, Grid |
scale |
"y" | "y2" |
"y" |
Die y-Skala, auf der seine Werte liegen: die yScale des Plots oder dessen zweite, y2Scale (Duale Achsen). |
Grid |
x |
number[] |
keine | Wo die vertikalen Linien verlaufen, als Werte der x-Skala (Zeilenindizes bei einer point-Skala). |
Grid |
y |
number[] |
keine | Wo die horizontalen Linien verlaufen, als Werte der y-Skala. Ohne x und y folgen die Linien den Ticks ihrer Skala, dort, wo ein PlotAxis ohne ticks sie setzt (bei liegenden Balken entlang x). |
Grid |
ticks |
number |
Standard der Achse | Ungefähr, wie viele Linien ohne x und y gezeichnet werden, wie das ticks eines PlotAxis. |
Line, Area |
color |
string | (d, index) => string |
keine | Die Kategorie seiner Zeilen, deren Farbe aus der colorScale des Plots stammt: Die Kategorie der ersten Zeile bestimmt die stroke der Line (die fill der Area), wo nichts anderes gesetzt ist (Farben nach Kategorie). |
Jedes Mark füllt den Plot und wird mit seinen SVG-Paint-Eigenschaften gezeichnet, wobei seine eigenen Vorrang vor denen des Plots haben, so wie CSS sie vererbt:
| Mark | Zeichnet mit | Wo keine gesetzt ist |
|---|---|---|
Line |
stroke, strokeWidth, strokeDasharray |
currentColor, 1 breit, durchgezogen |
Area |
fill (über seinem background-Teil); seine obere Kante stroke, strokeWidth, strokeDasharray |
fill currentColor (none neben einem Hintergrund); kein Strich |
Grid |
stroke, strokeWidth, strokeDasharray |
currentColor, 1 breit, durchgezogen; jede Linie auf ganzen Geräte-Pixeln, damit eine dünne scharf bleibt |
Die Werte, die eine Line oder eine Area zeichnet, bei einer Area auch ihr y0 (die Basislinie oder der untere Wert jeder Zeile), bilden die Domain der y-Skala, wenn der Plot keine hat; die eines Grid tun das nicht. Ein Mark wird nur neu gezeichnet, wenn sich ändert, was es zeichnet (seine Zeilen, Werte, Kurve, die Größe oder Skalen des Plots, sein Paint); der Zeiger bewegt die Teile des Hovers und führt nie dazu, dass ein Mark neu gezeichnet wird.
Ein Band ist eine Area, deren y0 die Zeilen liest: Ihre obere Kante verläuft durch y1, ihre untere Kante zurück durch y0, beide mit dem curve der Area, und das appear zeichnet beide von der Vorderkante her ein. Der Hover-Punkt sitzt auf der oberen Kante; eine Line darüber markiert den mittleren Wert:
<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 ist das von CSS, wie in einem Browser: eine Farbe. Ein Verlauf unter einer Line ist der Hintergrund einer Area, so wie eine Seite einen Verlauf in einer Form zeigt (ein background-image einer Box unter clip-path): backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" blendet vom höchsten Punkt der Area bis zu ihrer Basislinie aus, in den Tokens des Themes und den Farben des Appearance wie der Hintergrund jeder View.
#Bar
Ein Bar zeichnet pro Zeile einen Balken im Band der Zeile auf einer band-Skala: aufrecht auf einer Band-x-Skala, liegend auf einer Band-y-Skala (die x-Skala ist dann linear). Jeder Balken reicht von base bis zum Wert der Zeile auf der anderen Skala, sodass ein negativer Wert in die andere Richtung wächst; eine Zeile ohne Wert hat keinen Balken. Auf einer umgekehrten Wertskala wachsen die Balken von der oberen Kante nach unten oder von der rechten nach links, und eine umgekehrte Band-Skala setzt das Band der ersten Zeile nach rechts oder unten (Umgekehrte Skalen).
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>
Balken außerhalb eines Stacks stehen in jedem Band nebeneinander, in der Reihenfolge der Marks; Balken mit demselben stack werden gestapelt, wobei der Balken jeder Zeile dort beginnt, wo der vorherige im Stack endet:
<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 | Typ | Standard | Beschreibung |
|---|---|---|---|
value |
string | (d, index) => number |
y des Plots |
Was jeder Balken zeigt. |
base |
number | string | (d, index) => number |
0 |
Der Wert, von dem die Balken auf der Wertskala aus wachsen: einer für alle Zeilen oder ein Feld oder eine Funktion, die jeder Zeile einen eigenen liefert, sodass jeder Balken von der Basis seiner Zeile bis zu ihrem Wert reicht (eine Zeile ohne Basis hat keinen Balken). Er zählt zur Domain. Ein Stack wächst Zeile für Zeile von der Basis seines ersten Balkens aus. |
stack |
string |
keiner | Der Stack, dem er beitritt: Die Balken mit demselben stack werden der Reihe nach gestapelt (eine Zeile ohne Wert fügt nichts hinzu). |
scale |
"y" | "y2" |
"y" |
Die y-Skala, auf der die Werte seiner aufrechten Balken liegen (Duale Achsen); Balken auf beiden stehen nebeneinander, und ein Stack stapelt nur die Balken auf seiner Skala. |
cornerRadii |
number | { topLeft?, topRight?, bottomRight?, bottomLeft? } |
0 |
Der Radius jeder Ecke seiner Balken; { topLeft: 4, topRight: 4 } rundet die Spitzen aufrechter Balken ab. Ein Radius, der größer als ein halber Balken ist, wird angepasst. |
stagger |
number |
0 |
Mit dem appear des Plots starten die Balken nacheinander, Zeile für Zeile, über diesen Anteil davon (0 bis 0.95), wobei jeder in der restlichen Zeit wächst; 0 lässt sie gemeinsam wachsen. |
inactiveOpacity |
number |
keine | Die Deckkraft seiner Balken abseits der Zeile unter dem Zeiger, solange der Zeiger über dem Plot ist, damit diese Zeile hervorsticht: Sie blendet mit dem Hover ein und aus und wandert mit ihm von Balken zu Balken. |
label |
string | (d, index) => string |
keiner | Der Text am Wertende jedes Balkens: ein Feld (sein Text oder seine Zahl) oder eine Funktion, die den von der App formatierten Text zurückgibt. |
labelClass, labelStyle |
string, StyleProp<TextStyle> |
keiner | Der label-Teil: ein auf dem Balken zentrierter Text, über dem Ende eines aufrechten Balkens (unter einem, der nach unten wächst), hinter einem liegenden Balken (vor einem, der zurückwächst). Sein Padding ist sein Abstand vom Balken. |
backgroundClass, backgroundStyle |
string, StyleProp<ViewStyle> |
keiner | Sein Hintergrund: die Box jedes Balkens, deren Hintergrund (bg-linear-to-t from-… to-…, vom unteren Ende des Balkens bis zu seinem oberen) nur innerhalb des Balkens sichtbar ist, unter seinem fill. Ist einer gesetzt, ist fill standardmäßig none. |
color |
string | (d, index) => string |
keine | Die Kategorie jedes Balkens, deren Farbe aus der colorScale des Plots stammt: die fill des Balkens, wo nichts anderes gesetzt ist (Farben nach Kategorie). |
Die Band-Skala legt pro Zeile des Plots ein Band an, der Reihe nach, oder pro Kategorie ihres domain, wie es scaleBand von d3 tut:
| Option | Standard | Beschreibung |
|---|---|---|
domain |
keine | Die Kategorien seiner Bänder, der Reihe nach (scaleBand().domain() von d3): Ein Cell kommt in das Band der Kategorie seiner Zeile, und ein PlotAxis schreibt die Kategorie jedes Bands; eine doppelt geschriebene Kategorie behält ihren ersten Platz. Ohne Angabe sind die Bänder die Zeilen oder, über Zellen, die Kategorien, die die Zellen entlang der Achse benennen, in der Reihenfolge, in der die Zeilen sie liefern. |
paddingInner |
0 |
Der Abstand zwischen benachbarten Bändern, ein Anteil der Schrittweite jedes Bands (0 bis 1). |
paddingOuter |
0 |
Der Abstand vor dem ersten und nach dem letzten Band, ein Anteil einer Schrittweite. Die Hälfte von paddingInner setzt die Mitten der Bänder in gleichen Schritten über den Plot. |
maxBandWidth |
keine | Die maximale Breite eines Bands in Punkten: Wenige Bänder über einem breiten Plot bleiben schmal, jedes in der Mitte seines Platzes. |
bandCount |
die Zeilen | Die Bänder, für die die Achse angelegt wird, wenn es mehr als die Zeilen sind: Die Zeilen nehmen die vorderen ein und behalten ihre Breite und ihren Platz, wie Zeilen eintreffen. |
groupPadding |
0 |
Der Abstand zwischen den nebeneinander stehenden Balken in einem Band, ein Anteil des Platzes jedes Balkens. |
reverse |
false |
Die Bänder laufen vom anderen Rand aus: das der ersten Zeile rechts an der x-Achse, unten an der y-Achse; die nebeneinander stehenden Balken in einem Band behalten ihre Reihenfolge. |
Ein Balken wird mit seinem fill gezeichnet (fill-*; vom Plot geerbt, wie CSS vererbt), mit currentColor, wo keiner gesetzt ist (die Farbe seiner Kategorie mit color), und zeigt keinen Hover-Punkt. Auf einer Band-Skala findet der Hover das Band unter dem Zeiger, und die Cross-Line des Plots sitzt in diesem Band, so breit wie es selbst: crossLineClass="w-full h-full bg-foreground/5" tönt das Band, w-px behält eine Linie in seiner Mitte. Die Werte und die Basis der Balken bilden die Domain der Wertskala, wenn der Plot keine hat. Mit dem appear des Plots wächst jeder Balken aus der Basis seines Stacks heraus; die Bewegung, das Ein- und Ausblenden und das Gleiten laufen auf dem Animator der Plattform, und der Zeiger führt nie dazu, dass ein Balken neu gezeichnet wird.
#Bereiche
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);Ist base ein Feld oder eine Funktion, umfasst jeder Balken zwei Werte seiner Zeile: den Schritt eines Waterfall vom laufenden Gesamtwert davor zu dem danach, den Körper einer Kerze von ihrer Eröffnung bis zu ihrem Schlusskurs, eine Aufgabe von ihrem Start bis zu ihrem Ende. Ein Balken wächst beim Erscheinen von seiner eigenen Basis aus, und sein Label sitzt an seinem Wertende, über einem Balken, der steigt, und unter einem, der fällt.
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>
Ein Funnel besteht aus liegenden, zentrierten Balken: base={(d) => -d.users / 2} und value={(d) => d.users / 2} auf einer Band-y-Skala.
#Rule
Eine Rule zeichnet pro Zeile eine gerade Linie entlang der Wertachse, von base bis zum Wert der Zeile: in der Mitte des Bands der Zeile auf einer Band-Skala (quer über den Plot auf einer Band-y-Skala), am Platz der Zeile entlang der x-Achse auf einer Point- oder Linear-Skala. Der Docht einer Kerze, der Whisker eines Box-Plots, ein Fehlerbalken und der Stiel eines Lollipops sind Rules; ein Candlestick ist eine Bar über einem 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 | Typ | Standard | Beschreibung |
|---|---|---|---|
value |
string | (d, index) => number |
y des Plots |
Wo jede Linie endet. Eine Zeile ohne Wert hat keine Linie. |
base |
number | string | (d, index) => number |
0 |
Wo jede Linie beginnt: ein Wert für alle Zeilen (der Stiel eines Lollipops von der Nulllinie aus) oder ein Feld oder eine Funktion, die jeder Zeile einen eigenen liefert (das Tief einer Kerze). Er zählt zur Domain. |
scale |
"y" | "y2" |
"y" |
Die y-Skala, auf der seine Werte liegen (Duale Achsen). |
stagger |
number |
0 |
Mit dem appear des Plots starten die Linien nacheinander, Zeile für Zeile, über diesen Anteil davon (0 bis 0.95), wobei jede in der restlichen Zeit von ihrem Start aus wächst. |
inactiveOpacity |
number |
keine | Die Deckkraft seiner Linien abseits der Zeile unter dem Zeiger, solange der Zeiger über dem Plot ist, wie bei Bar. |
color |
string | (d, index) => string |
keine | Die Kategorie jeder Linie, deren Farbe aus der colorScale des Plots stammt: die stroke der Linie, wo nichts anderes gesetzt ist. |
Eine Rule wird mit ihrem stroke gezeichnet (stroke-*, strokeWidth, strokeDasharray; vom Plot geerbt, wie CSS vererbt), mit currentColor, wo keiner gesetzt ist, und zeigt keinen Hover-Punkt. Ihre Werte und ihre Basis bilden die Domain der Wertskala, wenn der Plot keine hat; sie wächst mit dem appear des Plots von ihrem Start aus und bewegt sich mit den Domains der Skalen, auf dem Animator der Plattform.
#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);Ein Dot zeichnet pro Zeile einen Punkt dort, wo zwei Werte der Zeile zusammentreffen: ihr x entlang einer linear-x-Skala und ihr value auf der y-Achse ergeben ein Streudiagramm; auf einer Band-x-Skala sitzt jeder Punkt in der Mitte des Bands seiner Zeile; über einer Band-y-Skala (die x-Skala linear) verläuft sein Wert entlang von x und das Band seiner Zeile die Zeichnung hinab, ein Dot Plot. Sein Radius r ist entweder eine einzige Zahl für alle Punkte oder ein Wert jeder Zeile, den rScale in einen Radius umrechnet, ein Blasendiagramm. Eine Zeile ohne Wert hat keinen Punkt.
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>
Wenn r ein Feld oder eine Funktion ist, bestimmt der Wert jeder Zeile die Größe ihres Punkts. rScale={{ type: "sqrt", range: [min, max] }} bildet die Quadratwurzeln der Werte auf Radien in Punkten ab, wie es scaleSqrt von d3 tut. Bei einem Bereich ab 0 ist daher die Fläche einer Blase proportional zu ihrem Wert; ein Bereich ab einem Wert über 0 hält auch die kleinsten Blasen sichtbar. Die Domain reicht von 0 bis zum größten Wert, sofern die Skala keine angibt. Die größeren Punkte werden zuerst gezeichnet, damit die kleineren darüber sichtbar bleiben:
<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>
Die Werte eines Dots bilden die Domain der Wertskala, wenn der Plot keine hat, ohne dabei die 0 einzubeziehen, sodass die Achsen eines Streudiagramms zu seinen Punkten passen. Gib dem Plot domains mit etwas Spielraum um die Werte, wenn die Punkte groß sind: Eine Blase am Rand der Domain wird über den Rand des Plots hinaus gezeichnet.
Zwei Marks in denselben Bändern ergeben die Diagramme, die zwei Werte einer Zeile vergleichen: Ein Lollipop ist ein Rule von 0 unter einem Dot an seinem Wert, und ein Dumbbell ist über einer Band-y-Skala ein Rule von einem Wert zum anderen mit einem Dot an jedem Ende:
<Plot data={stocks} yScale={{ type: "band", paddingInner: 0.3 }} xScale={{ type: "linear", domain: [-50, 80] }} class="h-48">
<Rule value="high" base="low" class="stroke-border stroke-2" />
<Dot value="before" r={3.5} class="fill-card stroke-muted-foreground" hoverDotClass="hidden" />
<Dot value="now" r={5} class="fill-chart-1 stroke-card stroke-2" hitRadius={24} />
</Plot>
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string | (d, index) => number |
der y des Plots |
Wohin jeder Punkt entlang der Wertachse gehört: auf der y-Achse nach oben oder über einer Band-y-Skala entlang von x. Eine Zeile ohne Wert hat keinen Punkt. |
r |
number | string | (d, index) => number |
3 |
Der Radius der Punkte in Punkten oder ein Feld bzw. eine Funktion, die jeder Zeile einen Wert gibt, den rScale in einen Radius umrechnet (ohne eine solche Skala ist der Wert der Radius). Eine Zeile, deren Wert fehlt, hat keinen Punkt. |
rScale |
{ type: "sqrt"; domain?: [number, number]; range: [number, number] } |
none | Wie die Werte von r zu Radien werden: ihre Quadratwurzeln über der domain (0 bis zum größten Wert, wenn keine angegeben ist) auf dem range der Radien, sodass ein Bereich ab 0 die Fläche jedes Punkts proportional zu seinem Wert macht. Werte außerhalb der Domain nehmen die Enden des Bereichs an. |
stagger |
number |
0 |
Mit dem Appear des Plots beginnen die Punkte nacheinander, Zeile für Zeile, zu wachsen, verteilt über diesen Anteil davon (0 bis 0.95); 0 lässt sie gemeinsam wachsen. |
inactiveOpacity |
number |
none | Die Deckkraft der übrigen Punkte, solange einer unter dem Zeiger oder den Tasten liegt: Sie blenden mit dem Hover ein und aus. |
color |
string | (d, index) => string |
none | Die Kategorie jedes Punkts, seine Farbe aus colorScale des Plots: der fill des Punkts, wo nichts anderes eine Farbe festlegt (Farben nach Kategorie). |
hitRadius |
number |
8 |
Wie weit jenseits des Rands eines Punkts der Zeiger ihn noch findet: Von den Punkten innerhalb dieses Abstands findet der Zeiger den, dessen Mittelpunkt ihm am nächsten liegt (Voronoi-Zellen), sodass eine kleine Blase über einer großen um ihren eigenen Mittelpunkt herum gefunden wird. Die Zeile eines Dot Plots wird von überall entlang ihrer gefunden, wenn der Radius so groß wie eine Zeile ist. |
scale |
"y" | "y2" |
"y" |
Die y-Skala, entlang der seine Werte verlaufen (Doppelte Achsen); Punkte über einer Band-y-Skala legen ihre Werte entlang von x. |
hoverDotClass, hoverDotStyle |
string, StyleProp<ViewStyle> |
dotClass, dotStyle des Plots |
Der hoverDot-Part: eine View so breit und hoch wie der Punkt unter dem Zeiger, auf ihm zentriert. rounded-full outline-2 outline-offset-1 umrandet ihn; hidden zeigt für diesen Mark keinen an. |
Ein Punkt wird mit seinem fill gefüllt (fill-*; vom Plot geerbt, so wie CSS erbt), mit currentColor, wo keiner gesetzt ist (die Farbe seiner Kategorie mit color), und mit seinem stroke umrandet, wo einer gesetzt ist (stroke-*, strokeWidth): Ein Ring in der Farbe der Card hält überlappende Punkte auseinander. Über einem Plot mit Punkten meldet der Plot die Zeile des Punkts, dessen Mittelpunkt dem Zeiger am nächsten liegt (null von jedem Punkt entfernt), die Tasten schalten in der Reihenfolge der Zeilen durch sie (sortiere die Zeilen nach x, und die Tasten gehen die Punkte von links nach rechts ab), und der hoverDot-Part markiert den Punkt. Mit dem Appear des Plots wachsen die Punkte aus dem Nichts; mit motion.update bewegen sie sich zu ihren neuen Werten und Größen, der Punkt einer neuen Zeile wächst ein und der Punkt einer entfernten Zeile schrumpft weg; alles auf dem Animator der Plattform, ohne dass ein Mark neu gezeichnet wird.
#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);Eine Cell zeichnet pro Zeile ein Rechteck in zwei Bändern zugleich: dem Band ihrer x-Kategorie auf einer band-x-Skala und dem Band ihrer y-Kategorie auf einer band-y-Skala, sodass die Zeilen ein Raster bilden, eine Heatmap. Eine Korrelationsmatrix, ein Kalender monatlicher Renditen und eine Woche an Trades pro Stunde sind Cells. Jede Cell füllt ihre beiden Bänder, daher sind paddingInner und paddingOuter der Skalen die Abstände zwischen und um die Cells. Ihre Farbe ist ihr value entlang der colorScale des Plots, einer Farbrampe (Farben entlang einer Rampe), oder die ihrer color-Kategorie über eine ordinale Skala. Cells liegen auf den x- und y-Skalen des Plots.
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>
Die domain einer Band-Skala nennt die Kategorien ihrer Bänder der Reihe nach, wie es scaleBand().domain() von d3 tut: die Monate eines Kalenders von Januar bis Dezember, jeder mit seinem Band, gleich ob seine Zeilen schon vorliegen oder nicht, sodass März 2026 oben eine leere Stelle im Raster ist. Ohne Domain sind die Bänder die Kategorien, die die Cells entlang der Achse nennen, in der Reihenfolge, in der die Zeilen sie bringen (2025 über 2026). Eine PlotAxis entlang einer Band-Skala von Kategorien schreibt die Kategorie jedes Bands, ohne format.
Eine Korrelationsmatrix nennt dieselben Holdings entlang beider Achsen, ihre domain ist der gesamte Wertebereich der Koeffizienten, sodass ein Koeffizient von 0 die Mitte der Rampe erhält, was auch immer die Holdings sind:
const tickers = ["NVDA", "AMD", "MSFT", "GLD"];
<Plot
data={pairs}
xScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
yScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
colorScale={{ type: "diverging", domain: [-1, 1], range: [token("chart-2"), token("muted"), token("chart-1")] }}
class="aspect-square"
>
<Cell x="b" y="a" value="r" label={(d) => d.r.toFixed(2)} labelClass="text-xs text-black/70 data-dark:text-white" />
</Plot>
| Prop | Type | Default | Description |
|---|---|---|---|
x |
string | (d, index) => string |
none | Die Spalte jeder Zeile: ein Feld (sein Text oder seine Zahl) oder eine Funktion, die die Kategorie zurückgibt. Die Cell der Zeile kommt in das Band dieser Kategorie der x-Skala; eine Zeile ohne Kategorie hat keine Cell. |
y |
string | (d, index) => string |
none | Die Reihe jeder Zeile, als x entlang der y-Skala. |
value |
string | (d, index) => number |
none | Die Zahl jeder Cell, die eine sequential- oder diverging-colorScale zu ihrer Farbe macht. Eine Zeile ohne Zahl erhält die unknown-Farbe der Skala und hat ohne diese keine Cell. |
color |
string | (d, index) => string |
none | Die Kategorie jeder Cell, ihre Farbe aus einer ordinal-colorScale (Farben nach Kategorie). |
cornerRadii |
number | { topLeft?, topRight?, bottomRight?, bottomLeft? } |
0 |
Der Radius jeder Ecke ihrer Cells. |
stagger |
number |
0 |
Mit dem Appear des Plots beginnen die Cells nacheinander, Zeile für Zeile, zu wachsen, verteilt über diesen Anteil davon (0 bis 0.95), jede für den Rest von ihrer Mitte aus; 0 lässt sie gemeinsam wachsen. |
inactiveOpacity |
number |
none | Die Deckkraft der übrigen Cells und ihrer Labels, solange eine unter dem Zeiger oder den Tasten liegt: Sie blenden mit dem Hover ein und aus. |
label |
string | (d, index) => string |
none | Der label-Part: ein Text in der Mitte jeder Cell, ein Feld (sein Text oder seine Zahl) oder eine Funktion, die den Text zurückgibt, den die App formatiert. Jedes Label wird dort gezeigt, wo seine berechnete Größe in seine Cell passt, sodass ein schmaler Plot nur die Farben zeigt. |
labelClass, labelStyle |
string, StyleProp<TextStyle> |
none | Schrift, Größe und Farbe der Labels. Ein Label auf einer Cell mit dunkler colorScale-Farbe hat das Attribut data-dark (wo helle Schrift besser lesbar ist), sodass data-dark:text-white es dort hell schreibt. |
hoverCellClass, hoverCellStyle |
string, StyleProp<ViewStyle> |
none | Der hoverCell-Part: eine View so groß wie die Cell unter dem Zeiger oder den Tasten, über ihr; rounded-sm outline-2 outline-foreground umrandet sie. Sie bewegt sich im selben Frame mit dem Zeiger und gleitet auf der pointer-Spring, wenn die Tasten sie bewegen. |
Eine Cell wird mit ihrer Farbe aus der colorScale des Plots gefüllt, wo die App kein fill setzt (fill-*-Klassen und -Styles haben Vorrang, vom Plot geerbt, so wie CSS sie erbt), mit currentColor, wo nichts eines setzt, und mit ihrem stroke umrandet, wo einer gesetzt ist. Über einem Plot mit Cells meldet der Plot die Zeile der Cell unter dem Zeiger, und über dem Abstand zwischen zwei Cells die nähere; die Pfeiltasten schalten über das Raster auf dem Bildschirm (Tastatur), und Screenreader durch die Zeilen in ihrer Reihenfolge. Mit dem Appear des Plots wachsen die Cells aus ihrer Mitte; mit motion.update gleitet die Farbe einer Cell zu ihrer neuen, die Cell einer neuen Zeile wächst ein, die Cell einer entfernten Zeile schrumpft weg, und Cells wandern in ihre neuen Bänder; alles auf dem Animator der Plattform, ohne dass ein Mark neu gezeichnet wird.
#Pie und Arc
Ein Pie teilt eine volle Drehung in ein Segment pro Zeile auf, jeweils im Anteil des Zeilenwerts an der Summe aller Werte, im Uhrzeigersinn ab 12 Uhr in der Reihenfolge der Zeilen, um den Mittelpunkt des Plots. Ein innerRadius macht daraus ein Donut. Jedes Segment ist eine eigene Canvas, der slice-Part, und wird daher mit eigenen Klassen gezeichnet: sliceClass sind auf jedem Segment dieselben Klassen, oder eine Funktion der Zeile des Segments, die jedem Segment eigene Klassen gibt. Über einem Pie meldet der Plot die Zeile des Segments unter dem Zeiger (keine im Loch oder außerhalb des Rings), sein Tooltip folgt dem Zeiger, und dieses Segment wächst um hoverLift, während die anderen um hoverDim verblassen, auf dem Animator der Plattform mit der motion des Plots. Ein Arc zeichnet einen Sektor zwischen zwei Winkeln: die Spur und den Wert eines Gauge oder einen Ring hinter einem Pie.
import { mount, signal, Text, View } from "nsh";
import { Arc, Pie, Plot } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui, wide } from "./palette";
// A donut built from the plot's parts: a `Pie` of the rows' shares, each
// slice in a series color of the theme (`fill-chart-*`, a class per row),
// 2-point gaps between them (the pad is as wide at the hole as at the rim),
// the largest share first, clockwise from 12 o'clock. The slice under the
// pointer lifts and the others fade, natively; its value is in the hole and
// its legend row stands out while the others fade too. The legend's values
// and shares are tabular and at the trailing edge. A breakdown with nothing
// in it keeps a faint ring, an `Arc`, so the chart still reads as one.
type Part = { name: string; revenue: number };
type Breakdown = "Region" | "Channel" | "None";
type Shape = "donut" | "pie";
const BREAKDOWNS: Record<Breakdown, Part[]> = {
Region: [
{ name: "North America", revenue: 482 },
{ name: "EMEA", revenue: 312 },
{ name: "APAC", revenue: 268 },
{ name: "Latin America", revenue: 104 },
{ name: "Other", revenue: 74 },
],
Channel: [
{ name: "Direct", revenue: 540 },
{ name: "Partners", revenue: 330 },
{ name: "Marketplace", revenue: 250 },
{ name: "Resellers", revenue: 120 },
],
None: [
{ name: "North America", revenue: 0 },
{ name: "EMEA", revenue: 0 },
],
};
/** The theme's series colors, as a slice's fill and a legend key's
* background. */
const FILLS = ["fill-chart-1", "fill-chart-2", "fill-chart-3", "fill-chart-4", "fill-chart-5"];
const KEYS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"];
const money = (k: number) => (k >= 1000 ? `$${(k / 1000).toFixed(2)}M` : `$${k}k`);
const percent = (share: number) => `${(share * 100).toFixed(1)}%`;
const num = { fontVariant: ["tabular-nums" as const], textAlign: "right" as const };
function App() {
const breakdown = signal<Breakdown>("Region");
const shape = signal<Shape>("donut");
const hovered = signal<number | null>(null);
const events = signal<string[]>([]);
const parts = () => BREAKDOWNS[breakdown()];
const total = () => parts().reduce((sum, p) => sum + p.revenue, 0);
const shown = () => {
const at = hovered();
return at == null ? undefined : parts()[at];
};
const share = (p: Part) => (total() > 0 ? p.revenue / total() : 0);
// The ring's size: its rim is half of it, the gaps 2 points wide there.
const ring = desktop ? 200 : 184;
const donut = () => shape() === "donut";
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>pie</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Move the pointer over a slice to read it" : "Pick a breakdown to draw other slices"}
</Text>
<Split
width={520}
inspector={
<>
<Panel title="shape · data">
<Segmented options={["donut", "pie"] as const} value={shape()} onChange={(s) => shape.set(s)} />
<Segmented
options={["Region", "Channel", "None"] as const}
value={breakdown()}
onChange={(b) => (breakdown.set(b), hovered.set(null))}
/>
<Text style={{ fontSize: 13, color: ui().text }}>
{`${parts().filter((p) => p.revenue > 0).length} slices · ${money(total())} in all`}
</Text>
</Panel>
<Log title="onHoverChange" lines={events()} minHeight={92} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 16 }}>
<View class="flex-row items-baseline gap-3">
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>Revenue</Text>
<Text style={{ fontSize: size.detail, color: ui().muted }}>{`Q3 2026 · by ${breakdown() === "Channel" ? "channel" : "region"}`}</Text>
</View>
<Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{money(total())}</Text>
</View>
<View style={{ flexDirection: wide() ? "row" : "column", alignItems: "center", gap: wide() ? 28 : 18 }}>
<Plot
data={parts()}
accessibilityLabel={
total() > 0
? `Revenue by ${breakdown().toLowerCase()}, ${money(total())} in all; largest ${parts()[0].name} at ${percent(share(parts()[0]))}`
: "Revenue: nothing this quarter"
}
accessibilityValue={{ text: shown() && `${shown()!.name}: ${money(shown()!.revenue)}, ${percent(share(shown()!))}` }}
// Motion of the app's: the ring sweeps in for each breakdown,
// the hole opens and closes between donut and pie, the slice
// under the pointer lifts on the spring; reduced motion shows
// it all at once.
appearGeneration={["Region", "Channel", "None"].indexOf(breakdown())}
motion={{ appear: { duration: 900, easing: "easeOut" }, update: { duration: 450, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 420, damping: 40 } }}
onHoverChange={({ index }) => {
hovered.set(index);
const p = index == null ? undefined : parts()[index];
const line = p ? `${index}: ${p.name}, ${money(p.revenue)} (${percent(share(p))})` : "null: off the slices";
events.set([line, ...events()].slice(0, 5));
}}
style={{ width: ring, height: ring }}
>
{/* Nothing to split: a faint ring keeps the chart's shape. */}
<If when={total() === 0}>
<Arc innerRadius={donut() ? "62%" : 0} outerRadius={ring / 2 - 8} style={{ fill: tint(ui().text, 0.06) }} />
</If>
<Pie
value="revenue"
innerRadius={donut() ? "62%" : 0}
// The rim 8 points in, room for the lift.
outerRadius={ring / 2 - 8}
padAngle={2 / (ring / 2 - 8)}
sliceClass={(_, i) => FILLS[i % FILLS.length]}
hoverLift={6}
hoverDim={0.64}
/>
{/* The hovered part, or the total, in the hole: it fades with
the hole opening and closing. */}
<View
class="transition-opacity duration-300 motion-reduce:transition-none"
style={{ pointerEvents: "none", position: "absolute", left: 0, top: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center", gap: 4, opacity: donut() ? 1 : 0 }}
>
<Text style={{ fontSize: size.detail, color: ui().muted }}>{shown()?.name ?? "Total"}</Text>
<Text style={{ fontSize: 22, fontWeight: "600", color: ui().text, ...num, textAlign: "center" }}>
{money(shown()?.revenue ?? total())}
</Text>
<Text style={{ fontSize: size.detail, color: ui().muted, ...num, textAlign: "center" }}>
{shown() ? percent(share(shown()!)) : `${parts().filter((p) => p.revenue > 0).length} parts`}
</Text>
</View>
</Plot>
{/* The legend: a key in each slice's color, the value and the
share at the trailing edge; the hovered part's row stands out. */}
<View style={{ alignSelf: "stretch", flex: wide() ? 1 : undefined, gap: 2 }}>
<View style={{ flexDirection: "row", gap: 12, paddingHorizontal: 8, paddingBottom: 4 }}>
<View style={{ width: 8 }} />
<Text style={{ flex: 1, fontSize: 11, color: ui().faint }}>{breakdown() === "Channel" ? "Channel" : "Region"}</Text>
<Text style={{ fontSize: 11, color: ui().faint, ...num }}>Revenue</Text>
<Text style={{ width: 52, fontSize: 11, color: ui().faint, ...num }}>Share</Text>
</View>
{parts().map((p, i) => (
<View
class="transition-opacity duration-150 motion-reduce:transition-none"
style={{
flexDirection: "row",
alignItems: "center",
gap: 12,
height: size.rowSm,
paddingHorizontal: 8,
borderRadius: 6,
backgroundColor: hovered() === i ? tint(ui().text, 0.05) : undefined,
opacity: hovered() == null || hovered() === i ? 1 : 0.45,
}}
>
<View class={KEYS[i % KEYS.length]} style={{ width: 8, height: 8, borderRadius: 2 }} />
<Text style={{ numberOfLines: 1, flex: 1, fontSize: size.label - 1, color: ui().text }}>{p.name}</Text>
<Text style={{ fontSize: size.label - 1, color: ui().text, ...num }}>{money(p.revenue)}</Text>
<Text style={{ width: 52, fontSize: size.label - 1, color: ui().muted, ...num }}>{percent(share(p))}</Text>
</View>
))}
</View>
</View>
</View>
</Split>
</View>
);
}
mount(App);import { signal, Text, View } from "nsh";
import { Pie, Plot } from "nsh/base/plot";
type Plan = { name: string; seats: number };
const FILLS = ["fill-chart-1", "fill-chart-2", "fill-chart-3", "fill-chart-4"];
const KEYS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4"];
export function Seats(props: { plans: Plan[] }) {
const hovered = signal<number | null>(null);
const total = () => props.plans.reduce((sum, p) => sum + p.seats, 0);
const shown = () => (hovered() == null ? undefined : props.plans[hovered()!]);
const share = (n: number) => `${((n / total()) * 100).toFixed(1)}%`;
return (
<View class="flex-row items-center gap-6 rounded-xl border border-border bg-card p-4">
<Plot
data={props.plans}
accessibilityLabel={`Seats by plan, ${total()} in all`}
accessibilityValue={{ text: shown() && `${shown()!.name}, ${shown()!.seats} seats, ${share(shown()!.seats)}` }}
appearGeneration={1}
motion={{ appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 400, damping: 32 } }}
onHoverChange={({ index }) => hovered.set(index)}
class="size-44"
>
{/* A donut with 2-point gaps (0.025 of a turn at its 80-point rim). */}
<Pie
value="seats"
innerRadius="62%"
padAngle={0.025}
sliceClass={(_, i) => FILLS[i % FILLS.length]}
hoverLift={6}
hoverDim={0.64}
/>
{/* The hovered plan, or the total, in the hole. */}
<View class="absolute inset-0 items-center justify-center gap-1">
<Text class="text-xs text-muted-foreground">{shown()?.name ?? "Total"}</Text>
<Text class="text-2xl font-semibold tabular-nums text-foreground">{shown()?.seats ?? total()}</Text>
</View>
</Plot>
{/* The legend: a key in each slice's color, the value and the share at the end. */}
<View class="flex-1 gap-1">
{props.plans.map((plan, i) => (
<View class="flex-row items-center gap-3 rounded-md px-2 py-1.5" style={{ opacity: hovered() == null || hovered() === i ? 1 : 0.45 }}>
<View class={`size-2 rounded-sm ${KEYS[i % KEYS.length]}`} />
<Text class="flex-1 text-sm text-foreground">{plan.name}</Text>
<Text class="text-right text-sm tabular-nums text-foreground">{plan.seats}</Text>
<Text class="w-14 text-right text-sm tabular-nums text-muted-foreground">{share(plan.seats)}</Text>
</View>
))}
</View>
</View>
);
}
use nsh_base::plot::{SliceClass, pie, plot};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};
fn seats(seats: Vec<f64>) -> impl IntoElement {
let rows = Rows::from_columns(seats.len(), [("seats".into(), seats)]);
plot()
.data(rows)
.accessibility_label("Seats by plan")
.w(px(176.0))
.h(px(176.0))
.child(
pie()
.value(RowAccessor::key("seats"))
.inner_radius(relative(0.62))
.slice_class(SliceClass::from_fn(|i| format!("fill-chart-{}", i % 4 + 1).into())),
)
}
| Mark | Prop | Rust | Type | Default | Description |
|---|---|---|---|---|---|
Pie |
value |
.value(..) |
keyof T | (d: T, index: number) => number |
the plot’s y |
Was jede Zeile wert ist. Eine Zeile mit dem Wert null, einem negativen Wert oder ohne Zahl hat kein Segment. |
Pie, Arc |
startAngle, endAngle |
.start_angle(..), .end_angle(..) |
number (radians) |
0, 2 * Math.PI |
Wo das erste Segment beginnt und das letzte endet (bei einem Arc seine Enden): Bogenmaß ab 12 Uhr, im Uhrzeigersinn. Ein endAngle kleiner als startAngle ordnet die Segmente eines Pie in die andere Richtung an. |
Pie |
padAngle |
.pad_angle(..) |
number (radians) |
0 |
Der Winkel zwischen benachbarten Segmenten am äußeren Rand. Jede Lücke behält diese Breite (etwa padAngle * outerRadius Punkte) bei jedem Radius, ihre Kanten verlaufen parallel, sie ist also in der Mitte oder am Loch so breit wie am Rand; ein Segment, das zu schmal ist, um die Mitte zu erreichen, endet dort, wo sich seine Kanten treffen. Ein Pie mit einem einzigen Segment hat keine Lücke. |
Pie, Arc |
innerRadius, outerRadius |
.inner_radius(..), .outer_radius(..) |
number | `${number}%` |
0, "100%" |
Die Radien des Lochs und des Rands: in Punkten oder als Prozentsatz des größten Kreises, den der Plot fasst (die Hälfte seiner kürzeren Seite). |
Pie, Arc |
cornerRadius |
.corner_radius(..) |
number |
0 |
Der Radius der Ecken jedes Segments (bei einem Arc seiner Ecken), in Punkten, wie bei d3s arc.cornerRadius: Jede Ecke ist ein Kreis, der die Kante und den äußeren Rand oder das Loch berührt, einschließlich der parallelen Kanten der Lücken. Er beträgt höchstens die halbe Dicke des Rings, was einem Arc runde Enden gibt; bei einem Segment, das für zwei ganze Ecken zu schmal ist, treffen sich diese in seiner Mitte. Ein vollständiger Ring behält nur seine runden Kanten. |
Pie |
sliceClass |
.slice_class(..) |
string | (d: T, index: number) => string |
none | Die Klassen jedes Segments (fill-*, stroke-*): auf jedem Segment dieselben, oder eine Funktion der Zeile des Segments und ihres Index, die einmal pro Zeile aufgerufen wird, wenn sich die Zeilen oder die Funktion ändern. |
Pie |
sliceStyle |
.slice_style(..) |
StyleProp<ViewStyle> |
none | Der Stil jedes Segments, über seinen Klassen (fill, stroke, strokeWidth). |
Pie |
hoverLift |
.hover_lift(..) |
number |
0 |
Wie weit das Segment unter dem Zeiger über die anderen hinauswächst, in Punkten: auf der pointer-Spring des Plots, mit dem Hover ein- und ausgeblendet. |
Pie |
hoverDim |
.hover_dim(..) |
number (0 to 1) |
0 |
Wie stark die anderen Segmente verblassen, solange eines unter dem Zeiger liegt, als Anteil ihrer Deckkraft, mit dem Überblenden des Hovers. |
Pie |
color |
.color(..) |
keyof T | (d: T, index: number) => string |
none | Die Kategorie jedes Segments, seine Farbe aus der colorScale des Plots: das fill des Segments, wo nichts anderes gesetzt ist (Farben nach Kategorie). |
Pie |
label |
.label(..) |
string | (d: T, index: number) => string |
none | Der Text neben jedem Segment: ein Feld (seine Zahl) oder eine Funktion, die den von der App formatierten Text zurückgibt. Eine Zeile mit leerem Text oder ein Segment, das schmaler als ein halbes Grad ist, hat keinen. |
Pie |
labelClass, labelStyle |
.label_class(..), .label_style(..) |
string, StyleProp<TextStyle> |
none | Der label-Part: ein Text außerhalb des Rings am Ende seiner Hilfslinie, darauf zentriert, auf der rechten Hälfte des Pie dahinter und auf der linken davor. Sein Padding ist sein Abstand vom Ende der Linie. |
Pie |
labelGap |
.label_gap(..) |
number |
15 |
Wie weit die Labels jenseits des Rands sitzen, in Punkten. |
Pie |
labelLineClass, labelLineStyle |
.label_line_class(..), .label_line_style(..) |
string | (d: T, index: number) => string, StyleProp<ViewStyle> |
none | Der labelLine-Part: die Hilfslinie jedes Labels, vom Rand in der Mitte des Segments hinaus zum Radius des Labels, dann quer zum Text. Ihre stroke (currentColor, wo keine gesetzt ist), strokeWidth und strokeDasharray, auf jeder Linie dieselben oder eine Funktion der Zeile des Segments; sie wird angezeigt, sobald sie gestylt ist. |
Ein Segment und ein Arc werden mit fill gefüllt (currentColor, wo keines gesetzt ist, bei einem Segment die Farbe seiner Kategorie mit color) und an ihren Kanten mit stroke umrandet (keine, wo keine gesetzt ist), jeweils ihr eigener Wert vor dem des Pie und des Plots, wie CSS sie vererbt: Eine Umrandung in der Farbe der Fläche (stroke-card stroke-2) schneidet auch Lücken zwischen die Segmente. Ihre Werte gehen nicht in die Skalen des Plots ein. Die Segmente eines Pie werden nur neu gezeichnet, wenn sich die Zeilen, ihre Werte, die Winkel, die Radien oder die Größe des Plots ändern; der Zeiger bewegt das Anheben und das Verblassen auf dem Animator.
Plots mit einem Pie platzieren den Tooltip meist am Zeiger (tooltipPlacement="pointer"), da die Zeilen eines Pie keinen Platz entlang einer Achse haben: Die Fadenkreuzlinie und der Tooltip folgen darüber dem Zeiger, und es werden keine Hover-Punkte angezeigt. Die Mitte eines Donuts ist ein eigenes Element der App, das über den Plot gelegt wird (der Wert des überfahrenen Segments oder die Summe), und ebenso seine Legende, mit jedem Schlüssel in der Farbe seines Segments.
Die Labels eines Pie benennen seine Segmente außerhalb des Rings: Jeder Text sitzt labelGap jenseits des Rands in Richtung der Mitte seines Segments (der Schwerpunkt des Segments bei diesem Radius), dann quer zu diesem Radius auf der Seite des Pie, auf der das Segment liegt, und eine gestylte Hilfslinie verbindet ihn mit dem Rand. Die Labels schwenken beim Appear des Plots mit ihren Segmenten ein und blenden über dessen Ende ein, bewegen sich mit ihnen auf motion.update und werden mit dem überfahrenen Segment angehoben; alles läuft auf dem Animator der Plattform. Geben Sie dem Plot Platz dafür: ein outerRadius unterhalb von "100%" oder Raum um den Plot. Eigener Inhalt der App an einem Segment ist ein Element, das über den Plot gelegt wird, wie die Mitte eines Donuts.
<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"
/>
#Farben nach Kategorie
Die colorScale eines Plots gibt Kategorien ihre Farben, wie d3s scaleOrdinal, und das color eines Marks benennt die Kategorie jeder Zeile: ein Feld (sein Text oder seine Zahl) oder eine Funktion der Zeile. Die Kategorien der domain erhalten der Reihe nach die Farben der range, und der Bereich beginnt wieder bei seiner ersten Farbe, wenn die Domain länger ist. Eine Kategorie außerhalb der Domain erhält unknown; ohne diese tritt sie der Domain bei, sobald ein Mark ihr zum ersten Mal begegnet (die Marks der Reihe nach, jeweils ihre Zeilen der Reihe nach), und erhält die nächste Farbe, sodass bei völligem Fehlen eines domain die Kategorien die Farben in der Reihenfolge erhalten, in der die Zeilen sie liefern. Die Farben gehören der App: CSS-Farben, darunter auch die Tokens des Themes.
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>
Ein Segment, ein Balken und ein Punkt erhalten die Farbe ihrer Zeile; ein Line und ein Area sind jeweils eine Serie und erhalten die Farbe ihrer ersten Zeile, sodass eine Linie mit eigenen Zeilen (data) in der Farbe ihrer Zeilen erscheint, und eine Skala hält die Farbe einer Kategorie über ein Pie, Balken und Linien hinweg gleich. Die Farbe ist das fill des Marks (bei einer Linie ihr stroke), wo nichts anderes gesetzt ist, anstelle von currentColor: Die Klassen und Stile der App haben Vorrang, dann die des Parts (sliceClass, sliceStyle), die des Marks und die, die es vom Plot erbt, sodass sliceClass={(d) => (d.name === "Other" ? "fill-muted" : "")} ein einzelnes Segment abhebt, während die Skala die anderen färbt. Neben einem background-Part bleibt das fill eines Balkens und einer Fläche none.
OrdinalScale |
Type | Default | Description |
|---|---|---|---|
type |
"ordinal" |
(required) | Die Art der Skala. |
domain |
string[] |
none | Die Kategorien, der Reihe nach. Eine doppelt angegebene Kategorie behält ihren ersten Platz. |
range |
string[] |
(required) | Die Farben, der Reihe nach (CSS-Farben); Kategorie i erhält Farbe i % range.length. |
unknown |
string |
none | Die Farbe jeder Kategorie außerhalb der Domain; ohne diese erhält jede die nächste Farbe. |
#Farben entlang eines Verlaufs
Eine colorScale vom Typ type: "sequential" oder "diverging" gibt Zahlen ihre Farben entlang eines Verlaufs, wie d3s scaleSequential und scaleDiverging, für das value eines Cell. Ihr range besteht aus zwei oder mehr Farben, die gleichmäßig entlang des Verlaufs vom unteren bis zum oberen Ende verteilt und dazwischen in OKLab gemischt werden: Die Abstufungen wirken für das Auge gleichmäßig, und zwei Farbtöne treffen sich über ihre eigene Mischung. Ein Wert außerhalb der domain erhält das Ende des Verlaufs, und eine Zeile ohne Wert erhält unknown.
sequential verteilt seine domain über den gesamten Verlauf, wobei die Domain der Wertebereich der Zellen ist, sofern die App keine benennt: Anzahlen, Volumen, Aktivität. diverging legt seinen pivot (standardmäßig 0) auf die mittlere Farbe des Verlaufs, die Werte darunter entlang der ersten Hälfte und die darüber entlang der zweiten: Verluste in einem Farbton, Gewinne in einem anderen, ein unveränderter Monat in der neutralen Mitte. Sofern die App keine Domain benennt, ist sie der Wertebereich, auf beiden Seiten des Pivots symmetrisch gemacht (symmetric von Observable Plot), sodass ein Verlust und ein Gewinn gleicher Größe gleich stark erscheinen; eine Korrelationsmatrix benennt domain: [-1, 1].
colorScale={{ type: "sequential", range: [token("muted"), token("chart-1")] }}
colorScale={{ type: "diverging", pivot: 0, range: [token("destructive"), token("muted"), token("chart-1")] }}
SequentialScale, DivergingScale |
Type | Default | Description |
|---|---|---|---|
type |
"sequential" | "diverging" |
(required) | Die Art des Verlaufs. |
domain |
[number, number] |
the values’ extent (made even about the pivot for diverging) |
Die Werte der beiden Enden des Verlaufs. |
pivot |
number |
0 |
Nur diverging: der Wert der mittleren Farbe des Verlaufs. |
range |
string[] |
(required) | Die Farben entlang des Verlaufs, vom unteren zum oberen Ende (CSS-Farben), dazwischen in OKLab gemischt. |
unknown |
string |
none | Die Farbe einer Zeile ohne Wert; ohne diese hat die Zeile keine Zelle. |
#RadialLine and RadialGrid
Eine RadialLine zeichnet eine Datenreihe um den Mittelpunkt des Plots: das Polygon eines Radars oder eine Linie rund um ein Zifferblatt. Jede Zeile mit einem Wert ist ein Punkt im Winkel der Zeile, so weit außen, wie die y-Skala des Plots ihren Wert platziert, von innerRadius (dem kleinsten Wert der Domäne) bis outerRadius (ihrem größten). Ohne angle verteilen sich die Zeilen der Reihe nach gleichmäßig über den Vollkreis, ab 12 Uhr im Uhrzeigersinn, sechs Zeilen ergeben also ein Sechseck; closed verbindet den letzten Punkt mit dem ersten. Eine Linie zeichnet ihre Punkte als Strich und füllt ihre Fläche bis hinunter zu innerRadius (standardmäßig der Mittelpunkt), sodass ein Mark zugleich der Umriss und die Flächenfüllung eines Radars ist. Ein RadialGrid zeichnet dahinter das Radargitter: rings bei Werten der y-Skala, Polygone durch die Winkel der Zeilen oder circular, und spokes hinaus zu jeder Zeile. Über einer radialen Linie meldet der Plot die Zeile, deren Winkel dem des Zeigers am nächsten liegt, innerhalb des äußeren Radius der Linie, und der Hover-Punkt jeder Linie sitzt auf ihrem Punkt dieser Zeile.
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 | Typ | Standard | Beschreibung |
|---|---|---|---|---|---|
RadialLine |
radius |
.radius(..) |
keyof T | (d: T, index: number) => number |
y des Plots |
Was jede Zeile von der Mitte nach außen platziert: ein Wert der y-Skala, von innerRadius beim kleinsten Wert der Domäne bis outerRadius beim größten. Eine Zeile ohne Wert wird übersprungen. |
RadialLine |
angle |
.angle(..) |
keyof T | (d: T, index: number) => number |
die Zeilen gleichmäßig über den Vollkreis verteilt | Wo jede Zeile um den Mittelpunkt liegt: Radiant ab 12 Uhr, im Uhrzeigersinn. |
RadialLine, RadialGrid |
startAngle, endAngle |
.start_angle(..), .end_angle(..) |
number (Radiant) |
0, 2 * Math.PI |
Wo die Zeilen ohne angle liegen: ab startAngle, gleichmäßig verteilt. Über einen ganzen Vollkreis liegen sie je einen Schritt von 1 / rows eines Vollkreises auseinander, die letzte einen Schritt vor der ersten; über einen Teil eines Vollkreises liegt die erste auf startAngle und die letzte auf endAngle. |
RadialLine, RadialGrid |
innerRadius, outerRadius |
.inner_radius(..), .outer_radius(..) |
number | `${number}%` |
0, "100%" |
Wo der kleinste und der größte Wert der y-Domäne liegen: in Punkten oder als Prozentsatz des größten Kreises, den der Plot fasst (die Hälfte seiner kürzeren Seite), wie bei den Radien eines Kreisdiagramms. Die Fläche einer Linie wird bis innerRadius gefüllt; die Speichen eines Gitters verlaufen zwischen beiden. |
RadialLine |
curve |
.curve(..) |
"linear" | "natural" | "stepAfter" |
"linear" |
Wie sie ihre Punkte verbindet: gerade Segmente, eine glatte Kurve (geschlossen: eine glatte Schleife) oder Stufen. |
RadialLine |
closed |
.closed(..) |
boolean |
false |
Sie führt vom letzten Punkt zurück zum ersten weiter: das Polygon eines Radars. Offen schließt sich ihre Fläche zum Mittelpunkt (oder entlang ihres inneren Rands). |
RadialLine |
dot, dotClass, dotStyle |
.dot(..), .dot_class(..), .dot_style(..) |
boolean, string, StyleProp<ViewStyle> |
false |
Ein Punkt auf jedem Datenpunkt: eine auf ihn zentrierte View, deren Größe, Hintergrund und Rahmen die der App sind. Die Punkte wachsen heraus und bewegen sich mit der Linie. |
RadialLine |
backgroundClass, backgroundStyle |
.background_class(..), .background_style(..) |
string, StyleProp<ViewStyle> |
keine | Ihr Hintergrund: eine Box um ihren äußeren Kreis, deren Hintergrund (bg-*, ein Verlauf) nur innerhalb ihrer Fläche sichtbar ist, unter ihrem fill, wie bei einer Fläche. |
RadialLine |
hoverDotClass, hoverDotStyle |
.hover_dot_class(..), .hover_dot_style(..) |
string, StyleProp<ViewStyle> |
dotClass, dotStyle des Plots |
Ihr Hover-Punkt, auf ihrem Punkt der Zeile unter dem Zeiger, wobei der Zeiger im selben Frame liegt und beim Weiterschalten mit den Tasten mit der pointer-Feder des Plots zur nächsten Zeile gleitet; hidden zeigt keinen. |
RadialGrid |
rings |
.rings(..) |
number[] |
keine | Wo ihre Ringe liegen, als Werte der y-Skala: jeder so weit außen, wie eine radiale Linie diesen Wert platziert. |
RadialGrid |
spokes |
.spokes(..) |
boolean |
false |
Eine Linie von innerRadius bis outerRadius im Winkel jeder Zeile. |
RadialGrid |
circular |
.circular(..) |
boolean |
false |
Ihre Ringe sind Kreise (Bögen über einen Teil eines Vollkreises). Standardmäßig sind sie Polygone durch die Winkel der Zeilen, und Kreise, solange der Plot weniger als drei Zeilen hat. |
Eine radiale Linie zeichnet ihren Strich mit stroke, strokeWidth und strokeDasharray (currentColor, 1 breit und durchgezogen, wo nichts gesetzt ist) und füllt ihre Fläche mit fill (keine Füllung, wo nichts gesetzt ist); ein radiales Gitter zeichnet wie ein Gitter. Ihr Paint erbt vom Plot. Die Werte einer Linie bilden zusammen mit 0 die Domäne der y-Skala, wenn der Plot keine hat, sodass ein Radar mit positiven Werten aus seiner Mitte wächst; die Ringe eines Gitters tun das nicht. Mit einer y-domain am Plot bleiben die Ringe und die Skala des Radars fest, während sich die Werte ändern.
Beschriftungen rund um ein Radar sind Elemente der App, die über den Plot gelegt werden, wie die Mitte eines Donuts. Ohne angle liegt Zeile i von n bei a = 2π·i / n ab 12 Uhr, sodass in einem quadratischen Plot eine Box ohne Größe bei left: 50% + r·sin(a), top: 50% − r·cos(a) (r ein Prozentsatz der Seite des Plots, etwas über dem halben Anteil des äußeren Radius) die Stelle jenseits des Rands markiert, und der Name von dort vom Mittelpunkt weg wächst: rechts davon auf der rechten Hälfte, links davon auf der linken, darüber oben und darunter unten, wie oben.
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
Ein Sankey zeichnet Flüsse zwischen den Zeilen des Plots: Jede Zeile ist ein Knoten, und links benennt die Flüsse zwischen ihnen, { source, target, value } with the indices of two rows in the plot’s data. Es legt sie wie d3-sankey in der Größe des Plots aus: die Knoten in Spalten (nodeAlign), jeder so hoch wie das Meiste, was in ihn hinein- oder aus ihm herausfließt, die Knoten einer Spalte nodePadding voneinander entfernt und über iterations Durchgänge zu ihren Flüssen hin entspannt; dazu pro Link ein Band von der rechten Kante seiner Quelle zur linken Kante seines Ziels, an jedem Ende so breit wie sein Anteil an der Seite dieses Knotens, sodass beide Seiten jedes Knotens bedeckt sind, auch wo seine ein- und ausgehenden Flüsse sich unterscheiden. Jede Spalte ist im Plot zentriert, und eine Folge gleicher Spalten mit je einem Knoten wird leicht versetzt, damit sich die Bänder dazwischen krümmen. valueScale="sqrt" skaliert mit der Quadratwurzel der Werte, damit ein dominanter Fluss die kleinen nicht erdrückt.
Jeder Knoten und jedes Band ist ein eigenes Canvas, die Parts node und link, sodass jedes mit eigenen Klassen zeichnet: nodeClass sind dieselben Klassen für jeden Knoten oder eine Funktion der Zeile des Knotens, und linkClass dieselben für jedes Band oder eine Funktion des Links. Der Part linkBackground zeigt einen Hintergrund innerhalb jedes Bandes, eine Box von seiner Quelle zu seinem Ziel: Eine Funktion des Links gibt jedem Band einen Verlauf von der Farbe seiner Quelle zu der seines Ziels. Die Bänder werden unter den Knoten gezeichnet, und beide werden mit der Appear-Animation des Plots von der Vorderkante her eingezeichnet.
Über einem Sankey meldet der Plot den Knoten unter dem Zeiger (onHoverChange, die Zeile des Knotens; keinen über einem Band), sein Tooltip folgt dem Zeiger, und die nicht mit diesem Knoten verbundenen Links blenden um hoverDim aus, auf dem Animator der Plattform mit dem motion des Plots. Die Tasten gehen die Knoten in der Reihenfolge der Zeilen durch, der Tooltip steht neben der Mitte jedes Knotens. nodeLabel benennt jeden Knoten und valueLabel gibt seinen Wert an, der Wert über dem Namen, wenn beide gesetzt sind: links von einem Knoten in der ersten Spalte, rechts von einem in der letzten, zentriert über einem dazwischen, wie das Diagramm von gpui-component sie platziert; eine Beschriftung neben einem Knoten bleibt zwischen Ober- und Unterkante des Plots, und eine über den Rand des Plots hinaus sitzt in dem Platz, den die App um ihn lässt (ihr Margin). Das Padding des Parts nodeLabel ist der Abstand zu seinem Knoten und seine Klassen bestimmen seinen Text (max-w-20 truncate kürzt einen langen Namen); die Beschriftungen blenden mit der Appear-Animation ein. Ändert die App die Flüsse, bewegt sich jeder Knoten und jedes Band mit dem motion.update des Plots von seinem alten zu seinem neuen Platz, seine Beschriftung mit: Ein neuer Knoten wächst aus seiner Mitte, ein neuer Link aus seiner geringsten Breite, und entfallene verschwinden sofort; eine Änderung während der Bewegung setzt dort an, wo sie gerade gezeichnet sind. Links werden anhand von Quelle und Ziel unterschieden, sodass ein Umsortieren von links nichts bewegt.
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 | Typ | Standard | Beschreibung |
|---|---|---|---|---|
links |
.links(..) |
SankeyLink[] ({ source: number; target: number; value: number }) |
erforderlich | Die Flüsse: von der Zeile source zur Zeile target (ihre Indizes in den data des Plots), value ihre Größe. Ein Wert, der keine Zahl oder kleiner als null ist, trägt nichts und zeichnet kein Band. |
nodeWidth |
.node_width(..) |
number |
24 |
Die Breite jedes Knotens in Punkten. |
nodePadding |
.node_padding(..) |
number |
8 |
Der Abstand zwischen den Knoten einer Spalte in Punkten; kleiner in einer Spalte, die dafür zu voll ist. |
nodeAlign |
.node_align(..) |
"justify" | "left" | "right" | "center" |
"justify" |
In welche Spalte jeder Knoten gehört, wie bei d3-sankey: seine Tiefe ab der ersten Spalte ("left"), so weit rechts, wie seine Flüsse es zulassen ("right"), seine Tiefe, wobei ein Knoten, der nur Flüsse beginnt, neben seine Ziele rückt ("center"), oder seine Tiefe, wobei die Knoten, in denen Flüsse enden, in der letzten Spalte stehen ("justify"). |
iterations |
.iterations(..) |
number |
6 |
Wie viele Durchgänge die Knoten zu ihren Flüssen hin entspannen. |
valueScale |
.value_scale(..) |
"linear" | "sqrt" |
"linear" |
Höhen und Breiten proportional zu den Werten oder zu ihren Quadratwurzeln. |
minLinkWidth |
.min_link_width(..) |
number |
1 |
Das Dünnste, was ein Band zeichnet, in Punkten, damit ein kleiner Fluss sichtbar bleibt. |
nodeCornerRadius |
.node_corner_radius(..) |
number |
0 |
Der Radius der Ecken jedes Knotens in Punkten. |
nodeClass |
.node_class(..) |
string | (d: T, index: number) => string |
keine | Die Klassen jedes Knotens (fill-*, stroke-*): dieselben für jeden Knoten oder eine Funktion der Zeile des Knotens und ihres Index, einmal pro Zeile aufgerufen, wenn sich die Zeilen oder die Funktion ändern. |
nodeStyle |
.node_style(..) |
StyleProp<ViewStyle> |
keiner | Der Stil jedes Knotens, über seinen Klassen. |
linkClass |
.link_class(..) |
string | (link: SankeyLink, index: number) => string |
keine | Die Klassen jedes Bandes: dieselben für jedes Band oder eine Funktion des Links und seines Index in links. |
linkStyle |
.link_style(..) |
StyleProp<ViewStyle> |
keiner | Der Stil jedes Bandes, über seinen Klassen. |
linkBackgroundClass |
.link_background_class(..) |
string | (link: SankeyLink, index: number) => string |
keine | Die Klassen des Hintergrunds jedes Bandes (bg-*, ein Verlauf: bg-linear-to-r from-… to-…), innerhalb des Bandes über einer Box von seiner Quelle zu seinem Ziel sichtbar, unter seinem fill. Mit einem Hintergrund ist der fill eines Bandes none, wo keiner gesetzt ist. |
linkBackgroundStyle |
.link_background_style(..) |
StyleProp<ViewStyle> |
keiner | Der Stil des Hintergrunds jedes Bandes, über seinen Klassen. |
hoverDim |
.hover_dim(..) |
number (0 bis 1) |
0 |
Wie stark die nicht mit dem Knoten unter dem Zeiger verbundenen Links ausblenden, als Anteil ihrer Deckkraft, mit der pointer-Feder des Plots und dem Ausblenden des Hovers. |
nodeLabel |
.node_label(..) |
keyof T | (d: T, index: number) => string |
keiner | Der Name neben jedem Knoten: ein Feld oder eine Funktion der Zeile des Knotens und ihres Index. |
valueLabel |
.value_label(..) |
(d: T, value: number, index: number) => string |
keiner | Der Wert neben jedem Knoten, über seinem Namen: eine Funktion seiner Zeile und des Meisten, was in ihn hinein- oder aus ihm herausfließt (in den Werten der Links, unabhängig von valueScale). |
nodeLabelClass |
.node_label_class(..) |
string |
keine | Die Klassen jeder Beschriftung, des Parts nodeLabel: ihr Padding ist der Abstand zu ihrem Knoten, ihre Textstile gelten für beide Zeilen. |
nodeLabelStyle |
.node_label_style(..) |
StyleProp<TextStyle> |
keiner | Der Stil jeder Beschriftung, über ihren Klassen. |
valueLabelClass |
.value_label_class(..) |
string |
keine | Die Klassen der Wertzeile, des Parts valueLabel, über denen der Beschriftung. |
valueLabelStyle |
.value_label_style(..) |
StyleProp<TextStyle> |
keiner | Der Stil der Wertzeile, über ihren Klassen. |
Ein Knoten und ein Band füllen mit fill (currentColor, wo keine gesetzt ist) und zeichnen ihren Strich mit stroke (keiner, wo keiner gesetzt ist), jeweils ihre eigenen Werte über denen des Sankey und des Plots, wie CSS sie vererbt. Ein Knoten, durch den nichts fließt, ist einen Punkt hoch. Das Layout läuft nur erneut, wenn sich die Links, die Optionen oder die Größe des Plots ändern, nie für den Zeiger oder die Appear-Animation; ändert sich die Größe des Plots, bewegt sich nichts. Links, die einen Zyklus bilden oder eine Zeile benennen, die der Plot nicht hat, zeichnen nichts und protokollieren eine Warnung. Ihre Werte gehen nicht in die Skalen des Plots ein.
#Treemap
Ein Treemap zeichnet die Zeilen des Plots als Hierarchie verschachtelter Rechtecke: Jede Zeile ist ein Knoten, ihr id und die parent, die sie benennt, bilden den Baum (d3s stratify), die Fläche jedes Blatts ist proportional zu seinem value, und jede Gruppe ist die Box dessen, was unter ihr liegt; ihr Wert ist die Summe der Werte ihrer Blätter (unabhängig davon, was ihre eigene Zeile enthält). Eine Zeile ohne parent oder deren Parent keine Zeile benennt, ist ein Knoten der obersten Ebene; ohne id ist die id einer Zeile ihr Index, sodass parent eine Zeile auch über ihren Index benennen kann. Ein Baum in der verschachtelten Form, die gpt-vis schreibt ({ name, value, children }), lässt sich mit wenigen Zeilen Code der App in Zeilen umwandeln; die Anzahl der Ebenen ist nicht begrenzt.
Die Kacheln werden squarified angeordnet, wie d3s treemapSquarify sie erzeugt: die Knoten jeder Ebene nach Wert geordnet, der größte zuerst oben links (sort="value"), nahe am Seitenverhältnis ratio (dem goldenen Schnitt); sort="data" behält die Reihenfolge der Zeilen bei, sodass die Knoten ihre Plätze behalten, wenn sich ihre Werte ändern. paddingInner ist der Abstand zwischen Geschwistern, nicht entlang des äußeren Rands ihres Parents; paddingOuter ist der Platz innerhalb einer Gruppe um ihre Kinder; paddingTop ist ein Band am oberen Rand jeder Gruppe, in dem ihr groupLabel geschrieben steht. Jeder dieser Werte nimmt eine Zahl für alle Ebenen oder eine pro Ebene von der obersten abwärts ([3, 1]: 3 zwischen den Knoten der obersten Ebene, 1 darunter). Das Band einer Ebene gilt für alle Geschwister eines Parents oder für keines; es entfällt, wo eines dafür zu klein ist, sodass die Gruppen einer Ebene einheitlich aussehen.
Ein Blatt, dessen Wert 0, kleiner als 0 oder keine Zahl ist, erhält keine Fläche: Es wird nicht gezeichnet, hat kein Label und wird vom Zeiger nicht gefunden; dasselbe gilt für eine Gruppe, deren Blätter alle so sind. Die Flächen einer Treemap sind Anteile eines Ganzen, daher zeigt die App mit eigenen Worten, was ausgelassen wurde (ein Hinweis unter der Karte). Negative Zahlen sind für Farben gedacht: die Tagesveränderung einer Marktkarte.
Die Farbe eines Knotens ist die seiner Zeile aus dem colorScale des Plots: colorValue ist eine Zahl auf einer Rampe (sequential,
diverging: Verluste rot, Gewinne grün, unverändert neutral, über den Wertebereich der Werte ohne domain), color eine
Kategorie (ordinal: "parent" färbt die Blätter einer Gruppe gleich). Die Blätter sind der leaf-Part (leafClass,
leafStyle) und die Gruppen der group-Part (groupClass, groupStyle), die nur mit einer Klasse oder einem Style gezeichnet werden: ein
Rechteck unter ihren Kindern, das in den Lücken, in seinem Padding und in seinem Band sichtbar ist. Jeder Part ist auf allen Knoten gleich oder eine
Funktion der Zeile des Knotens. cornerRadii rundet jedes Rechteck ab.
label schreibt den Namen jedes Blatts und valueLabel seinen Wert darunter, als Funktion der Zeile und ihres Werts (der Wert einer Gruppe ist die
Summe ihrer Blätter): oben links (labelPlacement="start") oder in der Mitte ("center"). Der label-Part ist eine Box
so groß wie ihr Blatt: Ihr Padding ist der Abstand zu den Rändern des Blatts, und ihr Text gibt den Zeilen ihren Stil. Der Name wird
mit einer Ellipse abgeschnitten, wo das Blatt schmaler ist, und nur angezeigt, wo er passt; der Wert wird vollständig oder gar nicht angezeigt. Ein Label, dessen Blatt eine dunkle Farbe hat, erhält das Attribut data-dark,
sodass data-dark:text-white es auf einem tiefen Rot lesbar hält.
Über einer Treemap meldet der Plot den Knoten unter dem Zeiger (onHoverChange, die Zeile des Knotens): ein Blatt oder eine Gruppe in
ihrem Band oder Padding; über einer Lücke zwischen Geschwistern eines von ihnen, damit das Hover nicht flackert. Die anderen blenden
auf inactiveOpacity aus (ein Blatt behält seine Gruppen, eine Gruppe das, was unter ihr liegt), und der hover-Part, eine View so groß wie der
Knoten, umrandet ihn. Die Pfeiltasten wechseln zum Blatt daneben auf dem Bildschirm, Pos1 und Ende zum ersten und zum letzten.
Beim Erscheinen des Plots wachsen die Knoten der obersten Ebene aus ihrer Mitte, einer nach dem anderen mit stagger; wenn sich die Zeilen
ändern, bewegt sich jeder Knoten mit motion.update zu seiner neuen Box und Farbe, erkannt an seiner id: ein Knoten, der neu in den Zeilen ist,
wächst aus seiner Mitte, und einer, der weggefallen ist, schrumpft in seine eigene. Die x- und y-Skalen des Plots werden nicht verwendet.
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 | Typ | Standard | Beschreibung |
|---|---|---|---|---|
id |
.id(..) |
keyof T | (d: T, index: number) => string |
der Index der Zeile | Die id des Knotens jeder Zeile: das, was parent benennt, und das, woran ein Knoten erkannt wird, wenn sich die Zeilen ändern. |
parent |
.parent(..) |
keyof T | (d: T, index: number) => string |
keiner: Jede Zeile ist ein Knoten der obersten Ebene | Die id des Parents jeder Zeile; leer oder keine id einer Zeile für einen Knoten der obersten Ebene. |
value |
.value(..) |
keyof T | (d: T, index: number) => number |
erforderlich | Die Größe jedes Blatts; die einer Gruppe ist die Summe ihrer Kinder. Ein Blatt mit 0, kleiner als 0 oder keiner Zahl erhält keine Fläche. |
sort |
.sort(..) |
"value" | "data" |
"value" |
Geschwister, der größte zuerst oben links, oder in der Reihenfolge der Zeilen. |
ratio |
.ratio(..) |
number |
der goldene Schnitt | Das Seitenverhältnis, dem sich die Kacheln annähern, mindestens 1. |
paddingInner |
.padding_inner(..) |
number | number[] |
0 |
Der Abstand zwischen Geschwistern, in Punkten, pro Ebene von der obersten abwärts (der letzte gilt für alle darunter). |
paddingOuter |
.padding_outer(..) |
number | number[] |
0 |
Der Platz innerhalb jeder Gruppe um ihre Kinder, pro Ebene. |
paddingTop |
.padding_top(..) |
number | number[] |
0 |
Das Band am oberen Rand jeder Gruppe, für ihr groupLabel, pro Ebene; für alle Geschwister eines Parents oder für keines. |
cornerRadii |
.corner_radii(..) |
number | { topLeft?, topRight?, bottomRight?, bottomLeft? } |
eckig | Der Radius jeder Ecke ihrer Rechtecke. |
color |
.color(..) |
keyof T | (d: T, index: number) => string |
keine | Die Kategorie jedes Knotens, der ein ordinal colorScale eine Farbe gibt. |
colorValue |
.color_value(..) |
keyof T | (d: T, index: number) => number |
keine | Die Zahl jedes Knotens, die ein sequential oder diverging colorScale zu seiner Farbe macht. |
leafClass |
.leaf_class(..) |
string | (d: T, index: number) => string |
keine | Die Klassen jedes Blatts (fill-*, stroke-*), auf jedem Blatt gleich oder eine Funktion seiner Zeile. |
leafStyle |
.leaf_style(..) |
StyleProp<ViewStyle> |
keiner | Der Style jedes Blatts, über seinen Klassen. |
groupClass |
.group_class(..) |
string | (d: T, index: number) => string |
keine | Die Klassen jeder Gruppe, ein Rechteck unter ihren Kindern; Gruppen werden nur mit einer Klasse oder einem Style gezeichnet. |
groupStyle |
.group_style(..) |
StyleProp<ViewStyle> |
keiner | Der Style jeder Gruppe, über ihren Klassen. |
inactiveOpacity |
.inactive_opacity(..) |
number (0 bis 1) |
keine | Die Deckkraft der Knoten abseits des gehoverten und ihrer Labels, mit dem Ausblenden des Hovers. |
label |
.label(..) |
keyof T | (d: T, index: number) => string |
keiner | Der Name in jedem Blatt, einzeilig, mit einer Ellipse abgeschnitten. |
valueLabel |
.value_label(..) |
(d: T, value: number, index: number) => string |
keiner | Der Wert in jedem Blatt, unter seinem Namen, nur angezeigt, wo er vollständig passt. |
labelPlacement |
.label_placement(..) |
"start" | "center" |
"start" |
Die Labels oben links in ihren Blättern oder in deren Mitte. |
labelClass |
.label_class(..) |
string |
keine | Die Klassen des Labels jedes Blatts, eine Box so groß wie das Blatt: sein Padding und seine Textstile; data-dark: auf dunklen Blättern. |
labelStyle |
.label_style(..) |
StyleProp<TextStyle> |
keiner | Der Style des Labels jedes Blatts, über seinen Klassen. |
valueLabelClass |
.value_label_class(..) |
string |
keine | Die Klassen der Wertzeile, über denen des Labels. |
valueLabelStyle |
.value_label_style(..) |
StyleProp<TextStyle> |
keiner | Der Style der Wertzeile, über ihren Klassen. |
groupLabel |
.group_label(..) |
keyof T | (d: T, index: number) => string |
keiner | Der Name jeder Gruppe, in ihrem Band, mit einer Ellipse abgeschnitten. |
groupLabelClass |
.group_label_class(..) |
string |
keine | Die Klassen des Labels jeder Gruppe, eine Box so groß wie das Band. |
groupLabelStyle |
.group_label_style(..) |
StyleProp<TextStyle> |
keiner | Der Style des Labels jeder Gruppe, über ihren Klassen. |
stagger |
.stagger(..) |
number (0 bis 0.95) |
0 |
Wie die Knoten der obersten Ebene nacheinander erscheinen: der Anteil des Erscheinens, über den sie starten. |
hoverClass |
.hover_class(..) |
string |
keine | Die Klassen des hover-Parts, eine View so groß wie der gehoverte Knoten darüber (rounded-xs outline-2 outline-foreground). |
hoverStyle |
.hover_style(..) |
StyleProp<ViewStyle> |
keiner | Der Style des Hover-Parts, über seinen Klassen. |
Ein Rechteck füllt sich mit fill (der Farbe des colorScale, sonst currentColor, wo keine gesetzt ist) und wird mit
stroke umrandet (keine, wo keine gesetzt ist), wobei seine eigene Einstellung der der Treemap und der des Plots vorgeht, so wie CSS sie vererbt. Das Layout läuft
nur dann erneut, wenn sich die Zeilen, die Optionen oder die Größe des Plots ändern, nie für den Zeiger oder das Erscheinen; eine Änderung der Größe des Plots
verschiebt nichts. Eine wiederholte id, ein parent, das keine Zeile benennt, und eine Zeile, die ihr eigener Vorfahre ist, protokollieren einmalig eine Warnung:
Die erste Zeile behält die id, die verwaiste Zeile wird ein Knoten der obersten Ebene, der Zyklus wird nicht gezeichnet.
#PlotAxis
Eine PlotAxis beschriftet einen Rand des Plots mit den Ticks der Skala, die dieser Rand trägt: der untere und der obere Rand die der x-Skala, der linke und der rechte die der y-Skala, wie d3s axisBottom, axisTop, axisLeft und axisRight. Ihre Ticks
stammen aus der Skala: runde Werte bei einer linear-Skala (das 1-, 2- oder 5-Fache einer Zehnerpotenz, innerhalb des Wertebereichs), Zeilen bei einer
point- oder band-Skala. Ihre Labels sind Texte, die das Layout des Plots platziert, sodass die App festlegt, was sie lesen und wie sie
aussehen, und das Layout sie an Ort und Stelle setzt: Jedes Label ist entlang des Rands auf seinem Tick zentriert. Entlang des unteren und oberen
Rands rücken das erste und das letzte genau so weit nach innen, wie nötig ist, damit sie innerhalb der Enden des Plots bleiben; neben dem linken und
rechten Rand bleibt jedes Label auf seinem Tick, sodass es als das seiner Gitterlinie gelesen wird, und wo ein Label nahe einem Ende den oberen oder unteren Rand des
Plots überschreiten würde, beginnen die linearen y-Skalen des Plots um diesen Betrag weiter innen (die halbe Höhe des Labels bei einem Tick
am Ende), sodass jedes Label innerhalb des Plots auf seiner Gitterlinie bleibt.
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 | Typ | Standard | Beschreibung |
|---|---|---|---|---|
placement |
.placement(..) |
"bottom" | "top" | "left" | "right" |
"bottom" |
Der Rand, entlang dem sie verläuft: der untere und der obere Rand tragen die Ticks der x-Skala, der linke und der rechte die der y-Skala. |
scale |
.scale(..) |
"y" | "y2" |
"y" |
Die y-Skala, die eine linke oder rechte Achse beschriftet: die yScale des Plots oder seine zweite, y2Scale (Duale Achsen). |
labelPlacement |
.label_placement(..) |
"outside" | "inside" |
"outside" |
Wo ihre Labels sitzen: jenseits des Rands, in dem Platz, den die App um den Plot lässt, wo sie frei von den Marks bleiben; oder über dem Plot entlang des Rands, sodass der Plot seine volle Größe behält (neben einem linken oder rechten Rand sitzt jedes Label auf der Oberseite seiner Gitterlinie). |
ticks |
.ticks(..) |
number |
10 bei einer linear-Skala (jeder Schritt bei ausgerichteten Skalen), bei den übrigen jede Zeile |
Ungefähr wie viele Ticks sie nimmt: bei einer linear-Skala runde Werte innerhalb des Wertebereichs, ungefähr so viele; bei einer point- oder band-Skala so viele Zeilen, gleichmäßig verteilt von der ersten bis zur letzten. |
tickValues |
.tick_values(..) |
number[] |
keine | Die Werte ihrer Ticks, anstelle von ticks: Werte der Skala, bei einer point- oder band-Skala Zeilenindizes ([0, 3, 6] beschriftet jede dritte Zeile). Die Werte innerhalb des Wertebereichs erhalten jeweils einen Tick. |
format |
.format(..) |
(value: number, index: number) => string |
die Kategorie eines Bands bei einer Band-Skala von Kategorien, sonst der Wert, mit den Dezimalstellen, die der Abstand zwischen den Ticks braucht, Tausender gruppiert | Der Text jedes Ticks: die Funktion der App von seinem Wert (der Index einer Zeile bei einer point- oder band-Skala) und seinem Index unter den Ticks der Achse. |
labelClass, labelStyle |
.label_class(..), .label_style(..) |
string, StyleProp<TextStyle> |
keine | Der label-Part: ein Text pro Tick, dessen Schrift, Farbe und Padding von der App stammen; sein Padding zum Plot hin ist sein Abstand vom Rand (pt-2 unter einer unteren Achse). |
tickClass, tickStyle |
.tick_class(..), .tick_style(..) |
string, StyleProp<ViewStyle> |
keine | Der tick-Part: eine View pro Tick auf dem Rand, auf ihrem Tick zentriert und zu den Labels hin reichend (h-1 w-px bg-border unter einer unteren Achse); wird angezeigt, sobald er einen Style hat. |
lineClass, lineStyle |
.line_class(..), .line_style(..) |
string, StyleProp<ViewStyle> |
keine | Der line-Part: eine View entlang des gesamten Rands, auf der Seite der Labels (h-px bg-border unter einer unteren Achse); wird angezeigt, sobald er einen Style hat. |
Außen sitzen die Labels im Rand des Plots, so wie die Achsen von d3 in den Rändern eines Diagramms sitzen: Der Plot behält die Größe, die die App ihm
gibt, und die App gibt ihm auf der Seite der Achse einen Rand, der so tief ist wie die Labels (mb-6 für pt-2 text-xs-Labels
darunter), oder das Padding seines Parents dort. Zwei Achsen an einem Rand beschriften jeweils ihre eigenen Werte, in ihrem eigenen Style: die
Enden eines Bereichs blass und seine Mitte kräftiger.
format wird einmal pro Tick aufgerufen, wenn sich die Ticks oder die Funktion ändern, im nächsten Turn, nie während der Plot zeichnet
oder dem Zeiger folgt: Halte sie pur (derselbe Wert, derselbe Text). Ohne sie liest sich ein Tick so, wie d3 ihn schreibt:
1,500, 0.25, −2. In Rust erzeugt plot_axis() eine Achse, ihr format eine TickFormat::from_fn(|value, index| ..).
Eine Achse nimmt keinen Zeiger an, und Screenreader hören den Plot statt seiner Labels.
#PlotLabel
Ein PlotLabel schreibt Texte an Punkte des Plots: eine Beschriftung über einem Gipfel, ein Tag am letzten Punkt einer Linie, eine Notiz am Ende einer Gitterlinie. Jeder seiner texts benennt seinen Punkt mit Werten der Skalen des Plots, wie es die Marks tun: der Index einer Zeile auf einer point- oder band-Skala (die Mitte des Bands der Zeile), ein Wert auf einer linear-Skala. Die Oberkante des Textes sitzt auf dem Punkt, und sein align lässt ihn dort beginnen, zentriert ihn darauf oder lässt ihn dort enden. Das Layout des Plots platziert die Texte, sodass sie der Größe des Plots folgen; sie gleiten mit den Domains der Skalen (motion.update) und blenden mit dem Appear ein.
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 | Typ | Standard | Beschreibung |
|---|---|---|---|---|
texts |
.texts(..) |
PlotText[] |
[] |
Die Texte, die es schreibt, jeweils an einem eigenen Punkt. |
scale |
.scale(..) |
"y" | "y2" |
"y" |
Die y-Skala, deren Werte die y seiner Texte sind (Duale Achsen). |
Jeder Text ist ein PlotText:
| Feld | Typ | Standard | Beschreibung |
|---|---|---|---|
text |
string |
Was er anzeigt. | |
x |
number |
Wo er entlang der x-Achse liegt: ein Wert der x-Skala, der Index einer Zeile auf einer point- oder band-Skala (0.5 liegt auf halbem Weg zwischen den ersten beiden Zeilen). |
|
y |
number |
Wo seine Oberkante entlang der y-Achse liegt: ein Wert der y-Skala. | |
align |
"left" | "center" | "right" |
"left" |
Wie er entlang x von seinem Punkt aus sitzt: dort beginnend, darauf zentriert oder dort endend. |
class, style |
string, StyleProp<TextStyle> |
keine | Die eigenen Klassen und der eigene Style des Textes, über dem, was er vom Label erbt: seine Box (Hintergrund und Padding eines Tags) und sein Abstand zum Punkt. |
Das Aussehen bestimmt die App: Das class des Labels gibt jedem Text die Textstile, die er erbt (Schrift, Größe, Farbe), und die eigenen class und style eines Textes stylen ihn allein. Ein Text steht mit -translate-y-full über seinem Punkt, und sein Padding ist sein Abstand zum Punkt (pb-1); ein Tag neben dem letzten Punkt einer Linie sitzt in dem Rand, den die App auf dieser Seite lässt (mr-12), wie die Beschriftungen einer Achse. In Rust erzeugt plot_label() ein Label, jeder Text ein PlotText::new(text, x, y) mit .align(PlotTextAlign::Center), .class(..) und .style(..). Der Pointer geht durch die Texte hindurch zum Plot, und Screenreader lesen den Plot vor, dessen accessibilityLabel sagt, worauf die Texte hinweisen.
#Bewegung
Die Bewegung gehört der App, und standardmäßig gibt es keine: Alles erscheint sofort. motion nimmt die Timings von nsh ({ duration, easing, delay } und { stiffness, damping, mass }):
| Schlüssel | Typ | Was sich bewegt |
|---|---|---|
appear |
Timing | Die Marks zeichnen sich ein, mit einem appearGeneration: Linien und Flächen von der Vorderkante aus, Balken wachsen aus ihrer Basis (nacheinander mit dem stagger eines Balkens); Punkte wachsen aus dem Nichts (nacheinander mit dem stagger eines Punkts); Zellen wachsen aus ihrer Mitte (nacheinander mit dem stagger einer Zelle); der Ring eines Pie und eines Arc schwingt im Uhrzeigersinn von seinem Start aus ein; eine radiale Linie wächst aus ihrem inneren Radius, ihre Punkte mit ihr; die Nodes und Ribbons eines Sankey von der Vorderkante aus, seine Labels blenden ein. |
update |
Timing | Marks bewegen sich zur neuen Form, wenn die App sie ändert: Punkte zu ihren neuen Werten und Größen, der Punkt einer neuen Zeile wächst hinein und der einer entfallenen Zeile schrumpft weg; Zellen zu ihren neuen Farben (gemischt in OKLab) und Bändern, die Zelle einer neuen Zeile wächst hinein und die einer entfallenen schrumpft weg; die Radien eines Pie und eines Arc, sodass sich das Loch eines Donuts öffnet und schließt, und ihre Winkel, sodass ein Gauge oder ein Slice zu seinem neuen Wert schwingt (arcTween von d3: die Winkel bewegen sich, nicht die Kontur; ein neuer Slice öffnet sich dort, wo der vorherige endet; eine Änderung während einer Bewegung setzt dort an, wo gerade gezeichnet wird); die Punkte einer radialen Linie zu ihren neuen Werten, Winkeln und Radien (eine neue Zeile wächst aus dem inneren Radius), die Ringe eines radialen Gitters mit ihnen; und die Domain einer linearen Skala, das domain der App oder der Umfang der Werte der Marks, während Zeilen eintreffen, die vom alten zum neuen gleitet, wobei die Marks, die Gitterlinien, die PlotAxis und die PlotLabel mitgehen; und die Nodes, Ribbons und Labels eines Sankey, vom Layout der alten Flows zu dem der neuen, wobei eine Änderung während der Bewegung dort ansetzt, wo gerade gezeichnet wird. |
enter |
Timing | Die Teile des Hovers blenden ein, wenn der Pointer über den Plot kommt. |
exit |
Timing | Sie blenden aus, nachdem er ihn verlässt. |
pointer |
Spring | Sie gleiten von einer Zeile zur nächsten, wenn die Tasten oder ein Screenreader den Hover weiterschalten; der Slice eines Pie hebt sich dabei. |
Die Teile des Hovers folgen dem Pointer im selben Frame: Wenn er sich bewegt, liegen die Kreuzlinie, die Punkte und der Tooltip sofort auf der Zeile darunter, sodass sie der Hand folgen, während ihr Ein- und Ausblenden (enter, exit) weiterhin animiert wird.
Die Bewegung läuft auf dem Animator der Plattform, ohne JS pro Frame, und ist für Personen, die reduzierte Bewegung wünschen, sofort abgeschlossen. Ein Plot, den eine Liste für eine zurückkehrende Zeile neu erzeugt, zeichnet sich erneut ein, es sei denn, ein PlotAppearScope um die Liste hat gesehen, dass sein Appear beendet wurde.
Eine Domain bewegt sich so, wie d3 die Domain einer Skala überblendet: Die Skala dazwischen platziert jeden Wert, sodass die Marks ihre Formen behalten, die Gitterlinien und die Teile des Hovers sich mit ihnen bewegen, Balken ihre Ecken behalten und eine Zeile, die mit der neuen Domain hinzukommt, sich mit den übrigen bewegt. Ein Live-Chart, das seine y-Skala hin und wieder an seine Zeilen anpasst (mit etwas Spielraum, damit das Gitter zwischen den Anpassungen stillhält), gleitet mit einem update-Timing zu jeder neuen Anpassung. Wenn sich die Größe des Plots ändert, übernehmen die Marks die neue Größe sofort.
Das ist die eigene Bewegung des Plots, die Timings dessen, was ein Chart bewegt: der Clip seiner Marks und die Teile seines Hovers. Die Entrances, Exits und Attention Seekers einer View sind die Presets und Keyframes von createMotion, die eine App weiterhin auf der View des Plots oder auf den Elementen darum abspielen kann.
#PlotAppearScope
Eine Liste erzeugt den Plot einer Zeile jedes Mal neu, wenn die Zeile wieder in den Blick kommt, sodass sich eine Sparkline mit einem Appear jedes Mal erneut einzeichnet, wenn die Liste zu ihr zurückscrollt. Ein PlotAppearScope um die Liste merkt sich die Appears, die darin beendet wurden: Ein dort neu erzeugter Plot, dessen appearGeneration beendet wurde, zeigt seine Marks sofort vollständig. Ein Plot wird über seine Generation gemerkt, daher bekommt der Plot jeder Zeile eine eigene: den Key der Zeile mit allem, was sie neu abspielen soll (`${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 | Typ | Standard | Beschreibung |
|---|---|---|---|---|
id |
.id(..) |
number | string |
keine | Wofür sich der Scope etwas merkt: Wenn es sich ändert, vergisst der Scope, und seine Plots zeichnen sich neu ein, dort wo sie sind. Benenne es nach dem Inhalt, etwa einer Watchlist oder einer Konversation; ohne eines merkt sich der Scope, solange er angezeigt wird. |
children |
.child(..) |
JSX.Element |
keine | Was er enthält: die Liste oder alles, was seine Plots neu erzeugt, wenn sie kommen und gehen. |
Eine neue Generation zeichnet den Plot weiterhin ein, ebenso ein Plot, der vor dem Ende seines Appears entfernt wurde, und zwar von vorn. Sobald ein Plot eine neue Generation beendet hat, vergisst sein Scope die vorherige, sodass der Wechsel zurück zu einem früheren Zeitraum den Plot erneut einzeichnet. Das Gedächtnis gehört zum Scope: Ein Scope, der nicht mehr angezeigt wird, oder einer mit neuem id zeichnet seine Plots neu ein, und ein Plot merkt sich seine Appears im innersten Scope um ihn herum. Sein Aussehen bestimmt die App: class und style legen ihn wie jede View aus (flex-1 um eine Liste, die den Bildschirm füllt). In Rust erzeugt plot_appear_scope() einen.
#Tastatur
Ein interaktiver Plot ist ein Tab-Stopp (macOS, das Web), und solange er den Tastaturfokus hat, bewegen seine Tasten denselben Hover wie der Pointer: Die Kreuzlinie, die Punkte und der Tooltip gleiten mit dem pointer-Spring, der Slice eines Pie hebt sich, onHoverChange meldet die Zeile, und Screenreader lesen das accessibilityValue vor, das die App dafür angibt. Sein Fokusring bestimmt die App: eine focus-visible:-Klasse oder ein focusStyle (focus-visible:outline-2 focus-visible:outline-ring).
| Tasten | Was sie tun |
|---|---|
| Rechts, Links | Zur Zeile auf dieser Seite wechseln: die nächste oder die vorherige Zeile, auf einer umgekehrten x-Skala, deren erste Zeile rechts liegt, andersherum (über einem Pie die Slices in der Reihenfolge der Zeilen; über einem Sankey seine Nodes; über einer Treemap das Blatt daneben auf dem Bildschirm, wie es Oben und Unten tun); ohne gehoverte Zeile beginnt Rechts bei der Zeile am linken Ende und Links bei der am rechten Ende (über einem Pie beim ersten und beim letzten Slice). |
| Pos1, Ende | Zur ersten oder zur letzten Zeile wechseln. |
| Oben, Unten | Über Zellen: zur Zelle darüber oder darunter in derselben Spalte wechseln. Über Zellen folgen die Tasten dem Raster auf dem Bildschirm (Data Grid von WAI-ARIA): Rechts und Links wechseln zur nächsten Zelle entlang der Reihe, Pos1 und Ende zur ersten und zur letzten Zelle der Reihe; ohne gehoverte Zelle beginnt die erste Taste bei der Zelle oben links (Links, Oben und Ende bei der unten rechts). An einem Rand bleibt der Hover. |
| Escape | Löscht den Hover. |
Der Plot reagiert auf seine Tasten, solange er den Fokus hat, und auf Escape, solange eine Zeile gehovert ist; in der übrigen Zeit gehen sie an die App und die Views darum. Wenn der Fokus woandershin wechselt, wird ein durch die Tasten erzeugter Hover gelöscht. Der Plot nimmt keine Presses an, sodass ein Plot in einer pressbaren Karte der Karte ihre Presses lässt. Auch Screenreader schalten durch die Zeilen: VoiceOvers Wischen nach oben und unten, TalkBacks Scrollen vorwärts und zurück, und jeder Screenreader, der die Aktionen increment und decrement eines Elements anbietet, wechselt zur nächsten und zur vorherigen Zeile, auch auf einer umgekehrten Skala in der Reihenfolge der Zeilen.
#Diagramme bauen
Ein Chart besteht aus einem Plot, seinen Marks, seinen Achsen und den eigenen Elementen der App darum. Gib der y-Skala ein domain und ihrer Achse die Werte des Gitters (tickValues): Jedes Label sitzt dann an seiner Gitterlinie. Die Kreuzlinie, die Punkte und der Tooltip des Hovers sind Teile des Plots, von der App gestylt und befüllt; die Werte der Zeile stehen dort, wo die App sie zeigt.
Gitter lesen sich am besten als durchgezogene Haarlinien mit einer festeren Grundlinie, was eine gestrichelte Linie frei lässt, einen Referenzwert zu bedeuten (der Eröffnungskurs unten). Eine Fläche wirkt leichter als Verlauf, der zu ihrer Grundlinie hin ausblendet, denn als flache Füllung. Der Hover-Punkt jeder Serie nimmt die Farbe seiner Serie und als Ring die Farbe der Fläche, auf der das Chart sitzt, sodass er sich von seiner Linie abhebt, und der Tooltip sitzt oben im Plot, bei der ersten und der letzten Zeile innerhalb seiner Ränder: Ein domain mit Platz über dem höchsten Wert hält ihn von der Linie fern.
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>
);
}
Ein Balkendiagramm liest sich am besten mit abgerundeten Spitzen der Balken und eckigen Basen (cornerRadii={{ topLeft: 4, topRight: 4 }}; in a stack only on its top bar), a hair of room between the bars of a band (groupPadding), mit abgeschwächten Balken der anderen Zeilen, solange eine gehovert wird (inactiveOpacity), und getöntem Band (crossLineClass), und mit den Werten in kleinen tabellarischen Ziffern über den Balken, bei einem Stack mit der Summe. Mit paddingOuter gleich der Hälfte von paddingInner fallen die Mitten der Bänder in gleichen Abständen, und ein PlotAxis zentriert jeden Monat unter seinem Band; ein maxBandWidth hält einige Balken in einer breiten Karte schmal.
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>
);
}
Eine Sparkline in einer Listenzeile ist ein Plot mit interactive={false}: Sie zeichnet ihre Marks und folgt keinem Pointer.
#Barrierefreiheit
- Ein Plot ist für Screenreader ein Element mit der Image-Rolle und dem
accessibilityLabelder App; seine Marks, die Labels seiner Achsen, seinePlotLabelund die Teile des Hovers sind Teile davon und sagen selbst nichts. Sag im Label, was das Chart zeigt und was heraussticht (die Spanne, der höchste Wert), und gibaccessibilityValuedie Worte für die Zeile unter dem Pointer, damit ein Screenreader den Wert ansagt, wenn er sich ändert. - Die Zeile unter dem Pointer wird mit
onHoverChangeauf Plattformen mit Pointer gemeldet (Desktops, das Web, ein iPad mit Trackpad). Touchscreens melden keinen Hover: Zeige die Werte, die Touch-Nutzer brauchen, in den eigenen Elementen der App, oder biete die Zeilen als Text oder Liste an, wo die Zahlen wichtig sind. - Über einem Pie ist die gemeldete Zeile die des Slices unter dem Pointer: Gib
accessibilityValueseinen Namen, Wert und Anteil ("Team, 340 seats, 34.0%"), und nenne die Summe und den größten Anteil im Label. Über einem Radar ist es die Achse, die dem Pointer am nächsten liegt: Gib ihr den Namen der Achse und den Wert jeder Serie dort. - Ein interaktiver Plot ist ein Stopp des Tastaturfokus, dessen Pfeiltasten den Hover bewegen (Tastatur), und er bietet Screenreadern
incrementunddecrement, um durch die Zeilen zu schalten (VoiceOver liest ihn als einstellbar vor); ein Plot mitinteractive={false}ist keines von beidem, und er nimmt in beiden Fällen keine Presses an.
Siehe Barrierefreiheit für Labels und Rollen im Allgemeinen.
#Plattformhinweise
- Jede Plattform zeichnet die Marks auf dieselbe Weise: aus einer in Rust aufgezeichneten Zeichnung pro Mark. Ändern sich die Zeilen, zeichnet sie die Marks, die diese lesen, in einem Batch neu. Ändert sich eine Paint-Klasse (das Erscheinungsbild,
dark:), wird die Zeichnung erneut gesendet, ohne sie neu aufzuzeichnen. - Die Einblendung beschneidet Linien und Flächen und lässt Balken wachsen, jeweils mit Werten, die der Animator der Plattform bewegt. Das Ausblenden eines Balkens im Abstand zur gehoverten Zeile ist eine Deckkraft, die der Animator aus der Position des Hovers berechnet. Die Teile des Hovers sind Views, die der Animator bewegt und ein- bzw. ausblendet, und er hält den Tooltip innerhalb des Plots, ausgehend von der Größe des Plots und der des Tooltips.
- Der Hintergrund einer Fläche wird in einem eigenen Canvas gezeichnet, beschnitten auf die Form der Fläche, mit demselben Verlauf, den die Plattform für das
bg-linear-*einer View zeichnet. Ändern sich ihre Klassen (das Erscheinungsbild,dark:), wird die Zeichnung erneut gesendet, ohne sie neu aufzuzeichnen. - Ein Pie zeichnet jedes Segment in einem eigenen Canvas. Das Segment unter dem Zeiger wird in Rust aus den Winkeln und Radien der Segmente ermittelt. Das Anheben des Segments ist ein Radius und das Ausblenden der übrigen eine Deckkraft, die der Animator bewegt. Der Sweep der Einblendung bewegt die Winkel der Segmente auf dieselbe Weise, sodass keines von beidem eine Zeichnung neu aufzeichnet.
- Die Zeile einer Radial-Linie unter dem Zeiger wird in Rust aus den Winkeln ihrer Punkte ermittelt. Ihr Hover-Punkt ist eine View, die der Animator auf der Zeile unter dem Zeiger platziert (er gleitet auf der
pointer-Spring, während die Keys weiterspringen). Während die Linie eingeblendet wird oder zu neuen Werten wechselt, sind ihre Punkte Zahlen, die der Animator von ihrem bisherigen Stand aus bewegt, sodass die Linie pro Änderung einmal aufgezeichnet wird. - Ein Dot-Mark wird in einem Canvas gezeichnet. Der Punkt unter dem Zeiger wird in Rust aus den Mittelpunkten und Radien der Punkte ermittelt, und sein Hover-Punkt ist eine View, die der Animator darauf platziert, in derselben Größe. Das Wachsen der Einblendung, die Bewegungen bei einer Änderung und das Ausblenden des Hovers sind Werte, die der Animator bewegt, sodass die Punkte pro Änderung einmal aufgezeichnet werden.
- Ein Cell-Mark wird in einem Canvas gezeichnet. Die Zelle unter dem Zeiger wird in Rust aus den Boxen der Zellen ermittelt, und ihre Hover-Zelle ist eine View, die der Animator darauf platziert. Das Wachsen, die Farb- und Positionswechsel bei einer Änderung und das Ausblenden des Hovers sind Werte, die der Animator bewegt, sodass die Zellen pro Änderung einmal aufgezeichnet werden. Jedes Label ist ein Text der Plattform und wird dort angezeigt, wo seine berechnete Größe in die Zelle passt.
- Die Labels, Tick-Marks und die Linie einer Achse sind die eigenen Texte und Views der Plattform. Das Layout in Rust platziert sie anhand der Größe und der Skalen des Plots: Sie bewegen sich mit den Marks, wenn sich die Größe des Plots ändert, und eine neue Domain schreibt die Labels neu.