元素
Plot
Plot 是圖表的框架:它顯示的資料列、x 軸與 y 軸的比例尺、其上的指標及指標的動態。它的標記負責繪製其所含的內容:穿過數列的 Line、介乎數列與基線之間的 Area、在 band 比例尺的各個 band 中每列一個的 Bar、每列由起點至終點的 Rule(蠟燭圖的影線)、每列在其兩個值交會處的 Dot(散佈圖的點、氣泡圖的氣泡)、在兩個類別的 band 中每列一個並按其值著色的 Cell(熱圖)、橫跨的一組 Grid、由各列佔比組成的 Pie(中間有孔的環形圖)、介乎兩個角度之間的 Arc、環繞中心並以後方的 RadialGrid 之環與輻條襯底的 RadialLine(雷達圖)、各列之間流向的 Sankey,以及由巢狀矩形組成、每片葉的面積即其值的 Treemap(市場地圖);PlotAxis 以比例尺的刻度標示邊緣,PlotLabel 則在比例尺上的各點寫出文字。每個標記都是一塊填滿 Plot 的畫布,由資料列以原生方式繪製:應用程式的資料列在設定時只讀取一次,之後在 Rust 中縮放、記錄並繪製,因此一千個點的圖表不會為每個點執行任何 JS。指標同樣在 Rust 中追蹤:懸停時的十字線、圓點與工具提示由平台的動畫器移動及淡入淡出,應用程式只會得知新的資料列,僅此而已。外觀由應用程式決定:標記以 CSS 中 SVG 形狀的繪製屬性(stroke-*、fill-*)上色,未設定時使用 currentColor;區域或長條會在其形狀內顯示背景(漸層、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("Visitors per day")
.h(px(160.0))
.on_hover_change(move |index: &Option<u32>| hovered.set(*index))
.child(grid().y(vec![0.0, 1500.0, 3000.0]).stroke_color(move || theme().border))
.child(area().fill_color(move || theme().chart_1.opacity(0.2)))
.child(line().stroke_color(move || theme().chart_1).stroke_width(px(2.0)))
}import { mount, signal, Text, View } from "nsh";
import { Area, Grid, Line, Plot } from "nsh/base/plot";
const rows = [
{ day: "Mon", visitors: 1820 },
{ day: "Tue", visitors: 2140 },
{ day: "Wed", visitors: 1960 },
{ day: "Thu", visitors: 2480 },
{ day: "Fri", visitors: 2310 },
];
function App() {
const hovered = signal<number | null>(null);
return (
<View class="gap-2 p-4">
<Text class="text-sm text-muted-foreground tabular-nums">
{hovered() == null ? "Visitors" : `${rows[hovered()!].day}: ${rows[hovered()!].visitors}`}
</Text>
<Plot
data={rows}
y="visitors"
yScale={{ type: "linear", domain: [0, 3000] }}
accessibilityLabel="Visitors per day, highest on Thursday"
onHoverChange={({ index }) => hovered.set(index)}
crossLineClass="w-px h-full bg-border"
dotClass="size-2.5 rounded-full border-2 border-background bg-chart-1"
class="h-40"
>
<Grid y={[0, 1500, 3000]} class="stroke-border" />
<Area class="fill-chart-1/20" />
<Line class="stroke-chart-1 stroke-2" />
</Plot>
</View>
);
}
mount(App);Plot、Line、Area、Bar、Rule、Dot、Cell、Grid、Pie、Arc、RadialLine、RadialGrid、Sankey、Treemap、PlotAxis 及 PlotLabel 來自 nsh/base/plot,這是 nsh/base 中專屬於它們的模組(在 Rust 中為 nsh_base::plot)。在 TypeScript 中,Plot 的 props 以其資料列的類型為泛型:欄位名稱會對照該類型檢查,而作用於資料列的函式會取得該資料列的類型。在 Rust 中,plot() 建立 Plot 時,每個 prop 各有一個 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(每列一個 band,用於長條,見 Bar;或每個類別一個 band,用於儲存格,見 Cell)。y 比例尺為 band 時,預設為 linear,用於橫向長條。reverse: true 使其由右邊緣開始(反向比例尺)。 |
yScale |
.y_scale(..) |
Scale |
{ type: "linear" } |
y 軸如何安排值的位置:定義域的最小值在底邊,最大值在頂邊。沒有 domain 時,採用標記所繪製內容的範圍。band 由頂邊向下為每列鋪設一個 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 內,已在其中完成的一代會一次過完整顯示。 |
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 內,寬度與 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 之內。使用鍵盤時,它位於該列第一條線或第一個區域的點旁(長條則為其 band 的中間,pie 則為其扇形的中間)。 |
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 軸有 band 時則沿 y 軸)、其下 pie 的扇形索引、中心最接近指標的 dot 索引、其下 cell 的索引(橫跨兩個儲存格之間的間隙時,取較近的一個),或按角度最接近指標的 radial line 資料列索引;指標離開 Plot 後則為 null(在 pie 上為其各扇形;在 radial line 上為其外半徑)。 |
Scale is { type: "point"; reverse? } | { type: "linear"; domain?: [number, number]; reverse? } | { type: "band"; domain?: string[]; paddingInner?; paddingOuter?; maxBandWidth?; bandCount?; groupPadding?; reverse? }. A linear 比例尺的 domain 是其兩個值所界定的範圍,次序不限;只有一個值的定義域會將它置於中間。band 比例尺的 domain 是其 band 的類別,按順序排列(Cell);其他選項見 Bar。
作用於資料列的函式(y={(d) => d.high - d.low})會在資料列或函式改變時,於下一輪為每列呼叫一次,絕不會在 Plot 繪製或追蹤指標期間呼叫:請保持其為純函式(相同的資料列,得出相同的數字)。欄位名稱則完全無需 JS 即可讀取。
#反向比例尺
reverse: true 令比例尺由其另一邊開始運行,一如 visx 的比例尺接受 reverse,而 d3 的比例尺接受倒寫的範圍。在 x 軸上,第一列或定義域的最小值位於右邊緣;在 y 軸上,定義域的最小值位於頂邊,資料列由底邊向上排列。所有置於比例尺上的內容都會跟隨它:標記、長條的基底及其生長方向、區域的基線、格線、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>
每個比例尺的定義域各自獨立:由應用程式的 domain 決定,或取其上各值的範圍,並且各自以 motion.update 移至新的定義域。當兩個比例尺都沒有指定定義域時,Plot 會以相同數目的整數步長排佈兩者(1、2 或 5 乘以 10 的冪,大約每 32 點高度一步):當兩者都包含零時,零在兩者上落在同一條線上;否則,由零起始的比例尺(成交量)向上增加額外步長,另一個(價格)則向兩側增加。軸的刻度就是這些步長,而沒有 x 也沒有 y 的 Grid 會在這些位置繪製其線條,因此一套格線可同時服務兩條軸。alignTicks={false} 會關閉此行為;應用程式指定的 domain 則使兩個比例尺保持原狀。
懸停只會找到一個資料列;每個標記的圓點位於其自己的比例尺上,而讀數由應用程式負責,它會讀取 onHoverChange 所回報資料列的兩個值。兩個比例尺上的長條在每個 band 內並排而立,而 stack 只會堆疊其比例尺上的長條。y2Scale 是線性的:y 軸有 band 的 Plot 沒有第二個 y 比例尺。
#Line、Area 與 Grid
| 標記 | 屬性 | 類型 | 預設值 | 說明 |
|---|---|---|---|---|
Line |
y |
string | (d, index) => number |
圖表的 y |
繪製的內容:每個有值的資料列繪製一個點,沒有值的資料列會略過。 |
Line、Area |
data、x |
T[]、string | (d, index) => number |
圖表的資料列 | 自有數列的資料列(使用其他 x 值),由其自身的 x 放置到圖表的比例尺上(沒有時則使用資料列的索引)。 |
Line、Area |
curve |
"natural" | "linear" | "stepAfter" |
"natural" |
連接各點的方式:平滑曲線、直線段,或階梯(每個值維持至下一列)。面積的底邊跟隨同一條曲線。 |
Line |
dot、dotClass、dotStyle |
boolean、string、StyleProp<ViewStyle> |
false |
每個點上的圓點:以該點為中心的檢視,其大小、背景和邊框由應用程式決定。 |
Line、Area |
hoverDotClass、hoverDotStyle |
string、StyleProp<ViewStyle> |
圖表的 dotClass、dotStyle |
其懸停圓點,位於指標下方資料列的對應點上:每個數列的圓點使用各自的顏色。各項設定會取代圖表在此標記上的設定(標記的顏色優先於圖表的大小和外環);hidden 表示不顯示。 |
Area |
backgroundClass、backgroundStyle |
string、StyleProp<ViewStyle> |
無 | 其背景:包圍其形狀的方框(由第一個點至最後一個點,由最高點至基線,在帶狀面積上則至底邊的最低點),其背景(bg-*、bg-linear-to-b from-… to-…、backgroundImage)只顯示在形狀內,位於 fill 之下。設有背景時,未另行設定的 fill 為 none。 |
Area |
y1 |
string | (d, index) => number |
圖表的 y |
構成其頂邊的內容。 |
Area |
y0 |
number | string | (d, index) => number |
y 比例尺的低端 | 其底邊:數字是 y 比例尺上的基線;欄位或函數則為每個資料列提供各自的底部值,使面積成為帶狀(某日的最低至最高、預測範圍)。未設定時,面積會閉合至 y 比例尺最小值所在的一邊:底邊;在反向比例尺上則為頂邊(從頂部垂下的面積)。任何一邊缺少值的資料列會略過。 |
Line、Area、Grid |
scale |
"y" | "y2" |
"y" |
其值所沿用的 y 比例尺:圖表的 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 |
無 | 其資料列的類別,顏色來自圖表的 colorScale:未另行設定時,其第一列的類別決定線條的 stroke(面積的 fill)(依類別著色)。 |
每個標記都填滿圖表,並以其 SVG 繪製屬性繪製,標記自身的設定優先於圖表的,一如 CSS 的繼承方式:
| 標記 | 繪製所用 | 未設定時 |
|---|---|---|
Line |
stroke、strokeWidth、strokeDasharray |
currentColor、寬 1、實線 |
Area |
fill(覆蓋其 background 部分);其頂邊 stroke、strokeWidth、strokeDasharray |
fill currentColor(旁有背景時為 none);無描邊 |
Grid |
stroke、strokeWidth、strokeDasharray |
currentColor、寬 1、實線;每條線對齊整數裝置像素,因此細線依然清晰 |
圖表沒有 y 比例尺的定義域時,Line 或 Area 所繪製的值(面積的 y0 也計算在內,即其基線或每列的底部值)會構成 y 比例尺的定義域;Grid 的值則不會。標記只在其繪製內容改變時才重新繪製(其資料列、值、曲線、圖表大小或比例尺、繪製屬性);指標移動只會移動懸停的各個部分,不會重新繪製標記。
帶狀面積是 Area,其 y0 讀取資料列:頂邊經過 y1,底邊折返經過 y0,兩者都使用面積的 curve,而 appear 動畫會從前緣將兩條邊繪入。其懸停圓點位於頂邊;其上的 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 相同:是一種顏色。線條之下的漸層是面積的背景,就像網頁在形狀中顯示漸層(方框的 background-image 置於 clip-path 之下):backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" 會由面積的最高點淡出至基線,使用主題的 token 與外觀的顏色,與任何檢視的背景相同。
#Bar
Bar 在 band 比例尺中每個資料列所屬的帶內繪製一根長條:在帶狀 x 比例尺上為直立,在帶狀 y 比例尺上為橫向(此時 x 比例尺 linear)。每根長條由 base 延伸至該列在另一比例尺上的值,因此負值會向另一方向延伸,沒有值的資料列沒有長條。在反向的值比例尺上,長條從頂邊向下或從右邊向左延伸,而反向的帶比例尺會把第一列的帶放在右邊或底部(反向比例尺)。
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 |
圖表的 y |
每根長條所顯示的內容。 |
base |
number | string | (d, index) => number |
0 |
長條在值比例尺上由哪個值開始向外延伸:所有資料列共用一個值,或以欄位或函數為每列提供各自的值,使每根長條橫跨該列的基值至其值(沒有基值的資料列沒有長條)。它會計入定義域。堆疊由其第一根長條的基值開始,逐列計算。 |
stack |
string |
無 | 所加入的堆疊:stack 相同的長條依序堆疊(沒有值的資料列不會增加任何內容)。 |
scale |
"y" | "y2" |
"y" |
直立長條的值所沿用的 y 比例尺(雙軸);兩者上的長條並排顯示,而堆疊只會堆疊其比例尺上的長條。 |
cornerRadii |
number | { topLeft?, topRight?, bottomRight?, bottomLeft? } |
0 |
長條每個角的半徑;{ topLeft: 4, topRight: 4 } 會令直立長條的末端變圓。大於長條一半的半徑會自動調整至適合長條。 |
stagger |
number |
0 |
配合圖表的 appear 動畫,長條會逐根依序開始,逐列進行,佔 appear 時間(0 至 0.95)的這個比例,每根在餘下的時間內增長;0 則令所有長條一起增長。 |
inactiveOpacity |
number |
無 | 指標位於圖表上時,指標下方資料列以外的長條的不透明度,令該列突出:它會隨懸停淡入淡出,並隨懸停在長條之間移轉。 |
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 |
無 | 每根長條的類別,顏色來自圖表的 colorScale:未另行設定時為長條的 fill(依類別著色)。 |
帶比例尺會依序為圖表的每個資料列配置一個帶,或為其 domain 的每個類別配置一個帶,一如 d3 的 scaleBand:
| 選項 | 預設值 | 說明 |
|---|---|---|
domain |
無 | 各帶的類別,依序排列(d3 的 scaleBand().domain()):Cell 會放入其資料列的類別所屬的帶,PlotAxis 則寫出每個帶的類別;重複寫出的類別保留其第一個位置。未設定時,帶即為資料列,而在儲存格上則為儲存格沿座標軸所指明的類別,依資料列帶來的順序排列。 |
paddingInner |
0 |
相鄰帶之間的間距,為每個帶步長的比例(0 至 1)。 |
paddingOuter |
0 |
第一個帶之前與最後一個帶之後的間距,為步長的比例。取 paddingInner 的一半,會使各帶的中點在圖表上等距。 |
maxBandWidth |
無 | 帶的最大寬度(以點為單位):在寬圖表上只有少數帶時,帶仍然保持窄,各自位於其位置的中央。 |
bandCount |
資料列 | 在多於資料列數目時,座標軸所配置的帶數:資料列佔用前面的帶,並保持其寬度與位置,隨資料列加入。 |
groupPadding |
0 |
帶內並排的長條之間的間距,為每根長條所佔位置的比例。 |
reverse |
false |
各帶從另一邊開始排列:第一列的帶在 x 軸的右邊、y 軸的底部;帶內並排的長條保持其順序。 |
長條以其 fill(fill-*;與 CSS 的繼承方式相同,繼承自圖表)繪製,未設定時為 currentColor(設有 color 時為其類別的顏色),並且不顯示懸停圓點。在帶比例尺上,懸停會找出指標下方的帶,圖表的十字線位於該帶內,寬度與該帶相同:crossLineClass="w-full h-full bg-foreground/5" 會為該帶著色,w-px 則在其中間保留一條線。圖表沒有定義域時,長條的值與基值會構成值比例尺的定義域。配合圖表的 appear 動畫,每根長條從其堆疊的基值長出;動態、淡入淡出與滑動都在平台的動畫執行器上運行,指標不會令任何長條重新繪製。
#範圍
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);當 base 為欄位或函數時,每根長條會橫跨其資料列的兩個值:瀑布圖的一步由其之前的累計值到其之後的累計值,蠟燭的實體由開盤價到收盤價,任務由開始到結束。長條出現時會從其自身的基值長出,其標籤位於值端,上升的長條在其上方,下降的長條在其下方。
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>
漏斗圖是置中的橫向長條:在帶狀 y 比例尺上使用 base={(d) => -d.users / 2} 與 value={(d) => d.users / 2}。
#Rule
Rule 沿值軸為每個資料列繪製一條直線,由 base 至該列的值:在帶比例尺上位於該列的帶中央(在帶狀 y 比例尺上則橫跨圖表),在點比例尺或線性比例尺上則位於該列沿 x 軸的位置。蠟燭的影線、箱形圖的鬚、誤差線和棒棒糖圖的桿都是 Rule;燭台圖則是 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 |
圖表的 y |
每條線的終點。沒有值的資料列沒有線。 |
base |
number | string | (d, index) => number |
0 |
每條線的起點:所有資料列共用一個值(棒棒糖圖從零線出發的桿),或以欄位或函數為每列提供各自的值(蠟燭的最低價)。它會計入定義域。 |
scale |
"y" | "y2" |
"y" |
其值所沿用的 y 比例尺(雙軸)。 |
stagger |
number |
0 |
配合圖表的 appear 動畫,線條會逐條依序開始,逐列進行,佔 appear 時間(0 至 0.95)的這個比例,每條在餘下的時間內由其起點開始增長。 |
inactiveOpacity |
number |
無 | 指標位於圖表上時,指標下方資料列以外的線條的不透明度,與 Bar 的相同。 |
color |
string | (d, index) => string |
無 | 每條線的類別,顏色來自圖表的 colorScale:未另行設定時為線條的 stroke。 |
Rule 以其 stroke(stroke-*、strokeWidth、strokeDasharray;與 CSS 的繼承方式相同,繼承自圖表)描邊,未設定時為 currentColor,並且不顯示懸停圓點。圖表沒有定義域時,其值與基值會構成值比例尺的定義域;它會隨圖表的 appear 動畫由其起點增長,並隨比例尺的定義域移動,兩者都在平台的動畫執行器上運行。
#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 的中央;在 band y 比例尺上(此時 x 比例尺 linear),點的值沿 x 軸延伸,該列的 band 則由上而下排列,構成點狀圖(dot 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] }} 會將數值的平方根對應到以點(points)為單位的半徑,與 d3 的 scaleSqrt 相同,因此值域(range)從 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>
當圖表沒有定義域時,點的值會組成數值比例尺的定義域,且不會刻意延伸至 0,令散佈圖的座標軸貼合其點。點較大時,請為圖表提供在數值周圍留有餘地的 domain:位於定義域邊緣的氣泡會畫出圖表邊界之外。
在相同 band 中放置兩種 mark,就能組成比較同一列兩個值的圖表:棒棒糖圖是一條由 0 起的 Rule,其上方於值的位置疊有 Dot;啞鈴圖則是在 band y 比例尺上,以 Rule 連接一個值到另一個值,兩端各有一個 Dot:
<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 |
圖表的 y |
每個點沿數值軸的位置:沿 y 軸向上,或在 band y 比例尺上沿 x 軸。沒有值的列不會有點。 |
r |
number | string | (d, index) => number |
3 |
點的半徑(以點為單位),或是為每列提供一個值的欄位或函數,該值由 rScale 轉換為半徑(若沒有的話,該值即為半徑)。值缺失的列不會有點。 |
rScale |
{ type: "sqrt"; domain?: [number, number]; range: [number, number] } |
無 | r 的值如何轉換為半徑:將其平方根在 domain(沒有時為 0 至最大值)上對應到半徑的 range,因此從 0 開始的值域會令每個點的面積與其值成正比。超出定義域的值取值域的兩端。 |
stagger |
number |
0 |
配合圖表的 appear,點會逐列依次開始增長,涵蓋 appear 的這個比例(0 至 0.95);0 則讓所有點同時增長。 |
inactiveOpacity |
number |
無 | 當有一個點位於指標或按鍵焦點之下時,其他點的不透明度:它們會隨懸停淡入淡出。 |
color |
string | (d, index) => string |
無 | 每個點的類別,其顏色取自圖表的 colorScale:在沒有其他設定時即點的 fill(依類別著色)。 |
hitRadius |
number |
8 |
指標距離點的邊緣多遠仍能找到它:在此距離內的點之中,指標會找到中心最接近它的那一個(Voronoi 單元),因此疊在大氣泡上的小氣泡可在其自身中心附近被找到。點狀圖的列,在半徑與一列同樣大時,可從沿該列的任何位置找到。 |
scale |
"y" | "y2" |
"y" |
其值所沿的 y 比例尺(雙軸);在 band y 比例尺上的點則將值放在 x 軸上。 |
hoverDotClass、hoverDotStyle |
string、StyleProp<ViewStyle> |
圖表的 dotClass、dotStyle |
hoverDot 部件:一個與指標下的點同寬同高、並以其為中心的 view。rounded-full outline-2 outline-offset-1 會為它加上外環;hidden 則令此 mark 不顯示任何外環。 |
點以其 fill 填色(fill-*;如 CSS 般從圖表繼承),未設定時使用 currentColor(即其類別的顏色,配合 color),並在有設定時以其 stroke 描邊(stroke-*、strokeWidth):以卡片顏色畫出的外環可讓重疊的點彼此分開。在有點的圖表上,圖表會回報中心最接近指標的點所屬的列(離每個點都 null 時除外),按鍵會依列的順序逐列移動(把列按 x 排序,按鍵就會由左至右走過各點),hoverDot 部件則標示該點。配合圖表的 appear,點會由無到有增長;配合 motion.update,點會移到新的值和大小,新列的點會增長出現,已移除列的點則縮小消失;全部由平台的 animator 處理,不會重新繪製任何 mark。
#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 會為每一列同時在兩個 band 中畫出一個矩形:其 x 類別在 band x 比例尺上的 band,以及其 y 類別在 band y 比例尺上的 band,因此各列組成一個網格,即熱圖。相關矩陣、每月回報的日曆,以及每小時一週的交易,都是以 Cell 呈現。每個 Cell 填滿其兩個 band,因此比例尺的 paddingInner 與 paddingOuter 就是 Cell 之間及周圍的空隙。它的顏色是其 value 在圖表的 colorScale(一條顏色漸層,沿漸層著色)上的對應色,或是其 color 類別透過序數比例尺取得的顏色。Cell 位於圖表的 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 會按順序列出其 band 的類別,與 d3 的 scaleBand().domain() 相同:例如日曆的月份由一月至十二月,不論有沒有對應的列,每個月都有自己的 band,所以上圖的 2026 年 3 月是網格中的空白位置。沒有定義域時,band 就是 Cell 沿該軸所指名的類別,依列帶來的順序排列(2025 在 2026 之上)。沿類別 band 比例尺的 PlotAxis 會寫出每個 band 的類別,不需要 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 |
無 | 每列所在的欄:欄位(其文字或數字)或回傳類別的函數。該列的 Cell 放在 x 比例尺中該類別的 band;沒有類別的列不會有 Cell。 |
y |
string | (d, index) => string |
無 | 每列所在的行,方式與 x 相同,沿 y 比例尺。 |
value |
string | (d, index) => number |
無 | 每個 Cell 的數字,由 sequential 或 diverging colorScale 轉為顏色。沒有數字的列取比例尺的 unknown 顏色,若也沒有則沒有 Cell。 |
color |
string | (d, index) => string |
無 | 每個 Cell 的類別,其顏色取自 ordinal ordinal colorScale(依類別著色)。 |
cornerRadii |
number | { topLeft?, topRight?, bottomRight?, bottomLeft? } |
0 |
每個 Cell 各個角的半徑。 |
stagger |
number |
0 |
配合圖表的 appear,Cell 會逐列依次開始增長,涵蓋 appear 的這個比例(0 至 0.95),其餘時間各自從中央增長;0 則讓所有 Cell 同時增長。 |
inactiveOpacity |
number |
無 | 當有一個 Cell 位於指標或按鍵焦點之下時,其他 Cell 及其標籤的不透明度:它們會隨懸停淡入淡出。 |
label |
string | (d, index) => string |
無 | label 部件:每個 Cell 中央的文字,可為欄位(其文字或數字)或回傳由應用程式格式化之文字的函數。每個標籤只在其排版後的大小能容納於 Cell 內時才顯示,因此窄的圖表只顯示顏色。 |
labelClass、labelStyle |
string、StyleProp<TextStyle> |
無 | 標籤的字型、大小與顏色。其 colorScale 顏色偏深的 Cell 上的標籤帶有屬性 data-dark(此時淺色文字較易閱讀),因此 data-dark:text-white 在該處會將其寫成淺色。 |
hoverCellClass、hoverCellStyle |
string、StyleProp<ViewStyle> |
無 | hoverCell 部件:與指標或按鍵所在 Cell 一樣大的 view,覆蓋其上;rounded-sm outline-2 outline-foreground 為它加上外環。它在同一幀內跟隨指標移動,按鍵移動它時則以 pointer spring 滑動。 |
當應用程式沒有設定 fill 時,Cell 以圖表 colorScale 中的顏色填色(fill-* 的 class 和樣式優先,如 CSS 般從圖表繼承),沒有任何設定時則用 currentColor,並在有設定時以其 stroke 描邊。在有 Cell 的圖表上,圖表會回報指標之下的 Cell 所屬的列,在兩個 Cell 之間的空隙則回報較近的一個;方向鍵依螢幕上的網格逐格移動(鍵盤操作),螢幕閱讀器則依列的順序逐列讀取。配合圖表的 appear,Cell 從中央增長;配合 motion.update,Cell 的顏色會滑動至新的顏色,新列的 Cell 增長出現,已移除列的 Cell 縮小消失,Cell 也會移到新的 band;全部由平台的 animator 處理,不會重新繪製任何 mark。
#圓餅圖與弧形
Pie 會把一整圈按每一列分成一塊扇形,每塊佔所有列數值的比例,按列的順序,由 12 點鐘方向起順時針圍繞繪圖區的中心排列。innerRadius 則把它變成環形圖。每塊扇形都是獨立的畫布,即 slice 部件,因此各自以自己的 class 繪製:sliceClass 可以是每塊扇形都相同的 class,也可以是以該扇形所屬的列為參數的函數,讓每塊扇形各有其 class。滑鼠指標移到圓餅圖上時,繪圖區會回報指標下扇形所屬的列(在中空部分或圓環以外則無),其工具提示跟隨指標移動,而該扇形會放大 hoverLift,其餘扇形則淡出 hoverDim,並透過平台的動畫器以繪圖區的 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={`Seats by plan, ${total()} in all`}
accessibilityValue={{ text: shown() && `${shown()!.name}, ${shown()!.seats} seats, ${share(shown()!.seats)}` }}
appearGeneration={1}
motion={{ appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 400, damping: 32 } }}
onHoverChange={({ index }) => hovered.set(index)}
class="size-44"
>
{/* A donut with 2-point gaps (0.025 of a turn at its 80-point rim). */}
<Pie
value="seats"
innerRadius="62%"
padAngle={0.025}
sliceClass={(_, i) => FILLS[i % FILLS.length]}
hoverLift={6}
hoverDim={0.64}
/>
{/* The hovered plan, or the total, in the hole. */}
<View class="absolute inset-0 items-center justify-center gap-1">
<Text class="text-xs text-muted-foreground">{shown()?.name ?? "Total"}</Text>
<Text class="text-2xl font-semibold tabular-nums text-foreground">{shown()?.seats ?? total()}</Text>
</View>
</Plot>
{/* The legend: a key in each slice's color, the value and the share at the end. */}
<View class="flex-1 gap-1">
{props.plans.map((plan, i) => (
<View class="flex-row items-center gap-3 rounded-md px-2 py-1.5" style={{ opacity: hovered() == null || hovered() === i ? 1 : 0.45 }}>
<View class={`size-2 rounded-sm ${KEYS[i % KEYS.length]}`} />
<Text class="flex-1 text-sm text-foreground">{plan.name}</Text>
<Text class="text-right text-sm tabular-nums text-foreground">{plan.seats}</Text>
<Text class="w-14 text-right text-sm tabular-nums text-muted-foreground">{share(plan.seats)}</Text>
</View>
))}
</View>
</View>
);
}
use nsh_base::plot::{SliceClass, pie, plot};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};
fn seats(seats: Vec<f64>) -> impl IntoElement {
let rows = Rows::from_columns(seats.len(), [("seats".into(), seats)]);
plot()
.data(rows)
.accessibility_label("Seats by plan")
.w(px(176.0))
.h(px(176.0))
.child(
pie()
.value(RowAccessor::key("seats"))
.inner_radius(relative(0.62))
.slice_class(SliceClass::from_fn(|i| format!("fill-chart-{}", i % 4 + 1).into())),
)
}
| 標記 | 屬性 | Rust | 類型 | 預設值 | 說明 |
|---|---|---|---|---|---|
Pie |
value |
.value(..) |
keyof T | (d: T, index: number) => number |
繪圖區的 y |
每一列的值。值為零、為負數或不是數字的列沒有扇形。 |
Pie、Arc |
startAngle、endAngle |
.start_angle(..)、.end_angle(..) |
number(弧度) |
0、2 * Math.PI |
第一塊扇形的起點與最後一塊的終點(弧形的兩端):由 12 點鐘方向起順時針計算的弧度。endAngle 若小於 startAngle,圓餅圖的扇形會以相反方向排列。 |
Pie |
padAngle |
.pad_angle(..) |
number(弧度) |
0 |
相鄰扇形在外緣的夾角。每道間隙在任何半徑都保持該寬度(約 padAngle * outerRadius 點),兩邊平行,因此在中心或中空處與在外緣一樣寬;太窄而未能伸達中心的扇形,會在兩邊相交處終止。只有一塊扇形的圓餅圖沒有間隙。 |
Pie、Arc |
innerRadius、outerRadius |
.inner_radius(..)、.outer_radius(..) |
number | `${number}%` |
0、"100%" |
中空部分與外緣的半徑:以點為單位,或以繪圖區所容納的最大圓(其較短一邊的一半)的百分比表示。 |
Pie、Arc |
cornerRadius |
.corner_radius(..) |
number |
0 |
每塊扇形(或弧形)轉角的半徑,以點為單位,與 d3 的 arc.cornerRadius 相同:每個轉角都是一個與邊緣及外緣(或中空處)相切的圓,包括間隙的平行邊。最大為圓環厚度的一半,此時弧形會有圓形端點;扇形太窄而容不下兩個完整轉角時,兩者會在其中線相遇。完整的圓環只保留圓形邊緣。 |
Pie |
sliceClass |
.slice_class(..) |
string | (d: T, index: number) => string |
無 | 每塊扇形的 class(fill-*、stroke-*):可以每塊都相同,也可以是以該扇形所屬的列及其索引為參數的函數,在列或函數改變時對每列各呼叫一次。 |
Pie |
sliceStyle |
.slice_style(..) |
StyleProp<ViewStyle> |
無 | 每塊扇形的樣式,疊加在其 class 之上(fill、stroke、strokeWidth)。 |
Pie |
hoverLift |
.hover_lift(..) |
number |
0 |
指標下的扇形比其他扇形多放大多少,以點為單位:使用繪圖區的 pointer 彈簧動畫,隨懸停淡入淡出。 |
Pie |
hoverDim |
.hover_dim(..) |
number(0 至 1) |
0 |
有扇形位於指標下時,其他扇形淡出的程度,以其不透明度的比例計算,並採用懸停的淡化效果。 |
Pie |
color |
.color(..) |
keyof T | (d: T, index: number) => string |
無 | 每塊扇形的類別,其顏色來自繪圖區的 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,可以每條線都相同,也可以是以該扇形所屬的列為參數的函數;套用樣式後才會顯示。 |
扇形與弧形以 fill 填色(未設定時為 currentColor,扇形的類別顏色則配合 color),並以 stroke 描繪邊緣(未設定時為無),各自的設定優先於圓餅圖及繪圖區的設定,按 CSS 的繼承方式處理:以表面顏色(stroke-card stroke-2)描邊,也會在扇形之間切出間隙。它們的值不參與繪圖區的比例尺。圓餅圖的扇形只會在列、其數值、角度、半徑或繪圖區大小改變時重繪;指標移動時,放大與淡出效果由動畫器處理。
含圓餅圖的繪圖區通常會把工具提示放在指標旁(tooltipPlacement="pointer"),因為圓餅圖的列在軸上沒有位置:十字線與工具提示會在其上跟隨指標,並且不會顯示懸停圓點。環形圖的中心是應用程式自行疊在繪圖區上的元素(顯示懸停扇形的值或總計),圖例亦然,每個圖例標記都使用其扇形的顏色。
圓餅圖的標籤會在圓環外標示各扇形:每段文字位於外緣之外 labelGap 處,朝向其扇形的中線(即扇形在該半徑處的形心),然後橫向移到圓餅圖該扇形一側的同一半徑處,並以套用了樣式的引導線連到外緣。標籤會隨繪圖區的入場動畫與扇形一同掃入,並在動畫尾段淡入,在 motion.update 時隨扇形移動,並隨懸停的扇形上浮;這一切都透過平台的動畫器執行。請為標籤預留空間:把 outerRadius 設為低於 "100%" 的值,或在繪圖區四周留白。應用程式自行在扇形位置加入的內容,則與環形圖的中心一樣,是疊在繪圖區上的元素。
<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"
/>
#按類別著色
繪圖區的 colorScale 為各類別指定顏色,作用與 d3 的 scaleOrdinal 相同,而標記的 color 則指出每一列所屬的類別:可以是欄位(取其文字或數字),也可以是以該列為參數的函數。domain 的類別按次序取用 range 的顏色,當定義域比值域長時,值域會由第一個顏色重新開始。不在定義域內的類別會取 unknown;若沒有設定,則在標記首次遇到它時加入定義域(按標記的次序,每個標記再按其列的次序),並取用下一個顏色,因此在完全沒有 domain 的情況下,各類別會按列帶來的次序取得顏色。顏色由應用程式決定:可以是 CSS 顏色,包括主題的 token。
import { Pie, Plot } from "nsh/base/plot";
import { token } from "nsh";
const regions = [
{ name: "North America", revenue: 482 },
{ name: "EMEA", revenue: 312 },
{ name: "APAC", revenue: 268 },
];
<Plot data={regions} colorScale={{ type: "ordinal", range: [token("chart-1"), token("chart-2"), token("chart-3")] }} class="size-44">
<Pie value="revenue" color="name" innerRadius="62%" />
</Plot>
扇形、長條與圓點取其所屬列的顏色;Line 與 Area 各自是一個數列,取其第一列的顏色,因此各自帶有列的線條(data)會使用其列的顏色,而同一個比例尺可令同一類別的顏色在圓餅圖、長條與折線中保持一致。在沒有任何設定時,顏色就是標記的 fill(折線則為 stroke),取代 currentColor:應用程式的 class 與樣式優先,其次是部件的(sliceClass、sliceStyle)、標記的,以及從繪圖區繼承而來的,因此 sliceClass={(d) => (d.name === "Other" ? "fill-muted" : "")} 可以單獨設定某一塊扇形,而其他扇形仍由比例尺著色。在有 background 部件時,長條與區域的 fill 仍維持 none。
OrdinalScale |
類型 | 預設值 | 說明 |
|---|---|---|---|
type |
"ordinal" |
(必填) | 比例尺的種類。 |
domain |
string[] |
無 | 依次序排列的類別。重複寫出的類別保留其第一次出現的位置。 |
range |
string[] |
(必填) | 依次序排列的顏色(CSS 顏色);第 i 個類別取第 i % range.length 個顏色。 |
unknown |
string |
無 | 定義域以外所有類別的顏色;沒有設定時,各類別取用下一個顏色。 |
#沿漸變色帶著色
colorScale 若為 type: "sequential" 或 "diverging",會讓數字沿漸變色帶取得顏色,作用與 d3 的 scaleSequential 和 scaleDiverging 相同,供 Cell 的 value 使用。其 range 是兩個或以上的顏色,由色帶的低端至高端均勻分布,並在其間以 OKLab 混合:色階在視覺上均勻,而兩種色相會經由它們自己的混合色相接。超出 domain 的值取色帶的端點顏色,沒有值的列則取 unknown。
sequential 把其 domain 攤布於整條色帶,除非應用程式指定,否則定義域為各格數值的範圍:適用於計數、數量、活躍程度。diverging 則把其 pivot(預設為 0)放在色帶的中間顏色,低於它的值沿前半段,高於它的值沿後半段:虧損用一種色相,收益用另一種色相,持平的月份則落在中性的中間色。除非應用程式指定定義域,否則定義域為數值範圍,並在支點兩側取成對稱(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")] }}
#RadialLine 與 RadialGrid
RadialLine 會圍繞繪圖區中心繪製一個數據系列:雷達圖的多邊形,或是錶盤上的一條線。每個有值的資料列都是位於該列角度上的一個點,距中心的遠近由繪圖區的 y 比例尺按其值決定,由 innerRadius(定義域的最小值)至 outerRadius(最大值)。沒有 angle 時,各列會由 12 點鐘方向起,按順序順時針平均分佈一圈,所以六列便是一個六邊形;closed 會將最後一點連回第一點。線條會描畫其各點,並把其面積填滿至 innerRadius(預設為中心),所以一個標記就同時是雷達圖的輪廓及其底色。RadialGrid 會在其後方繪製雷達圖的網格:rings 位於 y 比例尺的各個值,是穿過各列角度的多邊形或 circular,以及 spokes 伸向每一列。在徑向線上,繪圖區會回報角度最接近指標角度(在線條外半徑之內)的那一列,而每條線的懸停圓點就落在其於該列的點上。
import { signal, Text, View } from "nsh";
import { Plot, RadialGrid, RadialLine } from "nsh/base/plot";
type Metric = { name: string; ours: number; theirs: number };
export function Compare(props: { metrics: Metric[] }) {
const hovered = signal<number | null>(null);
const shown = () => (hovered() == null ? undefined : props.metrics[hovered()!]);
return (
<View class="items-center gap-3 rounded-xl border border-border bg-card p-4">
<Text class="text-sm tabular-nums text-muted-foreground">
{shown() ? `${shown()!.name}: ${shown()!.ours} against ${shown()!.theirs}` : "Ours against theirs"}
</Text>
<Plot
data={props.metrics}
yScale={{ type: "linear", domain: [0, 100] }}
accessibilityLabel="Ours against theirs on six metrics"
accessibilityValue={{ text: shown() && `${shown()!.name}: ours ${shown()!.ours}, theirs ${shown()!.theirs}` }}
appearGeneration={1}
motion={{ appear: { duration: 700, easing: "easeOut" }, update: { duration: 400, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 380, damping: 34 } }}
onHoverChange={({ index }) => hovered.set(index)}
class="size-56"
>
<RadialGrid rings={[25, 50, 75, 100]} spokes outerRadius="80%" class="stroke-border" />
<RadialLine radius="theirs" closed outerRadius="80%" class="fill-chart-2/15 stroke-chart-2" hoverDotClass="size-2 rounded-full border-2 border-card bg-chart-2" />
<RadialLine radius="ours" closed outerRadius="80%" class="fill-chart-1/25 stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
{/* Each metric's name past the rim at its row's angle, growing away from the center. */}
{props.metrics.map((m, i) => {
const angle = (2 * Math.PI * i) / props.metrics.length;
const away = (v: number) => (v > 0.01 ? "flex-start" : v < -0.01 ? "flex-end" : "center");
const align = (v: number) => (v > 0.01 ? "left" : v < -0.01 ? "right" : "center");
return (
<View
class="absolute size-0"
style={{
left: `${50 + 44 * Math.sin(angle)}%`,
top: `${50 - 44 * Math.cos(angle)}%`,
alignItems: away(Math.sin(angle)),
justifyContent: away(-Math.cos(angle)),
pointerEvents: "none",
}}
>
<Text class="w-24 text-xs text-muted-foreground" style={{ textAlign: align(Math.sin(angle)) }}>{m.name}</Text>
</View>
);
})}
</Plot>
</View>
);
}
| 標記 | 屬性 | Rust | 類型 | 預設值 | 說明 |
|---|---|---|---|---|---|
RadialLine |
radius |
.radius(..) |
keyof T | (d: T, index: number) => number |
繪圖區的 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 / rows,最後一列在第一列前一步;若只佔一圈的一部分,第一列在 startAngle,最後一列在 endAngle。 |
RadialLine、RadialGrid |
innerRadius、outerRadius |
.inner_radius(..)、.outer_radius(..) |
number | `${number}%` |
0、"100%" |
y 定義域的最小值和最大值的位置:以點數計,或以繪圖區可容納的最大圓(較短邊的一半)的百分比計,與圓餅圖的半徑相同。線條的面積填滿至 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 之下,與面積圖相同。 |
RadialLine |
hoverDotClass、hoverDotStyle |
.hover_dot_class(..)、.hover_dot_style(..) |
string、StyleProp<ViewStyle> |
繪圖區的 dotClass、dotStyle |
其懸停圓點,位於指標下那一列的點上,指標處於同一框架內,按鍵切換時會以繪圖區的 pointer 彈簧滑向下一列;hidden 則不顯示。 |
RadialGrid |
rings |
.rings(..) |
number[] |
無 | 其圓環的位置,以 y 比例尺的值表示:每個環與徑向線放置該值的位置同樣遠。 |
RadialGrid |
spokes |
.spokes(..) |
boolean |
false |
在每一列的角度上,由 innerRadius 至 outerRadius 的一條線。 |
RadialGrid |
circular |
.circular(..) |
boolean |
false |
其環為圓形(只佔部分圓周時為圓弧)。預設為穿過各列角度的多邊形,繪圖區少於三列時則為圓形。 |
徑向線以 stroke、strokeWidth 和 strokeDasharray 描邊(未設定時為 currentColor,線寬 1 的實線),並以 fill 填滿其面積(未設定時不填色);徑向網格的描邊方式與一般網格相同。它們的繪製樣式繼承自繪圖區。繪圖區沒有設定 y 定義域時,線條的各個值連同 0 會構成 y 比例尺的定義域,所以正值的雷達圖會由中心向外伸展;網格的環則不參與。若要在數值變化時令環和雷達圖的比例尺保持固定,請為繪圖區設定 y domain。
雷達圖四周的標籤是應用程式覆疊在繪圖區上的元素,就如環形圖的中心一樣。沒有 angle 時,n 列中的第 i 列位於距 12 點鐘方向 a = 2π·i / n 之處,所以在正方形繪圖區中,位於 left: 50% + r·sin(a)、top: 50% − r·cos(a)(r 為繪圖區邊長的百分比,略大於外半徑所佔比例的一半)而沒有尺寸的方框,會標示出邊緣之外的位置,名稱則由該處向遠離中心的方向伸展:在右半邊時位於其右方,在左半邊時位於其左方,在頂部時位於其上方,在底部時位於其下方,如上所示。
use nsh_base::plot::{plot, radial_grid, radial_line};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};
fn compare(ours: Vec<f64>, theirs: Vec<f64>) -> impl IntoElement {
let rows = Rows::from_columns(ours.len(), [("ours".into(), ours), ("theirs".into(), theirs)]);
plot()
.data(rows)
.accessibility_label("Ours against theirs")
.w(px(224.0))
.h(px(224.0))
.child(radial_grid().rings(vec![25.0, 50.0, 75.0, 100.0]).spokes(true))
.child(radial_line().radius(RowAccessor::key("theirs")).closed(true))
.child(radial_line().radius(RowAccessor::key("ours")).closed(true))
}
#Sankey
Sankey 會繪製繪圖區各列之間的流向:每一列都是一個節點,links 指明它們之間的流向,即 { source, target, value } with the indices of two rows in the plot’s data。它按繪圖區的大小,以 d3-sankey 的方式排版:節點分成多個欄(nodeAlign),每個節點的高度等於流入或流出它的最大總量,同一欄的節點相隔 nodePadding,並經 iterations 輪鬆弛調整,使其靠近各自的流向;每條連結則有一條由來源節點右緣伸至目標節點左緣的絲帶,兩端的闊度各為其佔該節點該側的比例,所以即使節點的流入與流出不相等,其兩側仍會被完全覆蓋。每一欄都在繪圖區內置中,而一連串相同的單節點欄會稍為錯開,令它們之間的絲帶呈現彎曲。valueScale="sqrt" 按數值的平方根決定大小,使單一佔優的流向不會令小流向相形見絀。
每個節點和每條絲帶都是各自獨立的畫布,即 node 和 link 部件,因此各自使用自己的樣式類別:nodeClass 可以是對每個節點都相同的類別,也可以是節點所屬列的函數;linkClass 可以是對每條絲帶都相同的類別,也可以是連結的函數。linkBackground 部件在每條絲帶內顯示背景,即由來源至目標的一個方框:以連結的函數可為每條絲帶提供由來源顏色漸變至目標顏色的效果。絲帶繪製在節點之下,兩者都隨繪圖區的出現動畫由前緣開始繪入。
在桑基圖上,繪圖區會回報指標下的節點(onHoverChange,即該節點所屬的列;在絲帶上則為無),其工具提示跟隨指標,而未連接該節點的連結會按 hoverDim 淡出,由平台的動畫器配合繪圖區的 motion 執行。按鍵會按各列的順序逐個切換節點,工具提示顯示在每個節點中間的旁邊。nodeLabel 為每個節點命名,valueLabel 提供其數值,兩者都設定時,數值顯示在名稱之上:第一欄的節點,標籤在其左方;最後一欄的節點,標籤在其右方;中間各欄的節點,標籤置中顯示在其上方,與 gpui-component 的圖表放置方式相同;節點旁的標籤會保持在繪圖區頂部與底部之間,而超出繪圖區邊緣的標籤,則位於應用程式在其四周預留的空間(即其邊距)之內。nodeLabel 部件的內距是與其節點之間的間隙,其類別則決定文字樣式(max-w-20 truncate 會截短過長的名稱);標籤隨出現動畫淡入。當應用程式更改流向時,每個節點和絲帶都會以繪圖區的 motion.update 由舊位置移至新位置,其標籤亦一併移動:新加入流向的節點由其中間長出,新加入的連結由其最小闊度長出,而被移除的則會立即消失;動畫進行期間再有更改,會由當時繪製的位置繼續。連結以其來源和目標區分,所以重新排列 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 },
];
// Each step's color: its node's fill, and its ribbons' gradient ends.
const FILLS = ["fill-chart-1", "fill-chart-2", "fill-muted", "fill-chart-3", "fill-chart-4", "fill-muted"];
const FROM = ["from-chart-1/40", "from-chart-2/40", "from-muted/40", "from-chart-3/40", "from-chart-4/40", "from-muted/40"];
const TO = ["to-chart-1/40", "to-chart-2/40", "to-muted/40", "to-chart-3/40", "to-chart-4/40", "to-muted/40"];
export function Funnel() {
const hovered = signal<number | null>(null);
return (
<Plot
data={steps}
accessibilityLabel="Visits to paid: 10,000 visits, 1,100 paid"
accessibilityValue={{ text: hovered() == null ? undefined : steps[hovered()!].name }}
onHoverChange={({ index }) => hovered.set(index)}
appearGeneration={1}
motion={{ appear: { duration: 900, easing: "easeOut" }, update: { duration: 500 }, enter: { duration: 120 }, exit: { duration: 160 } }}
class="mx-20 h-64"
>
<Sankey
links={flows}
nodeWidth={8}
nodePadding={16}
nodeCornerRadius={2}
nodeClass={(_, i) => FILLS[i]}
linkBackgroundClass={(link) => `bg-linear-to-r ${FROM[link.source]} ${TO[link.target]}`}
hoverDim={0.7}
nodeLabel={(d: Step) => d.name}
valueLabel={(_: Step, value: number) => value.toLocaleString("en-US")}
nodeLabelClass="px-2 py-1 text-xs text-muted-foreground"
valueLabelClass="font-medium text-foreground"
/>
</Plot>
);
}
use nsh_base::plot::{RowClass, SankeyLinkClass, SankeyLinks, plot, sankey};
use nsh_core::{element::Rows, prelude::*};
fn funnel() -> impl IntoElement {
plot()
.data(Rows::from_columns(4, []))
.accessibility_label("Visits to paid")
.h(px(256.0))
.child(
sankey()
.links(SankeyLinks::new([(0, 1, 4200.0), (1, 2, 2600.0), (2, 3, 1100.0)]))
.node_width(px(8.0))
.node_class(RowClass::from_fn(|i| format!("fill-chart-{}", i + 1).into()))
.link_class(SankeyLinkClass::from("fill-foreground/10")),
)
}
| 屬性 | Rust | 類型 | 預設值 | 說明 |
|---|---|---|---|---|
links |
.links(..) |
SankeyLink[]({ source: number; target: number; value: number }) |
必填 | 流向:由列 source 流向列 target(兩者為其在繪圖區 data 中的索引),value 為其大小。不是數字或小於零的值不帶任何流量,也不會繪製絲帶。 |
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 |
無 | 每條絲帶的類別:對每條絲帶都相同,或是連結及其在 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 |
未連接指標下節點的連結淡出多少,以其不透明度的比例計,使用繪圖區的 pointer 彈簧,並與懸停的淡入淡出同步。 |
nodeLabel |
.node_label(..) |
keyof T | (d: T, index: number) => string |
無 | 每個節點旁的名稱:一個欄位,或是節點所屬列及其索引的函數。 |
valueLabel |
.value_label(..) |
(d: T, value: number, index: number) => string |
無 | 每個節點旁的數值,顯示在名稱之上:是其所屬列,以及流入或流出該節點的最大總量(以連結的值計,不論 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 相同。沒有任何流量經過的節點,高度為一點。只有在連結、選項或繪圖區的大小改變時,排版才會重新執行,指標移動或出現動畫則絕不會觸發;繪圖區大小改變時不會令任何東西移動。構成循環的連結,或指向繪圖區不存在的列的連結,不會繪製任何內容,並會記錄一則警告。其數值不參與繪圖區的比例尺。
#Treemap
Treemap 會把圖表的各列資料繪製成由巢狀矩形組成的層級結構:每一列是一個節點,其 id 與它所指向的 parent 組成樹(即 d3 的 stratify),每個葉節點的面積與其 value 成正比,每個群組則是其下所有節點所佔的方框,其值為各葉節點之值的總和(不論群組自身那一列載有甚麼值)。沒有 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、小於 0 或並非數字的葉節點不佔面積:不會繪製、沒有標籤、指標也找不到它,而所有葉節點皆如此的群組亦然。 Treemap 的面積代表整體中的份額,因此被排除的部分,應由應用程式以自己的文字說明(例如在圖下加註)。 負數用於顏色:例如市場圖中當日的升跌幅。
節點的顏色來自該列在圖表 colorScale 中的設定:colorValue 是沿色階變化的數字(sequential、diverging:跌為紅、升為綠、持平為中性色,在未設定 domain 時,以數值的範圍為準),color 則是類別(ordinal:"parent" 令同一群組的葉節點顏色一致)。葉節點是 leaf 部分(leafClass、leafStyle),群組是 group 部分(groupClass、groupStyle),只有設定了 class 或 style 才會繪製:它是位於子節點之下的矩形,會在間隙、內距及橫帶中顯示出來。各部分可以在每個節點上都相同,或是節點所屬列資料的函式。cornerRadii 會令每個矩形都變成圓角。
label 會寫出每個葉節點的名稱,valueLabel 則在其下寫出數值,兩者都是該列資料及其值的函式(群組的值為其葉節點之總和):位置在左上角(labelPlacement="start")或中間("center")。label 部分是與其葉節點同樣大小的方框:其內距是與葉節點邊緣的距離,其文字樣式則適用於各行文字。葉節點較窄時,名稱會以省略號截短,只有容得下時才會顯示;數值要麼完整顯示,要麼完全不顯示。葉節點顏色較深時,其標籤會帶有屬性 data-dark,因此 data-dark:text-white 可令標籤在深紅色上仍然清晰易讀。
在 Treemap 上,圖表會回報指標下的節點(onHoverChange,即該節點所屬的列):可以是葉節點,或是位於橫帶或內距中的群組;若指標在同層節點之間的間隙上,則回報其中一個,以免懸停狀態閃爍。其餘節點會淡出至 inactiveOpacity(葉節點保留其所屬群組,群組則保留其下的內容),而 hover 部分是與該節點同樣大小的 view,會為它勾出輪廓。方向鍵移到畫面上相鄰的葉節點,Home 和 End 則移到第一個和最後一個。圖表出現時,頂層節點會由其中心逐個長大,並以 stagger 控制先後;當列資料改變時,每個節點都會以 motion.update 移到新的方框和顏色,並以其 id 識別:新加入的節點會由中心長大,已移除的節點則縮回自己的中心。圖表的 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、小於 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 |
無 | 每個葉節點的 class(fill-*、stroke-*),可在每個葉節點上都相同,或是其列資料的函式。 |
leafStyle |
.leaf_style(..) |
StyleProp<ViewStyle> |
無 | 每個葉節點的樣式,優先於其 class。 |
groupClass |
.group_class(..) |
string | (d: T, index: number) => string |
無 | 每個群組的 class,即位於子節點之下的矩形;群組只有設定了 class 或 style 才會繪製。 |
groupStyle |
.group_style(..) |
StyleProp<ViewStyle> |
無 | 每個群組的樣式,優先於其 class。 |
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 |
無 | 每個葉節點標籤的 class,是與葉節點同樣大小的方框:包括其內距和文字樣式;深色葉節點上會有 data-dark:。 |
labelStyle |
.label_style(..) |
StyleProp<TextStyle> |
無 | 每個葉節點標籤的樣式,優先於其 class。 |
valueLabelClass |
.value_label_class(..) |
string |
無 | 數值行的 class,優先於標籤的 class。 |
valueLabelStyle |
.value_label_style(..) |
StyleProp<TextStyle> |
無 | 數值行的樣式,優先於其 class。 |
groupLabel |
.group_label(..) |
keyof T | (d: T, index: number) => string |
無 | 每個群組在橫帶中的名稱,過長時以省略號截短。 |
groupLabelClass |
.group_label_class(..) |
string |
無 | 每個群組標籤的 class,是與橫帶同樣大小的方框。 |
groupLabelStyle |
.group_label_style(..) |
StyleProp<TextStyle> |
無 | 每個群組標籤的樣式,優先於其 class。 |
stagger |
.stagger(..) |
number(0 至 0.95) |
0 |
頂層節點逐個出現的方式:即它們開始出現的時間佔整段出現動畫的比例。 |
hoverClass |
.hover_class(..) |
string |
無 | hover 部分的 class,是疊在懸停節點之上、與其同樣大小的 view(rounded-xs outline-2 outline-foreground)。 |
hoverStyle |
.hover_style(..) |
StyleProp<ViewStyle> |
無 | 懸停部分的樣式,優先於其 class。 |
矩形以 fill 填色(即 colorScale 的顏色,未設定時則為 currentColor),並以 stroke 描邊(未設定時不描邊),其自身的設定優先於 Treemap 和圖表的設定,繼承方式與 CSS 相同。只有當列資料、選項或圖表尺寸改變時,才會重新進行佈局,指標移動或出現動畫則不會;圖表尺寸改變時,不會移動任何東西。重複的 id、未指向任何一列的 parent,以及自己是自己祖先的列,都會記錄一次警告:第一列保留該 id,孤立節點成為頂層節點,循環的部分則不繪製。
#PlotAxis
PlotAxis 會以該邊所採用比例尺的刻度,為圖表的一條邊加上標籤:下邊和上邊使用 x 比例尺,左邊和右邊使用 y 比例尺,與 d3 的 axisBottom、axisTop、axisLeft 和 axisRight 相同。其刻度來自比例尺:在 linear 比例尺上是定義域內的整數值(1、2 或 5 乘以 10 的冪),在 point 或 band 比例尺上則是各列資料。其標籤是由圖表佈局放置的文字,因此應用程式決定內容和外觀,佈局負責定位:每個標籤都沿該邊以其刻度為中心。沿下邊和上邊時,第一個和最後一個標籤只會向內移動至剛好不超出圖表兩端;在左邊和右邊旁,每個標籤都固定在其刻度上,讓它清楚對應其網格線,而當靠近端點的標籤會超出圖表的頂邊或底邊時,圖表的線性 y 比例尺會由邊緣向內縮進相應的距離(端點處的刻度則縮進標籤高度的一半),使每個標籤都留在圖表之內,並對齊其網格線。
import { Area, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";
const rows = [
{ day: "Mon", visitors: 1820 },
{ day: "Tue", visitors: 2140 },
{ day: "Wed", visitors: 1960 },
{ day: "Thu", visitors: 2480 },
{ day: "Fri", visitors: 2310 },
];
<Plot data={rows} y="visitors" yScale={{ type: "linear", domain: [0, 3000] }} class="mb-6 ml-10 h-40">
<Grid y={[0, 1000, 2000, 3000]} class="stroke-border" />
<Area class="fill-chart-1/20" />
<Line class="stroke-chart-1 stroke-2" />
{/* 0, 1,000, 2,000 and 3,000 left of the plot; a day under each row. */}
<PlotAxis placement="left" ticks={3} labelClass="pr-2 text-xs tabular-nums text-muted-foreground" />
<PlotAxis
format={(i) => rows[i].day}
labelClass="pt-2 text-xs text-muted-foreground"
tickClass="h-1 w-px bg-border"
lineClass="h-px bg-border"
/>
</Plot>
| 屬性 | Rust | 類型 | 預設值 | 說明 |
|---|---|---|---|---|
placement |
.placement(..) |
"bottom" | "top" | "left" | "right" |
"bottom" |
它沿哪一條邊延伸:下邊和上邊使用 x 比例尺的刻度,左邊和右邊使用 y 比例尺的刻度。 |
scale |
.scale(..) |
"y" | "y2" |
"y" |
左軸或右軸為哪一個 y 比例尺加標籤:圖表的 yScale 或其第二個比例尺 y2Scale(雙軸)。 |
labelPlacement |
.label_placement(..) |
"outside" | "inside" |
"outside" |
標籤的位置:在邊緣之外、應用程式於圖表四周預留的空間內,這樣標籤不會與圖形元素重疊;或在圖表之上沿邊緣排列,令圖表保持完整大小(在左邊或右邊旁,每個標籤位於其網格線的上方)。 |
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 比例尺上為該 band 的類別,否則為數值,帶有刻度間距所需的小數位,並以千位分組 | 每個刻度的文字:由應用程式根據其值(在 point 或 band 比例尺上為列索引)及其在該軸刻度中的索引所定義的函式。 |
labelClass、labelStyle |
.label_class(..)、.label_style(..) |
string、StyleProp<TextStyle> |
無 | label 部分:每個刻度一段文字,其字體、顏色和內距由應用程式決定;朝向圖表一側的內距就是它與邊緣的距離(下軸下方為 pt-2)。 |
tickClass、tickStyle |
.tick_class(..)、.tick_style(..) |
string、StyleProp<ViewStyle> |
無 | tick 部分:在邊緣上每個刻度一個 view,以其刻度為中心,並朝標籤伸出(下軸下方為 h-1 w-px bg-border);設定樣式後才會顯示。 |
lineClass、lineStyle |
.line_class(..)、.line_style(..) |
string、StyleProp<ViewStyle> |
無 | line 部分:沿整條邊延伸的 view,位於標籤的一側(下軸下方為 h-px bg-border);設定樣式後才會顯示。 |
在外側時,標籤位於圖表的邊距中,就像 d3 的軸位於圖表的邊距中一樣:圖表保持應用程式給它的大小,而應用程式會在軸的一側為它預留與標籤同樣深的邊距(其下的 pt-2 text-xs 標籤為 mb-6),或使用該處父元素的內距。同一條邊上的兩個軸,各自以自己的樣式為自己的值加標籤:範圍的兩端淡一些,中間則較突出。
format 會在刻度或函式改變時,於下一個回合為每個刻度各呼叫一次,絕不會在圖表繪製或跟隨指標時呼叫:請保持它是純函式(相同的值,得出相同的文字)。沒有它時,刻度會依 d3 的寫法顯示:1,500、0.25、−2。在 Rust 中,plot_axis() 建立一個軸,其 format 為 TickFormat::from_fn(|value, index| ..)。
軸不接受指標操作,螢幕閱讀器讀出的是圖表,而非其標籤。
#PlotLabel
PlotLabel 在圖表的各個點上寫出文字:山峰上方的標題、折線最後一點的標籤、格線末端的註解。它的每個 texts 都以圖表比例尺的值指明自己的點,做法與標記(marks)相同:在 point 或 band 比例尺上用某一列的索引(即該列色帶的中間),在 linear 比例尺上則用數值。文字的頂邊位於該點上,而它的 align 決定文字由該點開始、以該點為中心,還是在該點結束。圖表的版面配置負責放置這些文字,因此它們會跟隨圖表的大小;它們會隨比例尺的定義域滑動(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" />
{/* The peak's value over it, and a tag right of the last point, its middle at the point's height. */}
<PlotLabel
class="text-xs tabular-nums text-muted-foreground"
texts={[
{ text: `High ${rows[peak].visitors}`, x: peak, y: rows[peak].visitors, align: "center", class: "-translate-y-full pb-1" },
{ text: `${rows[last].visitors}`, x: last, y: rows[last].visitors, class: "-translate-y-1/2 ml-2 rounded bg-chart-1 px-1.5 text-white" },
]}
/>
</Plot>
| 屬性 | 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> |
無 | 文字自己的 class 和樣式,會覆蓋它從標籤繼承的內容:文字框(標籤的背景和內距)以及與該點的距離。 |
外觀由應用程式決定:標籤的 class 為每段文字提供它們繼承的文字樣式(字體、大小、顏色),而文字自己的 class 和 style 只作用於該段文字。使用 -translate-y-full 可讓文字位於其點的上方,而它的內距就是與該點的間距(pb-1);位於折線最後一點旁邊的標籤,會放在應用程式在該側預留的邊距內(mr-12),與座標軸的標籤相同。在 Rust 中,plot_label() 建立標籤,每段文字則是帶有 .align(PlotTextAlign::Center)、.class(..) 和 .style(..) 的 PlotText::new(text, x, y)。指標會穿過這些文字到達圖表,而螢幕閱讀器讀出的是圖表,其 accessibilityLabel 會說明這些文字所指出的內容。
#動態效果
動態效果由應用程式決定,預設為無:所有內容即時顯示。motion 接受 nsh 的時序({ duration, easing, delay } 和 { stiffness, damping, mass }):
| 鍵 | 類型 | 移動的內容 |
|---|---|---|
appear |
timing | 標記以 appearGeneration 繪入:折線和面積圖從前緣開始;長條從底部向外生長(配合長條的 stagger 逐個出現);圓點從無到有生長(配合圓點的 stagger 逐個出現);單元格從中心向外生長(配合單元格的 stagger 逐個出現);圓餅圖和弧形圖的環從起點順時針掃入;放射線從內半徑向外生長,其圓點隨之出現;桑基圖的節點和絲帶從前緣開始,其標籤淡入。 |
update |
timing | 應用程式改變形狀時,標記移動到新的形狀:圓點移動到新的值和大小,新增列的圓點生長出現,移除列的圓點縮小消失;單元格變為新的顏色(在 OKLab 中混合)和色帶,新增列的單元格生長出現,移除列的單元格縮小消失;圓餅圖和弧形圖的半徑(因此甜甜圈圖的中空部分會張開和閉合)以及它們的角度(因此儀表或扇形會轉到新的值;d3 的 arcTween:移動的是角度而非輪廓;新的扇形從前一個扇形的終點展開;在移動期間再次變更時,會從目前繪製的位置繼續);放射線的各點移動到新的值、角度和半徑(新增的列從內半徑向外生長),放射狀格線的環隨之移動;線性比例尺的定義域(即應用程式的 domain,或隨列到來而取得的標記值範圍)從舊定義域滑動到新定義域,標記、格線、PlotAxis 和 PlotLabel 隨之移動;以及桑基圖的節點、絲帶和標籤,從舊流量的佈局移動到新佈局,在移動期間再次變更時,會從目前繪製的位置繼續。 |
enter |
timing | 指標移到圖表上時,懸停的各部分淡入。 |
exit |
timing | 指標離開後,它們淡出。 |
pointer |
spring | 按鍵或螢幕閱讀器逐步移動懸停時,它們由一列滑動到下一列;圓餅圖的扇形會隨之抬起。 |
懸停的各部分會在同一個影格內跟隨指標:指標移動時,十字線、圓點和提示框會立即位於指標下方的那一列,因此它們緊貼手部動作,而它們的淡入和淡出(enter、exit)仍然有動畫。
動態效果在平台的動畫器上運行,每個影格都不需要 JS,而對要求減少動態效果的使用者,則會立即完成。若列表為重新出現的列再次建立圖表,圖表會再次繪入,除非列表外圍的 PlotAppearScope 已看到它的 appear 完成。
定義域的移動方式與 d3 轉場比例尺定義域的方式相同:中間狀態的比例尺會放置每個值,因此標記保持形狀,格線和懸停的各部分隨之移動,長條保持其圓角,而隨新定義域進入的列也與其餘部分一起移動。若即時圖表不時依據其列來調整 y 比例尺(留有一些空間,使格線在兩次調整之間保持不動),它會以 update 時序滑動到每次新的調整。當圖表大小改變時,標記會立即採用新的大小。
這是圖表本身的動態效果,即圖表移動的各項內容的時序:其標記的裁剪和懸停的各部分。視圖的進場、離場和吸引注意的動畫,是 createMotion 的預設效果和關鍵影格,應用程式仍然可以將它們用於圖表的視圖或其周圍的元素。
#PlotAppearScope
每當列回到視野中,列表就會再次建立該列的圖表,因此帶有 appear 的迷你折線圖會在列表捲回它時再次繪入。列表外圍的 PlotAppearScope 會記住其中已完成的 appear:在那裡再次建立的圖表,若其 appearGeneration 已完成,便會立即完整顯示其標記。圖表是按其世代(generation)來記住的,因此每一列的圖表都有自己的世代,即該列的 key 加上任何應使它重播的內容(`${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 |
無 | 範圍為何而記憶:它改變時,範圍會遺忘,其圖表會在原處重新繪入。請按內容命名,例如監察清單或對話;若沒有設定,範圍會在顯示期間一直記住。 |
children |
.child(..) |
JSX.Element |
無 | 它所包含的內容:列表,或在圖表來去時再次建立它們的任何內容。 |
新的世代仍然會讓圖表繪入,在 appear 完成前被移除的圖表也一樣,並從頭開始。圖表一旦完成新的世代,其範圍就會遺忘之前的世代,因此切換回較早的時段會再次讓圖表繪入。記憶隨範圍而存在:不再顯示的範圍,或帶有新 id 的範圍,會讓其圖表重新繪入,而圖表會在它外圍最內層的範圍中記憶。它的外觀由應用程式決定:class 和 style 如同任何視圖般為它排版(在填滿螢幕的列表外圍使用 flex-1)。在 Rust 中,plot_appear_scope() 建立一個範圍。
#鍵盤
互動式圖表是 Tab 鍵的一個停駐點(macOS、網頁),而在它擁有鍵盤焦點時,它的按鍵會移動與指標相同的懸停:十字線、圓點和提示框以 pointer spring 滑動,圓餅圖的扇形抬起,onHoverChange 回報該列,而螢幕閱讀器會讀出應用程式為它提供的 accessibilityValue。它的焦點環由應用程式決定:focus-visible: class 或 focusStyle(focus-visible:outline-2 focus-visible:outline-ring)。
| 按鍵 | 作用 |
|---|---|
| Right、Left | 移到該側的列:下一列或上一列,在反向的 x 比例尺上則相反,其第一列在右側(在圓餅圖上是按列的順序移動扇形;在桑基圖上是移動節點;在樹狀圖上是移到螢幕上相鄰的葉節點,Up 和 Down 也是如此);在沒有懸停任何列時,Right 從最左端的列開始,Left 從最右端的列開始(在圓餅圖上則分別是第一個和最後一個扇形)。 |
| Home、End | 移到第一列或最後一列。 |
| Up、Down | 在單元格上:移到同一欄中上方或下方的單元格。在單元格上,按鍵跟隨螢幕上的網格(WAI-ARIA 的資料網格):Right 和 Left 沿行移到下一個單元格,Home 和 End 移到該行的第一個和最後一個單元格;在沒有懸停任何單元格時,第一次按鍵從左上角的單元格開始(Left、Up 和 End 則從右下角的單元格開始)。到達邊緣時,懸停保持不動。 |
| Escape | 清除懸停。 |
圖表在擁有焦點時回應它的按鍵,而在有列被懸停時回應 Escape;其餘時間,這些按鍵會交給應用程式及其周圍的視圖。焦點移開時,會清除由按鍵造成的懸停。圖表不接受按壓,因此位於可按壓卡片內的圖表會把按壓留給卡片。螢幕閱讀器也可以逐列瀏覽:VoiceOver 的向上和向下滑動、TalkBack 的向前和向後捲動,以及所有提供元素 increment 和 decrement 動作的螢幕閱讀器,都會移到下一列和上一列,在反向比例尺上也是按列的順序。
#建立圖表
圖表由圖表區(plot)、它的標記、它的座標軸以及周圍應用程式自己的元素組成。為 y 比例尺指定 domain,並讓它的座標軸使用格線的值(tickValues):每個標籤便會位於其格線上。懸停的十字線、圓點和提示框是圖表的組成部分,由應用程式設定樣式和填入內容;列的值則放在應用程式顯示它們的地方。
格線以實線細線搭配較明顯的基線最易閱讀,這樣虛線便可留作表示參考值(見下方的開盤價)。面積圖以向基線漸隱的淡色渲染,比純色填充更易閱讀。每個數列的懸停圓點採用其數列的顏色,並以圖表所在表面的顏色作為外環,使它從折線中突顯出來;而提示框位於圖表的頂部,在第一列和最後一列時則位於其邊緣以內:在最高值上方留有空間的 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">
{/* The value of the row under the pointer, or the last one. */}
<View class="flex-row items-baseline">
<Text class="flex-1 text-sm text-muted-foreground">{shown().time}</Text>
<Text class="text-right text-xl font-semibold tabular-nums text-foreground">{shown().price.toFixed(2)}</Text>
</View>
<Plot
data={props.rows}
y="price"
yScale={{ type: "linear", domain: [props.low, props.high] }}
accessibilityLabel={`Price, from ${props.rows[0].price} to ${props.rows[props.rows.length - 1].price}`}
accessibilityValue={{ text: `${shown().time}, ${shown().price.toFixed(2)}` }}
appearGeneration={props.rows.length}
motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 300, damping: 30 } }}
crossLineClass="w-px h-full bg-muted-foreground/50"
tooltipOffset={4}
tooltipContent={
<View class="flex-row items-center gap-1.5 rounded-lg border border-border bg-popover px-2 py-1 shadow-md">
<View class="h-0.5 w-2.5 rounded-full bg-chart-1" />
<Text class="text-xs tabular-nums text-popover-foreground">{shown().price.toFixed(2)}</Text>
</View>
}
onHoverChange={({ index }) => hovered.set(index)}
class="mr-10 mb-6 h-48"
>
<Grid y={[middle(), props.high]} class="stroke-border" />
{/* A wash that fades out toward the baseline. */}
<Area backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" />
<Grid y={[props.low]} class="stroke-muted-foreground/40" />
<Grid y={[props.rows[0].price]} class="stroke-muted-foreground" style={{ strokeDasharray: "3 3" }} />
<Line class="stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
{/* The grid's values right of the plot, and the first and last times under it. */}
<PlotAxis
placement="right"
tickValues={[props.low, middle(), props.high]}
format={(v) => v.toFixed(0)}
labelClass="pl-2 text-xs tabular-nums text-muted-foreground"
/>
<PlotAxis
tickValues={[0, props.rows.length - 1]}
format={(i) => props.rows[i].time}
labelClass="pt-2 text-xs text-muted-foreground"
/>
</Plot>
</View>
);
}
長條圖在長條頂端為圓角、底部為方角時最易閱讀(cornerRadii={{ topLeft: 4, topRight: 4 }}; in a stack only on its top bar), a hair of room between the bars of a band (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="Sales per month, online and in store"
appearGeneration={props.stacked ? 1 : 0}
motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 150 }, exit: { duration: 150 }, pointer: { stiffness: 320, damping: 32 } }}
crossLineClass="w-full h-full bg-foreground/5"
onHoverChange={({ index }) => hovered.set(index)}
class="mb-6 h-44"
>
<Grid y={[40, 80, 120]} class="stroke-border" />
<Bar
value="online"
stack={props.stacked ? "sales" : undefined}
cornerRadii={props.stacked ? 0 : tip}
stagger={0.4}
inactiveOpacity={0.45}
label={props.stacked ? undefined : "online"}
labelClass="pb-1 text-[11px] tabular-nums text-muted-foreground"
class="fill-chart-1"
/>
<Bar
value="store"
stack={props.stacked ? "sales" : undefined}
cornerRadii={tip}
stagger={0.4}
inactiveOpacity={0.45}
label={props.stacked ? (d) => String(d.online + d.store) : "store"}
labelClass="pb-1 text-[11px] tabular-nums text-muted-foreground"
class="fill-chart-2"
/>
{/* A month centered under each band. */}
<PlotAxis format={(i) => props.rows[i].month} labelClass="pt-2 text-xs text-muted-foreground" />
</Plot>
</View>
);
}
列表列中的迷你折線圖是帶有 interactive={false} 的圖表:它繪製其標記,但不跟隨任何指標。
#無障礙功能
- 對螢幕閱讀器而言,圖表是一個元素,具有圖像角色和應用程式的
accessibilityLabel;它的標記、座標軸的標籤、它的PlotLabel以及懸停的各部分都是它的組成部分,自己不會讀出任何內容。請在標籤中說明圖表顯示什麼以及哪些內容突出(範圍、最高值),並為accessibilityValue提供描述指標下方那一列的文字,使螢幕閱讀器在值改變時讀出該值。 - 在有指標的平台(桌面電腦、網頁、配備觸控板的 iPad)上,指標下方的列會以
onHoverChange回報。觸控螢幕不會回報懸停:請在應用程式自己的元素中顯示觸控使用者所需的值,或在數字重要的地方以文字或列表提供各列。 - 在圓餅圖上,回報的列是指標下方的扇形:請為
accessibilityValue提供它的名稱、值和比例("Team, 340 seats, 34.0%"),並在標籤中說明總數和最大的比例。在雷達圖上,回報的是最接近指標的軸:請提供該軸的名稱以及每個數列在該處的值。 - 互動式圖表是鍵盤焦點的一個停駐點,其方向鍵會移動懸停(鍵盤),並向螢幕閱讀器提供
increment和decrement以逐列瀏覽(VoiceOver 會把它讀作可調整項目);帶有interactive={false}的圖表兩者皆非,而且無論哪種情況,它都不接受按壓。
標籤和角色的一般說明,請參閱無障礙功能。
#平台說明
- 每個平台都以相同方式繪製標記:每個標記的繪圖只在 Rust 中記錄一次;列(rows)變更時,會一次過批次重新繪製讀取這些列的標記;而繪製類別(paint class)變更(外觀,
dark:)時,則會重新傳送繪圖,無需重新記錄。 - 出現動畫以平台動畫器(animator)移動的數值來裁剪線條和區域,並令長條增長;長條由懸停列逐漸淡出,是動畫器根據懸停位置計算出的不透明度;懸停的各部分是由動畫器移動及淡入淡出的視圖,而動畫器會根據繪圖區和工具提示的大小,將工具提示保持在繪圖區內。
- 區域的背景繪製在其專屬的 canvas 中,並裁剪成區域的形狀,所用的漸層與平台為視圖的
bg-linear-*所繪製的相同;其類別(外觀,dark:)變更時,會重新傳送繪圖,無需重新記錄。 - 圓餅圖在各自的 canvas 中繪製每個扇形;指標下的扇形由 Rust 根據各扇形的角度和半徑找出,其凸起是半徑,其他扇形的淡出是不透明度,兩者都由動畫器移動,而出現動畫的掃動也以同樣方式移動扇形的角度,因此兩者都不會重新記錄繪圖。
- 放射線(radial line)中指標下的列由 Rust 根據各點的角度找出;其懸停點是由動畫器放置在指標下那一列的視圖(隨按鍵步進時,在
pointer彈簧上滑動),而在出現或移至新數值時,其各點是動畫器由原來位置開始移動的數字,因此每次變更只會記錄一次線條。 - 圓點標記繪製在同一個 canvas 中;指標下的圓點由 Rust 根據各圓點的中心和半徑找出,其懸停點是由動畫器放置在該圓點上的視圖,大小與該圓點相同;出現動畫的增長、變更時的移動以及懸停的淡出,都是由動畫器移動的數值,因此每次變更只會記錄一次圓點。
- 格子標記繪製在同一個 canvas 中;指標下的格子由 Rust 根據各格子的方框找出,其懸停格是由動畫器放置在該格子上的視圖;增長、變更時的顏色和移動以及懸停的淡出,都是由動畫器移動的數值,因此每次變更只會記錄一次格子。每個標籤都是平台的文字元件,會在其排版後的大小能容納於格子內時顯示。
- 座標軸的標籤、刻度線和軸線是平台自己的文字和視圖,由 Rust 中的版面配置根據繪圖區的大小和比例尺放置:繪圖區大小改變時,它們會隨標記一起移動,而新的定義域則會重新寫入標籤。