تُرجمت هذه الصفحة آليًا وقد تحتوي على أخطاء. اقرأ الأصل الإنجليزي

العناصر

Plot

Plot هو إطار المخطط البياني: صفوف البيانات التي يعرضها، ومقياسا محوريه x وy، والمؤشر فوقه وحركته. ترسم علاماته (marks) ما يحتويه: Line عبر سلسلة، وArea بين سلسلة وخط أساس، وBar لكل صف في نطاقات مقياس نطاقات، وRule لكل صف من بداية إلى نهاية (فتيل الشمعة)، وDot لكل صف حيث تلتقي قيمتان من قيمه (نقاط مخطط التبعثر، وفقاعات المخطط الفقاعي)، وCell لكل صف في نطاقات فئتين، ملوّنة بقيمته (خريطة حرارية)، وGrid من الخطوط عرضًا، وPie من حصص الصفوف (دونات بثقب)، وArc بين زاويتين، وRadialLine حول المركز تقف خلفه حلقات ومحاور RadialGrid (مخطط رادار)، وSankey من التدفقات بين الصفوف، وTreemap من مستطيلات متداخلة تساوي مساحة كل ورقة فيها قيمتها (خريطة سوق)؛ ويضع PlotAxis تسميات على حافة بعلامات مقياسها، ويكتب PlotLabel نصوصًا عند نقاط من المقاييس. كل علامة هي canvas واحدة تملأ plot، وتُرسم محليًا من الصفوف: تُقرأ صفوف التطبيق مرة واحدة عندما يضبطها، ثم تُقاس وتُسجَّل وتُرسم في Rust، فلا يشغّل مخطط من ألف نقطة أي JS لكل نقطة. ويُتابَع المؤشر في Rust أيضًا: ينتقل خط التقاطع الخاص بالتمرير ونقاطه وتلميحه ويتلاشى على animator المنصة، ولا يعرف التطبيق سوى صف جديد، لا أكثر. المظهر من شأن التطبيق: ترسم العلامات بخصائص الطلاء في CSS الخاصة بأشكال SVG (stroke-*، fill-*)، وcurrentColor حيث لا يُحدَّد شيء، وتعرض المساحة أو الشريط خلفية (تدرجًا، bg-linear-*) داخل شكلها، وأجزاء التمرير (ومنها نقطة كل علامة) وتسميات المحاور وعلامات التدريج وخطوطها كلها يتولى التطبيق تنسيقها وملأها.

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 عامة (generic) بحسب نوع صفوفه: تُفحص أسماء الحقول مقابله، وتحصل الدالة المطبَّقة على صف على نوع ذلك الصف. في 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فهرس الصفما يحدد موضع الصف على المحور x في مقياس x من النوع linear. أما مقياس point فيضع الصفوف بالترتيب.
y.y(..)keyof T | (d: T, index: number) => numberلا شيءما ترسمه العلامات ما لم تسمِّ علامة ما يخصها.
xScale.x_scale(..)Scale{ type: "point" }كيف يضع المحور x الصفوف: point (بالترتيب، بتباعد متساوٍ من الحافة اليسرى إلى اليمنى؛ ويقع صف واحد في الوسط)، أو linear مع domain اختياري، أو band (نطاق لكل صف، للأشرطة، Bar؛ أو نطاق لكل فئة، للخلايا، Cell). ومع مقياس y من النوع band يكون الافتراضي linear، للأشرطة الأفقية. ويجعله reverse: true يبدأ من الحافة اليمنى (المقاييس المعكوسة).
yScale.y_scale(..)Scale{ type: "linear" }كيف يضع المحور y القيم: أصغر قيمة في المجال عند الحافة السفلية، وأكبرها عند العلوية. وبدون domain يكون المجال امتداد ما ترسمه العلامات. ويضع band نطاقًا لكل صف من الحافة العلوية نزولًا، للأشرطة الأفقية. ويعكسه reverse: true: أصغر قيمة عند الحافة العلوية، والصفوف من الحافة السفلية صعودًا (المقاييس المعكوسة).
y2Scale.y2_scale(..)LinearScaleلا شيءمقياس y ثانٍ، { type: "linear", domain?, reverse? }، للعلامات التي تسميه (scale="y2"): المقياس الأيمن في مخطط ثنائي المحور (المحاور المزدوجة). وبدون domain يكون امتداد القيم الموضوعة عليه.
alignTicks.align_ticks(..)booleantrueما دام أي من مقياسي y لا يسمّي مجالًا وتوجد علامات على كليهما، يُوسَّع كلاهما إلى الخطوات المستديرة نفسها لتقع علاماتهما على الخطوط نفسها (المحاور المزدوجة). ويُبقي false كلًّا منهما على امتداد قيمه.
interactive.interactive(..)booleantrueيتابع plot المؤشر ولوحة المفاتيح: فيبلّغ عن الصف الواقع تحته ويعرض أجزاء التمرير، وهو محطة توقف لمفتاح Tab تحرّك مفاتيح الأسهم فيه التمرير (لوحة المفاتيح). ويرسم false العلامات فقط (sparkline في قائمة)، وتذهب الضغطات عليه إلى العروض المحيطة به. أما تقديم plot للوحة المفاتيح من الأساس فيُحدَّد عند إنشائه.
motion.motion(..)PlotMotionلا شيءتوقيت الظهور، وتلاشي التمرير دخولًا وخروجًا، والزنبرك الذي ينزلق عليه التمرير عندما تحرّكه المفاتيح (أدناه).
appearGeneration.appear_generation(..)number | stringلا شيءيفعّل الظهور: تُرسم العلامات تدريجيًا عندما يُعرض plot أول مرة، ومجددًا كلما تغيّر هذا (رمز آخر، فترة أخرى). داخل PlotAppearScope، يظهر الجيل الذي انتهى هناك كاملًا دفعة واحدة.
accessibilityLabel.accessibility_label(..)stringلا شيءما تقوله قارئات الشاشة عن المخطط: ما يعرضه وما يبرز فيه.
accessibilityValue.accessibility_value(..){ min?, max?, now?, text? }لا شيءما تسمعه قارئات الشاشة بوصفه قيمته: كلمات التطبيق للصف الواقع تحت المؤشر.
crossLineClass، crossLineStyle.cross_line_class(..)، .cross_line_style(..)string، StyleProp<ViewStyle>لا شيءالخط المتقاطع: عرض بارتفاع plot، متمركز على الصف الواقع تحت المؤشر. وعلى مقياس نطاقات يقع في نطاق الصف، بعرض النطاق (عبر plot على مقياس y ذي نطاقات).
dotClass، dotStyle.dot_class(..)، .dot_style(..)string، StyleProp<ViewStyle>لا شيءنقاط التمرير: عرض لكل من Line وArea وRadialLine، متمركز على نقطته عند الصف الواقع تحت المؤشر، ويكون حجم عرض Dot بحجم نقطته تحت المؤشر. وتحل hoverDotClass / hoverDotStyle الخاصة بعلامة محلها لنقطتها.
tooltipContent.tooltip_content(..)JSX.Elementلا شيءتلميح التطبيق (tooltip)، يضعه tooltipPlacement ويتحرك ويتلاشى مع الخط المتقاطع.
tooltipPlacement.tooltip_placement(..)"top" | "pointer""top"أين يوضع التلميح: أعلى plot، متمركزًا على الخط المتقاطع؛ أو بجانب المؤشر، على جانب الخط المتقاطع المتجه نحو منتصف plot، وتحت المؤشر في النصف العلوي، وفوقه في النصف السفلي. وفي الأعلى يبقى بين حافتي plot اليسرى واليمنى (إزاحته عن الحافة العلوية من شأن التطبيق: فالوسم تحت plot يأخذ ارتفاع plot وأكثر)؛ وبجانب المؤشر يبقى داخل plot. ومع لوحة المفاتيح يذهب بجانب نقطة أول خط أو مساحة عند الصف (منتصف نطاق الشريط، ومنتصف شريحة pie).
tooltipOffset.tooltip_offset(..)number0المسافة بين التلميح وما يضعه: الحافة العلوية لـ "top"، والخط المتقاطع والمؤشر لـ "pointer".
colorScale.color_scale(..)ColorScaleلا شيءألوان العلامات: من الفئات التي يقرؤها color الخاص بها، كما في scaleOrdinal في d3 ({ type: "ordinal", domain?, range, unknown? }، الألوان حسب الفئة)، أو من الأرقام التي يقرؤها value الخاص بـ Cell على طول تدرج، كما في scaleSequential وscaleDiverging في d3 ({ type: "sequential" | "diverging", … }، الألوان على طول تدرج).
children.child(..)JSX.Elementلا شيءالعلامات، تُرسم بالترتيب فوق بعضها، وكل ما يضعه التطبيق فوقها.
class، styleدوال التنسيقstring، StyleProp<ViewStyle>لا شيءحجم plot ومظهره؛ وترث العلامات خصائص الطلاء الخاصة به، كما يرثها CSS. ويرسم صنفا focus: / focus-visible: وfocusStyle حلقة التركيز الخاصة به.
الحدثRustالحمولةمتى
onHoverChange.on_hover_change(..){ index: number | null }جاء المؤشر فوق صف آخر: فهرس الصف الأقرب إليه على طول المحور x (على طول المحور y عندما يكون له وحده نطاقات)، أو شريحة pie الواقعة تحته، أو dot الذي مركزه الأقرب إليه، أو cell الواقعة تحته (وعبر الفجوة بين خليتين، الأقرب منهما)، أو صف radial line الأقرب إليه بحسب الزاوية؛ وnull عندما يغادر plot (فوق 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 فهو فئات نطاقاته، بالترتيب (Cell)؛ وخياراته الأخرى في Bar.

تُستدعى الدالة المطبَّقة على صف (y={(d) => d.high - d.low}) مرة لكل صف عندما تتغير الصفوف أو الدالة، في الدورة التالية، ولا تُستدعى أبدًا أثناء رسم plot أو متابعته للمؤشر: أبقِها نقية (الصف نفسه، الرقم نفسه). ويُقرأ اسم الحقل بدون أي JS على الإطلاق.

#المقاييس المعكوسة

يجعل reverse: true المقياس يبدأ من حافته الأخرى، كما تأخذ مقاييس visx القيمة reverse وتأخذ مقاييس d3 مدىً مكتوبًا بالعكس. على المحور x يكون الصف الأول، أو أصغر قيمة في المجال، عند الحافة اليمنى؛ وعلى المحور y تكون أصغر قيمة في المجال عند الحافة العلوية وتسير الصفوف من الحافة السفلية صعودًا. وكل ما وُضع على المقياس يتبعه: العلامات، وقاعدة الأشرطة واتجاه نموها، وخط أساس المساحة، وخطوط الشبكة، وعلامات PlotAxis، والصف الذي يجده التمرير، ومفاتيح الأسهم (لوحة المفاتيح) والانتقالات إلى مجال جديد. تتدلى الأشرطة من الحافة العلوية على مقياس y معكوس من النوع linear، وتنمو الأشرطة الأفقية نحو اليسار من الحافة اليمنى على مقياس x معكوس من النوع linear:

<Plot
  data={depths}
  y="depth"
  xScale={{ type: "band", paddingInner: 0.2 }}
  yScale={{ type: "linear", domain: [0, 40], reverse: true }}
  class="h-32"
>
  <Bar class="fill-chart-2" cornerRadii={{ bottomLeft: 3, bottomRight: 3 }} />
</Plot>

مع ظهور plot، تُرسم الخطوط والمساحات تدريجيًا من الحافة التي يبدأ منها مقياس x، أي اليمنى عندما يكون معكوسًا.

#المحاور المزدوجة

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" (الافتراضي، yScale الخاص بـ plot) أو "y2" (y2Scale الخاص به). ويسمّيه PlotAxis على اليمين مع تسميات scale="y2":

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

لكل مقياس مجاله الخاص: domain الخاص بالتطبيق، أو امتداد القيم الموضوعة عليه، ويتحرك كل منهما إلى مجال جديد بواسطة motion.update. ما دام أي من المقياسين لا يسمّي مجالًا، يرتب plot كليهما بعدد الخطوات المستديرة نفسه (1 أو 2 أو 5 أضعاف قوة من عشرة، بخطوة لكل 32 نقطة تقريبًا من الارتفاع): فعندما يحتوي كلاهما على الصفر، يقع الصفر على الخط نفسه في كليهما؛ وإلا فإن المقياس الذي يبدأ من الصفر (أحجام التداول) يكسب خطواته الإضافية إلى الأعلى، والآخر (السعر) على الجانبين. وعلامات المحورين هي هذه الخطوات، ويرسم Grid الذي لا x له ولا y خطوطه عندها، فتخدم شبكة واحدة المحورين. ويوقف alignTicks={false} هذا السلوك؛ وإذا سمّى التطبيق domain تُركت المقياسان كما هما.

يجد التمرير صفًا واحدًا؛ وتقع نقطة كل علامة على مقياسها، وعرض القراءة من شأن التطبيق، الذي يقرأ قيمتي الصف اللتين يبلّغ عنهما onHoverChange. تقف الأشرطة على المقياسين جنبًا إلى جنب في كل نطاق، وstack يكدّس الأشرطة الموجودة على مقياسه فقط. y2Scale خطي: فلا مقياس y ثانيًا لـ plot ذي محور y بنطاقات.

#Line وArea وGrid

العلامةالخاصيةالنوعالافتراضيالوصف
Lineystring | (d, index) => numbery الخاص بالمخططما ترسمه: نقطة لكل صف له قيمة، وتُتخطى الصفوف التي لا قيمة لها.
Line، Areadata، xT[]، string | (d, index) => numberصفوف المخططصفوف سلسلة خاصة بالعلامة (قيم x أخرى)، توضع على مقاييس المخطط وفق x الخاص بها (وفهرس الصف عند غيابه).
Line، Areacurve"natural" | "linear" | "stepAfter""natural"كيفية وصل نقاطها: منحنى ناعم، أو قطع مستقيمة، أو درجات تُبقي كل قيمة حتى الصف التالي. وتتبع الحافة السفلية للمساحة المنحنى نفسه.
Linedot، dotClass، dotStyleboolean، string، StyleProp<ViewStyle>falseنقطة دائرية على كل نقطة بيانات: عرض (view) متمركز عليها، ويحدد التطبيق حجمها وخلفيتها وحدودها.
Line، AreahoverDotClass، hoverDotStylestring، StyleProp<ViewStyle>dotClass وdotStyle الخاصان بالمخططنقطة التمرير الخاصة بها، على نقطتها في الصف الواقع تحت المؤشر: نقطة كل سلسلة بلونها. وتحل كل منهما محل نقطة المخطط لهذه العلامة (لون العلامة فوق حجم المخطط وحلقته)؛ وhidden لا يُظهر شيئًا.
AreabackgroundClass، backgroundStylestring، StyleProp<ViewStyle>بلاخلفيتها: صندوق حول شكلها (من أول نقطة إلى آخرها، ومن أعلى نقطة إلى خط الأساس، أو إلى أدنى نقطة في حافتها السفلية في حالة النطاق) وتظهر خلفيته (bg-*، bg-linear-to-b from-… to-…، backgroundImage) داخل الشكل فقط، تحت fill. وعند وجود خلفية، تكون قيمة fill هي none حيث لا توجد قيمة محددة.
Areay1string | (d, index) => numbery الخاص بالمخططما يشكّل حافتها العليا.
Areay0number | string | (d, index) => numberالحافة الدنيا لمقياس yحافتها السفلية: الرقم هو خط أساس على مقياس y؛ أما الحقل أو الدالة فيعطيان كل صف قيمته السفلية الخاصة، مما يجعل المساحة نطاقًا (من أدنى قيمة في اليوم إلى أعلاها، أو مدى التوقعات). وبدونها تنغلق المساحة على الحافة التي توجد عندها أصغر قيم مقياس y: الحافة السفلية، أو العليا في المقياس المعكوس (مساحة معلّقة من الأعلى). ويُتخطى الصف الذي لا قيمة له عند أي من الحافتين.
Line، Area، Gridscale"y" | "y2""y"مقياس y الذي تسير قيمها عليه: yScale الخاص بالمخطط أو مقياسه الثاني y2Scale (المحوران المزدوجان).
Gridxnumber[]بلامواضع خطوطها الرأسية، كقيم على مقياس x (فهارس الصفوف على مقياس point).
Gridynumber[]بلامواضع خطوطها الأفقية، كقيم على مقياس y. وعند غياب x وy معًا، تتبع الخطوط علامات تدريج مقياسها، حيث يضعها PlotAxis بدون ticks (على طول x للأعمدة الأفقية).
Gridticksnumberافتراضي المحورالعدد التقريبي للخطوط التي ترسمها عند غياب x وy، مثل ticks الخاص بـ PlotAxis.
Line، Areacolorstring | (d, index) => stringبلافئة صفوفها، ولونها من colorScale الخاص بالمخطط: تحدد فئة صفها الأول stroke الخاص بالخط (وfill الخاص بالمساحة) حيث لا يحدده شيء آخر (الألوان حسب الفئة).

تملأ كل علامة المخطط وترسم بـخصائص الطلاء في SVG الخاصة بها، فوق خصائص المخطط، كما ترثها CSS:

العلامةترسم بـحيث لا شيء محدد
Linestroke، strokeWidth، strokeDasharraycurrentColor، بعرض 1، متصل
Areafill (فوق جزء background منها)؛ وحافتها العليا stroke، strokeWidth، strokeDasharrayfill currentColor (none بجانب الخلفية)؛ بلا حد
Gridstroke، strokeWidth، strokeDasharraycurrentColor، بعرض 1، متصل؛ ويُرسم كل خط على بكسلات جهاز كاملة، فيبقى الرفيع منها حادًا

القيم التي يرسمها Line أو Area، وكذلك y0 الخاص بالمساحة (خط أساسها، أو قيمة كل صف السفلية)، تشكّل نطاق مقياس y عندما لا يكون للمخطط نطاق؛ أما قيم Grid فلا. ولا تُعاد رسم العلامة إلا عندما يتغير ما ترسمه (صفوفها، قيمها، منحناها، حجم المخطط أو مقاييسه، طلاؤها)؛ والمؤشر يحرّك أجزاء التمرير ولا يعيد رسم العلامة أبدًا.

النطاق (band) هو Area يقرأ y0 الخاص به الصفوف: تمر حافته العليا عبر y1، وتعود حافته السفلية عبر y0، كلتاهما مع curve الخاص بالمساحة، ويرسم appear الاثنتين تدريجيًا من الحافة البادئة. وتقع نقطة التمرير فيه على الحافة العليا؛ ويشير Line فوقه إلى القيمة الوسطى:

<Plot data={days} y="close">
  <Area y1="high" y0="low" class="fill-chart-1/15" hoverDotClass="hidden" />
  <Line class="stroke-chart-1 stroke-2" />
</Plot>

fill هو نفسه في CSS، كما في المتصفح: لون. أما التدرج اللوني تحت الخط فهو خلفية المساحة، كما تعرض الصفحة تدرجًا داخل شكل (background-image الخاص بالصندوق ضمن clip-path): backgroundClass="bg-linear-to-b from-chart-1/20 to-transparent" يخفت من أعلى نقطة في المساحة إلى خط الأساس، باستخدام رموز السمة وألوان المظهر مثل خلفية أي عرض.

#Bar

يرسم Bar عمودًا لكل صف في نطاق الصف ضمن مقياس band: رأسيًا على مقياس x من نوع band، وأفقيًا على مقياس y من نوع band (ويكون مقياس x حينئذ linear). يمتد كل عمود من base إلى قيمة الصف على المقياس الآخر، فتنمو القيمة السالبة في الاتجاه المعاكس، ولا عمود للصف الذي لا قيمة له. وعلى مقياس قيم معكوس تنمو الأعمدة نزولًا من الحافة العليا أو نحو اليسار من الحافة اليمنى، ويضع مقياس band المعكوس نطاق الصف الأول عند اليمين أو في الأسفل (المقاييس المعكوسة).

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

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

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

الأعمدة خارج التكديس تقف جنبًا إلى جنب في كل نطاق، بترتيب العلامات؛ أما الأعمدة التي لها stack نفسه فتتراكم، ويبدأ عمود كل صف من حيث ينتهي العمود الذي قبله في التكديس:

<Bar value="online" stack="sales" class="fill-chart-1" />
<Bar value="store" stack="sales" class="fill-chart-2" cornerRadii={{ topLeft: 4, topRight: 4 }} label={(d) => `${d.online + d.store}`} />
الخاصيةالنوعالافتراضيالوصف
valuestring | (d, index) => numbery الخاص بالمخططما يعرضه كل عمود.
basenumber | string | (d, index) => number0القيمة التي تنمو منها أعمدتها على مقياس القيم: قيمة واحدة لكل الصفوف، أو حقل أو دالة تعطي كل صف قيمته، فيمتد كل عمود من قاعدة صفه إلى قيمته (والصف الذي لا قاعدة له لا عمود له). وتُحتسب في النطاق. ويبدأ التكديس من قاعدة أول عمود فيه، صفًا بعد صف.
stackstringبلاالتكديس الذي تنضم إليه: الأعمدة التي لها stack نفسه تتراكم بالترتيب (والصف الذي لا قيمة له لا يضيف شيئًا).
scale"y" | "y2""y"مقياس y الذي تسير عليه قيم أعمدتها الرأسية (المحوران المزدوجان)؛ وتقف الأعمدة على كليهما جنبًا إلى جنب، ولا يكدّس التكديس إلا الأعمدة الواقعة على مقياسه.
cornerRadiinumber | { topLeft?, topRight?, bottomRight?, bottomLeft? }0نصف قطر كل زاوية من أعمدتها؛ و{ topLeft: 4, topRight: 4 } يدوّر رؤوس الأعمدة الرأسية. ونصف القطر الأكبر من نصف العمود يُقيَّد ليناسبه.
staggernumber0مع appear الخاص بالمخطط، تبدأ الأعمدة واحدًا تلو الآخر، صفًا بعد صف، خلال هذه الحصة منه (من 0 إلى 0.95)، ويتمدد كل منها في الباقي؛ والقيمة 0 تنميها معًا.
inactiveOpacitynumberبلاشفافية أعمدتها بعيدًا عن الصف الواقع تحت المؤشر ما دام المؤشر فوق المخطط، ليبرز ذلك الصف: تظهر وتختفي مع التمرير وتنتقل من عمود إلى عمود معه.
labelstring | (d, index) => stringبلاالنص عند طرف قيمة كل عمود: حقل (نصه أو رقمه) أو دالة تعيد النص الذي ينسقه التطبيق.
labelClass، labelStylestring، StyleProp<TextStyle>بلاالجزء label: نص متمركز على العمود، فوق طرف العمود الرأسي (وتحت العمود الذي ينمو نزولًا)، وبعد العمود الأفقي (وقبل العمود الذي ينمو عائدًا). والحشو (padding) هو بعده عن العمود.
backgroundClass، backgroundStylestring، StyleProp<ViewStyle>بلاخلفيتها: صندوق كل عمود، وتظهر خلفيته (bg-linear-to-t from-… to-…، من أسفل العمود إلى أعلاه) داخل العمود فقط، تحت fill. وعند وجود خلفية، تكون قيمة fill هي none حيث لا توجد قيمة محددة.
colorstring | (d, index) => stringبلافئة كل عمود، ولونه من colorScale الخاص بالمخطط: fill الخاص بالعمود حيث لا يحدده شيء آخر (الألوان حسب الفئة).

يوزّع مقياس band نطاقًا لكل صف من صفوف المخطط بالترتيب، أو نطاقًا لكل فئة من domain الخاص به، كما يفعل scaleBand في d3:

الخيارالافتراضيالوصف
domainبلافئات نطاقاته بالترتيب (scaleBand().domain() في d3): يقع Cell في نطاق فئة صفه، ويكتب PlotAxis فئة كل نطاق؛ والفئة المكتوبة مرتين تحتفظ بمكانها الأول. وبدونه تكون النطاقات هي الصفوف، أو، على الخلايا، الفئات التي تسميها الخلايا على طول المحور بالترتيب الذي تأتي به الصفوف.
paddingInner0الفجوة بين النطاقات المتجاورة، كحصة من خطوة كل نطاق (من 0 إلى 1).
paddingOuter0الفجوة قبل النطاق الأول وبعد الأخير، كحصة من الخطوة. ونصف paddingInner يضع منتصفات النطاقات على خطوات متساوية عبر المخطط.
maxBandWidthبلاأقصى عرض للنطاق، بالنقاط: فعدد قليل من النطاقات على مخطط عريض يبقى ضيقًا، كل منها في منتصف مكانه.
bandCountالصفوفالنطاقات التي يُرتَّب المحور لها عندما تزيد على الصفوف: تأخذ الصفوف النطاقات الأولى وتحتفظ بعرضها ومكانها كما تأتي الصفوف.
groupPadding0الفجوة بين الأعمدة الواقفة جنبًا إلى جنب في النطاق، كحصة من مكان كل عمود.
reversefalseتمتد النطاقات من الحافة الأخرى: نطاق الصف الأول عند يمين محور x، وعند أسفل محور y؛ وتحتفظ الأعمدة الواقفة جنبًا إلى جنب في النطاق بترتيبها.

يُرسم العمود بـ fill (fill-*؛ موروثة من المخطط كما ترثها CSS)، وcurrentColor حيث لا يوجد شيء محدد (لون فئته مع color)، ولا يُظهر نقطة تمرير. وعلى مقياس band، يجد التمرير النطاق الواقع تحت المؤشر، ويقع خط التقاطع الخاص بالمخطط في ذلك النطاق، بعرضه كله: crossLineClass="w-full h-full bg-foreground/5" يلوّن النطاق، وw-px يُبقي خطًا في منتصفه. وتشكّل قيم الأعمدة وقاعدتها نطاق مقياس القيم عندما لا يكون للمخطط نطاق. ومع appear الخاص بالمخطط، ينمو كل عمود من قاعدة تكديسه؛ وتجري الحركة والتلاشي والانزلاق على animator الخاص بالمنصة، ولا يعيد المؤشر رسم أي عمود.

#Ranges

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 حقلًا أو دالة، يمتد كل عمود بين قيمتين من صفه: خطوة الشلال (waterfall) من المجموع الجاري قبلها إلى المجموع بعدها، وجسم الشمعة من الافتتاح إلى الإغلاق، والمهمة من بدايتها إلى نهايتها. وينمو العمود من قاعدته الخاصة عند ظهوره، وتقع تسميته عند طرف قيمته، فوق العمود الصاعد وتحت الهابط.

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) أعمدة أفقية متمركزة: base={(d) => -d.users / 2} وvalue={(d) => d.users / 2} على مقياس y من نوع band.

