Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
<script src="/js/compare/ux.js" defer></script>
<script src="/js/compare/load.js" defer></script>
<script src="/js/compare/filmstrip.js" defer></script>
<script src="/js/compare/filmstripNav.js" defer></script>
<script src="/js/compare/lightbox.js" defer></script>
<script src="/js/compare/generate.js" defer></script>
<script src="/js/compare/generateVisualProgress.js" defer></script>
<script src="/js/compare/templates.js" defer></script>
Expand Down Expand Up @@ -164,9 +166,10 @@
</head>

<body>
<a class="skip-link" href="#mainContent">Skip to content</a>
<div id="choosehars">
<header class="header">
<div class="wip">BETA</div>
<header class="header" role="banner">
<div class="wip" aria-hidden="true">BETA</div>
<div class="grid grid-pad">
<div class="col-1-1">
<div class="logo">
Expand Down Expand Up @@ -217,27 +220,33 @@ <h1 class="title">Compare</h1>
</div>

<div id="result" class="result">
<header class="header-result">
<header class="header-result" role="banner">
<div class="grid grid-pad grid-result">
<div class="col-1-1">
<div class="logo">
<a href="https://compare.sitespeed.io/">
<img src="img/compare.png" width="34" class="img-logo" />
</a>
</div>
<div id="resultHeaderContent"></div>
<nav id="resultHeaderContent" aria-label="Page sections"></nav>
</div>
</div>
</header>
<div class="grid grid-pad grid-result">
<main id="mainContent" class="grid grid-pad grid-result" tabindex="-1">
<div class="col-1-1">
<h1 class="sr-only">HAR comparison</h1>
<div id="comment-intro" class="comment"></div>
<div id="pageXrayContent" class="card"></div>
<div id="filmstripContent" class="card"></div>
<section class="card">
<h3 id="waterfallHeader" class="card-title">Waterfall</h3>
<h3 id="waterfallHeader" class="card-title">Waterfall
<button id="waterfallLayoutToggle" type="button"
onclick="toggleWaterfallLayout(this);"
class="chip-toggle"
aria-pressed="false">Side by side</button>
</h3>
<div id="comment-waterfall" class="comment"></div>
<div class="rangeWrapper">
<div class="rangeWrapper" id="harBlendWrapper">
<span class="har-label" id="har1Label"></span>
<input id="harBlendSlider" type="range" min="0" max="1" step="0.05" value="0"
oninput="blendWaterfalls(this.value)"
Expand All @@ -257,11 +266,11 @@ <h3 id="waterfallHeader" class="card-title">Waterfall</h3>
Upload again
</button>
</div>
</div>
</main>
</div>
<div id="loading" class="loading result">
<header class="header">
<div class="wip">BETA</div>
<div class="wip" aria-hidden="true">BETA</div>
<div class="grid grid-pad">
<div class="col-1-1">
<div class="logo">
Expand Down
85 changes: 76 additions & 9 deletions public/js/compare/filmstrip.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,41 +69,108 @@ function getFilmstripForPage(har, pageIndex) {
ms: ms,
time: (ms / 1000).toFixed(2),
img: dataBase + '/filmstrip/' + runId + '/ms_' +
String(ms).padStart(6, '0') + '.jpg'
String(ms).padStart(6, '0') + '.jpg',
// Visual-progress percent at this change point. Carries
// through padFrames so each padded cell knows how rendered
// the page was at that moment — used to flag divergence
// between the two HARs.
progress: vp[ms]
};
});
}
}

// Legacy WPT path — the HAR's page object has a filmstrip array
// directly. Translate into the same shape.
// directly. Translate into the same shape. WPT entries usually carry
// a `VisuallyComplete` percent on each frame; surface it as
// progress so divergence colouring still works.
const legacy = page.filmstrip || (page._wpt && page._wpt.filmstrip);
if (Array.isArray(legacy) && legacy.length > 0) {
return legacy.map(function (f) {
const ms = Math.round((f.time || 0) * 1000);
const prog = typeof f.progress === 'number' ? f.progress
: typeof f.VisuallyComplete === 'number' ? f.VisuallyComplete
: null;
return {
ms: ms,
time: (ms / 1000).toFixed(2),
img: f.file || f.image || ''
img: f.file || f.image || '',
progress: prog
};
});
}

return null;
}

