本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

Plot

Plot 是圖表的框架:它顯示的資料列、x 軸與 y 軸的刻度、位於其上的指標,以及它的動態。它的標記負責繪製其中的內容:穿過一個數列的 Line、數列與基線之間的 Area、在 band 刻度的各個 band 中每列一個的 Bar、每列從起點到終點的 Rule(蠟燭圖的影線)、每列在其兩個值交會處的 Dot(散佈圖的點、泡泡圖的泡泡)、在兩個類別的 band 中每列一格並依其值著色的 Cell(熱圖)、橫跨整張圖的一組線 Grid、各列佔比的 Pie(中間有洞的甜甜圈圖)、兩個角度之間的 Arc、繞著中心並在後方襯有 RadialGrid 的環與輻條的 RadialLine(雷達圖)、列與列之間流向的 Sankey,以及由巢狀矩形組成、每個葉節點的面積即其值的 Treemap(市場地圖);PlotAxis 以其刻度的刻度線標示一條邊,PlotLabel 則在刻度的各點上寫出文字。每個標記都是一張填滿整個 plot 的 canvas,直接由資料列以原生方式繪製:app 設定資料列時只會讀取一次,之後的縮放、記錄與繪製都在 Rust 中完成,因此有一千個點的圖表不會為每個點執行任何 JS。指標同樣在 Rust 中追蹤:hover 的十字線、圓點與 tooltip 都在平台的 animator 上移動與淡入淡出,而 app 只會得知新的資料列,僅此而已。外觀由 app 決定:標記以 SVG 圖形的 CSS 繪製屬性(stroke-*、fill-*)上色,未設定時使用 currentColor;area 或 bar 會在其形狀內顯示背景(漸層、bg-linear-*);hover 的各部分(包含每個標記的圓點)以及座標軸的標籤、刻度記號與線條,都由 app 自行設定樣式並填入內容。

el-plot.tsx
el-plot.tsxLive
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)))
}

Plot、Line、Area、Bar、Rule、Dot、Cell、Grid、Pie、Arc、RadialLine、RadialGrid、Sankey、Treemap、PlotAxis 與 PlotLabel 來自 nsh/base/plot,這是 nsh/base 中專屬於它們的模組(在 Rust 中為 nsh_base::plot)。在 TypeScript 中,plot 的 props 以資料列的型別為泛型:欄位名稱會依其檢查,接收資料列的函式也會得到該列的型別。在 Rust 中,plot() 會建立一個 plot 並為每個 prop 提供一個 setter;資料列是 Rows(以 key 為索引的數值欄),數值是 RowAccessor(一個 key,或一個以資料列索引為參數的函式),而標記則使用具型別的繪製方法(.stroke_color(..)、.fill_color(..)、.stroke_width(..))。

#Plot

屬性Rust型別預設值說明
data.data(..)T[](必填)資料列,每個點一個物件。標記在 app 設定資料列時只讀取一次其值;沒有值的資料列(欄位不是數字,null)會被略過。
x.x(..)keyof T | (d: T, index: number) => number資料列的索引在 linear x 刻度上決定資料列沿 x 軸位置的依據。point 刻度則依序安排各列。
y.y(..)keyof T | (d: T, index: number) => number無標記在未指定自己的值時所繪製的內容。
xScale.x_scale(..)Scale{ type: "point" }x 軸如何安排資料列:point(依序,從左邊緣到右邊緣均勻分布;只有一列時置中)、帶有選用 domain 的 linear,或 band(每列一個 band,用於長條,見 Bar;或每個類別一個 band,用於儲存格,見 Cell)。當 y 刻度為 band 時,預設為 linear,用於橫向長條。reverse: true 讓它從右邊緣開始(反向刻度)。
yScale.y_scale(..)Scale{ type: "linear" }y 軸如何安排數值:domain 的最小值在下邊緣,最大值在上邊緣。若沒有 domain,則為標記所繪製內容的範圍。band 會從上邊緣往下為每列鋪一個 band,用於橫向長條。reverse: true 則反方向:最小值在上邊緣,資料列從下邊緣往上排(反向刻度)。
y2Scale.y2_scale(..)LinearScale無第二個 y 刻度 { type: "linear", domain?, reverse? },供指定它的標記使用(scale="y2"):雙軸圖表的右側刻度(雙軸)。若沒有 domain,則為其上各值的範圍。
alignTicks.align_ticks(..)booleantrue當兩個 y 刻度都未指定 domain 且兩邊都有標記時,兩者會被擴展到相同的整數級距,使其刻度落在同樣的線上(雙軸)。false 則讓各自維持其數值的範圍。
interactive.interactive(..)booleantrueplot 會追蹤指標與鍵盤:回報指標下的資料列並顯示 hover 的各部分,同時它是 Tab 的停駐點,方向鍵可移動 hover(鍵盤操作)。false 只繪製標記(例如清單中的 sparkline),對它的按壓會交給周圍的 view。plot 是否提供鍵盤操作,在建立時就已決定。
motion.motion(..)PlotMotion無出場動畫的時間、hover 的淡入淡出,以及鍵盤移動 hover 時所用的彈簧動畫(見下文)。
appearGeneration.appear_generation(..)number | string無啟用出場動畫:標記會在 plot 首次顯示時繪入,且每當此值改變(換了股票代號、換了期間)就再次繪入。在 PlotAppearScope 內,已在其中完成的那一代會一次完整顯示。
accessibilityLabel.accessibility_label(..)string無螢幕閱讀器對圖表的描述:它顯示什麼,以及什麼特別突出。
accessibilityValue.accessibility_value(..){ min?, max?, now?, text? }無螢幕閱讀器讀出的值:app 針對指標下資料列所寫的文字。
crossLineClass、crossLineStyle.cross_line_class(..)、.cross_line_style(..)string、StyleProp<ViewStyle>無十字線:與 plot 等高的 view,以指標下的資料列為中心。在 band 刻度上,它位於該列的 band 內,寬度與 band 相同(在 band y 刻度上則橫跨整個 plot)。
dotClass、dotStyle.dot_class(..)、.dot_style(..)string、StyleProp<ViewStyle>無hover 的圓點:為每個 Line、Area 與 RadialLine 各提供一個 view,以指標下資料列的該點為中心;Dot 的圓點大小則與指標下的圓點相同。標記的 hoverDotClass / hoverDotStyle 會取代它們,用於該標記自己的圓點。
tooltipContent.tooltip_content(..)JSX.Element無app 的 tooltip,由 tooltipPlacement 決定位置,並隨十字線移動與淡入淡出。
tooltipPlacement.tooltip_placement(..)"top" | "pointer""top"tooltip 的位置:在 plot 頂部並以十字線為中心;或在指標旁,位於十字線朝向 plot 中央的那一側,指標在上半部時置於其下方,在下半部時置於其上方。在頂部時,它會停留在 plot 的左右邊緣之間(它與上邊緣的偏移由 app 決定:放在 plot 下方的標籤會取用 plot 的高度再加上更多);在指標旁時,它會停留在 plot 內。使用鍵盤時,它位於該列中第一條線或第一個 area 的點旁(長條 band 的中央、圓餅圖切片的中央)。
tooltipOffset.tooltip_offset(..)number0tooltip 與其定位依據之間的距離:對 "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無標記,依序彼此疊繪,以及 app 疊在其上的任何內容。
class、style樣式方法string、StyleProp<ViewStyle>無plot 的尺寸與外觀;標記會繼承它的繪製屬性,如同 CSS 的繼承。focus: / focus-visible: class 與 focusStyle 用來繪製它的焦點外框。
事件Rust負載時機
onHoverChange.on_hover_change(..){ index: number | null }指標移到另一個資料列上:沿 x 軸最接近指標的資料列索引(只有 y 軸有 band 時則沿 y 軸)、指標下的 pie 切片、中心最接近指標的 dot、指標下的 cell(在兩個儲存格之間的間隙上,取較近者),或依角度最接近指標的 radial line 的資料列;指標離開 plot 後為 null(在 pie 上方時是其各個切片;在 radial line 上方時是其外半徑)。

Scale 為 { type: "point"; reverse? } | { type: "linear"; domain?: [number, number]; reverse? } | { type: "band"; domain?: string[]; paddingInner?; paddingOuter?; maxBandWidth?; bandCount?; groupPadding?; reverse? }. A linear 刻度的 domain 是其兩個值的範圍,順序不拘;只有一個值的 domain 會將它置於中間。band 刻度的 domain 是其 band 的類別,依序排列(Cell);其他選項見 Bar。

對資料列的函式(y={(d) => d.high - d.low})會在資料列或函式改變時,於下一個 turn 對每一列呼叫一次,絕不會在 plot 繪製或追蹤指標時呼叫:請保持其純粹(相同的資料列,得到相同的數字)。欄位名稱的讀取則完全不需要 JS。

#反向刻度

reverse: true 讓刻度從另一個邊緣開始,如同 visx 的刻度接受 reverse,以及 d3 的刻度接受反向書寫的 range。在 x 軸上,第一列或 domain 的最小值位於右邊緣;在 y 軸上,domain 的最小值位於上邊緣,資料列從下邊緣往上排。所有放置在刻度上的東西都會隨之改變:標記、長條的基底與生長方向、area 的基線、格線、PlotAxis 的刻度、hover 所找到的資料列、方向鍵(鍵盤操作),以及移到新 domain 的動作。在反向的 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 的出場動畫時,線與 area 會從 x 刻度起始的邊緣繪入,反向時即為右邊緣。

#雙軸

el-plot-dual.tsx
el-plot-dual.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Bar, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Two y scales in one plot: the volume bars on the plot's `yScale`, the
// close on its second one (`y2Scale`), which the line names with
// `scale="y2"` and the right-hand `PlotAxis` labels. Neither scale names a
// domain, so the plot widens both to the same round steps (`alignTicks`,
// on by default) and the one `<Grid />`, which follows the ticks, serves
// both axes. Switching the symbol moves both scales to their new domains;
// turning alignment off shows each scale on its own extent.
type Day = { day: number; volume: number; close: number; dir: "up" | "down" };

/** A repeatable walk of a stock's days: its close and its volume in
 * millions of shares, heavier on the days that move more. */
function walk(seed: number, start: number, volume: number): Day[] {
  let s = seed;
  const next = () => {
    s = (s * 16807) % 2147483647;
    return (s % 1000) / 1000;
  };
  let close = start;
  return Array.from({ length: 20 }, (_, day) => {
    const before = close;
    close = Math.round(close * (1 + (next() - 0.47) * 0.04) * 100) / 100;
    const move = Math.abs(close / before - 1);
    return { day, close, volume: Math.round(volume * (0.7 + next() * 0.5) * (1 + move * 18) * 10) / 10, dir: close >= before ? "up" : "down" };
  });
}

const SYMBOLS = {
  ORBX: walk(23, 184, 42),
  QBIT: walk(71, 36.5, 160),
} as const;
type Symbol = keyof typeof SYMBOLS;

const num = { fontVariant: ["tabular-nums" as const] };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;
const SIDE = 40;
const shares = (m: number) => (m === 0 ? "0" : m >= 1000 ? `${(m / 1000).toFixed(1)}B` : `${m.toFixed(0)}M`);

function App() {
  const symbol = signal<Symbol>("ORBX");
  const aligned = signal(true);
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const height = desktop ? 200 : 180;
  const rows = () => SYMBOLS[symbol()];
  const day = () => rows()[hovered() ?? rows().length - 1];
  const tone = (dir: string) => (dir === "down" ? ui().down : ui().up);
  const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot dual axes</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Move the pointer over the chart to read both values" : "Volume on the left scale, the close on the right"}</Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="symbol">
              <Segmented options={["ORBX", "QBIT"] as const} value={symbol()} onChange={(s) => symbol.set(s)} />
            </Panel>
            <Panel title="alignTicks">
              <Segmented options={["on", "off"] as const} value={aligned() ? "on" : "off"} onChange={(v) => aligned.set(v === "on")} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>{aligned() ? "Both scales in the same steps" : "Each scale its own extent"}</Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <View class="flex-row items-baseline gap-3">
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{symbol()}</Text>
              <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>{`Day ${day().day + 1} · ${shares(day().volume)} shares`}</Text>
            </View>
            <Text style={{ fontSize: 20, fontWeight: "600", color: tone(day().dir), ...num }}>{`$${day().close.toFixed(2)}`}</Text>
          </View>
          <View class="flex-row justify-between">
            <Text style={{ fontSize: 10, letterSpacing: 0.8, color: ui().muted }}>SHARES</Text>
            <Text style={{ fontSize: 10, letterSpacing: 0.8, color: ui().muted }}>USD</Text>
          </View>
          <Plot
            data={rows()}
            xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15 }}
            alignTicks={aligned()}
            colorScale={{ type: "ordinal", domain: ["up", "down"], range: [tint(ui().up, 0.45), tint(ui().down, 0.45)] }}
            accessibilityLabel={`${symbol()}, ${rows().length} days: volume and close`}
            accessibilityValue={{ text: hovered() == null ? undefined : `Day ${day().day + 1}: close ${day().close}, ${shares(day().volume)} shares` }}
            appearGeneration="dual"
            motion={{ appear: { duration: 800, easing: "easeOut" }, update: { duration: 600, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 180 } }}
            crossLineClass="w-full h-full"
            crossLineStyle={{ backgroundColor: tint(ui().text, 0.05) }}
            dotStyle={{ width: 9, height: 9, borderRadius: 4.5, borderWidth: 2, borderColor: ui().info, backgroundColor: ui().page }}
            onHoverChange={({ index }) => {
              hovered.set(index);
              const d = index == null ? undefined : rows()[index];
              log(d == null ? "null: left the chart" : `${index}: $${d.close} · ${shares(d.volume)}`);
            }}
            style={{ height, marginLeft: SIDE, marginRight: SIDE, marginBottom: AXIS_BELOW }}
          >
            {/* No values: lines at the ticks of the y scale, which the right axis' share. */}
            <Grid style={{ stroke: tint(ui().text, 0.08) }} />
            <Bar value="volume" color="dir" cornerRadii={{ topLeft: 1.5, topRight: 1.5 }} stagger={0.5} inactiveOpacity={0.4} />
            <Line y="close" scale="y2" curve="linear" style={{ stroke: ui().info, strokeWidth: 2 }} />
            <PlotAxis placement="left" format={shares} labelStyle={{ ...axisLabel(), width: SIDE, paddingRight: 8, textAlign: "right" }} />
            <PlotAxis placement="right" scale="y2" format={(v) => v.toFixed(0)} labelStyle={{ ...axisLabel(), width: SIDE, paddingLeft: 8 }} />
            <PlotAxis tickValues={[0, 9, 19]} format={(i) => `Day ${i + 1}`} labelStyle={{ fontSize: 11, lineHeight: 14, paddingTop: 12, color: ui().muted }} />
          </Plot>
        </View>
      </Split>
    </View>
  );
}

mount(App);

plot 有第二個 y 刻度,用於單位不同的數值:價格旁的成交量、利潤率旁的營收。每個沿 y 放置數值的部分都接受 scale:"y"(預設,即 plot 的 yScale)或 "y2"(即它的 y2Scale)。右側帶有 scale="y2" 的 PlotAxis 會為它加上標籤:

<Plot data={days} xScale={{ type: "band", paddingInner: 0.3 }} class="mx-10 mb-6 h-44">
  <Grid class="stroke-border" />
  <Bar value="volume" class="fill-chart-2/50" />
  <Line y="close" scale="y2" class="stroke-chart-1 stroke-2" />
  <PlotAxis placement="left" format={(v) => `${v}M`} labelClass="pr-2 text-xs text-muted-foreground" />
  <PlotAxis placement="right" scale="y2" labelClass="pl-2 text-xs text-muted-foreground" />