#Rule

يرسم Rule خطًا مستقيمًا لكل صف على طول محور القيم، من base إلى قيمة الصف: في منتصف نطاق الصف على مقياس band (عبر المخطط على مقياس y من نوع band)، وفي موضع الصف على طول محور x على مقياس من نوع point أو linear. وفتيل الشمعة، وشعرة مخطط الصندوق (box plot)، وشريط الخطأ، وساق المصاصة (lollipop) كلها Rule؛ ومخطط الشموع (candlestick) هو 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) => numbery الخاص بالمخططأين ينتهي كل خط. والصف الذي لا قيمة له لا خط له.
basenumber | string | (d, index) => number0أين يبدأ كل خط: قيمة واحدة لكل الصفوف (ساق المصاصة من خط الصفر)، أو حقل أو دالة تعطي كل صف قيمته (أدنى سعر في الشمعة). وتُحتسب في النطاق.
scale"y" | "y2""y"مقياس y الذي تسير عليه قيمها (المحوران المزدوجان).
staggernumber0مع appear الخاص بالمخطط، تبدأ الخطوط واحدًا تلو الآخر، صفًا بعد صف، خلال هذه الحصة منه (من 0 إلى 0.95)، ويمتد كل منها من بدايته في الباقي.
inactiveOpacitynumberبلاشفافية خطوطها بعيدًا عن الصف الواقع تحت المؤشر ما دام المؤشر فوق المخطط، مثل Bar.
colorstring | (d, index) => stringبلافئة كل خط، ولونه من colorScale الخاص بالمخطط: stroke الخاص بالخط حيث لا يحدده شيء آخر.

