From 33015823183bc194bc94e487b83c7b48810a641e Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 16 Jul 2026 12:25:40 -0700 Subject: [PATCH 1/7] Add a couple tooltip tests Getting these in puts us in a spot where we can pretty easily see the differences between uplot and recharts. --- app/components/TimeSeriesChart.tsx | 9 ++++-- flake.lock | 19 +++++++++++- flake.nix | 43 +++++++++++++++++++++------- mock-api/instance.ts | 28 ++++++++++++++++++ mock-api/msw/util.ts | 28 +++++++++++++++++- test/e2e/combobox.e2e.ts | 2 ++ test/e2e/instance-metrics.e2e.ts | 46 +++++++++++++++++++++++++++++- 7 files changed, 158 insertions(+), 17 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 5cf3dad15..f54bb3c00 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -92,7 +92,10 @@ function renderTooltip(props: TooltipProps, unit?: string) { } = payload[0] if (!timestamp || typeof value !== 'number') return null return ( -
+
{longDateTime(timestamp)}
@@ -233,7 +236,7 @@ export function TimeSeriesChart({ // ResponsiveContainer has default height and width of 100% // https://recharts.org/en-US/api/ResponsiveContainer return ( -
+
@@ -285,7 +288,7 @@ export function TimeSeriesChart({ /> -
+ ) } diff --git a/flake.lock b/flake.lock index 601bb5158..afa4c2b96 100644 --- a/flake.lock +++ b/flake.lock @@ -34,10 +34,27 @@ "type": "github" } }, + "nixpkgs-playwright": { + "locked": { + "lastModified": 1784120854, + "narHash": "sha256-KesHgItiZPgGX740axSiQLcIQ8D24MDqNpkKYWIek8k=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "753cc8a3a87467296ddd1fa93f0cc3e81120ee46", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nixpkgs-playwright": "nixpkgs-playwright" } }, "systems": { diff --git a/flake.nix b/flake.nix index a39dd3a38..9aff164ed 100644 --- a/flake.nix +++ b/flake.nix @@ -1,25 +1,46 @@ { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05"; + nixpkgs-playwright.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, nixpkgs-playwright, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let - pkgs = import nixpkgs { - inherit system; - }; + pkgs = nixpkgs.legacyPackages.${system}; + inherit (pkgs) lib; + + playwrightDriver = nixpkgs-playwright.legacyPackages.${system}.playwright-driver; + + # The @playwright/test dependency in package.json expects you to run `playwright install`, + # which installs binaries that won't run on nix. We install from playwright-driver instead; + # as long as the major.minor version matches, we'll have compatible browsers. + npmPlaywrightVersion = + (lib.importJSON ./package-lock.json).packages."node_modules/@playwright/test".version; in { - devShells.default = pkgs.mkShell { - nativeBuildInputs = with pkgs; [ - nodejs_22 - ]; - shellHook = '' - echo "Node $(node --version)" + devShells.default = + assert lib.assertMsg + (lib.versions.majorMinor npmPlaywrightVersion == lib.versions.majorMinor playwrightDriver.version) '' + Playwright version mismatch: package.json @playwright/test is ${npmPlaywrightVersion} + but the nixpkgs-playwright input's playwright-driver is ${playwrightDriver.version}. + Repin nixpkgs-playwright or upgrade (don't downgrade!) @playwright/test so they share a + major.minor for browser compatibility. ''; - }; + pkgs.mkShell { + packages = [ + pkgs.nodejs_22 + ]; + env = { + PLAYWRIGHT_BROWSERS_PATH = "${playwrightDriver.browsers}"; + # https://wiki.nixos.org/wiki/Playwright thinks you need this, but i haven't found it necessary + # PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = "true"; + }; + shellHook = '' + echo "Node $(node --version)" + ''; + }; } ); } diff --git a/mock-api/instance.ts b/mock-api/instance.ts index 60d6c3715..76f904a71 100644 --- a/mock-api/instance.ts +++ b/mock-api/instance.ts @@ -157,6 +157,32 @@ export const instanceDb3: Json = { run_state: 'running', } +// Flat, constant series. A tooltip hover reads back a known value regardless of +// cursor position. +export const SENTINEL_FLAT_INSTANCE_ID = 'f0968b0d-6f4a-49e8-8d96-a58dc2c93993' +export const sentinelFlatInstance: Json = { + ...base, + id: SENTINEL_FLAT_INSTANCE_ID, + name: 'sentinel-metrics-flat', + description: 'returns constant metric data for tooltip tests', + hostname: 'oxide.com', + project_id: project.id, + run_state: 'running', +} + +// Series that increases linearly with time. Lets you do slightly more thorough +// graph testing. +export const SENTINEL_SLOPE_INSTANCE_ID = 'c7d3b8a5-71f7-4588-bce8-38c9f1f85f2f' +export const sentinelSlopeInstance: Json = { + ...base, + id: SENTINEL_SLOPE_INSTANCE_ID, + name: 'sentinel-metrics-slope', + description: 'returns linearly increasing metric data for axis tests', + hostname: 'oxide.com', + project_id: project.id, + run_state: 'running', +} + export const instances: Json[] = [ instance, failedInstance, @@ -168,4 +194,6 @@ export const instances: Json[] = [ instanceDb2, stoppedInstance, instanceDb3, + sentinelFlatInstance, + sentinelSlopeInstance, ] diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index d51267a9b..b213c7dc8 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -39,6 +39,7 @@ import { parseIp } from '~/util/ip' import { GiB, TiB } from '~/util/units' import type { DbRoleAssignmentResourceType } from '..' +import { SENTINEL_FLAT_INSTANCE_ID, SENTINEL_SLOPE_INSTANCE_ID } from '../instance' import { genI64Data } from '../metrics' import { getMockOxqlInstanceData } from '../oxql-metrics' import { db, lookupById } from './db' @@ -581,10 +582,35 @@ const getCpuStateFromQuery = (query: string): OxqlVcpuState | undefined => { return match ? (match[1] as OxqlVcpuState) : undefined } +// Pull the instance UUID out of the `instance_id == "..."` filter (also matches +// the `attached_instance_id` used by disk metrics). +const getInstanceIdFromQuery = (query: string): string | undefined => + query.match(/(?:attached_)?instance_id\s*==\s*"([^"]+)"/)?.[1] + +// getUtilizationChartProps renders raw values on screen as value * 100 / (5s * +// 1e9 * 1 series); invertUtilization goes the other way — from a target percent +// to the raw value that produces it. +const invertUtilization = (percent: number): number => (percent * 5 * 1e9) / 100 +const SENTINEL_CONSTANT_RAW_VALUE = invertUtilization(12345) // 12,345% +const sentinelSlopeRawValue = (i: number) => invertUtilization((i + 1) * 1000) // (i + 1) * 1000% + export function handleOxqlMetrics({ query }: TimeseriesQuery): Json { const metricName = getMetricNameFromQuery(query) as OxqlNetworkMetricName const stateValue = getCpuStateFromQuery(query) - return getMockOxqlInstanceData(metricName, stateValue) + const data = getMockOxqlInstanceData(metricName, stateValue) + + // Sentinel instances: replace the series with synthetic data — flat (constant) + // or a slope that increases with time — so tests can assert on plotted values. + const instanceId = getInstanceIdFromQuery(query) + const points = data.tables[0].timeseries[0].points + const series = points.values[0].values.values + if (instanceId === SENTINEL_FLAT_INSTANCE_ID) { + points.values[0].values.values = series.map(() => SENTINEL_CONSTANT_RAW_VALUE) + } else if (instanceId === SENTINEL_SLOPE_INSTANCE_ID) { + points.values[0].values.values = series.map((_, i) => sentinelSlopeRawValue(i)) + } + + return data } export function randomHex(length: number) { diff --git a/test/e2e/combobox.e2e.ts b/test/e2e/combobox.e2e.ts index 45aa43b90..fe891fe7f 100644 --- a/test/e2e/combobox.e2e.ts +++ b/test/e2e/combobox.e2e.ts @@ -179,6 +179,8 @@ test('arbitrary-values combobox keeps typed values and resets submitted fields', 'db2', 'db-stopped', 'db3', + 'sentinel-metrics-flat', + 'sentinel-metrics-slope', ]) await instanceInput.fill('d') diff --git a/test/e2e/instance-metrics.e2e.ts b/test/e2e/instance-metrics.e2e.ts index 071b2531b..4e8769a0d 100644 --- a/test/e2e/instance-metrics.e2e.ts +++ b/test/e2e/instance-metrics.e2e.ts @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { expect, test } from '@playwright/test' +import { expect, test, type Locator, type Page } from '@playwright/test' import { OXQL_GROUP_BY_ERROR } from '~/api' @@ -40,6 +40,50 @@ test('Click through instance metrics', async ({ page }) => { await expect(page.getByText('Something went wrong')).toBeHidden() }) +async function readChartValueAt(page: Page, chart: Locator, fracX: number) { + const box = await chart.boundingBox() + if (!box) throw new Error('chart has no bounding box') + const x = box.x + box.width * fracX + await page.mouse.move(x, box.y) + await page.mouse.move(x, box.y + box.height * 0.25) + const tooltip = page.getByRole('tooltip') + await expect(tooltip).toBeVisible() + // within the tooltip, the value line is the only text that's just a number + unit + const value = tooltip.getByText(/^[\d,]+%$/) + return Number((await value.textContent())!.replace(/\D/g, '')) +} + +test('chart tooltip reads back the plotted value', async ({ page }) => { + // sentinel-metrics-flat returns a flat series (see handleOxqlMetrics), so a + // hover anywhere in the plot reads back the same value. + await page.goto('/projects/mock-project/instances/sentinel-metrics-flat/metrics/cpu') + + const heading = page.getByRole('heading', { name: 'CPU Utilization: Running' }) + await expect(heading).toBeVisible() + // wait for data so the chart, not the loading skeleton, is rendered + await expect(page.getByLabel('Chart loading')).toBeHidden() + + const chart = page.getByRole('figure', { name: 'CPU Utilization: Running' }) + expect(await readChartValueAt(page, chart, 0.5)).toBe(12345) +}) + +test('chart x-axis maps earlier times to the left', async ({ page }) => { + await page.goto('/projects/mock-project/instances/sentinel-metrics-slope/metrics/cpu') + + const heading = page.getByRole('heading', { name: 'CPU Utilization: Running' }) + await expect(heading).toBeVisible() + await expect(page.getByLabel('Chart loading')).toBeHidden() + + const chart = page.getByRole('figure', { name: 'CPU Utilization: Running' }) + const leftValue = await readChartValueAt(page, chart, 0.25) + const rightValue = await readChartValueAt(page, chart, 0.7) + + // sentinel-metrics-slope returns a series that increases with time (see + // handleOxqlMetrics), so a hover on the left of the plot reads a smaller value + // than one on the right. + expect(leftValue).toBeLessThan(rightValue) +}) + test('Date range picker: choosing a custom range', async ({ page }) => { await page.goto('/projects/mock-project/instances/db1/metrics/cpu') await expect( From 3c57526d97e37295e7f17361f6072ef010acacce Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Thu, 16 Jul 2026 14:29:43 -0700 Subject: [PATCH 2/7] Get bash from env This is just a touch more portable. --- tools/generate-visual-baseline.sh | 2 +- tools/generate_api_client.sh | 2 +- tools/populate_omicron_data.sh | 2 +- tools/start_api.sh | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/generate-visual-baseline.sh b/tools/generate-visual-baseline.sh index 002e76e2f..aa92310db 100755 --- a/tools/generate-visual-baseline.sh +++ b/tools/generate-visual-baseline.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # 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/. diff --git a/tools/generate_api_client.sh b/tools/generate_api_client.sh index b82610862..688d8597d 100755 --- a/tools/generate_api_client.sh +++ b/tools/generate_api_client.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # 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/. diff --git a/tools/populate_omicron_data.sh b/tools/populate_omicron_data.sh index 71abf47df..7abd27dab 100755 --- a/tools/populate_omicron_data.sh +++ b/tools/populate_omicron_data.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # 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/. diff --git a/tools/start_api.sh b/tools/start_api.sh index 5b5ff1bf3..06ef353f4 100755 --- a/tools/start_api.sh +++ b/tools/start_api.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # 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/. From 633e5e091f05f41b9f0dcaf59c199c7d8685bf27 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Fri, 17 Jul 2026 12:54:50 -0700 Subject: [PATCH 3/7] Migrate from recharts to uplot Verbose, but fairly straightforward. The only meaningful changes you should see are the lack of a top grid line (since uplot does tick calculation, this would often be quite tight with the line below) and uplot's tick selection logic replacing ours (which is for the greater good). --- app/components/TimeSeriesChart.tsx | 382 +++++++++++++--------- app/hooks/use-element-size.ts | 30 ++ app/ui/styles/index.css | 1 + package-lock.json | 491 +++-------------------------- package.json | 3 +- test/unit/setup.ts | 15 +- test/visual/regression.e2e.ts | 4 +- 7 files changed, 325 insertions(+), 601 deletions(-) create mode 100644 app/hooks/use-element-size.ts diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f54bb3c00..f317585d1 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -7,45 +7,18 @@ */ import cn from 'classnames' import { format } from 'date-fns' -import { useMemo, type ReactNode } from 'react' -import { - Area, - AreaChart, - CartesianGrid, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts' -import type { TooltipProps } from 'recharts/types/component/Tooltip' +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 { classed } from '~/util/classed' -// Recharts's built-in ticks behavior is useless and probably broken -/** - * Split the data into n evenly spaced ticks, with one at the left end and one a - * little bit in from the right end, and the rest evenly spaced in between. - */ -function getTicks(data: { timestamp: number }[], n: number): number[] { - if (data.length === 0) return [] - if (n < 2) throw Error('n must be at least 2 because of the start and end ticks') - // bring the last tick in a bit from the end - const maxIdx = data.length > 10 ? Math.floor((data.length - 1) * 0.8) : data.length - 1 - const startOffset = Math.floor((data.length - maxIdx) * 0.6) - // if there are 4 ticks, their positions are 0/3, 1/3, 2/3, 3/3 (as fractions of maxIdx) - const idxs = Array.from({ length: n }).map((_, i) => - Math.floor((maxIdx * i) / (n - 1) + startOffset) - ) - return idxs.map((i) => data[i].timestamp) -} - -function getVerticalTicks(n: number, max: number): number[] { - return Array.from({ length: n }).map((_, i) => Math.floor(((i + 1) / n) * max)) -} - /** * 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 @@ -58,20 +31,28 @@ function isSameDay(d1: Date, d2: Date) { ) } -const shortDateTime = (ts: number) => format(new Date(ts), 'M/d HH:mm') +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 GRID_GRAY = 'var(--stroke-secondary)' -const CURSOR = 'var(--chart-stroke-item)' -const GREEN_400 = 'var(--surface-accent-secondary)' -const GREEN_600 = 'var(--content-accent-tertiary)' -const GREEN_800 = 'var(--content-accent)' +// const GRID_GRAY = 'var(--stroke-secondary)' +// const CURSOR = 'var(--chart-stroke-item)' +// const GREEN_400 = 'var(--surface-accent-secondary)' +// const GREEN_600 = 'var(--content-accent-tertiary)' +// const GREEN_800 = 'var(--content-accent)' // TODO: figure out how to do this with TW classes instead. As far as I can tell // ticks only take direct styling +// TODO(joe) the above isn't going to change. but now it's even more fun, because i need to set the +// desired rem (0.6875) in px const textMonoMd = { - fontSize: '0.6875rem', + fontSize: '11px', fontFamily: '"GT America Mono", monospace', fill: 'var(--content-quaternary)', } @@ -80,17 +61,47 @@ const textMonoMd = { // Used for dynamically sizing the yAxis. If this were to fallback // the font would likely be thinner than the monospaced character // and therefore not overflow -const TEXT_CHAR_WIDTH = 6.82 - -function renderTooltip(props: TooltipProps, unit?: string) { - const { payload } = props - if (!payload || payload.length < 1) return null - // TODO: there has to be a better way to get these values - const { - name, - payload: { timestamp, value }, - } = payload[0] - if (!timestamp || typeof value !== 'number') return null +// const TEXT_CHAR_WIDTH = 6.82 + +// TODO(joe): swap for design-system CSS vars like Terminal.tsx does +const GREEN_STROKE = 'oklch(0.563 0.1714 170.4)' +const GREEN_FILL = 'oklch(0.379 0.1169 177 / 0.6)' +const AXIS_LINE = 'oklch(0.247 0.007 260)' +const AXIS_TEXT = 'oklch(0.483 0.0041 260)' +const AXIS_FONT = `${textMonoMd.fontSize} ${textMonoMd.fontFamily}` +const AXIS_TICK_LENGTH = 6 +const AXIS_TICK_GAP = 8 +// left padding (px-5) 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 TOOLTIP_GAP = 12 + +/** 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 (
, unit?: string) { {longDateTime(timestamp)}
-
{name}
+
{seriesName}
{value.toLocaleString()} {unit && {unit}}
- {/* TODO: unit on value if relevant */}
) @@ -124,15 +134,6 @@ type TimeSeriesChartProps = { loading: boolean } -const TICK_COUNT = 6 -const TICK_MARGIN = 8 -const TICK_SIZE = 6 - -/** Round `value` up to nearest number divisible by `divisor` */ -function roundUpToDivBy(value: number, divisor: number) { - return Math.ceil(value / divisor) * divisor -} - // this top margin is also in the chart, probably want a way of unifying the sizing between the two const SkeletonMetric = ({ children, @@ -168,6 +169,8 @@ const SkeletonMetric = ({
) +const defaultYAxisTickFormatter = (val: number) => val.toLocaleString() + export function TimeSeriesChart({ data: rawData, title, @@ -175,41 +178,159 @@ export function TimeSeriesChart({ startTime, endTime, unit, - yAxisTickFormatter = (val) => val.toLocaleString(), + yAxisTickFormatter = defaultYAxisTickFormatter, hasError = false, loading, }: TimeSeriesChartProps) { - // We use the largest data point +20% for the graph scale. !rawData doesn't - // mean it's empty (it will never be empty because we fill in artificial 0s at - // beginning and end), it means the metrics requests haven't come back yet - const maxY = useMemo(() => { - if (!rawData) return null - const dataMax = Math.max( - ...rawData.map((datum) => datum.value).filter((x) => x !== null) - ) - return roundUpToDivBy(dataMax * 1.2, TICK_COUNT) // avoid uneven ticks - }, [rawData]) - - // If max value is set we normalize the graph so that - // is the maximum, we also use our own function as recharts - // doesn't fill the whole domain (just up to the data max) - const yTicks = maxY - ? { domain: [0, maxY], ticks: getVerticalTicks(TICK_COUNT, maxY) } - : undefined - - // We get the longest label length and multiply that with our `TICK_CHAR_WIDTH` - // and add the extra space for the tick stroke and spacing - // It's possible to get clever and calculate the width using the canvas or font metrics - // But our font is monospace so we can just use the length of the text * the baked width of the character - const maxLabelLength = yTicks - ? Math.max(...yTicks.ticks.map((tick) => yAxisTickFormatter(tick).length)) - : 0 - const maxLabelWidth = maxLabelLength * TEXT_CHAR_WIDTH + TICK_SIZE + TICK_MARGIN - // falling back here instead of in the parent lets us avoid causing a // re-render on every render of the parent when the data is undefined const data = useMemo(() => rawData || [], [rawData]) + const [size, sizeRef] = useElementSize() + + const formatTime = isSameDay(startTime, endTime) ? shortTime : shortDateTime + + const [tooltip, setTooltip] = useState<{ + hoveredDataIndex: number + left: number + top: number + // which side of the point the box sits on + leftRight: LeftRight + topBottom: TopBottom + } | null>(null) + + const tooltipPlugin = useMemo( + () => ({ + hooks: { + setCursor: (self) => { + const { idx, top } = self.cursor + if (idx == null || top == null) { + setTooltip(null) + return + } + + const x = self.data[0][idx] + const y = self.data[1][idx] + if (y == null) { + setTooltip(null) + return + } + + const plotRect = self.over.getBoundingClientRect() + const chartRect = self.root.getBoundingClientRect() + + // cursor picks the y position, data picks the x position + const left = self.valToPos(x, 'x') + + setTooltip({ + hoveredDataIndex: idx, + // cursor coords are relative to the plot area, so we add in the diff between the plot + // and the whole container + left: plotRect.left - chartRect.left + left, + top: plotRect.top - chartRect.top + top, + leftRight: left > plotRect.width / 2 ? 'left' : 'right', + topBottom: top > plotRect.height / 2 ? 'top' : 'bottom', + }) + }, + init: (self) => { + self.over.addEventListener('mouseleave', () => setTooltip(null)) + }, + }, + }), + [] + ) + + // 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. + const chartOptions = useMemo( + () => + ({ + scales: { + x: {}, + y: { + range: (_u, _min, max) => uPlot.rangeNum(0, max * 1.2, 0.1, true), + }, + }, + series: [ + {}, + { + show: true, + stroke: GREEN_STROKE, + fill: GREEN_FILL, + points: { show: false }, + paths: match(interpolation) + .with('linear', () => uPlot.paths.linear?.()) + .with('stepAfter', () => uPlot.paths.stepped?.({ align: 1 })) + .exhaustive(), + }, + ], + axes: [ + { + stroke: AXIS_TEXT, + font: AXIS_FONT, + space: (_u, _axisIdx, _min, _max, plotDim) => plotDim / 5, + values: (_u, times) => times.map((t) => formatTime(t * 1000)), + border: { show: true, stroke: AXIS_LINE, width: 1 }, + gap: AXIS_TICK_GAP, + // TODO(joe): evil! + size: parseInt(textMonoMd.fontSize, 10) + AXIS_TICK_GAP + AXIS_TICK_LENGTH, + ticks: { + show: true, + stroke: AXIS_LINE, + width: 1, + size: AXIS_TICK_LENGTH, + }, + }, + { + stroke: AXIS_TEXT, + font: AXIS_FONT, + side: 1, + border: { show: true, stroke: AXIS_LINE, width: 1 }, + gap: AXIS_TICK_GAP, + ticks: { + show: true, + stroke: AXIS_LINE, + width: 1, + size: AXIS_TICK_LENGTH, + filter: (_u, yValues) => yValues.map((v) => (v === 0 ? null : v)), + }, + values: (_u, yValues) => + yValues.map((v) => (v === 0 ? '' : yAxisTickFormatter(v))), + grid: { show: true, stroke: AXIS_LINE, 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) || '' + self.ctx.font = AXIS_FONT + return axisBase + self.ctx.measureText(longestVal).width + }, + }, + ], + padding: [null, null, null, CHART_LEFT_PAD], + cursor: { + x: false, + y: false, + // i like the drag and we should put it back in. but one thing at a time + drag: { x: false }, + }, + legend: { show: false }, + plugins: [tooltipPlugin], + }) satisfies Omit, + [formatTime, tooltipPlugin, yAxisTickFormatter, interpolation] + ) + + // 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: 300, + }) satisfies uPlot.Options, + [chartOptions, size?.width] + ) + if (hasError) { return ( @@ -233,61 +354,34 @@ export function TimeSeriesChart({ ) } - // ResponsiveContainer has default height and width of 100% - // https://recharts.org/en-US/api/ResponsiveContainer + const aligned: uPlot.AlignedData = [ + data.map(({ timestamp }) => timestamp / 1000), + data.map(({ value }) => value), + ] + + const hovered = tooltip ? data[tooltip.hoveredDataIndex] : undefined return ( -
- - - - - - {/* TODO: stop tooltip being focused by default on pageload if nothing else has been clicked */} - ) => renderTooltip(props, unit)} - cursor={{ stroke: CURSOR, strokeDasharray: '3,3' }} - wrapperStyle={{ outline: 'none' }} - /> - - - +
+
+ + {tooltip && hovered && hovered.value !== null && ( +
+ +
+ )} +
) } diff --git a/app/hooks/use-element-size.ts b/app/hooks/use-element-size.ts new file mode 100644 index 000000000..3bed99477 --- /dev/null +++ b/app/hooks/use-element-size.ts @@ -0,0 +1,30 @@ +/* + * 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 { useState, useRef, useCallback, type RefCallback } from 'react' + +type Size = { width: number; height: number } | null + +export function useElementSize(): [Size, RefCallback] { + const [size, setSize] = useState(null) + const observer = useRef(null) + + const ref = useCallback((element: HTMLElement | null) => { + observer.current?.disconnect() + if (!element) return + + observer.current = new ResizeObserver(([first]: ResizeObserverEntry[]) => { + setSize({ + width: first.contentBoxSize[0].inlineSize, + height: first.contentBoxSize[0].blockSize, + }) + }) + observer.current.observe(element) + }, []) + + return [size, ref] +} diff --git a/app/ui/styles/index.css b/app/ui/styles/index.css index d65e951fb..61ee08385 100644 --- a/app/ui/styles/index.css +++ b/app/ui/styles/index.css @@ -29,6 +29,7 @@ @import '@oxide/design-system/styles/light.css'; @import '@oxide/design-system/styles/preflight.css' layer(base); @import 'simplebar-react/dist/simplebar.min.css' layer(components); +@import 'uplot/dist/uPlot.min.css' layer(components); @import '@oxide/design-system/styles/red.css'; @import '@oxide/design-system/styles/yellow.css'; diff --git a/package-lock.json b/package-lock.json index 6931eb65a..4ce0bcd71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,13 +41,14 @@ "react-merge-refs": "^2.1.1", "react-router": "^8.0.0", "react-stately": "^3.32.2", - "recharts": "^2.15.1", "remeda": "^2.30.0", "semver": "^7.7.3", "simplebar-react": "^3.2.6", "ts-pattern": "^5.8.0", "tslib": "^2.7.0", "tunnel-rat": "^0.1.2", + "uplot": "^1.6.32", + "uplot-react": "^1.2.4", "use-debounce": "^10.0.4", "uuid": "^14.0.0", "zod": "^4.0.17", @@ -1354,9 +1355,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1374,9 +1372,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1394,9 +1389,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1414,9 +1406,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1434,9 +1423,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1454,9 +1440,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1474,9 +1457,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1494,9 +1474,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1871,9 +1848,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1891,9 +1865,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1911,9 +1882,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1931,9 +1899,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1951,9 +1916,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1971,9 +1933,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1991,9 +1950,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2011,9 +1967,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4700,9 +4653,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4719,9 +4669,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4738,9 +4685,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4757,9 +4701,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4776,9 +4717,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4795,9 +4733,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5096,9 +5031,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5115,9 +5047,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5134,9 +5063,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -5153,9 +5079,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -5613,69 +5536,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/d3-array": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", - "license": "MIT" - }, - "node_modules/@types/d3-color": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", - "license": "MIT" - }, - "node_modules/@types/d3-ease": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", - "license": "MIT" - }, - "node_modules/@types/d3-interpolate": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", - "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", - "license": "MIT", - "dependencies": { - "@types/d3-color": "*" - } - }, - "node_modules/@types/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", - "license": "MIT" - }, - "node_modules/@types/d3-scale": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", - "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", - "license": "MIT", - "dependencies": { - "@types/d3-time": "*" - } - }, - "node_modules/@types/d3-shape": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", - "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", - "license": "MIT", - "dependencies": { - "@types/d3-path": "*" - } - }, - "node_modules/@types/d3-time": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", - "license": "MIT" - }, - "node_modules/@types/d3-timer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -7068,129 +6928,9 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, "license": "MIT" }, - "node_modules/d3-array": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", - "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", - "license": "ISC", - "dependencies": { - "internmap": "1 - 2" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-color": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", - "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-ease": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", - "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-format": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", - "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-interpolate": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", - "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", - "license": "ISC", - "dependencies": { - "d3-color": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-scale": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", - "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", - "license": "ISC", - "dependencies": { - "d3-array": "2.10.0 - 3", - "d3-format": "1 - 3", - "d3-interpolate": "1.2.0 - 3", - "d3-time": "2.1.1 - 3", - "d3-time-format": "2 - 4" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-shape": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", - "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", - "license": "ISC", - "dependencies": { - "d3-path": "^3.1.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", - "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", - "license": "ISC", - "dependencies": { - "d3-array": "2 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-time-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", - "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", - "license": "ISC", - "dependencies": { - "d3-time": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/d3-timer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", - "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -7240,12 +6980,6 @@ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", "license": "MIT" }, - "node_modules/decimal.js-light": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", - "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", - "license": "MIT" - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -7331,16 +7065,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dom-helpers": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", - "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.7", - "csstype": "^3.0.2" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -7827,15 +7551,6 @@ "license": "MIT", "peer": true }, - "node_modules/fast-equals": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", - "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8482,6 +8197,18 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8528,15 +8255,6 @@ "license": "MIT", "peer": true }, - "node_modules/internmap": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", - "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/intl-messageformat": { "version": "10.7.17", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.17.tgz", @@ -8660,6 +8378,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -8977,9 +8696,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9000,9 +8716,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9023,9 +8736,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9046,9 +8756,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9151,18 +8858,6 @@ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", "license": "MIT" }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "11.5.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", @@ -9639,15 +9334,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -10420,23 +10106,6 @@ "dev": true, "license": "MIT" }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -10705,21 +10374,6 @@ } } }, - "node_modules/react-smooth": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", - "license": "MIT", - "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/react-stately": { "version": "3.32.2", "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.32.2.tgz", @@ -10754,66 +10408,6 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-transition-group": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", - "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/runtime": "^7.5.5", - "dom-helpers": "^5.0.1", - "loose-envify": "^1.4.0", - "prop-types": "^15.6.2" - }, - "peerDependencies": { - "react": ">=16.6.0", - "react-dom": ">=16.6.0" - } - }, - "node_modules/recharts": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz", - "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==", - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", - "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" - } - }, - "node_modules/recharts/node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -10917,9 +10511,9 @@ "license": "MIT" }, "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, "node_modules/retry": { @@ -11442,12 +11036,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -11907,6 +11495,25 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uplot": { + "version": "1.6.32", + "resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz", + "integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==", + "license": "MIT" + }, + "node_modules/uplot-react": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/uplot-react/-/uplot-react-1.2.4.tgz", + "integrity": "sha512-mDe/mqD9KtXeHDR8llSJaUFpDcEJvYpHNS+cyUhJ2qvkbT9GPKod1BVXG+hNegRqYiV1ldsFBlI5+OKSi/yPNA==", + "license": "MIT", + "engines": { + "node": ">=8.10" + }, + "peerDependencies": { + "react": ">=16.8.6", + "uplot": "^1.6.32" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -12021,28 +11628,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", - "license": "MIT AND ISC", - "dependencies": { - "@types/d3-array": "^3.0.3", - "@types/d3-ease": "^3.0.0", - "@types/d3-interpolate": "^3.0.1", - "@types/d3-scale": "^4.0.2", - "@types/d3-shape": "^3.1.0", - "@types/d3-time": "^3.0.0", - "@types/d3-timer": "^3.0.0", - "d3-array": "^3.1.6", - "d3-ease": "^3.0.1", - "d3-interpolate": "^3.0.1", - "d3-scale": "^4.0.2", - "d3-shape": "^3.1.0", - "d3-time": "^3.0.0", - "d3-timer": "^3.0.1" - } - }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/package.json b/package.json index 3bbdfb181..c8528f7df 100644 --- a/package.json +++ b/package.json @@ -65,13 +65,14 @@ "react-merge-refs": "^2.1.1", "react-router": "^8.0.0", "react-stately": "^3.32.2", - "recharts": "^2.15.1", "remeda": "^2.30.0", "semver": "^7.7.3", "simplebar-react": "^3.2.6", "ts-pattern": "^5.8.0", "tslib": "^2.7.0", "tunnel-rat": "^0.1.2", + "uplot": "^1.6.32", + "uplot-react": "^1.2.4", "use-debounce": "^10.0.4", "uuid": "^14.0.0", "zod": "^4.0.17", diff --git a/test/unit/setup.ts b/test/unit/setup.ts index 48de20fe1..0d36aaceb 100644 --- a/test/unit/setup.ts +++ b/test/unit/setup.ts @@ -12,7 +12,7 @@ */ import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' -import { afterAll, afterEach, beforeAll } from 'vitest' +import { afterAll, afterEach, beforeAll, vi } from 'vitest' import { resetDb } from '../../mock-api/msw/db' import { server } from './server' @@ -21,6 +21,19 @@ import { server } from './server' // an error that the method is not implemented HTMLCanvasElement.prototype.getContext = () => null +// uPlot wants to matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}) + // jsdom has no ResizeObserver, but Headless UI (e.g. Listbox) constructs one for // popover positioning. A no-op stub is enough — there's no real layout to observe // in jsdom, so the callback never needs to fire. diff --git a/test/visual/regression.e2e.ts b/test/visual/regression.e2e.ts index bd0b5c8e7..9f139ebdf 100644 --- a/test/visual/regression.e2e.ts +++ b/test/visual/regression.e2e.ts @@ -238,7 +238,7 @@ test.describe('Visual Regression', { tag: '@visual' }, () => { test('silo utilization', async ({ page }) => { await page.goto('/utilization', { waitUntil: 'networkidle' }) await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() - await expect(page.locator('.recharts-curve').first()).toBeVisible() + await expect(page.locator('figure').first()).toBeVisible() await expect(page).toHaveScreenshot('silo-utilization.png', { fullPage: true, mask: [page.getByTestId('refetch-interval-refresh')], @@ -249,7 +249,7 @@ test.describe('Visual Regression', { tag: '@visual' }, () => { test('system utilization metrics tab', async ({ page }) => { await page.goto('/system/utilization?tab=metrics', { waitUntil: 'networkidle' }) await expect(page.getByRole('heading', { name: 'Utilization' })).toBeVisible() - await expect(page.locator('.recharts-curve').first()).toBeVisible() + await expect(page.locator('figure').first()).toBeVisible() await expect(page).toHaveScreenshot('system-utilization-metrics-tab.png', { fullPage: true, mask: [page.getByTestId('refetch-interval-refresh')], From 27f4db9a10a6d2f202fb0de4d64cadc3528ebf70 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 20 Jul 2026 15:45:36 -0700 Subject: [PATCH 4/7] Use theme variables for colors and fonts Any canvas drawing element is going to need themes this way. I considered doing something more involved, like computing the style within the subscription, or actually making a the subscription an effect hook, but the thing is, you kind of want to only use getComputedStyle consciously. --- app/components/Terminal.tsx | 9 +-- app/components/TimeSeriesChart.tsx | 115 +++++++++++++++++------------ app/stores/theme.ts | 14 ++++ 3 files changed, 85 insertions(+), 53 deletions(-) diff --git a/app/components/Terminal.tsx b/app/components/Terminal.tsx index f36fc4e00..9a287c1cd 100644 --- a/app/components/Terminal.tsx +++ b/app/components/Terminal.tsx @@ -11,6 +11,7 @@ import { useEffect, useRef, useState } from 'react' import { DirectionDownIcon, DirectionUpIcon } from '@oxide/design-system/icons/react' +import { subscribeToTheme } from '~/stores/theme' import { classed } from '~/util/classed' import { AttachAddon } from './AttachAddon' @@ -110,16 +111,12 @@ export function Terminal({ ws }: TerminalProps) { // Update terminal colors when the theme changes. getComputedStyle in // getTheme() forces a synchronous style recalc, so the CSS custom // properties already reflect the new theme by the time we read them. - const observer = new MutationObserver(() => { + const unsubscribe = subscribeToTheme(() => { newTerm.options.theme = getTheme() }) - observer.observe(document.documentElement, { - attributes: true, - attributeFilter: ['data-theme'], - }) return () => { - observer.disconnect() + unsubscribe() newTerm.dispose() window.removeEventListener('resize', resize) } diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index f317585d1..e8d8954ed 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -7,7 +7,7 @@ */ import cn from 'classnames' import { format } from 'date-fns' -import { useMemo, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useState, type ReactNode } from 'react' import * as R from 'remeda' import { match } from 'ts-pattern' import uPlot from 'uplot' @@ -17,6 +17,7 @@ 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 { classed } from '~/util/classed' /** @@ -41,41 +42,58 @@ const shortDateTime = (ts: number) => { 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 GRID_GRAY = 'var(--stroke-secondary)' -// const CURSOR = 'var(--chart-stroke-item)' -// const GREEN_400 = 'var(--surface-accent-secondary)' -// const GREEN_600 = 'var(--content-accent-tertiary)' -// const GREEN_800 = 'var(--content-accent)' - -// TODO: figure out how to do this with TW classes instead. As far as I can tell -// ticks only take direct styling -// TODO(joe) the above isn't going to change. but now it's even more fun, because i need to set the -// desired rem (0.6875) in px -const textMonoMd = { - fontSize: '11px', - fontFamily: '"GT America Mono", monospace', - fill: 'var(--content-quaternary)', +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 } -// The length of a character in pixels at 11px with GT America Mono -// Used for dynamically sizing the yAxis. If this were to fallback -// the font would likely be thinner than the monospaced character -// and therefore not overflow -// const TEXT_CHAR_WIDTH = 6.82 - -// TODO(joe): swap for design-system CSS vars like Terminal.tsx does -const GREEN_STROKE = 'oklch(0.563 0.1714 170.4)' -const GREEN_FILL = 'oklch(0.379 0.1169 177 / 0.6)' -const AXIS_LINE = 'oklch(0.247 0.007 260)' -const AXIS_TEXT = 'oklch(0.483 0.0041 260)' -const AXIS_FONT = `${textMonoMd.fontSize} ${textMonoMd.fontFamily}` +const AXIS_FONT_REM_XS = 0.6875 const AXIS_TICK_LENGTH = 6 const AXIS_TICK_GAP = 8 -// left padding (px-5) 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 +// 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 TOOLTIP_GAP = 12 +type ChartTheme = { + fontFamily: string + stroke: string + fill: string + axisLine: string + axisText: 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('--content-accent-tertiary'), + fill: withAlpha(v('--surface-accent-secondary'), 0.6), + axisLine: v('--stroke-secondary'), + axisText: v('--content-quaternary'), + } +} + +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' @@ -186,6 +204,10 @@ export function TimeSeriesChart({ // re-render on every render of the parent when the data is undefined const data = useMemo(() => rawData || [], [rawData]) + 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 @@ -255,8 +277,8 @@ export function TimeSeriesChart({ {}, { show: true, - stroke: GREEN_STROKE, - fill: GREEN_FILL, + stroke: theme.stroke, + fill: theme.fill, points: { show: false }, paths: match(interpolation) .with('linear', () => uPlot.paths.linear?.()) @@ -266,43 +288,42 @@ export function TimeSeriesChart({ ], axes: [ { - stroke: AXIS_TEXT, - font: AXIS_FONT, + 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: AXIS_LINE, width: 1 }, + border: { show: true, stroke: theme.axisLine, width: 1 }, gap: AXIS_TICK_GAP, - // TODO(joe): evil! - size: parseInt(textMonoMd.fontSize, 10) + AXIS_TICK_GAP + AXIS_TICK_LENGTH, + grid: { show: false }, + size: fontPx + AXIS_TICK_GAP + AXIS_TICK_LENGTH, ticks: { show: true, - stroke: AXIS_LINE, + stroke: theme.axisLine, width: 1, size: AXIS_TICK_LENGTH, }, }, { - stroke: AXIS_TEXT, - font: AXIS_FONT, + stroke: theme.axisText, + font: axisFont, side: 1, - border: { show: true, stroke: AXIS_LINE, width: 1 }, + border: { show: true, stroke: theme.axisLine, width: 1 }, gap: AXIS_TICK_GAP, ticks: { show: true, - stroke: AXIS_LINE, + 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 ? '' : yAxisTickFormatter(v))), - grid: { show: true, stroke: AXIS_LINE, width: 1 }, - size: (self, values) => { + 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) || '' - self.ctx.font = AXIS_FONT - return axisBase + self.ctx.measureText(longestVal).width + return axisBase + measureTextWidth(longestVal, axisFont) }, }, ], @@ -310,13 +331,13 @@ export function TimeSeriesChart({ cursor: { x: false, y: false, - // i like the drag and we should put it back in. but one thing at a time + // TODO: i like the drag and we should put it back in drag: { x: false }, }, legend: { show: false }, plugins: [tooltipPlugin], }) satisfies Omit, - [formatTime, tooltipPlugin, yAxisTickFormatter, interpolation] + [formatTime, tooltipPlugin, yAxisTickFormatter, interpolation, theme, axisFont, fontPx] ) // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets diff --git a/app/stores/theme.ts b/app/stores/theme.ts index dfd800f92..6d5eb627c 100644 --- a/app/stores/theme.ts +++ b/app/stores/theme.ts @@ -43,6 +43,20 @@ function getSystemIsLight() { return window.matchMedia('(prefers-color-scheme: light)').matches } +/** + * Run `cb` whenever the resolved theme (data-theme on ) changes. Use for + * canvas renderers that can't consume CSS custom properties directly. Returns + * an unsubscribe function. + */ +export function subscribeToTheme(cb: () => void) { + const observer = new MutationObserver(cb) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-theme'], + }) + return () => observer.disconnect() +} + /** * Hook that applies the resolved theme to the document. Renders in RootLayout * so it runs on every page. From a56a8d34b9ca51a69dc7874841f77598b2914d06 Mon Sep 17 00:00:00 2001 From: Joe Thel Date: Mon, 20 Jul 2026 14:06:30 -0700 Subject: [PATCH 5/7] Add guard against accidental recreations from formatter prop If you passed an anonymous function as the yAxisTickFormatter, that would cause rerender cycles that screwed with hovering, because the graph was being constantly re-created. You could say the caller is just responsible for not doing that, but I was feeling generous. --- app/components/TimeSeriesChart.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index e8d8954ed..a66297590 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -7,7 +7,7 @@ */ import cn from 'classnames' import { format } from 'date-fns' -import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import * as R from 'remeda' import { match } from 'ts-pattern' import uPlot from 'uplot' @@ -262,6 +262,13 @@ export function TimeSeriesChart({ [] ) + const uRef = useRef(null) + const yAxisTickFormatterRef = useRef<(val: number) => string>(yAxisTickFormatter) + yAxisTickFormatterRef.current = yAxisTickFormatter + useEffect(() => { + uRef.current?.redraw() + }, [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. const chartOptions = useMemo( @@ -317,7 +324,7 @@ export function TimeSeriesChart({ filter: (_u, yValues) => yValues.map((v) => (v === 0 ? null : v)), }, values: (_u, yValues) => - yValues.map((v) => (v === 0 ? '' : yAxisTickFormatter(v))), + yValues.map((v) => (v === 0 ? '' : yAxisTickFormatterRef.current(v))), grid: { show: true, stroke: theme.axisLine, width: 1 }, size: (_self, values) => { const axisBase = AXIS_TICK_LENGTH + AXIS_TICK_GAP @@ -337,7 +344,7 @@ export function TimeSeriesChart({ legend: { show: false }, plugins: [tooltipPlugin], }) satisfies Omit, - [formatTime, tooltipPlugin, yAxisTickFormatter, interpolation, theme, axisFont, fontPx] + [formatTime, tooltipPlugin, interpolation, theme, axisFont, fontPx] ) // Width/height changes cause a cheaper "update" path for uplot, instead of "create", so it gets @@ -384,7 +391,7 @@ export function TimeSeriesChart({ return (
- + (uRef.current = u)} /> {tooltip && hovered && hovered.value !== null && (
Date: Tue, 21 Jul 2026 15:32:01 -0500 Subject: [PATCH 6/7] Fix uPlot initialization in production builds --- app/components/TimeSeriesChart.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index a66297590..5aa78339f 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -391,7 +391,14 @@ export function TimeSeriesChart({ return (
- (uRef.current = u)} /> + {/* uPlot does not recover its x scale when initialized at zero width in production */} + {size && size.width > 0 && ( + (uRef.current = u)} + /> + )} {tooltip && hovered && hovered.value !== null && (
Date: Tue, 21 Jul 2026 10:56:48 +0100 Subject: [PATCH 7/7] Tweak hover point colour --- app/components/TimeSeriesChart.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/components/TimeSeriesChart.tsx b/app/components/TimeSeriesChart.tsx index 5aa78339f..c78523b65 100644 --- a/app/components/TimeSeriesChart.tsx +++ b/app/components/TimeSeriesChart.tsx @@ -66,6 +66,7 @@ type ChartTheme = { fontFamily: string stroke: string fill: string + hoverPoint: string axisLine: string axisText: string } @@ -83,6 +84,7 @@ function getChartTheme(): ChartTheme { fontFamily: v('--font-mono'), stroke: v('--content-accent-tertiary'), fill: withAlpha(v('--surface-accent-secondary'), 0.6), + hoverPoint: v('--content-accent'), axisLine: v('--stroke-secondary'), axisText: v('--content-quaternary'), } @@ -340,6 +342,10 @@ export function TimeSeriesChart({ y: false, // TODO: i like the drag and we should put it back in drag: { x: false }, + points: { + size: 6, + fill: theme.hoverPoint, + }, }, legend: { show: false }, plugins: [tooltipPlugin],