packages/web: allow selecting integer precision in visuals & comparison view

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idd4ed0b32ba827254de9c501685d5edb6a6a6964
This commit is contained in:
raf 2026-04-14 09:06:11 +03:00
commit 5af2457177
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
8 changed files with 143 additions and 48 deletions

View file

@ -1,23 +1,23 @@
export function formatBytes(bytes: number): string {
export function formatBytes(bytes: number, precision = 2): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log2(bytes) / 10);
return `${(bytes / (1 << (i * 10))).toFixed(2)} ${units[i]}`;
return `${(bytes / (1 << (i * 10))).toFixed(precision)} ${units[i]}`;
}
export function formatNumber(num: number): string {
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K';
export function formatNumber(num: number, precision = 2): string {
if (num >= 1e9) return (num / 1e9).toFixed(precision) + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(precision) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(precision) + 'K';
return num.toString();
}
export function formatTime(seconds: number): string {
if (seconds < 0.001) return (seconds * 1e6).toFixed(2) + 'μs';
if (seconds < 1) return (seconds * 1000).toFixed(2) + 'ms';
export function formatTime(seconds: number, precision = 2): string {
if (seconds < 0.001) return (seconds * 1e6).toFixed(precision) + 'μs';
if (seconds < 1) return (seconds * 1000).toFixed(precision) + 'ms';
return seconds.toFixed(3) + 's';
}
export function formatPercent(value: number): string {
return (value * 100).toFixed(2) + '%';
export function formatPercent(value: number, precision = 2): string {
return (value * 100).toFixed(precision) + '%';
}

View file

@ -46,7 +46,8 @@ const TOOLTIPS = {
attrSelect: 'Number of attribute selections performed (accessing .attr from an attribute set)',
};
const Analysis: Component<{ stats: StatsData }> = props => {
const Analysis: Component<{ stats: StatsData; precision?: number }> = props => {
const prec = () => props.precision ?? 2;
const totalMemory = createMemo(
() =>
props.stats.envs.bytes +
@ -105,18 +106,22 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="header-stats">
<MetricCard
label="CPU Time"
value={formatTime(props.stats.cpuTime)}
value={formatTime(props.stats.cpuTime, prec())}
tooltip={TOOLTIPS.cpuTime}
/>
<MetricCard label="Memory" value={formatBytes(totalMemory())} tooltip={TOOLTIPS.memory} />
<MetricCard
label="Memory"
value={formatBytes(totalMemory(), prec())}
tooltip={TOOLTIPS.memory}
/>
<MetricCard
label="Expressions"
value={formatNumber(props.stats.nrExprs)}
value={formatNumber(props.stats.nrExprs, prec())}
tooltip={TOOLTIPS.nrExprs}
/>
<MetricCard
label="Thunks"
value={`${formatNumber(props.stats.nrAvoided)} / ${formatNumber(props.stats.nrThunks)}`}
value={`${formatNumber(props.stats.nrAvoided, prec())} / ${formatNumber(props.stats.nrThunks, prec())}`}
tooltip="Avoided / Created thunks. Avoided means the value was already computed and reused."
highlight={props.stats.nrAvoided >= props.stats.nrThunks}
/>
@ -131,11 +136,11 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="gc-header">GC Statistics</div>
<div class="gc-values">
<span class="gc-label">Heap:</span>
<span class="gc-value">{formatBytes(props.stats.gc?.heapSize || 0)}</span>
<span class="gc-value">{formatBytes(props.stats.gc?.heapSize || 0, prec())}</span>
<span class="gc-label">Allocated:</span>
<span class="gc-value">{formatBytes(props.stats.gc?.totalBytes || 0)}</span>
<span class="gc-value">{formatBytes(props.stats.gc?.totalBytes || 0, prec())}</span>
<span class="gc-label">Cycles:</span>
<span class="gc-value">{formatNumber(props.stats.gc?.cycles || 0)}</span>
<span class="gc-value">{formatNumber(props.stats.gc?.cycles || 0, prec())}</span>
</div>
</div>
</Show>
@ -143,15 +148,15 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<Section title="Time & Thunks" collapsible>
<div class="time-thunks-combined">
<TimeChart stats={props.stats} />
<TimeChart stats={props.stats} precision={prec()} />
<div class="thunks-section">
<ThunkChart stats={props.stats} />
<ThunkChart stats={props.stats} precision={prec()} />
</div>
</div>
</Section>
<Section title="Operations" collapsible>
<OperationsChart stats={props.stats} />
<OperationsChart stats={props.stats} precision={prec()} />
</Section>
</div>
@ -160,17 +165,17 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="metrics-grid small">
<MetricCard
label="Count"
value={formatNumber(props.stats.envs.number)}
value={formatNumber(props.stats.envs.number, prec())}
tooltip={TOOLTIPS.envsNumber}
/>
<MetricCard
label="Elements"
value={formatNumber(props.stats.envs.elements)}
value={formatNumber(props.stats.envs.elements, prec())}
tooltip={TOOLTIPS.envsElements}
/>
<MetricCard
label="Memory"
value={formatBytes(props.stats.envs.bytes)}
value={formatBytes(props.stats.envs.bytes, prec())}
tooltip={TOOLTIPS.envsBytes}
/>
</div>
@ -180,22 +185,22 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="metrics-grid small">
<MetricCard
label="Values"
value={formatNumber(props.stats.values.number)}
value={formatNumber(props.stats.values.number, prec())}
tooltip={TOOLTIPS.valuesNumber}
/>
<MetricCard
label="Value Bytes"
value={formatBytes(props.stats.values.bytes)}
value={formatBytes(props.stats.values.bytes, prec())}
tooltip={TOOLTIPS.valuesBytes}
/>
<MetricCard
label="Symbols"
value={formatNumber(props.stats.symbols.number)}
value={formatNumber(props.stats.symbols.number, prec())}
tooltip={TOOLTIPS.symbolsNumber}
/>
<MetricCard
label="Symbol Bytes"
value={formatBytes(props.stats.symbols.bytes)}
value={formatBytes(props.stats.symbols.bytes, prec())}
tooltip={TOOLTIPS.symbolsBytes}
/>
</div>
@ -205,22 +210,22 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="metrics-grid small">
<MetricCard
label="List Elements"
value={formatNumber(props.stats.list.elements)}
value={formatNumber(props.stats.list.elements, prec())}
tooltip={TOOLTIPS.listElements}
/>
<MetricCard
label="Concat Ops"
value={formatNumber(props.stats.list.concats)}
value={formatNumber(props.stats.list.concats, prec())}
tooltip={TOOLTIPS.listConcats}
/>
<MetricCard
label="Set Count"
value={formatNumber(props.stats.sets.number)}
value={formatNumber(props.stats.sets.number, prec())}
tooltip={TOOLTIPS.setsNumber}
/>
<MetricCard
label="Attributes"
value={formatNumber(props.stats.sets.elements)}
value={formatNumber(props.stats.sets.elements, prec())}
tooltip={TOOLTIPS.setsElements}
/>
</div>
@ -235,7 +240,7 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="top-item">
<span class="rank">{i() + 1}</span>
<span class="name">{item.name}</span>
<span class="count">{formatNumber(item.count)}</span>
<span class="count">{formatNumber(item.count, prec())}</span>
</div>
)}
</For>
@ -251,7 +256,7 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="top-item">
<span class="rank">{i() + 1}</span>
<span class="name">{item.name || '<lambda>'}</span>
<span class="count">{formatNumber(item.count)}</span>
<span class="count">{formatNumber(item.count, prec())}</span>
<span class="location">
{item.file}:{item.line}
</span>
@ -267,22 +272,22 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="metrics-grid small">
<MetricCard
label="NAR Reads"
value={formatNumber(props.stats.narRead || 0)}
value={formatNumber(props.stats.narRead || 0, prec())}
tooltip={TOOLTIPS.narRead}
/>
<MetricCard
label="NAR Writes"
value={formatNumber(props.stats.narWrite || 0)}
value={formatNumber(props.stats.narWrite || 0, prec())}
tooltip={TOOLTIPS.narWrite}
/>
<MetricCard
label="Read Bytes"
value={formatBytes(props.stats.narReadBytes || 0)}
value={formatBytes(props.stats.narReadBytes || 0, prec())}
tooltip={TOOLTIPS.narReadBytes}
/>
<MetricCard
label="Write Bytes"
value={formatBytes(props.stats.narWriteBytes || 0)}
value={formatBytes(props.stats.narWriteBytes || 0, prec())}
tooltip={TOOLTIPS.narWriteBytes}
/>
</div>
@ -299,7 +304,7 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<span class="location">
{item.file}:{item.line}
</span>
<span class="count">{formatNumber(item.count)}</span>
<span class="count">{formatNumber(item.count, prec())}</span>
</div>
)}
</For>

View file

@ -10,9 +10,11 @@ interface ComparisonViewProps {
entries: ComparisonEntry[];
onSelect: (entry: ComparisonEntry) => void;
onDelete: (id: number) => void;
precision?: number;
}
const ComparisonView: Component<ComparisonViewProps> = props => {
const prec = () => props.precision ?? 2;
const [leftEntry, setLeftEntry] = createSignal<ComparisonEntry | null>(null);
const [rightEntry, setRightEntry] = createSignal<ComparisonEntry | null>(null);
@ -203,7 +205,7 @@ const ComparisonView: Component<ComparisonViewProps> = props => {
<Show when={!row.isReduction}>
<ArrowUpIcon size={14} />
</Show>
{Math.abs(row.change).toFixed(2)}%
{Math.abs(row.change).toFixed(prec())}%
</span>
</Show>
}

View file

@ -4,9 +4,11 @@ import { formatNumber } from '@ns/ui-utils';
interface OperationsChartProps {
stats: StatsData;
precision?: number;
}
const OperationsChart: Component<OperationsChartProps> = props => {
const prec = () => props.precision ?? 2;
const operations = createMemo(() =>
[
{ label: 'Lookups', value: props.stats.nrLookups, colorClass: 'chart-1' },
@ -34,7 +36,7 @@ const OperationsChart: Component<OperationsChartProps> = props => {
}}
/>
</div>
<div class="op-value">{formatNumber(item.value)}</div>
<div class="op-value">{formatNumber(item.value, prec())}</div>
</div>
)}
</For>

View file

@ -4,9 +4,11 @@ import { formatNumber } from '@ns/ui-utils';
interface ThunkChartProps {
stats: StatsData;
precision?: number;
}
const ThunkChart: Component<ThunkChartProps> = props => {
const prec = () => props.precision ?? 2;
const maxValue = createMemo(() => Math.max(props.stats.nrThunks, props.stats.nrAvoided));
const avoidedRatio = createMemo(() => {
@ -47,7 +49,7 @@ const ThunkChart: Component<ThunkChartProps> = props => {
<div class="ratio-bar">
<div class="ratio-fill" style={{ width: `${avoidedRatio() * 100}%` }} />
</div>
<span class="ratio-label">Avoidance rate: {(avoidedRatio() * 100).toFixed(1)}%</span>
<span class="ratio-label">Avoidance rate: {(avoidedRatio() * 100).toFixed(prec())}%</span>
</div>
</div>
);

View file

@ -4,9 +4,11 @@ import { formatBytes, formatTime, formatPercent } from '@ns/ui-utils';
interface TimeChartProps {
stats: StatsData;
precision?: number;
}
const TimeChart: Component<TimeChartProps> = props => {
const prec = () => props.precision ?? 2;
const timeData = createMemo(() => {
const cpu = props.stats.time.cpu;
const gc = props.stats.time.gc || 0;
@ -45,7 +47,7 @@ const TimeChart: Component<TimeChartProps> = props => {
}}
/>
</div>
<div class="time-bar-value">{formatTime(item.value)}</div>
<div class="time-bar-value">{formatTime(item.value, prec())}</div>
</div>
)}
</For>
@ -59,11 +61,13 @@ const TimeChart: Component<TimeChartProps> = props => {
</div>
<div class="gc-stat">
<span class="gc-stat-label">Heap Size</span>
<span class="gc-stat-value">{formatBytes(props.stats.gc?.heapSize || 0)}</span>
<span class="gc-stat-value">{formatBytes(props.stats.gc?.heapSize || 0, prec())}</span>
</div>
<div class="gc-stat">
<span class="gc-stat-label">GC Fraction</span>
<span class="gc-stat-value">{formatPercent(props.stats.time.gcFraction || 0)}</span>
<span class="gc-stat-value">
{formatPercent(props.stats.time.gcFraction || 0, prec())}
</span>
</div>
</div>
</Show>

View file

@ -31,6 +31,7 @@ function App() {
const [showSaveDialog, setShowSaveDialog] = createSignal(false);
const [showHelp, setShowHelp] = createSignal(false);
const [showManageSnapshots, setShowManageSnapshots] = createSignal(false);
const [precision, setPrecision] = createSignal(2);
const [isLoading, setIsLoading] = createSignal(true);
const STORAGE_KEY = 'ns-data';
@ -58,6 +59,9 @@ function App() {
if (parsed.view) {
setView(parsed.view);
}
if (typeof parsed.precision === 'number' && parsed.precision >= 0) {
setPrecision(parsed.precision);
}
}
} catch (e) {
console.warn('Failed to load saved data:', e);
@ -84,6 +88,7 @@ function App() {
raw: Record<string, unknown> | null,
snaps: ComparisonEntry[],
v: 'analysis' | 'compare',
prec: number,
) => {
try {
localStorage.setItem(
@ -93,6 +98,7 @@ function App() {
currentStats: stats,
currentRaw: raw,
view: v,
precision: prec,
}),
);
} catch (e) {
@ -108,10 +114,11 @@ function App() {
const raw = currentRaw();
const snaps = snapshots();
const v = view();
const prec = precision();
if (isLoading()) return;
saveToStorage(stats, raw, snaps, v);
saveToStorage(stats, raw, snaps, v, prec);
});
const saveSnapshot = () => {
@ -190,6 +197,16 @@ function App() {
<Trash2Icon size={16} />
</button>
</Show>
<select
class="precision-select"
value={precision()}
onChange={e => setPrecision(parseInt(e.currentTarget.value, 10))}
title="Decimal Precision"
>
<For each={[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}>
{p => <option value={p}>{p} dp</option>}
</For>
</select>
</nav>
</header>
@ -213,7 +230,7 @@ function App() {
/>
</Show>
<Show when={currentStats()}>
<Analysis stats={currentStats()!} />
<Analysis stats={currentStats()!} precision={precision()} />
<Show when={showSaveDialog()}>
<div class="modal-overlay" onClick={() => setShowSaveDialog(false)}>
<div class="modal" onClick={e => e.stopPropagation()}>
@ -246,6 +263,7 @@ function App() {
entries={snapshots()}
onSelect={entry => setCurrentStats(entry.data)}
onDelete={deleteSnapshot}
precision={precision()}
/>
</Show>
</Show>

View file

@ -1628,3 +1628,65 @@ body {
display: none;
}
}
.precision-select {
padding: 0.5rem 1.5rem 0.5rem 0.75rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
color: var(--text-secondary);
font-size: 0.875rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
}
.precision-select:hover {
border-color: var(--text-muted);
color: var(--text-primary);
}
.precision-select:focus {
outline: none;
border-color: var(--accent);
}
.settings-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
.precision-input {
width: 120px;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
color: var(--text-primary);
font-size: 0.9375rem;
text-align: center;
}
.precision-input:focus {
outline: none;
border-color: var(--accent);
}
/* Hide number input spinners */
.precision-input::-webkit-outer-spin-button,
.precision-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.precision-input[type='number'] {
-moz-appearance: textfield;
}
.settings-hint {
font-size: 0.75rem;
color: var(--text-muted);
}