يُرسم حد Rule بـ stroke (stroke-*، strokeWidth، strokeDasharray؛ موروثة من المخطط كما ترثها CSS)، وcurrentColor حيث لا يوجد شيء محدد، ولا يُظهر نقطة تمرير. وتشكّل قيمه وقاعدته نطاق مقياس القيم عندما لا يكون للمخطط نطاق؛ وينمو من بدايته مع appear الخاص بالمخطط، ويتحرك مع نطاقات المقاييس، على 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 على مقياس x linear وتقع value على محور y ينتج مخطط انتشار (scatter plot). وعلى مقياس x من نوع band تقع كل نقطة في منتصف نطاق صفها. وعبر مقياس y من نوع band (ومقياس x linear) تمتد قيمتها على طول x ويمتد نطاق صفها نزولًا عبر الرسم، فينتج مخطط نقطي (dot plot). نصف قطر النقطة r إما رقم واحد لكل النقاط، أو قيمة لكل صف rScale تحوّلها إلى نصف قطر، فينتج مخطط فقاعي (bubble chart). الصف الذي لا قيمة له لا نقطة له.

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

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

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

عندما يكون r حقلًا أو دالة، تحدد قيمة كل صف حجم نقطته. يحوّل rScale={{ type: "sqrt", range: [min, max] }} الجذور التربيعية للقيم إلى أنصاف أقطار بالنقاط (points)، كما يفعل scaleSqrt في d3، لذلك تتناسب مساحة الفقاعة مع قيمتها عندما يبدأ المدى من 0، أما المدى الذي يبدأ من قيمة أكبر من 0 فيُبقي أصغر الفقاعات ظاهرة. يمتد النطاق (domain) من 0 إلى أكبر قيمة ما لم يحدد المقياس نطاقًا. تُرسم النقاط الأكبر أولًا لتبقى الأصغر ظاهرة فوقها:

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

تشكّل قيم النقطة نطاق مقياس القيمة عندما لا يكون للرسم نطاق، من دون السعي إلى بلوغ 0، فتلائم محاور مخطط الانتشار نقاطه. أعطِ الرسم domain بمساحة حول القيم عندما تكون النقاط كبيرة، لأن الفقاعة الواقعة عند حافة النطاق تُرسم متجاوزة حافة الرسم.

تصنع علامتان في النطاقات نفسها المخططات التي تقارن بين قيمتين من الصف: مخطط lollipop هو Rule من 0 تحت Dot عند قيمته، ومخطط dumbbell هو Rule من قيمة إلى أخرى مع Dot عند كل طرف، عبر مقياس y من نوع band:

<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>
الخاصيةالنوعالافتراضيالوصف
valuestring | (d, index) => numbery الخاص بالرسمموضع كل نقطة على محور القيمة: على محور y، أو على طول x عبر مقياس y من نوع band. الصف الذي لا قيمة له لا نقطة له.
rnumber | string | (d, index) => number3نصف قطر النقاط بالنقاط (points)، أو حقل أو دالة تعطي كل صف قيمة يحوّلها rScale إلى نصف قطر (تكون القيمة هي نصف القطر نفسه من دونه). الصف الذي قيمته مفقودة لا نقطة له.
rScale{ type: "sqrt"; domain?: [number, number]; range: [number, number] }لا شيءكيف تتحول قيم r إلى أنصاف أقطار: جذورها التربيعية على domain (من 0 إلى أكبر قيمة إن لم يوجد نطاق) إلى range أنصاف الأقطار، فيجعل المدى الذي يبدأ من 0 مساحة كل نقطة متناسبة مع قيمتها. القيم خارج النطاق تأخذ طرفي المدى.
staggernumber0مع appear الخاص بالرسم، تبدأ النقاط بالنمو واحدة تلو الأخرى، صفًا بعد صف، على امتداد هذه الحصة منه (من 0 إلى 0.95)؛ والقيمة 0 تنميها معًا.
inactiveOpacitynumberلا شيءشفافية بقية النقاط عندما تكون إحداها تحت المؤشر أو المفاتيح: تتلاشى وتعود مع التمرير (hover).
colorstring | (d, index) => stringلا شيءفئة كل نقطة، ولونها من colorScale الخاص بالرسم: fill النقطة حيث لا شيء يحدده (الألوان حسب الفئة).
hitRadiusnumber8المسافة التي يظل المؤشر يجد النقطة ضمنها بعد حافتها: من بين النقاط ضمن هذه المسافة، يجد المؤشر تلك التي يكون مركزها الأقرب إليه (خلايا Voronoi)، فتُوجد الفقاعة الصغيرة فوق فقاعة كبيرة حول مركزها هي. يمكن إيجاد صف المخطط النقطي من أي موضع على امتداده بنصف قطر بحجم الصف.
scale"y" | "y2""y"مقياس y الذي تمتد عليه قيمها (المحاور المزدوجة)؛ أما النقاط عبر مقياس y من نوع band فتضع قيمها على x.
hoverDotClass، hoverDotStylestring، StyleProp<ViewStyle>dotClass، dotStyle الخاصان بالرسمالجزء hoverDot: عرض بعرض النقطة التي تحت المؤشر وارتفاعها، متمركز عليها. يحيط rounded-full outline-2 outline-offset-1 بها بحلقة؛ ولا يُظهر hidden شيئًا لهذه العلامة.

تمتلئ النقطة بـ fill (fill-*؛ يُورَّث من الرسم كما يورّث CSS)، أو بـ currentColor حيث لا شيء مضبوط (لون فئتها مع color)، وتُحدَّد بـ stroke حيث يكون مضبوطًا (stroke-*، strokeWidth): فالحلقة بلون البطاقة تُبقي النقاط المتداخلة منفصلة. فوق رسم فيه نقاط، يبلّغ الرسم عن صف النقطة التي يكون مركزها الأقرب إلى المؤشر (null بعيدًا عن كل النقاط)، وتتنقل المفاتيح بين الصفوف بترتيبها (رتّب الصفوف حسب x فتمر المفاتيح على النقاط من اليسار إلى اليمين)، ويحدد الجزء hoverDot النقطة. مع appear الخاص بالرسم، تنمو النقاط من العدم؛ ومع motion.update، تنتقل إلى قيمها وأحجامها الجديدة، وتنمو نقطة الصف الجديد، وتنكمش نقطة الصف المحذوف حتى تختفي؛ كل ذلك على animator الخاص بالمنصة، من دون إعادة رسم أي علامة.

#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 مستطيلًا لكل صف في نطاقين في آن واحد: نطاق فئة x على مقياس x band ونطاق فئة y على مقياس y band، فتشكّل الصفوف شبكة، أي خريطة حرارية (heatmap). مصفوفة الارتباط، وتقويم العوائد الشهرية، وأسبوع من الصفقات في كل ساعة، كلها خلايا. تملأ كل خلية نطاقيها، لذلك تكون paddingInner وpaddingOuter الخاصان بالمقياسين هما الفجوات بين الخلايا وحولها. لونها هو value على colorScale الخاص بالرسم، وهو تدرج ألوان (الألوان على تدرج)، أو لون فئة color عبر تدرج ترتيبي (ordinal). تقع الخلايا على مقياسي 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>

يسمّي domain الخاص بمقياس band فئات نطاقاته بالترتيب، كما يفعل scaleBand().domain() في d3: أشهر التقويم من يناير إلى ديسمبر، لكل منها نطاقه سواء وصلت صفوفها أم لم تصل، لذلك يكون مارس 2026 أعلاه مكانًا فارغًا في الشبكة. وبلا نطاق، تكون النطاقات هي الفئات التي تسمّيها الخلايا على المحور، بالترتيب الذي تأتي به الصفوف (2025 فوق 2026). يكتب 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>
الخاصيةالنوعالافتراضيالوصف
xstring | (d, index) => stringلا شيءعمود كل صف: حقل (نصه أو رقمه) أو دالة تعيد الفئة. تذهب خلية الصف في نطاق تلك الفئة من مقياس x؛ والصف الذي لا فئة له لا خلية له.
ystring | (d, index) => stringلا شيءسطر كل صف، بوصفه x على مقياس y.
valuestring | (d, index) => numberلا شيءرقم كل خلية، الذي يجعله sequential أو diverging colorScale لونها. الصف الذي لا رقم له يأخذ لون unknown الخاص بالمقياس، ومن دونه لا خلية له.
colorstring | (d, index) => stringلا شيءفئة كل خلية، ولونها من ordinal colorScale (الألوان حسب الفئة).
cornerRadiinumber | { topLeft?, topRight?, bottomRight?, bottomLeft? }0نصف قطر كل زاوية من زوايا خلاياها.
staggernumber0مع appear الخاص بالرسم، تبدأ الخلايا بالنمو واحدة تلو الأخرى، صفًا بعد صف، على امتداد هذه الحصة منه (من 0 إلى 0.95)، كل منها من منتصفها في ما تبقى؛ والقيمة 0 تنميها معًا.
inactiveOpacitynumberلا شيءشفافية بقية الخلايا وتسمياتها عندما تكون إحداها تحت المؤشر أو المفاتيح: تتلاشى وتعود مع التمرير (hover).
labelstring | (d, index) => stringلا شيءالجزء label: نص في منتصف كل خلية، وهو حقل (نصه أو رقمه) أو دالة تعيد النص الذي ينسّقه التطبيق. تظهر كل تسمية حيث يتسع حجمها بعد التخطيط داخل خليتها، فيُظهر الرسم الضيق الألوان وحدها.
labelClass، labelStylestring، StyleProp<TextStyle>لا شيءخط التسميات وحجمها ولونها. التسمية على خلية لون colorScale فيها داكن تحمل السمة data-dark (حيث يُقرأ النص الفاتح بشكل أفضل)، لذلك يكتبها data-dark:text-white بلون فاتح هناك.
hoverCellClass، hoverCellStylestring، StyleProp<ViewStyle>لا شيءالجزء hoverCell: عرض بحجم الخلية التي تحت المؤشر أو المفاتيح، فوقها؛ ويحيط rounded-sm outline-2 outline-foreground بها بحلقة. يتحرك مع المؤشر في الإطار نفسه وينزلق على نابض pointer عندما تحركه المفاتيح.

