Keep single History observations visible and inspectable

A one-point stored History response drew no visible series and pointer inspection mapped it into a fictional one-millisecond window. Show a centred marker and one real timestamp, including measured zero, and share timestamp geometry with pointer inspection. Preserve multi-point rendering, keyboard access, empty states and request ownership.

Chromium and WebKit reproduce the predecessor defect. Twelve final desktop and phone states verify real canvas pixels, pointer and keyboard inspection, and sparse-to-empty refreshes. Synthetic presentation proof does not establish native collection or release availability.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-02 19:38:38 +01:00
parent ad7f7a1063
commit b112524cd2
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 = () => {