/**
* Resample a change-point frame list onto a uniform time grid so the
* rendered strip reads like an actual filmstrip: every cell represents
* the same time slice, repeating the last-known frame until the page
* visibly changes again. Without this the cells are spaced by *visual
* progress*, which makes "nothing happened for 2 s" look identical to
* "everything changed in 50 ms".
*/
function padFrames(frames, maxMs, stepMs) {
if (frames.length === 0) return [];
const padded = [];
let lastFrame = frames[0];
let nextIdx = 0;
for (let t = 0; t <= maxMs; t += stepMs) {
while (nextIdx < frames.length && frames[nextIdx].ms <= t) {
lastFrame = frames[nextIdx];
nextIdx++;
}
padded.push({
ms: t,
time: (t / 1000).toFixed(1),
img: lastFrame.img,
sourceMs: lastFrame.ms,
progress: lastFrame.progress
});
}
// Always end on the exact LastVisualChange frame so the final visual
// state is shown, even if it falls between two grid steps.
const lastAvailable = frames[frames.length - 1];
if (lastAvailable.ms !== padded[padded.length - 1].ms) {
padded.push({
ms: lastAvailable.ms,
time: (lastAvailable.ms / 1000).toFixed(1),
img: lastAvailable.img,
sourceMs: lastAvailable.ms,
progress: lastAvailable.progress
});
}
return padded;
}

/**
* Build filmstrip data for both HARs in the comparison.
* Returns null if neither HAR has frames available.
*/
function getFilmstrip(har1, run1, har2, run2) {
const frames1 = getFilmstripForPage(har1, run1) || [];
const frames2 = getFilmstripForPage(har2, run2) || [];
if (frames1.length === 0 && frames2.length === 0) return null;
const raw1 = getFilmstripForPage(har1, run1) || [];
const raw2 = getFilmstripForPage(har2, run2) || [];
if (raw1.length === 0 && raw2.length === 0) return null;

const maxMs = Math.max(
frames1.length ? frames1[frames1.length - 1].ms : 0,
frames2.length ? frames2[frames2.length - 1].ms : 0
raw1.length ? raw1[raw1.length - 1].ms : 0,
raw2.length ? raw2[raw2.length - 1].ms : 0
);
return { frames1: frames1, frames2: frames2, maxMs: maxMs };

// 100 ms is sitespeed.io's native capture cadence, so every cell
// maps to a real on-disk frame whenever the page is actually
// changing. For very long pages we widen the step so the rail
// stops at ~120 cells.
let stepMs = 100;
while (Math.floor(maxMs / stepMs) + 1 > 120) {
stepMs *= 2;
}

return {
frames1: padFrames(raw1, maxMs, stepMs),
frames2: padFrames(raw2, maxMs, stepMs),
maxMs: maxMs,
stepMs: stepMs
};
}
114 changes: 114 additions & 0 deletions public/js/compare/filmstripNav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/* exported initFilmstripNav */

//
// Filmstrip navigation enhancements.
//
// - Mouse drag scrolls the rail horizontally (more discoverable than
// the native scrollbar for long strips). We only kick in if the
// user actually moves while holding down; a plain click still goes
// through to the lightbox-trigger inside the cell.
// - Arrow keys move focus between cells when one is focused. Home /
// End jump to the start / end of the strip.
//
// Delegation-based so newly-generated rails (after Switch or after
// uploading a new HAR pair) pick up the same behaviour without a
// re-wire.
//

function initFilmstripNav() {
if (window.__filmstripNavInstalled) return;
window.__filmstripNavInstalled = true;

const DRAG_THRESHOLD_PX = 4;

document.body.addEventListener('mousedown', function (e) {
if (!(e.target instanceof HTMLElement)) return;
const rail = e.target.closest('.filmstrip-rail');
if (!rail) return;
// Ignore mousedown on the actual zoom button — let the lightbox
// handle that click. Drag-scroll triggers on mousedown anywhere
// else inside the rail (the padding, the column gap, etc.).
if (e.target.closest('.lightbox-trigger')) {
// Still want to allow drag if user moves before releasing; we
// arm a "candidate" state and promote to dragging only after
// crossing the threshold.
}
if (e.button !== 0) return;

const startX = e.pageX;
const startScroll = rail.scrollLeft;
let dragging = false;

function onMove(ev) {
const dx = ev.pageX - startX;
if (!dragging && Math.abs(dx) >= DRAG_THRESHOLD_PX) {
dragging = true;
rail.classList.add('is-dragging');
}
if (dragging) {
rail.scrollLeft = startScroll - dx;
ev.preventDefault();
}
}

function onUp(ev) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (dragging) {
rail.classList.remove('is-dragging');
// Swallow the click that would otherwise follow the
// mouseup — otherwise a drag would also open the lightbox.
ev.preventDefault();
ev.stopPropagation();
const swallow = function (ce) {
ce.stopPropagation();
ce.preventDefault();
document.removeEventListener('click', swallow, true);
};
document.addEventListener('click', swallow, true);
}
}

document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});