تمتلئ الخلية بلونها من colorScale الخاص بالرسم حيث لا يضبط التطبيق fill (تأتي أصناف fill-* وأنماطها أولًا، وتُورَّث من الرسم كما يورّثها CSS)، أو بـ currentColor حيث لا شيء يحدده، وتُحدَّد بـ stroke حيث يكون مضبوطًا. فوق رسم فيه خلايا، يبلّغ الرسم عن صف الخلية التي تحت المؤشر، وعبر الفجوة بين خليتين عن الأقرب منهما؛ وتتنقل مفاتيح الأسهم عبر الشبكة كما تظهر على الشاشة (لوحة المفاتيح)، وتمر قارئات الشاشة على الصفوف بترتيبها. مع appear الخاص بالرسم، تنمو الخلايا من منتصفها؛ ومع motion.update، ينزلق لون الخلية إلى لونها الجديد، وتنمو خلية الصف الجديد، وتنكمش خلية الصف المحذوف حتى تختفي، وتنتقل الخلايا إلى نطاقاتها الجديدة؛ كل ذلك على animator الخاص بالمنصة، من دون إعادة رسم أي علامة.

#المخطط الدائري والقوس

يقسّم Pie الدورة إلى شريحة لكل صف، لكل منها حصتها من قيم الصفوف، باتجاه عقارب الساعة بدءًا من الساعة 12 وبترتيب الصفوف، حول مركز الرسم البياني. ويجعله innerRadius مخططًا حلقيًا. كل شريحة canvas مستقل بذاتها، وهي الجزء slice، فترسم كل شريحة بفئاتها (classes) الخاصة: sliceClass هي الفئات نفسها على كل شريحة، أو دالة لصف الشريحة تعطي كل شريحة فئاتها. فوق المخطط الدائري، يُبلغ الرسم البياني عن صف الشريحة الواقعة تحت المؤشر (لا شيء في الفتحة أو خارج الحلقة)، ويتبع تلميحه المؤشر، وتكبر تلك الشريحة بمقدار hoverLift بينما تخفت الشرائح الأخرى بمقدار hoverDim، على animator الخاص بالمنصة مع motion الخاص بالرسم البياني. ويرسم Arc قطاعًا واحدًا بين زاويتين: مسار مقياس وقيمته، أو حلقة خلف مخطط دائري.

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) => numbery الخاص بالرسم البيانيقيمة كل صف. الصف الذي قيمته صفر أو أقل، أو الذي لا رقم له، لا شريحة له.
Pie، ArcstartAngle، endAngle.start_angle(..)، .end_angle(..)number (راديان)0، 2 * Math.PIموضع بداية الشريحة الأولى ونهاية الأخيرة (طرفا القوس): بالراديان من الساعة 12، باتجاه عقارب الساعة. إذا كان endAngle أقل من startAngle فإن شرائح المخطط الدائري تُوزَّع في الاتجاه المعاكس.
PiepadAngle.pad_angle(..)number (راديان)0الزاوية بين الشرائح المتجاورة عند الحافة. تحتفظ كل فجوة بهذا العرض (نحو padAngle * outerRadius نقطة) عند كل نصف قطر، وحافتاها متوازيتان، فعرضها عند المركز أو الفتحة كعرضها عند الحافة؛ والشريحة الأضيق من أن تبلغ المركز تنتهي حيث تلتقي حافتاها. المخطط الدائري ذو الشريحة الواحدة لا فجوة فيه.
Pie، ArcinnerRadius، outerRadius.inner_radius(..)، .outer_radius(..)number | `${number}%` 0، "100%"نصفا قطر الفتحة والحافة: بالنقاط، أو نسبة مئوية من أكبر دائرة يتسع لها الرسم البياني (نصف ضلعه الأصغر).
Pie، ArccornerRadius.corner_radius(..)number0نصف قطر زوايا كل شريحة (أو القوس)، بالنقاط، كما في arc.cornerRadius في d3: كل زاوية دائرة تمس الحافة والحافة الخارجية أو الفتحة، بما في ذلك الحافتان المتوازيتان للفجوات. وحده الأقصى نصف سماكة الحلقة، وهو ما يعطي القوس أطرافًا مستديرة، وفي الشريحة الأضيق من أن تتسع لزاويتين كاملتين تلتقي الزاويتان في منتصفها. الحلقة الكاملة تحتفظ بحوافها المستديرة فقط.
PiesliceClass.slice_class(..)string | (d: T, index: number) => stringلا شيءفئات كل شريحة (fill-*، stroke-*): نفسها على كل شريحة، أو دالة لصف الشريحة وفهرسها، تُستدعى مرة لكل صف عند تغيّر الصفوف أو الدالة.
PiesliceStyle.slice_style(..)StyleProp<ViewStyle>لا شيءنمط كل شريحة، فوق فئاتها (fill، stroke، strokeWidth).
PiehoverLift.hover_lift(..)number0مقدار ما تكبره الشريحة الواقعة تحت المؤشر عن الأخريات، بالنقاط: على زنبرك pointer الخاص بالرسم البياني، يظهر ويختفي تدريجيًا مع التمرير.
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: نص خارج الحلقة عند نهاية خط الإرشاد الخاص به، متمركز عليه، بعده في النصف الأيمن من المخطط الدائري وقبله في النصف الأيسر. حشوته (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، نفسها على كل خط أو دالة لصف الشريحة؛ ويظهر متى نُسِّق.

تُملأ الشريحة والقوس بـfill (currentColor حيث لا يُحدَّد شيء، ولون فئة الشريحة مع color) وتُرسم حوافها بـstroke (لا شيء حيث لا يُحدَّد شيء)، وقيمهما الخاصة تعلو قيم المخطط الدائري والرسم البياني كما ترثها CSS: فحدّ بلون السطح (stroke-card stroke-2) يقطع فجوات بين الشرائح أيضًا. ولا تشارك قيمها في مقاييس الرسم البياني. وتُرسم شرائح المخطط الدائري من جديد فقط عند تغيّر الصفوف أو قيمها أو الزوايا أو أنصاف الأقطار أو حجم الرسم البياني؛ أما المؤشر فيحرّك الرفع والخفوت على animator.

عادةً تضع الرسوم البيانية ذات المخطط الدائري التلميح بجانب المؤشر (tooltipPlacement="pointer")، لأن صفوف المخطط الدائري لا موضع لها على محور: يتبع الخط المتقاطع والتلميح المؤشر فوقه، ولا تظهر نقاط تمرير. ومركز المخطط الحلقي عنصر خاص بالتطبيق موضوع فوق الرسم البياني (قيمة الشريحة المُمرَّر فوقها، أو المجموع)، وكذلك مفتاحه، بحيث يحمل كل رمز لون شريحته.

تسمّي تسميات المخطط الدائري شرائحه خارج الحلقة: يقع كل نص على بعد labelGap من الحافة باتجاه منتصف شريحته (مركز ثقل الشريحة عند ذلك نصف القطر)، ثم عرضًا إلى نصف القطر ذاك في جهة الشريحة من المخطط، ويصله بالحافة خط إرشاد منسَّق. تنساب التسميات مع شرائحها عند ظهور الرسم البياني وتتلاشى ظاهرةً عند نهايته، وتتحرك معها عند 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 الخاص بالرسم البياني الفئاتِ ألوانها، كما يفعل scaleOrdinal في d3، ويسمّي color الخاص بالعلامة فئة كل صف: حقل (نصه أو رقمه) أو دالة للصف. تأخذ فئات domain ألوان range بالترتيب، ويبدأ المدى من لونه الأول من جديد حين يكون النطاق أطول. والفئة خارج النطاق تأخذ unknown؛ وإن لم يوجد، تنضم إلى النطاق أول مرة تصادفها فيها علامة (العلامات بالترتيب، وصفوف كل منها بالترتيب) وتأخذ اللون التالي، فإذا لم يوجد domain أصلًا أخذت الفئات الألوان بترتيب مجيء الصفوف بها. والألوان من اختيار التطبيق: ألوان CSS، ومنها رموز (tokens) السمة.

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

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

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

تأخذ الشريحة والعمود والنقطة لون صفها؛ أما Line وArea فكل منهما سلسلة واحدة وتأخذان لون أول صف فيها، فالخط ذو الصفوف الخاصة (data) يكون بلون صفوفه، ويُبقي مقياس واحد لون الفئة نفسه عبر المخطط الدائري والأعمدة والخطوط. اللون هو fill الخاص بالعلامة (stroke للخط) حيث لا يحدد شيء آخر لونًا، في مكان currentColor: تأتي فئات التطبيق وأنماطه أولًا، ثم فئات الجزء (sliceClass، sliceStyle)، ثم فئات العلامة وما ترثه من الرسم البياني، ولذلك يحدد sliceClass={(d) => (d.name === "Other" ? "fill-muted" : "")} شريحة بمفردها بينما يلوّن المقياس الأخريات. إلى جانب جزء background، يبقى fill للعمود وللمساحة none.

OrdinalScaleالنوعالقيمة الافتراضيةالوصف
type"ordinal"(مطلوب)نوع المقياس.
domainstring[]لا شيءالفئات بالترتيب. الفئة المكتوبة مرتين تحتفظ بمكانها الأول.
rangestring[](مطلوب)الألوان بالترتيب (ألوان CSS)؛ تأخذ الفئة i اللون i % range.length.
unknownstringلا شيءلون كل فئة خارج النطاق؛ وإن لم يوجد، تأخذ كل فئة اللون التالي.

#الألوان على طول تدرّج

يعطي colorScale من النوع type: "sequential" أو "diverging" الأرقامَ ألوانها على طول تدرّج، كما يفعل scaleSequential وscaleDiverging في d3، من أجل value الخاص بـCell. وrange الخاص به لونان أو أكثر موزعة بالتساوي على طول التدرّج من طرفه الأدنى إلى طرفه الأعلى ومخلوطة في OKLab بينها: فتبدو الدرجات متساوية للعين، ويلتقي لونان عبر مزيجهما الخاص. والقيمة التي تتجاوز domain تأخذ طرف التدرّج، والصف بلا قيمة يأخذ unknown.

يوزّع sequential قيم domain على التدرّج كله، والنطاق هو مدى قيم الخلايا ما لم يسمِّ التطبيق نطاقًا: أعداد، أحجام، نشاط. ويضع diverging قيمة pivot (0 افتراضيًا) عند لون منتصف التدرّج، فالقيم الأدنى منها تمتد على النصف الأول والأعلى منها على النصف الثاني: الخسائر بلون واحد، والأرباح بلون آخر، والشهر المستقر في المنتصف المحايد. وما لم يسمِّ التطبيق نطاقًا، فهو مدى القيم مساويًا على جانبي المحور (symmetric في Observable Plot)، فتكون الخسارة والربح بالحجم نفسه بالقوة نفسها؛ أما مصفوفة الارتباط فتسمّي 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)قيمتا طرفي التدرّج.
pivotnumber0diverging فقط: قيمة لون منتصف التدرّج.
rangestring[](مطلوب)الألوان على طول التدرّج، من الطرف الأدنى إلى الأعلى (ألوان CSS)، مخلوطة في OKLab بينها.
unknownstringلا شيءلون الصف بلا قيمة؛ وإن لم يوجد، فلا خلية لذلك الصف.

#RadialLine and RadialGrid

يرسم RadialLine سلسلة حول مركز المخطط: مضلّع الرادار، أو خطًا حول قرص مدرّج. كل صف له قيمة يصبح نقطة عند زاوية الصف، على بُعد من المركز يحدده مقياس y للمخطط لقيمته، من innerRadius (أصغر قيمة في النطاق) إلى outerRadius (أكبر قيمة فيه). بدون angle، تتوزع الصفوف بالتساوي على الدورة الكاملة بدءًا من الساعة 12، في اتجاه عقارب الساعة، وبالترتيب، فستة صفوف تشكّل سداسيًا؛ ويصل closed النقطة الأخيرة بالأولى. يرسم الخط حدود نقاطه ويملأ مساحته نزولًا إلى innerRadius (المركز افتراضيًا)، فعلامة واحدة تعطي مخطط الرادار الخارجي وتظليله. يرسم RadialGrid شبكة الرادار خلفه: rings عند قيم مقياس y، ومضلّعات تمر عبر زوايا الصفوف أو circular، وspokes تمتد إلى كل صف. فوق الخط الشعاعي، يُبلغ المخطط عن الصف الأقرب زاويةً إلى مؤشر الفأرة ضمن نصف القطر الخارجي للخط، وتقع نقطة التمرير لكل خط على نقطته في ذلك الصف.

