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'; if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB']; const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log2(bytes) / 10); 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 { export function formatNumber(num: number, precision = 2): string {
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B'; if (num >= 1e9) return (num / 1e9).toFixed(precision) + 'B';
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'; if (num >= 1e6) return (num / 1e6).toFixed(precision) + 'M';
if (num >= 1e3) return (num / 1e3).toFixed(2) + 'K'; if (num >= 1e3) return (num / 1e3).toFixed(precision) + 'K';
return num.toString(); return num.toString();
} }
export function formatTime(seconds: number): string { export function formatTime(seconds: number, precision = 2): string {
if (seconds < 0.001) return (seconds * 1e6).toFixed(2) + 'μs'; if (seconds < 0.001) return (seconds * 1e6).toFixed(precision) + 'μs';
if (seconds < 1) return (seconds * 1000).toFixed(2) + 'ms'; if (seconds < 1) return (seconds * 1000).toFixed(precision) + 'ms';
return seconds.toFixed(3) + 's'; return seconds.toFixed(3) + 's';
} }
export function formatPercent(value: number): string { export function formatPercent(value: number, precision = 2): string {
return (value * 100).toFixed(2) + '%'; 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)', 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( const totalMemory = createMemo(
() => () =>
props.stats.envs.bytes + props.stats.envs.bytes +
@ -105,18 +106,22 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="header-stats"> <div class="header-stats">
<MetricCard <MetricCard
label="CPU Time" label="CPU Time"
value={formatTime(props.stats.cpuTime)} value={formatTime(props.stats.cpuTime, prec())}
tooltip={TOOLTIPS.cpuTime} tooltip={TOOLTIPS.cpuTime}
/> />
<MetricCard label="Memory" value={formatBytes(totalMemory())} tooltip={TOOLTIPS.memory} /> <MetricCard
label="Memory"
value={formatBytes(totalMemory(), prec())}
tooltip={TOOLTIPS.memory}
/>
<MetricCard <MetricCard
label="Expressions" label="Expressions"
value={formatNumber(props.stats.nrExprs)} value={formatNumber(props.stats.nrExprs, prec())}
tooltip={TOOLTIPS.nrExprs} tooltip={TOOLTIPS.nrExprs}
/> />
<MetricCard <MetricCard
label="Thunks" 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." tooltip="Avoided / Created thunks. Avoided means the value was already computed and reused."
highlight={props.stats.nrAvoided >= props.stats.nrThunks} 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-header">GC Statistics</div>
<div class="gc-values"> <div class="gc-values">
<span class="gc-label">Heap:</span> <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-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-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>
</div> </div>
</Show> </Show>
@ -143,15 +148,15 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<Section title="Time & Thunks" collapsible> <Section title="Time & Thunks" collapsible>
<div class="time-thunks-combined"> <div class="time-thunks-combined">
<TimeChart stats={props.stats} /> <TimeChart stats={props.stats} precision={prec()} />
<div class="thunks-section"> <div class="thunks-section">
<ThunkChart stats={props.stats} /> <ThunkChart stats={props.stats} precision={prec()} />
</div> </div>
</div> </div>
</Section> </Section>
<Section title="Operations" collapsible> <Section title="Operations" collapsible>
<OperationsChart stats={props.stats} /> <OperationsChart stats={props.stats} precision={prec()} />
</Section> </Section>
</div> </div>
@ -160,17 +165,17 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="metrics-grid small"> <div class="metrics-grid small">
<MetricCard <MetricCard
label="Count" label="Count"
value={formatNumber(props.stats.envs.number)} value={formatNumber(props.stats.envs.number, prec())}
tooltip={TOOLTIPS.envsNumber} tooltip={TOOLTIPS.envsNumber}
/> />
<MetricCard <MetricCard
label="Elements" label="Elements"
value={formatNumber(props.stats.envs.elements)} value={formatNumber(props.stats.envs.elements, prec())}
tooltip={TOOLTIPS.envsElements} tooltip={TOOLTIPS.envsElements}
/> />
<MetricCard <MetricCard
label="Memory" label="Memory"
value={formatBytes(props.stats.envs.bytes)} value={formatBytes(props.stats.envs.bytes, prec())}
tooltip={TOOLTIPS.envsBytes} tooltip={TOOLTIPS.envsBytes}
/> />
</div> </div>
@ -180,22 +185,22 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="metrics-grid small"> <div class="metrics-grid small">
<MetricCard <MetricCard
label="Values" label="Values"
value={formatNumber(props.stats.values.number)} value={formatNumber(props.stats.values.number, prec())}
tooltip={TOOLTIPS.valuesNumber} tooltip={TOOLTIPS.valuesNumber}
/> />
<MetricCard <MetricCard
label="Value Bytes" label="Value Bytes"
value={formatBytes(props.stats.values.bytes)} value={formatBytes(props.stats.values.bytes, prec())}
tooltip={TOOLTIPS.valuesBytes} tooltip={TOOLTIPS.valuesBytes}
/> />
<MetricCard <MetricCard
label="Symbols" label="Symbols"
value={formatNumber(props.stats.symbols.number)} value={formatNumber(props.stats.symbols.number, prec())}
tooltip={TOOLTIPS.symbolsNumber} tooltip={TOOLTIPS.symbolsNumber}
/> />
<MetricCard <MetricCard
label="Symbol Bytes" label="Symbol Bytes"
value={formatBytes(props.stats.symbols.bytes)} value={formatBytes(props.stats.symbols.bytes, prec())}
tooltip={TOOLTIPS.symbolsBytes} tooltip={TOOLTIPS.symbolsBytes}
/> />
</div> </div>
@ -205,22 +210,22 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="metrics-grid small"> <div class="metrics-grid small">
<MetricCard <MetricCard
label="List Elements" label="List Elements"
value={formatNumber(props.stats.list.elements)} value={formatNumber(props.stats.list.elements, prec())}
tooltip={TOOLTIPS.listElements} tooltip={TOOLTIPS.listElements}
/> />
<MetricCard <MetricCard
label="Concat Ops" label="Concat Ops"
value={formatNumber(props.stats.list.concats)} value={formatNumber(props.stats.list.concats, prec())}
tooltip={TOOLTIPS.listConcats} tooltip={TOOLTIPS.listConcats}
/> />
<MetricCard <MetricCard
label="Set Count" label="Set Count"
value={formatNumber(props.stats.sets.number)} value={formatNumber(props.stats.sets.number, prec())}
tooltip={TOOLTIPS.setsNumber} tooltip={TOOLTIPS.setsNumber}
/> />
<MetricCard <MetricCard
label="Attributes" label="Attributes"
value={formatNumber(props.stats.sets.elements)} value={formatNumber(props.stats.sets.elements, prec())}
tooltip={TOOLTIPS.setsElements} tooltip={TOOLTIPS.setsElements}
/> />
</div> </div>
@ -235,7 +240,7 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="top-item"> <div class="top-item">
<span class="rank">{i() + 1}</span> <span class="rank">{i() + 1}</span>
<span class="name">{item.name}</span> <span class="name">{item.name}</span>
<span class="count">{formatNumber(item.count)}</span> <span class="count">{formatNumber(item.count, prec())}</span>
</div> </div>
)} )}
</For> </For>
@ -251,7 +256,7 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="top-item"> <div class="top-item">
<span class="rank">{i() + 1}</span> <span class="rank">{i() + 1}</span>
<span class="name">{item.name || '<lambda>'}</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"> <span class="location">
{item.file}:{item.line} {item.file}:{item.line}
</span> </span>
@ -267,22 +272,22 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<div class="metrics-grid small"> <div class="metrics-grid small">
<MetricCard <MetricCard
label="NAR Reads" label="NAR Reads"
value={formatNumber(props.stats.narRead || 0)} value={formatNumber(props.stats.narRead || 0, prec())}
tooltip={TOOLTIPS.narRead} tooltip={TOOLTIPS.narRead}
/> />
<MetricCard <MetricCard
label="NAR Writes" label="NAR Writes"
value={formatNumber(props.stats.narWrite || 0)} value={formatNumber(props.stats.narWrite || 0, prec())}
tooltip={TOOLTIPS.narWrite} tooltip={TOOLTIPS.narWrite}
/> />
<MetricCard <MetricCard
label="Read Bytes" label="Read Bytes"
value={formatBytes(props.stats.narReadBytes || 0)} value={formatBytes(props.stats.narReadBytes || 0, prec())}
tooltip={TOOLTIPS.narReadBytes} tooltip={TOOLTIPS.narReadBytes}
/> />
<MetricCard <MetricCard
label="Write Bytes" label="Write Bytes"
value={formatBytes(props.stats.narWriteBytes || 0)} value={formatBytes(props.stats.narWriteBytes || 0, prec())}
tooltip={TOOLTIPS.narWriteBytes} tooltip={TOOLTIPS.narWriteBytes}
/> />
</div> </div>
@ -299,7 +304,7 @@ const Analysis: Component<{ stats: StatsData }> = props => {
<span class="location"> <span class="location">
{item.file}:{item.line} {item.file}:{item.line}
</span> </span>
<span class="count">{formatNumber(item.count)}</span> <span class="count">{formatNumber(item.count, prec())}</span>
</div> </div>
)} )}
</For> </For>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1628,3 +1628,65 @@ body {
display: none; 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);
}