要素
Plot
Plot はチャートの枠組みです。表示する行データ、x 軸と y 軸のスケール、その上のポインター、そしてその動きを受け持ちます。マークは Plot が保持するものを描画します。系列を通るLine、系列とベースラインの間のArea、バンドスケールのバンドごとに行ごとのBar、開始から終了までの行ごとのRule(ローソク足のヒゲ)、2つの値が交わる位置の行ごとのDot(散布図の点、バブルチャートのバブル)、2つのカテゴリのバンドにおける行ごとのCellを値で色付けしたもの(ヒートマップ)、横切る線のGrid、行のシェアのPie(中央に穴のあるドーナツ)、2つの角度の間のArc、中心の周りのRadialLineとその背後にあるRadialGridのリングとスポーク(レーダー)、行間のフローのSankey、入れ子の長方形のTreemap(各葉の面積がその値、マーケットマップ)です。PlotAxisはスケールの目盛りで端にラベルを付け、PlotLabelはスケール上の点にテキストを書きます。各マークは Plot を埋める1枚のキャンバスで、行からネイティブに描画されます。アプリの行は設定時に一度だけ読み取られ、その後 Rust でスケーリング、記録、描画されるため、1000点のチャートでも点ごとの JS は実行されません。ポインターの追従も Rust で行われます。ホバーの十字線、ドット、ツールチップはプラットフォームのアニメーター上で動いてフェードし、アプリには新しい行だけが通知されます。見た目はアプリ次第です。マークは SVG シェイプ向けの CSS のペイントプロパティ(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のモジュールであるnsh/base/plotから提供されます(Rust ではnsh_base::plot)。TypeScript では、Plot の props は行の型に対してジェネリックです。フィールド名はその型に対して検査され、行の関数は行の型を受け取ります。Rust では、plot()が props ごとのセッターを持つ Plot を作り、行はRows(キーごとの数値の列)、値はRowAccessor(キー、または行のインデックスの関数)で、マークは型付きのペイントメソッド(.stroke_color(..)、.fill_color(..)、.stroke_width(..))を受け取ります。
#Plot
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
data | .data(..) | T[] | (必須) | 行データ。1点につき1つのオブジェクト。マークはアプリが行を設定したときに一度だけ値を読み取ります。値のない行(数値でないフィールド、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(順番に、左端から右端まで均等に。1行の場合は中央)、任意のdomainを伴うlinear、またはband(棒グラフ用に行ごとのバンド、Bar。またはセル用にカテゴリごとのバンド、Cell)。bandの y スケールでは、横向きの棒グラフ用にlinearがデフォルトになります。reverse: trueを指定すると右端から配置します(反転したスケール)。 |
yScale | .y_scale(..) | Scale | { type: "linear" } | y 軸が値を配置する方法: ドメインの最小値が下端、最大値が上端に来ます。domainがない場合は、マークが描画するものの範囲になります。bandは横向きの棒グラフ用に、上端から下に向かって行ごとのバンドを敷きます。reverse: trueは逆向きにします。最小値が上端、行は下端から上へ並びます(反転したスケール)。 |
y2Scale | .y2_scale(..) | LinearScale | なし | それを指定するマーク(scale="y2")用の2つ目の y スケール{ type: "linear", domain?, reverse? }。二重軸チャートの右側のスケールです(二重軸)。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 と同じ高さのビューで、ポインター下の行を中心に置かれます。バンドスケールでは行のバンド内に、バンドと同じ幅で配置されます(バンドの y スケールでは Plot を横断します)。 |
dotClass、dotStyle | .dot_class(..)、.dot_style(..) | string、StyleProp<ViewStyle> | なし | ホバーのドット: Line、Area、RadialLineごとのビューで、ポインター下の行のその点を中心に置かれ、Dotのものはポインター下でそのドットと同じ大きさになります。マークのhoverDotClass / hoverDotStyleは、そのマーク自身のドットについてこれらを置き換えます。 |
tooltipContent | .tooltip_content(..) | JSX.Element | なし | アプリのツールチップ。tooltipPlacementによって配置され、十字線とともに移動・フェードします。 |
tooltipPlacement | .tooltip_placement(..) | "top" | "pointer" | "top" | ツールチップの置き場所: Plot の上部で十字線を中心にするか、ポインターの横で十字線の Plot 中央寄りの側に、上半分ではポインターの下、下半分では上に置きます。上部の場合は Plot の左端と右端の間に収まります(上端からのオフセットはアプリ次第で、Plot の下に置くタグは Plot の高さ以上を取ります)。ポインターの横の場合は Plot の内側に収まります。キーボード使用時は、その行における最初の線または面の点の横に置かれます(棒ならバンドの中央、円グラフならスライスの中央)。 |
tooltipOffset | .tooltip_offset(..) | number | 0 | ツールチップと、それを配置する基準との距離: "top"では上端、"pointer"では十字線とポインター。 |
colorScale | .color_scale(..) | ColorScale | なし | マークの色: colorが読むカテゴリに対しては d3 のscaleOrdinalのように({ type: "ordinal", domain?, range, unknown? }、カテゴリによる色)、Cellのvalueが読む数値に対しては d3 のscaleSequentialやscaleDivergingのようにランプに沿って決まります({ type: "sequential" | "diverging", … }、ランプに沿った色)。 |
children | .child(..) | JSX.Element | なし | マーク(順に重ねて描画)と、その上にアプリが重ねるもの。 |
class、style | スタイルメソッド | string、StyleProp<ViewStyle> | なし | Plot のサイズと見た目。マークは CSS の継承と同様に、そのペイントプロパティを継承します。focus: / focus-visible:クラスとfocusStyleがフォーカスリングを描きます。 |
| イベント | Rust | ペイロード | 発生タイミング |
|---|---|---|---|
onHoverChange | .on_hover_change(..) | { index: number | null } | ポインターが別の行に移ったとき。x 軸方向でポインターに最も近い行(バンドが y 軸にだけある場合は y 軸方向)、その下にあるpieのスライス、中心が最も近いdot、その下にあるcell(2つのセルの隙間ではより近い方)、または角度で最も近いradial lineの行のインデックスです。Plot を離れるとnullになります(円グラフ上ではそのスライスの範囲、放射状の線上ではその外半径の範囲)。 |
Scaleは { type: "point"; reverse? } | { type: "linear"; domain?: [number, number]; reverse? } | { type: "band"; domain?: string[]; paddingInner?; paddingOuter?; maxBandWidth?; bandCount?; groupPadding?; reverse? }. A linear スケールのdomainは2つの値の範囲で、順序は問いません。値が1つのドメインはそれを中央に置きます。bandスケールのdomainはバンドのカテゴリを順に並べたものです(Cell)。その他のオプションは Bar にあります。
行の関数(y={(d) => d.high - d.low})は、行または関数が変わったときに、行ごとに一度、次のターンで呼び出されます。Plot の描画中やポインター追従中には決して呼ばれません。純粋に保ってください(同じ行なら同じ数値)。フィールド名は JS を一切介さずに読み取られます。
#反転したスケール
reverse: trueはスケールを反対の端から走らせます。visx のスケールがreverseを取り、d3 のスケールが逆向きに書かれた range を取るのと同様です。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 は、価格の隣に出来高を、利益率の隣に売上を置くように、別の単位の値のための2つ目の y スケールを持てます。y に沿って値を配置するすべての部品がscaleを取ります。"y"(デフォルト、Plot のyScale)または"y2"(そのy2Scale)です。右側のPlotAxisにscale="y2"を付けるとラベルが付きます。
<Plot data={days} xScale={{ type: "band", paddingInner: 0.3 }} class="mx-10 mb-6 h-44">
<Grid class="stroke-border" />
<Bar value="volume" class="fill-chart-2/50" />
<Line y="close" scale="y2" class="stroke-chart-1 stroke-2" />
<PlotAxis placement="left" format={(v) => `${v}M`} labelClass="pr-2 text-xs text-muted-foreground" />
<PlotAxis placement="right" scale="y2" labelClass="pl-2 text-xs text-muted-foreground" />
</Plot>
各スケールのドメインは独立しています。アプリのdomain、またはそのスケール上の値の範囲で、それぞれmotion.updateで新しいドメインへ移動します。どちらのスケールもドメインを指定していない間、Plot は両方を同じ数のキリのよい刻み(1、2、5に10のべき乗を掛けた値、高さ約32ポイントにつき1刻み)で配置します。両方がゼロを含む場合、ゼロは両方で同じ線上に来ます。そうでない場合、ゼロから始まるスケール(出来高)は余分な刻みを上側に増やし、もう一方(価格)は両側に増やします。軸の目盛りはこれらの刻みで、xもyもないGridはその位置に線を引くので、1つのグリッドが両方の軸に使えます。alignTicks={false}でこれをオフにできます。アプリが指定したdomainは、両方のスケールをそのままにします。
ホバーが見つける行は1つです。各マークのドットは自身のスケール上に置かれ、読み出しはアプリが行い、onHoverChangeが報告する行の両方の値を読み取ります。両方のスケールの棒は各バンド内で並んで立ち、stackは自身のスケール上の棒だけを積み上げます。y2Scaleは線形です。y 軸にバンドがある Plot には2つ目の y スケールはありません。
#Line、Area、Grid
| マーク | プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|---|
Line | y | string | (d, index) => number | プロットの y | 描画する内容。値を持つ行ごとに1点を描き、値のない行はスキップします。 |
Line、Area | data、x | T[]、string | (d, index) => number | プロットの行 | マーク自身の系列の行(x の値が異なるもの)。自身の x によってプロットのスケール上に配置されます(なければ行のインデックス)。 |
Line、Area | curve | "natural" | "linear" | "stepAfter" | "natural" | 点を結ぶ方法。滑らかな曲線、直線セグメント、または次の行まで各値を保持する階段状。Area の下端も同じ曲線に従います。 |
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 スケール上のベースラインを表し、フィールドまたは関数を指定すると行ごとに独自の下端値を持ち、Area がバンドになります(1日の最安値から最高値、予測の範囲など)。指定しない場合、Area は y スケールの最小値がある端に閉じます。通常は下端、反転したスケールでは上端です(上から垂れ下がる Area)。どちらかの端に値がない行はスキップされます。 |
Line、Area、Grid | scale | "y" | "y2" | "y" | 値が沿う y スケール。プロットの yScale または 2つ目の y2Scale です(二軸)。 |
Grid | x | number[] | なし | 縦線の位置。x スケールの値で指定します(point スケールでは行インデックス)。 |
Grid | y | number[] | なし | 横線の位置。y スケールの値で指定します。x も y も指定しない場合、線はそのスケールの目盛りに従います。これは PlotAxis が ticks なしで線を置く位置です(横向きのバーでは x に沿う)。 |
Grid | ticks | number | 軸のデフォルト | x も y も指定しない場合に描く線のおおよその本数。PlotAxis の ticks と同様です。 |
Line、Area | color | string | (d, index) => string | なし | 行のカテゴリ。色はプロットの colorScale から決まります。何も設定されていない場合、最初の行のカテゴリが line の stroke(area の fill)を決めます(カテゴリ別の色)。 |
各マークはプロットを埋めるように配置され、SVG のペイントプロパティで描画されます。CSS の継承と同様に、マーク自身の指定がプロットの指定より優先されます。
| マーク | 描画に使うもの | 未設定の場合 |
|---|---|---|
Line | stroke、strokeWidth、strokeDasharray | currentColor、幅 1、実線 |
Area | fill(background 部分に対して)。上端は stroke、strokeWidth、strokeDasharray | fill currentColor(背景がある場合は none)。ストロークなし |
Grid | stroke、strokeWidth、strokeDasharray | currentColor、幅 1、実線。各線はデバイスピクセル単位に揃えられるため、細い線もくっきり表示されます |
Line または Area が描画する値(area の y0、つまりベースラインまたは各行の下端値も含む)は、プロットにドメインがない場合に y スケールのドメインを構成します。grid の値は構成しません。マークが再描画されるのは、描画内容(行、値、曲線、プロットのサイズやスケール、ペイント)が変わったときだけです。ポインターはホバーの各部分を動かしますが、マークを再描画することはありません。
バンドは、y0 が行を読み取る Area です。上端は y1 を通り、下端は y0 を逆向きに通ります。どちらも area の 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 のもので、色を指します。line の下のグラデーションは area の背景です。これは、ページが図形の中にグラデーションを表示する方法(ボックスの background-image を clip-path の下に置く)と同じです。backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" は、area の最も高い点からベースラインに向かってフェードします。どのビューの背景とも同様に、テーマのトークンとアピアランスの色を使います。
#Bar
Bar は、band スケールの行のバンド内に行ごとに1本のバーを描きます。band の x スケールでは縦向き、band の 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 | 値スケール上でバーが伸び始める値。全行共通の1つの値、またはフィールドか関数で行ごとに指定でき、その場合各バーは行の基準値から値まで伸びます(基準値のない行にはバーがありません)。ドメインに含まれます。スタックは、行ごとに最初のバーの基準値から伸びます。 |
stack | string | なし | 参加するスタック。同じ stack を持つバーが順に積み重なります(値のない行は何も加えません)。 |
scale | "y" | "y2" | "y" | 縦向きバーの値が沿う y スケール(二軸)。両方のスケールのバーは並んで配置され、スタックはそのスケール上のバーのみを積み重ねます。 |
cornerRadii | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 0 | バーの各角の半径。{ topLeft: 4, topRight: 4 } は縦向きバーの先端を丸めます。バーの半分より大きい半径はバーに収まるよう調整されます。 |
stagger | number | 0 | プロットの 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 になります(カテゴリ別の色)。 |
band スケールは、d3 の scaleBand と同様に、プロットの行ごとに順番にバンドを配置するか、domain のカテゴリごとにバンドを配置します:
| オプション | デフォルト | 説明 |
|---|---|---|
domain | なし | バンドのカテゴリを順に指定します(d3 の scaleBand().domain())。Cell は行のカテゴリのバンドに入り、PlotAxis は各バンドのカテゴリを書き出します。同じカテゴリが2回書かれた場合は最初の位置が保持されます。指定しない場合、バンドは行になります。セルの場合は、セルが軸に沿って指定するカテゴリが、行が渡す順に並びます。 |
paddingInner | 0 | 隣り合うバンド間の隙間。各バンドのステップに対する割合(0〜1)。 |
paddingOuter | 0 | 最初のバンドの前と最後のバンドの後ろの隙間。ステップに対する割合。paddingInner の半分にすると、バンドの中央がプロット全体で等間隔になります。 |
maxBandWidth | なし | バンドの最大幅(ポイント単位)。幅の広いプロットで少数のバンドがあっても細いままで、それぞれの場所の中央に配置されます。 |
bandCount | 行 | 行より多い場合に、軸が配置される対象のバンド数。行は先頭のバンドを使い、行が追加されても幅と位置を保ちます。 |
groupPadding | 0 | バンド内で並ぶバー間の隙間。各バーの場所に対する割合。 |
reverse | false | バンドが逆の端から始まります。x 軸では最初の行が右、y 軸では下になります。バンド内で並ぶバーの順序は保たれます。 |
バーは fill(fill-*。CSS の継承と同様にプロットから継承)で描画され、未設定の場合は currentColor(color がある場合はそのカテゴリの色)になり、ホバードットは表示しません。band スケールでは、ホバーはポインター下のバンドを検出し、プロットのクロスラインはそのバンド内にバンドと同じ幅で置かれます。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 にフィールドまたは関数を指定すると、各バーは行の2つの値にまたがります。たとえばウォーターフォールの1ステップは直前の累計から直後の累計まで、ローソク足の実体は始値から終値まで、タスクは開始から終了までです。バーは表示時に自身の基準から伸び、ラベルは値側の端に置かれます。上昇するバーでは上、下降するバーでは下です。
const bridge = [
{ step: "Revenue", from: 0, to: 120, kind: "total" },
{ step: "Costs", from: 120, to: 72, kind: "down" },
{ step: "Other", from: 72, to: 81, kind: "up" },
{ step: "Profit", from: 0, to: 81, kind: "total" },
];
<Plot
data={bridge}
xScale={{ type: "band", paddingInner: 0.3 }}
colorScale={{ type: "ordinal", domain: ["total", "up", "down"], range: ["#64748b", "#16a34a", "#dc2626"] }}
class="h-40"
>
<Bar value="to" base="from" color="kind" label={(d) => `${d.to - d.from}`} labelClass="text-xs pb-1" />
</Plot>
ファネルは中央揃えの横向きバーです。band の y スケールで base={(d) => -d.users / 2} と value={(d) => d.users / 2} を使います。
#Rule
Rule は、値軸に沿って行ごとに base から行の値まで直線を1本描きます。band スケールでは行のバンドの中央(band の y スケールではプロットを横切る)、point または linear スケールでは x 軸に沿った行の位置です。ローソク足のヒゲ、箱ひげ図のひげ、エラーバー、ロリポップの軸は rule です。ローソク足チャートは Rule の上に Bar を重ねたものです:
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 | 各線の始点。全行共通の1つの値(ロリポップの軸はゼロラインから)、またはフィールドか関数で行ごとに指定します(ローソク足の安値など)。ドメインに含まれます。 |
scale | "y" | "y2" | "y" | 値が沿う y スケール(二軸)。 |
stagger | number | 0 | プロットの 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 は、行の 2 つの値が交わる位置に、行ごとに 1 つのドットを描画します。行の x を linear の x スケールに沿わせ、value を y 軸の上方向に取ると散布図になります。band の x スケールでは、各ドットは行の band の中央に置かれます。band の y スケール(x スケールは linear)では、値は x に沿い、行の band はプロットの下方向に並び、ドットプロットになります。半径 r は、全ドット共通の 1 つの数値にすることも、行ごとの値を rScale が半径に変換するようにすることもでき、後者はバブルチャートになります。値のない行にはドットが描画されません。
import { Dot, Grid, Plot } from "nsh/base/plot";
import { token } from "nsh";
const sessions = [
{ minutes: 12, trades: 5, platform: "iOS" },
{ minutes: 8, trades: 3, platform: "Android" },
{ minutes: 26, trades: 11, platform: "Web" },
{ minutes: 17, trades: 8, platform: "iOS" },
];
<Plot
data={sessions}
x="minutes"
xScale={{ type: "linear", domain: [0, 40] }}
yScale={{ type: "linear", domain: [0, 20] }}
colorScale={{ type: "ordinal", domain: ["iOS", "Android", "Web"], range: [token("chart-1"), token("chart-2"), token("chart-3")] }}
class="h-48"
>
<Grid x={[10, 20, 30]} y={[5, 10, 15]} class="stroke-border" />
<Dot
value="trades"
r={4}
color="platform"
inactiveOpacity={0.3}
class="stroke-card stroke-2"
hoverDotClass="rounded-full outline-2 outline-offset-1 outline-foreground"
/>
</Plot>
r にフィールドまたは関数を指定すると、各行の値がそのドットの大きさを決めます。rScale={{ type: "sqrt", range: [min, max] }} は、d3 の scaleSqrt と同様に、値の平方根をポイント単位の半径に対応付けます。そのため、range が 0 から始まる場合、バブルの面積は値に比例します。range が 0 より大きい値から始まる場合は、最小のバブルも見えたままになります。スケールで domain を指定しない限り、domain は 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>
プロットに domain がない場合、ドットの値が値スケールの domain になります。このとき 0 を含めようとはしないため、散布図の軸はその点に合わせて収まります。ドットが大きい場合は、値の周囲に余裕を持たせた domain をプロットに指定してください。domain の端にあるバブルは、プロットの端をまたいで描画されます。
同じ band に 2 つのマークを重ねると、行の 2 つの値を比較するチャートになります。ロリポップチャートは、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 に対応付けます。そのため、range が 0 から始まる場合、各ドットの面積は値に比例します。domain の外にある値は、range の端の値になります。 |
stagger | number | 0 | プロットの appear に合わせて、ドットは行ごとに順番に成長し始めます。この割合(0 から 0.95)の時間をかけて行われ、0 の場合はすべてが同時に成長します。 |
inactiveOpacity | number | なし | ポインターまたはキーが 1 つのドットの上にあるとき、このマークの他のドットの不透明度。ホバーに合わせてフェードインとフェードアウトします。 |
color | string | (d, index) => string | なし | 各ドットのカテゴリ。プロットの colorScale から色が決まります。何も設定されていない場合は、ドットの fill になります(カテゴリによる色)。 |
hitRadius | number | 8 | ドットの端からどれだけ離れてもポインターがそのドットを見つけられるか。この距離内にあるドットのうち、中心がポインターに最も近いものが見つかります(Voronoi セル)。そのため、大きなバブルの上にある小さなバブルは、それ自身の中心付近で見つかります。ドットプロットの行は、半径が行の幅と同じ大きさであれば、行に沿ったどこからでも見つかります。 |
scale | "y" | "y2" | "y" | 値が沿う y スケール(Dual axes)。band の y スケール上のドットは、値を x に沿って配置します。 |
hoverDotClass, hoverDotStyle | string, StyleProp<ViewStyle> | プロットの dotClass, dotStyle | hoverDot パーツ。ポインターの下にあるドットと同じ幅と高さのビューで、そのドットを中心に配置されます。rounded-full outline-2 outline-offset-1 はそれを囲む輪を描き、hidden はこのマークでは何も表示しません。 |
ドットは fill(fill-*。CSS の継承と同様にプロットから継承されます)で塗りつぶされ、指定がない場合は currentColor(カテゴリの色に color を適用したもの)で塗りつぶされます。また、指定がある場合は stroke(stroke-*、strokeWidth)で縁取られます。カードの色の輪を使うと、重なり合うドットを区別できます。ドットのあるプロットでは、中心がポインターに最も近いドットの行がプロットから通知されます(どのドットからも離れている場合は null)。キーは行をその順番どおりに移動します(行を x でソートしておけば、キーはドットを左から右へたどります)。また、hoverDot パーツがドットを示します。プロットの appear では、ドットは何もない状態から成長します。motion.update を指定すると、ドットは新しい値とサイズへ移動し、新しい行のドットは成長しながら現れ、削除された行のドットは縮んで消えます。これらはすべてプラットフォームのアニメーターで実行され、マークの再描画は行われません。
#Cell
import { mount, signal, Text, View } from "nsh";
import { Cell, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";
// Heatmaps: a `Cell` per row in the band of its `x` category across and
// of its `y` category down, both band scales, colored by its `value`
// through the plot's `colorScale`.
//
// The returns are a stock's months: the months across in the order of the
// x scale's `domain` (this year's months to come stay blank), the years
// down in the order the rows bring them, a loss red and a gain green along
// a diverging ramp about 0, its domain the largest move either way, each
// return written in its cell, white on the dark ones (`data-dark:`). The
// activity is a market's trades per hour of a week along a sequential
// ramp from the card's line color to the accent; the hour of its lunch
// break has no rows and stays blank. The cells grow in one after another,
// the cell under the pointer (or the arrow keys, which step across the
// grid) is ringed and read out in the header, and the others fade.
type Month = { year: string; month: string; ret: number };
type Hour = { day: string; hour: string; trades: number };
/** A repeatable stream of numbers in [0, 1), so every visit shows the same chart. */
const random = (seed: number) => {
let s = seed;
return () => {
s = (s * 16807) % 2147483647;
return (s % 10000) / 10000;
};
};
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Four years of a stock's monthly returns in percent, this year's up to October. */
const RETURNS: Month[] = (() => {
const r = random(41);
const drift: Record<string, number> = { "2023": 2.4, "2024": 1.9, "2025": 0.6, "2026": 1.2 };
return Object.keys(drift).flatMap((year) =>
MONTHS.slice(0, year === "2026" ? 10 : 12).map((month) => ({ year, month, ret: Math.round((drift[year] + (r() - 0.5) * 12) * 10) / 10 })),
);
})();
const HOURS = ["9:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"];
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
/** A week of a market's trades per hour, in thousands: busy at the open and the close, closed from 12:00 to 13:00. */
const ACTIVITY: Hour[] = (() => {
const r = random(17);
const shape = [1, 0.62, 0.5, null, 0.58, 0.55, 0.86];
return DAYS.flatMap((day, d) =>
HOURS.flatMap((hour, h) => (shape[h] == null ? [] : [{ day, hour, trades: Math.round(40 * shape[h]! * (0.92 + d * 0.04) * (0.9 + r() * 0.2) * 10) / 10 }])),
);
})();
const num = { fontVariant: ["tabular-nums" as const] };
const signed = (v: number) => `${v < 0 ? "−" : "+"}${Math.abs(v).toFixed(1)}%`;
const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });
/** The largest move either way: the ends of the diverging scale's own domain. */
const REACH = Math.max(...RETURNS.map((m) => Math.abs(m.ret)));
function App() {
const chart = signal<"returns" | "activity">("returns");
const hovered = signal<number | null>(null);
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 5));
const month = () => (hovered() == null || chart() !== "returns" ? undefined : RETURNS[hovered()!]);
const hour = () => (hovered() == null || chart() !== "activity" ? undefined : ACTIVITY[hovered()!]);
// A ring around the cell under the pointer, in the text's color, a little out from its edge.
const ring = () => ({ outlineWidth: 1.5, outlineOffset: 1.5, outlineColor: ui().text });
const returnsRamp = () => [ui().down, ui().line, ui().up];
const activityRamp = () => [ui().line, ui().accent];
const key = (colors: string[], labels: string[]) => (
<View style={{ gap: 4 }}>
<View style={{ height: 6, borderRadius: 3, backgroundImage: `linear-gradient(to right, ${colors.join(", ")})` }} />
<View class="flex-row justify-between">
{labels.map((l) => (
<Text style={{ fontSize: 11, color: ui().muted, ...num }}>{l}</Text>
))}
</View>
</View>
);
const motion = { appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 380, damping: 34 } } as const;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot cells</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Move the pointer over a cell to read it" : chart() === "returns" ? "Each cell is a month's return" : "Each cell is an hour's trades"}
</Text>
<Split
width={560}
inspector={
<>
<Panel title="chart">
<Segmented options={["returns", "activity"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
<Text style={{ fontSize: 13, color: ui().text, ...num }}>
{chart() === "returns" ? `colorScale diverging · ${RETURNS.length} rows` : `colorScale sequential · ${ACTIVITY.length} rows`}
</Text>
</Panel>
<Log title="onHoverChange" lines={events()} minHeight={92} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
<If
when={chart() === "returns"}
else={
<>
<View class="flex-row items-baseline gap-3">
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{hour() ? `${hour()!.day} ${hour()!.hour}` : "Trades per hour"}</Text>
<Text style={{ fontSize: size.detail, color: ui().muted }}>{hour() ? "thousand trades" : "A week, closed from 12:00 to 13:00"}</Text>
</View>
<Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{hour() ? `${hour()!.trades.toFixed(1)}k` : ""}</Text>
</View>
{key(activityRamp(), ["Fewer", "More"])}
<Plot
data={ACTIVITY}
xScale={{ type: "band", domain: HOURS, paddingInner: 0.12 }}
yScale={{ type: "band", domain: DAYS, paddingInner: 0.12 }}
colorScale={{ type: "sequential", range: activityRamp() }}
accessibilityLabel="Trades per hour over a week, busiest at the open"
accessibilityValue={{ text: hour() && `${hour()!.day} ${hour()!.hour}: ${hour()!.trades} thousand trades` }}
appearGeneration="activity"
motion={motion}
onHoverChange={({ index }) => {
hovered.set(index);
const h = index == null ? undefined : ACTIVITY[index];
log(h == null ? "null: left the chart" : `${index}: ${h.day} ${h.hour}, ${h.trades}k`);
}}
style={{ height: DAYS.length * 30, marginLeft: 36, marginTop: 20 }}
>
<Cell x="hour" y="day" value="trades" cornerRadii={4} stagger={0.6} inactiveOpacity={0.4} hoverCellClass="rounded-[5px]" hoverCellStyle={ring()} />
<PlotAxis placement="top" labelStyle={{ ...axisLabel(), lineHeight: 14, paddingBottom: 6 }} />
<PlotAxis placement="left" labelStyle={{ ...axisLabel(), width: 36, paddingRight: 8, textAlign: "right" }} />
</Plot>
</>
}
>
<View class="flex-row items-baseline gap-3">
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{month() ? `${month()!.month} ${month()!.year}` : "Monthly returns"}</Text>
<Text style={{ fontSize: size.detail, color: ui().muted }}>{month() ? "the month's return" : "Red for a loss, green for a gain"}</Text>
</View>
<Text style={{ fontSize: 20, fontWeight: "600", color: month() ? (month()!.ret < 0 ? ui().down : ui().up) : ui().text, ...num }}>
{month() ? signed(month()!.ret) : ""}
</Text>
</View>
{key(returnsRamp(), [signed(-REACH), "0", signed(REACH)])}
<Plot
data={RETURNS}
xScale={{ type: "band", domain: MONTHS, paddingInner: 0.1 }}
yScale={{ type: "band", paddingInner: 0.12 }}
colorScale={{ type: "diverging", range: returnsRamp() }}
accessibilityLabel={`Monthly returns since 2023, ${RETURNS.filter((m) => m.ret > 0).length} of ${RETURNS.length} months up`}
accessibilityValue={{ text: month() && `${month()!.month} ${month()!.year}: ${signed(month()!.ret)}` }}
appearGeneration="returns"
motion={motion}
onHoverChange={({ index }) => {
hovered.set(index);
const m = index == null ? undefined : RETURNS[index];
log(m == null ? "null: left the chart" : `${index}: ${m.month} ${m.year}, ${signed(m.ret)}`);
}}
style={{ height: 4 * 30, marginLeft: 36, marginTop: 20 }}
>
<Cell
x="month"
y="year"
value="ret"
cornerRadii={3}
stagger={0.6}
inactiveOpacity={0.4}
label={(d: Month) => (d.ret < 0 ? `−${(-d.ret).toFixed(1)}` : d.ret.toFixed(1))}
labelClass="text-black/70 data-dark:text-white/90"
labelStyle={{ fontSize: 10, ...num }}
hoverCellClass="rounded-[4px]"
hoverCellStyle={ring()}
/>
<PlotAxis placement="top" labelStyle={{ ...axisLabel(), lineHeight: 14, paddingBottom: 6 }} />
<PlotAxis placement="left" labelStyle={{ ...axisLabel(), width: 36, paddingRight: 8, textAlign: "right" }} />
</Plot>
<Text style={{ fontSize: 12, color: tint(ui().text, 0.5) }}>Each return shows where its cell has room for it.</Text>
</If>
</View>
</Split>
</View>
);
}
mount(App);Cell は、行ごとに 1 つの矩形を 2 つの band で同時に描画します。x のカテゴリの band は band の x スケール上に、y のカテゴリの band は band の y スケール上にあり、行がグリッドを構成するヒートマップになります。相関行列、月次リターンのカレンダー、時間ごとの 1 週間の取引は、いずれも Cell で表せます。各セルは 2 つの band を満たすため、スケールの paddingInner と paddingOuter がセルの間と周囲の隙間になります。セルの色は、プロットの colorScale(色のランプ。ランプに沿った色)に沿った value で決まります。あるいは、序数スケールによる 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 は、d3 の scaleBand().domain() と同様に、band のカテゴリを順番に指定します。たとえばカレンダーの月は 1 月から 12 月まで、その行があるかどうかにかかわらずそれぞれの band を持つため、上の 2026 年 3 月はグリッド上の空白になります。domain がない場合、band は軸に沿ってセルが指定するカテゴリで、行が渡された順に並びます(上の例では 2025 の上に 2026)。カテゴリの band スケールに沿った PlotAxis は、format なしで各 band のカテゴリを表示します。
相関行列では、両方の軸に同じ銘柄が並び、domain が係数の全範囲になります。そのため、銘柄にかかわらず、係数 0 はランプの中央の色になります。
const tickers = ["NVDA", "AMD", "MSFT", "GLD"];
<Plot
data={pairs}
xScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
yScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
colorScale={{ type: "diverging", domain: [-1, 1], range: [token("chart-2"), token("muted"), token("chart-1")] }}
class="aspect-square"
>
<Cell x="b" y="a" value="r" label={(d) => d.r.toFixed(2)} labelClass="text-xs text-black/70 data-dark:text-white" />
</Plot>
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
x | string | (d, index) => string | なし | 各行の列。フィールド(そのテキストまたは数値)、またはカテゴリを返す関数です。行のセルは x スケールのそのカテゴリの band に置かれ、値のない行にはセルがありません。 |
y | string | (d, index) => string | なし | 各行の行位置。y スケールに沿った x として扱われます。 |
value | string | (d, index) => number | なし | 各セルの数値。sequential または diverging の colorScale がこれを色にします。値のない行にはスケールの unknown の色が使われ、それもない場合はセルがありません。 |
color | string | (d, index) => string | なし | 各セルのカテゴリ。ordinal の colorScale から色が決まります(カテゴリによる色)。 |
cornerRadii | number | { topLeft?, topRight?, bottomRight?, bottomLeft? } | 0 | セルの各角の半径。 |
stagger | number | 0 | プロットの appear に合わせて、セルは行ごとに順番に成長し始めます。この割合(0 から 0.95)の時間をかけて行われ、各セルは残りの時間をその中央から成長します。0 の場合はすべてが同時に成長します。 |
inactiveOpacity | number | なし | ポインターまたはキーが 1 つのセルの上にあるとき、他のセルとそのラベルの不透明度。ホバーに合わせてフェードインとフェードアウトします。 |
label | string | (d, index) => string | なし | label パーツ。各セルの中央に表示するテキストで、フィールド(そのテキストまたは数値)、またはアプリが整形したテキストを返す関数です。各ラベルは、レイアウトされたサイズがセル内に収まる場合にのみ表示されるため、幅の狭いプロットでは色だけが表示されます。 |
labelClass, labelStyle | string, StyleProp<TextStyle> | なし | ラベルのフォント、サイズ、色。colorScale の色が暗いセルのラベルには、属性 data-dark(明るいテキストのほうが読みやすい場合)が付くため、data-dark:text-white はそこで明るい色で表示します。 |
hoverCellClass, hoverCellStyle | string, StyleProp<ViewStyle> | なし | hoverCell パーツ。ポインターまたはキーの下にあるセルと同じ大きさのビューで、そのセルの上に重なります。rounded-sm outline-2 outline-foreground はそれを囲む輪を描きます。ポインターには同じフレームで追従し、キーで移動する場合は pointer のスプリングで滑らかに移動します。 |
セルは、アプリが fill を設定していない場合はプロットの colorScale の色で塗りつぶされます(fill-* のクラスとスタイルが優先され、CSS の継承と同様にプロットから継承されます)。何も設定されていない場合は currentColor で塗りつぶされ、指定がある場合は stroke で縁取られます。セルのあるプロットでは、ポインターの下にあるセルの行がプロットから通知され、2 つのセルの間の隙間では近いほうのセルの行が通知されます。矢印キーは画面上のグリッドに沿って移動し(Keyboard)、スクリーンリーダーは行をその順番どおりにたどります。プロットの appear では、セルはそれぞれの中央から成長します。motion.update を指定すると、セルの色は新しい色へ滑らかに変化し、新しい行のセルは成長しながら現れ、削除された行のセルは縮んで消え、セルは新しい band へ移動します。これらはすべてプラットフォームのアニメーターで実行され、マークの再描画は行われません。
#Pie と Arc
Pie は、1周を行ごとのスライスに分割します。各スライスは行の値に占める割合に応じた大きさで、12時の位置から時計回りに、行の順に、プロットの中心の周りに並びます。innerRadius を指定するとドーナツになります。各スライスはそれぞれ独立したキャンバス、つまり slice パートであり、それぞれ自身のクラスで描画されます。sliceClass は全スライスに同じクラスを適用するか、スライスの行の関数として各スライスに個別のクラスを与えます。パイの上では、プロットはポインター下のスライスの行を報告し(穴の中やリングの外では行なし)、ツールチップはポインターに追従し、そのスライスは hoverLift だけ拡大し、他のスライスは hoverDim だけフェードします。これはプラットフォームのアニメーター上で、プロットの motion を使って行われます。Arc は2つの角度の間の1つの扇形を描きます。ゲージのトラックと値、またはパイの背後のリングなどに使います。
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 | 各行の値。値が 0、負、または数値でない行にはスライスがありません。 |
Pie, Arc | startAngle, endAngle | .start_angle(..), .end_angle(..) | number(ラジアン) | 0, 2 * Math.PI | 最初のスライスの開始位置と最後のスライスの終了位置(アークの両端)。12時の位置から時計回りのラジアンで指定します。endAngle が startAngle より小さいと、パイのスライスは逆向きに配置されます。 |
Pie | padAngle | .pad_angle(..) | number(ラジアン) | 0 | リムでの隣り合うスライス間の角度。各ギャップはどの半径でもその幅(約 padAngle * outerRadius ポイント)を保ち、両端は平行なので、中心や穴でもリムと同じ幅になります。中心まで届かないほど狭いスライスは、両端が交わる位置で終わります。スライスが1つのパイにはギャップがありません。 |
Pie, Arc | innerRadius, outerRadius | .inner_radius(..), .outer_radius(..) | number | `${number}%` | 0, "100%" | 穴とリムの半径。ポイント、またはプロットが収まる最大の円(短辺の半分)に対するパーセンテージで指定します。 |
Pie, Arc | cornerRadius | .corner_radius(..) | number | 0 | 各スライス(アークの場合はアーク)の角の半径。ポイントで指定します。d3 の arc.cornerRadius と同様に、各角はエッジとリムまたは穴に接する円で、ギャップの平行なエッジも含みます。最大値はリングの厚みの半分で、このときアークには丸いキャップがつきます。2つの完全な角を収められないほど狭いスライスでは、角は中央で合わさります。完全なリングは丸いエッジだけを保ちます。 |
Pie | sliceClass | .slice_class(..) | string | (d: T, index: number) => string | なし | 各スライスのクラス(fill-*, stroke-*)。全スライス共通にするか、スライスの行とインデックスの関数にします。関数は、行または関数が変わったときに行ごとに1回呼び出されます。 |
Pie | sliceStyle | .slice_style(..) | StyleProp<ViewStyle> | なし | すべてのスライスのスタイル。クラスの上に適用されます(fill, stroke, strokeWidth)。 |
Pie | hoverLift | .hover_lift(..) | number | 0 | ポインター下のスライスが他のスライスよりどれだけ外側に拡大するか(ポイント)。プロットの pointer スプリングを使い、ホバーに合わせてフェードイン・アウトします。 |
Pie | hoverDim | .hover_dim(..) | number(0 から 1) | 0 | 1つのスライスがポインター下にある間、他のスライスがどれだけフェードするか。不透明度に対する割合で、ホバーのフェードに合わせて変化します。 |
Pie | color | .color(..) | keyof T | (d: T, index: number) => string | なし | 各スライスのカテゴリ。プロットの colorScale から得られる色になります。何も設定されていない場合はスライスの fill が使われます(カテゴリによる色)。 |
Pie | label | .label(..) | string | (d: T, index: number) => string | なし | 各スライスの横に表示するテキスト。フィールド(その数値)、またはアプリが整形したテキストを返す関数で指定します。テキストが空の行、または 0.5 度より狭いスライスにはテキストがありません。 |
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 だけ外側に置かれ、そこからパイのスライス側のその半径まで横に渡り、スタイルを設定した引き出し線がリムとつなぎます。ラベルはプロットの appear でスライスとともに広がり、その終わりにかけてフェードインし、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 の色(テーマのトークンを含む)を使えます。
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 はそれぞれ1つの系列であり、最初の行の色を取ります。そのため、独自の行を持つ線(data)はその行の色になり、1つのスケールによってカテゴリの色はパイ、バー、線のあいだで同じに保たれます。何も設定されていない場合、色はマークの fill(線の場合は stroke)になり、currentColor の代わりに使われます。優先順位は、アプリのクラスとスタイルが最初で、次にパート(sliceClass, sliceStyle)、マーク、そしてプロットから継承されるものの順です。そのため、sliceClass={(d) => (d.name === "Other" ? "fill-muted" : "")} はスケールが他のスライスに色を付ける一方で、1つのスライスだけを別に設定できます。background パートがある場合、バーとエリアの fill は none のままです。
OrdinalScale | 型 | デフォルト | 説明 |
|---|---|---|---|
type | "ordinal" | (必須) | スケールの種類。 |
domain | string[] | なし | カテゴリを順に並べたもの。2回書かれたカテゴリは最初の位置を保ちます。 |
range | string[] | (必須) | 色を順に並べたもの(CSS の色)。カテゴリ i は色 i % range.length を取ります。 |
unknown | string | なし | ドメイン外のすべてのカテゴリの色。指定しない場合、各カテゴリは次の色を取ります。 |
#ランプに沿った色
colorScale に type: "sequential" または "diverging" を指定すると、d3 の scaleSequential と scaleDiverging と同様に、数値にランプに沿った色が割り当てられます。これは Cell の value 用です。range は、ランプの低い端から高い端まで均等に配置された2色以上の色で、その間は OKLab で混合されます。これにより、段階が目に均等に見え、2つの色相はそれぞれの混合を経て移り変わります。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時の位置から時計回りに、順番に一周へ等間隔で並ぶので、6行なら六角形になります。closed は最後の点を最初の点につなぎます。線は点をストロークし、その面を innerRadius(デフォルトは中心)まで塗りつぶすので、1つのマークでレーダーの輪郭とその塗りの両方になります。RadialGrid は、その背後にレーダーのグリッドを描画します。y スケールの値に置かれる rings、行の角度を通る多角形または 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 ずつ離れ、最後の行は最初の行の1ステップ手前に置かれます。一周の一部では、最初の行が 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 | リングを円(一周の一部では円弧)にします。デフォルトでは、リングは行の角度を通る多角形になり、プロットの行が3つ未満の間は円になります。 |
放射状の線は 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" は値の平方根でサイズを決めるので、1つの支配的なフローが小さなフローを圧倒することがありません。
各ノードと各リボンはそれぞれ独立したキャンバスで、node パートと link パートにあたり、それぞれ独自のクラスで描画されます。nodeClass はすべてのノードに共通のクラス、またはノードの行の関数で、linkClass はすべてのリボンに共通のクラス、またはリンクの関数です。linkBackground パートは各リボンの内側に背景を表示します。ソースからターゲットまでのボックスで、リンクの関数を指定すると、各リボンにソースの色からターゲットの色へのグラデーションを付けられます。リボンはノードの下に描画され、どちらもプロットの appear に合わせて先頭の端から描き込まれます。
サンキー上では、プロットはポインターの下のノードを報告し(onHoverChange、ノードの行。リボンの上では何も報告しません)、ツールチップはポインターに追従し、そのノードにつながっていないリンクは、プラットフォームのアニメーターで、プロットの motion とともに hoverDim だけフェードします。キーはノードを行の順に移動し、ツールチップは各ノードの中央の横に表示されます。nodeLabel は各ノードの名前を、valueLabel はその値を示し、両方が設定されている場合は名前の上に値が表示されます。ラベルは、最初の列のノードでは左、最後の列では右、その間の列では上に中央揃えで配置され、これは gpui-component のチャートの配置と同じです。ノードの横のラベルはプロットの上端と下端の間に収まり、プロットの端を越えるラベルは、アプリがその周囲に残した余白(マージン)に配置されます。nodeLabel パートのパディングはノードまでの間隔、そのクラスはテキストのスタイルです(max-w-20 truncate は長い名前を切り詰めます)。ラベルは appear に合わせてフェードインします。アプリがフローを変更すると、すべてのノードとリボンが、プロットの 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 がその大きさです。数値でない値や0未満の値は何も運ばず、リボンも描画されません。 |
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-*)です。すべてのノードに共通のクラス、またはノードの行とそのインデックスの関数を指定でき、関数は行または関数が変わったときに行ごとに1回呼び出されます。 |
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 の継承と同様に、サンキーやプロットの設定よりも自身の設定が優先されます。何も流れないノードの高さは1ポイントです。レイアウトが再実行されるのは、リンク、オプション、またはプロットのサイズが変わったときだけで、ポインターや appear では再実行されません。プロットのサイズが変わっても、何も動きません。循環するリンク、またはプロットにない行を指すリンクは、何も描画せず、警告をログに出力します。サンキーの値はプロットのスケールには関与しません。
#Treemap
Treemap は、プロットの行を入れ子になった長方形の階層として描画します。各行が1つのノードで、その id と、それが指す parent が木を作ります(d3の stratify)。各リーフの面積は value に比例し、各グループは配下のものを収める箱で、その値はリーフの値の合計です(グループ自身の行が持つ値にかかわらず)。parent のない行、または親がどの行も指していない行は、トップレベルのノードです。id がない場合、行のidはそのインデックスになるため、parent でもインデックスで行を指定できます。gpt-vis が書き出す入れ子形式のツリー({ name, value, children })は、アプリ側の数行で行に変換できます。階層の深さに制限はありません。
タイルは、d3の treemapSquarify と同様にスクォーキファイ(squarify)されます。各階層のノードは値の大きい順に左上から並べられ(sort="value")、縦横比 ratio(黄金比)に近づけられます。sort="data" は行の順序を保つため、値が変わってもノードの位置は変わりません。paddingInner は兄弟ノード間の隙間で、親の外縁には設けられません。paddingOuter はグループ内で子の周囲に取る余白です。paddingTop は各グループの上部にある帯で、そこに groupLabel が書かれます。いずれも、全階層に共通の数値、または最上位から順に階層ごとに1つずつ指定します([3, 1]: トップレベルのノード間は3、それより下は1)。階層ごとの帯は、親の兄弟すべてに付くか、まったく付かないかのどちらかで、小さすぎて収まらない場合は省かれます。そのため、同じ階層のグループは同じ見た目になります。
値が0、0未満、または数値でないリーフは面積を持ちません。描画されず、ラベルもなく、ポインターでも検出されません。リーフがすべてそのようなグループも同様です。 ツリーマップの面積は全体に対する割合なので、除外されたものは、アプリが自身の言葉で示します(マップの下の注記など)。負の値は色のためのものです。たとえば、マーケットマップの日次の騰落率です。
ノードの色は、プロットの colorScale に対する、その行の値で決まります。colorValue はランプ上の数値です(sequential、diverging: 下落は赤、上昇は緑、変化なしはニュートラル。domain がない場合は値の範囲全体が対象)。color はカテゴリです(ordinal: "parent" はグループのリーフを同じ色にします)。リーフは leaf パート(leafClass、leafStyle)、グループは group パート(groupClass、groupStyle)で、クラスまたはスタイルが指定された場合にのみ描画されます。これは子の下にある長方形で、隙間、パディング、帯の部分に表示されます。各パートは、すべてのノードで同じにするか、ノードの行の関数にします。cornerRadii はすべての長方形を丸めます。
label は各リーフの名前を、valueLabel はその下に値を書きます。これらは行とその値の関数です(グループの値は、そのリーフの合計)。位置は左上(labelPlacement="start")または中央("center")です。label パートはリーフと同じ大きさのボックスで、そのパディングはリーフの端からの余白、テキストスタイルは各行のスタイルになります。名前は、リーフの幅が足りない場合は省略記号で切り詰められ、収まる場合にのみ表示されます。値は全体が表示されるか、まったく表示されないかのどちらかです。リーフの色が暗い場合、ラベルには属性 data-dark が付くため、data-dark:text-white により濃い赤の上でも読みやすさが保たれます。
ツリーマップの上では、プロットがポインター下のノード(onHoverChange、ノードの行)を報告します。対象はリーフ、またはグループの帯やパディングです。兄弟ノード間の隙間を横切るときは、いずれかの兄弟が対象となるため、ホバーがちらつきません。それ以外のノードは inactiveOpacity までフェードします(リーフは自分のグループを保ち、グループは配下のものを保ちます)。hover パート(ノードと同じ大きさのビュー)がそのノードの輪郭を描きます。矢印キーは画面上で隣のリーフへ移動し、Home と End は最初と最後のリーフへ移動します。
プロットの表示(appear)では、トップレベルのノードが中央から広がり、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 | なし | 各リーフのクラス(fill-*、stroke-*)。すべてのリーフで同じにするか、その行の関数にします。 |
leafStyle | .leaf_style(..) | StyleProp<ViewStyle> | なし | すべてのリーフのスタイル。クラスより優先されます。 |
groupClass | .group_class(..) | string | (d: T, index: number) => string | なし | 各グループのクラス。子の下にある長方形です。グループは、クラスまたはスタイルがある場合にのみ描画されます。 |
groupStyle | .group_style(..) | StyleProp<ViewStyle> | なし | すべてのグループのスタイル。クラスより優先されます。 |
inactiveOpacity | .inactive_opacity(..) | number(0〜1) | なし | ホバーされたノード以外のノードとそのラベルの不透明度。ホバーのフェードとともに適用されます。 |
label | .label(..) | keyof T | (d: T, index: number) => string | なし | 各リーフ内の名前。1行で、省略記号で切り詰められます。 |
valueLabel | .value_label(..) | (d: T, value: number, index: number) => string | なし | 各リーフ内の値。名前の下に、全体が収まる場合にのみ表示されます。 |
labelPlacement | .label_placement(..) | "start" | "center" | "start" | ラベルをリーフの左上に置くか、中央に置くか。 |
labelClass | .label_class(..) | string | なし | 各リーフのラベルのクラス。リーフと同じ大きさのボックスで、パディングとテキストスタイルを指定します。暗いリーフでは data-dark: が付きます。 |
labelStyle | .label_style(..) | StyleProp<TextStyle> | なし | 各リーフのラベルのスタイル。クラスより優先されます。 |
valueLabelClass | .value_label_class(..) | string | なし | 値の行のクラス。ラベルのクラスより優先されます。 |
valueLabelStyle | .value_label_style(..) | StyleProp<TextStyle> | なし | 値の行のスタイル。クラスより優先されます。 |
groupLabel | .group_label(..) | keyof T | (d: T, index: number) => string | なし | 各グループの名前。帯の中に表示され、省略記号で切り詰められます。 |
groupLabelClass | .group_label_class(..) | string | なし | 各グループのラベルのクラス。帯と同じ大きさのボックスです。 |
groupLabelStyle | .group_label_style(..) | StyleProp<TextStyle> | なし | 各グループのラベルのスタイル。クラスより優先されます。 |
stagger | .stagger(..) | number(0〜0.95) | 0 | トップレベルのノードが順に現れる方法。表示(appear)のうち、各ノードが開始するまでの割合です。 |
hoverClass | .hover_class(..) | string | なし | hover パートのクラス。ホバーされたノードの上に重なる、同じ大きさのビューです(rounded-xs outline-2 outline-foreground)。 |
hoverStyle | .hover_style(..) | StyleProp<ViewStyle> | なし | ホバーパートのスタイル。クラスより優先されます。 |
長方形は fill で塗りつぶされ(colorScale の色。未設定の場合は currentColor)、stroke で線が描かれます(未設定の場合は線なし)。いずれも、CSSの継承と同様に、ツリーマップやプロットの設定より自身の設定が優先されます。レイアウトが再実行されるのは、行、オプション、またはプロットのサイズが変わったときだけで、ポインターや表示(appear)では再実行されません。プロットのサイズが変わっても、何も移動しません。重複した id、どの行も指さない parent、自身の祖先になっている行は、警告を一度だけログに出力します。最初の行がそのidを保持し、孤立した行はトップレベルのノードとなり、循環は描画されません。
#PlotAxis
PlotAxis は、プロットの端に、その端が持つスケールの目盛りでラベルを付けます。下端と上端はxスケールの目盛り、左端と右端はyスケールの目盛りを使います。これは d3の axisBottom、axisTop、axisLeft、axisRight に相当します。目盛りはスケールから決まります。linear スケールではキリのよい値(10のべき乗の1、2、または5倍で、ドメインの内側)、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 か、2つ目のスケール y2Scale です(Dual axes)。 |
labelPlacement | .label_placement(..) | "outside" | "inside" | "outside" | ラベルの位置。端の外側で、アプリがプロットの周囲に確保した余白に置いてマークと重ならないようにするか、端に沿ってプロットの上に重ねてプロットのサイズを保つかを選びます(左端または右端では、各ラベルはグリッド線の上側に置かれます)。 |
ticks | .ticks(..) | number | 10(linear スケールの場合。揃えられたスケールではすべてのステップ)、それ以外のスケールではすべての行 | 目盛りのおおよその数。linear スケールでは、ドメイン内のキリのよい値をおよそこの数だけ取ります。point または band スケールでは、最初から最後まで均等に配置した、この数の行を取ります。 |
tickValues | .tick_values(..) | number[] | なし | 目盛りの値。ticks の代わりに指定します。スケールの値、または point や band スケールでは行のインデックスです([0, 3, 6] は3行おきにラベルを付けます)。ドメイン内の値にはそれぞれ目盛りが付きます。 |
format | .format(..) | (value: number, index: number) => string | カテゴリのバンドスケールではバンドのカテゴリ、それ以外では、目盛りの間隔に必要な小数桁数で、桁区切りを付けた値 | 各目盛りのテキスト。目盛りの値(point または band スケールでは行のインデックス)と、軸の目盛りの中でのインデックスを受け取る、アプリの関数です。 |
labelClass、labelStyle | .label_class(..)、.label_style(..) | string、StyleProp<TextStyle> | なし | label パート。目盛りごとのテキストで、フォント、色、パディングはアプリが指定します。プロット側のパディングが、端からの距離になります(下の軸では pt-2)。 |
tickClass、tickStyle | .tick_class(..)、.tick_style(..) | string、StyleProp<ViewStyle> | なし | tick パート。端にある目盛りごとのビューで、目盛りを中心に、ラベルの方向へ伸びます(下の軸では h-1 w-px bg-border)。スタイルを指定すると表示されます。 |
lineClass、lineStyle | .line_class(..)、.line_style(..) | string、StyleProp<ViewStyle> | なし | line パート。ラベル側の端全体に沿ったビューです(下の軸では h-px bg-border)。スタイルを指定すると表示されます。 |
outside では、d3の軸がチャートのマージンに置かれるのと同様に、ラベルがプロットのマージンに置かれます。プロットはアプリが与えたサイズを保ち、アプリは軸の側に、ラベルと同じ深さのマージン(その下の pt-2 text-xs ラベルなら mb-6)、または親のパディングを与えます。同じ端にある2つの軸は、それぞれ自分の値に、自分のスタイルでラベルを付けます。たとえば、範囲の両端は薄く、中ほどは濃くできます。
format は、目盛りまたは関数が変わったとき、次のターンで目盛りごとに1回呼ばれます。プロットの描画中やポインターの追従中には呼ばれません。純粋な関数にしてください(同じ値には同じテキスト)。これを指定しない場合、目盛りは d3 が書くとおりに読まれます。1,500、0.25、−2。Rust では、plot_axis() が軸を作り、その format が TickFormat::from_fn(|value, index| ..) になります。
軸はポインターを受け付けず、スクリーンリーダーはラベルではなくプロットを読み上げます。
#PlotLabel
PlotLabelは、プロットの各点にテキストを書きます。ピークの上のキャプション、線の最後の点のタグ、グリッド線の端のメモなどです。textsはそれぞれ、マークと同じくプロットのスケールの値で自分の点を指定します。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 は最初の 2 行の中間)。 | |
y | number | 上端の y 軸方向の位置。y スケールの値。 | |
align | "left" | "center" | "right" | "left" | 点から見た x 方向の置き方。点から始める、点を中心にする、点で終わる、のいずれか。 |
class, style | string, StyleProp<TextStyle> | なし | テキスト自身のクラスとスタイル。ラベルから継承するものより優先されます。ボックス(タグの背景とパディング)や点からの距離を指定します。 |
見た目はアプリ側で決めます。ラベルの 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 で 1 本ずつ順に)、ドットは何もない状態から大きくなるように(ドットの stagger で 1 つずつ順に)、セルは中央から大きくなるように(セルの stagger で 1 つずつ順に)現れます。円グラフと弧のリングは始点から時計回りに広がり、放射状の線は内側の半径から伸びてドットもそれに伴います。サンキーのノードとリボンは先頭の端から現れ、ラベルはフェードインします。 |
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)で記憶されるため、各行のプロットには固有の世代を与えます。行のキーに、再生し直すべき要素を組み合わせたものです(`${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、Web)となり、キーボードフォーカスがある間は、ポインターと同じホバーをキーで動かせます。クロスライン、ドット、ツールチップは pointer のスプリングで滑らかに動き、円グラフのスライスは持ち上がり、onHoverChange が行を通知し、スクリーンリーダーはアプリがそのために指定した accessibilityValue を読み上げます。フォーカスリングはアプリ側のもので、focus-visible: クラスまたは 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 アクションを提供するすべてのスクリーンリーダーが、次の行と前の行へ移動します。反転したスケールでも、行の順に従います。
#チャートの構築
チャートは、プロット、そのマーク、軸、そしてそれらを囲むアプリ独自の要素で構成されます。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)。1 つがホバーされている間は他の行の棒を控えめにし(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} を持つプロットです。マークを描画するだけで、ポインターには追従しません。
#アクセシビリティ
- プロットはスクリーンリーダーにとって 1 つの要素で、image ロールとアプリの
accessibilityLabelを持ちます。マーク、軸のラベル、PlotLabel、ホバーのパーツはその一部であり、それら自身は何も読み上げません。ラベルには、チャートが何を示しているか、何が際立っているか(範囲、最も高い値)を書き、accessibilityValueにはポインターの下の行を表す言葉を与えてください。そうすると、スクリーンリーダーは値が変わるたびにそれを読み上げます。 - ポインターの下の行は、ポインターのあるプラットフォーム(デスクトップ、Web、トラックパッド付きの iPad)では
onHoverChangeで通知されます。タッチスクリーンではホバーは通知されません。タッチ操作のユーザーに必要な値はアプリ独自の要素に表示するか、数値が重要な場面では行をテキストやリストとして提供してください。 - 円グラフでは、通知される行はポインターの下のスライスです。
accessibilityValueにその名前、値、割合("Team, 340 seats, 34.0%")を与え、ラベルに合計と最大の割合を書いてください。レーダーでは、ポインターに最も近い軸です。その軸の名前と、そこでの各シリーズの値を与えてください。 - インタラクティブなプロットはキーボードフォーカスの停止位置で、矢印キーでホバーを動かせます(キーボード)。また、スクリーンリーダーに対して、行を順に移動するための
incrementとdecrementを提供します(VoiceOver では adjustable として読み上げられます)。interactive={false}を持つプロットはどちらでもなく、いずれの場合もプレスを受け取りません。
ラベルとロール全般については、アクセシビリティを参照してください。
#プラットフォームに関する注意
- どのプラットフォームでも、マークは Rust で記録されたマークごとの 1 つの描画から、同じ方法で描画されます。行が変更されると、その行を読み取るマークがまとめて 1 回のバッチで再描画されます。ペイントクラス(外観、
dark:)が変更された場合は、描画を記録し直さずに、その描画を再送します。 - appear は、プラットフォームのアニメーターが動かす値で、線と面をクリップし、棒を成長させます。ホバーされた行から離れた棒がフェードアウトする際の不透明度は、アニメーターがホバー位置から計算します。ホバーの各パーツはアニメーターが動かしてフェードさせるビューで、アニメーターはプロットのサイズとツールチップのサイズから、ツールチップがプロット内に収まるようにします。
- 面の背景は専用のキャンバスに、面の形状でクリップして描画されます。グラデーションは、プラットフォームがビューの
bg-linear-*に対して描画するものと同じです。面のクラス(外観、dark:)が変更された場合は、記録し直さずに描画を再送します。 - 円グラフは各スライスをそれぞれ専用のキャンバスに描画します。ポインター下のスライスは、スライスの角度と半径から Rust 側で特定されます。スライスの持ち上がりは半径、他のスライスのフェードは不透明度で、どちらもアニメーターが動かします。appear のスイープも同様にスライスの角度をアニメーターが動かすため、いずれも描画を記録し直しません。
- ラジアルラインでは、ポインター下の行は点の角度から Rust 側で特定されます。ホバードットはアニメーターがポインター下の行に配置するビューで(キーが切り替わるときは
pointerスプリングで滑らかに移動します)、表示時や新しい値への移動時には、点は元の位置からアニメーターが動かす数値になるため、ラインは変更ごとに 1 回だけ記録されます。 - ドットマークは 1 つのキャンバスに描画されます。ポインター下のドットは、ドットの中心と半径から Rust 側で特定されます。ホバードットはアニメーターがそのドット上に、同じ大きさで配置するビューです。appear の成長、変更による移動、ホバーのフェードはアニメーターが動かす値であるため、ドットは変更ごとに 1 回だけ記録されます。
- セルマークは 1 つのキャンバスに描画されます。ポインター下のセルは、セルのボックスから Rust 側で特定されます。ホバーセルはアニメーターがそのセル上に配置するビューです。成長、変更による色や移動、ホバーのフェードはアニメーターが動かす値であるため、セルは変更ごとに 1 回だけ記録されます。各ラベルはプラットフォームのテキストで、レイアウト後のサイズがセル内に収まる場合に表示されます。
- 軸のラベル、目盛り線、軸線は、プラットフォーム固有のテキストとビューです。Rust 側のレイアウトが、プロットのサイズとスケールから配置します。プロットのサイズが変わると、これらはマークとともに移動し、新しいドメインではラベルが書き直されます。