import { signal, Text, View } from "nsh";
import { Plot, RadialGrid, RadialLine } from "nsh/base/plot";

type Metric = { name: string; ours: number; theirs: number };

export function Compare(props: { metrics: Metric[] }) {
  const hovered = signal<number | null>(null);
  const shown = () => (hovered() == null ? undefined : props.metrics[hovered()!]);
  return (
    <View class="items-center gap-3 rounded-xl border border-border bg-card p-4">
      <Text class="text-sm tabular-nums text-muted-foreground">
        {shown() ? `${shown()!.name}: ${shown()!.ours} against ${shown()!.theirs}` : "Ours against theirs"}
      </Text>
      <Plot
        data={props.metrics}
        yScale={{ type: "linear", domain: [0, 100] }}
        accessibilityLabel="Ours against theirs on six metrics"
        accessibilityValue={{ text: shown() && `${shown()!.name}: ours ${shown()!.ours}, theirs ${shown()!.theirs}` }}
        appearGeneration={1}
        motion={{ appear: { duration: 700, easing: "easeOut" }, update: { duration: 400, easing: "easeInOut" }, enter: { duration: 120 }, exit: { duration: 160 }, pointer: { stiffness: 380, damping: 34 } }}
        onHoverChange={({ index }) => hovered.set(index)}
        class="size-56"
      >
        <RadialGrid rings={[25, 50, 75, 100]} spokes outerRadius="80%" class="stroke-border" />
        <RadialLine radius="theirs" closed outerRadius="80%" class="fill-chart-2/15 stroke-chart-2" hoverDotClass="size-2 rounded-full border-2 border-card bg-chart-2" />
        <RadialLine radius="ours" closed outerRadius="80%" class="fill-chart-1/25 stroke-chart-1 stroke-2" hoverDotClass="size-2.5 rounded-full border-2 border-card bg-chart-1" />
        {/* Each metric's name past the rim at its row's angle, growing away from the center. */}
        {props.metrics.map((m, i) => {
          const angle = (2 * Math.PI * i) / props.metrics.length;
          const away = (v: number) => (v > 0.01 ? "flex-start" : v < -0.01 ? "flex-end" : "center");
          const align = (v: number) => (v > 0.01 ? "left" : v < -0.01 ? "right" : "center");
          return (
            <View
              class="absolute size-0"
              style={{
                left: `${50 + 44 * Math.sin(angle)}%`,
                top: `${50 - 44 * Math.cos(angle)}%`,
                alignItems: away(Math.sin(angle)),
                justifyContent: away(-Math.cos(angle)),
                pointerEvents: "none",
              }}
            >
              <Text class="w-24 text-xs text-muted-foreground" style={{ textAlign: align(Math.sin(angle)) }}>{m.name}</Text>
            </View>
          );
        })}
      </Plot>
    </View>
  );
}
العلامةالخاصيةRustالنوعالافتراضيالوصف
RadialLineradius.radius(..)keyof T | (d: T, index: number) => numbery المخططما يحدد موضع كل صف بُعدًا عن المركز: قيمة من مقياس 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نقطة على كل نقطة من الخط: عنصر عرض متمركز عليها، وحجمه وخلفيته وحدّه من اختيار التطبيق. تنمو النقاط وتتحرك مع الخط.
RadialLinebackgroundClass، backgroundStyle.background_class(..)، .background_style(..)string، StyleProp<ViewStyle>لا شيءخلفيته: صندوق حول دائرته الخارجية لا تظهر خلفيته (bg-*، أو تدرّج) إلا داخل مساحته، تحت fill، كما في المساحة.
RadialLinehoverDotClass، hoverDotStyle.hover_dot_class(..)، .hover_dot_style(..)string، StyleProp<ViewStyle>dotClass، dotStyle المخططنقطة التمرير الخاصة به، على نقطته من الصف الذي تحت المؤشر، مع المؤشر في الإطار نفسه، وتنزلق إلى الصف التالي بنابض pointer الخاص بالمخطط عند التنقل بالمفاتيح؛ ولا تُظهر hidden أيّ نقطة.
RadialGridrings.rings(..)number[]لا شيءمواضع حلقاته، كقيم من مقياس y: كل حلقة على البُعد الذي يضع الخط الشعاعي عنده تلك القيمة.
RadialGridspokes.spokes(..)booleanfalseخط من innerRadius إلى outerRadius عند زاوية كل صف.
RadialGridcircular.circular(..)booleanfalseحلقاته دوائر (أقواس على جزء من الدورة). افتراضيًا تكون مضلّعات تمر عبر زوايا الصفوف، وتكون دوائر ما دام في المخطط أقل من ثلاثة صفوف.

يرسم الخط الشعاعي حدوده بـ stroke وstrokeWidth وstrokeDasharray (currentColor، بعرض 1 ومتصل حيث لا يُحدَّد شيء) ويملأ مساحته بـ fill (لا شيء حيث لا يُحدَّد شيء)؛ وترسم الشبكة الشعاعية حدودها كما تفعل الشبكة العادية. ويرث طلاؤهما من المخطط. تشكّل قيم الخط، مع 0، نطاق مقياس y حين لا يكون للمخطط نطاق، فينمو رادار القيم الموجبة من مركزه؛ أما حلقات الشبكة فلا تفعل ذلك. أعطِ المخطط y domain لتبقى الحلقات ومقياس الرادار ثابتين بينما تتغير القيم.

التسميات حول الرادار عناصر من التطبيق توضع فوق المخطط، كما يوضع مركز المخطط الحلقي. بدون angle، يقع الصف i من n عند a = 2π·i / n من الساعة 12، فيكون في المخطط المربع صندوق بلا حجم عند left: 50% + r·sin(a)، top: 50% − r·cos(a) (r نسبة مئوية من ضلع المخطط تتجاوز قليلًا نصف حصة نصف القطر الخارجي) علامةً على الموضع خلف الحافة، ويمتد الاسم منه مبتعدًا عن المركز: إلى يمينه في النصف الأيمن، وإلى يساره في النصف الأيسر، وفوقه عند القمة، وتحته عند القاع، كما في الأعلى.

use nsh_base::plot::{plot, radial_grid, radial_line};
use nsh_core::{element::{RowAccessor, Rows}, prelude::*};

fn compare(ours: Vec<f64>, theirs: Vec<f64>) -> impl IntoElement {
    let rows = Rows::from_columns(ours.len(), [("ours".into(), ours), ("theirs".into(), theirs)]);
    plot()
        .data(rows)
        .accessibility_label("Ours against theirs")
        .w(px(224.0))
        .h(px(224.0))
        .child(radial_grid().rings(vec![25.0, 50.0, 75.0, 100.0]).spokes(true))
        .child(radial_line().radius(RowAccessor::key("theirs")).closed(true))
        .child(radial_line().radius(RowAccessor::key("ours")).closed(true))
}

#Sankey

يرسم Sankey التدفقات بين صفوف المخطط: كل صف عقدة، ويسمّي links التدفقات بينها، { source, target, value } with the indices of two rows in the plot’s data. ويرتّبها كما يفعل d3-sankey، ضمن حجم المخطط: العقد في أعمدة (nodeAlign)، وارتفاع كل عقدة بقدر أكبر ما يتدفق إليها أو منها، والمسافة بين عقد العمود الواحد nodePadding، وتُرخى نحو تدفقاتها على مدى iterations تمريرة؛ ولكل رابط شريط من الحافة اليمنى لمصدره إلى الحافة اليسرى لهدفه، عرضه عند كل طرف بقدر حصته من ضلع تلك العقدة، فيُغطّى جانبا كل عقدة حتى حين يختلف تدفقاها الداخل والخارج. يتوسط كل عمود المخطط، وتُزاح قليلًا سلسلة الأعمدة المتساوية ذات العقدة الواحدة لتنحني الأشرطة بينها. ويحدد valueScale="sqrt" الأحجام بالجذر التربيعي للقيم، فلا يطغى تدفق واحد مهيمن على التدفقات الصغيرة.

كل عقدة وكل شريط لوحة رسم مستقلة، وهما الجزءان node وlink، فيُرسم كل منهما بفئاته الخاصة: nodeClass هي الفئات نفسها على كل عقدة أو دالة في صف العقدة، وlinkClass هي نفسها على كل شريط أو دالة في الرابط. ويُظهر الجزء linkBackground خلفية داخل كل شريط، وهي صندوق من مصدره إلى هدفه: فتمنح الدالة في الرابط كل شريط تدرجًا من لون مصدره إلى لون هدفه. تُرسم الأشرطة تحت العقد، ويُرسم كلاهما من الحافة الأمامية مع حركة ظهور المخطط.

فوق مخطط sankey، يُبلغ المخطط عن العقدة تحت المؤشر (onHoverChange، صف العقدة؛ ولا شيء فوق الشريط)، وتتبع أداة التلميح المؤشر، وتخفت الروابط غير المتصلة بتلك العقدة بمقدار hoverDim، على animator المنصة مع motion الخاص بالمخطط. وتتنقل المفاتيح بين العقد بترتيب الصفوف، وتظهر أداة التلميح بجانب منتصف كل عقدة. يسمّي nodeLabel كل عقدة ويعطي valueLabel قيمتها، والقيمة فوق الاسم إذا حُدّدا معًا: يسار العقدة في العمود الأول، ويمينها في العمود الأخير، ومتوسطًا فوقها بين ذلك، كما يضعها مخطط gpui-component؛ وتبقى التسمية بجانب العقدة بين أعلى المخطط وأسفله، وتستقر التسمية التي تتجاوز حافة المخطط في المساحة التي يتركها التطبيق حوله (هامشه). حشوة الجزء nodeLabel هي المسافة إلى عقدته، وفئاته تحدد نصه (max-w-20 truncate يقتطع الاسم الطويل)؛ وتظهر التسميات تدريجيًا مع حركة الظهور. وحين يغيّر التطبيق التدفقات، تنتقل كل عقدة وكل شريط من مكانه القديم إلى الجديد مع motion.update الخاص بالمخطط، وتنتقل تسميته معه: فالعقدة الجديدة على التدفقات تنمو من منتصفها، والرابط الجديد من أقل عرض له، وتغادر العناصر المحذوفة فورًا؛ ويستمر التغيير الذي يحدث أثناء الحركة من حيث هي مرسومة. تُميَّز الروابط بمصدرها وهدفها، فإعادة ترتيب links لا تحرّك شيئًا.

import { signal } from "nsh";
import { Plot, Sankey } from "nsh/base/plot";

type Step = { name: string };

const steps: Step[] = [{ name: "Visits" }, { name: "Sign-ups" }, { name: "Bounced" }, { name: "Trials" }, { name: "Paid" }, { name: "Churned" }];
const flows = [
  { source: 0, target: 1, value: 4200 },
  { source: 0, target: 2, value: 5800 },
  { source: 1, target: 3, value: 2600 },
  { source: 3, target: 4, value: 1100 },
  { source: 3, target: 5, value: 1500 },
];
// Each step's color: its node's fill, and its ribbons' gradient ends.
const FILLS = ["fill-chart-1", "fill-chart-2", "fill-muted", "fill-chart-3", "fill-chart-4", "fill-muted"];
const FROM = ["from-chart-1/40", "from-chart-2/40", "from-muted/40", "from-chart-3/40", "from-chart-4/40", "from-muted/40"];
const TO = ["to-chart-1/40", "to-chart-2/40", "to-muted/40", "to-chart-3/40", "to-chart-4/40", "to-muted/40"];

export function Funnel() {
  const hovered = signal<number | null>(null);
  return (
    <Plot
      data={steps}
      accessibilityLabel="Visits to paid: 10,000 visits, 1,100 paid"
      accessibilityValue={{ text: hovered() == null ? undefined : steps[hovered()!].name }}
      onHoverChange={({ index }) => hovered.set(index)}
      appearGeneration={1}
      motion={{ appear: { duration: 900, easing: "easeOut" }, update: { duration: 500 }, enter: { duration: 120 }, exit: { duration: 160 } }}
      class="mx-20 h-64"
    >
      <Sankey
        links={flows}
        nodeWidth={8}
        nodePadding={16}
        nodeCornerRadius={2}
        nodeClass={(_, i) => FILLS[i]}
        linkBackgroundClass={(link) => `bg-linear-to-r ${FROM[link.source]} ${TO[link.target]}`}
        hoverDim={0.7}
        nodeLabel={(d: Step) => d.name}
        valueLabel={(_: Step, value: number) => value.toLocaleString("en-US")}
        nodeLabelClass="px-2 py-1 text-xs text-muted-foreground"
        valueLabelClass="font-medium text-foreground"
      />
    </Plot>
  );
}
use nsh_base::plot::{RowClass, SankeyLinkClass, SankeyLinks, plot, sankey};
use nsh_core::{element::Rows, prelude::*};

