元素

Plot

Plot 是图表的框架:它持有要显示的数据行、x 轴和 y 轴的比例尺、指针在它上面的位置以及动效。它的图形负责绘制这些内容:Line 是穿过一个系列的折线,Area 是系列与基线之间的区域,Grid 是横跨图表的网格线。每个图形都是一个铺满 plot 的画布,根据数据行原生绘制:应用设置数据行时只读取一次,之后在 Rust 中完成缩放、录制和绘制,所以一千个点的图表也不会为每个点运行 JS。指针同样在 Rust 中跟踪:悬停的十字线、圆点和提示框由平台的动画器移动和淡入淡出,应用只会在指针移到另一行时收到通知。外观由应用决定:图形使用 CSS 中 SVG 图形的绘制属性(stroke-*、fill-*),没有设置时使用 currentColor;area 可以在自己的形状内显示背景(渐变,bg-linear-*);悬停部件(包括每个图形自己的圆点)和坐标轴标签都由应用设置样式和内容。

el-plot.tsx
el-plot.tsx运行中
import { mount, signal, Text, View } from "nsh";
import { Area, Grid, Line, Plot } 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, so its labels sit at the
// grid's values; 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.
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]}` },
};

// 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 };

function App() {
  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" : "Pick a range to draw other rows"}
      </Text>

      <Split
        width={560}
        inspector={
          <>
            <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>
            <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 }}>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>

          {/* Above the x axis' labels, so the tooltip covers them. */}
          <View class="flex-row gap-2" style={{ zIndex: 1 }}>
            <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={{ flex: 1, height }}
            >
              <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 }}
              />
            </Plot>
            <View style={{ justifyContent: "space-between", marginVertical: -7, width: 28 }}>
              {[hi(), (lo() + hi()) / 2, lo()].map((v) => (
                <Text style={{ fontSize: 11, color: ui().muted, ...num }}>{label(v)}</Text>
              ))}
            </View>
          </View>
          <View class="flex-row justify-between" style={{ marginRight: 36 }}>
            {RANGES[range()].ticks.map((t) => (
              <Text style={{ fontSize: 11, color: ui().muted }}>{t}</Text>
            ))}
          </View>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{
    plot::{area, grid, line, plot},
    theme,
};
use nsh_core::ui::{RowAccessor, Rows, prelude::*};

fn week(visitors: Vec<f64>, hovered: Signal<Option<u32>>) -> impl IntoElement {
    let rows = Rows::from_columns(visitors.len(), [("visitors".into(), visitors)]);
    plot()
        .data(rows)
        .y(RowAccessor::key("visitors"))
        .accessibility_label("每天的访客数")
        .h(px(160.0))
        .on_hover_change(move |index: &Option<u32>| hovered.set(*index))
        .child(grid().y(vec![0.0, 1500.0, 3000.0]).stroke_color(move || theme().border))
        .child(area().fill_color(move || theme().chart_1.opacity(0.2)))
        .child(line().stroke_color(move || theme().chart_1).stroke_width(px(2.0)))
}

Plot、Line、Area 和 Grid 来自 nsh/base/plot,这是 nsh/base 中单独的一个模块(Rust 中是 nsh_base::plot)。在 TypeScript 中,plot 的属性对数据行的类型是泛型的:字段名会按数据行做类型检查,行的函数会拿到行的类型。在 Rust 中,plot() 为每个属性提供一个 setter,数据行是 Rows(按键名存放的数字列),取值方式是 RowAccessor(键名,或以行序号为参数的函数),图形使用带类型的绘制方法(.stroke_color(..)、.fill_color(..)、.stroke_width(..))。

#Plot

属性Rust类型默认值说明
data.data(..)T[](必填)数据行,每个点一个对象。应用设置数据行时,图形读取一次它们的值;没有值的行(字段不是数字、为 null)会跳过。
x.x(..)keyof T | (d: T, index: number) => number行的序号在 linear x 比例尺上决定一行沿 x 轴位置的取值。point 比例尺按顺序放置各行。
y.y(..)keyof T | (d: T, index: number) => number无图形没有指定自己的取值时绘制的内容。
xScale.x_scale(..)Scale{ type: "point" }x 轴如何放置各行:point(按顺序从左边缘到右边缘均匀分布;只有一行时放在中间),或带可选 domain 的 linear。
yScale.y_scale(..)Scale{ type: "linear" }y 轴如何放置数值:定义域的最小值在底边,最大值在顶边。没有 domain 时取各图形所绘制数值的范围。
interactive.interactive(..)booleantrueplot 跟随指针:报告指针下的行并显示悬停部件。为 false 时只绘制图形(例如列表中的迷你图)。
motion.motion(..)PlotMotion无出现动效、悬停淡入淡出的时间,以及悬停跟随指针所用的弹簧(见下文)。
appearGeneration.appear_generation(..)number无启用出现动效:plot 第一次显示时图形逐渐绘出,此值每次变化时再播放一次(换一支股票、换一个周期)。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器对图表读出的内容:图表显示了什么,以及值得注意的地方。
accessibilityValue.accessibility_value(..){ min?, max?, now?, text? }无屏幕阅读器读出的数值:应用对指针下那一行的描述。
crossLineClass、crossLineStyle.cross_line_class(..)、.cross_line_style(..)string、StyleProp<ViewStyle>无十字线:与 plot 等高的视图,以指针下的行为中心。
dotClass、dotStyle.dot_class(..)、.dot_style(..)string、StyleProp<ViewStyle>无悬停圆点:每个 Line 和 Area 一个视图,以该图形在指针下那一行的点为中心。图形自己的 hoverDotClass / hoverDotStyle 会替换它们,用于该图形的圆点。
tooltipContent.tooltip_content(..)JSX.Element无应用的提示框,按 tooltipPlacement 放置,随十字线移动和淡入淡出。
tooltipPlacement.tooltip_placement(..)"top" | "pointer""top"提示框的位置:在 plot 顶部、以十字线为中心;或在指针旁边,位于十字线朝 plot 中间的一侧,指针在上半部时在它下方,在下半部时在它上方。在顶部时,提示框留在 plot 的左右边缘之间(与顶边的距离由应用决定:放在 plot 下方的标签取 plot 的高度再多一些);在指针旁边时,提示框留在 plot 内。
tooltipOffset.tooltip_offset(..)number0提示框与定位依据之间的距离:"top" 时是顶边,"pointer" 时是十字线和指针。
children.child(..)JSX.Element无图形,按顺序叠加绘制,以及应用放在它们上面的内容。
class、style样式方法string、StyleProp<ViewStyle>无plot 的尺寸和外观;图形会像 CSS 那样继承它的绘制属性。
事件Rust参数触发时机
onHoverChange.on_hover_change(..)index: number | null指针移到另一行上:沿 x 轴离指针最近的那一行的序号;指针离开 plot 后为 null。

Scale 是 { type: "point" } | { type: "linear"; domain?: [number, number] }。domain 取两个值构成的范围,顺序不限;只有一个值的定义域会把它放在中间。

行的函数(y={(d) => d.high - d.low})在数据行或函数变化时,于下一轮对每行调用一次,绘制和跟随指针时从不调用:请保持它是纯函数(同一行得到同一个数)。字段名则完全不运行 JS。

#Line、Area 和 Grid

图形属性类型默认值说明
Lineystring | (d, index) => numberplot 的 y要绘制的内容:每个有值的行对应一个点,没有值的行跳过。
Line、Areadata、xT[]、string | (d, index) => numberplot 的数据行自己的一组数据行(x 值不同的系列),用自己的 x 放在 plot 的比例尺上(没有时用行的序号)。
Line、Areacurve"natural" | "linear" | "stepAfter""natural"点与点之间如何连接:平滑曲线、直线段,或保持每个值直到下一行的阶梯。
Linedot、dotClass、dotStyleboolean、string、StyleProp<ViewStyle>false在每个点上显示圆点:以点为中心的视图,尺寸、背景和边框由应用决定。
Line、AreahoverDotClass、hoverDotStylestring、StyleProp<ViewStyle>plot 的 dotClass、dotStyle它的悬停圆点,位于它在指针下那一行的点上:每个系列的圆点用自己的颜色。两者分别替换 plot 上对应的值,只作用于这个图形(图形的颜色配合 plot 的尺寸和描边);hidden 表示不显示。
AreabackgroundClass、backgroundStylestring、StyleProp<ViewStyle>无它的背景:包围其形状的盒子(从第一个点到最后一个点,从最高点到基线),盒子的背景(bg-*、bg-linear-to-b from-… to-…、backgroundImage)只在形状内显示,位于 fill 之下。设置背景后,fill 没有设置时为 none。
Areay1string | (d, index) => numberplot 的 y顶边使用的取值。
Areay0number底边基线在 y 比例尺上的值。
Gridxnumber[]无竖线的位置,取 x 比例尺上的值(point 比例尺上是行的序号)。
Gridynumber[]无横线的位置,取 y 比例尺上的值。

每个图形都铺满 plot,使用自己的 SVG 绘制属性 绘制;像 CSS 的继承一样,自己设置的值优先于 plot 上的值:

图形使用的属性没有设置时
Linestroke、strokeWidth、strokeDasharraycurrentColor,宽 1,实线
Areafill(位于 background 部件之上);顶边使用 stroke、strokeWidth、strokeDasharrayfill 为 currentColor(有背景时为 none);没有描边
Gridstroke、strokeWidth、strokeDasharraycurrentColor,宽 1,实线;每条线都落在整数设备像素上,细线也保持清晰

plot 没有设置定义域时,Line 和 Area 绘制的数值(包括 area 的 y0)构成 y 比例尺的定义域;grid 的数值不计入。图形只在要画的内容变化时重绘(数据行、取值、曲线、plot 的尺寸或比例尺、绘制属性);指针只移动悬停部件,从不让图形重绘。

fill 与浏览器中一样是 CSS 的 fill:一个颜色。折线下方的渐变是 area 的背景,就像网页在形状里显示渐变那样(盒子的 background-image 加上 clip-path):backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" 从 area 的最高点渐隐到基线,与任何视图的背景一样使用主题令牌和当前外观的颜色。

#动效

动效由应用决定,默认没有:一切立即显示。motion 使用 nsh/animated 的时间参数({ duration, easing, delay } 和 { stiffness, damping, mass }):

键类型作用
appeartiming有 appearGeneration 时,图形从起始边缘逐渐绘出。
entertiming指针移到 plot 上时,悬停部件淡入。
exittiming指针离开后,悬停部件淡出。
pointerspring指针在 plot 上移动时,悬停部件从一行滑到下一行。

动效在平台的动画器上运行,不需要每帧运行 JS;对偏好减少动态效果的用户立即完成。列表为回到视野的行重新创建 plot 时,图形会再绘出一次;可以把行的键名作为它的 appearGeneration。

这是 plot 自己的动效,用来设置图表里动的部分(图形的裁剪区域和悬停部件)的时间参数。视图的入场、离场和强调效果使用 createMotion 的预设和关键帧,应用仍然可以把它们用在 plot 的视图或周围的元素上。

#构建图表

一个图表由 plot、它的图形以及应用自己放在周围的元素组成。应用要给 y 轴加标签时,为 y 比例尺指定 domain,标签就能对齐网格线的数值。悬停的十字线、圆点和提示框是 plot 的部件,由应用设置样式和内容;该行的数值显示在应用想放的地方。

网格线用实线细线、底部基线稍深时最清楚,虚线就可以专门表示参考值(下例中的开盘价)。area 用朝基线渐隐的淡色比平涂更轻盈。每个系列的悬停圆点用该系列的颜色,描边用图表所在表面的颜色,从折线上“挖”出来;提示框位于 plot 顶部,在第一行和最后一行时也留在边缘以内,domain 在最高值之上留出空间,提示框就不会盖住折线。

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

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

export function PriceChart(props: { rows: Row[]; low: number; high: number }) {
  const hovered = signal<number | null>(null);
  const shown = () => props.rows[hovered() ?? props.rows.length - 1];
  const middle = () => (props.low + props.high) / 2;
  return (
    <View class="gap-3 rounded-xl border border-border bg-card p-4">
      {/* 指针下那一行的数值,没有时取最后一行。 */}
      <View class="flex-row items-baseline">
        <Text class="flex-1 text-sm text-muted-foreground">{shown().time}</Text>
        <Text class="text-right text-xl font-semibold tabular-nums text-foreground">{shown().price.toFixed(2)}</Text>
      </View>
      <View class="flex-row gap-2">
        <Plot
          data={props.rows}
          y="price"
          yScale={{ type: "linear", domain: [props.low, props.high] }}
          accessibilityLabel={`价格,从 ${props.rows[0].price} 到 ${props.rows[props.rows.length - 1].price}`}
          accessibilityValue={{ text: `${shown().time}, ${shown().price.toFixed(2)}` }}
          appearGeneration={props.rows.length}
          motion={{ appear: { duration: 600, easing: "easeOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 300, damping: 30 } }}
          crossLineClass="w-px h-full bg-muted-foreground/50"
          tooltipOffset={4}
          tooltipContent={
            <View class="flex-row items-center gap-1.5 rounded-lg border border-border bg-popover px-2 py-1 shadow-md">
              <View class="h-0.5 w-2.5 rounded-full bg-chart-1" />
              <Text class="text-xs tabular-nums text-popover-foreground">{shown().price.toFixed(2)}</Text>
            </View>
          }
          onHoverChange={(index) => hovered.set(index)}
          class="h-48 flex-1"
        >
          <Grid y={[middle(), props.high]} class="stroke-border" />
          {/* 朝基线渐隐的淡色。 */}
          <Area backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" />
          <Grid y={[props.low]} class="stroke-muted-foreground/40" />
          <Grid y={[props.rows[0].price]} class="stroke-muted-foreground" style={{ strokeDasharray: "3 3" }} />
          <Line class="stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
        </Plot>
        {/* y 轴标签,放在网格线的数值处(坐标轴图形推出之前先用 Text)。 */}
        <View class="-my-2 w-8 justify-between">
          {[props.high, middle(), props.low].map((v) => (
            <Text class="text-right text-xs tabular-nums text-muted-foreground">{v.toFixed(0)}</Text>
          ))}
        </View>
      </View>
      <View class="mr-10 flex-row justify-between">
        <Text class="text-xs text-muted-foreground">{props.rows[0].time}</Text>
        <Text class="text-xs text-muted-foreground">{props.rows[props.rows.length - 1].time}</Text>
      </View>
    </View>
  );
}

列表行中的迷你图是一个 interactive={false} 的 plot:只绘制图形,不跟随指针。

#无障碍

  • plot 对屏幕阅读器是一个元素,角色为图像,读出应用的 accessibilityLabel;图形和悬停部件是它的组成部分,自己不读出任何内容。在标签中说明图表显示了什么以及值得注意的地方(范围、最高值),并用 accessibilityValue 给出指针下那一行的描述,屏幕阅读器就会随之读出数值。
  • 在有指针的平台上(桌面、网页、配触控板的 iPad),指针下的行通过 onHoverChange 报告。触摸屏没有悬停:触摸用户需要的数值请用应用自己的元素显示;数字很重要时,再用文字或列表提供数据行。
  • plot 不接收点按,也不获得键盘焦点。

关于标签和角色的一般说明,见 无障碍。

#平台说明

  • 所有平台以相同的方式绘制图形:每个图形一份在 Rust 中录制的绘图。数据行变化时,读取它们的图形在一个批次中重绘;绘制类名的变化(外观、dark:)只会重新发送绘图,不需要重新录制。
  • 出现动效用平台动画器推动的值裁剪图形;悬停部件是由动画器移动和淡入淡出的视图,动画器还根据 plot 和提示框的尺寸把提示框留在 plot 内。
  • area 的背景画在它自己的画布里,裁剪到 area 的形状,使用的渐变与平台为视图的 bg-linear-* 绘制的相同;它的类名变化(外观、dark:)只会重新发送绘图,不需要重新录制。