diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5e2e9760b..d945c8054 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7713,3 +7713,19 @@ remain separate errors. No polling frequency, request or access policy changes. and selection changes; `browser-tests/history-status.cjs` checks actual chart states in desktop Chromium and phone WebKit with synthetic transport failures. These checks do not establish native collection or released availability. + +### Shared History single observations + +A single stored observation renders as a visible centred marker with one real +timestamp label, including measured zero. It must not imply a trend or invent +a duration. Pointer and keyboard inspection resolve to that actual observation; +ordinary multi-observation interpolation and empty-result states are unchanged. +The geometry's inverse timestamp mapping is shared by pointer inspection. + +`HistoryChart.sparse.test.tsx` and `historyChartModel.branchcov2.test.ts` cover +marker geometry, one timestamp label, pointer bounds, keyboard inspection and +single/zero/multiple/empty refreshes. `browser-tests/history-sparse.cjs` checks +actual series pixels and tooltips in desktop Chromium and phone WebKit, using +the production chart and API transport with synthetic stored samples. This +does not establish native collection, whole-report resolution or availability +in a release. Existing request, source, units and access boundaries are unchanged. diff --git a/frontend-modern/browser-tests/history-sparse.cjs b/frontend-modern/browser-tests/history-sparse.cjs new file mode 100644 index 000000000..2cde2cc36 --- /dev/null +++ b/frontend-modern/browser-tests/history-sparse.cjs @@ -0,0 +1,154 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { chromium, webkit } = require('playwright'); + +(async () => { + const root = '/workspace/frontend-modern'; + process.chdir(root); + const artifacts = path.join(root, 'node_modules/history-sparse-proof'); + fs.mkdirSync(artifacts, { recursive: true }); + const { createServer } = await import(path.join(root, 'node_modules/vite/dist/node/index.js')); + const server = await createServer({ + root, configFile: path.join(root, 'vite.config.ts'), + cacheDir: fs.mkdtempSync(path.join(artifacts, 'vite-')), + server: { host: '127.0.0.1', port: 5226, strictPort: true }, + }); + const parent = process.argv.includes('--parent'); + const timestamp = 1790942400000; + const point = (value, offset = 0) => ({ timestamp: timestamp + offset, value, min: value, max: value }); + const observations = []; + let browser; + try { + await server.listen(); + for (const [name, engine, width, height] of [['chromium', chromium, 1365, 900], ['webkit', webkit, 390, 844]]) { + browser = await engine.launch(name === 'chromium' + ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] } + : { headless: true }); + const page = await browser.newPage({ + viewport: { width, height }, hasTouch: name === 'webkit', isMobile: name === 'webkit', + }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + let samples = [point(42)], calls = 0; + await page.route('**/*', route => { + const url = new URL(route.request().url()); + if (url.origin !== 'http://127.0.0.1:5226') return route.abort(); + if (!url.pathname.startsWith('/api/')) return route.continue(); + if (url.pathname.includes('/metrics-store/history')) { + calls++; + return route.fulfill({ json: { points: samples, source: 'store' } }); + } + return route.fulfill({ json: url.pathname === '/api/license/runtime-capabilities' + ? { capabilities: [], limits: [], max_history_days: 7, hosted_mode: false, runtime: { build: 'community' }, blocked_capabilities: [] } + : { data: [], enabled: false } }); + }); + await page.clock.install(); + await page.goto('http://127.0.0.1:5226/browser-tests/history-sparse.html'); + if (name === 'webkit') await page.evaluate(() => document.documentElement.classList.add('dark')); + const chart = page.getByRole('img', { name: 'Usage chart', exact: true }); + await chart.waitFor(); + const description = page.locator('#' + await chart.getAttribute('aria-describedby')); + const tooltip = page.locator('[data-history-chart-tooltip]'); + const live = page.locator('[aria-live="polite"]'); + async function state(phase, expected) { + await page.waitForFunction(({ id, expected }) => document.getElementById(id).textContent.includes(expected), + { id: await chart.getAttribute('aria-describedby'), expected }); + await page.clock.runFor(100); + // Count actual blue series pixels, not mocked drawing calls or tooltip DOM. + const pixels = await chart.evaluate(canvas => { + const ctx = canvas.getContext('2d'); + const data = ctx.getImageData(0, 0, canvas.width, canvas.height).data; + let count = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i] >= 40 && data[i] <= 80 && data[i + 1] >= 100 && data[i + 1] <= 150 && data[i + 2] >= 220 && data[i + 2] <= 255 && data[i + 3] >= 80) count++; + } + return count; + }); + const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, width: innerWidth })); + assert.ok(dimensions.scroll <= dimensions.width + 1, JSON.stringify(dimensions)); + const screenshot = `${parent ? 'parent-' : ''}${name}-${phase}.png`; + await page.screenshot({ path: path.join(artifacts, screenshot), fullPage: true }); + observations.push({ name, version: browser.version(), phase, description: await description.textContent(), pixels, calls, dimensions, screenshot }); + return pixels; + } + const pixels = await state('single', '1 data point'); + const box = await chart.boundingBox(); + assert.ok(box); + if (parent) { + assert.equal(pixels, 0, 'predecessor singleton has no visible series pixels'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.clock.runFor(100); + assert.equal(await tooltip.count(), 0, 'predecessor pointer rejects singleton'); + observations.at(-1).pointerTooltipCount = await tooltip.count(); + } else { + assert.ok(pixels >= 30, 'singleton has a visible marker without hover'); + for (const fraction of [0.25, 0.5, 0.75]) { + await page.mouse.move(box.x + box.width * fraction, box.y + box.height / 2); + await page.clock.runFor(100); + assert.equal(await tooltip.count(), 1); + assert.ok((await tooltip.textContent()).includes('42.0%')); + assert.ok((await tooltip.textContent()).includes(await page.evaluate(ts => new Date(ts).toLocaleString(), timestamp))); + assert.equal(await live.textContent(), '', 'pointer does not announce mouse movements'); + } + await state('pointer', '1 data point'); + await page.mouse.move(0, 0); + await page.getByRole('button', { name: 'Before chart' }).focus(); + await page.keyboard.press('Tab'); + assert.equal(await chart.evaluate(el => el === document.activeElement), true); + for (const key of ['Home', 'ArrowLeft', 'ArrowRight', 'End']) { + await page.keyboard.press(key); + await page.clock.runFor(100); + assert.ok((await live.textContent()).includes('42.0%')); + } + await page.keyboard.press('Escape'); + assert.equal(await tooltip.count(), 0); + await page.keyboard.press('Tab'); + assert.equal(await page.getByRole('button', { name: 'After chart' }).evaluate(el => el === document.activeElement), true); + + samples = [point(0)]; + await page.clock.runFor(10000); + assert.ok(await state('zero', '0.0%') >= 30, 'measured zero keeps its marker'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.clock.runFor(100); + assert.ok((await tooltip.textContent()).includes('0.0%')); + + samples = [point(10), point(20, 60000), point(30, 120000)]; + await page.mouse.move(0, 0); + await page.clock.runFor(10000); + assert.ok(await state('multiple', '3 data points') > 100, 'normal series still draws'); + await chart.focus(); + await page.keyboard.press('Home'); + await page.clock.runFor(100); + assert.ok((await live.textContent()).includes('10.0%')); + await page.keyboard.press('ArrowRight'); + await page.clock.runFor(100); + assert.ok((await live.textContent()).includes('20.0%')); + await page.keyboard.press('Tab'); + + samples = []; + await page.clock.runFor(10000); + assert.equal(await state('empty', 'No 1-hour'), 0); + assert.equal(await tooltip.count(), 0); + assert.equal(await live.textContent(), ''); + assert.equal(await page.getByText('No history samples in this time range.', { exact: true }).count(), 1); + + samples = [point(70, 180000)]; + await page.clock.runFor(10000); + assert.ok(await state('recovered-single', '1 data point') >= 30); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.clock.runFor(100); + assert.ok((await tooltip.textContent()).includes('70.0%')); + } + assert.deepEqual(errors, []); + await browser.close(); browser = null; + } + fs.writeFileSync(path.join(artifacts, parent ? 'parent.json' : 'result.json'), JSON.stringify({ + playwright: require('playwright/package.json').version, parent, observations, + }, null, 2)); + console.log(JSON.stringify({ result: 'passed', parent, states: observations.length })); + } finally { + if (browser) await browser.close(); + await server.close(); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/frontend-modern/browser-tests/history-sparse.html b/frontend-modern/browser-tests/history-sparse.html new file mode 100644 index 000000000..19d1b6947 --- /dev/null +++ b/frontend-modern/browser-tests/history-sparse.html @@ -0,0 +1 @@ +Sparse History verification
diff --git a/frontend-modern/browser-tests/history-sparse.tsx b/frontend-modern/browser-tests/history-sparse.tsx new file mode 100644 index 000000000..b876116d6 --- /dev/null +++ b/frontend-modern/browser-tests/history-sparse.tsx @@ -0,0 +1,28 @@ +import { render } from 'solid-js/web'; +import { HistoryChart } from '../src/components/shared/HistoryChart'; +import '../src/index.css'; + +render( + () => ( +
+

Storage History: sparse observations

+

Synthetic stored samples; production chart, transport and styles.

+ +
+ +
+ +
+ ), + document.getElementById('root')!, +); diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 3daf270df..d836db5f3 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,16 +1,18 @@ { "version": 1, - "base_sha": "df18123e7da3b682eed811c0e01c94a44dc2c29c", - "verified_at": "2026-10-02T16:36:02.327983Z", + "base_sha": "ad7f7a1063d45e26f77faf35bacbca6877f41cac", + "verified_at": "2026-10-02T18:37:50.309899Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts" + "frontend-modern/src/components/shared/historyChartModel.ts", + "frontend-modern/src/components/shared/useHistoryChartState.ts" ], "content_sha256": { - "frontend-modern/src/features/storageBackups/diskDetailPresentation.ts": "035614af7ddee52c5ee56099b4f447b0137afd9dccf07be9981d44a486eb19a4" + "frontend-modern/src/components/shared/historyChartModel.ts": "c96437081022b073dca0c730a8617954987f712ee84e058c6078ae3cad05f162", + "frontend-modern/src/components/shared/useHistoryChartState.ts": "38af5f416fb95874e708b08d66fdbcf27b85660840e0096cb1dd24d9bc744991" }, "routes": [ - "/browser-tests/disk-temperature.html" + "/browser-tests/history-sparse.html" ], "viewports": [ { @@ -23,28 +25,37 @@ } ], "states": [ - "Temperature without extended SMART; critical temperature; Fahrenheit; absent temperature; full SMART snapshot", - "Chromium desktop light and WebKit phone dark: 10 final states, no page errors or horizontal overflow; synthetic inputs, not native collection" + "Single stored 42% before inspection, pointer inspection, measured zero, multiple observations, successful empty response, restored singleton", + "Desktop Chromium light and phone WebKit dark-class presentation; 12 observations, no page errors or horizontal overflow" ], "interactions": [ - "Same-identity snapshot replacement and reactive temperature-unit changes", - "Parent Chromium and WebKit reproduce hidden 42 C; representative desktop and all five phone final screenshots inspected" + "Pointer at three plot positions resolves to the real singleton timestamp; no mouse live announcements", + "Tab, Home/End, Left/Right, Escape and focus exit; matching API refreshes across sparse, populated and empty results", + "Both actual predecessor engines reproduce zero series pixels and missing pointer tooltip. Parent phone and final phone singleton/tooltip/zero/empty plus final desktop multi screenshots inspected." ], - "command": "pulse-worker-browser frontend-modern/browser-tests/disk-temperature.cjs", + "command": "pulse-worker-browser frontend-modern/browser-tests/history-sparse.cjs", "browser_versions": { "playwright": "1.56.1", "chromium": "141.0.7390.37", "webkit": "26.0" }, "artifacts": [ - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/result.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/parent.json", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/parent-chromium-temperature-hidden.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/chromium-temperature-only.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-temperature-only.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-hot.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-fahrenheit.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-missing.png", - "/var/lib/pulse-maintainer/worker-outputs/web-product-ebfg3k6d/browser/webkit-extended.png" - ] + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/result.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent.json", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-chromium-single.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/parent-webkit-single.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-single.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-pointer.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-zero.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-multiple.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-empty.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/chromium-recovered-single.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-single.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-pointer.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-zero.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-multiple.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-empty.png", + "/var/lib/pulse-maintainer/worker-outputs/web-product-i9rj2yde/browser/webkit-recovered-single.png" + ], + "limitations": "Synthetic samples, not native PBS/TrueNAS collection, assistive-device testing or whole-application theme-transition acceptance." } diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx new file mode 100644 index 000000000..ce0261a8c --- /dev/null +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.sparse.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSignal } from 'solid-js'; +import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; +import type { AggregatedMetricPoint } from '@/api/charts'; +import { HistoryChart } from '../HistoryChart'; +import { formatHistoryChartTimeLabel } from '../historyChartModel'; + +vi.mock('@/stores/license', () => ({ + isRangeLocked: () => false, + loadRuntimeCapabilities: vi.fn(), + maxHistoryDays: () => 7, +})); +vi.mock('@/api/charts', () => ({ ChartsAPI: { getMetricsHistory: vi.fn() } })); + +const ctx = { + clearRect: vi.fn(), + setTransform: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + fillText: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + arc: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + setLineDash: vi.fn(), + measureText: vi.fn(() => ({ width: 20 })), +}; +const point = (value: number, timestamp = 1000): AggregatedMetricPoint => ({ + timestamp, + value, + min: value, + max: value, +}); + +function mount(initial: AggregatedMetricPoint[]) { + const [data, setData] = createSignal(initial); + const view = render(() => ( + + )); + const canvas = screen.getByRole('img', { name: 'History chart' }); + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 300, + bottom: 200, + width: 300, + height: 200, + toJSON: () => ({}), + }); + return { ...view, canvas, setData }; +} + +describe('Sparse History observations', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + ctx as unknown as CanvasRenderingContext2D, + ); + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + disconnect() {} + }, + ); + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it.each([0, 42])( + 'draws a %s singleton marker and exactly one timestamp before hover', + (value) => { + mount([point(value)]); + const lastClear = ctx.clearRect.mock.invocationCallOrder.at(-1)!; + const arcs = ctx.arc.mock.calls.filter( + (_, i) => ctx.arc.mock.invocationCallOrder[i] > lastClear, + ); + expect(arcs).toHaveLength(1); + expect(arcs[0][0]).toBe(164); + expect(arcs[0][1]).toBeCloseTo(180 - (value / 100) * 160); + expect(arcs[0][2]).toBe(4); + const times = ctx.fillText.mock.calls.filter( + (args, i) => + ctx.fillText.mock.invocationCallOrder[i] > lastClear && + args[0] === formatHistoryChartTimeLabel(1000, '1h'), + ); + expect(times).toHaveLength(1); + expect(times[0][1]).toBe(164); + }, + ); + + it('inspects the actual singleton from any pointer position and clears outside the plot', () => { + const { canvas, container } = mount([point(42)]); + for (const clientX of [100, 175, 250]) { + fireEvent.mouseMove(canvas, { clientX }); + const tooltip = container.querySelector('[data-history-chart-tooltip]')!; + expect(tooltip).toHaveTextContent('42.0%'); + expect(tooltip).toHaveTextContent(new Date(1000).toLocaleString()); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(''); + } + fireEvent.mouseMove(canvas, { clientX: 5 }); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + }); + + it('keeps zero, multiple and empty refreshes truthful during inspection', () => { + const { canvas, container, setData } = mount([point(42)]); + fireEvent.focus(canvas); + for (const key of ['Home', 'End', 'ArrowLeft', 'ArrowRight']) { + fireEvent.keyDown(canvas, { key }); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent('42.0%'); + } + setData([point(0)]); + expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('0.0%'); + setData([point(10), point(20, 2000), point(30, 3000)]); + fireEvent.keyDown(canvas, { key: 'ArrowRight' }); + expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('20.0%'); + setData([]); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + expect(container.querySelector('[aria-live="polite"]')).toHaveTextContent(''); + expect(screen.getByText('No history samples in this time range.')).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx index 7bfe9b99c..d2eb0a84b 100644 --- a/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/HistoryChart.test.tsx @@ -231,6 +231,46 @@ describe('HistoryChart', () => { expect(screen.queryByText('Min')).not.toBeInTheDocument(); }); + it('exposes a stored singleton to pointer inspection without treating a later empty response as zero', async () => { + const request = vi.mocked(ChartsAPI.getMetricsHistory); + request.mockResolvedValueOnce({ + points: [{ timestamp: 1000, value: 42, min: 42, max: 42 }], + source: 'store', + } as never); + const [target, setTarget] = createSignal('pool-a'); + const { container } = render(() => ( + + )); + const chart = screen.getByRole('img', { name: 'History chart' }); + const description = document.getElementById(chart.getAttribute('aria-describedby')!)!; + const rectSpy = vi.spyOn(chart, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + left: 0, + top: 0, + right: 300, + bottom: 200, + width: 300, + height: 200, + toJSON: () => ({}), + }); + await waitFor(() => expect(description).toHaveTextContent('1 data point')); + fireEvent.mouseMove(chart, { clientX: 175 }); + expect(container.querySelector('[data-history-chart-tooltip]')).toHaveTextContent('42.0%'); + request.mockResolvedValueOnce({ points: [], source: 'store' } as never); + setTarget('pool-b'); + await waitFor(() => expect(description).toHaveTextContent('No 1-hour history')); + expect(description).not.toHaveTextContent('0.0%'); + expect(container.querySelector('[data-history-chart-tooltip]')).toBeNull(); + rectSpy.mockRestore(); + }); + it('synchronizes the hovered timestamp across charts in the same group', () => { const rectSpy = vi.spyOn(HTMLCanvasElement.prototype, 'getBoundingClientRect').mockReturnValue({ x: 0, diff --git a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts index 76f4c1c49..fb517d9ae 100644 --- a/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts +++ b/frontend-modern/src/components/shared/__tests__/historyChartModel.branchcov2.test.ts @@ -406,6 +406,40 @@ describe('createHistoryChartGeometry', () => { expect(geo.getX(100)).toBe(40); }); + it('centres a single timestamp and maps every plot position back to that observation', () => { + const geo = createHistoryChartGeometry({ + width: 300, + height: 120, + startTime: 1000, + endTime: 1000, + minValue: 0, + maxValue: 100, + leftInset: 60, + rightInset: 20, + }); + expect(geo.getX(1000)).toBe(170); + for (const x of [60, 100, 170, 280]) { + expect(geo.getTimestamp(x)).toBe(1000); + } + expect(geo.getY(0)).toBe(100); + }); + + it('inverts timestamp geometry without changing multi-observation interpolation', () => { + const geo = createHistoryChartGeometry({ + width: 300, + height: 120, + startTime: 1000, + endTime: 3000, + minValue: 0, + maxValue: 100, + leftInset: 60, + rightInset: 20, + }); + for (const timestamp of [1000, 1500, 2000, 3000]) { + expect(geo.getTimestamp(geo.getX(timestamp))).toBeCloseTo(timestamp); + } + }); + it('left-pads the first timestamp to the chart origin', () => { const geo = createHistoryChartGeometry({ width: 200, diff --git a/frontend-modern/src/components/shared/historyChartModel.ts b/frontend-modern/src/components/shared/historyChartModel.ts index 1e9cab000..bd7436171 100644 --- a/frontend-modern/src/components/shared/historyChartModel.ts +++ b/frontend-modern/src/components/shared/historyChartModel.ts @@ -241,14 +241,21 @@ export function createHistoryChartGeometry({ rightInset?: number; }) { const timeSpan = Math.max(1, endTime - startTime); + const plotWidth = width - leftInset - rightInset; + const singleTimestamp = startTime === endTime; const getX = (timestamp: number) => - leftInset + ((timestamp - startTime) / timeSpan) * (width - leftInset - rightInset); + singleTimestamp + ? leftInset + plotWidth / 2 + : leftInset + ((timestamp - startTime) / timeSpan) * plotWidth; + const getTimestamp = (x: number) => + singleTimestamp ? startTime : startTime + ((x - leftInset) / plotWidth) * timeSpan; const getY = (value: number) => height - 20 - ((value - minValue) / (maxValue - minValue)) * (height - 40); return { timeSpan, getX, + getTimestamp, getY, }; } diff --git a/frontend-modern/src/components/shared/useHistoryChartState.ts b/frontend-modern/src/components/shared/useHistoryChartState.ts index a63b01d35..93d36881d 100644 --- a/frontend-modern/src/components/shared/useHistoryChartState.ts +++ b/frontend-modern/src/components/shared/useHistoryChartState.ts @@ -197,15 +197,22 @@ export function useHistoryChartState( const yAxisTicks = getHistoryChartYAxisLabels(scale, props.unit); const labelCount = 4; const timeAxisTicks = - points.length > 0 - ? Array.from({ length: labelCount }, (_, index) => { - const timestamp = - points[0].timestamp + - ((points[points.length - 1].timestamp - points[0].timestamp) * index) / - (labelCount - 1); - return { timestamp, label: formatHistoryChartTimeLabel(timestamp, range()) }; - }) - : []; + points.length === 1 + ? [ + { + timestamp: points[0].timestamp, + label: formatHistoryChartTimeLabel(points[0].timestamp, range()), + }, + ] + : points.length > 0 + ? Array.from({ length: labelCount }, (_, index) => { + const timestamp = + points[0].timestamp + + ((points[points.length - 1].timestamp - points[0].timestamp) * index) / + (labelCount - 1); + return { timestamp, label: formatHistoryChartTimeLabel(timestamp, range()) }; + }) + : []; ctx.font = '10px sans-serif'; chartLeftInset = getHistoryChartLeftInset( @@ -248,26 +255,39 @@ export function useHistoryChartState( rightInset: chartRightInset, }); - ctx.beginPath(); - points.forEach((point, index) => { - if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), height - 20); - ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value)); - }); - if (points.length > 0) { + if (points.length === 1) { + // A stored observation is visible before inspection, including measured + // zero. A line or filled area would invent a duration for this sample. + ctx.beginPath(); + ctx.arc( + geometry.getX(points[0].timestamp), + geometry.getY(points[0].value), + 4, + 0, + Math.PI * 2, + ); + ctx.fillStyle = mainColor; + ctx.fill(); + } else { + ctx.beginPath(); + points.forEach((point, index) => { + if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), height - 20); + ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value)); + }); ctx.lineTo(geometry.getX(points[points.length - 1].timestamp), height - 20); - } - ctx.closePath(); - ctx.fillStyle = `${mainColor}66`; - ctx.fill(); + ctx.closePath(); + ctx.fillStyle = `${mainColor}66`; + ctx.fill(); - ctx.beginPath(); - ctx.strokeStyle = mainColor; - ctx.lineWidth = 2; - points.forEach((point, index) => { - if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), geometry.getY(point.value)); - else ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value)); - }); - ctx.stroke(); + ctx.beginPath(); + ctx.strokeStyle = mainColor; + ctx.lineWidth = 2; + points.forEach((point, index) => { + if (index === 0) ctx.moveTo(geometry.getX(point.timestamp), geometry.getY(point.value)); + else ctx.lineTo(geometry.getX(point.timestamp), geometry.getY(point.value)); + }); + ctx.stroke(); + } ctx.fillStyle = axisTextColor; ctx.font = '10px sans-serif'; @@ -404,9 +424,17 @@ export function useHistoryChartState( return; } - const ratio = (x - chartLeftInset) / (width - chartLeftInset - chartRightInset); - const timeSpan = Math.max(1, points[points.length - 1].timestamp - points[0].timestamp); - setHoveredTimestamp(points[0].timestamp + ratio * timeSpan); + const geometry = createHistoryChartGeometry({ + width, + height: chartHeight(), + startTime: points[0].timestamp, + endTime: points[points.length - 1].timestamp, + minValue: 0, + maxValue: 1, + leftInset: chartLeftInset, + rightInset: chartRightInset, + }); + setHoveredTimestamp(geometry.getTimestamp(x)); }; const handleMouseLeave = () => {