fn funnel() -> impl IntoElement {
    plot()
        .data(Rows::from_columns(4, []))
        .accessibility_label("Visits to paid")
        .h(px(256.0))
        .child(
            sankey()
                .links(SankeyLinks::new([(0, 1, 4200.0), (1, 2, 2600.0), (2, 3, 1100.0)]))
                .node_width(px(8.0))
                .node_class(RowClass::from_fn(|i| format!("fill-chart-{}", i + 1).into()))
                .link_class(SankeyLinkClass::from("fill-foreground/10")),
        )
}
الخاصيةRustالنوعالافتراضيالوصف
links.links(..)SankeyLink[] ({ source: number; target: number; value: number })مطلوبالتدفقات: من الصف source إلى الصف target (فهرساهما في data الخاص بالمخطط)، وvalue حجمه. القيمة التي ليست رقمًا أو التي هي أقل من صفر لا تحمل شيئًا ولا يُرسم لها شريط.
nodeWidth.node_width(..)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لا شيءفئات كل عقدة (fill-*، stroke-*): هي نفسها على كل عقدة، أو دالة في صف العقدة وفهرسها، تُستدعى مرة لكل صف عند تغيّر الصفوف أو الدالة.
nodeStyle.node_style(..)StyleProp<ViewStyle>لا شيءنمط كل عقدة، فوق فئاتها.
linkClass.link_class(..)string | (link: SankeyLink, index: number) => stringلا شيءفئات كل شريط: هي نفسها على كل شريط، أو دالة في الرابط وفهرسه في links.
linkStyle.link_style(..)StyleProp<ViewStyle>لا شيءنمط كل شريط، فوق فئاته.
linkBackgroundClass.link_background_class(..)string | (link: SankeyLink, index: number) => stringلا شيءفئات خلفية كل شريط (bg-*، تدرّج: bg-linear-to-r from-… to-…)، وتظهر داخل الشريط فوق صندوق من مصدره إلى هدفه، تحت fill. ومع وجود خلفية، تكون قيمة fill للشريط هي none حيث لا يُحدَّد شيء.
linkBackgroundStyle.link_background_style(..)StyleProp<ViewStyle>لا شيءنمط خلفية كل شريط، فوق فئاتها.
hoverDim.hover_dim(..)number (من 0 إلى 1)0مقدار خفوت الروابط غير المتصلة بالعقدة تحت المؤشر، كنسبة من عتمتها، بنابض pointer الخاص بالمخطط ومع خفوت التمرير.
nodeLabel.node_label(..)keyof T | (d: T, index: number) => stringلا شيءالاسم بجانب كل عقدة: حقل، أو دالة في صف العقدة وفهرسها.
valueLabel.value_label(..)(d: T, value: number, index: number) => stringلا شيءالقيمة بجانب كل عقدة، فوق اسمها: دالة في صفها وفي أكبر ما يتدفق إليها أو منها (بقيم الروابط، أيًا كان valueScale).
nodeLabelClass.node_label_class(..)stringلا شيءفئات كل تسمية، وهي الجزء nodeLabel: حشوته هي المسافة إلى عقدته، ونمط نصه يسري على السطرين معًا.
nodeLabelStyle.node_label_style(..)StyleProp<TextStyle>لا شيءنمط كل تسمية، فوق فئاتها.
valueLabelClass.value_label_class(..)stringلا شيءفئات سطر القيمة، وهو الجزء valueLabel، فوق فئات التسمية.
valueLabelStyle.value_label_style(..)StyleProp<TextStyle>لا شيءنمط سطر القيمة، فوق فئاته.

تُملأ العقدة والشريط بـ fill (currentColor حيث لا يُحدَّد شيء) ويُرسم حدّهما بـ stroke (لا شيء حيث لا يُحدَّد شيء)، وتكون قيمهما الخاصة فوق قيم sankey والمخطط كما يرثها CSS. العقدة التي لا يمر بها شيء ارتفاعها نقطة واحدة. لا يُعاد التخطيط إلا عند تغيّر الروابط أو الخيارات أو حجم المخطط، ولا يُعاد أبدًا بسبب المؤشر أو حركة الظهور؛ وتغيّر حجم المخطط لا يحرّك شيئًا. الروابط التي تشكّل دورة، أو التي تسمّي صفًا ليس في المخطط، لا يُرسم منها شيء ويُسجَّل تحذير. ولا تشارك قيمها في مقاييس المخطط.

#Treemap

يرسم Treemap صفوف الرسم على هيئة تسلسل هرمي من المستطيلات المتداخلة: كل صف عقدة، ويكوّن id الخاص بها مع parent الذي يسميه الشجرة (stratify في d3)، وتتناسب مساحة كل ورقة مع value، وكل مجموعة هي صندوق ما تحتها، وقيمتها مجموع قيم أوراقها (أيًّا كان ما يحمله صفها هي). الصف الذي بلا parent، أو الذي لا يسمي أبوه أي صف، هو عقدة من المستوى الأعلى؛ وبلا id يكون معرّف الصف هو فهرسه، فيمكن لـparent أن يسمي صفًا بفهرسه أيضًا. أما الشجرة بالصيغة المتداخلة التي يكتبها gpt-vis ({ name, value, children }) فتحويلها إلى صفوف لا يتطلب من التطبيق سوى بضعة أسطر؛ ولا حد لعدد المستويات.

تُرتَّب البلاطات بطريقة squarified كما يفعل treemapSquarify في d3: عقد كل مستوى بترتيب القيمة، الأكبر أولًا من أعلى اليسار (sort="value")، بنسبة أبعاد قريبة من ratio (النسبة الذهبية)؛ وsort="data" يحافظ على ترتيب الصفوف، فتبقى العقد في أماكنها حين تتغير قيمها. paddingInner هي الفجوة بين الأشقاء، ولا تُوضع على أي حافة خارجية لأبيهم؛ وpaddingOuter هي المساحة داخل المجموعة حول أبنائها؛ وpaddingTop شريط في أعلى كل مجموعة يُكتب فيه groupLabel. يقبل كلٌّ منها رقمًا واحدًا لكل المستويات أو رقمًا لكل مستوى من الأعلى نزولًا ([3, 1]: 3 بين عقد المستوى الأعلى، و1 تحتها). يُوضع شريط المستوى على كل أشقاء أب واحد أو على أيٍّ منهم، ويُحذف حيث يضيق أحدهم عنه، فتُقرأ مجموعات المستوى الواحد بالشكل نفسه.

الورقة التي قيمتها 0 أو أقل من 0 أو ليست رقمًا لا تأخذ مساحة: فلا تُرسم، ولا اسم لها، ولا يعثر عليها المؤشر، وكذلك المجموعة التي كل أوراقها كذلك. مساحات الـ treemap هي حصص من كل، لذلك يُبيّن التطبيق بكلماته ما حُذف منها (ملاحظة تحت الخريطة). أما الأرقام السالبة فللألوان: كتغير السوق خلال اليوم في خريطة السوق.

لون العقدة هو لون صفها من colorScale الخاص بالرسم: colorValue رقم على تدرج لوني (sequential، diverging: الخسائر حمراء، والمكاسب خضراء، والمحايد في الوسط، على مدى القيم إن لم يوجد domain)، وcolor فئة (ordinal: يلوّن "parent" أوراق المجموعة بلون واحد). الأوراق هي الجزء leaf (leafClass، leafStyle) والمجموعات هي الجزء group (groupClass، groupStyle)، ولا تُرسم إلا مع class أو style: مستطيل تحت أبنائه، يظهر في الفجوات وفي حشوته وفي شريطه. كل جزء إما واحد على كل العقد أو دالة لصف العقدة. cornerRadii يدوّر كل مستطيل.

يكتب label اسم كل ورقة، ويكتب valueLabel قيمتها تحته، وهو دالة للصف وقيمته (وقيمة المجموعة هي مجموع أوراقها): في أعلى اليسار (labelPlacement="start") أو في الوسط ("center"). الجزء label صندوق بحجم ورقته: حشوته هي المسافة من حواف الورقة، ونصه ينسّق الأسطر. يُقطع الاسم بنقاط الحذف حيث تضيق الورقة، ولا يظهر إلا حيث يتسع له المكان؛ أما القيمة فتظهر كاملة أو لا تظهر. التسمية التي لون ورقتها داكن تحمل السمة data-dark، فيُبقيها data-dark:text-white مقروءة على الأحمر الغامق.

فوق الـ treemap يُبلغ الرسم عن العقدة تحت المؤشر (onHoverChange، صف العقدة): ورقة، أو مجموعة في شريطها أو حشوتها؛ وعبر فجوة بين الأشقاء يُبلغ عن أحدهم، حتى لا يومض التحويم. أما العقد الأخرى فتخفت إلى inactiveOpacity (تحتفظ الورقة بمجموعاتها، وتحتفظ المجموعة بما تحتها)، ويحدد الجزء hover، وهو view بحجم العقدة، حدودها. تنتقل مفاتيح الأسهم إلى الورقة المجاورة على الشاشة، ويذهب Home وEnd إلى الأولى والأخيرة. مع stagger الخاص بالرسم تنمو عقد المستوى الأعلى من أواسطها، واحدة بعد أخرى؛ وعند تغير الصفوف تنتقل كل عقدة إلى صندوقها ولونها الجديدين مع motion.update، وتُعرف بـid: العقدة الجديدة على الصفوف تنمو من وسطها، والعقدة التي اختفت تنكمش نحو وسطها. ولا تُستخدم مقاييس x وy الخاصة بالرسم.

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فهرس الصفمعرّف عقدة كل صف: ما يسميه parent، وما تُعرف به العقدة عند تغير الصفوف.
parent.parent(..)keyof T | (d: T, index: number) => stringلا شيء: كل صف عقدة من المستوى الأعلىمعرّف أب كل صف؛ يكون فارغًا، أو معرّفًا لا يطابق أي صف، للعقدة من المستوى الأعلى.
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لا شيءclasses كل ورقة (fill-*، stroke-*)، وهي نفسها على كل ورقة أو دالة لصفها.
leafStyle.leaf_style(..)StyleProp<ViewStyle>لا شيءstyle كل ورقة، فوق classes الخاصة بها.
groupClass.group_class(..)string | (d: T, index: number) => stringلا شيءclasses كل مجموعة، وهي مستطيل تحت أبنائها؛ ولا تُرسم المجموعات إلا مع class أو style.
groupStyle.group_style(..)StyleProp<ViewStyle>لا شيءstyle كل مجموعة، فوق classes الخاصة بها.
inactiveOpacity.inactive_opacity(..)number (من 0 إلى 1)لا شيءشفافية العقد البعيدة عن العقدة المحوَّم فوقها، وشفافية تسمياتها، مع تلاشي التحويم.
label.label(..)keyof T | (d: T, index: number) => stringلا شيءالاسم داخل كل ورقة، في سطر واحد، يُقطع بنقاط الحذف.
valueLabel.value_label(..)(d: T, value: number, index: number) => stringلا شيءالقيمة داخل كل ورقة، تحت اسمها، ولا تظهر إلا حيث تتسع لها كاملة.
labelPlacement.label_placement(..)"start" | "center""start"التسميات في أعلى يسار أوراقها، أو في وسطها.
labelClass.label_class(..)stringلا شيءclasses تسمية كل ورقة، وهي صندوق بحجم الورقة: حشوته وتنسيقات نصه؛ وdata-dark: على الأوراق الداكنة.
labelStyle.label_style(..)StyleProp<TextStyle>لا شيءstyle تسمية كل ورقة، فوق classes الخاصة بها.
valueLabelClass.value_label_class(..)stringلا شيءclasses سطر القيمة، فوق classes التسمية.
valueLabelStyle.value_label_style(..)StyleProp<TextStyle>لا شيءstyle سطر القيمة، فوق classes الخاصة به.
groupLabel.group_label(..)keyof T | (d: T, index: number) => stringلا شيءاسم كل مجموعة، في شريطها، يُقطع بنقاط الحذف.
groupLabelClass.group_label_class(..)stringلا شيءclasses تسمية كل مجموعة، وهي صندوق بحجم الشريط.
groupLabelStyle.group_label_style(..)StyleProp<TextStyle>لا شيءstyle تسمية كل مجموعة، فوق classes الخاصة بها.
stagger.stagger(..)number (من 0 إلى 0.95)0كيف تظهر عقد المستوى الأعلى واحدة بعد أخرى: حصة الظهور التي تبدأ خلالها.
hoverClass.hover_class(..)stringلا شيءclasses الجزء hover، وهو view بحجم العقدة المحوَّم فوقها (rounded-xs outline-2 outline-foreground).
hoverStyle.hover_style(..)StyleProp<ViewStyle>لا شيءstyle جزء التحويم، فوق classes الخاصة به.

