Merge reviewed single-observation History repair

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-02 20:00:39 +01:00
commit 34257d19f6
10 changed files with 507 additions and 51 deletions

View file

@ -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.

View file

@ -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; });

View file

@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Sparse History verification</title></head><body><div id="root"></div><script type="module" src="./history-sparse.tsx"></script></body></html>

View file

@ -0,0 +1,28 @@
import { render } from 'solid-js/web';
import { HistoryChart } from '../src/components/shared/HistoryChart';
import '../src/index.css';
render(
() => (
<main class="min-h-screen space-y-4 bg-surface p-4 text-base-content">
<h1>Storage History: sparse observations</h1>
<p>Synthetic stored samples; production chart, transport and styles.</p>
<button class="min-h-11 border px-3">Before chart</button>
<section class="max-w-xl">
<HistoryChart
resourceType="storage"
resourceId="fixture-pool"
metric="usage"
label="Usage"
unit="%"
hideSelector
compact
range="1h"
height={140}
/>
</section>
<button class="min-h-11 border px-3">After chart</button>
</main>
),
document.getElementById('root')!,
);

View file

@ -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."
}

View file

@ -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(() => (
<HistoryChart
resourceType="storage"
resourceId="pool"
metric="usage"
unit="%"
range="1h"
data={data()}
/>
));
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();
});
});

View file

@ -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(() => (
<HistoryChart
resourceType="storage"
resourceId={target()}
metric="usage"
unit="%"
range="1h"
/>
));
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,

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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 = () => {