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
4 changes: 4 additions & 0 deletions .storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ module.exports = {
__dirname,
"../packages/nightingale-new-core/src/index.ts"
),
"@nightingale-elements/nightingale-variation": path.resolve(
__dirname,
"../packages/nightingale-variation/src/index.ts"
),
};
config.optimization = {
...config.optimization,
Expand Down
336 changes: 336 additions & 0 deletions dev/benchmarks/variation-canvas.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>nightingale-variation-canvas benchmark</title>
<script src="../../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
<script src="../../node_modules/lit/polyfill-support.js"></script>
<!--
Bare imports below are resolved by `web-dev-server.config.mjs` to
`packages/*/src/index.ts`, so this page works against the source
directly — no `yarn build` step required.
-->
<script type="module">
import "@nightingale-elements/nightingale-variation";
import "@nightingale-elements/nightingale-variation-canvas";
</script>
<style>
body {
font-family: system-ui, sans-serif;
padding: 16px;
max-width: 1100px;
}
#stage {
position: absolute;
left: -10000px;
top: 0;
width: 800px;
}
table {
border-collapse: collapse;
margin-top: 12px;
}
th,
td {
border: 1px solid #ccc;
padding: 4px 10px;
text-align: right;
font-variant-numeric: tabular-nums;
}
th:first-child,
td:first-child {
text-align: left;
}
button {
padding: 6px 14px;
font-size: 14px;
}
pre {
background: #f3f3f3;
padding: 10px;
overflow-x: auto;
}
</style>
</head>
<body>
<h1>nightingale-variation vs nightingale-variation-canvas</h1>
<p>
Two-curve benchmark: initial load time (data setter → first draw) and
refresh time (zoom-pan redraw) across variant counts. Uses a seeded RNG
(seed = <code>42</code>) so runs are reproducible. Components are rendered
offscreen in <code>#stage</code>.
</p>
<p>
<strong>How to run:</strong> from the repo root, run
<code>yarn benchmark:variation-canvas</code>. That starts the dev server
and opens this page in your browser; click <strong>Run benchmark</strong>
below to start the sweep. No build step is required — the dev server
compiles TypeScript on the fly and serves directly from
<code>packages/*/src</code>.
</p>
<button id="run">Run benchmark</button>
<span id="status" style="margin-left: 12px; color: #666;"></span>

<h2>Results</h2>
<div id="results"></div>

<div id="stage"></div>

<script type="module">
const SEED = 42;
const SEQUENCE_LENGTH = 2000;
const WIDTH = 800;
const HEIGHT = 400;
// 10^2 .. ~3×10^5, one point per half-decade. Upper bound chosen so the
// sweep stays within a few hundred MB of heap and finishes in minutes.
const VARIANT_COUNTS = [
100, 316, 1000, 3162, 10000, 31623, 100000, 316228,
];
const REFRESH_ITERATIONS = 5;
// Per-component hard cap (ms). If a single measurement exceeds this, we
// skip the rest of that component's sweep (SVG at 10^6+ can lock the
// page for minutes otherwise).
const TIMEOUT_MS = 60000;

const AA = "GAVLISTCMDNEQRKHFYWP*";

// Deterministic pseudo-random generator (mulberry32).
function makeRng(seed) {
let t = seed >>> 0;
return () => {
t = (t + 0x6d2b79f5) >>> 0;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}

function makeSequence(length, rng) {
let s = "";
for (let i = 0; i < length; i++) {
s += AA[Math.floor(rng() * 20)]; // exclude "*"
}
return s;
}

function makeVariants(count, sequenceLength, rng) {
const variants = [];
for (let i = 0; i < count; i++) {
const pos = 1 + Math.floor(rng() * sequenceLength);
const aa = AA[Math.floor(rng() * AA.length)];
variants.push({
accession: `v${i}`,
variant: aa,
start: pos,
xrefNames: [],
hasPredictions: rng() < 0.5,
consequenceType: "missense_variant",
});
}
return variants;
}

function makeData(count) {
const rng = makeRng(SEED + count);
return {
sequence: makeSequence(SEQUENCE_LENGTH, rng),
variants: makeVariants(count, SEQUENCE_LENGTH, rng),
};
}

// Wait for a repaint so the canvas has committed pixels.
function waitFrame() {
return new Promise((r) => requestAnimationFrame(() => r()));
}

function setupElement(tagName) {
const stage = document.getElementById("stage");
stage.innerHTML = "";
const el = document.createElement(tagName);
el.setAttribute("length", String(SEQUENCE_LENGTH));
el.setAttribute("display-start", "1");
el.setAttribute("display-end", String(SEQUENCE_LENGTH));
el.setAttribute("width", String(WIDTH));
el.setAttribute("height", String(HEIGHT));
el.setAttribute("row-height", "15");
stage.appendChild(el);
return el;
}

// The canvas component exposes `whenDrawn()` so we can await actual
// draw completion. The SVG component has no such hook — it draws
// synchronously inside `zoomRefreshed`, so a single RAF after the
// setter is enough to cover everything. Either way, this is more
// accurate than the previous double-RAF heuristic.
async function awaitDraw(el) {
if (typeof el.whenDrawn === "function") {
await el.whenDrawn();
} else {
await waitFrame();
}
}

async function measureInitialLoad(tagName, data) {
await customElements.whenDefined(tagName);
const el = setupElement(tagName);
// Give the element time to mount before we set data.
await waitFrame();
const start = performance.now();
el.data = data;
await awaitDraw(el);
return performance.now() - start;
}

async function measureRefresh(tagName, data) {
await customElements.whenDefined(tagName);
const el = setupElement(tagName);
await waitFrame();
el.data = data;
await awaitDraw(el);

const samples = [];
let start = 1;
let end = SEQUENCE_LENGTH;
for (let i = 0; i < REFRESH_ITERATIONS; i++) {
// Alternate between zoomed-in and zoomed-out to force redraws.
if (i % 2 === 0) {
start = 1 + Math.floor(SEQUENCE_LENGTH * 0.1 * i);
end = Math.min(
SEQUENCE_LENGTH,
start + Math.floor(SEQUENCE_LENGTH * 0.5),
);
} else {
start = 1;
end = SEQUENCE_LENGTH;
}
const t = performance.now();
el.setAttribute("display-start", String(start));
el.setAttribute("display-end", String(end));
await awaitDraw(el);
samples.push(performance.now() - t);
}
// Median is more stable than mean for these noisy measurements.
samples.sort((a, b) => a - b);
return samples[Math.floor(samples.length / 2)];
}

async function runSweep() {
const rows = [];
let svgDisabled = false;
let canvasDisabled = false;
for (const count of VARIANT_COUNTS) {
setStatus(`Building data for ${count.toLocaleString()} variants...`);
const data = makeData(count);

let svgLoad = null;
let svgRefresh = null;
if (!svgDisabled) {
setStatus(`SVG initial load at ${count.toLocaleString()}...`);
svgLoad = await measureInitialLoad(
"nightingale-variation",
data,
);
if (svgLoad > TIMEOUT_MS) svgDisabled = true;
}

let canvasLoad = null;
let canvasRefresh = null;
if (!canvasDisabled) {
setStatus(`Canvas initial load at ${count.toLocaleString()}...`);
canvasLoad = await measureInitialLoad(
"nightingale-variation-canvas",
data,
);
if (canvasLoad > TIMEOUT_MS) canvasDisabled = true;
}

if (!svgDisabled && svgLoad !== null) {
setStatus(`SVG refresh at ${count.toLocaleString()}...`);
svgRefresh = await measureRefresh(
"nightingale-variation",
data,
);
if (svgRefresh > TIMEOUT_MS) svgDisabled = true;
}

if (!canvasDisabled && canvasLoad !== null) {
setStatus(`Canvas refresh at ${count.toLocaleString()}...`);
canvasRefresh = await measureRefresh(
"nightingale-variation-canvas",
data,
);
if (canvasRefresh > TIMEOUT_MS) canvasDisabled = true;
}

rows.push({ count, svgLoad, canvasLoad, svgRefresh, canvasRefresh });
renderTable(rows);

if (svgDisabled && canvasDisabled) break;
}
setStatus("Done.");
console.table(rows);
}

function setStatus(msg) {
document.getElementById("status").textContent = msg;
}

function fmt(ms) {
if (ms === null || ms === undefined) return "—";
if (ms >= 10000) return (ms / 1000).toFixed(1) + " s";
return ms.toFixed(1);
}

function renderTable(rows) {
const el = document.getElementById("results");
const ratio = (a, b) => {
if (a === null || a === undefined) return "—";
if (b === null || b === undefined || b === 0) return "—";
return (a / b).toFixed(2) + "×";
};
el.innerHTML = `
<table>
<thead>
<tr>
<th>Variants</th>
<th>SVG load (ms)</th>
<th>Canvas load (ms)</th>
<th>Load speed-up</th>
<th>SVG refresh (ms)</th>
<th>Canvas refresh (ms)</th>
<th>Refresh speed-up</th>
</tr>
</thead>
<tbody>
${rows
.map(
(r) => `
<tr>
<td>${r.count.toLocaleString()}</td>
<td>${fmt(r.svgLoad)}</td>
<td>${fmt(r.canvasLoad)}</td>
<td>${ratio(r.svgLoad, r.canvasLoad)}</td>
<td>${fmt(r.svgRefresh)}</td>
<td>${fmt(r.canvasRefresh)}</td>
<td>${ratio(r.svgRefresh, r.canvasRefresh)}</td>
</tr>`,
)
.join("")}
</tbody>
</table>`;
}

document.getElementById("run").addEventListener("click", async () => {
document.getElementById("run").disabled = true;
try {
await runSweep();
} catch (err) {
console.error(err);
setStatus("Error — see console.");
} finally {
document.getElementById("run").disabled = false;
}
});
</script>
</body>
</html>
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"analyze:watch": "cem analyze --litelement --globs \"src/**/*.ts\" --watch",
"serve": "wds --node-resolve --watch",
"serve:prod": "MODE=prod yarn serve",
"benchmark:variation-canvas": "wds --node-resolve --watch --open /dev/benchmarks/variation-canvas.html",
"checksize": "rollup -c ; cat my-element.bundled.js | gzip -9 | wc -c ; rm my-element.bundled.js",
"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
Expand Down
Loading
Loading