Skip to content
Open
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
54 changes: 3 additions & 51 deletions packages/app/src/components/NumberTileBackgroundChart.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import { useMemo } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import {
Area,
AreaChart,
Line,
LineChart,
ResponsiveContainer,
} from 'recharts';
import { isBuilderChartConfig } from '@hyperdx/common-utils/dist/guards';
import {
BackgroundChart,
Expand All @@ -26,23 +19,12 @@ import { useQueriedChartConfig } from '@/hooks/useChartConfig';
import { useSource } from '@/source';
import { getColorFromCSSToken } from '@/utils';

import { Sparkline, type SparklinePoint } from './Sparkline';

// Trend hue used when neither the background override nor the tile's static
// `color` is set, so a sparkline is always visible once enabled.
const DEFAULT_BACKGROUND_TOKEN: ChartPaletteToken = 'chart-blue';

// The sparkline sits behind the value, so it is intentionally low-contrast:
// a translucent stroke with a fainter area fill.
const STROKE_OPACITY = 0.5;
const STROKE_WIDTH = 2;
const AREA_FILL_OPACITY = 0.15;

// `y` is the plotted value; `x` (bucket timestamp, seconds) is retained for
// ordering and future axis use. With no `<XAxis>`, recharts spaces points by
// array order, which is already sorted by bucket.
const VALUE_KEY = 'y';

type SparklinePoint = { x: number; y: number };

/**
* Flatten the time-chart formatter's `graphResults` into sparkline points.
* Number tiles are single-series, so a single value series is read by key.
Expand Down Expand Up @@ -163,8 +145,6 @@ function NumberTileBackgroundChartInner({
DEFAULT_BACKGROUND_TOKEN,
);

const margin = { top: 4, right: 0, bottom: 0, left: 0 };

return (
<div
aria-hidden
Expand All @@ -176,35 +156,7 @@ function NumberTileBackgroundChartInner({
zIndex: 0,
}}
>
<ResponsiveContainer width="100%" height="100%">
{backgroundChart.type === 'area' ? (
<AreaChart data={points} margin={margin}>
<Area
type="monotone"
dataKey={VALUE_KEY}
stroke={color}
strokeOpacity={STROKE_OPACITY}
strokeWidth={STROKE_WIDTH}
fill={color}
fillOpacity={AREA_FILL_OPACITY}
isAnimationActive={false}
dot={false}
/>
</AreaChart>
) : (
<LineChart data={points} margin={margin}>
<Line
type="monotone"
dataKey={VALUE_KEY}
stroke={color}
strokeOpacity={STROKE_OPACITY}
strokeWidth={STROKE_WIDTH}
isAnimationActive={false}
dot={false}
/>
</LineChart>
)}
</ResponsiveContainer>
<Sparkline points={points} type={backgroundChart.type} color={color} />
</div>
);
}
Expand Down
88 changes: 88 additions & 0 deletions packages/app/src/components/Sparkline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
Area,
AreaChart,
Bar,
BarChart,
Line,
LineChart,
ResponsiveContainer,
} from 'recharts';

type SparklineType = 'line' | 'area' | 'bar';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 SparklineType is not exported. Consumers who need to track or type the type prop in their own state will have to re-declare 'line' | 'area' | 'bar' themselves or use React.ComponentProps<typeof Sparkline>['type']. Given SparklinePoint is already exported and the follow-up DBTableChart consumer is the intended caller, exporting SparklineType alongside it would keep the public surface consistent.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept local on purpose for now. This package trims unused exports (knip reports unused exported types), and the only current consumer passes a value that is already typed by its own schema, so exporting it now would be a dead export. The table-cell consumer in the follow-up imports it, and the export lands with that change so it is never unused.


export type SparklinePoint = { x: number; y: number };

// `y` is the plotted value; `x` (bucket timestamp, seconds) is retained for
// ordering and future axis use. With no `<XAxis>`, recharts spaces points by
// array order, which callers keep sorted by bucket.
const VALUE_KEY = 'y';

// The line / area variants are drawn behind or beside a value, so they are
// intentionally low-contrast: a translucent stroke with a fainter area fill.
const STROKE_OPACITY = 0.5;
const STROKE_WIDTH = 2;
const AREA_FILL_OPACITY = 0.15;

const CHART_MARGIN = { top: 4, right: 0, bottom: 0, left: 0 };

/**
* Chrome-less recharts trend, drawn as a line, area, or bar. No axes, grid,
* legend, or tooltip; dots and animation are off. Fills its parent via
* `ResponsiveContainer`, so the parent owns the dimensions (pass `height` to
* override the default 100%). Renders nothing for fewer than two points, since
* a single point has no trend to draw.
*/
export function Sparkline({
points,
type,
color,
height = '100%',
}: {
points: SparklinePoint[];
type: SparklineType;
color: string;
height?: number | string;
}) {
if (points.length < 2) return null;

return (
<ResponsiveContainer width="100%" height={height}>
{type === 'bar' ? (
<BarChart data={points} margin={CHART_MARGIN}>
<Bar
dataKey={VALUE_KEY}
fill={color}
maxBarSize={24}
isAnimationActive={false}
/>
</BarChart>
) : type === 'area' ? (
<AreaChart data={points} margin={CHART_MARGIN}>
<Area
type="monotone"
dataKey={VALUE_KEY}
stroke={color}
strokeOpacity={STROKE_OPACITY}
strokeWidth={STROKE_WIDTH}
fill={color}
fillOpacity={AREA_FILL_OPACITY}
isAnimationActive={false}
dot={false}
/>
</AreaChart>
) : (
<LineChart data={points} margin={CHART_MARGIN}>
<Line
type="monotone"
dataKey={VALUE_KEY}
stroke={color}
strokeOpacity={STROKE_OPACITY}
strokeWidth={STROKE_WIDTH}
isAnimationActive={false}
dot={false}
/>
</LineChart>
)}
</ResponsiveContainer>
);
}
88 changes: 88 additions & 0 deletions packages/app/src/components/__tests__/Sparkline.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import React from 'react';

import { Sparkline, type SparklinePoint } from '@/components/Sparkline';

// recharts `ResponsiveContainer` sizes itself from its parent via a
// ResizeObserver, which is a no-op in jsdom (see setupTests), so it never
// reports a size and the chart never paints. Swap it for a fixed-size
// pass-through so the SVG renders and the variant can be asserted, and record
// the requested height so its forwarding can be asserted. The `mock` prefix
// lets the hoisted jest.mock factory reference it.
const mockResponsiveContainerHeights: Array<number | string | undefined> = [];

jest.mock('recharts', () => {
const actual = jest.requireActual<typeof import('recharts')>('recharts');
const { cloneElement } = jest.requireActual<typeof import('react')>('react');
return {
...actual,
ResponsiveContainer: ({
children,
height,
}: {
children: React.ReactElement<{ width?: number; height?: number }>;
height?: number | string;
}) => {
mockResponsiveContainerHeights.push(height);
return cloneElement(children, { width: 300, height: 80 });
},
};
});

const COLOR = '#abcdef';

const POINTS: SparklinePoint[] = [
{ x: 1, y: 3 },
{ x: 2, y: 7 },
{ x: 3, y: 5 },
];

describe('Sparkline', () => {
beforeEach(() => {
mockResponsiveContainerHeights.length = 0;
});

it('renders a line trend in the given color', () => {
const { container } = renderWithMantine(
<Sparkline points={POINTS} type="line" color={COLOR} />,
);
expect(container.querySelector('.recharts-line')).toBeInTheDocument();
expect(container.innerHTML).toContain(COLOR);
});

it('renders an area trend in the given color', () => {
const { container } = renderWithMantine(
<Sparkline points={POINTS} type="area" color={COLOR} />,
);
expect(container.querySelector('.recharts-area')).toBeInTheDocument();
expect(container.innerHTML).toContain(COLOR);
});

it('renders a bar trend in the given color', () => {
const { container } = renderWithMantine(
<Sparkline points={POINTS} type="bar" color={COLOR} />,
);
expect(container.querySelector('.recharts-bar')).toBeInTheDocument();
expect(container.innerHTML).toContain(COLOR);
});

it('renders nothing for fewer than two points', () => {
const { container } = renderWithMantine(
<Sparkline points={[{ x: 1, y: 3 }]} type="line" color={COLOR} />,
);
const surface = container.querySelector('.recharts-surface');
expect(surface).not.toBeInTheDocument();
});

it('fills its parent height by default', () => {
renderWithMantine(<Sparkline points={POINTS} type="line" color={COLOR} />);
expect(mockResponsiveContainerHeights).toContain('100%');
});

it('forwards an explicit numeric height (table-cell usage)', () => {
const { container } = renderWithMantine(
<Sparkline points={POINTS} type="line" color={COLOR} height={24} />,
);
expect(container.querySelector('.recharts-line')).toBeInTheDocument();
expect(mockResponsiveContainerHeights).toContain(24);
});
});
Loading