document.body.addEventListener('keydown', function (e) {
if (!(e.target instanceof HTMLElement)) return;
const cell = e.target.closest('.filmstrip-cell');
if (!cell) return;
const column = cell.closest('.filmstrip-column');
if (!column) return;

let next = null;
if (e.key === 'ArrowRight') {
const nextCol = column.nextElementSibling;
next = nextCol && nextCol.querySelector('.filmstrip-cell:not(.filmstrip-cell--missing)');
} else if (e.key === 'ArrowLeft') {
const prevCol = column.previousElementSibling;
next = prevCol && prevCol.querySelector('.filmstrip-cell:not(.filmstrip-cell--missing)');
} else if (e.key === 'ArrowDown') {
// Move from HAR1 cell to HAR2 cell within the same column.
next = column.querySelectorAll('.filmstrip-cell')[1];
} else if (e.key === 'ArrowUp') {
next = column.querySelectorAll('.filmstrip-cell')[0];
} else if (e.key === 'Home') {
const rail = column.parentElement;
next = rail && rail.querySelector('.filmstrip-cell:not(.filmstrip-cell--missing)');
} else if (e.key === 'End') {
const rail = column.parentElement;
const all = rail && rail.querySelectorAll('.filmstrip-cell:not(.filmstrip-cell--missing)');
next = all && all[all.length - 1];
} else {
return;
}

if (next) {
e.preventDefault();
next.focus();
next.scrollIntoView({ block: 'nearest', inline: 'center', behavior: 'smooth' });
}
});
}

document.addEventListener('DOMContentLoaded', initFilmstripNav);
26 changes: 25 additions & 1 deletion public/js/compare/generate.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,22 @@ function addVisualProgress(pageXray1, pageXray2, config, filmstrip) {
'visualProgressContent'
);

// Marker timings — vertical guide lines on the chart anchor the
// comparison in time so "when did this happen?" is answerable at
// a glance. We pick the standard set: First Visual Change, FCP,
// LCP and Speed Index. Each marker is per-HAR so a regressed LCP
// shows up as two side-by-side red lines rather than one.
function markerSet(p) {
const vm = p.visualMetrics || {};
const gw = p.googleWebVitals || {};
return [
{ label: 'FVC', time: vm.FirstVisualChange, kind: 'fvc' },
{ label: 'FCP', time: gw.firstContentfulPaint, kind: 'fcp' },
{ label: 'LCP', time: gw.largestContentfulPaint, kind: 'lcp' },
{ label: 'Speed Index', time: vm.SpeedIndex, kind: 'si' }
].filter(function (m) { return typeof m.time === 'number' && m.time > 0; });
}

generateVisualProgress(
pageXray1.visualMetrics.VisualProgress,
pageXray2.visualMetrics.VisualProgress,
Expand All @@ -73,7 +89,9 @@ function addVisualProgress(pageXray1, pageXray2, config, filmstrip) {
thumbnails1: filmstrip ? sampleFrames(filmstrip.frames1, 6) : [],
thumbnails2: filmstrip ? sampleFrames(filmstrip.frames2, 6) : [],
label1: config.har1.label,
label2: config.har2.label
label2: config.har2.label,
markers1: markerSet(pageXray1),
markers2: markerSet(pageXray2)
}
);
}
Expand Down Expand Up @@ -178,6 +196,12 @@ function generate(config) {
const slider = document.getElementById('harBlendSlider');
if (slider) slider.value = 0;
blendWaterfalls(0);
// Restore the user's saved side-by-side / overlay preference now
// that the waterfall DOM is populated. No-op when the wrapper is
// missing (e.g. during the loading view).
if (typeof applyWaterfallLayoutPreference === 'function') {
applyWaterfallLayoutPreference();
}

parseTemplate(
'pageXrayTemplate',
Expand Down
Loading
Loading