</Plot>

每個刻度的 domain 各自獨立:可以是 app 的 domain,或其上各值的範圍,且各自以 motion.update 移到新的 domain。當兩個刻度都未指定 domain 時,plot 會將兩者排成相同數量的整數級距(1、2 或 5 乘以十的冪,約每 32 點高度一個級距):當兩者都包含零時,零會落在兩邊的同一條線上;否則,從零開始的刻度(成交量)會向上增加額外的級距,另一個(價格)則向兩側增加。座標軸的刻度即這些級距,而既無 x 也無 y 的 Grid 會在這些位置繪製線條,因此一套格線即可服務兩個座標軸。alignTicks={false} 會關閉此行為;app 指定了 domain 時,兩個刻度都維持原樣。

hover 只會找到一列;每個標記的圓點位於它自己的刻度上,而讀數由 app 決定,app 會讀取 onHoverChange 所回報之資料列的兩個值。兩個刻度上的長條會在每個 band 中並排站立,而 stack 只會堆疊其刻度上的長條。y2Scale 是線性的:y 軸有 band 的 plot 沒有第二個 y 刻度。

#Line、Area 與 Grid

Mark屬性型別預設值說明
Lineystring | (d, index) => numberplot 的 y繪製的內容:每個有值的資料列對應一個點,沒有值的資料列會被略過。
Line、Areadata、xT[]、string | (d, index) => numberplot 的資料列自有系列的資料列(其他 x 值),由其自身的 x 放置在 plot 的 scale 上(沒有時則使用資料列的索引)。
Line、Areacurve"natural" | "linear" | "stepAfter""natural"點與點的連接方式:平滑曲線、直線段,或是在下一個資料列出現前維持每個值的階梯。area 的底邊沿用同一條曲線。
Linedot、dotClass、dotStyleboolean、string、StyleProp<ViewStyle>false每個點上的圓點:以該點為中心的 view,其大小、背景與邊框由 app 決定。
Line、AreahoverDotClass、hoverDotStylestring、StyleProp<ViewStyle>plot 的 dotClass、dotStyle它的 hover 圓點,位於指標所在資料列對應的點上:每個系列的圓點使用各自的顏色。各自取代 plot 的設定(mark 的顏色優先於 plot 的大小與外圈);hidden 則不顯示任何圓點。
AreabackgroundClass、backgroundStylestring、StyleProp<ViewStyle>無它的背景:圍繞其形狀的方框(從第一個點到最後一個點,從最高點到基線,在 band 上則到底邊的最低點),其背景(bg-*、bg-linear-to-b from-… to-…、backgroundImage)只顯示在形狀內部,位於其 fill 之下。設定背景後,若未另外設定,fill 為 none。
Areay1string | (d, index) => numberplot 的 y構成頂邊的內容。
Areay0number | string | (d, index) => numbery scale 的低端邊界它的底邊:數字代表 y scale 上的基線;欄位或函式則為每個資料列提供各自的底部值,使 area 成為一個 band(某日的最低到最高、預測的範圍)。未設定時,area 會收合到 y scale 最小值所在的邊:通常是底邊,在反向 scale 上則是頂邊(從頂部垂下的 area)。任一邊緣沒有值的資料列會被略過。
Line、Area、Gridscale"y" | "y2""y"其值所依循的 y scale:plot 的 yScale 或其第二個 scale y2Scale(Dual axes)。
Gridxnumber[]無垂直線的位置,以 x scale 的值表示(在 point scale 上則為資料列索引)。
Gridynumber[]無水平線的位置,以 y scale 的值表示。若 x 與 y 皆未設定,線條會沿用其 scale 的刻度,即沒有 ticks 的 PlotAxis 所放置的位置(橫向 bar 時沿 x 軸)。
Gridticksnumber座標軸的預設值在 x 與 y 皆未設定時,大約繪製多少條線,如同 PlotAxis 的 ticks。
Line、Areacolorstring | (d, index) => string無其資料列的類別,顏色取自 plot 的 colorScale:在未另外設定時,第一個資料列的類別決定線條的 stroke(area 的 fill)(Colors by category)。

每個 mark 都填滿 plot,並以其 SVG paint properties 繪製,自身的設定優先於 plot 的設定,如同 CSS 的繼承方式:

Mark繪製所用屬性未設定時
Linestroke、strokeWidth、strokeDasharraycurrentColor、寬度 1、實線
Areafill(作用於其 background 部分);其頂邊使用 stroke、strokeWidth、strokeDasharrayfill currentColor(有背景時為 none);無描邊
Gridstroke、strokeWidth、strokeDasharraycurrentColor、寬度 1、實線;每條線都對齊整個裝置像素,因此細線也能保持清晰

Line 或 Area 所繪製的值,以及 area 的 y0(其基線,或每個資料列的底部值),在 plot 沒有設定時會構成 y scale 的 domain;grid 的值則不會。只有在 mark 所繪製的內容改變時(其資料列、值、曲線、plot 的大小或 scale、其繪製設定),它才會重新繪製;指標只會移動 hover 的各個部分,不會讓 mark 重新繪製。

band 是 Area 的一種,其 y0 會讀取資料列:頂邊穿過 y1,底邊則經由 y0 返回,兩者都使用 area 的 curve,而 appear 動畫會從起始邊把兩者一併畫入。它的 hover 圓點位於頂邊上;疊在其上的 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 相同,如同在瀏覽器中:是一種顏色。線條下方的漸層是 area 的背景,就如同頁面在形狀中顯示漸層的方式(方框的 background-image 搭配 clip-path):backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" 會從 area 的最高點到基線逐漸淡出,使用主題的 token 與外觀的顏色,如同任何 view 的背景。

#Bar

Bar 會在 band scale 中,於每個資料列的 band 內為該列繪製一根 bar:在 band x scale 上為直立,在 band y scale 上為橫向(此時 x scale 為 linear)。每根 bar 從 base 延伸到該資料列在另一個 scale 上的值,因此負值會朝反方向增長,沒有值的資料列則沒有 bar。在反向的值 scale 上,bar 會從頂邊向下或從右邊向左增長,而反向的 band scale 會把第一個資料列的 band 放在右側或底部(Reversed scales)。

import { Bar, Grid, Plot } from "nsh/base/plot";

const sales = [
  { month: "Jan", online: 42, store: 31 },
  { month: "Feb", online: 51, store: 28 },
  { month: "Mar", online: 48, store: 36 },
];

<Plot
  data={sales}
  xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15, groupPadding: 0.1 }}
  yScale={{ type: "linear", domain: [0, 60] }}
  class="h-40"
>
  <Grid y={[20, 40, 60]} class="stroke-border" />
  <Bar value="online" class="fill-chart-1" cornerRadii={{ topLeft: 4, topRight: 4 }} />
  <Bar value="store" class="fill-chart-2" cornerRadii={{ topLeft: 4, topRight: 4 }} />
</Plot>

