mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Fit metric bar labels by measured glyph widths, not character count (#2438)
## What was wrong The metric, stacked memory and stacked disk bars choose between `60%` and `60% (265 GB/440 GB)` by estimating the label's width. The estimate was 5.5px per character plus 8. At the label's 10px type a digit is 6.7px wide and a full stop 3px, so the estimate was off by -7px to +5px on real labels. In the Kubernetes clusters table at a 560px viewport the memory bar is 113px wide, the estimate came to 112.5px, the label needs 118px, and it rendered with an ellipsis. A sweep of the platform tables found 24 such bars on `main`, all stacked memory bars: 3 on Kubernetes clusters at 560px, 4 on Proxmox overview at 900px, 16 on Docker overview and 1 on Kubernetes nodes at 1440px. ## The fix `estimateTextWidth` sums per-glyph advances instead of multiplying a character count. - It reads the advances once per page from a hidden probe that carries the label's type classes, so the estimate follows whichever system font the browser renders. The advances measured for the macOS system font stand in where there is no layout (unit tests). - The value is measured semibold and the detail after it at normal weight. - Each model adds the padding its own label sits in (4px in the metric and disk bars, 8px in the memory bar's chip) instead of sharing one constant. Two more gaps in the same decision are closed: - The stacked memory and disk bars now count the anomaly marker that shares the label's line. - The inline per-disk slots are sized after the gap between them. A two-disk Proxmox node at 900px clipped `backup 80%` by 2px. A flat safety margin was not enough: the error depends on which glyphs the label holds, so a margin that stops the clipping also hides labels that fit. Measuring each label in the DOM would add a layout read per bar on the Workloads hot path. ## Verification Playwright sweep of every table on 32 platform routes at 1440, 900, 700, 560 and 390px (160 loads, 2,579 bars), on this branch and on `main` against the same mock backend: | | `main` | this branch | | --- | --- | --- | | Bars whose chosen label does not fit | 24 | 0 | | Clipped inline disk labels | 3 | 0 | | Bars showing the long label | 567 | 550 | Of the 17 fewer long labels, 24 are the clipped ones, 14 Proxmox guest disk bars at 900px gained the long form, and 7 memory bars dropped it: 5 that had 1.0px to 2.8px to spare and 2 that overflowed by under 0.5px. With every glyph forced to Verdana (10% wider) no bar clips; with Arial (7% narrower) no bar clips and 237 more show the long label. WebKit matches Chromium. The probe is the estimator's only layout read: 1.7ms on the 429-bar Proxmox overview, about 110ns per estimate afterwards. Bar widths still come from the resize observer alone. ## Not changed here The storage usage bar on Proxmox > Storage still clips at 390px (72 rows) and by up to 2.3px at 700px (19 rows). It is a different component that always renders the long label and does not use the estimator; #2418 reworks it.
This commit is contained in:
@@ -2536,6 +2536,22 @@ resize-observer lifecycle lives in
|
||||
`frontend-modern/src/components/Workloads/useMetricBarState.ts`. Future
|
||||
metric-bar runtime changes must extend through those owners instead of
|
||||
reintroducing mixed resize state and label-fit logic into the shell.
|
||||
Label fit in the metric, stacked memory, and stacked disk bar models goes
|
||||
through `estimateTextWidth` in `frontend-modern/src/utils/format.ts`, which
|
||||
sums per-glyph advances rather than multiplying a character count. The
|
||||
advances come from one hidden probe of about 200 nodes carrying the label's
|
||||
type classes, read once per page load and again only if the device pixel
|
||||
ratio changes; that is the estimator's only layout read. It is never per bar,
|
||||
the bars still take their width from the resize observer alone, and each
|
||||
estimate after the probe is arithmetic over the label's characters. Each model
|
||||
adds the padding its own label sits in, the stacked bars count the anomaly
|
||||
marker that shares the label's line, and inline disk slots are sized after the
|
||||
gap between them. Measured in Chrome on the 429-bar Proxmox overview on
|
||||
2026-10-03: 1.7 ms for the probe and about 110 ns per estimate. Where layout
|
||||
is unavailable the estimator uses the advances measured for the macOS system
|
||||
font. `formatExtra.test.ts` verifies the single probe read and the fallback;
|
||||
`useMetricBarState.test.tsx` and `useStackedMemoryBarState.test.tsx` verify
|
||||
that the long label appears only at an observed width that fits it.
|
||||
The dashboard enhanced CPU bar now follows that same pattern: the shell stays
|
||||
in `frontend-modern/src/components/Workloads/EnhancedCPUBar.tsx`, while usage
|
||||
formatting, anomaly presentation, tooltip load-average formatting, and
|
||||
|
||||
+37
-9
@@ -2,16 +2,20 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { estimateTextWidth } from '@/utils/format';
|
||||
|
||||
import metricBarSource from '../MetricBar.tsx?raw';
|
||||
import type { MetricBarProps } from '../metricBarModel';
|
||||
import { buildMetricBarPresentation } from '../metricBarModel';
|
||||
|
||||
// estimateTextWidth(text) = text.length * 5.5 + 8 (mirrored from @/utils/format).
|
||||
// For label='CPU' + sublabel='8c' the composed threshold text is 'CPU (8c)'
|
||||
// (length 8) -> estimateTextWidth = 8 * 5.5 + 8 = 52. So a containerWidth of 52
|
||||
// trips the >= true arm of the showSublabel check, and 51 trips the false arm.
|
||||
// The long form needs its text plus the 4px of padding the label span
|
||||
// carries. For label='CPU' + sublabel='8c' the text is 'CPU' semibold (21.83)
|
||||
// and ' (8c)' at normal weight (23.04): 44.87, rounded up to 45, plus 4 = 49.
|
||||
// So a containerWidth of 49 trips the >= true arm of the showSublabel check,
|
||||
// and 48 trips the false arm.
|
||||
const LABEL = 'CPU';
|
||||
const SUBLABEL = '8c';
|
||||
const SUBLABEL_THRESHOLD = estimateTextWidth(`${LABEL} (${SUBLABEL})`); // 52
|
||||
const LABEL_PADDING_PX = 4;
|
||||
const SUBLABEL_THRESHOLD =
|
||||
estimateTextWidth(LABEL, { detail: ` (${SUBLABEL})` }) + LABEL_PADDING_PX; // 49
|
||||
|
||||
// Tailwind background class mirrored from metricThresholds.BG_CLASSES.normal.
|
||||
// Default cpu thresholds are warning 80 / critical 90 (METRIC_THRESHOLDS.cpu),
|
||||
@@ -65,23 +69,47 @@ describe('buildMetricBarPresentation (branch coverage 0720pm)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...))', () => {
|
||||
describe('showSublabel threshold branch (containerWidth >= estimateTextWidth(...) + padding)', () => {
|
||||
it('returns showSublabel=true when containerWidth meets the threshold exactly (>= true arm)', () => {
|
||||
// estimateTextWidth('CPU (8c)') === 52; containerWidth 52 -> 52 >= 52 true.
|
||||
expect(SUBLABEL_THRESHOLD).toBe(52);
|
||||
// The threshold is 49; containerWidth 49 -> 49 >= 49 true.
|
||||
expect(SUBLABEL_THRESHOLD).toBe(49);
|
||||
const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD);
|
||||
expect(p.showLabel).toBe(true);
|
||||
expect(p.showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('returns showSublabel=false when containerWidth is one below the threshold (false arm)', () => {
|
||||
// containerWidth 51 < 52 -> the >= arm is false -> showSublabel false.
|
||||
// containerWidth 48 < 49 -> the >= arm is false -> showSublabel false.
|
||||
// showLabel itself is unaffected by the width check (still true here).
|
||||
const p = buildMetricBarPresentation(makeProps(), SUBLABEL_THRESHOLD - 1);
|
||||
expect(p.showLabel).toBe(true);
|
||||
expect(p.showSublabel).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a 113px bar on the short label for "60% (265 GB/440 GB)"', () => {
|
||||
// Measured in the browser on 2026-10-03: that label is 110.1px of text
|
||||
// at text-[10px], so it needs 114.1px here and 118.1px in the memory
|
||||
// bar's wider chip. The old 5.5px-per-character estimate put it at
|
||||
// 112.5px, showed the long form in a 113px bar and clipped it.
|
||||
const props = makeProps({ value: 60, label: '60%', sublabel: '265 GB/440 GB' });
|
||||
expect(buildMetricBarPresentation(props, 109).showSublabel).toBe(false);
|
||||
expect(buildMetricBarPresentation(props, 113).showSublabel).toBe(false);
|
||||
expect(buildMetricBarPresentation(props, 114).showSublabel).toBe(false);
|
||||
expect(buildMetricBarPresentation(props, 115).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('fits a dotted label that the per-character estimate turned away', () => {
|
||||
// '71% (5.65/8.00TB)' renders 93.5px wide: full stops and the slash are
|
||||
// half a digit. It fits a 100px bar; 17 characters at 5.5px did not.
|
||||
const props = makeProps({ value: 71, label: '71%', sublabel: '5.65/8.00TB' });
|
||||
expect(buildMetricBarPresentation(props, 100).showSublabel).toBe(true);
|
||||
expect(buildMetricBarPresentation(props, 97).showSublabel).toBe(false);
|
||||
});
|
||||
|
||||
it('counts the padding the label span carries in MetricBar', () => {
|
||||
expect(metricBarSource).toContain('text-ellipsis px-0.5 text-center');
|
||||
});
|
||||
|
||||
it('returns showSublabel=false when sublabel is empty (Boolean(props.sublabel) false arm)', () => {
|
||||
// Even with a very wide container, Boolean('') is false -> showSublabel false.
|
||||
const p = buildMetricBarPresentation(makeProps({ sublabel: '' }), 1000);
|
||||
|
||||
+68
-5
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { AnomalyReport } from '@/types/aiIntelligence';
|
||||
import type { Disk } from '@/types/api';
|
||||
|
||||
import stackedDiskBarSource from '../StackedDiskBar.tsx?raw';
|
||||
import { buildStackedDiskBarPresentation } from '../stackedDiskBarModel';
|
||||
|
||||
// The four small helpers under test (getDiskUsagePercent, getShortDiskLabel,
|
||||
@@ -117,7 +118,8 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
});
|
||||
|
||||
describe('getInlineDiskText (via miniDisks[].inlineText)', () => {
|
||||
// estimateInlineTextWidth(text) = text.length * 5.4 + 4 (module-internal).
|
||||
// estimateInlineTextWidth(text) is the label's width at text-[9px]
|
||||
// semibold, rounded up, plus the slot's 2px of padding (module-internal).
|
||||
it('returns the full text when slotWidth <= 0 (short-circuit)', () => {
|
||||
// slotWidth = containerWidth(0) / 1 = 0 -> first operand of the || .
|
||||
const p = buildStackedDiskBarPresentation(
|
||||
@@ -130,7 +132,7 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
});
|
||||
|
||||
it('returns the full text when it fits within the slot', () => {
|
||||
// 'sda1 50%' width = 8 * 5.4 + 4 = 47.2 <= slotWidth(60).
|
||||
// 'sda1 50%' width = 46 + 2 = 48 <= slotWidth(60).
|
||||
const p = buildStackedDiskBarPresentation(
|
||||
{
|
||||
disks: [makeDisk({ mountpoint: '/dev/sda1', total: 100, used: 50, free: 50, usage: 50 })],
|
||||
@@ -141,8 +143,8 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
});
|
||||
|
||||
it('falls back to the percent label when only it fits', () => {
|
||||
// shortLabel 'a', percentLabel '100%'. full 'a 100%' width 36.4 > 30;
|
||||
// '100%' width 25.6 <= 30 -> returns '100%'.
|
||||
// shortLabel 'a', percentLabel '100%'. full 'a 100%' width 38 > 30;
|
||||
// '100%' width 30 <= 30 -> returns '100%'.
|
||||
const p = buildStackedDiskBarPresentation(
|
||||
{ disks: [makeDisk({ mountpoint: '/dev/a', total: 100, used: 100, free: 0, usage: 100 })] },
|
||||
30,
|
||||
@@ -151,7 +153,7 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
});
|
||||
|
||||
it('falls back to the short label when the percent label does not fit but the short label does', () => {
|
||||
// slotWidth 20: full 36.4 > 20; percent '100%' 25.6 > 20; short 'a' 9.4 <= 20 -> 'a'.
|
||||
// slotWidth 20: full 38 > 20; percent '100%' 30 > 20; short 'a' 8 <= 20 -> 'a'.
|
||||
const p = buildStackedDiskBarPresentation(
|
||||
{ disks: [makeDisk({ mountpoint: '/dev/a', total: 100, used: 100, free: 0, usage: 100 })] },
|
||||
20,
|
||||
@@ -167,6 +169,26 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
);
|
||||
expect(p.miniDisks[0].inlineText).toBe('');
|
||||
});
|
||||
|
||||
it('sizes each slot after the gap between slots', () => {
|
||||
// Two disks in a 117.58px bar (Proxmox nodes at a 900px viewport) get
|
||||
// 57.79px each once the 2px gap is out. 'backup 80%' needs 58 + 2, so
|
||||
// the slot keeps the percentage; splitting the bar in half had let the
|
||||
// full text in and clipped it.
|
||||
const disks = [
|
||||
makeDisk({ mountpoint: '/backup', total: 100, used: 80, free: 20, usage: 80 }),
|
||||
makeDisk({ mountpoint: '/', total: 100, used: 50, free: 50, usage: 50 }),
|
||||
];
|
||||
const narrow = buildStackedDiskBarPresentation({ disks }, 117.58);
|
||||
expect(narrow.miniDisks.map((disk) => disk.inlineText)).toEqual(['80%', '/ 50%']);
|
||||
const wide = buildStackedDiskBarPresentation({ disks }, 122);
|
||||
expect(wide.miniDisks[0].inlineText).toBe('backup 80%');
|
||||
});
|
||||
|
||||
it('counts the padding and gap the inline slots carry in StackedDiskBar', () => {
|
||||
expect(stackedDiskBarSource).toContain('flex h-full items-stretch gap-0.5');
|
||||
expect(stackedDiskBarSource).toContain('overflow-hidden px-px text-center text-[9px]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStackedDiskColor (via segments[].color, mode=stacked)', () => {
|
||||
@@ -463,6 +485,47 @@ describe('stackedDiskBarModel (branch coverage 2)', () => {
|
||||
expect(unknown.anomalyRatio).toBe('');
|
||||
});
|
||||
|
||||
it('shows the sublabel only where the whole label fits', () => {
|
||||
// '60% (265 GB/440 GB)' is 110.1px of text in the browser and sits in
|
||||
// 4px of padding: a 113px bar keeps the percentage, a 115px bar fits it.
|
||||
const GiB = 1024 ** 3;
|
||||
const disks = [
|
||||
makeDisk({
|
||||
mountpoint: '/',
|
||||
total: 440 * GiB,
|
||||
used: 265 * GiB,
|
||||
free: 175 * GiB,
|
||||
usage: 60,
|
||||
}),
|
||||
];
|
||||
const narrow = buildStackedDiskBarPresentation({ disks }, 113);
|
||||
expect(narrow.displayLabel).toBe('60%');
|
||||
expect(narrow.displaySublabel).toBe('265 GB/440 GB');
|
||||
expect(narrow.showSublabel).toBe(false);
|
||||
expect(buildStackedDiskBarPresentation({ disks }, 114).showSublabel).toBe(false);
|
||||
expect(buildStackedDiskBarPresentation({ disks }, 115).showSublabel).toBe(true);
|
||||
expect(stackedDiskBarSource).toContain('text-ellipsis px-0.5 text-center');
|
||||
});
|
||||
|
||||
it('leaves room for the anomaly marker that shares the line', () => {
|
||||
// ' 2.5x' adds 25.35px, taking the threshold from 115 to 140.
|
||||
const GiB = 1024 ** 3;
|
||||
const props = {
|
||||
disks: [
|
||||
makeDisk({
|
||||
mountpoint: '/',
|
||||
total: 440 * GiB,
|
||||
used: 265 * GiB,
|
||||
free: 175 * GiB,
|
||||
usage: 60,
|
||||
}),
|
||||
],
|
||||
anomaly: makeAnomaly({ baseline_mean: 10, current_value: 25 }),
|
||||
};
|
||||
expect(buildStackedDiskBarPresentation(props, 139).showSublabel).toBe(false);
|
||||
expect(buildStackedDiskBarPresentation(props, 140).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses the sublabel when containerWidth is too narrow to fit it', () => {
|
||||
// displaySublabel non-empty but containerWidth < estimateTextWidth(...) ->
|
||||
// showSublabel false (the width-failing arm of the && ).
|
||||
|
||||
+40
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { AnomalyReport } from '@/types/aiIntelligence';
|
||||
|
||||
import stackedMemoryBarSource from '../StackedMemoryBar.tsx?raw';
|
||||
import type { StackedMemoryBarProps } from '../stackedMemoryBarModel';
|
||||
import { buildStackedMemoryBarPresentation } from '../stackedMemoryBarModel';
|
||||
|
||||
@@ -194,4 +195,43 @@ describe('stackedMemoryBarModel (branch coverage 0724pm)', () => {
|
||||
expect(p.anomalyDescription).toBe('Memory spike');
|
||||
});
|
||||
});
|
||||
|
||||
describe('showSublabel fit (label text plus the chip padding)', () => {
|
||||
// 265 of 440 GiB reads '60% (265 GB/440 GB)': 110.1px of text in the
|
||||
// browser, 118.1px with the chip's 8px of padding. This is the label the
|
||||
// Kubernetes clusters table clipped in a 113px bar on 2026-10-03.
|
||||
const clusterMemory = makeProps({ used: 265 * GiB, total: 440 * GiB });
|
||||
|
||||
it('keeps a 113px bar on the short label for "60% (265 GB/440 GB)"', () => {
|
||||
const p = buildStackedMemoryBarPresentation(clusterMemory, 113);
|
||||
expect(p.displayLabel).toBe('60%');
|
||||
expect(p.displaySublabel).toBe('265 GB/440 GB');
|
||||
expect(p.showSublabel).toBe(false);
|
||||
expect(buildStackedMemoryBarPresentation(clusterMemory, 118).showSublabel).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the long label once the bar is wide enough for it', () => {
|
||||
expect(buildStackedMemoryBarPresentation(clusterMemory, 119).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves room for the anomaly marker that shares the line', () => {
|
||||
// ' 3.0x' adds 25.44px, taking the threshold from 119 to 145.
|
||||
const withMarker = makeProps({ ...clusterMemory, anomaly: makeAnomaly() });
|
||||
expect(buildStackedMemoryBarPresentation(withMarker, 144).showSublabel).toBe(false);
|
||||
expect(buildStackedMemoryBarPresentation(withMarker, 145).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an anomaly that renders no marker', () => {
|
||||
// The marker needs both a description and a ratio.
|
||||
const noDescription = makeProps({
|
||||
...clusterMemory,
|
||||
anomaly: makeAnomaly({ description: '' }),
|
||||
});
|
||||
expect(buildStackedMemoryBarPresentation(noDescription, 119).showSublabel).toBe(true);
|
||||
});
|
||||
|
||||
it('counts the padding the label chip carries in StackedMemoryBar', () => {
|
||||
expect(stackedMemoryBarSource).toContain('px-1 text-center');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,4 +65,38 @@ describe('useMetricBarState', () => {
|
||||
unmount();
|
||||
expect(observers[0].disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the long label only at an observed width that fits it', () => {
|
||||
const observers: MockResizeObserver[] = [];
|
||||
globalThis.ResizeObserver = class extends MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
super(callback);
|
||||
observers.push(this);
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
|
||||
let captured: ReturnType<typeof useMetricBarState> | undefined;
|
||||
|
||||
const Harness = () => {
|
||||
captured = useMetricBarState({
|
||||
value: 60,
|
||||
label: '60%',
|
||||
sublabel: '265 GB/440 GB',
|
||||
type: 'memory',
|
||||
});
|
||||
return <div ref={captured.setContainerRef} />;
|
||||
};
|
||||
|
||||
render(() => <Harness />);
|
||||
|
||||
// '60% (265 GB/440 GB)' renders 110.1px of text plus 4px of padding. The
|
||||
// width arrives from the resize observer alone; the fit never reads the
|
||||
// bar's own layout.
|
||||
observers[0].trigger(113);
|
||||
expect(captured!.presentation().showSublabel).toBe(false);
|
||||
observers[0].trigger(115);
|
||||
expect(captured!.presentation().showSublabel).toBe(true);
|
||||
observers[0].trigger(109);
|
||||
expect(captured!.presentation().showSublabel).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,4 +67,34 @@ describe('useStackedMemoryBarState', () => {
|
||||
unmount();
|
||||
expect(observers[0].disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the long label only at an observed width that fits it', () => {
|
||||
const observers: MockResizeObserver[] = [];
|
||||
globalThis.ResizeObserver = class extends MockResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
super(callback);
|
||||
observers.push(this);
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
|
||||
let captured: ReturnType<typeof useStackedMemoryBarState> | undefined;
|
||||
|
||||
const Harness = () => {
|
||||
captured = useStackedMemoryBarState({
|
||||
used: 265 * 1024 ** 3,
|
||||
total: 440 * 1024 ** 3,
|
||||
});
|
||||
return <div ref={captured.setContainerRef} />;
|
||||
};
|
||||
|
||||
render(() => <Harness />);
|
||||
|
||||
// '60% (265 GB/440 GB)' renders 110.1px of text plus the chip's 8px of
|
||||
// padding: the 113px Kubernetes clusters bar keeps the percentage.
|
||||
observers[0].trigger(113);
|
||||
expect(captured!.presentation().displaySublabel).toBe('265 GB/440 GB');
|
||||
expect(captured!.presentation().showSublabel).toBe(false);
|
||||
observers[0].trigger(119);
|
||||
expect(captured!.presentation().showSublabel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface MetricBarPresentation {
|
||||
width: number;
|
||||
}
|
||||
|
||||
// Horizontal padding around the label inside the bar (px-0.5 in MetricBar.tsx).
|
||||
const LABEL_PADDING_PX = 4;
|
||||
|
||||
export function buildMetricBarPresentation(
|
||||
props: MetricBarProps,
|
||||
containerWidth: number,
|
||||
@@ -31,7 +34,8 @@ export function buildMetricBarPresentation(
|
||||
const showSublabel =
|
||||
showLabel &&
|
||||
Boolean(props.sublabel) &&
|
||||
containerWidth >= estimateTextWidth(`${props.label} (${props.sublabel})`);
|
||||
containerWidth >=
|
||||
estimateTextWidth(props.label, { detail: ` (${props.sublabel})` }) + LABEL_PADDING_PX;
|
||||
const metric = props.type || 'cpu';
|
||||
const metricType: MetricType = metric === 'generic' ? 'cpu' : metric;
|
||||
|
||||
|
||||
@@ -144,8 +144,17 @@ function getShortDiskLabel(label: string): string {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Horizontal padding around the aggregate label inside the bar (px-0.5 in
|
||||
// StackedDiskBar.tsx).
|
||||
const LABEL_PADDING_PX = 4;
|
||||
// Inline per-disk labels are text-[9px] font-semibold, in px-px slots that a
|
||||
// gap-0.5 separates.
|
||||
const INLINE_DISK_FONT_PX = 9;
|
||||
const INLINE_DISK_SLOT_PADDING_PX = 2;
|
||||
const INLINE_DISK_SLOT_GAP_PX = 2;
|
||||
|
||||
function estimateInlineTextWidth(text: string): number {
|
||||
return text.length * 5.4 + 4;
|
||||
return estimateTextWidth(text, { fontPx: INLINE_DISK_FONT_PX }) + INLINE_DISK_SLOT_PADDING_PX;
|
||||
}
|
||||
|
||||
function getInlineDiskText(shortLabel: string, percentLabel: string, slotWidth: number): string {
|
||||
@@ -269,7 +278,10 @@ export function buildStackedDiskBarPresentation(
|
||||
const inlineDiskMode =
|
||||
(miniMode || hasMultipleDisks) && !aggregateMode && !explicitStackedMode && !verticalBarsMode;
|
||||
const useStackedSegments = hasMultipleDisks && explicitStackedMode;
|
||||
const inlineDiskSlotWidth = disks.length > 0 ? containerWidth / disks.length : 0;
|
||||
const inlineDiskSlotWidth =
|
||||
disks.length > 0
|
||||
? (containerWidth - INLINE_DISK_SLOT_GAP_PX * (disks.length - 1)) / disks.length
|
||||
: 0;
|
||||
const totalCapacity = hasDisks
|
||||
? disks.reduce((sum, disk) => sum + (disk.total || 0), 0)
|
||||
: (props.aggregateDisk?.total ?? 0);
|
||||
@@ -307,15 +319,19 @@ export function buildStackedDiskBarPresentation(
|
||||
(aggregateMode &&
|
||||
hasMultipleDisks &&
|
||||
maxLabelShort.length > 0 &&
|
||||
containerWidth >= estimateTextWidth(`${displayLabel} ${maxLabelShort}`));
|
||||
containerWidth >=
|
||||
estimateTextWidth(displayLabel, { detail: ` ${maxLabelShort}` }) + LABEL_PADDING_PX);
|
||||
// The anomaly marker shares the label's line whenever it renders.
|
||||
const anomalyMarker = props.anomaly?.description && anomalyRatio ? ` ${anomalyRatio}` : '';
|
||||
const showSublabel =
|
||||
displaySublabel.length > 0 &&
|
||||
containerWidth >=
|
||||
estimateTextWidth(
|
||||
`${displayLabel}${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${
|
||||
estimateTextWidth(`${displayLabel}${anomalyMarker}`, {
|
||||
detail: `${showMaxLabel ? ` ${maxLabelShort}` : ''} (${displaySublabel})${
|
||||
showDiskCount ? ` ${diskCountLabel}` : ''
|
||||
}`,
|
||||
);
|
||||
}) +
|
||||
LABEL_PADDING_PX;
|
||||
const barColor =
|
||||
aggregateMode && hasMultipleDisks && maxInfo
|
||||
? getMetricColorRgba(maxInfo.percent, 'disk', props.thresholds)
|
||||
|
||||
@@ -271,6 +271,9 @@ function getTooltipRows(
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Horizontal padding of the label chip inside the bar (px-1 in StackedMemoryBar.tsx).
|
||||
const LABEL_PADDING_PX = 8;
|
||||
|
||||
export function buildStackedMemoryBarPresentation(
|
||||
props: StackedMemoryBarProps,
|
||||
containerWidth: number,
|
||||
@@ -281,16 +284,21 @@ export function buildStackedMemoryBarPresentation(
|
||||
!props.unavailable && props.total > 0
|
||||
? `${formatBytes(props.used)}/${formatBytes(props.total)}`
|
||||
: '';
|
||||
const anomalyRatio = formatAnomalyRatio(props.anomaly) ?? '';
|
||||
// The anomaly marker shares the label's line whenever it renders.
|
||||
const anomalyMarker = props.anomaly?.description && anomalyRatio ? ` ${anomalyRatio}` : '';
|
||||
const showSublabel =
|
||||
displaySublabel.length > 0 &&
|
||||
containerWidth >= estimateTextWidth(`${displayLabel} (${displaySublabel})`);
|
||||
containerWidth >=
|
||||
estimateTextWidth(`${displayLabel}${anomalyMarker}`, { detail: ` (${displaySublabel})` }) +
|
||||
LABEL_PADDING_PX;
|
||||
|
||||
return {
|
||||
anomalyClass: props.anomaly
|
||||
? (ANOMALY_SEVERITY_CLASS[props.anomaly.severity] ?? 'text-yellow-400')
|
||||
: 'text-yellow-400',
|
||||
anomalyDescription: props.anomaly?.description,
|
||||
anomalyRatio: formatAnomalyRatio(props.anomaly) ?? '',
|
||||
anomalyRatio,
|
||||
displayLabel,
|
||||
displayPercentValue: utilizationPercent,
|
||||
displaySublabel,
|
||||
|
||||
@@ -58,24 +58,26 @@ describe('formatPowerOnHours - boundaries', () => {
|
||||
});
|
||||
|
||||
describe('estimateTextWidth - edge inputs', () => {
|
||||
it('counts whitespace characters as full-width', () => {
|
||||
expect(estimateTextWidth(' ')).toBe(13.5);
|
||||
expect(estimateTextWidth(' ')).toBe(19);
|
||||
it('gives a space its own narrow advance', () => {
|
||||
// A semibold space is 2.78px in the fallback advances.
|
||||
expect(estimateTextWidth(' ')).toBe(3);
|
||||
expect(estimateTextWidth(' ')).toBe(6);
|
||||
});
|
||||
|
||||
it('counts control characters (tab, newline) as length 1', () => {
|
||||
expect(estimateTextWidth('\t')).toBe(13.5);
|
||||
expect(estimateTextWidth('\n')).toBe(13.5);
|
||||
it('counts a glyph outside the measured ASCII range as a full em', () => {
|
||||
expect(estimateTextWidth('\t')).toBe(10);
|
||||
expect(estimateTextWidth('\n')).toBe(10);
|
||||
expect(estimateTextWidth('—')).toBe(10);
|
||||
});
|
||||
|
||||
it('counts surrogate pairs as length 2 (UTF-16 code units)', () => {
|
||||
it('counts surrogate pairs as two UTF-16 code units', () => {
|
||||
// "😀" is one code point but two UTF-16 code units -> length === 2
|
||||
expect('😀'.length).toBe(2);
|
||||
expect(estimateTextWidth('😀')).toBe(19);
|
||||
expect(estimateTextWidth('😀')).toBe(20);
|
||||
});
|
||||
|
||||
it('scales linearly for very long strings', () => {
|
||||
expect(estimateTextWidth('a'.repeat(100))).toBe(558);
|
||||
expect(estimateTextWidth('a'.repeat(100))).toBe(589); // 100 x 5.89
|
||||
});
|
||||
|
||||
it('is strictly monotonic with respect to length', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
formatPowerOnHours,
|
||||
estimateTextWidth,
|
||||
@@ -36,12 +36,75 @@ describe('formatPowerOnHours', () => {
|
||||
});
|
||||
|
||||
describe('estimateTextWidth', () => {
|
||||
it('estimates width based on character count', () => {
|
||||
expect(estimateTextWidth('')).toBe(8);
|
||||
expect(estimateTextWidth('a')).toBe(13.5);
|
||||
expect(estimateTextWidth('abc')).toBe(24.5);
|
||||
expect(estimateTextWidth('hello')).toBe(35.5); // 5 chars = 27.5 + 8
|
||||
expect(estimateTextWidth('hello world')).toBe(68.5); // 11 chars = 60.5 + 8
|
||||
// jsdom has no layout, so these read the measured fallback advances
|
||||
// (macOS system font at 10px) rather than a live probe.
|
||||
it('returns zero for empty text', () => {
|
||||
expect(estimateTextWidth('')).toBe(0);
|
||||
});
|
||||
|
||||
it('sums per-glyph advances and rounds up', () => {
|
||||
expect(estimateTextWidth('a')).toBe(6); // 5.89
|
||||
expect(estimateTextWidth('abc')).toBe(19); // 5.89 + 6.51 + 5.9 = 18.3
|
||||
});
|
||||
|
||||
it('tells wide glyphs from narrow ones at the same length', () => {
|
||||
expect(estimateTextWidth('888')).toBe(21); // 3 x 6.88
|
||||
expect(estimateTextWidth('...')).toBe(11); // 3 x 3.43
|
||||
expect(estimateTextWidth('MMM')).toBe(28); // 3 x 9.08
|
||||
expect(estimateTextWidth('iii')).toBe(9); // 3 x 2.86
|
||||
});
|
||||
|
||||
it('measures detail at normal weight after the semibold value', () => {
|
||||
// 60% is 23.7 semibold, " (265 GB/440 GB)" 86.89 at normal weight. The
|
||||
// browser renders that label 110.1px wide.
|
||||
expect(estimateTextWidth('60%', { detail: ' (265 GB/440 GB)' })).toBe(111);
|
||||
expect(estimateTextWidth('265 GB')).toBe(38);
|
||||
expect(estimateTextWidth('', { detail: '265 GB' })).toBe(37);
|
||||
});
|
||||
|
||||
it('scales with the label font size', () => {
|
||||
expect(estimateTextWidth('sda1 50%')).toBe(52); // 51.03
|
||||
expect(estimateTextWidth('sda1 50%', { fontPx: 9 })).toBe(46); // 45.93
|
||||
});
|
||||
});
|
||||
|
||||
describe('estimateTextWidth with layout', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('reads glyph advances from one hidden probe and reuses them', async () => {
|
||||
// A stand-in font: digits 8px, capitals 9px, everything else 4px, each
|
||||
// probe cell holding its glyph sixteen times.
|
||||
const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (
|
||||
this: Element,
|
||||
) {
|
||||
const text = this.textContent ?? '';
|
||||
const advance = /\d/.test(text[0]) ? 8 : /[A-Z]/.test(text[0]) ? 9 : 4;
|
||||
return { width: text.length * advance } as DOMRect;
|
||||
});
|
||||
vi.resetModules();
|
||||
const { estimateTextWidth: measured } = await import('@/utils/format');
|
||||
|
||||
// 8 digits, 4 capitals, 7 others.
|
||||
expect(measured('60%', { detail: ' (265 GB/440 GB)' })).toBe(8 * 8 + 4 * 9 + 7 * 4);
|
||||
// U+0020..U+007E at both weights, plus the ten tabular digits.
|
||||
expect(rect).toHaveBeenCalledTimes(95 + 10 + 95);
|
||||
expect(document.body.childElementCount).toBe(0);
|
||||
|
||||
measured('73%', { detail: ' (475 GB/648 GB)' });
|
||||
expect(rect).toHaveBeenCalledTimes(95 + 10 + 95);
|
||||
});
|
||||
|
||||
it('keeps the measured table when a stubbed layout reports every box alike', async () => {
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
width: 100,
|
||||
} as DOMRect);
|
||||
vi.resetModules();
|
||||
const { estimateTextWidth: measured } = await import('@/utils/format');
|
||||
|
||||
expect(measured('60%', { detail: ' (265 GB/440 GB)' })).toBe(111);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -283,13 +283,131 @@ export function formatPowerOnHours(hours: number, condensed = false): string {
|
||||
return condensed ? `${hours}h` : `${hours} hours`;
|
||||
}
|
||||
|
||||
// Metric bar labels are set at text-[10px]: the value semibold (with tabular
|
||||
// digits while it animates), any detail after it at normal weight. Glyph
|
||||
// widths in that type vary too much for a per-character constant (a digit is
|
||||
// more than twice as wide as a full stop), so label widths are summed from
|
||||
// per-glyph advances of the font the browser actually renders. A hidden probe
|
||||
// carrying the label's own type classes measures them once.
|
||||
const LABEL_FONT_PX = 10;
|
||||
const LABEL_PROBE_FIRST_CODE = 0x20;
|
||||
const LABEL_PROBE_LAST_CODE = 0x7e;
|
||||
const LABEL_PROBE_REPEAT = 16;
|
||||
|
||||
interface LabelGlyphAdvances {
|
||||
// Semibold; a digit takes the wider of its proportional and tabular advance.
|
||||
value: readonly number[];
|
||||
// Normal weight.
|
||||
detail: readonly number[];
|
||||
}
|
||||
|
||||
// Advances in px for U+0020..U+007E as the probe reads them in Chrome with the
|
||||
// macOS system font (2026-10-03). They stand in wherever layout is
|
||||
// unavailable, such as unit tests.
|
||||
const FALLBACK_LABEL_GLYPH_ADVANCES: LabelGlyphAdvances = {
|
||||
value: [
|
||||
2.78, 3.54, 5.51, 6.7, 6.7, 10.11, 7.46, 3.43, 4.25, 4.25, 4.92, 6.7, 3.43, 4.92, 3.43, 3.37,
|
||||
6.79, 6.7, 6.7, 6.7, 6.85, 6.7, 6.8, 6.7, 6.88, 6.8, 3.43, 3.43, 6.7, 6.7, 6.7, 5.55, 9.36,
|
||||
7.25, 6.93, 7.45, 7.52, 6.25, 6.01, 7.68, 7.82, 3.13, 5.92, 7.05, 5.98, 9.08, 7.71, 7.93, 6.73,
|
||||
7.93, 6.93, 6.75, 6.64, 7.65, 7.18, 10.08, 7.25, 7.03, 6.83, 4.25, 3.37, 4.25, 6.7, 5.88, 5.12,
|
||||
5.89, 6.51, 5.9, 6.51, 6.03, 4.08, 6.46, 6.3, 2.86, 2.86, 5.94, 2.93, 9.24, 6.25, 6.22, 6.47,
|
||||
6.47, 4.35, 5.55, 4.11, 6.25, 5.82, 8.35, 5.74, 5.91, 5.69, 4.25, 2.98, 4.25, 6.7,
|
||||
],
|
||||
detail: [
|
||||
2.93, 3.23, 4.9, 6.42, 6.42, 9.38, 7.24, 3.09, 3.94, 3.94, 4.84, 6.42, 3.09, 4.84, 3.09, 3.17,
|
||||
6.42, 4.76, 6.16, 6.39, 6.56, 6.3, 6.49, 6, 6.51, 6.49, 3.09, 3.09, 6.42, 6.42, 6.42, 5.25, 9.3,
|
||||
6.86, 6.69, 7.28, 7.39, 6.08, 5.84, 7.59, 7.54, 2.8, 5.5, 6.71, 5.8, 8.86, 7.54, 7.84, 6.47,
|
||||
7.84, 6.66, 6.49, 6.46, 7.5, 6.86, 9.8, 6.91, 6.67, 6.74, 3.94, 3.17, 3.94, 6.42, 5.59, 5.12,
|
||||
5.64, 6.26, 5.72, 6.26, 5.84, 3.74, 6.22, 6.01, 2.59, 2.59, 5.55, 2.65, 8.82, 5.96, 6.03, 6.23,
|
||||
6.22, 4.02, 5.27, 3.75, 5.96, 5.54, 7.87, 5.37, 5.55, 5.51, 3.94, 2.71, 3.94, 6.42,
|
||||
],
|
||||
};
|
||||
|
||||
let labelGlyphAdvances: LabelGlyphAdvances | undefined;
|
||||
let labelGlyphAdvancesPixelRatio = 0;
|
||||
|
||||
function measureLabelGlyphAdvances(): LabelGlyphAdvances | null {
|
||||
if (typeof document === 'undefined' || !document.body) return null;
|
||||
|
||||
const probe = document.createElement('div');
|
||||
probe.setAttribute('aria-hidden', 'true');
|
||||
probe.className =
|
||||
'pointer-events-none invisible fixed left-0 top-0 whitespace-pre font-sans text-[10px] leading-none';
|
||||
const addGroup = (className: string, firstCode: number, lastCode: number) => {
|
||||
const group = document.createElement('div');
|
||||
group.className = className;
|
||||
for (let code = firstCode; code <= lastCode; code++) {
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'w-max';
|
||||
cell.textContent = String.fromCharCode(code).repeat(LABEL_PROBE_REPEAT);
|
||||
group.appendChild(cell);
|
||||
}
|
||||
probe.appendChild(group);
|
||||
return group;
|
||||
};
|
||||
const semiboldGroup = addGroup('font-semibold', LABEL_PROBE_FIRST_CODE, LABEL_PROBE_LAST_CODE);
|
||||
const tabularGroup = addGroup('font-semibold tabular-nums', 0x30, 0x39);
|
||||
const normalGroup = addGroup('font-normal', LABEL_PROBE_FIRST_CODE, LABEL_PROBE_LAST_CODE);
|
||||
|
||||
document.body.appendChild(probe);
|
||||
const read = (group: HTMLElement) =>
|
||||
Array.from(group.children, (cell) => cell.getBoundingClientRect().width / LABEL_PROBE_REPEAT);
|
||||
const semibold = read(semiboldGroup);
|
||||
const tabular = read(tabularGroup);
|
||||
const normal = read(normalGroup);
|
||||
probe.remove();
|
||||
|
||||
// No layout (jsdom), or a stubbed one that reports every box alike.
|
||||
const at = (advances: number[], glyph: string) =>
|
||||
advances[glyph.charCodeAt(0) - LABEL_PROBE_FIRST_CODE];
|
||||
if (!(at(normal, '.') > 0 && at(normal, 'M') > at(normal, '.'))) return null;
|
||||
|
||||
const firstDigit = 0x30 - LABEL_PROBE_FIRST_CODE;
|
||||
tabular.forEach((advance, digit) => {
|
||||
semibold[firstDigit + digit] = Math.max(semibold[firstDigit + digit], advance);
|
||||
});
|
||||
return { value: semibold, detail: normal };
|
||||
}
|
||||
|
||||
function getLabelGlyphAdvances(): LabelGlyphAdvances {
|
||||
// Zoom changes how glyph advances round, so the table is per pixel ratio.
|
||||
const pixelRatio = typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1;
|
||||
if (!labelGlyphAdvances || pixelRatio !== labelGlyphAdvancesPixelRatio) {
|
||||
labelGlyphAdvances = measureLabelGlyphAdvances() ?? FALLBACK_LABEL_GLYPH_ADVANCES;
|
||||
labelGlyphAdvancesPixelRatio = pixelRatio;
|
||||
}
|
||||
return labelGlyphAdvances;
|
||||
}
|
||||
|
||||
function sumLabelGlyphAdvances(text: string, advances: readonly number[]): number {
|
||||
let width = 0;
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
// A glyph outside the measured range counts as a full em.
|
||||
width += advances[text.charCodeAt(index) - LABEL_PROBE_FIRST_CODE] ?? LABEL_FONT_PX;
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
export interface EstimateTextWidthOptions {
|
||||
/** Text that follows at normal weight, such as ` (265 GB/440 GB)`. */
|
||||
detail?: string;
|
||||
/** Label font size in px when it is not the bar label's 10px. */
|
||||
fontPx?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate rendered text width based on character count.
|
||||
* Estimate the rendered width in px of a metric bar label: `text` semibold,
|
||||
* `options.detail` after it at normal weight. The result is the text alone,
|
||||
* rounded up; callers add the padding their label sits in.
|
||||
* Used for determining if labels fit inside metric bars.
|
||||
* ALL text-width estimation MUST use this function.
|
||||
*/
|
||||
export function estimateTextWidth(text: string): number {
|
||||
return text.length * 5.5 + 8;
|
||||
export function estimateTextWidth(text: string, options: EstimateTextWidthOptions = {}): number {
|
||||
const advances = getLabelGlyphAdvances();
|
||||
const width =
|
||||
sumLabelGlyphAdvances(text, advances.value) +
|
||||
sumLabelGlyphAdvances(options.detail ?? '', advances.detail);
|
||||
return Math.ceil((width * (options.fontPx ?? LABEL_FONT_PX)) / LABEL_FONT_PX);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user