Home: one sparkline style with a left fade, SI values, a full-width activity table, roomier panels
The trend curve was two components: a rich one in the node panel and on edges,
and a bare line in the flow table. It is one `Sparkline` now, taking the colour
token, the height and whether the live dot and the readout show. The flow table
draws its rollup in the chart ramp with no dot — the rollups are polled, so the
right edge is the last completed slice rather than this instant — and keeps its
"nothing yet" state, as the panel keeps its three distinct silences.
All of them fade out to the left, through an SVG mask over the curve and its
area. The dot sits outside the mask: the newest reading is the one thing that
must stay solid.
`si` replaces `compact` and the ad-hoc "k" the uPlot axis carried. It prefixes
k/M/G and m/µ, but only outside 0.01–1000, where the plain number is already
the shortest thing to read and a written unit ("0.4 ms") stays honest. The
sparkline readout asks for four digits, so two neighbouring readings never
collapse into one string. Exact counts and anything the user acts on — a
payload, a form field, the edge inspector's value — are left unrounded.
`src/lib/utils.check.ts` asserts the rounding cases.
The activity table now spans its card: the flow name anchors the left, the four
numbers read down their own centre, and the trend takes the slack on the right.
Panel rhythm steps up one notch, gap-5 to gap-6 outside and gap-2 to gap-3
within a section.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
import { useId } from "react"
|
||||
|
||||
import type { HistoryPoint } from "@/client"
|
||||
import { cn, si } from "@/lib/utils"
|
||||
|
||||
/** Room above and below the curve for the stroke, in viewBox units. */
|
||||
const PAD = 8
|
||||
|
||||
/** Above this ratio a linear series is all baseline and one spike. */
|
||||
const LOG_RATIO = 100
|
||||
|
||||
/** How far the left end fades over, in viewBox units. */
|
||||
const FADE = 40
|
||||
|
||||
/** Enough digits to tell two neighbouring readings apart. */
|
||||
const READOUT_DIGITS = 4
|
||||
|
||||
/**
|
||||
* Curve and area for a series, in a 0–100 box.
|
||||
*
|
||||
* The awkward series are the point: one reading has no line to draw, a series
|
||||
* that never moved has no span to divide by, and one spanning decades is only
|
||||
* legible once the exponent is what varies.
|
||||
*/
|
||||
function shape(points: HistoryPoint[]) {
|
||||
const values = points.map((point) => point.value)
|
||||
const low = Math.min(...values)
|
||||
const high = Math.max(...values)
|
||||
// Logs need every reading on the same side of zero.
|
||||
const logged = low > 0 && high / low >= LOG_RATIO
|
||||
const project = (value: number) => (logged ? Math.log10(value) : value)
|
||||
const floor = project(low)
|
||||
const span = project(high) - floor
|
||||
|
||||
const y = (value: number) =>
|
||||
span === 0
|
||||
? 50
|
||||
: 100 - PAD - ((project(value) - floor) / span) * (100 - 2 * PAD)
|
||||
const x = (index: number) =>
|
||||
points.length === 1 ? 100 : (index / (points.length - 1)) * 100
|
||||
|
||||
const line = points
|
||||
.map(
|
||||
(point, index) =>
|
||||
`${index ? "L" : "M"}${x(index).toFixed(2)},${y(point.value).toFixed(2)}`,
|
||||
)
|
||||
.join(" ")
|
||||
|
||||
return {
|
||||
low,
|
||||
high,
|
||||
line,
|
||||
area: `${line} L100,100 L0,100 Z`,
|
||||
end: y(values[values.length - 1]),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a series has been moving — the one trend curve, drawn the same way in
|
||||
* the node panel, on an edge and in the flow table.
|
||||
*
|
||||
* The left end fades out, so the oldest readings read as the tail they are
|
||||
* rather than as an edge the series was cut at. The fade masks the curve and
|
||||
* its area together; the live dot sits outside the mask, since the newest
|
||||
* reading is the one thing that must stay solid.
|
||||
*
|
||||
* Needs at least one point: an empty series has no story, and what to say
|
||||
* instead is the caller's to decide.
|
||||
*/
|
||||
export function Sparkline({
|
||||
points,
|
||||
color = "var(--primary)",
|
||||
height = "h-8",
|
||||
dot = true,
|
||||
readout = true,
|
||||
}: {
|
||||
points: HistoryPoint[]
|
||||
/** The token the curve, its area and the dot are drawn in. */
|
||||
color?: string
|
||||
/** Tailwind height of the curve's box. */
|
||||
height?: string
|
||||
/** A dot on the newest reading; only honest where the series is live. */
|
||||
dot?: boolean
|
||||
/** The current value and the range, beside the curve. */
|
||||
readout?: boolean
|
||||
}) {
|
||||
const id = useId()
|
||||
const { low, high, line, area, end } = shape(points)
|
||||
|
||||
return (
|
||||
// The gap leaves the live dot room to sit on the last reading without
|
||||
// touching the labels.
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("relative min-w-0 flex-1", height)}>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="h-full w-full overflow-visible"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.28" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
{/* A mask reads luminance, so white here is "keep this", not a
|
||||
colour the design system has anything to say about. */}
|
||||
<linearGradient
|
||||
id={`${id}-ramp`}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2={FADE}
|
||||
y2="0"
|
||||
>
|
||||
<stop offset="0%" stopColor="white" stopOpacity="0" />
|
||||
<stop offset="100%" stopColor="white" stopOpacity="1" />
|
||||
</linearGradient>
|
||||
{/* Wider than the box, so a round cap or a stroke leaning past the
|
||||
viewBox is faded rather than cut. */}
|
||||
<mask
|
||||
id={`${id}-fade`}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="-10"
|
||||
y="-10"
|
||||
width="120"
|
||||
height="120"
|
||||
>
|
||||
<rect
|
||||
x="-10"
|
||||
y="-10"
|
||||
width="120"
|
||||
height="120"
|
||||
fill={`url(#${id}-ramp)`}
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
{points.length > 1 ? (
|
||||
<g mask={`url(#${id}-fade)`}>
|
||||
<path d={area} fill={`url(#${id})`} />
|
||||
<path
|
||||
d={line}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
// Keeps the stroke even, though the box is far wider than tall.
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</g>
|
||||
) : null}
|
||||
</svg>
|
||||
{/* The newest reading is always the right edge, so the dot only needs to
|
||||
know how high it sits. */}
|
||||
{dot ? (
|
||||
<>
|
||||
<span
|
||||
className="pointer-events-none absolute size-3 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
style={{
|
||||
left: "100%",
|
||||
top: `${end}%`,
|
||||
background: color,
|
||||
opacity: 0.2,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
||||
style={{ left: "100%", top: `${end}%`, background: color }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{/* A shared minimum width, so the curves all end on the same line. */}
|
||||
{readout ? (
|
||||
<div className="min-w-24 shrink-0 whitespace-nowrap text-right font-mono text-xs leading-tight">
|
||||
<div className="font-medium">
|
||||
{si(points[points.length - 1].value, READOUT_DIGITS)}
|
||||
</div>
|
||||
{/* A series that never moved has no range worth repeating. */}
|
||||
{low === high ? null : (
|
||||
<div className="text-muted-foreground">
|
||||
{si(low, READOUT_DIGITS)}–{si(high, READOUT_DIGITS)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import "uplot/dist/uPlot.min.css"
|
||||
|
||||
import type { HistoryPoint } from "@/client"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { compact } from "@/lib/utils"
|
||||
import { si } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* How many lines one chart carries.
|
||||
@@ -29,17 +29,6 @@ function token(name: string): string {
|
||||
|
||||
const seriesColor = (index: number) => token(`--chart-${(index % 5) + 1}`)
|
||||
|
||||
/**
|
||||
* An axis tick, kept short.
|
||||
*
|
||||
* The gutter the ticks are drawn in has a fixed width, so a grouped "15,000"
|
||||
* is clipped to something that reads as a different number entirely.
|
||||
*/
|
||||
const tick = (value: number) =>
|
||||
Math.abs(value) >= 1000
|
||||
? `${+(value / 1000).toPrecision(3)}k`
|
||||
: compact(value)
|
||||
|
||||
/** The series joined onto one x axis, which is what uPlot draws. */
|
||||
function table(plots: HistoryPoint[][]): uPlot.AlignedData {
|
||||
return uPlot.join(
|
||||
@@ -156,7 +145,10 @@ export function UplotChart({
|
||||
{
|
||||
...axis,
|
||||
size: 46,
|
||||
values: (_self: uPlot, ticks: number[]) => ticks.map(tick),
|
||||
// The gutter has a fixed width, so a grouped "15,000" would be
|
||||
// clipped to something that reads as a different number entirely.
|
||||
values: (_self: uPlot, ticks: number[]) =>
|
||||
ticks.map((value) => si(value)),
|
||||
},
|
||||
],
|
||||
series: [
|
||||
@@ -168,9 +160,8 @@ export function UplotChart({
|
||||
// rebuilt chart.
|
||||
stroke: () => seriesColor(index),
|
||||
// The cursor readout is what decides how wide the legend gets, so
|
||||
// it is rounded here and the unit named in the card's title.
|
||||
value: (_self: uPlot, raw: number) =>
|
||||
Number.isFinite(raw) ? compact(raw) : "--",
|
||||
// it is shortened here and the unit named in the card's title.
|
||||
value: (_self: uPlot, raw: number) => si(raw),
|
||||
// Series arrive on their own clocks; a joined table is mostly
|
||||
// holes, and a line with a hole per point is not a line.
|
||||
spanGaps: true,
|
||||
|
||||
Reference in New Issue
Block a user