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
52 changes: 52 additions & 0 deletions apps/frontend/src/renderer/__tests__/ui-charts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Tests for the @auto-code/ui chart-geometry helpers (Sparkline / LineChart).
* libs/ui has no CI test runner, so the pure math is exercised here.
*/

import { describe, expect, it } from 'vitest';
import { pointsToArea, seriesToPoints } from '@auto-code/ui';

describe('seriesToPoints', () => {
it('spreads points evenly across the width and inverts Y', () => {
const parsed = seriesToPoints([0, 1, 2], 200, 100, 0)
.split(' ')
.map((p) => p.split(',').map(Number));
expect(parsed.map(([x]) => x)).toEqual([0, 100, 200]);
// min sits at the bottom, max at the top (SVG origin is top-left).
expect(parsed[0][1]).toBe(100);
expect(parsed[2][1]).toBe(0);
expect(parsed[1][1]).toBe(50);
});

it('centers a flat series vertically', () => {
for (const p of seriesToPoints([5, 5, 5], 100, 40, 0).split(' ')) {
expect(Number(p.split(',')[1])).toBe(20);
}
});

it('applies vertical padding so the line never touches the edges', () => {
const ys = seriesToPoints([0, 10], 100, 100, 10)
.split(' ')
.map((p) => Number(p.split(',')[1]));
expect(Math.max(...ys)).toBe(90);
expect(Math.min(...ys)).toBe(10);
});

it('returns an empty string for an empty series', () => {
expect(seriesToPoints([], 100, 40)).toBe('');
});

it('places a single point at the horizontal center', () => {
expect(seriesToPoints([7], 100, 40, 0)).toBe('50,20');
});
});

