138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { AreaChart } from "@mantine/charts";
|
|
import { Box, Group, Paper, Stack, Text } from "@mantine/core";
|
|
|
|
import type { ThroughputPoint } from "./throughput-history";
|
|
|
|
/**
|
|
* Series colours.
|
|
*
|
|
* Not copied from the `systemResources` network chart, which pairs `orange.5`
|
|
* with `yellow.5`: that pair scores a normal-vision ΔE of 12.1, below the
|
|
* legibility floor of 15, so even readers with full colour vision struggle to
|
|
* tell the two lines apart. This pair was validated instead, and passes every
|
|
* check — lightness band, chroma, colour-vision-deficiency separation,
|
|
* normal-vision separation and surface contrast — against BOTH the light and
|
|
* the dark chart surface, so one pair serves both themes.
|
|
*/
|
|
const DOWN_COLOR = "blue.7";
|
|
const UP_COLOR = "orange.8";
|
|
|
|
/**
|
|
* A floor, not a height: the chart now takes the height the card has left over.
|
|
* `ResponsiveContainer` measures its parent and renders nothing at all when that
|
|
* parent collapses to zero, so the wrapper keeps a small non-zero minimum — on a
|
|
* very short tile the area stays legible instead of disappearing.
|
|
*/
|
|
const CHART_MIN_HEIGHT = 32;
|
|
|
|
/**
|
|
* A dot carries the series identity so the figure beside it can stay in muted
|
|
* ink: colour is never the only thing distinguishing up from down — the arrow
|
|
* glyph and the label do it too.
|
|
*/
|
|
const SeriesKey = ({ color, label }: { color: string; label: string }) => (
|
|
<Group gap={4} wrap="nowrap">
|
|
<Text c={color} size="xs" lh={1} aria-hidden>
|
|
●
|
|
</Text>
|
|
<Text c="dimmed" size="xs" ff="monospace" lh={1}>
|
|
{label}
|
|
</Text>
|
|
</Group>
|
|
);
|
|
|
|
/**
|
|
* Defined at module level rather than inline in the `content` prop: recharts
|
|
* clones this element and injects `payload`, so it keeps a stable identity
|
|
* across renders. The repo's other chart tooltips pass a closure instead and
|
|
* each carries a `no-unstable-nested-components` warning for it.
|
|
*/
|
|
const ThroughputTooltip = ({
|
|
payload,
|
|
formatRate,
|
|
upName,
|
|
downName,
|
|
}: {
|
|
payload?: { name?: string | number; value?: string | number }[];
|
|
formatRate: (bitsPerSecond: number) => string;
|
|
upName: string;
|
|
downName: string;
|
|
}) => {
|
|
if (!payload || payload.length === 0) return null;
|
|
|
|
return (
|
|
<Paper px="xs" py={4} shadow="md" withBorder>
|
|
<Stack gap={2}>
|
|
{payload.map((item) => (
|
|
<Text key={String(item.name)} size="xs" c="dimmed" ff="monospace">
|
|
{item.name === "up" ? `↑ ${upName}` : `↓ ${downName}`} {formatRate(Number(item.value))}
|
|
</Text>
|
|
))}
|
|
</Stack>
|
|
</Paper>
|
|
);
|
|
};
|
|
|
|
export interface ThroughputLegendProps {
|
|
/** Rendered beside the up marker, already formatted. */
|
|
upLabel: string;
|
|
/** Rendered beside the down marker, already formatted. */
|
|
downLabel: string;
|
|
}
|
|
|
|
/**
|
|
* Sits on the card's "Internet" header row rather than above the graph, so the
|
|
* figures read as a heading and detach from the area they summarise. Exported
|
|
* from here instead of being rewritten there: the dots take the very constants
|
|
* the chart's series use, so the two can never drift apart.
|
|
*/
|
|
export const ThroughputLegend = ({ upLabel, downLabel }: ThroughputLegendProps) => (
|
|
<Group gap="sm" wrap="nowrap" justify="flex-end">
|
|
<SeriesKey color={DOWN_COLOR} label={`↓ ${downLabel}`} />
|
|
<SeriesKey color={UP_COLOR} label={`↑ ${upLabel}`} />
|
|
</Group>
|
|
);
|
|
|
|
export interface ThroughputChartProps {
|
|
history: ThroughputPoint[];
|
|
/** Formats a raw bits-per-second value for the tooltip. */
|
|
formatRate: (bitsPerSecond: number) => string;
|
|
upName: string;
|
|
downName: string;
|
|
}
|
|
|
|
/**
|
|
* The wrapper is the flex child that claims the card's leftover height, and the
|
|
* chart fills it with `h="100%"`. Mantine turns that into an inline
|
|
* `height: 100%` on the chart root, which recharts' `ResponsiveContainer` then
|
|
* measures; a percentage resolves here because flex layout gives this wrapper a
|
|
* definite used height, the card above it being a flex item of a `h="100%"`
|
|
* stack inside a tile of fixed height.
|
|
*/
|
|
export const ThroughputChart = ({ history, formatRate, upName, downName }: ThroughputChartProps) => (
|
|
<Box style={{ flex: 1, minHeight: CHART_MIN_HEIGHT }}>
|
|
<AreaChart
|
|
h="100%"
|
|
data={history}
|
|
dataKey="index"
|
|
series={[
|
|
{ name: "down", label: downName, color: DOWN_COLOR },
|
|
{ name: "up", label: upName, color: UP_COLOR },
|
|
]}
|
|
curveType="monotone"
|
|
strokeWidth={2}
|
|
withDots={false}
|
|
withXAxis={false}
|
|
withYAxis={false}
|
|
gridAxis="none"
|
|
withLegend={false}
|
|
tooltipAnimationDuration={150}
|
|
tooltipProps={{
|
|
content: <ThroughputTooltip formatRate={formatRate} upName={upName} downName={downName} />,
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|