يُملأ المستطيل بـfill (لون colorScale، وإلا currentColor، حيث لا لون محدد) ويُحدَّد بـstroke (لا حد حيث لا يُحدَّد شيء)، وقيمه الخاصة تعلو قيم الـ treemap والرسم كما يرثها CSS. لا يُعاد التخطيط إلا عند تغير الصفوف أو الخيارات أو حجم الرسم، ولا يُعاد أبدًا بسبب المؤشر أو الظهور؛ وتغير حجم الرسم لا يحرك شيئًا. يسجل id مكرر، وparent لا يسمي أي صف، وصف هو سلف نفسه، تحذيرًا مرة واحدة: يحتفظ أول صف بالمعرّف، وتصبح العقدة اليتيمة عقدة من المستوى الأعلى، ولا تُرسم الحلقة.

#PlotAxis

يضع PlotAxis تسميات على حافة من حواف الرسم بعلامات المقياس الذي تحمله تلك الحافة: الحافتان السفلية والعلوية بعلامات مقياس x، والحافتان اليسرى واليمنى بعلامات مقياس y، كما في axisBottom وaxisTop وaxisLeft وaxisRight في d3. تأتي علاماته من المقياس: قيم مستديرة على مقياس 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(..)number10 على مقياس linear (كل خطوة في المقاييس المحاذاة)، وكل صف على المقاييس الأخرىعدد العلامات التقريبي الذي يأخذه: على مقياس linear، قيم مستديرة داخل المجال، بهذا العدد تقريبًا؛ وعلى مقياس point أو band، هذا العدد من الصفوف موزعة بالتساوي من الأول إلى الأخير.
tickValues.tick_values(..)number[]لا شيءقيم علاماته، بدلًا من ticks: قيم من المقياس، أو فهارس صفوف على مقياس point أو band ([0, 3, 6] يسمّي كل صف ثالث). كل قيمة داخل المجال تحصل على علامة.
format.format(..)(value: number, index: number) => stringفئة النطاق على مقياس نطاقات من الفئات، وإلا القيمة، بالمنازل العشرية التي تحتاجها الخطوة بين العلامات، مع تجميع الآلافنص كل علامة: دالة من التطبيق لقيمتها (فهرس الصف على مقياس point أو band) وفهرسها بين علامات المحور.
labelClass، labelStyle.label_class(..)، .label_style(..)string، StyleProp<TextStyle>لا شيءالجزء label: نص لكل علامة، وخطه ولونه وحشوته من اختيار التطبيق؛ وحشوته نحو الرسم هي مسافته من الحافة (pt-2 تحت المحور السفلي).
tickClass، tickStyle.tick_class(..)، .tick_style(..)string، StyleProp<ViewStyle>لا شيءالجزء tick: view لكل علامة على الحافة، يتوسط علامته ويمتد نحو التسميات (h-1 w-px bg-border تحت المحور السفلي)؛ ويظهر بمجرد تنسيقه.
lineClass، lineStyle.line_class(..)، .line_style(..)string، StyleProp<ViewStyle>لا شيءالجزء line: view على امتداد الحافة كلها، في جهة التسميات (h-px bg-border تحت المحور السفلي)؛ ويظهر بمجرد تنسيقه.

في الخارج تقع التسميات في هامش الرسم، كما تقع محاور d3 في هوامش المخطط: يحتفظ الرسم بالحجم الذي يمنحه إياه التطبيق، ويمنحه التطبيق هامشًا في جهة المحور بعمق التسميات (mb-6 لتسميات pt-2 text-xs تحته)، أو حشوة أبيه هناك. ويسمّي كل محور من محورين على حافة واحدة قيمه الخاصة، بتنسيقه الخاص: أطراف المدى باهتة ووسطه أشد.

يُستدعى format مرة لكل علامة عند تغير العلامات أو الدالة، في الدورة التالية، ولا يُستدعى أبدًا أثناء رسم الرسم أو تتبعه للمؤشر: أبقِه نقيًّا (القيمة نفسها، النص نفسه). وبدونه تُقرأ العلامة كما يكتبها d3: 1,500، 0.25، −2. في Rust، plot_axis() يصنع محورًا، وformat الخاص به هو TickFormat::from_fn(|value, index| ..). لا يستقبل المحور المؤشر، وتقرأ قارئات الشاشة الرسم لا تسمياته.

#PlotLabel

يكتب PlotLabel نصوصًا عند نقاط من المخطط: عنوانًا فوق قمة، أو وسمًا عند آخر نقطة في خط، أو ملاحظة عند طرف خط شبكة. يحدد كل عنصر من texts نقطته بقيم من مقاييس المخطط، كما تفعل العلامات: فهرس الصف على مقياس 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>
الخاصيةRustالنوعالافتراضيالوصف
texts.texts(..)PlotText[][]النصوص التي يكتبها، كل نص عند نقطته.
scale.scale(..)"y" | "y2""y"مقياس y الذي تكون y نصوصه قيمًا منه (المحاور المزدوجة).

كل نص هو PlotText:

الحقلالنوعالافتراضيالوصف
textstringما يعرضه النص.
xnumberموضعه على المحور x: قيمة من مقياس x، أو فهرس صف على مقياس point أو band (0.5 تقع في منتصف المسافة بين الصفين الأولين).
ynumberموضع حافته العليا على المحور y: قيمة من مقياس y.
align"left" | "center" | "right""left"كيف يقع على x بالنسبة إلى نقطته: يبدأ عندها، أو يتمركز عليها، أو ينتهي عندها.
class، stylestring، StyleProp<TextStyle>لا شيءالفئات والنمط الخاصان بالنص، فوق ما يرثه من PlotLabel: صندوقه (خلفية الوسم والحشو) ومسافته عن النقطة.

المظهر يعود إلى التطبيق: يمنح class الخاص بـ PlotLabel كل نص أنماط النص التي يرثها (الخط والحجم واللون)، ويصمّم class وstyle الخاصان بكل نص ذلك النص وحده. يوضع النص فوق نقطته باستخدام -translate-y-full، ويكون حشوه هو المسافة بينه وبين النقطة (pb-1)؛ ويقع الوسم المجاور لآخر نقطة في خط ضمن الهامش الذي يتركه التطبيق في ذلك الجانب (mr-12)، كما تفعل تسميات المحور. في Rust، ينشئ plot_label() تسمية، وكل نص هو PlotText::new(text, x, y) مع .align(PlotTextAlign::Center) و.class(..) و.style(..). يمر المؤشر عبر النصوص إلى المخطط، وتقرأ قارئات الشاشة المخطط، الذي يذكر accessibilityLabel فيه ما تشير إليه النصوص.

#الحركة

الحركة من شأن التطبيق، ولا توجد حركة افتراضيًا: يظهر كل شيء دفعة واحدة. يقبل motion توقيتات nsh ({ duration, easing, delay } و{ stiffness, damping, mass }):

المفتاحالنوعما الذي يتحرك
appeartimingتُرسم العلامات تدريجيًا، مع appearGeneration: الخطوط والمساحات من الحافة الأمامية، والأعمدة تنمو من قاعدتها (واحدًا تلو الآخر مع stagger العمود)؛ والنقاط تنمو من العدم (واحدة تلو الأخرى مع stagger النقطة)؛ والخلايا تنمو من منتصفها (واحدة تلو الأخرى مع stagger الخلية)؛ وحلقة المخطط الدائري وحلقة القوس تنسابان في اتجاه عقارب الساعة من بدايتهما؛ والخط الشعاعي ينمو من نصف قطره الداخلي ومعه نقاطه؛ وعُقد مخطط sankey وشرائطه من الحافة الأمامية، وتسمياته تظهر تدريجيًا.
updatetimingتنتقل العلامات إلى الشكل الجديد عندما يغيّره التطبيق: النقاط إلى قيمها وأحجامها الجديدة، ونقطة الصف الجديد تنمو ونقطة الصف المحذوف تتقلص وتختفي؛ والخلايا إلى ألوانها الجديدة (تُمزج في OKLab) ونطاقاتها، وخلية الصف الجديد تنمو وخلية الصف المحذوف تتقلص وتختفي؛ وأنصاف أقطار المخطط الدائري والقوس، فيتسع ثقب الدونات ويضيق، وزواياهما، فيدور المقياس أو الشريحة إلى قيمته الجديدة (arcTween في d3: تتحرك الزوايا لا المحيط الخارجي؛ وتنفتح الشريحة الجديدة من حيث تنتهي التي قبلها؛ والتغيير أثناء الحركة يستمر من حيث يُرسم الشكل)؛ ونقاط الخط الشعاعي إلى قيمها وزواياها وأنصاف أقطارها الجديدة (والصف الجديد ينمو من نصف القطر الداخلي)، وحلقات الشبكة الشعاعية معها؛ ومجال المقياس الخطي، أي domain الخاص بالتطبيق أو امتداد قيم العلامات عند ورود الصفوف، وينزلق من القديم إلى الجديد، وتنزلق معه العلامات وخطوط الشبكة وPlotAxis وPlotLabel؛ وعُقد مخطط sankey وشرائطه وتسمياته، من تخطيط التدفقات القديم إلى الجديد، والتغيير أثناء حركتها يستمر من حيث تُرسم.
entertimingتظهر أجزاء التحويم تدريجيًا عندما يمر المؤشر فوق المخطط.
exittimingوتختفي تدريجيًا بعد مغادرته.
pointerspringتنزلق من صف إلى التالي عندما تنقل المفاتيح أو قارئ الشاشة التحويم؛ وترتفع شريحة المخطط الدائري عندها.

تتبع أجزاء التحويم المؤشر في الإطار نفسه: فعند تحركه يكون الخط المتقاطع والنقاط والتلميح على الصف الذي تحته فورًا، فتتبع اليد، بينما يبقى ظهورها واختفاؤها التدريجي (enter، exit) متحركًا.

تعمل الحركة على المحرّك المتحرك الخاص بالمنصة، دون JS في كل إطار، وتكتمل فورًا لمن يطلب تقليل الحركة. المخطط الذي تعيد قائمة إنشاءه لصف يعود إلى العرض يُرسم من جديد، ما لم يرَ PlotAppearScope حول القائمة انتهاء ظهوره.

يتحرك المجال كما ينقل d3 مجال المقياس: يضع المقياس الوسيط كل قيمة في موضعها، فتحافظ العلامات على أشكالها، وتتحرك معها خطوط الشبكة وأجزاء التحويم، وتحتفظ الأعمدة بزواياها، ويتحرك الصف الذي يدخل مع المجال الجديد مع البقية. المخطط الحي الذي يلائم مقياس y مع صفوفه بين حين وآخر (مع هامش، فتثبت الشبكة بين الملاءمات) ينزلق إلى كل ملاءمة جديدة بتوقيت update. وعندما يتغير حجم المخطط، تتخذ العلامات الحجم الجديد فورًا.

هذه هي حركة المخطط نفسه، أي توقيتات ما يتحرك في المخطط: قصّ علاماته وأجزاء التحويم فيه. أما مداخل العرض ومخارجه وملفتات الانتباه فهي إعدادات createMotion المسبقة وإطاراتها المفتاحية، ويمكن للتطبيق أن يشغّلها على عرض المخطط أو على العناصر المحيطة به.

#PlotAppearScope

تعيد القائمة إنشاء مخطط الصف في كل مرة يعود فيها الصف إلى العرض، فيُرسم المخطط المصغّر الذي له ظهور من جديد كلما عادت القائمة إليه بالتمرير. يتذكر PlotAppearScope حول القائمة حالات الظهور التي انتهت فيها: المخطط الذي يُعاد إنشاؤه هناك وقد انتهى appearGeneration الخاص به يعرض علاماته كاملة فورًا. يُتذكر المخطط بجيله، فيكون لمخطط كل صف جيله الخاص، وهو مفتاح الصف مع ما ينبغي أن يعيد تشغيله (`${symbol} ${period}`).

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

