diff --git a/app/components/ChartTooltip.tsx b/app/components/ChartTooltip.tsx
new file mode 100644
index 000000000..e11ac0d58
--- /dev/null
+++ b/app/components/ChartTooltip.tsx
@@ -0,0 +1,66 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { format } from 'date-fns'
+import type { ReactNode } from 'react'
+import { match } from 'ts-pattern'
+
+const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss zz')
+
+type ChartTooltipProps = {
+ timestamp: number
+ left: number
+ top: number
+ offset: [LeftRight, TopBottom]
+ children: ReactNode
+}
+
+/** Offset the box into the quadrant away from the point so it never overflows an edge */
+export type LeftRight = 'left' | 'right'
+export type TopBottom = 'top' | 'bottom'
+
+const TOOLTIP_GAP = 12
+function tooltipTransform(leftRight: LeftRight, topBottom: TopBottom): string {
+ const tx = match(leftRight)
+ .with('left', () => `calc(-100% - ${TOOLTIP_GAP}px)`)
+ .with('right', () => `${TOOLTIP_GAP}px`)
+ .exhaustive()
+ const ty = match(topBottom)
+ .with('top', () => `calc(-100% - ${TOOLTIP_GAP}px)`)
+ .with('bottom', () => `${TOOLTIP_GAP}px`)
+ .exhaustive()
+ return `translate(${tx}, ${ty})`
+}
+
+export function ChartTooltip({
+ timestamp,
+ left,
+ top,
+ offset,
+ children,
+}: ChartTooltipProps) {
+ return (
+
+
+
+ {longDateTime(timestamp)}
+
+
{children}
+
+
+ )
+}
diff --git a/app/components/FramedChart.tsx b/app/components/FramedChart.tsx
new file mode 100644
index 000000000..f62105488
--- /dev/null
+++ b/app/components/FramedChart.tsx
@@ -0,0 +1,78 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * Copyright Oxide Computer Company
+ */
+import { useMemo, type ReactNode, type RefObject } from 'react'
+import type uPlot from 'uplot'
+import UplotReact from 'uplot-react'
+
+import { useElementSize } from '~/hooks/use-element-size'
+
+// The intended left padding (px-5) is taken from the container and given to
+// uPlot instead, so the plot sits flush left while x-tick labels can bleed into
+// the gutter without clipping.
+const CHART_LEFT_PAD = 20
+
+export type UPlotOptions = Omit
+
+type Props = {
+ title: string
+ height: number
+ chartOptions: UPlotOptions
+ data: uPlot.AlignedData
+ uRef: RefObject
+ children?: ReactNode
+ legend?: ReactNode
+}
+
+export function FramedChart({
+ title,
+ height,
+ chartOptions,
+ data,
+ uRef,
+ children,
+ legend,
+}: Props) {
+ const [size, sizeRef] = useElementSize()
+
+ // Width/height changes cause a cheaper "update" path for uplot, instead of
+ // "create", so it gets its own layer of memoization
+ const options = useMemo(
+ () =>
+ ({
+ ...chartOptions,
+ padding: [null, null, null, CHART_LEFT_PAD],
+ width: size?.width ?? 0,
+ height,
+ }) satisfies uPlot.Options,
+ [chartOptions, size?.width, height]
+ )
+
+ return (
+
+ {/* The actual chart is absolutely positioned so its fixed pixel width
+ doesn't influence layout and block future resizing. That in turn makes
+ its container need an explicit height */}
+
+ )
+}
diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx
index d1da5ddcb..b97ecbdd6 100644
--- a/app/components/TimeSeriesChart.tsx
+++ b/app/components/TimeSeriesChart.tsx
@@ -6,153 +6,28 @@
* Copyright Oxide Computer Company
*/
import cn from 'classnames'
-import { format } from 'date-fns'
-import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
+import { useMemo, useState, type ReactNode } from 'react'
import * as R from 'remeda'
import { match } from 'ts-pattern'
import uPlot from 'uplot'
-import UplotReact from 'uplot-react'
import type { ChartDatum } from '@oxide/api'
import { Error12Icon } from '@oxide/design-system/icons/react'
-import { useElementSize } from '~/hooks/use-element-size'
-import { subscribeToTheme } from '~/stores/theme'
+import { ChartTooltip, type LeftRight, type TopBottom } from '~/components/ChartTooltip'
+import { FramedChart, type UPlotOptions } from '~/components/FramedChart'
+import {
+ type ChartTheme,
+ seriesColor,
+ timeFormatterForRange,
+ useChartTheme,
+ useLiveAxisFormatter,
+ xTimeAxis,
+ yValueAxis,
+} from '~/util/charts'
import { classed } from '~/util/classed'
-/**
- * Check if the start and end time are on the same day
- * If they are we can omit the day/month in the date time format
- */
-function isSameDay(d1: Date, d2: Date) {
- return (
- d1.getFullYear() === d2.getFullYear() &&
- d1.getMonth() === d2.getMonth() &&
- d1.getDate() === d2.getDate()
- )
-}
-
-const shortDateTime = (ts: number) => {
- const date = new Date(ts)
- return format(
- date,
- date.getHours() === 0 && date.getMinutes() === 0 ? 'M/d' : 'M/d HH:mm'
- )
-}
-const shortTime = (ts: number) => format(new Date(ts), 'HH:mm')
-const longDateTime = (ts: number) => format(new Date(ts), 'MMM d, yyyy HH:mm:ss zz')
-
-const remToPx = (rem: number) =>
- rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
-// We measure axis label widths on a detached canvas instead of uPlot's to avoid overwriting its
-// own font setting.
-const measureCtx = document.createElement('canvas').getContext('2d')
-const measureTextWidth = (text: string, font: string) => {
- // getContext('2d') is only null if '2d' is unsupported, which, hey, you're not getting a graph
- if (!measureCtx) return 0
- measureCtx.font = font
- return measureCtx.measureText(text).width
-}
-
-const AXIS_FONT_REM_XS = 0.6875
-const AXIS_TICK_LENGTH = 6
-const AXIS_TICK_GAP = 8
-// Left padding (px-5) is taken from the container and given to uPlot instead, so the plot sits
-// flush left while x-tick labels can bleed into the gutter without clipping.
-const CHART_LEFT_PAD = 20
const CHART_HEIGHT = 300
-const TOOLTIP_GAP = 12
-
-type ChartTheme = {
- fontFamily: string
- stroke: string
- fill: string
- hoverPoint: string
- axisLine: string
- axisText: string
- lineColors: string[]
-}
-
-// Append an alpha channel to a resolved color, e.g. `oklch(l c h)` -> `oklch(l c h / 0.6)`. Assumes
-// our colors are set in oklch!
-const withAlpha = (color: string, alpha: number) => color.replace(/\)\s*$/, ` / ${alpha})`)
-
-// uPlot draws to a canvas, so it can't consume CSS custom properties directly. We subscribe to the
-// theme instead.
-function getChartTheme(): ChartTheme {
- const style = getComputedStyle(document.body)
- const v = (name: string) => style.getPropertyValue(name)
- return {
- fontFamily: v('--font-mono'),
- stroke: v('--stroke-accent-secondary'),
- fill: withAlpha(v('--surface-accent-secondary'), 0.6),
- hoverPoint: v('--content-accent'),
- axisLine: v('--stroke-secondary'),
- axisText: v('--content-quaternary'),
- lineColors: [
- '--color-green-800',
- '--color-blue-800',
- '--color-purple-800',
- '--color-yellow-800',
- '--color-red-800',
- ].map(v),
- }
-}
-
-const seriesColor = (i: number, theme: ChartTheme): string =>
- theme.lineColors[i] ||
- `oklch(0.77 0.175 ${((163.7 + (i - theme.lineColors.length) * 137.508) % 360).toFixed(1)})`
-
-function useChartTheme(): ChartTheme {
- const [colors, setColors] = useState(getChartTheme)
- useEffect(() => subscribeToTheme(() => setColors(getChartTheme())), [])
- return colors
-}
-
-/** Offset the box into the quadrant away from the point so it never overflows an edge */
-type LeftRight = 'left' | 'right'
-type TopBottom = 'top' | 'bottom'
-function tooltipTransform(leftRight: LeftRight, topBottom: TopBottom): string {
- const tx = match(leftRight)
- .with('left', () => `calc(-100% - ${TOOLTIP_GAP}px)`)
- .with('right', () => `${TOOLTIP_GAP}px`)
- .exhaustive()
- const ty = match(topBottom)
- .with('top', () => `calc(-100% - ${TOOLTIP_GAP}px)`)
- .with('bottom', () => `${TOOLTIP_GAP}px`)
- .exhaustive()
- return `translate(${tx}, ${ty})`
-}
-
-function ChartTooltip({
- timestamp,
- value,
- seriesName,
- unit,
-}: {
- timestamp: number
- value: number
- seriesName: string
- unit?: string
-}) {
- return (
-
-
- {longDateTime(timestamp)}
-
-
-
{seriesName}
-
- {value.toLocaleString()}
- {unit && {unit}}
-
-
-
- )
-}
type TimeSeriesChartProps = {
timestamps: number[] | undefined
@@ -169,7 +44,7 @@ type TimeSeriesChartProps = {
}
// this top margin is also in the chart, probably want a way of unifying the sizing between the two
-const SkeletonMetric = ({
+export const SkeletonMetric = ({
children,
shimmer = false,
className,
@@ -234,12 +109,8 @@ export function TimeSeriesChart({
seriesLabels,
}: TimeSeriesChartProps) {
const theme = useChartTheme()
- const fontPx = remToPx(AXIS_FONT_REM_XS)
- const axisFont = `${fontPx}px ${theme.fontFamily}`
-
- const [size, sizeRef] = useElementSize()
- const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime
+ const formatTime = timeFormatterForRange(startTime, endTime)
const dataLength = data?.length ?? 0
@@ -304,20 +175,7 @@ export function TimeSeriesChart({
[]
)
- const uRef = useRef(null)
- const yAxisTickFormatterRef = useRef<(val: number) => string>(yAxisTickFormatter)
- yAxisTickFormatterRef.current = yAxisTickFormatter
- useEffect(() => {
- uRef.current?.redraw(
- // Setting the `rebuildPaths` argument to true causes uPlot to reapply the _current_ x bounds,
- // which in the right conditions (e.g., initial render) can leave the chart blank. We only
- // need the axes recalculated anyways!
- //
- // See https://github.com/leeoniya/uPlot/issues/1099
- false, // rebuildPaths
- true // recalcAxes
- )
- }, [yAxisTickFormatter])
+ const { uRef, formatterRef } = useLiveAxisFormatter(yAxisTickFormatter)
// uplot-react rebuilds the whole chart (they call this the "create" path) when any top-level
// option (other than width or height) changes by reference.
@@ -344,47 +202,14 @@ export function TimeSeriesChart({
})),
],
axes: [
- {
- stroke: theme.axisText,
- font: axisFont,
- space: (_u, _axisIdx, _min, _max, plotDim) => plotDim / 5,
- values: (_u, times) => times.map((t) => formatTime(t * 1000)),
- border: { show: true, stroke: theme.axisLine, width: 1 },
- gap: AXIS_TICK_GAP,
- grid: { show: false },
- size: fontPx + AXIS_TICK_GAP + AXIS_TICK_LENGTH,
- ticks: {
- show: true,
- stroke: theme.axisLine,
- width: 1,
- size: AXIS_TICK_LENGTH,
- },
- },
- {
- stroke: theme.axisText,
- font: axisFont,
- side: 1,
- border: { show: true, stroke: theme.axisLine, width: 1 },
- gap: AXIS_TICK_GAP,
- ticks: {
- show: true,
- stroke: theme.axisLine,
- width: 1,
- size: AXIS_TICK_LENGTH,
- filter: (_u, yValues) => yValues.map((v) => (v === 0 ? null : v)),
- },
- values: (_u, yValues) =>
- yValues.map((v) => (v === 0 ? '' : yAxisTickFormatterRef.current(v))),
+ xTimeAxis({ theme, formatTime }),
+ yValueAxis({
+ theme,
grid: { show: true, stroke: theme.axisLine, width: 1 },
- size: (_self, values) => {
- const axisBase = AXIS_TICK_LENGTH + AXIS_TICK_GAP
- // given the monospace font, longest by char count is longest by rendered width
- const longestVal = R.firstBy(values ?? [], (s) => -s.length) || ''
- return axisBase + measureTextWidth(longestVal, axisFont)
- },
- },
+ values: (_u, yValues) =>
+ yValues.map((v) => (v === 0 ? '' : formatterRef.current(v))),
+ }),
],
- padding: [null, null, null, CHART_LEFT_PAD],
focus: { alpha: 0.5 },
cursor: {
// setting this property causes non-focused series to dim on hover.
@@ -402,20 +227,8 @@ export function TimeSeriesChart({
},
legend: { show: false },
plugins: [tooltipPlugin],
- }) satisfies Omit,
- [dataLength, formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx]
- )
-
- // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets
- // its own layer of memo
- const options = useMemo(
- () =>
- ({
- ...chartOptions,
- width: size?.width ?? 0,
- height: CHART_HEIGHT,
- }) satisfies uPlot.Options,
- [chartOptions, size?.width]
+ }) satisfies UPlotOptions,
+ [dataLength, formatTime, tooltipPlugin, interpolation, theme, formatterRef]
)
const aligned = useMemo(() => {
@@ -464,55 +277,42 @@ export function TimeSeriesChart({
: undefined
return (
-
- {/* The chart is absolutely positioned so its fixed pixel width doesn't feed back into the
- container's min-content width — otherwise the chart props the container open and it can
- grow but never shrink. The wrapper needs an explicit height because the absolute child
- contributes none, so it gets the same fixed height passed to uPlot. */}
-
- {/* Wait for the container measurement rather than creating a zero-width chart and
- immediately resizing it. The chart may appear a frame after the container, but the
- zero-width version had the same gap: an invisible chart until the measurement
- arrived through the same ResizeObserver → setState path. */}
- {size && (
- (uRef.current = u)}
+
- )}
- {tooltip && hovered && (
-