describe('pointsToArea', () => {
it('closes the line down to the baseline corners', () => {
expect(pointsToArea('0,10 100,0', 100, 40)).toBe('0,10 100,0 100,40 0,40');
});

it('returns an empty string when there are no points', () => {
expect(pointsToArea('', 100, 40)).toBe('');
});
});
11 changes: 11 additions & 0 deletions libs/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ export { Input } from './primitives/Input';
export type { InputProps } from './primitives/Input';
export { Kbd } from './primitives/Kbd';
export type { KbdProps } from './primitives/Kbd';
export { Sparkline } from './primitives/Sparkline';
export type { SparklineProps } from './primitives/Sparkline';
export { LineChart } from './primitives/LineChart';
export type {
LineChartProps,
LineChartSeries,
} from './primitives/LineChart';
export { StatTile } from './primitives/StatTile';
export type { StatTileProps, StatTileTone } from './primitives/StatTile';
export { seriesToPoints, pointsToArea, CHART_TONE_VARS } from './primitives/charts';
export type { ChartTone } from './primitives/charts';
export { ThemeProvider, useTheme } from './theme/ThemeProvider';
export type { Theme, ResolvedTheme, ThemeProviderProps } from './theme/ThemeProvider';
export { AppShell } from './shell/AppShell';
Expand Down
28 changes: 28 additions & 0 deletions libs/ui/src/primitives/LineChart.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
.ac-linechart {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.ac-linechart__svg {
display: block;
width: 100%;
height: auto;
}
.ac-linechart__legend {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 11.5px;
color: var(--muted);
}
.ac-linechart__legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.ac-linechart__swatch {
width: 10px;
height: 10px;
border-radius: 3px;
}
50 changes: 50 additions & 0 deletions libs/ui/src/primitives/LineChart.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { LineChart } from './LineChart';

const meta = {
title: 'Primitives/LineChart',
component: LineChart,
decorators: [
(Story) => (
<div style={{ maxWidth: 760, padding: 16 }}>
<Story />
</div>
),
],
args: {
series: [
{
label: 'Merged',
tone: 'good',
values: [4, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16],
},
{
label: 'Opened',
tone: 'info',
values: [8, 9, 7, 11, 10, 12, 11, 13, 12, 14, 13, 15],
},
],
xLabels: ['Apr 1', 'Apr 8', 'Apr 15', 'Apr 22', 'May 1', 'May 8'],
gridLines: 4,
showLegend: true,
ariaLabel: 'Merged vs opened PRs over six weeks',
},
} satisfies Meta<typeof LineChart>;

export default meta;
type Story = StoryObj<typeof meta>;

export const TwoSeries: Story = {};

export const SingleSeries: Story = {
args: {
series: [
{ label: 'Throughput', tone: 'info', values: [12, 14, 11, 18, 16, 22, 20, 26] },
],
showLegend: false,
},
};

export const NoAxisLabels: Story = {
args: { xLabels: undefined },
};
155 changes: 155 additions & 0 deletions libs/ui/src/primitives/LineChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { CHART_TONE_VARS, type ChartTone } from './charts';
import './LineChart.css';

export interface LineChartSeries {
label: string;
values: readonly number[];
tone?: ChartTone;
}

export interface LineChartProps {
/** One or more series, plotted on a shared y-scale. */
series: readonly LineChartSeries[];
/** Optional x-axis tick labels (rendered evenly across the width). */
xLabels?: readonly string[];
/** Number of horizontal grid lines. */
gridLines?: number;
/** Show a legend row above the chart. */
showLegend?: boolean;
/** Accessible label; when omitted the chart svg is decorative (aria-hidden). */
ariaLabel?: string;
/** viewBox width. */
width?: number;
/** viewBox height (chart plot area, excluding the axis-label row). */
height?: number;
className?: string;
}

const PLOT_PADDING = 8;
const AXIS_ROW = 18;

/**
* Multi-series line chart drawn as SVG polylines over a dashed grid, mirroring
* the `.lazyweb` line-chart. All series share one y-scale so they compare
* directly. Presentational and data-agnostic.
*/
export function LineChart({
series,
xLabels,
gridLines = 4,
showLegend = false,
ariaLabel,
width = 760,
height = 220,
className,
}: Readonly<LineChartProps>) {
const drawable = series.filter((s) => s.values.length >= 2);
const allValues = drawable.flatMap((s) => s.values);
const min = allValues.length > 0 ? Math.min(...allValues) : 0;
const max = allValues.length > 0 ? Math.max(...allValues) : 1;
const span = max - min;
const plotHeight = height - PLOT_PADDING * 2;

const toPoints = (values: readonly number[]): string => {
const stepX = values.length > 1 ? width / (values.length - 1) : 0;
return values
.map((value, index) => {
const x = index * stepX;
const ratio = span === 0 ? 0.5 : (value - min) / span;
const y = PLOT_PADDING + (1 - ratio) * plotHeight;
return `${round(x)},${round(y)}`;
})
.join(' ');
};

const totalHeight = xLabels != null ? height + AXIS_ROW : height;
const labelled = ariaLabel != null;

return (
<div className={`ac-linechart${className != null ? ` ${className}` : ''}`}>

Check warning on line 69 in libs/ui/src/primitives/LineChart.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ-UZSugB6QyNgYj-ev-&open=AZ-UZSugB6QyNgYj-ev-&pullRequest=438
{showLegend && (
<div className="ac-linechart__legend">
{series.map((s) => (
<span key={s.label} className="ac-linechart__legend-item">
<span
aria-hidden="true"
className="ac-linechart__swatch"
style={{ background: CHART_TONE_VARS[s.tone ?? 'info'] }}
/>
{s.label}
</span>
))}
</div>
)}
<svg
className="ac-linechart__svg"
viewBox={`0 0 ${width} ${totalHeight}`}
preserveAspectRatio="none"
role={labelled ? 'img' : undefined}
aria-label={ariaLabel}
aria-hidden={labelled ? undefined : true}
>

Check warning on line 91 in libs/ui/src/primitives/LineChart.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use <img alt=...>, or <img alt=...> instead of the "img" role to ensure accessibility across all devices.

See more on https://sonarcloud.io/project/issues?id=OBenner_Auto-Coding&issues=AZ-UZSugB6QyNgYj-ev_&open=AZ-UZSugB6QyNgYj-ev_&pullRequest=438
<g
stroke="var(--line)"
strokeWidth="1"
strokeDasharray="2 4"
opacity="0.7"
>
{Array.from({ length: gridLines }, (_, i) => {
const y = round(PLOT_PADDING + (plotHeight * (i + 1)) / (gridLines + 1));
return <line key={y} x1="0" y1={y} x2={width} y2={y} />;
})}
</g>

{drawable.map((s) => (
<polyline
key={s.label}
fill="none"
stroke={CHART_TONE_VARS[s.tone ?? 'info']}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
points={toPoints(s.values)}
/>
))}

{xLabels != null && xLabels.length > 0 && (
<g
fill="var(--quiet)"
fontSize="10"
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
>
{xLabels.map((label, index) => {
const x =
xLabels.length > 1
? (width * index) / (xLabels.length - 1)
: width / 2;
const anchor = tickAnchor(index, xLabels.length);
return (
<text
key={label}
x={round(x)}
y={height + AXIS_ROW - 4}
textAnchor={anchor}
>
{label}
</text>
);
})}
</g>
)}
</svg>
</div>
);
}

function round(n: number): number {
return Math.round(n * 100) / 100;
}

/** Edge ticks anchor to the plot edges so labels don't overflow the viewBox. */
function tickAnchor(index: number, count: number): 'start' | 'middle' | 'end' {
if (index === 0) return 'start';
if (index === count - 1) return 'end';
return 'middle';
}
6 changes: 6 additions & 0 deletions libs/ui/src/primitives/Sparkline.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.ac-sparkline {
display: block;
width: 100%;
height: 36px;
overflow: visible;
}
48 changes: 48 additions & 0 deletions libs/ui/src/primitives/Sparkline.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Sparkline } from './Sparkline';

const meta = {
title: 'Primitives/Sparkline',
component: Sparkline,
decorators: [
(Story) => (
<div style={{ width: 240, padding: 16 }}>
<Story />
</div>
),
],
args: {
values: [28, 26, 22, 24, 18, 20, 14, 16, 12, 10, 8, 6, 4],
tone: 'info',
ariaLabel: 'Throughput trending up over 13 days',
},
} satisfies Meta<typeof Sparkline>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Rising: Story = {};

export const WithArea: Story = {
args: { area: true },
};

export const Falling: Story = {
args: {
values: [8, 12, 10, 14, 16, 18, 20, 18, 22, 24, 22, 26, 28],
tone: 'warn',
ariaLabel: 'Latency trending up over 13 days',
},
};

export const Flat: Story = {
args: { values: [10, 10, 10, 10, 10], tone: 'neutral' },
};

export const Good: Story = {
args: {
values: [4, 6, 5, 8, 7, 10, 9, 12, 11, 14],
tone: 'good',
area: true,
},
};
Loading
Loading