Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
136 changes: 136 additions & 0 deletions src/components/ui/blocks/trend-chart/TrendChart.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof TrendChart> = {
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<typeof TrendChart>;

/** The base chart — a smooth line with an area fill and x-axis labels. */
export const Playground: Story = {
render: (args) => (
<div className="w-[520px] rounded-2xl border border-outline-variant bg-surface-container-low p-4 text-primary">
<TrendChart {...args} />
</div>
),
};

/** 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) => (
<div className="w-[420px] rounded-2xl border border-outline-variant bg-surface-container-low p-4 text-primary">
<TrendChart {...args} />
</div>
),
};

/** 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) => (
<div className="w-[420px] rounded-2xl border border-outline-variant bg-surface-container-low p-4 text-primary">
<TrendChart {...args} />
</div>
),
};

/** 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) => (
<div className="w-[420px] rounded-2xl border border-outline-variant bg-surface-container-low p-4 text-warning">
<TrendChart {...args} />
</div>
),
};

/** 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) => (
<div className="w-80 rounded-2xl border border-outline-variant bg-surface-container-low p-4 text-primary">
<TrendChart {...args} />
</div>
),
};

/** 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) => (
<div className="w-80 rounded-2xl border border-outline-variant bg-surface-container-low p-4 text-primary">
<TrendChart {...args} />
</div>
),
};
146 changes: 146 additions & 0 deletions src/components/ui/blocks/trend-chart/TrendChart.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<TrendChart values={VALUES} labels={LABELS} color="text-primary" fillId="t1" />,
);
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(
<TrendChart values={VALUES} labels={LABELS} color="text-primary" fillId="t2" showArea={false} />,
);
expect(container.querySelector('path[fill="url(#t2)"]')).not.toBeInTheDocument();
});

it("renders an empty frame instead of throwing when values.length < 2", () => {
expect(() =>
render(<TrendChart values={[1]} labels={["only"]} color="text-primary" fillId="t3" />),
).not.toThrow();
expect(() =>
render(<TrendChart values={[]} labels={[]} color="text-primary" fillId="t4" />),
).not.toThrow();
});

it("renders x-axis labels every labelEvery-th tick by default, and omits them when showXAxisLabels is false", () => {
const { container: withLabels } = render(
<TrendChart values={VALUES} labels={LABELS} color="text-primary" fillId="t5" labelEvery={2} />,
);
expect(withLabels.textContent).toContain("00:00");
expect(withLabels.textContent).toContain("08:00");
expect(withLabels.textContent).not.toContain("04:00");

const { container: withoutLabels } = render(
<TrendChart values={VALUES} labels={LABELS} color="text-primary" fillId="t6" showXAxisLabels={false} />,
);
expect(withoutLabels.textContent).not.toContain("00:00");
});

it("draws a dashed threshold line, gating the red shading behind showArea", () => {
const { container: withArea } = render(
<TrendChart values={VALUES} labels={LABELS} color="text-primary" fillId="t7" thresholdY={50} />,
);
expect(withArea.querySelector('line[stroke-dasharray="4 3"]')).toBeInTheDocument();
expect(withArea.querySelector('rect[fill="url(#t7-threshold)"]')).toBeInTheDocument();

const { container: noArea } = render(
<TrendChart
values={VALUES}
labels={LABELS}
color="text-primary"
fillId="t8"
thresholdY={50}
showArea={false}
/>,
);
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(
<TrendChart
values={VALUES}
labels={LABELS}
color="text-primary"
fillId="t9"
overlays={[
{ values: [5, 20, 15, 30, 25, 40], color: "text-secondary", label: "p50" },
{ values: [1, 2, 3, 2, 1, 2], color: "text-error", label: "5xx", fixedMax: 10, unit: "%" },
]}
/>,
);
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(
<TrendChart
values={VALUES}
labels={LABELS}
color="text-primary"
unit="ms"
fillId="t10"
overlays={[{ values: [5, 20, 15, 30, 25, 40], color: "text-secondary", label: "p50", fixedMax: 100, unit: "%" }]}
/>,
);
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(
<TrendChart
values={VALUES}
labels={LABELS}
color="text-primary"
unit="ms"
fillId="t11"
overlays={[{ values: [5, 20, 15, 30, 25, 40], color: "text-secondary", label: "p99", fixedMax: 100 }]}
/>,
);
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(<TrendChart values={VALUES} labels={LABELS} color="text-primary" />);
const gradient = container.querySelector("linearGradient")!;
expect(gradient.id).toMatch(/^trend-chart-/);
});
});
Loading
Loading