diff --git a/package.json b/package.json index 56f1cc28d..45038dc57 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.6.1", + "version": "0.6.2", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/ui/blocks/trend-chart/TrendChart.stories.tsx b/src/components/ui/blocks/trend-chart/TrendChart.stories.tsx new file mode 100644 index 000000000..847996b27 --- /dev/null +++ b/src/components/ui/blocks/trend-chart/TrendChart.stories.tsx @@ -0,0 +1,136 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { TrendChart } from "./TrendChart"; + +const LABELS = Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, "0")}:00`); + +function series(base: number, wobble: number, seed = 1) { + return LABELS.map((_, i) => Math.max(0, base + Math.sin((i + seed) / 2.3) * wobble + ((i * seed) % 5))); +} + +const REQUESTS = series(220, 90, 1); +const P95 = series(140, 40, 2); +const P50 = series(80, 20, 3); +const COLD_START = series(180, 120, 4); +const RATE_4XX = series(3, 2, 5); +const RATE_5XX = series(0.8, 0.6, 6); + +const meta: Meta = { + title: "UI/Blocks/TrendChart", + component: TrendChart, + args: { + values: REQUESTS, + labels: LABELS, + color: "text-primary", + fillId: "trend-chart-story-fill", + }, + argTypes: { + values: { control: "object" }, + labels: { control: "object" }, + height: { control: { type: "number", min: 80, max: 320, step: 10 } }, + unit: { control: "text" }, + showArea: { control: "boolean" }, + showXAxisLabels: { control: "boolean" }, + labelEvery: { control: { type: "number", min: 1, max: 12, step: 1 } }, + thresholdY: { control: { type: "number", min: 0, max: 400, step: 10 } }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** The base chart — a smooth line with an area fill and x-axis labels. */ +export const Playground: Story = { + render: (args) => ( +
+ +
+ ), +}; + +/** A single dashed overlay sharing the main series' auto-scaled axis (e.g. + * p50 read alongside p95, both in ms). */ +export const WithOverlay: Story = { + args: { + values: P95, + color: "text-primary", + unit: "ms", + showArea: false, + fillId: "trend-chart-story-p95", + overlays: [{ values: P50, color: "text-secondary", label: "p50" }], + }, + render: (args) => ( +
+ +
+ ), +}; + +/** Multiple overlays, each pinned to its own `fixedMax` — 4xx/5xx rates (%) + * overlaid on a request-count chart. Auto-scaling a stable ~1-3% rate to + * fill the whole height would exaggerate it into a dramatic-looking wave. */ +export const WithFixedMaxOverlays: Story = { + args: { + values: REQUESTS, + color: "text-primary", + fillId: "trend-chart-story-requests", + overlays: [ + { values: RATE_4XX, color: "text-warning", label: "4xx", fixedMax: 10, unit: "%" }, + { values: RATE_5XX, color: "text-error", label: "5xx", fixedMax: 10, unit: "%" }, + ], + }, + render: (args) => ( +
+ +
+ ), +}; + +/** A dashed threshold line with red-tinted shading above it — the shading + * only renders when `showArea` is also true; the line itself always renders. */ +export const WithThreshold: Story = { + args: { + values: COLD_START, + color: "text-warning", + unit: "ms", + thresholdY: 300, + fillId: "trend-chart-story-cold-start", + }, + render: (args) => ( +
+ +
+ ), +}; + +/** Compact tile look: no x-axis labels, tighter bottom margin. Used by + * dashboard tiles where axis labels would add noise without information. */ +export const NoAxisLabels: Story = { + args: { + values: REQUESTS, + color: "text-primary", + height: 100, + showXAxisLabels: false, + fillId: "trend-chart-story-compact", + }, + render: (args) => ( +
+ +
+ ), +}; + +/** Fewer than 2 points (e.g. data still loading) renders an empty chart + * frame instead of throwing. */ +export const EmptyData: Story = { + args: { + values: [], + labels: [], + color: "text-primary", + fillId: "trend-chart-story-empty", + }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/src/components/ui/blocks/trend-chart/TrendChart.test.tsx b/src/components/ui/blocks/trend-chart/TrendChart.test.tsx new file mode 100644 index 000000000..92f21d7c7 --- /dev/null +++ b/src/components/ui/blocks/trend-chart/TrendChart.test.tsx @@ -0,0 +1,146 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { TrendChart } from "./TrendChart"; + +afterEach(cleanup); + +// jsdom doesn't ship ResizeObserver, and the hover math needs a real, +// deterministic bounding rect — stub both the same way NotchGrid.test.tsx does. +beforeAll(() => { + type Cb = (entries: ResizeObserverEntry[], obs: ResizeObserver) => void; + class StubResizeObserver { + constructor(_cb: Cb) {} + observe() {} + unobserve() {} + disconnect() {} + } + (globalThis as unknown as { ResizeObserver: typeof StubResizeObserver }).ResizeObserver = + StubResizeObserver; + + Element.prototype.getBoundingClientRect = function () { + return { x: 0, y: 0, top: 0, left: 0, right: 400, bottom: 140, width: 400, height: 140, toJSON() {} } as DOMRect; + }; +}); + +const VALUES = [10, 40, 30, 60, 50, 80]; +const LABELS = ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00"]; + +describe("TrendChart", () => { + it("renders the main line and area fill", () => { + const { container } = render( + , + ); + expect(container.querySelector('path[stroke="currentColor"]')).toBeInTheDocument(); + expect(container.querySelector('path[fill="url(#t1)"]')).toBeInTheDocument(); + }); + + it("omits the area fill when showArea is false", () => { + const { container } = render( + , + ); + expect(container.querySelector('path[fill="url(#t2)"]')).not.toBeInTheDocument(); + }); + + it("renders an empty frame instead of throwing when values.length < 2", () => { + expect(() => + render(), + ).not.toThrow(); + expect(() => + render(), + ).not.toThrow(); + }); + + it("renders x-axis labels every labelEvery-th tick by default, and omits them when showXAxisLabels is false", () => { + const { container: withLabels } = render( + , + ); + expect(withLabels.textContent).toContain("00:00"); + expect(withLabels.textContent).toContain("08:00"); + expect(withLabels.textContent).not.toContain("04:00"); + + const { container: withoutLabels } = render( + , + ); + expect(withoutLabels.textContent).not.toContain("00:00"); + }); + + it("draws a dashed threshold line, gating the red shading behind showArea", () => { + const { container: withArea } = render( + , + ); + expect(withArea.querySelector('line[stroke-dasharray="4 3"]')).toBeInTheDocument(); + expect(withArea.querySelector('rect[fill="url(#t7-threshold)"]')).toBeInTheDocument(); + + const { container: noArea } = render( + , + ); + expect(noArea.querySelector('line[stroke-dasharray="4 3"]')).toBeInTheDocument(); + expect(noArea.querySelector('rect[fill="url(#t8-threshold)"]')).not.toBeInTheDocument(); + }); + + it("renders one dashed overlay per entry, each with its own color class", () => { + const { container } = render( + , + ); + const overlayPaths = container.querySelectorAll('path[stroke-dasharray="4 3"]'); + expect(overlayPaths).toHaveLength(2); + expect(overlayPaths[0]).toHaveClass("text-secondary"); + expect(overlayPaths[1]).toHaveClass("text-error"); + }); + + it("shows a tooltip on hover with the main value and each overlay's own unit", () => { + const { container } = render( + , + ); + const svg = container.querySelector("svg")!; + fireEvent.mouseMove(svg, { clientX: 200 }); + expect(screen.getByText(LABELS[2])).toBeInTheDocument(); + expect(screen.getByText(/p50:/)).toHaveTextContent("%"); + fireEvent.mouseLeave(svg); + expect(screen.queryByText(/p50:/)).not.toBeInTheDocument(); + }); + + it("falls back overlay unit to the chart's own unit when the overlay omits it", () => { + const { container } = render( + , + ); + fireEvent.mouseMove(container.querySelector("svg")!, { clientX: 200 }); + expect(screen.getByText(/p99:/)).toHaveTextContent("ms"); + }); + + it("uses a generated gradient id when fillId is omitted", () => { + const { container } = render(); + const gradient = container.querySelector("linearGradient")!; + expect(gradient.id).toMatch(/^trend-chart-/); + }); +}); diff --git a/src/components/ui/blocks/trend-chart/TrendChart.tsx b/src/components/ui/blocks/trend-chart/TrendChart.tsx new file mode 100644 index 000000000..7b62ae3df --- /dev/null +++ b/src/components/ui/blocks/trend-chart/TrendChart.tsx @@ -0,0 +1,396 @@ +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; +import { cn } from "@/utils/cn"; +import type { ComponentMeta } from "@/types/component-meta"; + +export const meta: ComponentMeta = { + name: "TrendChart", + description: + "A real-pixel-plotted trend chart with a smooth curve, optional area fill, 0..N dashed overlay series, an optional threshold line, and a hover tooltip. Colors follow the currentColor convention (Tailwind text-* classes), matching Sparkline.", +}; + +/* ── layout + smoothing (private port of web-applications' src/lib/charts.ts — + the kit can't import from a consuming app, so these live here instead). ── */ + +type ChartPadding = { top: number; right: number; bottom: number; left: number }; +const CHART_PADDING: ChartPadding = { top: 8, right: 4, bottom: 20, left: 36 }; + +const EMPTY_PATH = { pts: [] as [number, number][], line: "", area: "" }; + +// Catmull-Rom → cubic Bezier so series render as smooth curves rather than +// polylines. Callers must guarantee `values.length >= 2` — indexes `pts[0]` +// unconditionally. +function buildSmoothPath( + values: number[], + width: number, + height: number, + min: number, + max: number, + pad: ChartPadding, +) { + const range = max - min || 1; + const { top, right, bottom, left } = pad; + const pts = values.map((v, i): [number, number] => [ + left + (i / (values.length - 1)) * (width - left - right), + top + (1 - (v - min) / range) * (height - top - bottom), + ]); + + let line = `M${pts[0][0].toFixed(1)},${pts[0][1].toFixed(1)}`; + for (let i = 0; i < pts.length - 1; i++) { + const p0 = pts[i - 1] ?? pts[i]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[i + 2] ?? p2; + const cp1x = p1[0] + (p2[0] - p0[0]) / 6; + const cp1y = p1[1] + (p2[1] - p0[1]) / 6; + const cp2x = p2[0] - (p3[0] - p1[0]) / 6; + const cp2y = p2[1] - (p3[1] - p1[1]) / 6; + line += ` C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`; + } + + const area = + line + + ` L${pts.at(-1)![0].toFixed(1)},${(height - bottom).toFixed(1)}` + + ` L${pts[0][0].toFixed(1)},${(height - bottom).toFixed(1)} Z`; + + return { pts, line, area }; +} + +// Tracks the rendered width of a container so the SVG can size its viewBox to +// whatever column it lands in. Bails on no-op resizes so the plotting useMemo +// doesn't churn during sub-pixel layout shimmies. +function useContainerWidth(initial: number) { + const ref = useRef(null); + const [width, setWidth] = useState(initial); + + useEffect(() => { + const el = ref.current; + if (!el) return; + setWidth(el.getBoundingClientRect().width); + const ro = new ResizeObserver(([entry]) => { + const next = entry.contentRect.width; + setWidth((prev) => (Math.abs(prev - next) < 0.5 ? prev : next)); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + return [width, ref] as const; +} + +/* ── Component ────────────────────────────────────────────────────── */ + +export interface TrendChartOverlay { + /** Series values, same length/index alignment as the main `values` array. */ + values: number[]; + /** Tailwind text-color utility class (e.g. "text-tertiary") applied via + * `currentColor` to this overlay's dashed stroke + hover dot — same + * color convention as `color` below. Never a raw hex/CSS color. */ + color: string; + /** Legend/tooltip label for this overlay (e.g. "p50", "4xx"). */ + label: string; + /** Pin this overlay's own y-axis ceiling instead of sharing the main + * series' auto-computed max (`Math.max(values) * 1.15`). Required when + * the overlay is a different unit than the main series (e.g. a % rate + * overlaid on a request-count chart) — auto-scaling would exaggerate a + * small, stable value into a dramatic-looking wave. Omit when the + * overlay shares the main series' unit (e.g. p99 over p50, both ms), so + * sharing the main axis stays the honest read. */ + fixedMax?: number; + /** Unit suffix for THIS overlay's tooltip value. Defaults to the chart's + * own `unit` prop. Deliberately independent of `fixedMax` — a fixed + * ceiling does not imply "percent"; each overlay declares its own unit + * explicitly. */ + unit?: string; +} + +export interface TrendChartProps { + /** Main series values — plotted as the solid line (+ area fill if `showArea`). */ + values: number[]; + /** One label per value. Used for x-axis ticks (when `showXAxisLabels`) and + * as the hover-tooltip heading. */ + labels: string[]; + /** Tailwind text-color utility class for the main series (e.g. + * "text-primary"), applied via `currentColor` — matches Sparkline's + * color convention. Never a raw hex/CSS color, so the chart follows the + * design system's light/dark tokens automatically. */ + color: string; + /** Unique id for this chart's SVG gradient ``. Auto-generated via + * `useId()` when omitted — pass explicitly only if you need a stable, + * predictable id (e.g. snapshot tests). */ + fillId?: string; + /** SVG viewBox height in px. Default `140`. */ + height?: number; + /** Unit suffix appended to the main series' y-axis tick labels and + * tooltip value. Default `""`. */ + unit?: string; + /** Render the gradient area fill beneath the main line. Default `true`. */ + showArea?: boolean; + /** Render x-axis time labels below the chart (every `labelEvery`-th + * label). Default `true`. Set `false` for compact tiles where axis + * labels add noise without adding information — the chart reclaims the + * bottom margin those labels would otherwise occupy. */ + showXAxisLabels?: boolean; + /** When `showXAxisLabels` is true, render only every Nth label. Default `4`. */ + labelEvery?: number; + /** Draw a dashed threshold line at this y-value, with red-tinted shading + * above it (the shading only renders when `showArea` is also true; the + * dashed line itself always renders). Omit for no threshold. */ + thresholdY?: number; + /** Zero or more additional dashed series drawn over the main line, each + * with its own hover dot and tooltip row. */ + overlays?: TrendChartOverlay[]; + /** Extra classes on the chart's outer wrapper `div`. */ + className?: string; +} + +export function TrendChart({ + values, + labels, + color, + fillId, + height = 140, + unit = "", + showArea = true, + showXAxisLabels = true, + labelEvery = 4, + thresholdY, + overlays, + className, +}: TrendChartProps) { + const autoId = useId(); + const gradientId = fillId ?? `trend-chart-${autoId}`; + const svgRef = useRef(null); + const [W, containerRef] = useContainerWidth(400); + const [hover, setHover] = useState(null); + const H = height; + // Axis labels own CHART_PADDING's default bottom margin; without them, + // reclaim it instead of leaving it empty (matches the compact-tile look). + const PAD = useMemo( + () => ({ ...CHART_PADDING, bottom: showXAxisLabels ? 20 : 4 }), + [showXAxisLabels], + ); + + const { main, subs, threshY, yTicks } = useMemo(() => { + // Guard the brief window before async data resolves, where every series + // is still `[]` — buildSmoothPath indexes pts[0] unconditionally and + // throws on fewer than 2 points. + if (values.length < 2) { + return { + main: EMPTY_PATH, + subs: [] as (typeof EMPTY_PATH | null)[], + threshY: null as number | null, + yTicks: [] as { v: number; y: number }[], + }; + } + const shared = overlays?.filter((o) => o.fixedMax == null).flatMap((o) => o.values) ?? []; + const min = 0; + const max = Math.max(...values, ...shared) * 1.15 || 1; + const range = max - min || 1; + return { + main: buildSmoothPath(values, W, H, min, max, PAD), + subs: (overlays ?? []).map((o) => + o.values.length < 2 ? null : buildSmoothPath(o.values, W, H, min, o.fixedMax ?? max, PAD), + ), + threshY: + thresholdY != null + ? PAD.top + (1 - (thresholdY - min) / range) * (H - PAD.top - PAD.bottom) + : null, + yTicks: [0, 0.25, 0.5, 0.75, 1].map((t) => ({ + v: min + t * range, + y: PAD.top + (1 - t) * (H - PAD.top - PAD.bottom), + })), + }; + }, [values, overlays, W, H, PAD, thresholdY]); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const rect = svgRef.current?.getBoundingClientRect(); + if (!rect || values.length < 2) return; + const idx = Math.round( + ((((e.clientX - rect.left) / rect.width) * W - PAD.left) / (W - PAD.left - PAD.right)) * + (values.length - 1), + ); + setHover(Math.max(0, Math.min(values.length - 1, idx))); + }, + [values.length, W, PAD.left, PAD.right], + ); + + const hoverPt = hover != null ? main.pts[hover] : null; + + return ( +
+ setHover(null)} + > + + + + + + {thresholdY != null && ( + + + + + )} + + + {yTicks.slice(1, -1).map((t) => ( + + ))} + + {yTicks + .filter((_, i) => i % 2 === 0) + .map((t) => ( + + {t.v < 1 ? t.v.toFixed(1) : Math.round(t.v)} + {unit} + + ))} + + {threshY != null && showArea && ( + + )} + {threshY != null && ( + + )} + + {showArea && } + + {(overlays ?? []).map((o, i) => { + const sub = subs[i]; + return sub ? ( + + ) : null; + })} + + + + {showXAxisLabels && + labels.map((l, i) => { + if (i % labelEvery !== 0) return null; + const x = PAD.left + (i / (values.length - 1)) * (W - PAD.left - PAD.right); + return ( + + {l} + + ); + })} + + {hoverPt && ( + <> + + + {(overlays ?? []).map((o, i) => { + const sub = subs[i]; + return sub && hover != null ? ( + + ) : null; + })} + + )} + + + {hover != null && hoverPt && ( +
W * 0.7 ? "translateX(-110%)" : "translateX(8px)", + }} + > +

{labels[hover]}

+

+ {values[hover] < 1 ? values[hover].toFixed(2) : Math.round(values[hover])} + {unit} +

+ {(overlays ?? []).map((o) => ( +

+ {o.label}: {o.values[hover] < 1 ? o.values[hover].toFixed(2) : Math.round(o.values[hover])} + {o.unit ?? unit} +

+ ))} +
+ )} +
+ ); +} diff --git a/src/index.ts b/src/index.ts index 8a7b1db1c..784cff297 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,6 +117,11 @@ export { type StatusIndicatorProps, type StatusLevel, } from "./components/ui/blocks/status-indicator/StatusIndicator"; +export { + TrendChart, + type TrendChartProps, + type TrendChartOverlay, +} from "./components/ui/blocks/trend-chart/TrendChart"; export { StarRating, type StarRatingProps,