<PlotAppearScope id={watchlist().id} class="flex-1">
  <List each={watchlist().rows} keyExtractor={(row) => row.symbol} estimatedItemSize={48} class="flex-1">
    {(row) => (
      <Plot
        data={row().closes}
        y="close"
        interactive={false}
        appearGeneration={`${row().symbol} ${period()}`}
        motion={{ appear: { duration: 400, easing: "easeOut" } }}
        class="h-8 w-24"
      >
        <Line class="stroke-chart-1 stroke-[1.5]" />
      </Plot>
    )}
  </List>
</PlotAppearScope>
الخاصيةRustالنوعالافتراضيالوصف
id.id(..)number | stringلا شيءما يتذكره النطاق من أجله: عندما يتغير، ينسى النطاق وتُرسم مخططاته من جديد حيث هي. سمِّه باسم المحتوى، مثل قائمة متابعة أو محادثة؛ وبدونه يتذكر النطاق ما دام معروضًا.
children.child(..)JSX.Elementلا شيءما يحتويه: القائمة، أو أي شيء يعيد إنشاء مخططاته أثناء دخولها وخروجها.

يظل الجيل الجديد يرسم المخطط تدريجيًا، وكذلك المخطط الذي أُزيل قبل انتهاء ظهوره، من البداية. وما إن ينهي مخطط جيلًا جديدًا حتى ينسى نطاقه الجيل الذي قبله، فيؤدي الرجوع إلى فترة سابقة إلى رسم المخطط من جديد. وتذهب الذاكرة مع النطاق: فالنطاق الذي لم يعد معروضًا، أو الذي له id جديد، يرسم مخططاته من جديد، ويتذكر المخطط في أعمق نطاق يحيط به. مظهره من شأن التطبيق: يخطّطه class وstyle كما يخطَّط أي عرض (flex-1 حول قائمة تملأ الشاشة). في Rust، ينشئ plot_appear_scope() واحدًا.

#لوحة المفاتيح

المخطط التفاعلي نقطة توقف في Tab (macOS والويب)، وما دام يملك تركيز لوحة المفاتيح فإن مفاتيحه تحرك التحويم نفسه الذي يحركه المؤشر: ينزلق الخط المتقاطع والنقاط والتلميح بزنبرك pointer، وترتفع شريحة المخطط الدائري، ويبلّغ onHoverChange عن الصف، وتقرأ قارئات الشاشة accessibilityValue الذي يقدمه التطبيق له. حلقة التركيز فيه من شأن التطبيق: فئة focus-visible: أو focusStyle (focus-visible:outline-2 focus-visible:outline-ring).

المفاتيحما تفعله
Right, Leftالانتقال إلى الصف في ذلك الجانب: الصف التالي أو السابق، والعكس على مقياس x المعكوس الذي يكون صفه الأول في اليمين (فوق المخطط الدائري، شرائحه بترتيب الصفوف؛ وفوق sankey، عُقده؛ وفوق treemap، الورقة المجاورة على الشاشة، كما يفعل Up وDown)؛ وإن لم يكن أي صف محوَّمًا فوقه، يبدأ Right من الصف في الطرف الأيسر ويبدأ Left من الصف في الطرف الأيمن (فوق المخطط الدائري، الشريحة الأولى والأخيرة).
Home, Endالانتقال إلى الصف الأول أو الأخير.
Up, Downفوق الخلايا: الانتقال إلى الخلية الأعلى أو الأدنى في العمود نفسه. فوق الخلايا تتبع المفاتيح الشبكة كما تظهر على الشاشة (شبكة البيانات في WAI-ARIA): ينقل Right وLeft إلى الخلية التالية على امتداد السطر، وينقل Home وEnd إلى أول خلية في السطر وآخرها؛ وإن لم تكن أي خلية محوَّمًا فوقها، يبدأ المفتاح الأول من الخلية العليا اليسرى (ويبدأ Left وUp وEnd من الخلية السفلى اليمنى). عند الحافة يبقى التحويم حيث هو.
Escapeيمسح التحويم.

يستجيب المخطط لمفاتيحه ما دام يملك التركيز، ولـ Escape ما دام صف محوَّمًا فوقه؛ وفي بقية الوقت تذهب إلى التطبيق والعروض المحيطة به. نقل التركيز بعيدًا يمسح تحويمًا أنشأته المفاتيح. لا يتلقى المخطط أي ضغطات، فيترك المخطط داخل بطاقة قابلة للضغط ضغطاتها لها. وتتنقل قارئات الشاشة بين الصفوف أيضًا: بالتمرير لأعلى ولأسفل في VoiceOver، وبالتمرير للأمام وللخلف في TalkBack، وبكل قارئ شاشة يوفر إجراءي increment وdecrement للعنصر، فتنقل إلى الصف التالي والسابق، بترتيب الصفوف على المقياس المعكوس أيضًا.

#بناء المخططات

المخطط هو مخطط (plot) وعلاماته ومحاوره وعناصر التطبيق الخاصة المحيطة بها. امنح مقياس y domain وامنح محوره قيم الشبكة (tickValues): فيقع كل تسمية عندئذ عند خط الشبكة الخاص بها. الخط المتقاطع والنقاط والتلميح في التحويم هي أجزاء من المخطط، ينسّقها التطبيق ويملؤها؛ وقيم الصف تذهب حيث يعرضها التطبيق.

تُقرأ الشبكات أفضل كخطوط رفيعة متصلة مع خط أساس أكثر صلابة، مما يترك الخط المتقطع ليدل على قيمة مرجعية (سعر الافتتاح أدناه). وتُقرأ المساحة أخف كطبقة شفافة تتلاشى نحو خط أساسها منها كتعبئة مصمتة. تأخذ نقطة التحويم في كل سلسلة لون سلسلتها، ولون السطح الذي يجلس عليه المخطط كحلقة لها، فتبدو مقتطعة من خطها، ويقع التلميح أعلى المخطط، داخل حافتيه عند الصفين الأول والأخير: ويُبعده عن الخط domain مع فسحة فوق أعلى قيمة.

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

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

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

يُقرأ المخطط العمودي أفضل حين تكون أطراف أعمدته مستديرة وقواعدها قائمة (cornerRadii={{ topLeft: 4, topRight: 4 }}; in a stack only on its top bar), a hair of room between the bars of a band (groupPadding)، وأعمدة الصفوف الأخرى خافتة بينما يُحوَّم فوق أحدها (inactiveOpacity) ونطاقه مظلَّل (crossLineClass)، والقيم بأرقام صغيرة جدولية فوق الأعمدة، مع المجموع فوق التكديس. مع paddingOuter يساوي نصف paddingInner، تقع منتصفات النطاقات على خطوات متساوية، ويجعل PlotAxis كل شهر في منتصف نطاقه؛ ويُبقي maxBandWidth بضعة أعمدة رفيعة في بطاقة عريضة.

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

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

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

المخطط المصغّر في صف قائمة هو مخطط مع interactive={false}: يرسم علاماته ولا يتبع أي مؤشر.

#إمكانية الوصول

  • المخطط عنصر واحد لقارئات الشاشة، بدور الصورة وaccessibilityLabel الخاص بالتطبيق؛ وعلاماته وتسميات محاوره وPlotLabel وأجزاء التحويم فيه هي أجزاء منه ولا تقول شيئًا بنفسها. اذكر في التسمية ما يعرضه المخطط وما يبرز فيه (النطاق، وأعلى قيمة)، وامنح accessibilityValue الكلمات الخاصة بالصف الذي تحت المؤشر، فتنطق قارئة الشاشة القيمة عند تغيّرها.
  • يُبلَّغ عن الصف الذي تحت المؤشر عبر onHoverChange على المنصات التي لها مؤشر (أجهزة سطح المكتب والويب وiPad مع لوحة تتبع). لا تبلّغ الشاشات اللمسية عن أي تحويم: اعرض القيم التي يحتاجها مستخدم اللمس في عناصر التطبيق الخاصة، أو قدّم الصفوف كنص أو قائمة حيث تهم الأرقام.
  • فوق المخطط الدائري يكون الصف المُبلَّغ عنه هو شريحة المؤشر: امنح accessibilityValue اسمها وقيمتها ونسبتها ("Team, 340 seats, 34.0%")، واذكر المجموع وأكبر نسبة في التسمية. وفوق مخطط radar يكون المحور الأقرب إلى المؤشر: امنحه اسم المحور وقيمة كل سلسلة عنده.
  • المخطط التفاعلي نقطة توقف لتركيز لوحة المفاتيح تحرّك مفاتيح الأسهم فيه التحويم (لوحة المفاتيح)، ويقدّم لقارئات الشاشة increment وdecrement للتنقل بين الصفوف (يقرؤه VoiceOver كعنصر قابل للضبط)؛ أما المخطط مع interactive={false} فليس أيًّا من ذلك، ولا يتلقى أي ضغطات في الحالتين.

راجع إمكانية الوصول للاطلاع على التسميات والأدوار بوجه عام.

#ملاحظات حول المنصات

  • ترسم كل منصة العلامات بالطريقة نفسها، انطلاقًا من رسم واحد لكل علامة يُسجَّل في Rust؛ وأي تغيير في الصفوف يعيد رسم العلامات التي تقرؤها دفعةً واحدة، أما تغيير صنف الطلاء (المظهر، dark:) فيُعيد إرسال الرسم دون تسجيله من جديد.
  • يقصّ الظهور التدريجي (appear) الخطوط والمساحات، وينمّي الأعمدة، بقيم يحرّكها المحرّك الحركي (animator) الخاص بالمنصة؛ أما تلاشي عمود بعيد عن الصف المُمرَّر فوقه المؤشر فهو شفافية يحسبها المحرّك الحركي من موضع التمرير؛ وأجزاء التمرير هي عروض (views) يحرّكها المحرّك الحركي ويُخفتها، وهو يُبقي التلميح داخل المخطط اعتمادًا على حجم المخطط وحجم التلميح.
  • تُرسم خلفية المساحة في لوحة رسم (canvas) خاصة بها، مقصوصة بشكل المساحة، وبالتدرّج اللوني نفسه الذي ترسمه المنصة لـ bg-linear-* الخاص بالعرض؛ وتغيير أصنافها (المظهر، dark:) يُعيد إرسال الرسم دون تسجيله من جديد.
  • يرسم المخطط الدائري كل شريحة في لوحة رسم خاصة بها؛ وتُحدَّد الشريحة الواقعة تحت المؤشر في Rust انطلاقًا من زوايا الشرائح وأنصاف أقطارها، ويكون ارتفاعها نصف قطر، ويكون تلاشي الشرائح الأخرى شفافية يحرّكها المحرّك الحركي، ويحرّك مسحُ الظهور زوايا الشرائح بالطريقة نفسها، فلا يستلزم أيٌّ منهما إعادة تسجيل الرسم.
  • يُحدَّد الصف الواقع تحت المؤشر في الخط الشعاعي في Rust انطلاقًا من زوايا نقاطه؛ ونقطة التمرير الخاصة به هي عرض يضعه المحرّك الحركي على الصف الواقع تحت المؤشر (منزلقةً على نابض pointer كلما انتقلت المفاتيح)، وأثناء ظهوره أو انتقاله إلى قيم جديدة تكون نقاطه أرقامًا يحرّكها المحرّك الحركي انطلاقًا من مواضعها السابقة، فيُسجَّل الخط مرة واحدة لكل تغيير.
  • تُرسم علامة النقطة في لوحة رسم واحدة؛ وتُحدَّد النقطة الواقعة تحت المؤشر في Rust انطلاقًا من مراكز النقاط وأنصاف أقطارها، ونقطة التمرير الخاصة بها هي عرض يضعه المحرّك الحركي عليها، بحجمها نفسه؛ ونموّ الظهور وانتقالات التغيير وتلاشي التمرير كلها قيم يحرّكها المحرّك الحركي، فتُسجَّل النقاط مرة واحدة لكل تغيير.
  • تُرسم علامة الخلية في لوحة رسم واحدة؛ وتُحدَّد الخلية الواقعة تحت المؤشر في Rust انطلاقًا من مربعات الخلايا، وخلية التمرير الخاصة بها هي عرض يضعه المحرّك الحركي عليها؛ والنموّ وألوان التغيير وانتقالاته وتلاشي التمرير كلها قيم يحرّكها المحرّك الحركي، فتُسجَّل الخلايا مرة واحدة لكل تغيير. وكل تسمية نص تابع للمنصة، يظهر حيث يتسع حجمه بعد التخطيط داخل خليته.
  • نصوص المحور وعلامات تدريجه وخطه هي نصوص وعروض تابعة للمنصة نفسها، يضعها التخطيط في Rust انطلاقًا من حجم المخطط ومقاييسه: فهي تتحرك مع العلامات عند تغيّر حجم المخطط، ويؤدي النطاق الجديد إلى كتابة التسميات من جديد.