元素
Plot
Plot 是图表的框架:它持有要显示的数据行、x 轴和 y 轴的比例尺、指针在它上面的位置以及动效。它的图形负责绘制这些内容:Line 是穿过一个系列的折线,Area 是系列与基线之间的区域,Bar 是 band 比例尺条带里每行一根的柱子,Rule 是每行一条从起点到终点的线段(K 线的影线),Dot 是每行在两个数值交汇处的一个圆点(散点图的点、气泡图的气泡),Cell 是每行在两个类别的条带里的一个格子,按数值上色(热力图),Grid 是横跨图表的网格线,Pie 是各行所占份额的饼图(中间留空即为环形图),Arc 是两个角度之间的一段扇形,RadialLine 是围绕中心的一条线,背后是 RadialGrid 的圆环和辐条(雷达图),Sankey 是各行之间的流向图,Treemap 是嵌套矩形,每个叶子的面积就是它的值(市场热力图);PlotAxis 用比例尺的刻度为一条边加上标签,PlotLabel 在比例尺的点上写文字。每个图形都是一个铺满 plot 的画布,根据数据行原生绘制:应用设置数据行时只读取一次,之后在 Rust 中完成缩放、录制和绘制,所以一千个点的图表也不会为每个点运行 JS。指针同样在 Rust 中跟踪:悬停的十字线、圆点和提示框由平台的动画器移动和淡入淡出,应用只会在指针移到另一行时收到通知。外观由应用决定:图形使用 CSS 中 SVG 图形的绘制属性(stroke-*、fill-*),没有设置时使用 currentColor;area 和柱子可以在自己的形状内显示背景(渐变,bg-linear-*);悬停部件(包括每个图形自己的圆点)以及坐标轴的标签、刻度线和轴线都由应用设置样式和内容。
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("每天的访客数")
.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 ? "访客" : `${rows[hovered()!].day}: ${rows[hovered()!].visitors}`}
</Text>
<Plot
data={rows}
y="visitors"
yScale={{ type: "linear", domain: [0, 3000] }}
accessibilityLabel="每天的访客数,周四最多"
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 和 PlotLabel 来自 nsh/base/plot,这是 nsh/base 中单独的一个模块(Rust 中是 nsh_base::plot)。在 TypeScript 中,plot 的属性对数据行的类型是泛型的:字段名会按数据行做类型检查,行的函数会拿到行的类型。在 Rust 中,plot() 为每个属性提供一个 setter,数据行是 Rows(按键名存放的数字列),取值方式是 RowAccessor(键名,或以行序号为参数的函数),图形使用带类型的绘制方法(.stroke_color(..)、.fill_color(..)、.stroke_width(..))。
#Plot
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
data | .data(..) | T[] | (必填) | 数据行,每个点一个对象。应用设置数据行时,图形读取一次它们的值;没有值的行(字段不是数字、为 null)会跳过。 |
x | .x(..) | keyof T | (d: T, index: number) => number | 行的序号 | 在 linear x 比例尺上决定一行沿 x 轴位置的取值。point 比例尺按顺序放置各行。 |
y | .y(..) | keyof T | (d: T, index: number) => number | 无 | 图形没有指定自己的取值时绘制的内容。 |
xScale | .x_scale(..) | Scale | { type: "point" } | x 轴如何放置各行:point(按顺序从左边缘到右边缘均匀分布;只有一行时放在中间)、带可选 domain 的 linear,或 band(每行一个条带,用于柱子,见 Bar;或每个类别一个条带,用于格子,见 Cell)。y 轴为 band 时默认为 linear,用于横向柱子。reverse: true 让它从右边缘开始(反向比例尺)。 |
yScale | .y_scale(..) | Scale | { type: "linear" } | y 轴如何放置数值:定义域的最小值在底边,最大值在顶边。没有 domain 时取各图形所绘制数值的范围。band 从顶边向下每行布置一个条带,用于横放的柱子。reverse: true 让它反过来:最小值在顶边,各行从底边向上排列(反向比例尺)。 |
y2Scale | .y2_scale(..) | LinearScale | 无 | 第二个 y 比例尺 { type: "linear", domain?, reverse? },给声明 scale="y2" 的图形用:双轴图右侧的比例尺(双轴)。没有 domain 时取放在它上面的值的范围。 |
alignTicks | .align_ticks(..) | boolean | true | 两个 y 比例尺都没有指定定义域、且两边都有图形时,把两者扩展成同样多的整齐步长,让刻度落在同一组线上(双轴)。为 false 时各自取数据的范围。 |
interactive | .interactive(..) | boolean | true | plot 跟随指针和键盘:报告指针下的行并显示悬停部件;它也是 Tab 的一个停靠点,方向键移动悬停的行(键盘)。为 false 时只绘制图形(例如列表中的迷你图),在它上面的点按交给周围的视图。plot 是否提供键盘操作,在创建时确定。 |
motion | .motion(..) | PlotMotion | 无 | 出现动效、悬停淡入淡出的时间,以及按键移动悬停时所用的弹簧(见下文)。 |
appearGeneration | .appear_generation(..) | number | string | 无 | 启用出现动效:plot 第一次显示时图形逐渐绘出,此值每次变化时再播放一次(换一支股票、换一个周期)。在 PlotAppearScope 里,已经在其中播放完的 generation 立即完整显示。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 屏幕阅读器对图表读出的内容:图表显示了什么,以及值得注意的地方。 |
accessibilityValue | .accessibility_value(..) | { min?, max?, now?, text? } | 无 | 屏幕阅读器读出的数值:应用对指针下那一行的描述。 |
crossLineClass、crossLineStyle | .cross_line_class(..)、.cross_line_style(..) | string、StyleProp<ViewStyle> | 无 | 十字线:与 plot 等高的视图,以指针下的行为中心。在 band 比例尺上它位于该行的条带里,与条带等宽(band 在 y 轴时横跨 plot)。 |
dotClass、dotStyle | .dot_class(..)、.dot_style(..) | string、StyleProp<ViewStyle> | 无 | 悬停圆点:每个 Line、Area 和 RadialLine 一个视图,以该图形在指针下那一行的点为中心;Dot 的则与指针下的圆点一样大。图形自己的 hoverDotClass / hoverDotStyle 会替换它们,用于该图形的圆点。 |
tooltipContent | .tooltip_content(..) | JSX.Element | 无 | 应用的提示框,按 tooltipPlacement 放置,随十字线移动和淡入淡出。 |
tooltipPlacement | .tooltip_placement(..) | "top" | "pointer" | "top" | 提示框的位置:在 plot 顶部、以十字线为中心;或在指针旁边,位于十字线朝 plot 中间的一侧,指针在上半部时在它下方,在下半部时在它上方。在顶部时,提示框留在 plot 的左右边缘之间(与顶边的距离由应用决定:放在 plot 下方的标签取 plot 的高度再多一些);在指针旁边时,提示框留在 plot 内。用键盘时,提示框在该行第一个折线或区域的点旁边(柱状图在柱带中间,饼图在扇区中间)。 |
tooltipOffset | .tooltip_offset(..) | number | 0 | 提示框与定位依据之间的距离:"top" 时是顶边,"pointer" 时是十字线和指针。 |
colorScale | .color_scale(..) | ColorScale | 无 | 图形的颜色:图形的 color 读出的类别所对应的颜色,同 d3 的 scaleOrdinal({ type: "ordinal", domain?, range, unknown? },见按类别着色);或 Cell 的 value 读出的数值沿色阶对应的颜色,同 d3 的 scaleSequential 和 scaleDiverging({ type: "sequential" | "diverging", … },见按色阶着色)。 |
children | .child(..) | JSX.Element | 无 | 图形,按顺序叠加绘制,以及应用放在它们上面的内容。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | plot 的尺寸和外观;图形会像 CSS 那样继承它的绘制属性。focus: / focus-visible: 类和 focusStyle 绘制它的焦点环。 |
| 事件 | Rust | 参数 | 触发时机 |
|---|---|---|---|
onHoverChange | .on_hover_change(..) | { index: number | null } | 指针移到另一行上:沿 x 轴(只有 y 轴有条带时沿 y 轴)离指针最近的那一行的序号,指针下饼图扇区所在行的序号,圆心离指针最近的圆点所在行的序号,指针下格子所在行的序号(在两个格子之间的间隙里取较近的那个),或径向线上角度离指针最近的那一行的序号;指针离开 plot(在饼图上为离开它的扇区,在径向线上为离开它的外半径)后为 null。 |
Scale 是 { type: "point"; reverse? } | { type: "linear"; domain?: [number, number]; reverse? } | { type: "band"; domain?: string[]; paddingInner?; paddingOuter?; maxBandWidth?; bandCount?; groupPadding?; reverse? }。linear 比例尺的 domain 取两个值构成的范围,顺序不限;只有一个值的定义域会把它放在中间。band 比例尺的 domain 是各条带的类别,按顺序排列(见 Cell);它的其他选项见 Bar。
行的函数(y={(d) => d.high - d.low})在数据行或函数变化时,于下一轮对每行调用一次,绘制和跟随指针时从不调用:请保持它是纯函数(同一行得到同一个数)。字段名则完全不运行 JS。
#反向比例尺
reverse: true 让比例尺从另一条边开始,就像 visx 的比例尺接受 reverse、d3 的比例尺接受反着写的值域。在 x 轴上,第一行或定义域的最小值位于右边缘;在 y 轴上,定义域的最小值位于顶边,各行从底边向上排列。放在比例尺上的一切都随之变化:图形、柱子的基线和生长方向、area 的基线、网格线、PlotAxis 的刻度、悬停找到的行、方向键(键盘)以及向新定义域的移动。在反向的 linear y 比例尺上,柱子从顶边向下垂挂;在反向的 linear x 比例尺上,横放的柱子从右边缘向左生长:
<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>
plot 出现时,折线和区域从 x 比例尺起始的那条边逐渐绘出,比例尺反向时就是右边缘。
#双轴
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);plot 有第二个 y 比例尺,用来放单位不同的值:价格旁的成交量、营收旁的利润率。所有沿 y 方向放值的部件都接受
scale:"y"(默认,plot 的 yScale)或 "y2"(它的 y2Scale)。右侧加一个 scale="y2" 的 PlotAxis 来标注它:
<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>
每个比例尺有自己的定义域:app 给的 domain,或放在它上面的值的范围;有 motion.update 时各自移动到新的定义域。两个比例尺
都没有指定定义域时,plot 把两者排成同样多的整齐步长(1、2 或 5 乘以 10 的幂,大约每 32 点高度一步):两边都包含 0 时,0
落在同一条线上;否则从 0 开始的比例尺(成交量)向上补步长,其他的(价格)上下各补一半。坐标轴的刻度就是这些步长,既不写 x
也不写 y 的 Grid 也画在这些位置,于是一套网格同时服务两条轴。alignTicks={false} 关闭对齐;app 指定了 domain 时两个比例尺都保持原样。
悬停仍找一行;每个图形的悬停点在它自己的比例尺上,读数由 app 根据 onHoverChange 报告的那一行读出两个值。两个比例尺上的柱子
在每个条带里并排,stack 只堆叠同一比例尺上的柱子。y2Scale 只能是线性的:y 轴有条带的 plot 没有第二个 y 比例尺。
#Line、Area 和 Grid
| 图形 | 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
Line | y | string | (d, index) => number | plot 的 y | 要绘制的内容:每个有值的行对应一个点,没有值的行跳过。 |
Line、Area | data、x | T[]、string | (d, index) => number | plot 的数据行 | 自己的一组数据行(x 值不同的系列),用自己的 x 放在 plot 的比例尺上(没有时用行的序号)。 |
Line、Area | curve | "natural" | "linear" | "stepAfter" | "natural" | 点与点之间如何连接:平滑曲线、直线段,或保持每个值直到下一行的阶梯。area 的底边使用同一条曲线。 |
Line | dot、dotClass、dotStyle | boolean、string、StyleProp<ViewStyle> | false | 在每个点上显示圆点:以点为中心的视图,尺寸、背景和边框由应用决定。 |
Line、Area | hoverDotClass、hoverDotStyle | string、StyleProp<ViewStyle> | plot 的 dotClass、dotStyle | 它的悬停圆点,位于它在指针下那一行的点上:每个系列的圆点用自己的颜色。两者分别替换 plot 上对应的值,只作用于这个图形(图形的颜色配合 plot 的尺寸和描边);hidden 表示不显示。 |
Area | backgroundClass、backgroundStyle | string、StyleProp<ViewStyle> | 无 | 它的背景:包围其形状的盒子(从第一个点到最后一个点,从最高点到基线,带则到底边的最低点),盒子的背景(bg-*、bg-linear-to-b from-… to-…、backgroundImage)只在形状内显示,位于 fill 之下。设置背景后,fill 没有设置时为 none。 |
Area | y1 | string | (d, index) => number | plot 的 y | 顶边使用的取值。 |
Area | y0 | number | string | (d, index) => number | y 比例尺低端所在的边 | 它的底边:数字是 y 比例尺上的一条基线;字段名或函数为每一行给出自己的底值,area 由此成为一条带(一天的最低价到最高价、预测的区间)。没有它时,area 闭合到 y 比例尺最小值所在的边:通常是底边,反向比例尺上是顶边(从顶部垂下的区域)。任一边没有值的行会跳过。 |
Line、Area、Grid | scale | "y" | "y2" | "y" | 它的值沿哪个 y 比例尺放:plot 的 yScale,或第二个 y2Scale(双轴)。 |
Grid | x | number[] | 无 | 竖线的位置,取 x 比例尺上的值(point 比例尺上是行的序号)。 |
Grid | y | number[] | 无 | 横线的位置,取 y 比例尺上的值。x 和 y 都不写时,横线跟随它的比例尺的刻度,即不写 ticks 的 PlotAxis 放刻度的位置(横放的柱子沿 x)。 |
Grid | ticks | number | 坐标轴的默认值 | x 和 y 都不写时大约画多少条线,同 PlotAxis 的 ticks。 |
Line、Area | color | string | (d, index) => string | 无 | 它的数据行的类别,颜色来自 plot 的 colorScale:第一个有类别的行决定 line 的 stroke(area 的 fill),在没有任何设置时使用(见按类别着色)。 |
每个图形都铺满 plot,使用自己的 SVG 绘制属性 绘制;像 CSS 的继承一样,自己设置的值优先于 plot 上的值:
| 图形 | 使用的属性 | 没有设置时 |
|---|---|---|
Line | stroke、strokeWidth、strokeDasharray | currentColor,宽 1,实线 |
Area | fill(位于 background 部件之上);顶边使用 stroke、strokeWidth、strokeDasharray | fill 为 currentColor(有背景时为 none);没有描边 |
Grid | stroke、strokeWidth、strokeDasharray | currentColor,宽 1,实线;每条线都落在整数设备像素上,细线也保持清晰 |
plot 没有设置定义域时,Line 和 Area 绘制的数值(包括 area 的 y0:基线,或每一行的底值)构成 y 比例尺的定义域;grid 的数值不计入。图形只在要画的内容变化时重绘(数据行、取值、曲线、plot 的尺寸或比例尺、绘制属性);指针只移动悬停部件,从不让图形重绘。
带是 y0 读取数据行的 Area:顶边经过 y1,底边再经过 y0 回到起点,两条边都使用 area 的 curve,出现动画从起始边缘同时画出两条边。它的悬停圆点位于顶边;叠加一条 Line 标出中间的值:
<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 与浏览器中一样是 CSS 的 fill:一个颜色。折线下方的渐变是 area 的背景,就像网页在形状里显示渐变那样(盒子的 background-image 加上 clip-path):backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" 从 area 的最高点渐隐到基线,与任何视图的背景一样使用主题令牌和当前外观的颜色。
#Bar
Bar 在 band 比例尺上为每一行在该行的条带里画一根柱子:x 比例尺是 band 时柱子竖立,y 比例尺是 band 时柱子横放(此时 x 比例尺是 linear)。每根柱子从 base 延伸到该行在另一个比例尺上的值,所以负值朝反方向生长;没有值的行没有柱子。在反向的数值比例尺上,柱子从顶边向下或从右边缘向左生长;反向的 band 比例尺把第一行的条带放在右侧或底部(反向比例尺)。
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>
不在堆叠里的柱子按图形的顺序在每个条带里并排;stack 相同的柱子堆叠起来,每一行的柱子从堆叠中前一根柱子的末端开始:
<Bar value="online" stack="sales" class="fill-chart-1" />
<Bar value="store" stack="sales" class="fill-chart-2" cornerRadii={{ topLeft: 4, topRight: 4 }} label={(d) => `${d.online + d.store}`} />
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | string | (d, index) => number | plot 的 y | 每根柱子显示的值。 |
base | number | string | (d, index) => number | 0 | 柱子在数值比例尺上生长的起点:所有行共用一个值,或用字段、函数给每一行各自的起点,这时每根柱子从该行的起点延伸到它的值(没有起点的行没有柱子)。计入定义域。堆叠逐行从它的第一根柱子的 base 开始。 |
stack | string | 无 | 加入的堆叠:stack 相同的柱子按顺序堆叠(没有值的行不增加高度)。 |
scale | "y" | "y2" | "y" | 竖立柱子的值沿哪个 y 比例尺放(双轴);两个比例尺上的柱子并排,堆叠只包含同一比例尺上的柱子。 |
cornerRadii | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 0 | 柱子每个角的半径;{ topLeft: 4, topRight: 4 } 让竖立的柱子顶端变圆。超过柱子一半的半径会缩小以适应柱子。 |
stagger | number | 0 | plot 出现时,柱子按行依次开始生长,开始时间分布在出现动效的这一比例内(0 到 0.95),每根柱子用剩下的时间长完;为 0 时一起生长。 |
inactiveOpacity | number | 无 | 指针在 plot 上时,指针下那一行以外的柱子的不透明度,让那一行突出:随悬停淡入淡出,并随悬停从一根柱子交给下一根。 |
label | string | (d, index) => string | 无 | 每根柱子数值末端的文字:字段(它的文字或数字),或返回应用格式化后文字的函数。 |
labelClass、labelStyle | string、StyleProp<TextStyle> | 无 | label 部件:以柱子为中心的文字,位于竖立柱子末端的上方(向下生长时在下方),横放柱子末端之后(向回生长时在之前)。它的内边距就是与柱子的距离。 |
backgroundClass、backgroundStyle | string、StyleProp<ViewStyle> | 无 | 它的背景:每根柱子的盒子,盒子的背景(bg-linear-to-t from-… to-…,从柱子底部到顶部)只在柱子内显示,位于 fill 之下。设置背景后,fill 没有设置时为 none。 |
color | string | (d, index) => string | 无 | 每根柱子的类别,颜色来自 plot 的 colorScale:在没有任何设置时作为柱子的 fill(见按类别着色)。 |
band 比例尺像 d3 的 scaleBand 一样,按顺序为 plot 的每一行布置一个条带,或为 domain 的每个类别布置一个条带:
| 选项 | 默认值 | 说明 |
|---|---|---|
domain | 无 | 各条带的类别,按顺序排列(同 d3 的 scaleBand().domain()):Cell 放进它所在行的类别的条带,PlotAxis 为每个条带写上它的类别;重复出现的类别保留第一次的位置。没有设置时,条带就是各行;有格子时,是格子沿这条轴读出的类别,按数据行带来它们的顺序排列。 |
paddingInner | 0 | 相邻条带之间的间隙,占每个条带步长的比例(0 到 1)。 |
paddingOuter | 0 | 第一个条带之前和最后一个条带之后的间隙,占一个步长的比例。取 paddingInner 的一半时,各条带的中线在 plot 上等距分布。 |
maxBandWidth | 无 | 条带的最大宽度(点):宽 plot 上只有几个条带时保持窄,每个条带位于自己位置的中间。 |
bandCount | 行数 | 条带多于行数时布置的条带数:各行占据前面的条带,数据行增加时保持宽度和位置。 |
groupPadding | 0 | 同一条带里并排的柱子之间的间隙,占每根柱子位置的比例。 |
reverse | false | 条带从另一条边开始排列:x 轴上第一行的条带在右侧,y 轴上在底部;同一条带里并排的柱子保持原来的顺序。 |
柱子使用自己的 fill 着色(fill-*;像 CSS 那样从 plot 继承),没有设置时为 currentColor(设置 color 时为它的类别的颜色),没有悬停圆点。在 band 比例尺上,悬停会找到指针下的条带,plot 的十字线位于这个条带里、与条带等宽:crossLineClass="w-full h-full bg-foreground/5" 给条带上色,w-px 则在条带中间保留一条线。plot 没有设置定义域时,柱子的数值和 base 构成数值比例尺的定义域。plot 出现时,每根柱子从它所在堆叠的 base 长出;动效、变淡和滑动都在平台的动画器上运行,指针从不让柱子重绘。
#区间
base 是字段或函数时,每根柱子跨越它所在行的两个值:瀑布图的一级从它之前的累计值到之后的累计值,K 线的实体从开盘价到收盘价,一项任务从开始到结束。出现时柱子从自己的起点长出,标签位于数值末端:上升的柱子在上方,下降的柱子在下方。
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);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>
漏斗图就是居中的横放柱子:在 band y 比例尺上用 base={(d) => -d.users / 2} 和 value={(d) => d.users / 2}。
#Rule
Rule 沿数值轴为每一行画一条直线,从 base 到该行的值:在 band 比例尺上位于该行条带的中间(band y 比例尺上横跨 plot),在 point 或 linear 比例尺上位于该行在 x 轴上的位置。K 线的影线、箱线图的须、误差线和棒棒糖图的杆都是 rule;K 线图就是 Bar 叠在 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>
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | string | (d, index) => number | plot 的 y | 每条线的终点。没有值的行没有线。 |
base | number | string | (d, index) => number | 0 | 每条线的起点:所有行共用一个值(棒棒糖图从零线开始的杆),或用字段、函数给每一行各自的起点(K 线的最低价)。计入定义域。 |
scale | "y" | "y2" | "y" | 它的值沿哪个 y 比例尺放(双轴)。 |
stagger | number | 0 | plot 出现时,线按行依次开始生长,开始时间分布在出现动效的这一比例内(0 到 0.95),每条线用剩下的时间从起点长完。 |
inactiveOpacity | number | 无 | 指针在 plot 上时,指针下那一行以外的线的不透明度,与 Bar 的相同。 |
color | string | (d, index) => string | 无 | 每条线的类别,颜色来自 plot 的 colorScale:在没有任何设置时作为线的 stroke。 |
rule 使用自己的 stroke 描边(stroke-*、strokeWidth、strokeDasharray;像 CSS 那样从 plot 继承),没有设置时为 currentColor,没有悬停圆点。plot 没有设置定义域时,它的数值和 base 构成数值比例尺的定义域;plot 出现时它从起点长出,并随比例尺定义域的变化移动,都在平台的动画器上运行。
#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);Dot 在每一行的两个数值交汇处画一个圆点:行的 x 落在 linear x 比例尺上、value 落在 y 轴上,就是散点图;在 band x 比例尺上,每个圆点位于所在行条带的中间;横跨 band y 比例尺时(x 比例尺为 linear),数值沿 x 轴排布、行的条带沿 plot 向下排列,就是点图。半径 r 可以是所有圆点共用的一个数,也可以是每一行的一个数值,由 rScale 换算成半径,就是气泡图。没有值的行没有圆点。
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>
r 是字段或函数时,每一行的数值决定自己圆点的大小。rScale={{ type: "sqrt", range: [min, max] }} 把数值的平方根映射到以点为单位的半径上,同 d3 的 scaleSqrt,所以值域从 0 开始时,气泡的面积与数值成正比;值域从大于 0 开始时,最小的气泡也清晰可见。比例尺没有给出定义域时,定义域是 0 到最大的数值。较大的圆点先画,较小的圆点留在它们上面清晰可见:
<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>
plot 没有设置定义域时,圆点的数值构成数值比例尺的定义域,且不会延伸到 0,所以散点图的坐标轴贴合它的点。圆点较大时,给 plot 设置在数值四周留有余量的 domain:位于定义域边缘的气泡会画过 plot 的边缘。
同一组条带里的两个图形组合起来,就是比较一行中两个数值的图表:棒棒糖图是从 0 开始的 Rule 加上位于数值处的 Dot;哑铃图是从一个数值到另一个数值的 Rule,两端各有一个 Dot,横跨 band y 比例尺:
<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>
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | string | (d, index) => number | plot 的 y | 每个圆点沿数值轴的位置:沿 y 轴向上,横跨 band y 比例尺时沿 x 轴。没有值的行没有圆点。 |
r | number | string | (d, index) => number | 3 | 圆点的半径(点),或用字段、函数给每一行一个数值,由 rScale 换算成半径(没有 rScale 时数值就是半径)。缺少数值的行没有圆点。 |
rScale | { type: "sqrt"; domain?: [number, number]; range: [number, number] } | 无 | r 的数值如何变成半径:数值的平方根在 domain(没有设置时为 0 到最大的数值)上映射到半径的 range,所以值域从 0 开始时,每个圆点的面积与数值成正比。定义域之外的数值取值域的两端。 |
stagger | number | 0 | plot 出现时,圆点按行依次开始长出,开始时间分布在出现动效的这一比例内(0 到 0.95);为 0 时一起长出。 |
inactiveOpacity | number | 无 | 指针或按键停在一个圆点上时,其他圆点的不透明度:随悬停淡入淡出。 |
color | string | (d, index) => string | 无 | 每个圆点的类别,颜色来自 plot 的 colorScale:在没有任何设置时作为圆点的 fill(按类别着色)。 |
hitRadius | number | 8 | 指针在圆点边缘之外多远仍能找到它:在这个距离之内的圆点中,取圆心离指针最近的那个(Voronoi 单元),所以叠在大气泡上的小气泡在它自己的圆心附近就能找到。点图把它设得和一行一样大,指针在一行的任何位置都能找到这一行。 |
scale | "y" | "y2" | "y" | 它的值沿哪个 y 比例尺放(双轴);在 band y 比例尺上横向排列的圆点把值放在 x 上。 |
hoverDotClass、hoverDotStyle | string、StyleProp<ViewStyle> | plot 的 dotClass、dotStyle | hoverDot 部件:与指针下圆点同宽同高、以它为中心的视图。rounded-full outline-2 outline-offset-1 给它加一圈描边;hidden 表示这个图形不显示。 |
圆点使用自己的 fill 填充(fill-*;像 CSS 那样从 plot 继承),没有设置时为 currentColor(设置 color 时为类别的颜色);设置了 stroke 时用它描边(stroke-*、strokeWidth):一圈卡片颜色的描边能让重叠的圆点彼此分开。在有圆点的 plot 上,plot 报告圆心离指针最近的圆点所在的行(远离所有圆点时为 null),按键按行的顺序切换(按 x 给行排序,按键就从左到右依次经过圆点),hoverDot 部件标出这个圆点。plot 出现时,圆点从无到有长出;有 motion.update 时,圆点过渡到新的数值和大小,新行的圆点长出,已移除的行的圆点缩小消失;都在平台的动画器上运行,不需要重新绘制图形。
#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);Cell 为每一行同时在两个条带里画一个矩形:它的 x 类别在 band x 比例尺上的条带,和它的 y 类别在 band y 比例尺上的条带,于是各行组成一张网格,即热力图。相关系数矩阵、月度收益日历和一周里每小时的成交量都是格子。每个格子铺满它的两个条带,所以两个比例尺的 paddingInner 和 paddingOuter 就是格子之间和四周的间隙。它的颜色是它的 value 在 plot 的 colorScale 色阶上对应的颜色(见按色阶着色),或它的 color 类别在序数比例尺上对应的颜色。格子放在 plot 的 x 和 y 比例尺上。
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>
band 比例尺的 domain 按顺序给出各条带的类别,同 d3 的 scaleBand().domain():日历的月份从一月到十二月,每个月份都有自己的条带,无论它的数据行是否已经到来,所以上例中 2026 年 3 月是网格里的一个空位。没有 domain 时,条带就是格子沿这条轴读出的类别,按数据行带来它们的顺序排列(2025 在 2026 上方)。沿有类别的 band 比例尺放置的 PlotAxis 不需要 format,就会为每个条带写上它的类别。
相关系数矩阵的两条轴是同一组持仓,domain 取系数的完整范围,所以无论是哪些持仓,系数 0 都取色阶的中间色:
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>
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
x | string | (d, index) => string | 无 | 每一行所在的列:字段(它的文字或数字),或返回类别的函数。这一行的格子放进 x 比例尺上这个类别的条带;没有类别的行没有格子。 |
y | string | (d, index) => string | 无 | 每一行所在的行,与 x 相同,沿 y 比例尺。 |
value | string | (d, index) => number | 无 | 每个格子的数值,由 sequential 或 diverging 的 colorScale 换算成它的颜色。没有数值的行取比例尺的 unknown 颜色,没有 unknown 时没有格子。 |
color | string | (d, index) => string | 无 | 每个格子的类别,颜色来自 ordinal 的 colorScale(见按类别着色)。 |
cornerRadii | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 0 | 格子每个角的半径。 |
stagger | number | 0 | plot 出现时,格子按行依次开始长出,开始时间分布在出现动效的这一比例内(0 到 0.95),每个格子用剩下的时间从中心长完;为 0 时一起长出。 |
inactiveOpacity | number | 无 | 指针或按键停在一个格子上时,其他格子及其标签的不透明度:随悬停淡入淡出。 |
label | string | (d, index) => string | 无 | label 部件:每个格子中央的文字,可以是字段(它的文字或数字),或返回应用格式化好的文字的函数。每个标签在排版后的大小放得进格子时显示,所以窄的 plot 只显示颜色。 |
labelClass、labelStyle | string、StyleProp<TextStyle> | 无 | 标签的字体、字号和颜色。格子在 colorScale 上的颜色偏暗时(浅色文字在上面更清楚),它的标签带有 data-dark 属性,所以 data-dark:text-white 在那里把文字变浅。 |
hoverCellClass、hoverCellStyle | string、StyleProp<ViewStyle> | 无 | hoverCell 部件:与指针或按键所在的格子一样大、盖在它上面的视图;rounded-sm outline-2 outline-foreground 给它加一圈描边。它在同一帧里跟随指针,按键移动它时按 pointer 弹簧滑过去。 |
在应用没有设置 fill 时(fill-* 类和样式优先,像 CSS 那样从 plot 继承),格子用它在 plot 的 colorScale 上的颜色填充,什么都没有设置时为 currentColor;设置了 stroke 时用它描边。在有格子的 plot 上,plot 报告指针下的格子所在的行,在两个格子之间的间隙里取较近的那个;方向键按屏幕上的网格移动(键盘),屏幕阅读器按行的顺序浏览。plot 出现时,格子从中心长出;有 motion.update 时,格子的颜色过渡到新颜色,新行的格子长出,已移除的行的格子缩小消失,格子移动到新的条带;都在平台的动画器上运行,不需要重新绘制图形。
#Pie 和 Arc
Pie 把一整圈按行分成扇区,每个扇区占各行数值中的份额,从 12 点钟方向开始按行的顺序顺时针排列,围绕 plot 的中心。设置 innerRadius 即为环形图。每个扇区都是一个独立的画布,也就是 slice 部件,所以每个扇区可以用自己的类着色:sliceClass 可以是所有扇区共用的类,也可以是扇区所在行的函数,为每个扇区给出各自的类。在饼图上,plot 报告指针下扇区所在的行(在中间的空洞里或圆环外则没有),提示框跟随指针;该扇区按 hoverLift 变大,其他扇区按 hoverDim 变淡,由平台的动画器按 plot 的 motion 推动。Arc 绘制两个角度之间的一段扇形:仪表的轨道和数值,或饼图后面的圆环。
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={`按套餐划分的席位,共 ${total()} 个`}
accessibilityValue={{ text: shown() && `${shown()!.name},${shown()!.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"
>
{/* 扇区之间留 2 点的间隙(外缘 80 点处为一圈的 0.025)。 */}
<Pie
value="seats"
innerRadius="62%"
padAngle={0.025}
sliceClass={(_, i) => FILLS[i % FILLS.length]}
hoverLift={6}
hoverDim={0.64}
/>
{/* 空洞里显示悬停的套餐,或总数。 */}
<View class="absolute inset-0 items-center justify-center gap-1">
<Text class="text-xs text-muted-foreground">{shown()?.name ?? "总计"}</Text>
<Text class="text-2xl font-semibold tabular-nums text-foreground">{shown()?.seats ?? total()}</Text>
</View>
</Plot>
{/* 图例:每个色块使用对应扇区的颜色,数值和占比靠右。 */}
<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("按套餐划分的席位")
.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())),
)
}
| 图形 | 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|---|
Pie | value | .value(..) | keyof T | (d: T, index: number) => number | plot 的 y | 每一行的数值。数值为零、为负或不是数字的行没有扇区。 |
Pie、Arc | startAngle、endAngle | .start_angle(..)、.end_angle(..) | number(弧度) | 0、2 * Math.PI | 第一个扇区开始和最后一个扇区结束的位置(arc 的两端):从 12 点钟方向顺时针的弧度。endAngle 小于 startAngle 时,饼图的扇区反向排列。 |
Pie | padAngle | .pad_angle(..) | number(弧度) | 0 | 相邻扇区在外缘处的间隔角度。每条间隙在各个半径上都保持这个宽度(约 padAngle * outerRadius 点),两边平行,所以在圆心或空洞处与外缘处一样宽;太窄而到不了圆心的扇区在两边相交处收尾。只有一个扇区的饼图没有间隙。 |
Pie、Arc | innerRadius、outerRadius | .inner_radius(..)、.outer_radius(..) | number | `${number}%` | 0、"100%" | 空洞和外缘的半径:点数,或 plot 能容纳的最大圆(其较短边的一半)的百分比。 |
Pie、Arc | cornerRadius | .corner_radius(..) | number | 0 | 每个扇区(arc)圆角的半径,单位为点,同 d3 的 arc.cornerRadius:每个圆角都是一个与边和外缘或空洞边缘相切的圆,间隙的平行边也一样。它最大为圆环厚度的一半,此时 arc 两端是圆头;扇区太窄、放不下两个完整圆角时,两个圆角在扇区中线相接。完整的圆环只保留它的圆形边缘。 |
Pie | sliceClass | .slice_class(..) | string | (d: T, index: number) => string | 无 | 每个扇区的类(fill-*、stroke-*):所有扇区相同,或扇区所在行及其序号的函数;数据行或函数变化时,每行调用一次。 |
Pie | sliceStyle | .slice_style(..) | StyleProp<ViewStyle> | 无 | 所有扇区的样式,优先于它们的类(fill、stroke、strokeWidth)。 |
Pie | hoverLift | .hover_lift(..) | number | 0 | 指针下的扇区比其他扇区大出的距离,单位为点:按 plot 的 pointer 弹簧变化,随悬停淡入淡出。 |
Pie | hoverDim | .hover_dim(..) | number(0 到 1) | 0 | 有扇区位于指针下时,其他扇区变淡的程度,即不透明度降低的比例,随悬停淡入淡出。 |
Pie | color | .color(..) | keyof T | (d: T, index: number) => string | 无 | 每个扇区的类别,颜色来自 plot 的 colorScale:在没有任何设置时作为扇区的 fill(见按类别着色)。 |
Pie | label | .label(..) | string | (d: T, index: number) => string | 无 | 每个扇区旁边的文字:一个字段(取其数值),或返回应用格式化文字的函数。文字为空的行、窄于半度的扇区没有标签。 |
Pie | labelClass、labelStyle | .label_class(..)、.label_style(..) | string、StyleProp<TextStyle> | 无 | label 部件:圆环外、引导线末端处的文字,在末端处垂直居中;在饼图右半边时位于末端之后,左半边时位于末端之前。它的内边距就是与引导线末端的距离。 |
Pie | labelGap | .label_gap(..) | number | 15 | 标签在外缘之外的距离,单位为点。 |
Pie | labelLineClass、labelLineStyle | .label_line_class(..)、.label_line_style(..) | string | (d: T, index: number) => string、StyleProp<ViewStyle> | 无 | labelLine 部件:每个标签的引导线,从扇区中线处的外缘向外到标签所在的半径,再横向连到文字。它的 stroke(没有设置时为 currentColor)、strokeWidth 和 strokeDasharray,所有引导线相同,或扇区所在行的函数;设置样式后才显示。 |
扇区和 arc 用 fill 填充(没有设置时为 currentColor,设置 color 的扇区为它的类别的颜色),用 stroke 描边(没有设置时不描边);像 CSS 的继承一样,自己设置的值优先于饼图和 plot 上的值:使用界面背景色的描边(stroke-card stroke-2)同样可以在扇区之间切出间隙。它们的数值不计入 plot 的比例尺。饼图的扇区只在数据行、数值、角度、半径或 plot 的尺寸变化时重绘;指针只通过动画器推动抬起和变淡。
饼图的数据行在坐标轴上没有位置,所以带饼图的 plot 通常把提示框放在指针旁边(tooltipPlacement="pointer"):十字线和提示框跟随指针,没有悬停圆点。环形图中间的内容是应用自己叠放在 plot 上的元素(悬停扇区的数值或总数),图例也是,每个色块使用对应扇区的颜色。
饼图的标签在圆环外标出各个扇区:每段文字位于扇区中线方向、外缘之外 labelGap 处(即该半径上的扇区质心),再横向移到扇区所在一侧的同一半径处;设置样式的引导线把它和外缘连起来。标签随扇区在 plot 出现时扫入、在出现的末段淡入,随 motion.update 跟着扇区移动,并随悬停的扇区一起抬起;这些都在平台的动画器上运行。请为标签留出空间:outerRadius 小于 "100%",或在 plot 周围留白。应用自己要放在扇区处的内容,是叠放在 plot 上的元素,就像环形图中间的内容一样。
<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"
/>
#按类别着色
plot 的 colorScale 为类别分配颜色,同 d3 的 scaleOrdinal;图形的 color 给出每一行的类别:字段(它的文字或数字),或行的函数。domain 中的类别按顺序取 range 中的颜色;定义域比值域长时,值域从第一个颜色重新开始。定义域之外的类别取 unknown;没有设置时,它在第一次被图形遇到时加入定义域(图形按顺序,每个图形的行按顺序),取下一个颜色,所以完全没有 domain 时,各类别按数据行带来它们的顺序取色。颜色由应用决定:CSS 颜色,包括主题的 token。
import { Pie, Plot } from "nsh/base/plot";
import { token } from "nsh";
const regions = [
{ name: "北美", revenue: 482 },
{ name: "欧洲、中东和非洲", revenue: 312 },
{ name: "亚太", 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>
扇区、柱子和圆点取自己所在行的颜色;Line 和 Area 各是一个系列,取第一行的颜色,所以使用自己数据行(data)的折线就是这些行的类别的颜色,同一个比例尺让一个类别在饼图、柱子和折线上保持同一种颜色。这个颜色在没有任何设置时作为图形的 fill(折线的 stroke),取代 currentColor:应用的类和样式优先,包括部件的(sliceClass、sliceStyle)、图形自己的以及它从 plot 继承的,所以 sliceClass={(d) => (d.name === "其他" ? "fill-muted" : "")} 能让一个扇区与众不同,其余扇区仍由比例尺着色。有 background 部件时,柱子和区域的 fill 保持为 none。
OrdinalScale | 类型 | 默认值 | 说明 |
|---|---|---|---|
type | "ordinal" | (必填) | 比例尺的种类。 |
domain | string[] | 无 | 类别,按顺序排列。重复出现的类别保留第一次的位置。 |
range | string[] | (必填) | 颜色,按顺序排列(CSS 颜色);第 i 个类别取第 i % range.length 个颜色。 |
unknown | string | 无 | 定义域之外每个类别的颜色;没有设置时,每个类别取下一个颜色。 |
#按色阶着色
type: "sequential" 或 "diverging" 的 colorScale 沿色阶为数值分配颜色,同 d3 的 scaleSequential 和 scaleDiverging,用于 Cell 的 value。它的 range 是两个或更多颜色,从色阶的低端到高端均匀分布,相邻颜色之间在 OKLab 中混合:每一级看起来变化均匀,两种色相经由它们自己的混合色相接。超出 domain 的数值取色阶的端点,没有数值的行取 unknown。
sequential 把 domain 铺满整条色阶,应用没有给出定义域时取格子数值的范围:数量、成交量、活跃度。diverging 把 pivot(默认为 0)放在色阶的中间色上,低于它的数值沿前半段排布,高于它的沿后半段:亏损一种色相,盈利另一种,持平是中性的中间色。应用没有给出定义域时,它取数值的范围并在 pivot 两侧取成对称(Observable Plot 的 symmetric),所以同样大小的亏损和盈利颜色一样深;相关系数矩阵写 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 | "sequential" | "diverging" | (必填) | 色阶的种类。 |
domain | [number, number] | 数值的范围(diverging 在 pivot 两侧取成对称) | 色阶两端的数值。 |
pivot | number | 0 | 仅 diverging:色阶中间色对应的数值。 |
range | string[] | (必填) | 沿色阶从低端到高端的颜色(CSS 颜色),相邻颜色之间在 OKLab 中混合。 |
unknown | string | 无 | 没有数值的行的颜色;没有设置时,这一行没有格子。 |
#RadialLine 和 RadialGrid
RadialLine 围绕 plot 的中心绘制一个系列:雷达图的多边形,或沿表盘的一条线。每个有值的行是一个点,位于该行的角度上,离中心的距离由 plot 的 y 比例尺根据它的值决定,从 innerRadius(定义域的最小值)到 outerRadius(定义域的最大值)。没有 angle 时,各行按顺序从 12 点钟方向顺时针均匀地绕一圈排列,所以六行就是一个六边形;closed 把最后一个点连回第一个点。它描出各点之间的线,并把区域填充到 innerRadius(默认是中心),所以一个图形就是雷达图的轮廓和它的淡色填充。RadialGrid 在它后面绘制雷达图的网格:rings 是 y 比例尺上各个值处的圆环(穿过各行角度的多边形,或 circular 的圆),spokes 是通向每一行的辐条。在径向线上,plot 报告角度离指针最近、且在该线外半径以内的那一行,每条线的悬停圆点位于它在这一行的点上。
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},对手 ${shown()!.theirs}` : "我们与对手"}
</Text>
<Plot
data={props.metrics}
yScale={{ type: "linear", domain: [0, 100] }}
accessibilityLabel="我们与对手在六项指标上的对比"
accessibilityValue={{ text: shown() && `${shown()!.name}:我们 ${shown()!.ours},对手 ${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" />
{/* 每项指标的名称放在外缘之外、该行的角度上,朝远离中心的方向展开。 */}
{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>
);
}
| 图形 | 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|---|
RadialLine | radius | .radius(..) | keyof T | (d: T, index: number) => number | plot 的 y | 决定每一行离中心多远的取值:y 比例尺上的值,定义域最小值在 innerRadius,最大值在 outerRadius。没有值的行跳过。 |
RadialLine | angle | .angle(..) | keyof T | (d: T, index: number) => number | 各行均匀绕一圈 | 每一行在中心周围的位置:从 12 点钟方向顺时针的弧度。 |
RadialLine、RadialGrid | startAngle、endAngle | .start_angle(..)、.end_angle(..) | number(弧度) | 0、2 * Math.PI | 没有 angle 时各行的位置:从 startAngle 开始均匀排列。绕一整圈时相邻两行相隔一圈的 1 / 行数,最后一行在第一行之前一步;只占一圈的一部分时,第一行在 startAngle,最后一行在 endAngle。 |
RadialLine、RadialGrid | innerRadius、outerRadius | .inner_radius(..)、.outer_radius(..) | number | `${number}%` | 0、"100%" | y 定义域最小值和最大值所在的半径:点数,或 plot 能容纳的最大圆(其较短边的一半)的百分比,与饼图的半径相同。线的区域填充到 innerRadius;网格的辐条在两者之间。 |
RadialLine | curve | .curve(..) | "linear" | "natural" | "stepAfter" | "linear" | 点与点之间如何连接:直线段、平滑曲线(闭合时是平滑的环),或阶梯。 |
RadialLine | closed | .closed(..) | boolean | false | 从最后一个点继续连回第一个点:雷达图的多边形。不闭合时,它的区域收拢到中心(或沿内缘回到起点)。 |
RadialLine | dot、dotClass、dotStyle | .dot(..)、.dot_class(..)、.dot_style(..) | boolean、string、StyleProp<ViewStyle> | false | 在每个点上显示圆点:以点为中心的视图,尺寸、背景和边框由应用决定。圆点随线一起长出和移动。 |
RadialLine | backgroundClass、backgroundStyle | .background_class(..)、.background_style(..) | string、StyleProp<ViewStyle> | 无 | 它的背景:包围外圆的盒子,盒子的背景(bg-*、渐变)只在它的区域内显示,位于 fill 之下,与 area 的背景相同。 |
RadialLine | hoverDotClass、hoverDotStyle | .hover_dot_class(..)、.hover_dot_style(..) | string、StyleProp<ViewStyle> | plot 的 dotClass、dotStyle | 它的悬停圆点,位于它在指针下那一行的点上,与指针在同一帧移动;按键切换行时按 plot 的 pointer 弹簧滑到下一行;hidden 表示不显示。 |
RadialGrid | rings | .rings(..) | number[] | 无 | 圆环的位置,取 y 比例尺上的值:每个圆环离中心的距离与径向线对这个值的距离相同。 |
RadialGrid | spokes | .spokes(..) | boolean | false | 在每一行的角度上,从 innerRadius 到 outerRadius 画一条线。 |
RadialGrid | circular | .circular(..) | boolean | false | 圆环画成圆(只占一圈的一部分时是圆弧)。默认是穿过各行角度的多边形,plot 少于三行时是圆。 |
径向线用 stroke、strokeWidth 和 strokeDasharray 描边(没有设置时为 currentColor,宽 1,实线),用 fill 填充区域(没有设置时不填充);径向网格的描边与网格相同。它们的绘制属性从 plot 继承。plot 没有设置定义域时,线的数值连同 0 构成 y 比例尺的定义域,所以全为正值的雷达图从中心长出;网格的圆环不计入。数值变化时希望圆环和雷达图的刻度保持不动,就为 plot 指定 y domain。
雷达图周围的标签是应用自己叠放在 plot 上的元素,就像环形图中间的内容一样。没有 angle 时,n 行中的第 i 行位于从 12 点钟方向起的 a = 2π·i / n,所以在正方形的 plot 里,放在 left: 50% + r·sin(a)、top: 50% − r·cos(a) 的无尺寸盒子(r 是 plot 边长的百分比,比外半径所占比例的一半稍大)标出外缘之外的位置,名称从这里朝远离中心的方向展开:右半边向右,左半边向左,顶部向上,底部向下,如上例。
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("我们与对手")
.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
Sankey 绘制 plot 各行之间的流向:每一行是一个节点,links 列出它们之间的流,{ source, target, value },前两项是两行在 plot 的 data 中的序号。它按 d3-sankey 的方式在 plot 的尺寸内布局:节点按列排列(nodeAlign),每个节点的高度取流入和流出中较大的一方,同一列的节点相隔 nodePadding,再经过 iterations 轮松弛向各自的流靠拢;每条 link 是一条流带,从源节点的右边缘连到目标节点的左边缘,两端的宽度各是它在那一侧所占的份额,所以即使节点的流入和流出不相等,它的两侧也都被流带铺满。每一列在 plot 中垂直居中;连续几列都只有一个等高节点时,这些列会交错错开一点,让它们之间的流带弯曲起来。valueScale="sqrt" 按数值的平方根决定尺寸,避免一条特别大的流把小的流压得看不见。
每个节点和每条流带都是一个独立的画布,即 node 和 link 部件,所以各自可以用自己的类着色:nodeClass 可以是所有节点共用的类,也可以是节点所在行的函数;linkClass 可以是所有流带共用的类,也可以是 link 的函数。linkBackground 部件在每条流带内显示背景,背景框从源节点延伸到目标节点:用 link 的函数可以让每条流带显示从源节点颜色到目标节点颜色的渐变。流带画在节点下面,两者都随 plot 的出场动画从起始边缘画入。
在 sankey 上,plot 报告指针下的节点(onHoverChange 报告节点所在的行;在流带上则没有),提示框跟随指针,不与该节点相连的流带按 hoverDim 变淡,由平台的动画器按 plot 的 motion 推动。方向键按行的顺序在节点之间移动,提示框位于节点中点旁边。nodeLabel 为每个节点标上名称,valueLabel 给出它的数值,两者都设置时数值在上、名称在下:第一列的节点标在左边,最后一列的标在右边,中间各列的居中标在节点上方,与 gpui-component 的图表一致;节点旁边的标签保持在 plot 的上下边缘之内,超出 plot 边缘的标签位于应用在它周围留出的空间(外边距)里。nodeLabel 部件的内边距就是它与节点的间距,它的类决定文字样式(max-w-20 truncate 截断过长的名称);标签随出场动画淡入。应用改变流时,每个节点和流带都按 plot 的 motion.update 从旧位置移动到新位置,标签随之移动:新出现的节点从中点长出,新出现的 link 从最小宽度展开,消失的立即移除;过渡途中再次改变时,从当前绘制的位置继续。link 按源节点和目标节点区分,所以调整 links 的顺序不会产生移动。
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 },
];
// 每一步的颜色:节点的填充色,以及从它出发、到它为止的流带渐变两端。
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")),
)
}
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
links | .links(..) | SankeyLink[]({ source: number; target: number; value: number }) | 必填 | 各条流:从 source 行流向 target 行(它们在 plot 的 data 中的序号),value 是流量。数值不是数字或小于零的 link 不承载流量,也不画流带。 |
nodeWidth | .node_width(..) | number | 24 | 每个节点的宽度,单位为点。 |
nodePadding | .node_padding(..) | number | 8 | 同一列节点之间的间距,单位为点;列太挤时会变小。 |
nodeAlign | .node_align(..) | "justify" | "left" | "right" | "center" | "justify" | 每个节点放在哪一列,同 d3-sankey:按它离第一列的深度("left"),按它的流所允许的最右位置("right"),按深度但只发出流的节点紧挨它的目标("center"),或按深度但只接收流的节点放在最后一列("justify")。 |
iterations | .iterations(..) | number | 6 | 让节点向各自的流靠拢的松弛轮数。 |
valueScale | .value_scale(..) | "linear" | "sqrt" | "linear" | 高度和宽度与数值成正比,或与数值的平方根成正比。 |
minLinkWidth | .min_link_width(..) | number | 1 | 流带的最小宽度,单位为点,让很小的流也看得见。 |
nodeCornerRadius | .node_corner_radius(..) | number | 0 | 每个节点圆角的半径,单位为点。 |
nodeClass | .node_class(..) | string | (d: T, index: number) => string | 无 | 每个节点的类(fill-*、stroke-*):所有节点相同,或节点所在行及其序号的函数;数据行或函数变化时,每行调用一次。 |
nodeStyle | .node_style(..) | StyleProp<ViewStyle> | 无 | 所有节点的样式,优先于它们的类。 |
linkClass | .link_class(..) | string | (link: SankeyLink, index: number) => string | 无 | 每条流带的类:所有流带相同,或 link 及其在 links 中序号的函数。 |
linkStyle | .link_style(..) | StyleProp<ViewStyle> | 无 | 所有流带的样式,优先于它们的类。 |
linkBackgroundClass | .link_background_class(..) | string | (link: SankeyLink, index: number) => string | 无 | 每条流带背景的类(bg-*,渐变:bg-linear-to-r from-… to-…),显示在流带内,背景框从源节点延伸到目标节点,位于流带的 fill 之下。设置了背景时,流带的 fill 在没有设置时为 none。 |
linkBackgroundStyle | .link_background_style(..) | StyleProp<ViewStyle> | 无 | 所有流带背景的样式,优先于它们的类。 |
hoverDim | .hover_dim(..) | number(0 到 1) | 0 | 有节点位于指针下时,不与它相连的流带变淡的程度,即不透明度降低的比例,按 plot 的 pointer 弹簧变化,随悬停淡入淡出。 |
nodeLabel | .node_label(..) | keyof T | (d: T, index: number) => string | 无 | 每个节点旁的名称:字段名,或节点所在行及其序号的函数。 |
valueLabel | .value_label(..) | (d: T, value: number, index: number) => string | 无 | 每个节点旁、名称上方的数值:节点所在行和流入、流出中较大一方(按 links 的原始数值,与 valueScale 无关)的函数。 |
nodeLabelClass | .node_label_class(..) | string | 无 | 每个标签的类,即 nodeLabel 部件:内边距是它与节点的间距,文字样式作用于两行文字。 |
nodeLabelStyle | .node_label_style(..) | StyleProp<TextStyle> | 无 | 每个标签的样式,优先于它的类。 |
valueLabelClass | .value_label_class(..) | string | 无 | 数值那一行的类,即 valueLabel 部件,优先于标签的类。 |
valueLabelStyle | .value_label_style(..) | StyleProp<TextStyle> | 无 | 数值那一行的样式,优先于它的类。 |
节点和流带用 fill 填充(没有设置时为 currentColor),用 stroke 描边(没有设置时不描边);像 CSS 的继承一样,自己设置的值优先于 sankey 和 plot 上的值。没有任何流经过的节点高 1 点。只有 links、选项或 plot 的尺寸变化时才会重新布局,指针和出场动画都不会触发布局;plot 的尺寸变化不会产生移动。构成环的 links,或指向 plot 中不存在的行的 links,什么也不画,并记录一条警告。它的数值不计入 plot 的比例尺。
#Treemap
Treemap 把 plot 的数据行画成嵌套矩形组成的层级:每一行是一个节点,它的 id 和它指向的 parent 组成一棵树(d3 的 stratify),每个叶子的面积与它的 value 成正比,每个组是它下面所有节点的外框,组的值是其叶子之和(不管组自己那一行写了什么)。没有 parent 的行,或 parent 指向不存在的行,是顶层节点;不给 id 时,行的 id 就是它的序号,所以 parent 也可以用序号指向父行。gpt-vis 写的嵌套形式({ name, value, children })由应用用几行代码拍平成数据行即可;层数没有上限。
矩形按 squarify 方式铺排(同 d3 的 treemapSquarify):每一层的节点按值从大到小、从左上角开始(sort="value"),长宽比接近 ratio(黄金比);sort="data" 保持数据行的顺序,值变化时节点的位置更稳定。paddingInner 是兄弟节点之间的缝,父节点的外缘不留;paddingOuter 是组内围绕子节点的留白;paddingTop 是每个组顶部的标题带,groupLabel 写在里面。三者都可以给一个数(所有层相同),或从顶层往下每层一个([3, 1]:顶层节点之间 3,下面各层 1)。同一个父节点的兄弟组,标题带要么都留要么都不留,有一个组放不下时都不留,这样同一层的组看起来一致。
值为 0、负数或不是数字的叶子不占面积:不画、没有标签、指针找不到它;叶子全是这种情况的组也一样。treemap 的面积是整体中的份额,被省略的部分由应用自己说明(地图下方的一行注释)。负数可以用来上色:市场热力图的当日涨跌。
节点的颜色来自 plot 的 colorScale:colorValue 是沿色带取色的数值(sequential、diverging:跌为红、涨为绿、持平为中性色;没有 domain 时取数值的范围),color 是类别(ordinal:"parent" 让同组的叶子同色)。叶子是 leaf 部件(leafClass、leafStyle),组是 group 部件(groupClass、groupStyle),只有设置了类或样式才画:组是位于子节点下面的矩形,露在缝里、留白里和标题带里。每个部件可以对所有节点相同,也可以是节点所在行的函数。cornerRadii 给所有矩形加圆角。
label 在每个叶子里写名字,valueLabel 在名字下面写数值,它是行和值的函数(组的值是其叶子之和):放在左上角(labelPlacement="start")或居中("center")。label 部件是一个和叶子一样大的框:它的内边距是离叶子边缘的距离,文字样式作用于各行。叶子比名字窄时名字用省略号截断,只在放得下时显示;数值要么完整显示,要么不显示。叶子颜色较深时标签带有 data-dark 属性,所以 data-dark:text-white 能让它在深红色上依然清晰。
在 treemap 上,plot 报告指针下的节点(onHoverChange,节点所在的行):叶子,或位于标题带、留白中的组;在兄弟节点之间的缝里取其中一个,所以悬停不会闪烁。其他节点淡到 inactiveOpacity(叶子保留它所在的组,组保留它下面的节点),和节点一样大的 hover 部件给它描边。方向键移动到屏幕上旁边的叶子,Home 和 End 移动到第一个和最后一个。plot 出场时顶层节点从中心长出,stagger 让它们依次出现;数据行变化时,每个节点按 id 对应,以 motion.update 移动到新的位置和颜色:新出现的节点从中心长出,消失的节点缩回自己的中心。plot 的 x 和 y 比例尺不使用。
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())),
)
}
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
id | .id(..) | keyof T | (d: T, index: number) => string | 行的序号 | 每行节点的 id:parent 指向的就是它,数据行变化时也靠它认出同一个节点。 |
parent | .parent(..) | keyof T | (d: T, index: number) => string | 无:每行都是顶层节点 | 每行父节点的 id;为空或不是任何行的 id 时是顶层节点。 |
value | .value(..) | keyof T | (d: T, index: number) => number | 必填 | 每个叶子的大小;组的值是其子节点之和。值为 0、负数或不是数字的叶子不占面积。 |
sort | .sort(..) | "value" | "data" | "value" | 兄弟节点按值从大到小从左上角开始,或保持数据行的顺序。 |
ratio | .ratio(..) | number | 黄金比 | 矩形追求的长宽比,至少为 1。 |
paddingInner | .padding_inner(..) | number | number[] | 0 | 兄弟节点之间的缝,单位为点,从顶层往下每层一个(更深的层用最后一个)。 |
paddingOuter | .padding_outer(..) | number | number[] | 0 | 每个组内围绕子节点的留白,按层给。 |
paddingTop | .padding_top(..) | number | number[] | 0 | 每个组顶部的标题带,放 groupLabel,按层给;同一父节点的兄弟要么都留要么都不留。 |
cornerRadii | .corner_radii(..) | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 直角 | 矩形每个角的半径。 |
color | .color(..) | keyof T | (d: T, index: number) => string | 无 | 每个节点的类别,ordinal 的 colorScale 为它取色。 |
colorValue | .color_value(..) | keyof T | (d: T, index: number) => number | 无 | 每个节点的数值,sequential 或 diverging 的 colorScale 把它变成颜色。 |
leafClass | .leaf_class(..) | string | (d: T, index: number) => string | 无 | 每个叶子的类(fill-*、stroke-*),所有叶子相同或是叶子所在行的函数。 |
leafStyle | .leaf_style(..) | StyleProp<ViewStyle> | 无 | 所有叶子的样式,优先于它们的类。 |
groupClass | .group_class(..) | string | (d: T, index: number) => string | 无 | 每个组的类,即子节点下面的矩形;只有设置了类或样式才画组。 |
groupStyle | .group_style(..) | StyleProp<ViewStyle> | 无 | 所有组的样式,优先于它们的类。 |
inactiveOpacity | .inactive_opacity(..) | number(0 到 1) | 无 | 有节点悬停时,其他节点及其标签的不透明度,随悬停淡入淡出。 |
label | .label(..) | keyof T | (d: T, index: number) => string | 无 | 每个叶子里的名字,单行,放不下时用省略号截断。 |
valueLabel | .value_label(..) | (d: T, value: number, index: number) => string | 无 | 每个叶子里名字下面的数值,只有完整放得下时才显示。 |
labelPlacement | .label_placement(..) | "start" | "center" | "start" | 标签放在叶子的左上角或居中。 |
labelClass | .label_class(..) | string | 无 | 叶子标签的类,标签是和叶子一样大的框:内边距和文字样式;深色叶子上带 data-dark:。 |
labelStyle | .label_style(..) | StyleProp<TextStyle> | 无 | 叶子标签的样式,优先于它的类。 |
valueLabelClass | .value_label_class(..) | string | 无 | 数值那一行的类,优先于标签的类。 |
valueLabelStyle | .value_label_style(..) | StyleProp<TextStyle> | 无 | 数值那一行的样式,优先于它的类。 |
groupLabel | .group_label(..) | keyof T | (d: T, index: number) => string | 无 | 每个组的名字,写在标题带里,放不下时用省略号截断。 |
groupLabelClass | .group_label_class(..) | string | 无 | 组标签的类,标签是和标题带一样大的框。 |
groupLabelStyle | .group_label_style(..) | StyleProp<TextStyle> | 无 | 组标签的样式,优先于它的类。 |
stagger | .stagger(..) | number(0 到 0.95) | 0 | 顶层节点依次出现:它们的开始时间分布在出场动画的这个比例里。 |
hoverClass | .hover_class(..) | string | 无 | hover 部件的类,一个和悬停节点一样大、盖在它上面的视图(rounded-xs outline-2 outline-foreground)。 |
hoverStyle | .hover_style(..) | StyleProp<ViewStyle> | 无 | hover 部件的样式,优先于它的类。 |
矩形用 fill 填充(没有设置时用 colorScale 的颜色,再没有时为 currentColor),用 stroke 描边(没有设置时不描边);像 CSS 的继承一样,自己设置的值优先于 treemap 和 plot 上的值。只有数据行、选项或 plot 的尺寸变化时才会重新布局,指针和出场动画都不会触发布局;plot 的尺寸变化不会产生移动。重复的 id、指向不存在的行的 parent,以及自己是自己祖先的行,都会记录一次警告:第一行保留这个 id,孤立的行作为顶层节点,成环的行不画。
#PlotAxis
PlotAxis 用某条边所对应比例尺的刻度为这条边加上标签:底边和顶边用 x 比例尺的刻度,左边和右边用 y 比例尺的刻度,就像 d3 的 axisBottom、axisTop、axisLeft 和 axisRight。刻度来自比例尺:linear 比例尺上是定义域内的整齐数值(1、2 或 5 乘以 10 的幂),point 或 band 比例尺上是数据行。标签是由 plot 的布局摆放的文字:应用决定它写什么、长什么样,布局负责把它放到位置上。每个标签沿边居中对齐自己的刻度。在底边和顶边上,第一个和最后一个只向内移动必要的距离,从而留在 plot 的两端之内;在左边和右边上,每个标签都对在自己的刻度上,读起来就是那条网格线的值;靠近两端的标签如果会超出 plot 的顶边或底边,plot 的线性 y 比例尺就从那条边向内缩进超出的距离(刻度在端点上时是标签高度的一半),所以每个标签都留在 plot 之内,对在自己的网格线上。
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" />
{/* plot 左侧是 0、1,000、2,000 和 3,000,下方每行一个星期几。 */}
<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>
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
placement | .placement(..) | "bottom" | "top" | "left" | "right" | "bottom" | 坐标轴所在的边:底边和顶边对应 x 比例尺的刻度,左边和右边对应 y 比例尺的刻度。 |
scale | .scale(..) | "y" | "y2" | "y" | 左侧或右侧坐标轴标注哪个 y 比例尺:plot 的 yScale,或第二个 y2Scale(双轴)。 |
labelPlacement | .label_placement(..) | "outside" | "inside" | "outside" | 标签的位置:在边的外侧、应用在 plot 周围留出的空间里,与图形互不遮挡;或在 plot 上沿边内侧排列,plot 保持完整的尺寸(在左边或右边时,每个标签位于自己网格线的上方)。 |
ticks | .ticks(..) | number | linear 比例尺上为 10(对齐的比例尺上为每一步),其他比例尺上为每一行 | 大约取多少个刻度:linear 比例尺上是定义域内的整齐数值,大约这么多个;point 或 band 比例尺上是从第一行到最后一行均匀分布的这么多行。 |
tickValues | .tick_values(..) | number[] | 无 | 刻度的数值,代替 ticks:比例尺上的值,point 或 band 比例尺上是行的序号([0, 3, 6] 每三行标一次)。定义域内的每个值各有一个刻度。 |
format | .format(..) | (value: number, index: number) => string | 在有类别的 band 比例尺上是条带的类别,其余为数值本身,带上刻度间距所需的小数位,千位分组 | 每个刻度的文字:应用以刻度的数值(point 或 band 比例尺上是行的序号)和它在坐标轴刻度中的序号为参数的函数。 |
labelClass、labelStyle | .label_class(..)、.label_style(..) | string、StyleProp<TextStyle> | 无 | label 部件:每个刻度一段文字,字体、颜色和内边距由应用决定;朝向 plot 一侧的内边距就是它与边的距离(底部坐标轴用 pt-2)。 |
tickClass、tickStyle | .tick_class(..)、.tick_style(..) | string、StyleProp<ViewStyle> | 无 | tick 部件:每个刻度在边上一个视图,以刻度为中心,朝标签一侧延伸(底部坐标轴用 h-1 w-px bg-border);设置样式后显示。 |
lineClass、lineStyle | .line_class(..)、.line_style(..) | string、StyleProp<ViewStyle> | 无 | line 部件:沿整条边、位于标签一侧的视图(底部坐标轴用 h-px bg-border);设置样式后显示。 |
标签在外侧时位于 plot 的外边距里,就像 d3 的坐标轴位于图表的边距里:plot 保持应用给它的尺寸,应用在坐标轴那一侧给它留出与标签一样深的外边距(下方是 pt-2 text-xs 的标签时用 mb-6),或者给它的父元素在那一侧加内边距。同一条边上的两个坐标轴各自标注自己的数值、使用自己的样式:比如区间两端的价格较淡,中间的较深。
format 在刻度或函数变化时,于下一轮对每个刻度调用一次,绘制和跟随指针时从不调用:请保持它是纯函数(同一个数值得到同一段文字)。没有它时,刻度按 d3 的写法显示:1,500、0.25、−2。在 Rust 中,plot_axis() 创建坐标轴,format 是 TickFormat::from_fn(|value, index| ..)。坐标轴不接收指针,屏幕阅读器读出的是 plot 而不是它的标签。
#PlotLabel
PlotLabel 在 plot 的若干点上写文字:峰值上方的说明、折线最后一个点旁的价格标签、网格线末端的注释。它的每个 texts 都用 plot 比例尺上的值指定自己的点,与图形相同:point 或 band 比例尺上是行的序号(band 比例尺上是该行条带的中点),linear 比例尺上是数值。文字的上边缘位于这个点上,align 让文字从这个点开始、以它为中心或在它这里结束。文字由 plot 的布局摆放,所以会跟随 plot 的尺寸;它们随比例尺的 domain 一起平滑过渡(motion.update),并随 appear 淡入。
import { Area, Line, Plot, PlotLabel } from "nsh/base/plot";
const rows = [
{ day: "Mon", visitors: 1820 },
{ day: "Tue", visitors: 2140 },
{ day: "Wed", visitors: 1960 },
{ day: "Thu", visitors: 2480 },
{ day: "Fri", visitors: 2310 },
];
const [peak, last] = [3, rows.length - 1];
<Plot data={rows} y="visitors" yScale={{ type: "linear", domain: [0, 3000] }} class="mr-12 h-40">
<Area class="fill-chart-1/20" />
<Line class="stroke-chart-1 stroke-2" />
{/* 峰值上方写出它的数值,最后一个点右侧放一个垂直居中的标签。 */}
<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>
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
texts | .texts(..) | PlotText[] | [] | 要写的文字,每段位于自己的点上。 |
scale | .scale(..) | "y" | "y2" | "y" | 文字的 y 取哪个 y 比例尺上的值(双轴)。 |
每段文字是一个 PlotText:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
text | string | 文字内容。 | |
x | number | 沿 x 轴的位置:x 比例尺上的值,point 或 band 比例尺上是行的序号(0.5 位于前两行正中间)。 | |
y | number | 上边缘沿 y 轴的位置:y 比例尺上的值。 | |
align | "left" | "center" | "right" | "left" | 文字沿 x 方向相对于点的位置:从点开始、以点为中心,或在点处结束。 |
class、style | string、StyleProp<TextStyle> | 无 | 这段文字自己的类名和样式,叠加在它从 label 继承的样式之上:它的盒子(标签的背景和内边距)以及它与点的距离。 |
外观由应用决定:label 的 class 给所有文字提供它们继承的文字样式(字体、字号、颜色),每段文字自己的 class 和 style 只作用于它自己。用 -translate-y-full 把文字放到点的上方,它的内边距就是它与点的间距(pb-1);折线最后一个点旁的标签位于应用在那一侧留出的外边距里(mr-12),与坐标轴的标签相同。在 Rust 中,plot_label() 创建 label,每段文字是 PlotText::new(text, x, y),配合 .align(PlotTextAlign::Center)、.class(..) 和 .style(..)。指针穿过这些文字作用到 plot 上,屏幕阅读器读出的是 plot,由它的 accessibilityLabel 说明这些文字标出的内容。
#动效
动效由应用决定,默认没有:一切立即显示。motion 使用 nsh 的时间参数({ duration, easing, delay } 和 { stiffness, damping, mass }):
| 键 | 类型 | 作用 |
|---|---|---|
appear | timing | 有 appearGeneration 时图形逐渐绘出:折线和区域从起始边缘绘出,柱子从 base 长出(柱子设置 stagger 时依次长出);圆点从无到有长出(圆点设置 stagger 时依次长出);格子从中心长出(格子设置 stagger 时依次长出);饼图和 arc 的圆环从起点顺时针扫出;径向线从内半径长出,圆点随之一起;sankey 的节点和流带从起始边缘画入,标签随之淡入。 |
update | timing | 应用改变图形时,图形过渡到新形状:圆点过渡到新的数值和大小,新行的圆点长出,已移除的行的圆点缩小消失;格子过渡到新的颜色(在 OKLab 中混合)和条带,新行的格子长出,已移除的行的格子缩小消失;饼图和 arc 的半径,比如 donut 的空洞张开和合拢;它们的角度,比如仪表或扇区扫到新的数值(同 d3 的 arcTween:过渡的是角度而不是轮廓;新出现的扇区从前一个扇区的结束处展开;过渡途中再次改变时,从当前绘制的位置继续);径向线的点过渡到新的数值、角度和半径(新出现的行从内半径长出),径向网格的圆环随之移动;以及线性比例尺的 domain(应用给的 domain,或随数据行到来而变化的图形数值范围),从旧 domain 平滑过渡到新 domain,图形、网格线、PlotAxis 和 PlotLabel 一起移动;以及 sankey 的节点、流带和标签,从旧的流的布局移动到新的布局,过渡途中再次改变时从当前绘制的位置继续。 |
enter | timing | 指针移到 plot 上时,悬停部件淡入。 |
exit | timing | 指针离开后,悬停部件淡出。 |
pointer | spring | 按键或屏幕阅读器切换悬停的行时,悬停部件从一行滑到下一行;饼图的扇区也按它抬起。 |
悬停部件与指针在同一帧移动:指针移动时,十字线、圆点和提示框立即落在指针下的那一行,紧跟手的移动,它们的淡入淡出(enter、exit)仍然带有动画。
动效在平台的动画器上运行,不需要每帧运行 JS;对偏好减少动态效果的用户立即完成。列表为回到视野的行重新创建 plot 时,图形会再绘出一次,除非包住列表的 PlotAppearScope 已经见过它的出现动效播放完。
domain 的过渡与 d3 过渡比例尺 domain 的方式相同:每个数值都由过渡中的比例尺定位,所以图形保持各自的形状,网格线和悬停部件随之移动,柱子保持圆角,和新 domain 一起到来的数据行也随其他部分一起移动。实时图表不时按数据行重新拟合 y 比例尺(留出一些余量,两次拟合之间网格保持不动),设置 update 时间参数后,每次重新拟合都会平滑过渡。plot 尺寸变化时,图形立即采用新尺寸。
这是 plot 自己的动效,用来设置图表里动的部分(图形的裁剪区域和悬停部件)的时间参数。视图的入场、离场和强调效果使用 createMotion 的预设和关键帧,应用仍然可以把它们用在 plot 的视图或周围的元素上。
#PlotAppearScope
列表在行每次回到视野时都会重新创建该行的 plot,所以带出现动效的迷你图在列表滚回来时会再绘出一次。用 PlotAppearScope 包住列表,它会记住在其中已经播放完的出现动效:在它里面重新创建的 plot,如果它的 appearGeneration 已经在这里播放完,就立即完整显示图形。plot 按它的 generation 记住,所以每一行的 plot 要有自己的 generation:行的键名,加上应当让它重播的内容(`${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>
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
id | .id(..) | number | string | 无 | scope 为什么内容记忆:它变化时,scope 忘掉记住的内容,其中的 plot 在原处重新绘出。按内容命名,比如一个自选列表或一段对话;没有它时,scope 在显示期间一直记住。 |
children | .child(..) | JSX.Element | 无 | 它包含的内容:列表,或任何随显示和移除重新创建其中 plot 的内容。 |
新的 generation 仍然会让 plot 绘出;出现动效还没播放完就被移除的 plot,也会从头再绘出一次。plot 播放完一个新的 generation 后,scope 会忘掉它之前的那个,所以切回之前的周期时 plot 会再绘出一次。记忆随 scope 一起消失:不再显示的 scope,或者 id 变化的 scope,其中的 plot 都会重新绘出;plot 在包住它的最内层 scope 中记忆。scope 的外观由应用决定:class 和 style 像对任何视图一样为它布局(包住铺满屏幕的列表时用 flex-1)。在 Rust 中,用 plot_appear_scope() 创建。
#键盘
可交互的 plot 是 Tab 的一个停靠点(macOS、Web);获得键盘焦点时,按键移动的就是指针驱动的同一个悬停:十字线、圆点和提示框按 pointer 弹簧滑动,饼图的扇区抬起,onHoverChange 报告这一行,屏幕阅读器读出应用为它给出的 accessibilityValue。焦点环由应用决定:focus-visible: 类或 focusStyle(focus-visible:outline-2 focus-visible:outline-ring)。
| 按键 | 作用 |
|---|---|
| 右方向键、左方向键 | 移动到屏幕上那一侧的行:下一行或上一行,在第一行位于右侧的反向 x 比例尺上则反过来(饼图按行的顺序移动到各扇区,sankey 移动到各节点,treemap 和上下方向键一样移动到屏幕上旁边的叶子);没有悬停的行时,右方向键从最左端的行开始,左方向键从最右端的行开始(饼图上是第一个和最后一个扇区)。 |
| Home、End | 移动到第一行或最后一行。 |
| 上方向键、下方向键 | 在格子上:移动到同一列中上方或下方的格子。在格子上,按键按屏幕上的网格移动(WAI-ARIA 的数据网格):右方向键和左方向键移动到同一行中旁边的格子,Home 和 End 移动到这一行的第一个和最后一个格子;没有悬停的格子时,第一次按键从左上角的格子开始(左方向键、上方向键和 End 从右下角开始)。到达边缘时悬停保持不动。 |
| Escape | 清除悬停。 |
plot 获得焦点时响应这些按键,有悬停的行时响应 Escape;其余时候按键交给应用和周围的视图。焦点移走时,按键产生的悬停随之清除。plot 不接收点按,所以放在可点按卡片里的 plot 会把点按留给卡片。屏幕阅读器也能逐行浏览:VoiceOver 的上下轻扫、TalkBack 的向前滚动和向后滚动,以及提供元素 increment / decrement 操作的屏幕阅读器,都会移动到下一行和上一行,在反向比例尺上同样按行的顺序。
#构建图表
一个图表由 plot、它的图形、它的坐标轴以及应用自己放在周围的元素组成。为 y 比例尺指定 domain,并把网格线的数值交给它的坐标轴(tickValues),每个标签就正好对齐自己的网格线。悬停的十字线、圆点和提示框是 plot 的部件,由应用设置样式和内容;该行的数值显示在应用想放的地方。
网格线用实线细线、底部基线稍深时最清楚,虚线就可以专门表示参考值(下例中的开盘价)。area 用朝基线渐隐的淡色比平涂更轻盈。每个系列的悬停圆点用该系列的颜色,描边用图表所在表面的颜色,从折线上“挖”出来;提示框位于 plot 顶部,在第一行和最后一行时也留在边缘以内,domain 在最高值之上留出空间,提示框就不会盖住折线。
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">
{/* 指针下那一行的数值,没有时取最后一行。 */}
<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={`价格,从 ${props.rows[0].price} 到 ${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" />
{/* 朝基线渐隐的淡色。 */}
<Area backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" />
<Grid y={[props.low]} class="stroke-muted-foreground/40" />
<Grid y={[props.rows[0].price]} class="stroke-muted-foreground" style={{ strokeDasharray: "3 3" }} />
<Line class="stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
{/* plot 右侧是网格线的数值,下方是第一行和最后一行的时间。 */}
<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>
);
}
柱状图在柱子顶端圆角、底部方角时最清楚(cornerRadii={{ topLeft: 4, topRight: 4 }};堆叠时只给最上面的柱子),同一条带里的柱子之间留一点空隙(groupPadding),悬停某一行时其他行的柱子变淡(inactiveOpacity)、它的条带上色(crossLineClass),数值用小号等宽数字放在柱子上方,堆叠时显示总数。paddingOuter 取 paddingInner 的一半时,条带的中线等距分布,PlotAxis 把每个月份居中放在它的条带下方;maxBandWidth 让宽卡片里的少量柱子保持窄。
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="每月销售额,线上和门店"
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"
/>
{/* 每个条带下方居中一个月份。 */}
<PlotAxis format={(i) => props.rows[i].month} labelClass="pt-2 text-xs text-muted-foreground" />
</Plot>
</View>
);
}
列表行中的迷你图是一个 interactive={false} 的 plot:只绘制图形,不跟随指针。
#无障碍
- plot 对屏幕阅读器是一个元素,角色为图像,读出应用的
accessibilityLabel;图形、坐标轴的标签、PlotLabel和悬停部件是它的组成部分,自己不读出任何内容。在标签中说明图表显示了什么以及值得注意的地方(范围、最高值),并用accessibilityValue给出指针下那一行的描述,屏幕阅读器就会随之读出数值。 - 在有指针的平台上(桌面、网页、配触控板的 iPad),指针下的行通过
onHoverChange报告。触摸屏没有悬停:触摸用户需要的数值请用应用自己的元素显示;数字很重要时,再用文字或列表提供数据行。 - 在饼图上,报告的行是指针下扇区所在的行:用
accessibilityValue给出它的名称、数值和占比("团队版,340 个席位,34.0%"),并在标签中说明总数和最大的份额。在雷达图上,报告的是离指针最近的那一项指标:给出指标的名称和每个系列在这一项的数值。 - 可交互的 plot 是键盘焦点的一个停靠点,方向键移动悬停的行(键盘);它还向屏幕阅读器提供
increment和decrement,用于逐行浏览(VoiceOver 把它读作可调节)。interactive={false}的 plot 两者都没有;无论哪种,plot 都不接收点按。
关于标签和角色的一般说明,见 无障碍。
#平台说明
- 所有平台以相同的方式绘制图形:每个图形一份在 Rust 中录制的绘图。数据行变化时,读取它们的图形在一个批次中重绘;绘制类名的变化(外观、
dark:)只会重新发送绘图,不需要重新录制。 - 出现动效用平台动画器推动的值裁剪折线和区域、让柱子长出;柱子在悬停行以外变淡,是动画器根据悬停位置算出的不透明度;悬停部件是由动画器移动和淡入淡出的视图,动画器还根据 plot 和提示框的尺寸把提示框留在 plot 内。
- area 的背景画在它自己的画布里,裁剪到 area 的形状,使用的渐变与平台为视图的
bg-linear-*绘制的相同;它的类名变化(外观、dark:)只会重新发送绘图,不需要重新录制。 - 饼图的每个扇区画在自己的画布里;指针下的扇区在 Rust 中根据扇区的角度和半径找出,它的抬起是一个半径、其他扇区的变淡是一个不透明度,都由动画器推动;出现动效的扫出也以同样方式推动扇区的角度,所以两者都不需要重新录制绘图。
- 径向线指针下的行在 Rust 中根据各点的角度找出;它的悬停圆点是由动画器摆放在指针下那一行的视图(按键切换时按
pointer弹簧滑动);出现或过渡到新数值时,各点是由动画器从原来位置推动的数,所以每次变化只录制一次。 - 圆点图形画在一个画布里;指针下的圆点在 Rust 中根据各圆点的中心和半径找出,它的悬停圆点是由动画器摆放在它上面、与它一样大的视图;出现时的长出、变化时的移动和悬停时的变淡都是由动画器推动的值,所以每次变化只录制一次绘图。
- 格子图形画在一个画布里;指针下的格子在 Rust 中根据各格子的范围找出,它的悬停格子是由动画器摆放在它上面的视图;长出、变化时的颜色和移动以及悬停时的变淡都是由动画器推动的值,所以每次变化只录制一次绘图。每个标签都是平台自己的文字,在排版后的大小放得进格子时显示。
- 坐标轴的标签、刻度线和轴线是平台自己的文字和视图,由 Rust 中的布局根据 plot 的尺寸和比例尺摆放:plot 尺寸变化时它们随图形一起移动,定义域变化时标签会重新写出。