Files
horizon-airdef/tools/Compare-Benchmarks.html
T

247 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; base-uri 'none'; form-action 'none'">
<title>Horizon Lab — Compare runs</title>
<style>
:root{color-scheme:light;--ink:#192920;--muted:#65726a;--line:#d8ded8;--paper:#f6f7f3;--base:#247854;--candidate:#bc642f}
*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1100px;margin:auto;padding:40px 28px 70px}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:26px}.brand{display:flex;align-items:center;gap:11px;font-weight:700}.mark{width:30px;height:30px;background:var(--base);box-shadow:inset 0 -10px #195a3c,inset -10px 0 #2a8c62}.badge{border:1px solid var(--line);padding:4px 10px;border-radius:20px;color:var(--muted);font-size:12px}h1{font-size:clamp(30px,5vw,44px);letter-spacing:-1.3px;line-height:1.15;margin:0 0 10px}h2{font-size:20px;letter-spacing:-.3px;margin:0}p{margin:8px 0;color:var(--muted)}.intro{max-width:750px;margin-bottom:26px}.uploads{display:grid;grid-template-columns:1fr 1fr;gap:18px}.card{background:white;border:1px solid var(--line);border-radius:12px;padding:22px;min-width:0}.card-top{display:flex;gap:9px;align-items:center;margin-bottom:6px}.dot{width:9px;height:9px;border-radius:50%;background:var(--base)}.candidate .dot{background:var(--candidate)}input[type=file]{width:100%;font:inherit;font-size:13px;color:var(--muted);margin-top:15px}input::file-selector-button,button{font:inherit;font-size:13px;background:white;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:9px 13px;cursor:pointer;margin-right:10px}button:disabled{opacity:.45;cursor:default}input:focus-visible,button:focus-visible{outline:3px solid #80b998;outline-offset:3px}.status{font-size:13px;overflow-wrap:anywhere;margin-top:14px;min-height:20px}.error{color:#a23624}.details{font-size:12px;color:var(--muted);margin:12px 0 0;display:grid;grid-template-columns:100px 1fr;gap:4px 12px;overflow-wrap:anywhere}.details dt,.details dd{margin:0}.details dt{color:var(--ink)}.empty{border:1px dashed var(--line);border-radius:12px;padding:34px;text-align:center;margin-top:22px}.section{margin-top:24px}.section-top{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px}.notice{background:#fff8e8;border:1px solid #e7d9af;border-radius:9px;padding:14px 18px;margin-bottom:18px;font-size:13px}.notice p{color:#685c3f;margin:3px 0}.notice ul{padding-left:18px;margin:8px 0}.metrics{overflow:auto;border:1px solid var(--line);border-radius:10px;background:white}table{width:100%;border-collapse:collapse;white-space:nowrap;text-align:right;font-variant-numeric:tabular-nums}th,td{padding:12px 18px;border-bottom:1px solid #edf0ea}th{font-size:12px;font-weight:600;color:var(--muted)}th:first-child,td:first-child{text-align:left}tr:last-child td{border-bottom:0}td:first-child{font-weight:500}td:last-child{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}.chart{padding:20px;background:white;border:1px solid var(--line);border-radius:10px}.chart svg{display:block;width:100%;height:auto}.chart-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px}.chart-tools label{font-size:13px;color:var(--muted)}.chart-info{min-height:22px;font-size:13px;color:var(--muted);font-variant-numeric:tabular-nums}.footnote{font-size:12px;margin-top:15px;max-width:850px}.hidden{display:none!important}footer{margin-top:30px;border-top:1px solid var(--line);padding-top:16px;color:var(--muted);font-size:12px}.legend{display:flex;gap:18px;font-size:12px}.legend span{display:flex;gap:6px;align-items:center}@media(max-width:650px){main{padding:24px 16px 40px}.uploads{grid-template-columns:1fr}.card{padding:18px}header{align-items:flex-start}.section-top{align-items:flex-start}th,td{padding:10px 12px}.badge{white-space:nowrap;font-size:11px}.chart{padding:12px}}
</style>
<main>
<header><div class="brand"><span class="mark" aria-hidden="true"></span>Horizon Lab</div><span class="badge">Offline · files stay on your device</span></header>
<h1>Compare your runs.</h1>
<p class="intro">Choose two Horizon benchmark JSON files from your instance’s <strong>horizon-benchmarks</strong> folder. Add each matching CSV to see frame spikes. Compare frames with frames, or server ticks with server ticks.</p>
<div class="uploads">
<section class="card" aria-labelledby="baselineTitle"><div class="card-top"><span class="dot" aria-hidden="true"></span><h2 id="baselineTitle">Baseline</h2></div><p>Vanilla or your previous configuration.</p><input id="baselineFiles" type="file" multiple accept=".json,.csv" aria-label="Choose baseline JSON and optional CSV"><div id="baselineStatus" class="status" role="status">No report selected.</div><dl id="baselineDetails" class="details"></dl></section>
<section class="card candidate" aria-labelledby="candidateTitle"><div class="card-top"><span class="dot" aria-hidden="true"></span><h2 id="candidateTitle">Candidate</h2></div><p>The configuration you want to evaluate.</p><input id="candidateFiles" type="file" multiple accept=".json,.csv" aria-label="Choose candidate JSON and optional CSV"><div id="candidateStatus" class="status" role="status">No report selected.</div><dl id="candidateDetails" class="details"></dl></section>
</div>
<div id="empty" class="empty"><strong>Your measurements go here.</strong><p>Record with F8 in the game. Keep the world, route, graphics settings and warm-up consistent.</p></div>
<p id="comparisonError" class="error hidden" role="alert"></p>
<section id="results" class="section hidden">
<div class="section-top"><h2 id="resultsTitle">Frame comparison</h2><button id="export" type="button">Save comparison</button></div>
<div id="notices" class="notice"></div>
<div class="metrics"><table aria-label="Benchmark comparison"><thead><tr><th>Measurement</th><th>Baseline</th><th>Candidate</th><th>Change</th></tr></thead><tbody id="metricRows"></tbody></table></div>
<p class="footnote">Changes are candidate versus baseline. Higher FPS is better; lower frame/tick duration, memory and CPU use is usually better. Check workload differences before attributing any change to an optimization.</p>
<section id="chartSection" class="section hidden"><div class="chart"><div class="chart-tools"><h2 id="chartTitle">Frame pacing</h2><label><input id="logScale" type="checkbox" checked> Log scale</label></div><div class="legend"><span><i class="dot"></i>Baseline</span><span><i class="dot" style="background:var(--candidate)"></i>Candidate</span></div><svg id="timeline" viewBox="0 0 960 300" role="img" aria-label="Recorded duration timeline"></svg><div id="chartInfo" class="chart-info">Move over the chart to inspect a time bucket.</div><p id="chartNote" class="footnote"></p></div></section>
<p id="csvHint" class="footnote"></p>
</section>
<footer>No installation, account or network access is needed. Repeat at least three matched runs and test fresh terrain separately from a warm cache. Heap/nonheap exclude native allocations. Process RSS is not a complete GPU/unified-memory measurement.</footer>
</main>
<script>
'use strict';
const SETTINGS=['minecraft','kind','os','os_version','architecture','gpu','graphics_backend','graphics_driver','java_version','heap_max_bytes','framebuffer_width','framebuffer_height','render_distance','simulation_distance','vsync','fps_limit','graphics_preset','ambient_occlusion','improved_transparency','mipmap_levels','entity_distance_scaling','entity_shadows','particles','clouds','cloud_range','fov','world_difficulty','dh_capture_status','dh_rendering_enabled','dh_renderer_mode','dh_lod_radius_chunks','dh_horizontal_quality','dh_vertical_quality','dh_max_horizontal_resolution','dh_transparency','dh_lod_shading','dh_lod_only_mode','dh_camera_quality_dropoff','dh_ambient_occlusion','iris_capture_status','iris_shaders_enabled','iris_shader_pack'];
const state={baseline:null,candidate:null},generation={baseline:0,candidate:0};
let comparison=null,chartData=null;
const $=id=>document.getElementById(id);
const number=value=>typeof value==='number'&&Number.isFinite(value);
const text=(tag,value)=>{const element=document.createElement(tag);element.textContent=String(value);return element;};
const format=value=>number(value)?new Intl.NumberFormat(undefined,{maximumFractionDigits:2}).format(value):'—';
function validateReport(report){
if(!report||report.schema!==1||!['frames','ticks'].includes(report.kind))throw Error('Choose a Horizon frame or server-tick JSON report.');
if(!report.stats||!Number.isInteger(report.stats.count)||report.stats.count<1||report.stats.count>300000)throw Error('This report has no valid sample count. Record focused gameplay with F8 first.');
for(const key of ['meanRate','lowOnePercentRate','meanMs','p50Ms','p95Ms','p99Ms','maxMs']){
if(!number(report.stats[key])||report.stats[key]<0)throw Error('This report has missing or invalid timing measurements.');
}
if(!number(report.elapsed_seconds)||report.elapsed_seconds<=0)throw Error('This report has an invalid recording duration.');
return report;
}
function parseCsv(source,count){
const lines=source.trim().split(/\r?\n/);
if(lines.shift()!=='sample,duration_ns')throw Error('CSV header does not match a Horizon recording.');
if(lines.length!==count||lines.length>300000)throw Error('CSV sample count does not match this JSON report.');
return lines.map((line,index)=>{
const fields=line.split(','),sample=Number(fields[0]),duration=Number(fields[1]);
if(fields.length!==2||sample!==index||!Number.isSafeInteger(duration)||duration<=0)throw Error('CSV contains an invalid sample.');
return duration/1e6;
});
}
function softwareWarnings(a,b){
const warnings=[];
for(const key of ['java_vendor','java_vm_name','java_runtime_version']){
if(![a,b].every(r=>typeof r[key]==='string'&&r[key].length))warnings.push(key+' was not captured in both reports; Java implementation equivalence is unknown.');
else if(a[key]!==b[key])warnings.push('Software '+key+' differs. Treat it as an explicit experiment variable.');
}
const mods=[a,b].map(r=>Array.isArray(r.mods)&&r.mods.every(entry=>typeof entry==='string'&&entry.length)?new Set(r.mods):null);
if(mods.some(entries=>entries===null))warnings.push('Loaded mod versions were not captured in both reports; software equivalence is unknown.');
else{
const only=[...mods[0]].filter(entry=>!mods[1].has(entry)).sort(),other=[...mods[1]].filter(entry=>!mods[0].has(entry)).sort();
if(only.length||other.length){
warnings.push('Loaded mod versions differ. Treat added, removed or upgraded mods as explicit experiment variables.');
for(const [label,entries] of [['baseline',only],['candidate',other]])if(entries.length)warnings.push('Only '+label+': '+entries.slice(0,12).join(', ')+(entries.length>12?'; '+(entries.length-12)+' more':'')+'.');
}
}
return warnings;
}
function compareReports(a,b){
if(a.kind!==b.kind)throw Error('Compare frame runs with frame runs, or tick runs with tick runs.');
const differences=SETTINGS.filter(key=>JSON.stringify(a[key])!==JSON.stringify(b[key])).map(key=>({setting:key,baseline:a[key]??null,candidate:b[key]??null}));
if(a.kind==='ticks'&&a.peak_players!==b.peak_players)differences.push({setting:'peak_players',baseline:a.peak_players??null,candidate:b.peak_players??null});
const warnings=['World, camera route, shaders and terrain-cache warm-up still need to match. These are not verified by the captured settings.'];
warnings.push(...softwareWarnings(a,b));
for(const key of ['compact_object_headers','garbage_collectors']){
if(!(key in a)||!(key in b))warnings.push(key+' was not captured in both reports; JVM configuration equivalence is unknown.');
else if(JSON.stringify(a[key])!==JSON.stringify(b[key]))warnings.push('JVM '+key+' differs. Treat it as an explicit experiment variable.');
}
if(a.kind==='frames'){
for(const key of ['adaptive_budget_enabled','adaptive_target_fps','precise_worker_pacing','debounce_queue_wait','wakeable_idle_wait','dispatch_retry_backoff','tick_membership_dedup','empty_culling_whitelist','culling_ray_scratch','terrain_position_arithmetic','interruptible_terrain_read','dh_thread_count','dh_runtime_ratio']){
if(key in a&&key in b&&JSON.stringify(a[key])!==JSON.stringify(b[key]))warnings.push('Experiment '+key+' differs.');
}
for(const [label,r] of [['Baseline',a],['Candidate',b]]){
if(!['available','not_installed'].includes(r.dh_capture_status))warnings.push(label+' distant-terrain configuration was not captured; quality equivalence is unknown.');
if(!['available','not_installed'].includes(r.iris_capture_status))warnings.push(label+' Iris state was not captured; shader equivalence is unknown.');
if(Array.isArray(r.visual_settings_changed)&&r.visual_settings_changed.length){
differences.push({setting:label+' visual settings changed during recording',baseline:label==='Baseline'?r.visual_settings_changed:null,candidate:label==='Candidate'?r.visual_settings_changed:null});
warnings.push(label+' visual settings changed between capture boundaries: '+r.visual_settings_changed.join(', ')+'.');
}
}
}
if(Math.abs(a.elapsed_seconds-b.elapsed_seconds)>Math.max(2,Math.min(a.elapsed_seconds,b.elapsed_seconds)*.1))warnings.push('Recording durations differ. Match durations when comparing memory peaks and collection totals.');
for(const [label,r] of [['Baseline',a],['Candidate',b]]){
if(r.elapsed_seconds<60)warnings.push(label+' recording is shorter than 60 seconds. Use longer repeated runs.');
if(r.dropped_samples)warnings.push(label+' exceeded the recording buffer; '+r.dropped_samples+' samples were dropped.');
if(r.kind==='frames'&&r.excluded_frames)warnings.push(label+' excluded '+r.excluded_frames+' menu, paused or unfocused frame starts. Process CPU still spans the whole recording.');
}
const rows=[];
const add=(label,key,unit,scale=1,stats=false)=>{
const av=stats?a.stats[key]:a[key],bv=stats?b.stats[key]:b[key];
if(number(av)&&number(bv))rows.push({label,key,unit,baseline:av/scale,candidate:bv/scale,change_percent:av?100*(bv/av-1):null});
};
if(a.kind==='frames'){
add('Average FPS','meanRate','FPS',1,true);add('Slowest 1% average','lowOnePercentRate','FPS',1,true);
}else{
add('Average tick duration','meanMs','ms',1,true);add('Observed tick rate','observed_ticks_per_second','TPS');
warnings.push('Tick durations exclude scheduler sleep. Observed TPS includes recording time spent paused; tick execution capacity is not TPS.');
}
add('95th percentile duration','p95Ms','ms',1,true);add('99th percentile duration','p99Ms','ms',1,true);add('Longest duration','maxMs','ms',1,true);
add('Sampled peak process RSS','process_rss_peak_sampled_bytes','MiB',1048576);add('Last sampled process RSS','process_rss_last_sampled_bytes','MiB',1048576);
add('Sampled peak JVM heap','heap_used_peak_sampled_bytes','MiB',1048576);add('JVM heap at end','heap_used_end_bytes','MiB',1048576);
add('Sampled peak nonheap','nonheap_used_peak_sampled_bytes','MiB',1048576);add('Average process CPU','process_cpu_mean_cores','cores');
if(number(a.gc_time_ms)&&number(b.gc_time_ms)){
const av=a.gc_time_ms/a.elapsed_seconds,bv=b.gc_time_ms/b.elapsed_seconds;
rows.push({label:'GC collection time / second',key:'gc_collection_ms_per_second',unit:'ms/s',baseline:av,candidate:bv,change_percent:av?100*(bv/av-1):null});
}
warnings.push('Memory peaks are sampled and can miss short spikes. RSS includes resident JVM and native pages, but is not a complete GPU/unified-memory measurement. Heap/nonheap measure JVM pools only. CPU includes the whole process and integrated server.');
warnings.push('GC collection time comes from collector MXBean counters. It may include concurrent cycles and overlapping collector phases; it is not a measured stop-the-world pause total. Use JFR pause events for that comparison.');
for(const [label,r] of [['Baseline',a],['Candidate',b]])if(number(r.process_rss_last_sample_age_ms)&&r.process_rss_last_sample_age_ms>2500)warnings.push(label+' resident-memory sample is '+format(r.process_rss_last_sample_age_ms/1000)+' seconds old. The background reader may be slow or unavailable.');
return{kind:a.kind,differences,warnings,metrics:rows};
}
function renderDetails(side){
const entry=state[side],target=$(side+'Details');target.replaceChildren();
if(!entry)return;
const r=entry.report,fields=[['Profile',r.profile||r.label||'Unlabeled'],['Recording',format(r.elapsed_seconds)+' s · '+format(r.stats.count)+' samples'],['Platform',[r.os,r.architecture].filter(Boolean).join(' ')||'Not captured']];
if(r.kind==='frames')fields.push(['Graphics',[r.gpu,r.graphics_backend].filter(Boolean).join(' · ')||'Not captured'],['Window',number(r.framebuffer_width)?r.framebuffer_width+' × '+r.framebuffer_height:'Not captured'],['Experiments','Adaptive: '+(r.adaptive_budget_enabled?'on':'off')+' · precise pacing: '+(r.precise_worker_pacing?'on':'off')+' · queue wait: '+(r.debounce_queue_wait?'on':'off')]);
fields.push(['JVM', 'Compact headers: '+(r.compact_object_headers===true?'on':r.compact_object_headers===false?'off':'not captured')+' · GC: '+(Array.isArray(r.garbage_collectors)?r.garbage_collectors.join(', '):'not captured')]);
fields.push(['Java',[r.java_vendor,r.java_vm_name,r.java_runtime_version||r.java_version].filter(Boolean).join(' · ')||'Not captured']);
if(r.kind==='frames'){
fields.push(['Distant terrain',r.dh_capture_status==='not_installed'?'Not installed':r.dh_capture_status==='available'?(r.dh_rendering_enabled?'Enabled':'Disabled')+' · '+r.dh_lod_radius_chunks+' chunk radius · '+r.dh_horizontal_quality+'/'+r.dh_vertical_quality+' quality · '+r.dh_max_horizontal_resolution+' resolution':'Not captured']);
if(number(r.dh_thread_count))fields.push(['Terrain workers',r.dh_thread_count+' · runtime ratio at start '+format(r.dh_runtime_ratio)+(number(r.visual_settings_end?.dh_runtime_ratio)?' / end '+format(r.visual_settings_end.dh_runtime_ratio):'')]);
fields.push(['Iris shaders',r.iris_capture_status==='not_installed'?'Not installed':r.iris_capture_status==='available'?r.iris_shaders_enabled?'Enabled · '+(r.iris_shader_pack||'pack name unavailable'):'Disabled':'Not captured']);
if(number(r.fov))fields.push(['Field of view',format(r.fov)]);
if(r.world_difficulty)fields.push(['World difficulty',r.world_difficulty]);
fields.push(['Frame limit',r.fps_limit_unlimited===true||(r.minecraft==='26.3'&&number(r.fps_limit)&&r.fps_limit>=260)?'Uncapped':number(r.fps_limit)?r.fps_limit+' FPS':'not captured']);
if(r.adaptive_budget_enabled&&number(r.adaptive_target_fps))fields.push(['Adaptive frame goal',r.adaptive_target_fps+' FPS (not a cap)']);
}
fields.push(['Rejected dispatch retry', r.dispatch_retry_backoff===true?'on':r.dispatch_retry_backoff===false?'off':'not captured']);
fields.push(['Wakeable queue idle', r.wakeable_idle_wait===true?'on':r.wakeable_idle_wait===false?'off':'not captured']);
if(r.kind==='frames')fields.push(['Tick membership deduplication',r.tick_membership_dedup===true?'on':r.tick_membership_dedup===false?'off':'not captured']);
if(r.kind==='frames')fields.push(['Terrain position arithmetic',r.terrain_position_arithmetic===true?'on':r.terrain_position_arithmetic===false?'off':'not captured']);
if(r.kind==='frames')fields.push(['Culling ray scratch',r.culling_ray_scratch===true?'on':r.culling_ray_scratch===false?'off':'not captured']);
if(r.kind==='frames')fields.push(['Empty culling whitelist',r.empty_culling_whitelist===true?'on':r.empty_culling_whitelist===false?'off':'not captured']);
if(r.kind==='frames')fields.push(['Interruptible terrain read',r.interruptible_terrain_read===true?'on':r.interruptible_terrain_read===false?'off':'not captured']);
for(const [label,value]of fields)target.append(text('dt',label),text('dd',value));
if(Array.isArray(r.mods)&&r.mods.every(entry=>typeof entry==='string'&&entry.length)){
const versions=[...new Set(r.mods)].sort(),list=text('details','');
list.append(text('summary',versions.length+' loaded mods'),text('div',versions.join('\n')));
list.lastChild.style.whiteSpace='pre-wrap';
list.lastChild.style.maxHeight='16rem';list.lastChild.style.overflow='auto';
const value=text('dd','');value.append(list);target.append(text('dt','Software'),value);
}else target.append(text('dt','Software'),text('dd','Loaded mod versions not captured'));
}
async function loadFiles(side,files){
const token=++generation[side];state[side]=null;render();
const status=$(side+'Status');status.classList.remove('error');status.textContent='Reading files…';
try{
const list=Array.from(files),jsonFiles=list.filter(f=>f.name.toLowerCase().endsWith('.json')),csvFiles=list.filter(f=>f.name.toLowerCase().endsWith('.csv'));
if(list.length<1||list.length>2||jsonFiles.length!==1||csvFiles.length>1)throw Error('Choose one JSON report and, optionally, its matching CSV.');
if(jsonFiles[0].size>2*1024*1024||csvFiles.some(f=>f.size>16*1024*1024))throw Error('File is too large for a Horizon recording.');
const report=validateReport(JSON.parse(await jsonFiles[0].text()));
let samples=null,csvWarning=null;
if(csvFiles.length){
try{
if(csvFiles[0].name!==report.samples_file)throw Error('CSV filename does not match this report’s samples file.');
samples=parseCsv(await csvFiles[0].text(),report.stats.count);
const mean=samples.reduce((sum,value)=>sum+value,0)/samples.length;
if(Math.abs(mean-report.stats.meanMs)>Math.max(1e-6,report.stats.meanMs*1e-6)){
samples=null;throw Error('CSV durations do not match the timing summary in this JSON.');
}
}catch(error){csvWarning=error.message;}
}
if(token!==generation[side])return;
state[side]={report,samples,csvWarning,filename:jsonFiles[0].name};
status.textContent=jsonFiles[0].name+(samples?' · CSV loaded':'');
}catch(error){
if(token!==generation[side])return;
status.textContent=error instanceof SyntaxError?'This file is not valid JSON.':error.message;status.classList.add('error');
}
render();
}
function render(){
comparison=null;renderDetails('baseline');renderDetails('candidate');
$('results').classList.add('hidden');$('comparisonError').classList.add('hidden');$('empty').classList.toggle('hidden',!!state.baseline&&!!state.candidate);
if(!state.baseline||!state.candidate)return;
try{comparison=compareReports(state.baseline.report,state.candidate.report);}catch(error){$('comparisonError').textContent=error.message;$('comparisonError').classList.remove('hidden');return;}
$('results').classList.remove('hidden');$('resultsTitle').textContent=comparison.kind==='frames'?'Frame comparison':'Server tick comparison';
const notice=$('notices');notice.replaceChildren(text('strong',comparison.differences.length?'Captured settings differ — check the workload.':'Captured settings match. Check the route and warm-up too.'));
if(comparison.differences.length){const list=document.createElement('ul');for(const d of comparison.differences)list.append(text('li',d.setting+': '+JSON.stringify(d.baseline)+' → '+JSON.stringify(d.candidate)));notice.append(list);}
for(const warning of comparison.warnings)notice.append(text('p',warning));
const rows=$('metricRows');rows.replaceChildren();
for(const metric of comparison.metrics){const row=document.createElement('tr');row.append(text('td',metric.label),text('td',format(metric.baseline)+' '+metric.unit),text('td',format(metric.candidate)+' '+metric.unit),text('td',metric.change_percent===null?'—':(metric.change_percent>=0?'+':'')+format(metric.change_percent)+'%'));rows.append(row);}
const hints=[];for(const side of ['baseline','candidate'])if(state[side].csvWarning)hints.push((side==='baseline'?'Baseline: ':'Candidate: ')+state[side].csvWarning);
if(!state.baseline.samples||!state.candidate.samples)hints.push('Add each matching CSV alongside its JSON to draw both duration timelines.');
$('csvHint').textContent=hints.join(' ');drawChart();
}
function bucketSamples(samples,ticks){
const total=ticks?samples.length:samples.reduce((a,b)=>a+b,0)/1000,bucketSeconds=Math.max(total/1000,ticks?1:.001),buckets=[];
let time=0;
for(const duration of samples){time+=ticks?1:duration/1000;const index=Math.floor(time/bucketSeconds),last=buckets[buckets.length-1];if(last&&last.index===index)last.duration=Math.max(last.duration,duration);else buckets.push({index,time,duration});}
return{total,buckets};
}
function svgElement(tag,attributes){const element=document.createElementNS('http://www.w3.org/2000/svg',tag);for(const [key,value]of Object.entries(attributes))element.setAttribute(key,String(value));return element;}
function drawChart(){
const svg=$('timeline');svg.replaceChildren();chartData=null;
$('chartInfo').textContent='Move over the chart to inspect a bucket.';
const available=!!comparison&&!!state.baseline?.samples&&!!state.candidate?.samples;$('chartSection').classList.toggle('hidden',!available);if(!available)return;
const ticks=comparison.kind==='ticks';
$('chartTitle').textContent=ticks?'Tick durations by sample':'Frame pacing';
$('chartNote').textContent='Each point preserves the longest duration in its bucket. '+(ticks?'Ticks are placed by sample number. Scheduler sleep and pauses are not represented as durations.':'Frames use recorded active time; menus and unfocused intervals are omitted. This is not GPU execution time.');
const a=bucketSamples(state.baseline.samples,ticks),b=bucketSamples(state.candidate.samples,ticks),maxTime=Math.max(a.total,b.total,.001),maxMs=Math.max(...a.buckets.map(p=>p.duration),...b.buckets.map(p=>p.duration),1);
const width=Math.max(320,Math.round(svg.getBoundingClientRect().width));svg.setAttribute('viewBox','0 0 '+width+' 300');
const left=64,right=width-18,top=20,bottom=250,log=$('logScale').checked,transform=x=>log?Math.log1p(x):x;
const x=t=>left+t/maxTime*(right-left),y=ms=>bottom-transform(ms)/transform(maxMs)*(bottom-top);
for(const ms of [0,8.33,16.67,33.33,50,100,250,500,1000,2000,5000,10000,30000,60000]){
if(ms>maxMs)continue;
const line=svgElement('line',{x1:left,x2:right,y1:y(ms),y2:y(ms),stroke:'#e5eae3'});svg.append(line);
const label=svgElement('text',{x:left-8,y:y(ms)+4,'text-anchor':'end',fill:'#65726a','font-size':11});label.textContent=format(ms)+' ms';svg.append(label);
}
const divisions=width<500?3:5;
for(let i=0;i<=divisions;i++){const t=maxTime*i/divisions,label=svgElement('text',{x:x(t),y:bottom+24,'text-anchor':i===0?'start':i===divisions?'end':'middle',fill:'#65726a','font-size':11});label.textContent=ticks?'Tick '+format(Math.round(t)):format(t)+' s';svg.append(label);}
for(const [data,color]of [[a,'#247854'],[b,'#bc642f']])svg.append(svgElement('path',{d:data.buckets.map((point,index)=>(index?'L':'M')+x(point.time).toFixed(2)+' '+y(point.duration).toFixed(2)).join(' '),fill:'none',stroke:color,'stroke-width':1.7,'vector-effect':'non-scaling-stroke'}));
const cursor=svgElement('line',{x1:0,x2:0,y1:top,y2:bottom,stroke:'#192920','stroke-dasharray':'3 3',visibility:'hidden'});svg.append(cursor);chartData={a,b,maxTime,left,right,cursor,ticks,width};
}
$('timeline').addEventListener('pointermove',event=>{
if(!chartData)return;const rect=$('timeline').getBoundingClientRect(),px=(event.clientX-rect.left)/rect.width*chartData.width,t=Math.max(0,Math.min(chartData.maxTime,(px-chartData.left)/(chartData.right-chartData.left)*chartData.maxTime));
chartData.cursor.setAttribute('x1',px);chartData.cursor.setAttribute('x2',px);chartData.cursor.setAttribute('visibility',px>=chartData.left&&px<=chartData.right?'visible':'hidden');
const nearest=data=>{if(t>data.total)return'no samples';let point=data.buckets[0];for(const p of data.buckets){if(Math.abs(p.time-t)<Math.abs(point.time-t))point=p;}return format(point.duration)+' ms';};
$('chartInfo').textContent=(chartData.ticks?'Tick '+format(Math.round(t)):format(t)+' s')+' · baseline '+nearest(chartData.a)+' · candidate '+nearest(chartData.b);
});
$('timeline').addEventListener('pointerleave',()=>{if(chartData)chartData.cursor.setAttribute('visibility','hidden');});
$('logScale').addEventListener('change',drawChart);
window.addEventListener('resize',drawChart);
for(const side of ['baseline','candidate'])$(side+'Files').addEventListener('change',event=>loadFiles(side,event.target.files));
$('export').addEventListener('click',()=>{
if(!comparison)return;const summary={horizon_comparison:1,source_files:{baseline:state.baseline.filename,candidate:state.candidate.filename},...comparison},blob=new Blob([JSON.stringify(summary,null,2)+'\n'],{type:'application/json'}),url=URL.createObjectURL(blob),link=document.createElement('a');link.href=url;link.download='horizon-comparison.json';link.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
});
</script>
</html>