不在 stack 中的 bar 會依 mark 的順序在每個 band 內並排;具有相同 stack 的 bar 會堆疊,每個資料列的 bar 從 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}`} />
屬性型別預設值說明
valuestring | (d, index) => numberplot 的 y每根 bar 所顯示的內容。
basenumber | string | (d, index) => number0bar 在值 scale 上開始增長的值:可為所有資料列共用的一個值,或是為每個資料列提供各自值的欄位或函式,使每根 bar 涵蓋該列的基準值到其值(沒有基準值的資料列則沒有 bar)。它會計入 domain。stack 會逐列從其第一根 bar 的基準值開始增長。
stackstring無所加入的 stack:具有相同 stack 的 bar 會依序堆疊(沒有值的資料列不增加任何內容)。
scale"y" | "y2""y"其直立 bar 的值所依循的 y scale(Dual axes);位於兩者上的 bar 會並排,而 stack 只堆疊位於其 scale 上的 bar。
cornerRadiinumber | { topLeft?, topRight?, bottomRight?, bottomLeft? }0bar 每個圓角的半徑;{ topLeft: 4, topRight: 4 } 會讓直立 bar 的頂端變圓。大於 bar 一半的半徑會被調整到符合 bar 大小。
staggernumber0搭配 plot 的 appear,bar 會逐列依序開始,分布在此比例的時間內(0 到 0.95),每根 bar 在剩餘時間內增長;0 則讓它們同時增長。
inactiveOpacitynumber無當指標位於 plot 上時,指標所在資料列以外的 bar 的不透明度,使該列更突出:它會隨 hover 淡入淡出,並隨之從一根 bar 移到另一根。
labelstring | (d, index) => string無每根 bar 值端的文字:可為欄位(其文字或數字),或由 app 格式化並回傳文字的函式。
labelClass、labelStylestring、StyleProp<TextStyle>無label 部分:置中於 bar 的文字,位於直立 bar 末端的上方(向下增長的 bar 則在下方),橫向 bar 則在其後(向回增長的 bar 則在其前)。其 padding 即為與 bar 的距離。
backgroundClass、backgroundStylestring、StyleProp<ViewStyle>無它的背景:每根 bar 的方框,其背景(bg-linear-to-t from-… to-…,從 bar 底部到頂部)只顯示在 bar 內部,位於其 fill 之下。設定背景後,若未另外設定,fill 為 none。
colorstring | (d, index) => string無每根 bar 的類別,顏色取自 plot 的 colorScale:在未另外設定時為 bar 的 fill(Colors by category)。

band scale 會為 plot 的每個資料列依序配置一個 band,或為其 domain 的每個類別配置一個 band,如同 d3 的 scaleBand:

選項預設值說明
domain無各 band 的類別,依序排列(d3 的 scaleBand().domain()):Cell 會放入其資料列類別所屬的 band,而 PlotAxis 會寫出每個 band 的類別;重複寫入的類別保留其第一次的位置。未設定時,band 即為資料列,或在 cell 上則為各 cell 沿座標軸所指名的類別,依資料列帶來的順序排列。
paddingInner0相鄰 band 之間的間隙,為每個 band 步長的比例(0 到 1)。
paddingOuter0第一個 band 之前與最後一個 band 之後的間隙,為步長的比例。取 paddingInner 的一半,可讓各 band 的中心在整個 plot 上等距。
maxBandWidth無band 的最大寬度,以 point 為單位:寬 plot 上的少數 band 會保持窄寬,各自位於其位置的中央。
bandCount資料列當多於資料列時,座標軸所配置的 band 數:資料列取用前面的 band,並保持其隨資料列到來時的寬度與位置。
groupPadding0同一 band 內並排的 bar 之間的間隙,為每根 bar 所佔位置的比例。
reversefalseband 從另一端開始排列:第一個資料列的 band 在 x 軸的右側、y 軸的底部;同一 band 內並排的 bar 維持其順序。

bar 以其 fill(fill-*;如同 CSS 的繼承方式,從 plot 繼承)繪製,未設定時使用 currentColor(其類別的顏色搭配 color),且不顯示 hover 圓點。在 band scale 上,hover 會找出指標下方的 band,plot 的十字線則位於該 band 中,與其同寬:crossLineClass="w-full h-full bg-foreground/5" 為該 band 著色,w-px 則在其中央保留一條線。在 plot 沒有設定時,bar 的值與基準值會構成值 scale 的 domain。搭配 plot 的 appear,每根 bar 會從其 stack 的基準值長出;動作、淡入淡出與滑動都在平台的 animator 上執行,指標不會讓任何 bar 重新繪製。

#範圍

el-plot-ranges.tsx
el-plot-ranges.tsxLive
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 為欄位或函式時,每根 bar 涵蓋其資料列的兩個值:waterfall 的一個步驟,從之前的累計值到之後的累計值;蠟燭圖的實體,從開盤到收盤;任務則從開始到結束。bar 出現時從其自身的基準值開始增長,其標籤位於值端,上升的 bar 在上方,下降的 bar 在下方。

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>

funnel 是置中的橫向 bar:在 band y scale 上使用 base={(d) => -d.users / 2} 與 value={(d) => d.users / 2}。

#Rule

Rule 會為每個資料列沿值軸繪製一條直線,從 base 到該列的值:在 band scale 上位於該列 band 的中央(在 band y scale 上則橫跨 plot),在 point 或 linear scale 上則位於該列沿 x 軸的位置。蠟燭圖的燭芯、箱型圖的鬚線、誤差線與棒棒糖圖的莖都是 rule;蠟燭圖則是 Bar 疊在 Rule 上:

import { Bar, Plot, Rule } from "nsh/base/plot";

<Plot
  data={days}
  xScale={{ type: "band", paddingInner: 0.4 }}
  colorScale={{ type: "ordinal", domain: ["up", "down"], range: ["#16a34a", "#dc2626"] }}
  class="h-48"
>
  <Rule value="high" base="low" color={(d) => (d.close >= d.open ? "up" : "down")} />
  <Bar value="close" base="open" color={(d) => (d.close >= d.open ? "up" : "down")} />
</Plot>
屬性型別預設值說明
valuestring | (d, index) => numberplot 的 y每條線的終點。沒有值的資料列則沒有線。
basenumber | string | (d, index) => number0每條線的起點:可為所有資料列共用的一個值(棒棒糖圖從零線出發的莖),或是為每個資料列提供各自值的欄位或函式(蠟燭圖的最低價)。它會計入 domain。
scale"y" | "y2""y"其值所依循的 y scale(Dual axes)。
staggernumber0搭配 plot 的 appear,線條會逐列依序開始,分布在此比例的時間內(0 到 0.95),每條線在剩餘時間內從其起點增長。
inactiveOpacitynumber無當指標位於 plot 上時,指標所在資料列以外的線條的不透明度,與 Bar 的相同。
colorstring | (d, index) => string無每條線的類別,顏色取自 plot 的 colorScale:在未另外設定時為線條的 stroke。

rule 以其 stroke(stroke-*、strokeWidth、strokeDasharray;如同 CSS 的繼承方式,從 plot 繼承)描邊,未設定時使用 currentColor,且不顯示 hover 圓點。在 plot 沒有設定時,其值與基準值會構成值 scale 的 domain;它會隨 plot 的 appear 從起點增長,並隨 scale 的 domain 移動,皆在平台的 animator 上執行。

#Dot

el-plot-dots.tsx
el-plot-dots.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Dot, Grid, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Dots placed by two values of each row: a `Dot` per row at its `x` on a
// linear x scale and its `value` up the y axis. Its `color` names each
// row's category, which the plot's ordinal `colorScale` turns into the
// dot's fill; a stroke in the card's color rings each dot, so dots that
// overlap stay apart.
//
// The scatter is one session a dot, minutes in the app against trades
// placed, colored by platform. The bubbles are countries by income and
// life expectancy, each bubble's radius from its population through a
// square-root `rScale`, so its area is in proportion to it; the bigger
// bubbles draw first and the smaller stay in sight over them. The dots
// grow in one after another, the dot under the pointer (or the arrow
// keys) is ringed by its hover dot and read out in the header, and the
// others fade.
type Session = { minutes: number; trades: number; platform: "iOS" | "Android" | "Web" };
type Country = { name: string; gdp: number; life: number; pop: number; region: Region };
type Region = "Asia" | "Africa" | "Americas" | "Europe" | "Oceania";

/** A repeatable stream of numbers in [0, 1), so every visit shows the same chart. */
const random = (seed: number) => {
  let s = seed;
  return () => {
    s = (s * 16807) % 2147483647;
    return (s % 10000) / 10000;
  };
};
const PLATFORMS = ["iOS", "Android", "Web"] as const;
/** Fifteen sessions per platform around its own habits, in the order of their length. */
const SESSIONS: Session[] = (() => {
  const r = random(23);
  const centers = { iOS: [14, 6], Android: [9, 4], Web: [24, 10] } as const;
  return PLATFORMS.flatMap((platform) =>
    Array.from({ length: 15 }, () => {
      const [m, t] = centers[platform];
      const minutes = Math.max(1, Math.round(m + (r() - 0.5) * m * 1.1));
      const trades = Math.max(0, Math.round(t * (minutes / m) + (r() - 0.5) * 5));
      return { minutes, trades, platform };
    }),
  ).sort((a, b) => a.minutes - b.minutes);
})();

/** Countries' GDP per person (thousands of dollars), life expectancy (years) and population (millions), in the order of income. */
const COUNTRIES: Country[] = (
  [
    ["Ethiopia", 1.3, 66.6, 127, "Africa"],
    ["Nigeria", 1.6, 53.6, 224, "Africa"],
    ["India", 2.5, 70.8, 1430, "Asia"],
    ["Egypt", 3.5, 70.4, 113, "Africa"],
    ["Vietnam", 4.3, 74.6, 99, "Asia"],
    ["Indonesia", 4.9, 71.3, 278, "Asia"],
    ["South Africa", 6.0, 63.2, 60, "Africa"],
    ["Brazil", 10.0, 75.8, 216, "Americas"],
    ["China", 12.6, 78.6, 1410, "Asia"],
    ["Mexico", 13.9, 74.8, 129, "Americas"],
    ["South Korea", 33.1, 83.7, 52, "Asia"],
    ["Japan", 33.8, 84.7, 124, "Asia"],
    ["France", 44.5, 82.8, 68, "Europe"],
    ["United Kingdom", 48.9, 81.3, 68, "Europe"],
    ["Germany", 52.7, 81.4, 84, "Europe"],
    ["Australia", 64.7, 83.5, 27, "Oceania"],
    ["United States", 80.4, 77.5, 335, "Americas"],
  ] as const
).map(([name, gdp, life, pop, region]) => ({ name, gdp, life, pop, region }));
const REGIONS: Region[] = ["Asia", "Africa", "Americas", "Europe", "Oceania"];

const num = { fontVariant: ["tabular-nums" as const] };
/** The room under a plot for its x axis' labels: their 12 above a line of 14. */
const AXIS_BELOW = 12 + 14;
const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });

function App() {
  const chart = signal<"scatter" | "bubbles">("scatter");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const height = desktop ? 220 : 200;
  const platformColors = () => [ui().accent, ui().info, ui().warn];
  const regionColors = () => [ui().info, ui().warn, ui().accent, ui().violet, ui().muted];
  const session = () => (hovered() == null || chart() !== "scatter" ? undefined : SESSIONS[hovered()!]);
  const country = () => (hovered() == null || chart() !== "bubbles" ? undefined : COUNTRIES[hovered()!]);
  // A ring around the dot under the pointer, in the text's color, a little out from its edge.
  const ring = () => ({ outlineWidth: 1.5, outlineOffset: 1.5, outlineColor: ui().text });
  const key = (color: string, label: string) => (
    <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
      <View style={{ width: 9, height: 9, borderRadius: 4.5, backgroundColor: color }} />
      <Text style={{ fontSize: 12, color: ui().muted }}>{label}</Text>
    </View>
  );
  const motion = { appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 320, damping: 32 } } as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot dots</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over a dot to read it" : chart() === "scatter" ? "Each dot is a session, colored by platform" : "Each bubble's area is a country's population"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["scatter", "bubbles"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>
                {chart() === "scatter" ? `Dot x minutes · value trades · ${SESSIONS.length} rows` : `Dot r pop · rScale sqrt · ${COUNTRIES.length} rows`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <If
            when={chart() === "scatter"}
            else={
              <>
                <View class="flex-row items-baseline gap-3">
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{country()?.name ?? "Income and lifespan"}</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                      {country() ? `${country()!.region} · ${country()!.pop.toLocaleString("en-US")}M people` : "GDP per person against life expectancy"}
                    </Text>
                  </View>
                  <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{country() ? `${country()!.life.toFixed(1)} yrs` : ""}</Text>
                </View>
                <View class="flex-row flex-wrap gap-x-3 gap-y-1">{REGIONS.map((r, i) => key(regionColors()[i], r))}</View>
                <Plot
                  data={COUNTRIES}
                  x="gdp"
                  xScale={{ type: "linear", domain: [-10, 95] }}
                  yScale={{ type: "linear", domain: [48, 90] }}
                  colorScale={{ type: "ordinal", domain: REGIONS, range: regionColors().map((c) => tint(c, 0.75)) }}
                  accessibilityLabel={`${COUNTRIES.length} countries by GDP per person and life expectancy, sized by population`}
                  accessibilityValue={{ text: country() && `${country()!.name}: $${country()!.gdp}k per person, ${country()!.life} years` }}
                  appearGeneration="bubbles"
                  motion={motion}
                  onHoverChange={({ index }) => {
                    hovered.set(index);
                    const c = index == null ? undefined : COUNTRIES[index];
                    log(c == null ? "null: left the chart" : `${index}: ${c.name}, $${c.gdp}k, ${c.life} yrs, ${c.pop}M`);
                  }}
                  style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
                >
                  <Grid x={[20, 40, 60, 80]} y={[60, 70, 80]} style={{ stroke: tint(ui().text, 0.08) }} />
                  {/* A bubble's area is its population: radii from 3 to the largest's, over 0 to the most people. */}
                  <Dot
                    value="life"
                    r="pop"
                    rScale={{ type: "sqrt", range: [3, desktop ? 26 : 18] }}
                    color="region"
                    stagger={0.5}
                    inactiveOpacity={0.25}
                    style={{ stroke: ui().card.backgroundColor, strokeWidth: 1 }}
                    hoverDotClass="rounded-full"
                    hoverDotStyle={ring()}
                  />
                  <PlotAxis placement="right" tickValues={[60, 70, 80]} format={(v) => `${v}`} labelStyle={{ ...axisLabel(), width: 36, paddingLeft: 8, textAlign: "right" }} />
                  <PlotAxis tickValues={[0, 20, 40, 60, 80]} format={(v) => `$${v}k`} labelStyle={{ ...axisLabel(), lineHeight: 14, paddingTop: 12 }} />
                </Plot>
              </>
            }
          >
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{session() ? `${session()!.platform} session` : "Sessions"}</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                  {session() ? `${session()!.minutes} minutes in the app` : `${SESSIONS.length} sessions · minutes against trades`}
                </Text>
              </View>
              <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{session() ? `${session()!.trades} trades` : ""}</Text>
            </View>
            <View class="flex-row flex-wrap gap-x-3 gap-y-1">{PLATFORMS.map((p, i) => key(platformColors()[i], p))}</View>
            <Plot
              data={SESSIONS}
              x="minutes"
              xScale={{ type: "linear", domain: [0, 42] }}
              yScale={{ type: "linear", domain: [0, 22] }}
              colorScale={{ type: "ordinal", domain: [...PLATFORMS], range: platformColors() }}
              accessibilityLabel={`${SESSIONS.length} sessions by minutes in the app and trades placed, longest on the web`}
              accessibilityValue={{ text: session() && `${session()!.platform}: ${session()!.minutes} minutes, ${session()!.trades} trades` }}
              appearGeneration="scatter"
              motion={motion}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const s = index == null ? undefined : SESSIONS[index];
                log(s == null ? "null: left the chart" : `${index}: ${s.platform}, ${s.minutes} min, ${s.trades} trades`);
              }}
              style={{ height, marginRight: 36, marginBottom: AXIS_BELOW }}
            >
              <Grid x={[10, 20, 30, 40]} y={[5, 10, 15, 20]} style={{ stroke: tint(ui().text, 0.08) }} />
              <Grid y={[0]} style={{ stroke: tint(ui().text, 0.2) }} />
              <Dot
                value="trades"
                r={desktop ? 4 : 4.5}
                color="platform"
                stagger={0.6}
                inactiveOpacity={0.3}
                style={{ stroke: ui().card.backgroundColor, strokeWidth: 1.5 }}
                hoverDotClass="rounded-full"
                hoverDotStyle={ring()}
              />
              <PlotAxis placement="right" tickValues={[5, 10, 15, 20]} labelStyle={{ ...axisLabel(), width: 36, paddingLeft: 8, textAlign: "right" }} />
              <PlotAxis tickValues={[0, 10, 20, 30, 40]} format={(v) => `${v} min`} labelStyle={{ ...axisLabel(), lineHeight: 14, paddingTop: 12 }} />
            </Plot>
          </If>
        </View>
      </Split>
    </View>
  );
}

mount(App);

Dot 會為每一列在其兩個值交會之處繪製一個點:其 x 沿著 linear x 刻度,其 value 沿著 y 軸向上,構成散佈圖;在 band x 刻度上,每個點位於其所屬列的 band 中央;在 band y 刻度上(此時 x 刻度為 linear),其值沿 x 延伸,其所屬列的 band 則沿圖表向下排列,構成點圖(dot plot)。其半徑 r 可以是適用於所有點的單一數字,也可以是每一列的某個值,由 rScale 轉換為半徑,構成氣泡圖。沒有值的列不會有點。

import { Dot, Grid, Plot } from "nsh/base/plot";
import { token } from "nsh";

const sessions = [
  { minutes: 12, trades: 5, platform: "iOS" },
  { minutes: 8, trades: 3, platform: "Android" },
  { minutes: 26, trades: 11, platform: "Web" },
  { minutes: 17, trades: 8, platform: "iOS" },
];

<Plot
  data={sessions}
  x="minutes"
  xScale={{ type: "linear", domain: [0, 40] }}
  yScale={{ type: "linear", domain: [0, 20] }}
  colorScale={{ type: "ordinal", domain: ["iOS", "Android", "Web"], range: [token("chart-1"), token("chart-2"), token("chart-3")] }}
  class="h-48"
>
  <Grid x={[10, 20, 30]} y={[5, 10, 15]} class="stroke-border" />
  <Dot
    value="trades"
    r={4}
    color="platform"
    inactiveOpacity={0.3}
    class="stroke-card stroke-2"
    hoverDotClass="rounded-full outline-2 outline-offset-1 outline-foreground"
  />
</Plot>

當 r 為欄位或函式時,每一列的值決定其點的大小。rScale={{ type: "sqrt", range: [min, max] }} 會如同 d3 的 scaleSqrt 一樣,將這些值的平方根對應到以點(point)為單位的半徑,因此當範圍從 0 開始時,氣泡的面積與其值成正比;範圍從大於 0 的值開始,則能讓最小的氣泡保持可見。除非刻度指定了定義域,否則定義域為 0 到最大值。較大的點會先繪製,使較小的點能顯示在它們上方而保持可見:

<Plot
  data={countries}
  x="gdp"
  xScale={{ type: "linear", domain: [-10, 95] }}
  yScale={{ type: "linear", domain: [48, 90] }}
  colorScale={{ type: "ordinal", range: [token("chart-1"), token("chart-2"), token("chart-3"), token("chart-4")] }}
  class="h-56"
>
  <Dot value="life" r="population" rScale={{ type: "sqrt", range: [3, 24] }} color="region" class="stroke-card" />
</Plot>

當圖表沒有定義域時,點的值會形成值刻度的定義域,且不會刻意包含 0,因此散佈圖的座標軸會貼合其資料點。當點很大時,請為圖表的 domain 在值的周圍留出一些空間:位於定義域邊緣的氣泡會被繪製到圖表邊緣之外。

同一組 band 中的兩個 mark 可以組成比較一列中兩個值的圖表:棒棒糖圖(lollipop)是從 0 出發的 Rule,其下方在該值處有一個 Dot;啞鈴圖(dumbbell)則是在 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>
PropTypeDefaultDescription
valuestring | (d, index) => number圖表的 y每個點在值軸上的位置:沿 y 軸向上,或在 band y 刻度上沿 x 軸。沒有值的列不會有點。
rnumber | string | (d, index) => number3點的半徑(以點為單位),或是一個欄位或函式,為每一列提供一個值,由 rScale 轉換為半徑(未指定時,該值即為半徑)。值缺失的列不會有點。
rScale{ type: "sqrt"; domain?: [number, number]; range: [number, number] }noner 的值如何成為半徑:取其平方根,在 domain(未指定時為 0 到最大值)上對應到半徑的 range,因此從 0 開始的範圍會使每個點的面積與其值成正比。超出定義域的值會取範圍的端點值。
staggernumber0配合圖表的 appear,點會在整個動畫的這個比例(0 到 0.95)內逐列依序開始成長;0 則讓它們同時成長。
inactiveOpacitynumbernone當指標或按鍵停留在某個點上時,其他點的不透明度:它們會隨著懸停而淡入淡出。
colorstring | (d, index) => stringnone每個點的類別,其顏色取自圖表的 colorScale:在沒有其他設定時即為點的 fill(依類別著色)。
hitRadiusnumber8指標在超出點的邊緣多遠的範圍內仍能找到該點:在此距離內的點之中,指標會找到中心離它最近的那一個(Voronoi 單元),因此疊在大氣泡上的小氣泡能以其自身中心附近被找到。點圖的列在半徑與一列同樣大時,可從沿該列的任何位置被找到。
scale"y" | "y2""y"其值所沿用的 y 刻度(雙軸);位於 band y 刻度上的點則將其值沿 x 排列。
hoverDotClass, hoverDotStylestring, StyleProp<ViewStyle>圖表的 dotClass, dotStylehoverDot 部件:一個與指標下的點同樣寬高的視圖,以該點為中心。rounded-full outline-2 outline-offset-1 會為其加上環形框;hidden 則使此 mark 不顯示任何框。

點會以其 fill(fill-*;如同 CSS 的繼承方式從圖表繼承)填色,未設定時使用 currentColor(其類別的顏色搭配 color),並在有設定時以其 stroke(stroke-*、strokeWidth)描邊:使用卡片顏色的環可讓重疊的點彼此分開。在有點的圖表上,圖表會回報中心離指標最近的點所屬的列(距離所有點都太遠時為 null),按鍵會依列的順序逐一移動(將列依 x 排序,按鍵便會由左至右走過各點),而 hoverDot 部件會標示該點。配合圖表的 appear,點會從無到有成長;配合 motion.update,點會移動到其新的值與大小,新增列的點會成長出現,已移除列的點則會縮小消失;這一切都由平台的 animator 執行,不會重新繪製任何 mark。

#Cell

el-plot-cells.tsx
el-plot-cells.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Cell, Plot, PlotAxis } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// Heatmaps: a `Cell` per row in the band of its `x` category across and
// of its `y` category down, both band scales, colored by its `value`
// through the plot's `colorScale`.
//
// The returns are a stock's months: the months across in the order of the
// x scale's `domain` (this year's months to come stay blank), the years
// down in the order the rows bring them, a loss red and a gain green along
// a diverging ramp about 0, its domain the largest move either way, each
// return written in its cell, white on the dark ones (`data-dark:`). The
// activity is a market's trades per hour of a week along a sequential
// ramp from the card's line color to the accent; the hour of its lunch
// break has no rows and stays blank. The cells grow in one after another,
// the cell under the pointer (or the arrow keys, which step across the
// grid) is ringed and read out in the header, and the others fade.
type Month = { year: string; month: string; ret: number };
type Hour = { day: string; hour: string; trades: number };

/** A repeatable stream of numbers in [0, 1), so every visit shows the same chart. */
const random = (seed: number) => {
  let s = seed;
  return () => {
    s = (s * 16807) % 2147483647;
    return (s % 10000) / 10000;
  };
};
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** Four years of a stock's monthly returns in percent, this year's up to October. */
const RETURNS: Month[] = (() => {
  const r = random(41);
  const drift: Record<string, number> = { "2023": 2.4, "2024": 1.9, "2025": 0.6, "2026": 1.2 };
  return Object.keys(drift).flatMap((year) =>
    MONTHS.slice(0, year === "2026" ? 10 : 12).map((month) => ({ year, month, ret: Math.round((drift[year] + (r() - 0.5) * 12) * 10) / 10 })),
  );
})();
const HOURS = ["9:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00"];
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
/** A week of a market's trades per hour, in thousands: busy at the open and the close, closed from 12:00 to 13:00. */
const ACTIVITY: Hour[] = (() => {
  const r = random(17);
  const shape = [1, 0.62, 0.5, null, 0.58, 0.55, 0.86];
  return DAYS.flatMap((day, d) =>
    HOURS.flatMap((hour, h) => (shape[h] == null ? [] : [{ day, hour, trades: Math.round(40 * shape[h]! * (0.92 + d * 0.04) * (0.9 + r() * 0.2) * 10) / 10 }])),
  );
})();

const num = { fontVariant: ["tabular-nums" as const] };
const signed = (v: number) => `${v < 0 ? "−" : "+"}${Math.abs(v).toFixed(1)}%`;
const axisLabel = () => ({ fontSize: 11, color: ui().muted, ...num });
/** The largest move either way: the ends of the diverging scale's own domain. */
const REACH = Math.max(...RETURNS.map((m) => Math.abs(m.ret)));

function App() {
  const chart = signal<"returns" | "activity">("returns");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const month = () => (hovered() == null || chart() !== "returns" ? undefined : RETURNS[hovered()!]);
  const hour = () => (hovered() == null || chart() !== "activity" ? undefined : ACTIVITY[hovered()!]);
  // A ring around the cell under the pointer, in the text's color, a little out from its edge.
  const ring = () => ({ outlineWidth: 1.5, outlineOffset: 1.5, outlineColor: ui().text });
  const returnsRamp = () => [ui().down, ui().line, ui().up];
  const activityRamp = () => [ui().line, ui().accent];
  const key = (colors: string[], labels: string[]) => (
    <View style={{ gap: 4 }}>
      <View style={{ height: 6, borderRadius: 3, backgroundImage: `linear-gradient(to right, ${colors.join(", ")})` }} />
      <View class="flex-row justify-between">
        {labels.map((l) => (
          <Text style={{ fontSize: 11, color: ui().muted, ...num }}>{l}</Text>
        ))}
      </View>
    </View>
  );
  const motion = { appear: { duration: 900, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 180 }, pointer: { stiffness: 380, damping: 34 } } as const;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot cells</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Move the pointer over a cell to read it" : chart() === "returns" ? "Each cell is a month's return" : "Each cell is an hour's trades"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="chart">
              <Segmented options={["returns", "activity"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
              <Text style={{ fontSize: 13, color: ui().text, ...num }}>
                {chart() === "returns" ? `colorScale diverging · ${RETURNS.length} rows` : `colorScale sequential · ${ACTIVITY.length} rows`}
              </Text>
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <If
            when={chart() === "returns"}
            else={
              <>
                <View class="flex-row items-baseline gap-3">
                  <View style={{ flex: 1, gap: 2 }}>
                    <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{hour() ? `${hour()!.day} ${hour()!.hour}` : "Trades per hour"}</Text>
                    <Text style={{ fontSize: size.detail, color: ui().muted }}>{hour() ? "thousand trades" : "A week, closed from 12:00 to 13:00"}</Text>
                  </View>
                  <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>{hour() ? `${hour()!.trades.toFixed(1)}k` : ""}</Text>
                </View>
                {key(activityRamp(), ["Fewer", "More"])}
                <Plot
                  data={ACTIVITY}
                  xScale={{ type: "band", domain: HOURS, paddingInner: 0.12 }}
                  yScale={{ type: "band", domain: DAYS, paddingInner: 0.12 }}
                  colorScale={{ type: "sequential", range: activityRamp() }}
                  accessibilityLabel="Trades per hour over a week, busiest at the open"
                  accessibilityValue={{ text: hour() && `${hour()!.day} ${hour()!.hour}: ${hour()!.trades} thousand trades` }}
                  appearGeneration="activity"
                  motion={motion}
                  onHoverChange={({ index }) => {
                    hovered.set(index);
                    const h = index == null ? undefined : ACTIVITY[index];
                    log(h == null ? "null: left the chart" : `${index}: ${h.day} ${h.hour}, ${h.trades}k`);
                  }}
                  style={{ height: DAYS.length * 30, marginLeft: 36, marginTop: 20 }}
                >
                  <Cell x="hour" y="day" value="trades" cornerRadii={4} stagger={0.6} inactiveOpacity={0.4} hoverCellClass="rounded-[5px]" hoverCellStyle={ring()} />
                  <PlotAxis placement="top" labelStyle={{ ...axisLabel(), lineHeight: 14, paddingBottom: 6 }} />
                  <PlotAxis placement="left" labelStyle={{ ...axisLabel(), width: 36, paddingRight: 8, textAlign: "right" }} />
                </Plot>
              </>
            }
          >
            <View class="flex-row items-baseline gap-3">
              <View style={{ flex: 1, gap: 2 }}>
                <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>{month() ? `${month()!.month} ${month()!.year}` : "Monthly returns"}</Text>
                <Text style={{ fontSize: size.detail, color: ui().muted }}>{month() ? "the month's return" : "Red for a loss, green for a gain"}</Text>
              </View>
              <Text style={{ fontSize: 20, fontWeight: "600", color: month() ? (month()!.ret < 0 ? ui().down : ui().up) : ui().text, ...num }}>
                {month() ? signed(month()!.ret) : ""}
              </Text>
            </View>
            {key(returnsRamp(), [signed(-REACH), "0", signed(REACH)])}
            <Plot
              data={RETURNS}
              xScale={{ type: "band", domain: MONTHS, paddingInner: 0.1 }}
              yScale={{ type: "band", paddingInner: 0.12 }}
              colorScale={{ type: "diverging", range: returnsRamp() }}
              accessibilityLabel={`Monthly returns since 2023, ${RETURNS.filter((m) => m.ret > 0).length} of ${RETURNS.length} months up`}
              accessibilityValue={{ text: month() && `${month()!.month} ${month()!.year}: ${signed(month()!.ret)}` }}
              appearGeneration="returns"
              motion={motion}
              onHoverChange={({ index }) => {
                hovered.set(index);
                const m = index == null ? undefined : RETURNS[index];
                log(m == null ? "null: left the chart" : `${index}: ${m.month} ${m.year}, ${signed(m.ret)}`);
              }}
              style={{ height: 4 * 30, marginLeft: 36, marginTop: 20 }}
            >
              <Cell
                x="month"
                y="year"
                value="ret"
                cornerRadii={3}
                stagger={0.6}
                inactiveOpacity={0.4}
                label={(d: Month) => (d.ret < 0 ? `−${(-d.ret).toFixed(1)}` : d.ret.toFixed(1))}
                labelClass="text-black/70 data-dark:text-white/90"
                labelStyle={{ fontSize: 10, ...num }}
                hoverCellClass="rounded-[4px]"
                hoverCellStyle={ring()}
              />
              <PlotAxis placement="top" labelStyle={{ ...axisLabel(), lineHeight: 14, paddingBottom: 6 }} />
              <PlotAxis placement="left" labelStyle={{ ...axisLabel(), width: 36, paddingRight: 8, textAlign: "right" }} />
            </Plot>
            <Text style={{ fontSize: 12, color: tint(ui().text, 0.5) }}>Each return shows where its cell has room for it.</Text>
          </If>
        </View>
      </Split>
    </View>
  );
}

mount(App);

Cell 會為每一列同時在兩個 band 中繪製一個矩形:其 x 類別沿 band x 刻度的 band,以及其 y 類別沿 band y 刻度的 band,因此這些列構成一個格狀圖,即熱度圖(heatmap)。相關係數矩陣、每月報酬的日曆,以及每小時一週的交易量,都是 cell 的例子。每個 cell 會填滿它的兩個 band,因此刻度的 paddingInner 與 paddingOuter 就是 cell 之間與周圍的間隙。其顏色是其 value 沿圖表的 colorScale(一條色階,沿色階著色)所對應的顏色,或是透過序數色階由其 color 類別決定。Cell 位於圖表的 x 與 y 刻度上。

import { Cell, Plot, PlotAxis } from "nsh/base/plot";
import { token } from "nsh";

const months = [
  { year: "2025", month: "Jan", ret: 2.7 },
  { year: "2025", month: "Feb", ret: -1.4 },
  { year: "2025", month: "Mar", ret: -5.8 },
  { year: "2026", month: "Jan", ret: 1.9 },
  { year: "2026", month: "Feb", ret: 3.2 },
];

<Plot
  data={months}
  xScale={{ type: "band", domain: ["Jan", "Feb", "Mar"], paddingInner: 0.08 }}
  yScale={{ type: "band", paddingInner: 0.08 }}
  colorScale={{ type: "diverging", range: [token("destructive"), token("muted"), token("chart-1")] }}
  class="ml-10 mt-6 h-24"
>
  <Cell
    x="month"
    y="year"
    value="ret"
    cornerRadii={3}
    inactiveOpacity={0.4}
    label={(d) => d.ret.toFixed(1)}
    labelClass="text-xs tabular-nums text-black/70 data-dark:text-white"
    hoverCellClass="rounded-sm outline-2 outline-offset-1 outline-foreground"
  />
  <PlotAxis placement="top" labelClass="pb-1.5 text-xs text-muted-foreground" />
  <PlotAxis placement="left" labelClass="w-10 pr-2 text-right text-xs text-muted-foreground" />
</Plot>

band 刻度的 domain 會依序列出其 band 的類別,如同 d3 的 scaleBand().domain():日曆的月份從一月到十二月,無論其列是否已經出現,每個月都有自己的 band,因此上方的 2026 年 3 月是格中的一處空白。沒有定義域時,band 就是 cell 沿該軸所指名的類別,依列帶來它們的順序排列(上方的 2025 在 2026 之上)。沿著類別 band 刻度的 PlotAxis 會寫出每個 band 的類別,不需要 format。

相關係數矩陣在兩個軸上列出相同的持股,其 domain 為係數的完整範圍,因此無論持股為何,係數 0 都會取色階的中間色:

const tickers = ["NVDA", "AMD", "MSFT", "GLD"];

<Plot
  data={pairs}
  xScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
  yScale={{ type: "band", domain: tickers, paddingInner: 0.06 }}
  colorScale={{ type: "diverging", domain: [-1, 1], range: [token("chart-2"), token("muted"), token("chart-1")] }}
  class="aspect-square"
>
  <Cell x="b" y="a" value="r" label={(d) => d.r.toFixed(2)} labelClass="text-xs text-black/70 data-dark:text-white" />
</Plot>
PropTypeDefaultDescription
xstring | (d, index) => stringnone每一列所在的欄:一個欄位(其文字或數字)或回傳類別的函式。該列的 cell 會放在 x 刻度中該類別的 band;沒有類別的列不會有 cell。
ystring | (d, index) => stringnone每一列所在的行,為沿 y 刻度的 x。
valuestring | (d, index) => numbernone每個 cell 的數值,由 sequential 或 diverging colorScale 轉換為其顏色。沒有數值的列會取刻度的 unknown 顏色,若也沒有該顏色則不會有 cell。
colorstring | (d, index) => stringnone每個 cell 的類別,其顏色取自 ordinal colorScale(依類別著色)。
cornerRadiinumber | { topLeft?, topRight?, bottomRight?, bottomLeft? }0其 cell 每個圓角的半徑。
staggernumber0配合圖表的 appear,cell 會在整個動畫的這個比例(0 到 0.95)內逐列依序開始成長,其餘時間各自從中心成長;0 則讓它們同時成長。
inactiveOpacitynumbernone當指標或按鍵停留在某個 cell 上時,其他 cell 及其標籤的不透明度:它們會隨著懸停而淡入淡出。
labelstring | (d, index) => stringnonelabel 部件:每個 cell 中央的文字,可為欄位(其文字或數字)或回傳文字的函式,由應用程式負責格式化。每個標籤只在其排版後的大小能放進 cell 時才會顯示,因此窄的圖表只會顯示顏色。
labelClass, labelStylestring, StyleProp<TextStyle>none標籤的字型、大小與顏色。當 cell 的 colorScale 顏色較深時,其標籤會具有屬性 data-dark(此時淺色文字更易閱讀),因此 data-dark:text-white 會在那裡將其寫成淺色。
hoverCellClass, hoverCellStylestring, StyleProp<ViewStyle>nonehoverCell 部件:一個與指標或按鍵所在 cell 同樣大小的視圖,覆蓋在其上;rounded-sm outline-2 outline-foreground 會為其加上環形框。它會在同一個影格中隨指標移動,並在按鍵移動它時以 pointer 彈簧滑動。

Cell 在應用程式未設定 fill 時,會以圖表 colorScale 中的顏色填色(fill-* 的 class 與樣式優先,如同 CSS 的繼承方式從圖表繼承),沒有任何設定時使用 currentColor,並在有設定時以其 stroke 描邊。在有 cell 的圖表上,圖表會回報指標所在 cell 的列,在兩個 cell 之間的間隙則回報較近的那一個;方向鍵會依畫面上的格狀排列逐格移動(鍵盤),螢幕閱讀器則依列的順序逐一讀取。配合圖表的 appear,cell 會從中心成長;配合 motion.update,cell 的顏色會平滑轉為新的顏色,新增列的 cell 會成長出現,已移除列的 cell 會縮小消失,cell 也會移動到其新的 band;這一切都由平台的 animator 執行,不會重新繪製任何 mark。

#Pie 與 Arc

Pie 會把一圈依每列分成一個扇形,每個扇形佔各列數值的比例,從 12 點鐘方向起依順時針、按列的順序,繞著繪圖區的中心排列。innerRadius 則讓它成為環圈圖。每個扇形都是各自獨立的畫布,即 slice 部件,因此各自以自己的 class 繪製:sliceClass 可以是套用於每個扇形的同一組 class,或是以該扇形所屬的列為參數、為各扇形提供各自 class 的函式。在 Pie 上,繪圖區會回報指標下方扇形所屬的列(在中空處或圓環之外則為無),其 tooltip 會跟隨指標,該扇形會放大 hoverLift,其餘扇形則淡出 hoverDim,並以平台的 animator 搭配繪圖區的 motion 執行。Arc 在兩個角度之間繪製一個扇區:可用作儀表的軌道及其數值,或作為 Pie 後方的圓環。

el-plot-pie.tsx
el-plot-pie.tsxLive
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型別預設值說明
Pievalue.value(..)keyof T | (d: T, index: number) => number繪圖區的 y每一列的數值。數值為零、為負或不是數字的列沒有扇形。
Pie、ArcstartAngle、endAngle.start_angle(..)、.end_angle(..)number(弧度)0、2 * Math.PI第一個扇形的起點與最後一個扇形的終點(即 Arc 的兩端):從 12 點鐘方向起、依順時針計算的弧度。endAngle 小於 startAngle 時,Pie 的扇形會以相反方向排列。
PiepadAngle.pad_angle(..)number(弧度)0相鄰扇形在外緣的夾角。每道間隙在任何半徑上都維持該寬度(約 padAngle * outerRadius 點),兩側邊緣平行,因此在中心或中空處與在外緣一樣寬;窄到無法抵達中心的扇形,會在其邊緣相交處結束。只有一個扇形的 Pie 沒有間隙。
Pie、ArcinnerRadius、outerRadius.inner_radius(..)、.outer_radius(..)number | `${number}%` 0、"100%"中空處與外緣的半徑:以點為單位,或為繪圖區所能容納的最大圓(其較短邊的一半)的百分比。
Pie、ArccornerRadius.corner_radius(..)number0每個扇形(或 Arc)的圓角半徑,以點為單位,如同 d3 的 arc.cornerRadius:每個圓角都是與邊緣及外緣或中空處相切的圓,包含間隙的平行邊緣。其上限為圓環厚度的一半,此時 Arc 會有圓形端點;若扇形太窄、容不下兩個完整的圓角,兩個圓角會在其中線相接。完整的圓環只保留其圓形邊緣。
PiesliceClass.slice_class(..)string | (d: T, index: number) => string無每個扇形的 class(fill-*、stroke-*):可以對每個扇形都相同,或是以該扇形所屬的列及其索引為參數的函式,在列或函式變更時,對每一列各呼叫一次。
PiesliceStyle.slice_style(..)StyleProp<ViewStyle>無每個扇形的樣式,疊加在其 class 之上(fill、stroke、strokeWidth)。
PiehoverLift.hover_lift(..)number0指標下方的扇形比其他扇形多放大多少,以點為單位:依繪圖區的 pointer spring 運動,隨懸停而淡入淡出。
PiehoverDim.hover_dim(..)number(0 到 1)0指標位於某個扇形上時,其他扇形淡出的程度,以其不透明度的比例表示,並隨懸停的淡化而變化。
Piecolor.color(..)keyof T | (d: T, index: number) => string無每個扇形的類別,其顏色取自繪圖區的 colorScale:在沒有其他設定時,採用該扇形的 fill(依類別著色)。
Pielabel.label(..)string | (d: T, index: number) => string無每個扇形旁的文字:可以是欄位(取其數值),或是回傳由應用程式格式化之文字的函式。文字為空的列,或窄於半度的扇形,不會有文字。
PielabelClass、labelStyle.label_class(..)、.label_style(..)string、StyleProp<TextStyle>無label 部件:位於圓環外、引導線末端的文字,以該末端為中心;在 Pie 的右半邊置於末端之後,在左半邊則置於其前。其 padding 即與引導線末端的距離。
PielabelGap.label_gap(..)number15標籤位於外緣之外多遠,以點為單位。
PielabelLineClass、labelLineStyle.label_line_class(..)、.label_line_style(..)string | (d: T, index: number) => string、StyleProp<ViewStyle>無labelLine 部件:每個標籤的引導線,從扇形中線上的外緣延伸到標籤所在的半徑,再橫向連到文字。其 stroke(未設定時為 currentColor)、strokeWidth 與 strokeDasharray,可以對每條線都相同,或是以該扇形所屬的列為參數的函式;套用樣式後才會顯示。

扇形與 Arc 以 fill 填色(未設定時為 currentColor,扇形則在有 color 時採用其類別的顏色),並以 stroke 描繪邊緣(未設定時為無),其自身的設定優先於 Pie 及繪圖區的設定,依 CSS 的繼承方式而定:以表面顏色(stroke-card stroke-2)描邊,也會在扇形之間切出間隙。它們的數值不參與繪圖區的 scale。Pie 的扇形只有在列、其數值、角度、半徑或繪圖區大小變更時才會重新繪製;指標移動時,由 animator 驅動放大與淡化。

含有 Pie 的繪圖區通常會依指標放置 tooltip(tooltipPlacement="pointer"),因為 Pie 的列在軸上沒有位置:十字線與 tooltip 會在其上跟隨指標,且不會顯示懸停圓點。環圈圖的中心是覆蓋在繪圖區上、屬於應用程式自己的元素(顯示懸停扇形的數值或總計),其圖例也是如此,每個圖例項目都採用其扇形的顏色。

Pie 的標籤在圓環外標示其扇形:每段文字位於外緣之外 labelGap 處、朝向其扇形的中線(即該半徑上扇形的形心),再橫向延伸到 Pie 中該扇形所在一側的該半徑處,並以套用樣式的引導線連到外緣。標籤會隨繪圖區的出場動畫與其扇形一起掃入,並在動畫結束時淡入,在 motion.update 時隨扇形移動,並隨懸停的扇形上浮;這一切都在平台的 animator 上執行。請為標籤預留空間:將 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 的顏色,當 domain 比 range 長時,range 會從第一個顏色重新開始。位於 domain 之外的類別採用 unknown;若沒有設定,則在標記第一次遇到它時加入 domain(依標記順序,各標記再依其列的順序),並取用下一個顏色,因此完全沒有 domain 時,類別會依各列帶來的順序取得顏色。這些顏色由應用程式決定:可以是 CSS 顏色,包括主題的 token。

import { Pie, Plot } from "nsh/base/plot";
import { token } from "nsh";

const regions = [
  { name: "North America", revenue: 482 },
  { name: "EMEA", revenue: 312 },
  { name: "APAC", revenue: 268 },
];

<Plot data={regions} colorScale={{ type: "ordinal", range: [token("chart-1"), token("chart-2"), token("chart-3")] }} class="size-44">
  <Pie value="revenue" color="name" innerRadius="62%" />
</Plot>

扇形、長條與圓點採用其所屬列的顏色;Line 與 Area 各自是一個數列,採用其第一列的顏色,因此由自己的列組成的線(data)會呈現其列的顏色,而同一個 scale 能讓同一類別在 Pie、長條與折線中保持相同的顏色。在沒有其他設定時,該顏色即為標記的 fill(線則為 stroke),取代 currentColor:應用程式的 class 與樣式優先,接著依序是部件的(sliceClass、sliceStyle)、標記的,以及繼承自繪圖區的,因此 sliceClass={(d) => (d.name === "Other" ? "fill-muted" : "")} 可以單獨為某一個扇形指定顏色,而 scale 則為其他扇形著色。在 background 部件旁,長條與區域圖的 fill 維持為 none。

OrdinalScale型別預設值說明
type"ordinal"(必填)scale 的種類。
domainstring[]無依序列出的類別。重複寫出的類別保留其第一次出現的位置。
rangestring[](必填)依序列出的顏色(CSS 顏色);第 i 個類別取用第 i % range.length 個顏色。
unknownstring無domain 之外所有類別的顏色;若沒有設定,則各自取用下一個顏色。

#沿色階著色

colorScale 為 type: "sequential" 或 "diverging" 時,會沿著色階為數值指定顏色,如同 d3 的 scaleSequential 與 scaleDiverging,用於 Cell 的 value。其 range 為兩個或更多個顏色,從色階的低端到高端均勻分布,並在相鄰顏色之間以 OKLab 混色:階層在視覺上看起來均勻,兩種色相也會透過它們自己的混合色相接。超出 domain 的值會取用色階的端點顏色,沒有數值的列則取用 unknown。

sequential 會將其 domain 攤開到整個色階上,除非應用程式另外指定,否則 domain 即為各儲存格數值的範圍:適用於計數、數量與活躍度。diverging 則把其 pivot(預設為 0)放在色階的中間色,低於它的值沿前半段上色,高於它的值沿後半段上色:以一種色相表示虧損、另一種色相表示獲利,持平的月份則落在中性的中間色。除非應用程式指定 domain,否則 domain 為數值的範圍,並在 pivot 兩側調整為對稱(Observable Plot 的 symmetric),因此相同大小的虧損與獲利呈現的強度一致;相關係數矩陣則會指定 domain: [-1, 1]。

colorScale={{ type: "sequential", range: [token("muted"), token("chart-1")] }}
colorScale={{ type: "diverging", pivot: 0, range: [token("destructive"), token("muted"), token("chart-1")] }}
SequentialScale、DivergingScale型別預設值說明
type"sequential" | "diverging"(必填)色階的種類。
domain[number, number]數值的範圍(diverging 則在 pivot 兩側調整為對稱)色階兩端的值。
pivotnumber0僅限 diverging:色階中間色對應的值。
rangestring[](必填)沿色階從低端到高端的顏色(CSS 顏色),相鄰顏色之間以 OKLab 混色。
unknownstring無沒有數值的列的顏色;若沒有設定,該列沒有儲存格。

#RadialLine 與 RadialGrid

RadialLine 會繞著繪圖區的中心畫出一個數列:可以是雷達圖的多邊形,或是錶盤上的一圈線。每個有值的資料列都是位於該列角度上的一個點,離中心的距離由繪圖區的 y 比例尺依其值決定,範圍從 innerRadius(定義域的最小值)到 outerRadius(最大值)。沒有 angle 時,各列會從 12 點鐘方向起,依序順時針均分一整圈,因此六列就是一個六邊形;closed 會把最後一點連回第一點。線條會描出各點的輪廓,並把面積填滿至 innerRadius(預設為中心),因此一個標記就能同時畫出雷達圖的外框與底色。RadialGrid 會在其後方繪製雷達圖的格線:rings 位於 y 比例尺的各個值上,為通過各列角度的多邊形或 circular,以及延伸至每一列的 spokes。在 radial line 上方時,繪圖區會回報角度最接近指標角度、且位於該線外半徑之內的那一列,每條線的 hover 圓點則位於其在該列的點上。

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型別預設值說明
RadialLineradius.radius(..)keyof T | (d: T, index: number) => number繪圖區的 y決定每一列離中心多遠:取 y 比例尺的值,定義域最小值位於 innerRadius,最大值位於 outerRadius。沒有值的列會被略過。
RadialLineangle.angle(..)keyof T | (d: T, index: number) => number各列在一圈上均分每一列繞中心的位置:自 12 點鐘方向起、順時針的弧度。
RadialLine、RadialGridstartAngle、endAngle.start_angle(..)、.end_angle(..)number(弧度)0、2 * Math.PI沒有 angle 時各列的位置:從 startAngle 起均分。繞滿一整圈時,相鄰兩列相隔一圈的 1 / rows,最後一列與第一列也相隔一步;若只涵蓋部分圈,則第一列位於 startAngle,最後一列位於 endAngle。
RadialLine、RadialGridinnerRadius、outerRadius.inner_radius(..)、.outer_radius(..)number | `${number}%` 0、"100%"y 定義域的最小值與最大值所在位置:以點為單位,或是繪圖區所能容納最大圓(其較短邊的一半)的百分比,如同圓餅圖的半徑。線條的面積填滿至 innerRadius;格線的輻條則延伸於兩者之間。
RadialLinecurve.curve(..)"linear" | "natural" | "stepAfter""linear"連接各點的方式:直線段、平滑曲線(封閉時為平滑的環)或階梯狀。
RadialLineclosed.closed(..)booleanfalse從最後一點接回第一點:即雷達圖的多邊形。不封閉時,其面積會閉合至中心(或沿其內緣)。
RadialLinedot、dotClass、dotStyle.dot(..)、.dot_class(..)、.dot_style(..)boolean、string、StyleProp<ViewStyle>false每個點上的圓點:以該點為中心的 view,其大小、背景與邊框由應用程式決定。圓點會隨線條向外生長並移動。
RadialLinebackgroundClass、backgroundStyle.background_class(..)、.background_style(..)string、StyleProp<ViewStyle>無其背景:圍繞其外圓的方塊,其背景(bg-*,漸層)只顯示在其面積內部、位於其 fill 之下,與 area 相同。
RadialLinehoverDotClass、hoverDotStyle.hover_dot_class(..)、.hover_dot_style(..)string、StyleProp<ViewStyle>繪圖區的 dotClass、dotStyle其 hover 圓點,位於指標所在列的點上,指標也在同一個框架內;按方向鍵切換時,會依繪圖區的 pointer spring 滑向下一列;hidden 則不顯示。
RadialGridrings.rings(..)number[]無其環的位置,以 y 比例尺的值表示:每個環與 radial line 放置該值之處同樣遠。
RadialGridspokes.spokes(..)booleanfalse在每一列的角度上,從 innerRadius 到 outerRadius 的一條線。
RadialGridcircular.circular(..)booleanfalse其環為圓(涵蓋部分圈時為弧)。預設為通過各列角度的多邊形,而繪圖區少於三列時則為圓。

radial line 以 stroke、strokeWidth 與 strokeDasharray 描邊(currentColor,未設定時為 1 寬的實線),並以 fill 填滿其面積(未設定時為無);radial grid 的描邊方式與 grid 相同。它們的繪製樣式繼承自繪圖區。當繪圖區沒有定義域時,線條的值連同 0 會構成 y 比例尺的定義域,因此正值的雷達圖會從中心向外生長;格線的環則不會。請為繪圖區提供 y domain,以便在數值變動時保持環與雷達圖的比例尺固定。

雷達圖周圍的標籤是應用程式疊放在繪圖區上的元素,如同環圈圖的中心。沒有 angle 時,第 i 列(共 n 列)位於從 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" 依數值的平方根決定大小,避免單一占比極高的流量讓較小的流量相形見絀。

每個節點與每條緞帶都是各自獨立的 canvas,即 node 與 link 部件,因此各自以自己的 class 繪製:nodeClass 可以是套用在每個節點上的相同 class,也可以是節點資料列的函式;linkClass 可以是套用在每條緞帶上的相同 class,也可以是連結的函式。linkBackground 部件會在每條緞帶內顯示背景,為從來源到目標的方塊:傳入連結的函式,就能讓每條緞帶呈現從來源顏色到目標顏色的漸層。緞帶繪製在節點之下,兩者都會隨繪圖區的 appear 從前緣開始繪入。

在 sankey 上方時,繪圖區會回報指標下的節點(onHoverChange,即該節點的資料列;位於緞帶上時則無),其 tooltip 會跟隨指標,而與該節點無關的連結會淡出 hoverDim,以平台的 animator 並依繪圖區的 motion 進行。方向鍵會依各列的順序逐一切換節點,tooltip 位於各節點中央的旁邊。nodeLabel 為每個節點命名,valueLabel 提供其值;兩者都設定時,值顯示在名稱之上:位於第一欄節點的左側、最後一欄節點的右側、其間各欄節點的上方並置中,如同 gpui-component 的圖表所放置;節點旁的標籤會維持在繪圖區的頂端與底端之間,而超出繪圖區邊緣的標籤則位於應用程式在其周圍預留的空間(其邊距)內。nodeLabel 部件的 padding 是與其節點之間的間距,其 class 決定其文字(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 為其大小。不是數字或小於零的值不帶有任何流量,也不會畫出緞帶。
nodeWidth.node_width(..)number24每個節點的寬度,以點為單位。
nodePadding.node_padding(..)number8同一欄節點之間的間距,以點為單位;在過於擁擠的欄中會較小。
nodeAlign.node_align(..)"justify" | "left" | "right" | "center""justify"每個節點所在的欄,與 d3-sankey 相同:從第一欄起算的深度("left")、在其流量允許下盡量靠右("right")、其深度,但只發出流量的節點則緊鄰其目標("center"),或其深度,但只接收流量的節點置於最後一欄("justify")。
iterations.iterations(..)number6讓節點朝其流量鬆弛的迭代次數。
valueScale.value_scale(..)"linear" | "sqrt""linear"高度與寬度與數值成正比,或與其平方根成正比。
minLinkWidth.min_link_width(..)number1緞帶繪製的最小寬度,以點為單位,讓小流量仍然可見。
nodeCornerRadius.node_corner_radius(..)number0每個節點圓角的半徑,以點為單位。
nodeClass.node_class(..)string | (d: T, index: number) => string無每個節點的 class(fill-*、stroke-*):可以是套用在每個節點上的相同 class,也可以是節點資料列及其索引的函式,在資料列或該函式變更時,每一列呼叫一次。
nodeStyle.node_style(..)StyleProp<ViewStyle>無每個節點的樣式,疊加於其 class 之上。
linkClass.link_class(..)string | (link: SankeyLink, index: number) => string無每條緞帶的 class:可以是套用在每條緞帶上的相同 class,也可以是連結及其在 links 中索引的函式。
linkStyle.link_style(..)StyleProp<ViewStyle>無每條緞帶的樣式,疊加於其 class 之上。
linkBackgroundClass.link_background_class(..)string | (link: SankeyLink, index: number) => string無每條緞帶背景的 class(bg-*,漸層:bg-linear-to-r from-… to-…),顯示在緞帶內部、位於從來源到目標的方塊上,並位於其 fill 之下。有背景時,緞帶的 fill 在未設定時為 none。
linkBackgroundStyle.link_background_style(..)StyleProp<ViewStyle>無每條緞帶背景的樣式,疊加於其 class 之上。
hoverDim.hover_dim(..)number(0 到 1)0與指標下節點無關的連結淡出的程度,以其不透明度的比例表示,依繪圖區的 pointer spring 進行,並與 hover 的淡出同步。
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無每個標籤的 class,即 nodeLabel 部件:其 padding 為與節點之間的間距,其文字樣式適用於兩行文字。
nodeLabelStyle.node_label_style(..)StyleProp<TextStyle>無每個標籤的樣式,疊加於其 class 之上。
valueLabelClass.value_label_class(..)string無值這一行的 class,即 valueLabel 部件,疊加於標籤的 class 之上。
valueLabelStyle.value_label_style(..)StyleProp<TextStyle>無值這一行的樣式,疊加於其 class 之上。

節點與緞帶以 fill 填滿(未設定時為 currentColor),並以 stroke 描邊(未設定時為無),其自身的設定優先於 sankey 與繪圖區的設定,依 CSS 的繼承方式處理。沒有任何流量通過的節點高度為一個點。排版只會在連結、選項或繪圖區的大小變更時重新執行,不會因指標或 appear 而重新執行;繪圖區大小變更時不會造成任何移動。構成循環的連結,或指向繪圖區沒有的資料列的連結,不會繪製任何內容,並會記錄一則警告。其值不參與繪圖區的比例尺。

#Treemap

Treemap 將圖表的各列繪製為巢狀矩形組成的階層:每一列是一個節點,其 id 與它所指名的 parent 構成樹(d3 的 stratify),每個葉節點的面積與其 value 成正比,每個群組則是其下所有節點的外框,其值為底下各葉節點的值的總和(不論它自己那一列存放什麼)。沒有 parent、或其父節點所指名的列不存在的列,就是頂層節點;沒有 id 時,列的 id 即為其索引,因此 parent 也可以用索引指名一列。gpt-vis 所寫的巢狀形式的樹({ name, value, children }),只需應用程式寫幾行程式碼即可轉成列;層數沒有上限。

圖塊採用 squarified 方式排列,如同 d3 的 treemapSquarify:每一層的節點依值排序,由左上角起最大者在前(sort="value"),趨近長寬比 ratio(黃金比例);sort="data" 則維持各列的順序,使節點在值變動時保持原位。paddingInner 是同層節點之間的間隙,不會出現在其父節點的任何外緣;paddingOuter 是群組內、子節點周圍的空間;paddingTop 是每個群組頂端的一條橫帶,其 groupLabel 寫在其中。每一項可接受一個套用於所有層級的數字,或由頂層往下每層各一個數字([3, 1]:頂層節點之間為 3,其下為 1)。某一層的橫帶會套用在同一父節點的所有同層節點上,或完全不套用;若其中有一個太小放不下,就整層省略,因此同一層的群組看起來一致。

值為 0、小於 0 或不是數字的葉節點不佔面積:不會繪製、沒有標籤、指標也找不到它,所有葉節點皆如此的群組亦同。Treemap 的面積代表整體的占比,因此被排除的部分由應用程式以自己的文字說明(例如地圖下方的備註)。負數用於顏色:例如市場地圖的當日漲跌。

節點的顏色取自其列在圖表 colorScale 中的值:colorValue 是沿著色階的數字(sequential、diverging:下跌為紅、上漲為綠、持平為中性,範圍為未指定 domain 時各值的範圍),color 則是類別(ordinal:"parent" 讓同一群組的葉節點顏色相同)。葉節點是 leaf 部件(leafClass、leafStyle),群組是 group 部件(groupClass、groupStyle),只有在設定 class 或 style 時才會繪製:它是位於子節點之下的矩形,顯示在間隙、其 padding 與橫帶中。每個部件在所有節點上都相同,或是節點所屬列的函式。cornerRadii 會讓每個矩形變成圓角。

label 寫出每個葉節點的名稱,valueLabel 在其下寫出值,兩者皆為列及其值的函式(群組的值為其葉節點的總和):位於左上角(labelPlacement="start")或正中央("center")。label 部件是與其葉節點同大的方塊:其 padding 是與葉節點邊緣的距離,其文字樣式套用於各行。葉節點較窄時,名稱以刪節號截斷,且僅在放得下時顯示;值則完整顯示或完全不顯示。葉節點顏色較深的標籤具有屬性 data-dark,因此 data-dark:text-white 可讓它在深紅色上保持可讀。

在 treemap 上方,圖表會回報指標下方的節點(onHoverChange,即該節點的列):葉節點,或位於橫帶或 padding 中的群組;越過同層節點之間的間隙時,則回報其中一個,使 hover 不會閃爍。其餘節點淡化為 inactiveOpacity(葉節點保留其所屬群組,群組保留其下的內容),而 hover 部件(與該節點同大的 view)為其描邊。方向鍵移至畫面上相鄰的葉節點,Home 與 End 移至第一個與最後一個。隨著圖表出現,頂層節點從其中心依序成長,並以 stagger 錯開;列變動時,每個節點以 motion.update 移至其新的方框與顏色,並以其 id 辨識:列中新增的節點從其中心成長,已消失的節點則縮向其自己的中心。圖表的 x 與 y 比例尺不會使用。

el-plot-treemap.tsx
el-plot-treemap.tsxLive
import { mount, signal, Text, View } from "nsh";
import { Plot, Treemap } from "nsh/base/plot";
import { desktop, Log, Panel, Segmented, size, Split, tint, ui } from "./palette";

// A `Treemap` of the plot's rows: each row a node, its `id` and the
// `parent` it names making the tree, each leaf's area its `value` and each
// group's the sum of its leaves'. The squarified tiles go largest first
// from the top left (`sort="value"`), or in the rows' order
// (`sort="data"`); the gaps between siblings are `paddingInner` per level,
// and each group's name sits in a band at its top (`paddingTop`).
//
// The market map colors each stock by its change of the day along a
// diverging `colorScale` (losses red, gains green, flat neutral) and
// centers its ticker on it. The budget has three levels, a line of 0 and
// one below 0, which take no area: a treemap's areas are shares of a
// whole. The node under the pointer (or the arrow keys) is outlined and
// read out, the others fading.
type Node = { id: string; parent?: string; name: string; value?: number; chg?: number };

const MARKET: Node[] = [
  { id: "tech", name: "Technology" },
  { id: "comm", name: "Communication" },
  { id: "fin", name: "Financials" },
  { id: "health", name: "Healthcare" },
  ...(
    [
      ["NVDA", "tech", 4300, 2.4],
      ["MSFT", "tech", 3700, -0.6],
      ["AAPL", "tech", 3600, 0.4],
      ["AVGO", "tech", 1600, 3.1],
      ["ORCL", "tech", 720, -2.8],
      ["AMD", "tech", 380, 1.8],
      ["GOOGL", "comm", 3100, 1.1],
      ["META", "comm", 1800, -1.5],
      ["NFLX", "comm", 480, 0.2],
      ["BRK.B", "fin", 1050, 0.2],
      ["JPM", "fin", 830, 1.3],
      ["V", "fin", 670, -0.3],
      ["GS", "fin", 240, 2.7],
      ["LLY", "health", 820, -1.9],
      ["UNH", "health", 300, -4.1],
      ["JNJ", "health", 460, 0.4],
    ] as const
  ).map(([id, parent, value, chg]) => ({ id, parent, name: id, value, chg })),
];

/** A team's yearly budget in thousands: three levels, a line that is done (0) and one refunded (below 0). */
const BUDGET: Node[] = [
  { id: "eng", name: "Engineering" },
  { id: "ops", name: "Operations" },
  { id: "infra", parent: "eng", name: "Infrastructure" },
  { id: "product", parent: "eng", name: "Product" },
  { id: "cloud", parent: "infra", name: "Cloud", value: 420 },
  { id: "ci", parent: "infra", name: "CI runners", value: 96 },
  { id: "observability", parent: "infra", name: "Observability", value: 74 },
  { id: "salaries", parent: "product", name: "Contractors", value: 310 },
  { id: "design", parent: "product", name: "Design tools", value: 38 },
  { id: "research", parent: "product", name: "User research", value: 0 },
  { id: "office", parent: "ops", name: "Office", value: 180 },
  { id: "travel", parent: "ops", name: "Travel", value: 64 },
  { id: "legal", parent: "ops", name: "Legal", value: -12 },
];

const num = { fontVariant: ["tabular-nums" as const] };
const pct = (v: number) => `${v > 0 ? "+" : v < 0 ? "−" : ""}${Math.abs(v).toFixed(1)}%`;
/** A node's value: its own, or its leaves' sum (what the treemap gives `valueLabel`). */
const total = (rows: Node[], id: string): number => {
  const children = rows.filter((r) => r.parent === id);
  if (children.length === 0) return Math.max(0, rows.find((r) => r.id === id)?.value ?? 0);
  return children.reduce((s, c) => s + total(rows, c.id), 0);
};
const path = (rows: Node[], node: Node): string => {
  const parent = node.parent && rows.find((r) => r.id === node.parent);
  return parent ? `${path(rows, parent)} / ${node.name}` : node.name;
};

function App() {
  const chart = signal<"market" | "budget">("market");
  const sort = signal<"value" | "data">("value");
  const hovered = signal<number | null>(null);
  const events = signal<string[]>([]);
  const log = (line: string) => events.set([line, ...events()].slice(0, 5));
  const rows = () => (chart() === "market" ? MARKET : BUDGET);
  const shown = () => (hovered() == null ? undefined : rows()[hovered()!]);
  const height = desktop ? 260 : 240;
  const motion = { appear: { duration: 800, easing: "easeOut" }, update: { duration: 500, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 380, damping: 36 } } as const;
  const outline = () => ({ borderRadius: 3, outlineWidth: 2, outlineColor: ui().text });
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>plot treemap</Text>
      <Text style={{ color: ui().muted }}>{desktop ? "Move the pointer over a tile or a group's name" : "Each tile's area is its share of the whole"}</Text>

      <Split
        width={560}
        inspector={
          <>
            <Panel title="data">
              <Segmented options={["market", "budget"] as const} value={chart()} onChange={(c) => (chart.set(c), hovered.set(null))} />
            </Panel>
            <Panel title="sort">
              <Segmented options={["value", "data"] as const} value={sort()} onChange={(s) => sort.set(s)} />
            </Panel>
            <Log title="onHoverChange" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 14, gap: 12 }}>
          <View class="flex-row items-baseline gap-3">
            <View style={{ flex: 1, gap: 2 }}>
              <Text style={{ fontSize: size.label, fontWeight: "600", color: ui().text }}>
                {shown() ? path(rows(), shown()!) : chart() === "market" ? "Largest stocks by sector" : "Budget by team"}
              </Text>
              <Text style={{ fontSize: size.detail, color: ui().muted, ...num }}>
                {chart() === "market" ? "Area: market cap · color: change of the day" : "Thousands of dollars · a line of 0 and a refund take no area"}
              </Text>
            </View>
            <Text style={{ fontSize: 20, fontWeight: "600", color: ui().text, ...num }}>
              {shown() ? (chart() === "market" && shown()!.chg != null ? pct(shown()!.chg!) : `$${total(rows(), shown()!.id)}k`) : ""}
            </Text>
          </View>
          <Plot
            data={rows()}
            colorScale={
              chart() === "market"
                ? { type: "diverging", domain: [-3, 3], range: [ui().down, tint(ui().text, 0.1), ui().up] }
                : { type: "ordinal", domain: ["eng", "ops"], range: [ui().info, ui().warn] }
            }
            accessibilityLabel={chart() === "market" ? "Market map of 16 stocks in 4 sectors" : "Budget of two teams in three levels"}
            accessibilityValue={{ text: shown() && path(rows(), shown()!) }}
            appearGeneration={chart()}
            motion={motion}
            onHoverChange={({ index }) => {
              hovered.set(index);
              const n = index == null ? undefined : rows()[index];
              log(n == null ? "null: left the map" : `${index}: ${path(rows(), n)}`);
            }}
            style={{ height }}
          >
            <Treemap
              id="id"
              parent="parent"
              value="value"
              sort={sort()}
              colorValue={chart() === "market" ? "chg" : undefined}
              color={chart() === "budget" ? (d: Node) => (d.id === "ops" || d.parent === "ops" ? "ops" : "eng") : undefined}
              paddingInner={chart() === "market" ? [3, 1] : [4, 2, 1]}
              paddingOuter={chart() === "market" ? 0 : [2, 1]}
              paddingTop={18}
              cornerRadii={3}
              stagger={0.4}
              inactiveOpacity={0.35}
              groupClass={chart() === "budget" ? "fill-foreground/5" : undefined}
              label={(d: Node) => d.name}
              valueLabel={(d: Node, value: number) => (d.chg != null ? pct(d.chg) : `$${value}k`)}
              labelPlacement={chart() === "market" ? "center" : "start"}
              labelClass="p-1.5 text-foreground data-dark:text-white"
              labelStyle={{ fontSize: 11, fontWeight: "600" }}
              valueLabelStyle={{ fontSize: 10, fontWeight: "400", opacity: 0.85, ...num }}
              groupLabel="name"
              groupLabelClass="px-1.5 text-muted-foreground"
              groupLabelStyle={{ fontSize: 10.5, lineHeight: 18 }}
              hoverStyle={outline()}
            />
          </Plot>
        </View>
      </Split>
    </View>
  );
}

mount(App);
import { signal } from "nsh";
import { Plot, Treemap } from "nsh/base/plot";

type Row = { id: string; parent?: string; name: string; cap?: number; chg?: number };

const rows: Row[] = [
  { id: "tech", name: "Technology" },
  { id: "fin", name: "Financials" },
  { id: "NVDA", parent: "tech", name: "NVIDIA", cap: 4300, chg: 2.4 },
  { id: "MSFT", parent: "tech", name: "Microsoft", cap: 3700, chg: -0.6 },
  { id: "AVGO", parent: "tech", name: "Broadcom", cap: 1600, chg: 3.1 },
  { id: "JPM", parent: "fin", name: "JPMorgan", cap: 830, chg: 1.3 },
  { id: "V", parent: "fin", name: "Visa", cap: 670, chg: -0.3 },
];

export function MarketMap() {
  const hovered = signal<number | null>(null);
  return (
    <Plot
      data={rows}
      colorScale={{ type: "diverging", domain: [-3, 3], range: ["#dc2626", "#e5e5e5", "#16a34a"] }}
      accessibilityLabel="Market map of 5 stocks in 2 sectors"
      accessibilityValue={{ text: hovered() == null ? undefined : rows[hovered()!].name }}
      onHoverChange={({ index }) => hovered.set(index)}
      appearGeneration={1}
      motion={{ appear: { duration: 800 }, update: { duration: 500 }, enter: { duration: 120 }, exit: { duration: 160 } }}
      class="h-72"
    >
      <Treemap
        id="id"
        parent="parent"
        value="cap"
        colorValue="chg"
        paddingInner={[3, 1]}
        paddingTop={18}
        cornerRadii={2}
        inactiveOpacity={0.35}
        label="id"
        valueLabel={(d: Row) => `${d.chg! > 0 ? "+" : ""}${d.chg}%`}
        labelPlacement="center"
        labelClass="p-1 text-xs font-semibold text-foreground data-dark:text-white"
        groupLabel="name"
        groupLabelClass="px-1 text-xs text-muted-foreground"
        hoverClass="rounded-xs outline-2 outline-foreground"
      />
    </Plot>
  );
}
use nsh_base::plot::{ColorScale, DivergingScale, TreemapPadding, plot, treemap};
use nsh_core::{element::{RowAccessor, RowText, Rows}, prelude::*};

const IDS: [&str; 5] = ["tech", "fin", "NVDA", "MSFT", "JPM"];
const PARENTS: [&str; 5] = ["", "", "tech", "tech", "fin"];

fn market_map() -> impl IntoElement {
    let rows = Rows::from_columns(
        5,
        [
            ("cap".into(), vec![0.0, 0.0, 4300.0, 3700.0, 830.0]),
            ("chg".into(), vec![0.0, 0.0, 2.4, -0.6, 1.3]),
        ],
    );
    plot()
        .data(rows)
        .color_scale(ColorScale::from(
            DivergingScale::new([rgb(0xdc2626), rgb(0xe5e5e5), rgb(0x16a34a)]).domain([-3.0, 3.0]),
        ))
        .accessibility_label("Market map")
        .h(px(288.0))
        .child(
            treemap()
                .id(RowText::from_fn(|i| IDS[i].into()))
                .parent(RowText::from_fn(|i| PARENTS[i].into()))
                .value(RowAccessor::key("cap"))
                .color_value(RowAccessor::key("chg"))
                .padding_inner(TreemapPadding::from([px(3.0), px(1.0)]))
                .padding_top(TreemapPadding::from(px(18.0)))
                .label(RowText::from_fn(|i| IDS[i].into())),
        )
}
屬性Rust型別預設值說明
id.id(..)keyof T | (d: T, index: number) => string列的索引每列節點的 id:parent 所指名的對象,也是列變動時辨識節點的依據。
parent.parent(..)keyof T | (d: T, index: number) => string無:每一列都是頂層節點每列父節點的 id;空值或不是任何列的 id 時,為頂層節點。
value.value(..)keyof T | (d: T, index: number) => number必填每個葉節點的大小;群組的大小為其子節點的總和。值為 0、小於 0 或不是數字的葉節點不佔面積。
sort.sort(..)"value" | "data""value"同層節點由左上角起最大者在前,或依列的順序。
ratio.ratio(..)number黃金比例圖塊趨近的長寬比,至少為 1。
paddingInner.padding_inner(..)number | number[]0同層節點之間的間隙,單位為點,由頂層往下每層一個值(最後一個值適用於其下所有層)。
paddingOuter.padding_outer(..)number | number[]0每個群組內、子節點周圍的空間,每層一個值。
paddingTop.padding_top(..)number | number[]0每個群組頂端的橫帶,用於其 groupLabel,每層一個值;套用於同一父節點的所有同層節點,或完全不套用。
cornerRadii.corner_radii(..)number | { topLeft?, topRight?, bottomRight?, bottomLeft? }直角矩形每個圓角的半徑。
color.color(..)keyof T | (d: T, index: number) => string無每個節點的類別,由 ordinal colorScale 賦予顏色。
colorValue.color_value(..)keyof T | (d: T, index: number) => number無每個節點的數字,由 sequential 或 diverging colorScale 轉為其顏色。
leafClass.leaf_class(..)string | (d: T, index: number) => string無每個葉節點的 class(fill-*、stroke-*),所有葉節點相同,或為其列的函式。
leafStyle.leaf_style(..)StyleProp<ViewStyle>無每個葉節點的樣式,覆蓋其 class。
groupClass.group_class(..)string | (d: T, index: number) => string無每個群組的 class,即位於子節點之下的矩形;只有設定 class 或 style 時才會繪製群組。
groupStyle.group_style(..)StyleProp<ViewStyle>無每個群組的樣式,覆蓋其 class。
inactiveOpacity.inactive_opacity(..)number(0 到 1)無遠離被 hover 節點的其他節點及其標籤的不透明度,隨 hover 的淡化而變化。
label.label(..)keyof T | (d: T, index: number) => string無每個葉節點中的名稱,單行,以刪節號截斷。
valueLabel.value_label(..)(d: T, value: number, index: number) => string無每個葉節點中、名稱下方的值,僅在能完整放下時顯示。
labelPlacement.label_placement(..)"start" | "center""start"標籤位於其葉節點的左上角,或正中央。
labelClass.label_class(..)string無每個葉節點標籤的 class,是與葉節點同大的方塊:其 padding 與文字樣式;深色葉節點上有 data-dark:。
labelStyle.label_style(..)StyleProp<TextStyle>無每個葉節點標籤的樣式,覆蓋其 class。
valueLabelClass.value_label_class(..)string無值那一行的 class,覆蓋標籤的 class。
valueLabelStyle.value_label_style(..)StyleProp<TextStyle>無值那一行的樣式,覆蓋其 class。
groupLabel.group_label(..)keyof T | (d: T, index: number) => string無每個群組的名稱,位於其橫帶中,以刪節號截斷。
groupLabelClass.group_label_class(..)string無每個群組標籤的 class,是與橫帶同大的方塊。
groupLabelStyle.group_label_style(..)StyleProp<TextStyle>無每個群組標籤的樣式,覆蓋其 class。
stagger.stagger(..)number(0 到 0.95)0頂層節點依序出現的方式:它們開始出現的時間點分散在 appear 的多少比例之內。
hoverClass.hover_class(..)string無hover 部件的 class,即覆蓋在被 hover 節點之上、與其同大的 view(rounded-xs outline-2 outline-foreground)。
hoverStyle.hover_style(..)StyleProp<ViewStyle>無hover 部件的樣式,覆蓋其 class。

矩形以 fill 填色(colorScale 的顏色,未設定時則為 currentColor),並以 stroke 描邊(未設定時為無),其自身的設定優先於 treemap 與圖表的設定,如同 CSS 的繼承。版面配置只在列、選項或圖表大小改變時重新執行,指標移動或出現動畫時絕不會重新執行;圖表大小改變不會移動任何東西。重複的 id、指名不到任何列的 parent,以及自己是自己祖先的列,會記錄一次警告:第一列保留該 id,孤立節點成為頂層節點,循環則不繪製。

#PlotAxis

PlotAxis 以該邊所帶比例尺的刻度標示圖表的一條邊:下邊與上邊使用 x 比例尺的刻度,左邊與右邊使用 y 比例尺的刻度,如同 d3 的 axisBottom、axisTop、axisLeft 與 axisRight。其刻度來自比例尺:在 linear 比例尺上為整齊的值(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 或其第二個比例尺 y2Scale(雙軸)。
labelPlacement.label_placement(..)"outside" | "inside""outside"標籤所在位置:在邊緣之外,位於應用程式留在圖表周圍的空間中,避開標記;或在圖表之上沿著邊緣,使圖表保有完整大小(在左邊或右邊旁,每個標籤位於其格線的上側)。
ticks.ticks(..)number在 linear 比例尺上為 10(對齊的比例尺則為每一步),其他比例尺為每一列大約取多少個刻度:在 linear 比例尺上,為定義域內約此數量的整齊值;在 point 或 band 比例尺上,為從第一列到最後一列平均分布的此數量個列。
tickValues.tick_values(..)number[]無其刻度的值,取代 ticks:比例尺的值,在 point 或 band 比例尺上則為列索引([0, 3, 6] 標示每第三列)。位於定義域內的值各有一個刻度。
format.format(..)(value: number, index: number) => string類別型 band 比例尺上為該 band 的類別,其他為數值,帶有刻度間距所需的小數位數,並以千分位分組每個刻度的文字:由應用程式提供的函式,依其值(在 point 或 band 比例尺上為列索引)與其在該軸刻度中的索引而定。
labelClass、labelStyle.label_class(..)、.label_style(..)string、StyleProp<TextStyle>無label 部件:每個刻度一段文字,其字型、顏色與 padding 由應用程式決定;朝向圖表的 padding 即其與邊緣的距離(下軸時為 pt-2)。
tickClass、tickStyle.tick_class(..)、.tick_style(..)string、StyleProp<ViewStyle>無tick 部件:每個刻度一個位於邊緣上的 view,置中於其刻度並朝標籤延伸(下軸時為 h-1 w-px bg-border);設定樣式後才會顯示。
lineClass、lineStyle.line_class(..)、.line_style(..)string、StyleProp<ViewStyle>無line 部件:沿整條邊、位於標籤那一側的 view(下軸時為 h-px bg-border);設定樣式後才會顯示。

在外側時,標籤位於圖表的邊距中,如同 d3 的軸位於圖表的邊距中:圖表保持應用程式給它的大小,而應用程式在軸那一側給它一個與標籤同深的邊距(其下的 pt-2 text-xs 標籤為 mb-6),或使用其父層在該側的 padding。同一邊上的兩條軸各自以自己的樣式標示自己的值:範圍的兩端較淡,中段較實。

format 在刻度或函式改變時,於下一輪對每個刻度呼叫一次,絕不會在圖表繪製或跟隨指標時呼叫:請保持其純粹(相同的值,相同的文字)。沒有它時,刻度的讀法如同 d3 所寫:1,500、0.25、−2。在 Rust 中,plot_axis() 建立一條軸,其 format 為 TickFormat::from_fn(|value, index| ..)。軸不接受指標,螢幕閱讀器讀的是圖表而非其標籤。

#PlotLabel

PlotLabel 會在圖表的各個點上寫出文字:峰值上方的標題、折線最後一點的標籤、格線末端的註記。它的每個 texts 都以圖表比例尺的值來指定所在的點,與標記(mark)相同:在 point 或 band 比例尺上是資料列的索引(取該列頻帶的中間),在 linear 比例尺上則是一個數值。文字的上緣位於該點上,其 align 決定文字從該點開始、以該點為中心,或在該點結束。文字由圖表的版面配置放置,因此會隨圖表大小而變化;它們會隨比例尺的定義域滑動(motion.update),並在出現動畫時淡入。

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>
PropRustTypeDefaultDescription
texts.texts(..)PlotText[][]它所寫出的文字,每段文字各在自己的點上。
scale.scale(..)"y" | "y2""y"其文字的 y 所對應的 y 比例尺(Dual axes)。

每段文字是一個 PlotText:

FieldTypeDefaultDescription
textstring要顯示的內容。
xnumber沿 x 軸的位置:x 比例尺的一個值,或在 point 或 band 比例尺上的資料列索引(0.5 位於前兩列的正中間)。
ynumber其上緣沿 y 軸的位置:y 比例尺的一個值。
align"left" | "center" | "right""left"文字沿 x 方向相對於該點的位置:從該點開始、以該點為中心,或在該點結束。
class, stylestring, StyleProp<TextStyle>none文字自己的 class 與樣式,優先於從標籤繼承而來的設定:其方框(標籤的背景與內距)以及與該點的距離。

外觀由應用程式決定:標籤的 class 為每段文字提供其繼承的文字樣式(字型、大小、顏色),而每段文字自己的 class 與 style 則只為它單獨設定樣式。文字可用 -translate-y-full 放在其點的上方,其內距即是與該點的間距(pb-1);位於折線最後一點旁的標籤,會放在應用程式在該側保留的邊距中(mr-12),就像座標軸的標籤一樣。在 Rust 中,plot_label() 會建立一個標籤,每段文字是一個帶有 .align(PlotTextAlign::Center)、.class(..) 與 .style(..) 的 PlotText::new(text, x, y)。指標會穿過這些文字傳到圖表上,螢幕閱讀器讀出的是圖表本身,其 accessibilityLabel 會說明這些文字所指出的內容。

#Motion

動態由應用程式決定,預設為無動態:所有內容會立即顯示。motion 接受 nsh 的各項計時({ duration, easing, delay } 與 { stiffness, damping, mass }):

KeyTypeWhat moves
appeartiming標記以 appearGeneration 繪入:折線與面積圖從前緣開始,長條從基底長出(搭配長條的 stagger 依序出現);圓點從無到有長出(搭配圓點的 stagger 依序出現);儲存格從中心長出(搭配儲存格的 stagger 依序出現);圓餅圖與弧形圖的環從起點開始順時針掃入;放射狀折線從其內半徑長出,圓點隨之出現;桑基圖的節點與絲帶從前緣開始,其標籤淡入。
updatetiming當應用程式改變形狀時,標記會移動到新的形狀:圓點移到新的值與大小,新資料列的圓點長出、已移除資料列的圓點縮小消失;儲存格變為新的顏色(以 OKLab 混色)與頻帶,新資料列的儲存格長出、已移除資料列的儲存格縮小消失;圓餅圖與弧形圖的半徑(因此環形圖的中央孔洞會張開與閉合)以及它們的角度(因此儀表或扇形會旋轉到新的值;d3 的 arcTween:移動的是角度,而不是輪廓;新的扇形從前一個扇形結束的位置展開;在移動途中發生的變更,會從目前繪製的位置繼續);放射狀折線的各點移到新的值、角度與半徑(新資料列從內半徑長出),放射狀格線的環隨之移動;以及線性比例尺的定義域(應用程式的 domain,或隨資料列到來而取得的標記值範圍)會從舊值滑動到新值,標記、格線、PlotAxis 與 PlotLabel 隨之移動;還有桑基圖的節點、絲帶與標籤,會從舊的流量版面配置移動到新的,在移動途中發生的變更會從目前繪製的位置繼續。
entertiming當指標移到圖表上時,懸停的各部分淡入。
exittiming指標離開後,它們淡出。
pointerspring當按鍵或螢幕閱讀器逐步移動懸停位置時,它們會從一列滑動到下一列;圓餅圖的扇形會因此升起。

懸停的各部分會在同一個影格內跟隨指標:指標移動時,十字線、圓點與工具提示會立即出現在指標下的資料列上,因此能緊跟手的移動,而它們的淡入與淡出(enter、exit)仍然會播放動畫。

動態在平台的動畫器上執行,每個影格都不需要 JS,並且對要求減少動態的使用者會立即完成。由列表為重新出現的資料列再次建立的圖表會再次繪入,除非列表外圍的 PlotAppearScope 已看到它的出現動畫完成。

定義域的移動方式與 d3 轉場比例尺定義域相同:中間狀態的比例尺會放置每個值,因此標記保持其形狀,格線與懸停的各部分隨之移動,長條保持其圓角,隨新定義域進來的資料列也與其他部分一起移動。即時圖表若不時依資料列調整其 y 比例尺(保留一些空間,讓格線在兩次調整之間保持不動),則會以 update 計時滑動到每一次新的調整結果。當圖表大小改變時,標記會立即採用新的大小。

這是圖表自己的動態,也就是圖表中會移動之物的計時:其標記的裁切與懸停的各部分。檢視的進場、退場與引人注意的動畫,則是 createMotion 的預設集與關鍵影格,應用程式仍可將它們套用在圖表的檢視或其周圍的元素上。

#PlotAppearScope

每當資料列重新進入視野,列表就會再次建立該列的圖表,因此帶有出現動畫的迷你折線圖,在列表捲回它時都會重新繪入。列表外圍的 PlotAppearScope 會記住其中已完成的出現動畫:在其中再次建立且 appearGeneration 已完成的圖表,會立即完整顯示其標記。圖表是以其世代(generation)來記憶的,因此每一列的圖表都有自己的世代,也就是該列的 key,加上任何應讓它重播的內容(`${symbol} ${period}`)。

import { List } from "nsh";
import { Line, Plot, PlotAppearScope } from "nsh/base/plot";

<PlotAppearScope id={watchlist().id} class="flex-1">
  <List each={watchlist().rows} keyExtractor={(row) => row.symbol} estimatedItemSize={48} class="flex-1">
    {(row) => (
      <Plot
        data={row().closes}
        y="close"
        interactive={false}
        appearGeneration={`${row().symbol} ${period()}`}
        motion={{ appear: { duration: 400, easing: "easeOut" } }}
        class="h-8 w-24"
      >
        <Line class="stroke-chart-1 stroke-[1.5]" />
      </Plot>
    )}
  </List>
</PlotAppearScope>
PropRustTypeDefaultDescription
id.id(..)number | stringnone範圍所針對記憶的對象:當它改變時,範圍會遺忘,其中的圖表會在原地重新繪入。請依內容命名,例如監看清單或對話;若未提供,範圍會在其顯示期間持續記憶。
children.child(..)JSX.Elementnone它所容納的內容:列表,或隨圖表出現與消失而再次建立圖表的任何內容。

新的世代仍會讓圖表繪入,在出現動畫完成前就被移除的圖表也一樣,並且從頭開始。一旦圖表完成了新的世代,其範圍就會遺忘前一個世代,因此切換回較早的期間時,圖表會再次繪入。記憶隨範圍而存在:不再顯示的範圍,或帶有新的 id 的範圍,會讓其圖表重新繪入,而圖表會記憶在其外圍最內層的範圍中。其外觀由應用程式決定:class 與 style 會像任何檢視一樣為它配置版面(flex-1 用於填滿畫面的列表外圍)。在 Rust 中,plot_appear_scope() 會建立一個範圍。

#Keyboard

互動式圖表是 Tab 鍵的停駐點(macOS、web),當它取得鍵盤焦點時,其按鍵會移動與指標相同的懸停:十字線、圓點與工具提示以 pointer spring 滑動,圓餅圖的扇形升起,onHoverChange 回報該資料列,螢幕閱讀器則讀出應用程式為它提供的 accessibilityValue。其焦點環由應用程式決定:focus-visible: class 或 focusStyle(focus-visible:outline-2 focus-visible:outline-ring)。

KeysWhat they do
Right, Left移到該側的資料列:下一列或上一列,在反向的 x 比例尺上則相反,其第一列在右側(在圓餅圖上為依資料列順序排列的扇形;在桑基圖上為其節點;在樹狀圖上為螢幕上相鄰的葉節點,Up 與 Down 的行為亦同);在沒有任何資料列被懸停時,Right 從最左端的資料列開始,Left 從最右端的資料列開始(在圓餅圖上則分別是第一個與最後一個扇形)。
Home, End移到第一列或最後一列。
Up, Down在儲存格上:移到同一欄中上方或下方的儲存格。在儲存格上,按鍵會依螢幕上的格線移動(WAI-ARIA 的 data grid):Right 與 Left 移到沿該行的下一個儲存格,Home 與 End 移到該行的第一個與最後一個儲存格;在沒有任何儲存格被懸停時,第一次按鍵從左上角的儲存格開始(Left、Up 與 End 則從右下角的儲存格開始)。到達邊緣時,懸停會停留在原處。
Escape清除懸停。

圖表在取得焦點時回應其按鍵,並在有資料列被懸停時回應 Escape;其餘時候,按鍵會交給應用程式及其周圍的檢視。將焦點移開會清除由按鍵造成的懸停。圖表不接收按壓,因此位於可按壓卡片內的圖表會把按壓留給卡片。螢幕閱讀器同樣可以逐列瀏覽:VoiceOver 的上下滑動、TalkBack 的向前與向後捲動,以及每個提供元素的 increment 與 decrement 動作的螢幕閱讀器,都會移到下一列與上一列,在反向比例尺上也是依資料列的順序。

#Building charts

圖表由一個圖表區、其標記、其座標軸,以及圍繞它們的應用程式自有元素組成。為 y 比例尺指定 domain,並讓其座標軸使用格線的值(tickValues):每個標籤便會位於其格線上。懸停的十字線、圓點與工具提示是圖表的一部分,由應用程式設定樣式並填入內容;資料列的值則放在應用程式顯示它們的地方。

格線最適合使用實線細線,並搭配較明顯的基線,這樣虛線就可以用來表示參考值(如下方的開盤價)。面積圖以朝基線逐漸淡出的淡色漸層呈現,比平坦的填色更輕盈易讀。每個數列的懸停圓點採用該數列的顏色,並以圖表所在表面的顏色作為其外環,使它從線條中凸顯出來;工具提示位於圖表區的頂端,在第一列與最後一列時則保持在邊緣之內:在最高值上方留有空間的 domain 能讓它遠離線條。

import { signal, Text, View } from "nsh";
import { Area, Grid, Line, Plot, PlotAxis } from "nsh/base/plot";

type Row = { time: string; price: number };

export function PriceChart(props: { rows: Row[]; low: number; high: number }) {
  const hovered = signal<number | null>(null);
  const shown = () => props.rows[hovered() ?? props.rows.length - 1];
  const middle = () => (props.low + props.high) / 2;
  return (
    <View class="gap-3 rounded-xl border border-border bg-card p-4">
      {/* The value of the row under the pointer, or the last one. */}
      <View class="flex-row items-baseline">
        <Text class="flex-1 text-sm text-muted-foreground">{shown().time}</Text>
        <Text class="text-right text-xl font-semibold tabular-nums text-foreground">{shown().price.toFixed(2)}</Text>
      </View>
      <Plot
        data={props.rows}
        y="price"
        yScale={{ type: "linear", domain: [props.low, props.high] }}
        accessibilityLabel={`Price, from ${props.rows[0].price} to ${props.rows[props.rows.length - 1].price}`}
        accessibilityValue={{ text: `${shown().time}, ${shown().price.toFixed(2)}` }}
        appearGeneration={props.rows.length}
        motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 300, damping: 30 } }}
        crossLineClass="w-px h-full bg-muted-foreground/50"
        tooltipOffset={4}
        tooltipContent={
          <View class="flex-row items-center gap-1.5 rounded-lg border border-border bg-popover px-2 py-1 shadow-md">
            <View class="h-0.5 w-2.5 rounded-full bg-chart-1" />
            <Text class="text-xs tabular-nums text-popover-foreground">{shown().price.toFixed(2)}</Text>
          </View>
        }
        onHoverChange={({ index }) => hovered.set(index)}
        class="mr-10 mb-6 h-48"
      >
        <Grid y={[middle(), props.high]} class="stroke-border" />
        {/* A wash that fades out toward the baseline. */}
        <Area backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" />
        <Grid y={[props.low]} class="stroke-muted-foreground/40" />
        <Grid y={[props.rows[0].price]} class="stroke-muted-foreground" style={{ strokeDasharray: "3 3" }} />
        <Line class="stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
        {/* The grid's values right of the plot, and the first and last times under it. */}
        <PlotAxis
          placement="right"
          tickValues={[props.low, middle(), props.high]}
          format={(v) => v.toFixed(0)}
          labelClass="pl-2 text-xs tabular-nums text-muted-foreground"
        />
        <PlotAxis
          tickValues={[0, props.rows.length - 1]}
          format={(i) => props.rows[i].time}
          labelClass="pt-2 text-xs text-muted-foreground"
        />
      </Plot>
    </View>
  );
}

長條圖最適合讓長條的頂端為圓角、底部為直角(cornerRadii={{ topLeft: 4, topRight: 4 }}; in a stack only on its top bar), a hair of room between the bars of a band (groupPadding),懸停某一列時讓其他列的長條變淡(inactiveOpacity)並為該列的頻帶著色(crossLineClass),並在長條上方以小型等寬數字顯示數值,堆疊圖則在堆疊上方顯示總計。當 paddingOuter 為 paddingInner 的一半時,各頻帶的中間會落在等距的位置,而 PlotAxis 會讓每個月份置中於其頻帶下方;在寬卡片中,maxBandWidth 可讓少數長條保持較窄。

import { signal, Text, View } from "nsh";
import { Bar, Grid, Plot, PlotAxis } from "nsh/base/plot";

type Sale = { month: string; online: number; store: number };

export function SalesChart(props: { rows: Sale[]; stacked: boolean }) {
  const hovered = signal<number | null>(null);
  const shown = () => props.rows[hovered() ?? props.rows.length - 1];
  const tip = { topLeft: 4, topRight: 4 };
  return (
    <View class="gap-3 rounded-xl border border-border bg-card p-4">
      <View class="flex-row items-baseline">
        <Text class="flex-1 text-sm text-muted-foreground">{shown().month}</Text>
        <Text class="text-right text-sm tabular-nums text-foreground">{`${shown().online + shown().store}k`}</Text>
      </View>
      <Plot
        data={props.rows}
        xScale={{ type: "band", paddingInner: 0.3, paddingOuter: 0.15, maxBandWidth: 56, groupPadding: 0.1 }}
        yScale={{ type: "linear", domain: [0, 130] }}
        accessibilityLabel="Sales per month, online and in store"
        appearGeneration={props.stacked ? 1 : 0}
        motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 150 }, exit: { duration: 150 }, pointer: { stiffness: 320, damping: 32 } }}
        crossLineClass="w-full h-full bg-foreground/5"
        onHoverChange={({ index }) => hovered.set(index)}
        class="mb-6 h-44"
      >
        <Grid y={[40, 80, 120]} class="stroke-border" />
        <Bar
          value="online"
          stack={props.stacked ? "sales" : undefined}
          cornerRadii={props.stacked ? 0 : tip}
          stagger={0.4}
          inactiveOpacity={0.45}
          label={props.stacked ? undefined : "online"}
          labelClass="pb-1 text-[11px] tabular-nums text-muted-foreground"
          class="fill-chart-1"
        />
        <Bar
          value="store"
          stack={props.stacked ? "sales" : undefined}
          cornerRadii={tip}
          stagger={0.4}
          inactiveOpacity={0.45}
          label={props.stacked ? (d) => String(d.online + d.store) : "store"}
          labelClass="pb-1 text-[11px] tabular-nums text-muted-foreground"
          class="fill-chart-2"
        />
        {/* A month centered under each band. */}
        <PlotAxis format={(i) => props.rows[i].month} labelClass="pt-2 text-xs text-muted-foreground" />
      </Plot>
    </View>
  );
}

列表資料列中的迷你折線圖是帶有 interactive={false} 的圖表:它只繪製其標記,不追蹤任何指標。

#Accessibility

  • 對螢幕閱讀器而言,圖表是單一元素,具有 image 角色與應用程式提供的 accessibilityLabel;其標記、座標軸的標籤、PlotLabel 以及懸停的各部分都是它的一部分,本身不會發出任何朗讀。請在標籤中說明圖表顯示的內容與突出之處(範圍、最高值),並為 accessibilityValue 提供指標下資料列的描述文字,讓螢幕閱讀器在值改變時能朗讀出來。
  • 在有指標的平台(桌面、web、搭配觸控板的 iPad)上,指標下的資料列會以 onHoverChange 回報。觸控螢幕不會回報懸停:請在應用程式自己的元素中顯示觸控使用者所需的值,或在數字重要的地方以文字或列表的形式提供各資料列。
  • 在圓餅圖上,回報的資料列是指標下的扇形:請為 accessibilityValue 提供其名稱、值與占比("Team, 340 seats, 34.0%"),並在標籤中說明總計與最大的占比。在雷達圖上,回報的是最接近指標的軸:請提供該軸的名稱以及各數列在該處的值。
  • 互動式圖表是鍵盤焦點的停駐點,其方向鍵會移動懸停(Keyboard),並向螢幕閱讀器提供 increment 與 decrement 以逐列瀏覽(VoiceOver 會將它讀作可調整項目);帶有 interactive={false} 的圖表兩者皆非,且無論如何都不接收按壓。

關於標籤與角色的一般說明,請參閱 Accessibility。

#平台說明

  • 每個平台都以相同方式繪製標記:每個標記的繪製內容由 Rust 記錄一次;資料列變更時,讀取這些資料列的標記會在同一批次中重新繪製;而繪製類別(外觀,dark:)變更時,會重新送出繪製內容而不重新記錄。
  • 出現動畫以平台動畫器移動的數值來裁切折線與區域,並使長條增長;長條從被懸停的資料列逐漸淡出,是動畫器依懸停位置計算出的不透明度;懸停的各個部分是由動畫器移動與淡入淡出的檢視,且動畫器會依繪圖區的大小與工具提示的大小,將工具提示保持在繪圖區內。
  • 區域的背景繪製於其專屬的畫布中,並裁切成區域的形狀,使用的漸層與平台為檢視的 bg-linear-* 所繪製的漸層相同;其類別(外觀,dark:)變更時,會重新送出繪製內容而不重新記錄。
  • 圓餅圖將每個扇形繪製於各自的畫布中;指標下方的扇形由 Rust 依各扇形的角度與半徑找出,其凸起是半徑,其他扇形的淡出是不透明度,兩者都由動畫器移動;出現動畫的掃動也以同樣方式移動各扇形的角度,因此兩者都不會重新記錄繪製內容。
  • 放射狀折線中,指標下方的資料列由 Rust 依各點的角度找出;其懸停圓點是由動畫器放置在指標下方資料列上的檢視(隨著按鍵步進,以 pointer 彈簧動畫滑動);而在出現或移動到新值時,其各點是動畫器從原位置開始移動的數值,因此每次變更只會記錄一次折線。
  • 點標記繪製於單一畫布中;指標下方的圓點由 Rust 依各圓點的中心與半徑找出,其懸停圓點是由動畫器放置在該圓點上、且大小與其相同的檢視;出現時的增長、變更時的移動,以及懸停的淡出,都是由動畫器移動的數值,因此每次變更只會記錄一次圓點。
  • 儲存格標記繪製於單一畫布中;指標下方的儲存格由 Rust 依各儲存格的方框找出,其懸停儲存格是由動畫器放置在該儲存格上的檢視;增長、變更時的顏色與移動,以及懸停的淡出,都是由動畫器移動的數值,因此每次變更只會記錄一次儲存格。每個標籤都是平台的文字,僅在其排版後的大小能容納於儲存格內時才會顯示。
  • 座標軸的標籤、刻度記號與軸線,是平台自有的文字與檢視,由 Rust 的版面配置依繪圖區的大小與比例尺放置:繪圖區大小變更時,它們會隨標記一起移動,而新的定義域會重新寫入標籤。