289 lines
8.0 KiB
HTML
289 lines
8.0 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Nested transform frame-pacing reproduction</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
font: 15px/1.4 system-ui, sans-serif;
|
||
background: #0b0d12;
|
||
color: #f5f7ff;
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
max-width: 760px;
|
||
margin: auto;
|
||
padding: 24px;
|
||
}
|
||
|
||
h1 { margin-bottom: 8px; font-size: 24px; }
|
||
p { color: #aeb6ca; }
|
||
|
||
.controls {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin: 24px 0;
|
||
padding: 16px;
|
||
border: 1px solid #30384c;
|
||
border-radius: 12px;
|
||
background: #151923;
|
||
}
|
||
|
||
label {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
|
||
input, select, button {
|
||
min-height: 40px;
|
||
padding: 8px 12px;
|
||
border: 1px solid #3a4359;
|
||
border-radius: 8px;
|
||
background: #202637;
|
||
color: inherit;
|
||
font: inherit;
|
||
}
|
||
|
||
input { width: 110px; }
|
||
button { cursor: pointer; }
|
||
button:disabled { cursor: default; opacity: .5; }
|
||
.buttons { display: flex; gap: 8px; }
|
||
|
||
.viewport {
|
||
position: relative;
|
||
height: 300px;
|
||
overflow: hidden;
|
||
border: 1px solid #30384c;
|
||
border-radius: 16px;
|
||
background: #11141c;
|
||
}
|
||
|
||
.under, .route {
|
||
position: absolute;
|
||
inset: 0;
|
||
padding: 28px;
|
||
}
|
||
|
||
.under {
|
||
background: linear-gradient(135deg, #171c28, #11141c);
|
||
color: #8f99b0;
|
||
}
|
||
|
||
.route {
|
||
transform: translate3d(100%, 0, 0);
|
||
background: #202637;
|
||
will-change: transform;
|
||
}
|
||
|
||
.card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
margin-top: 24px;
|
||
padding: 18px;
|
||
border-radius: 12px;
|
||
background: #151923;
|
||
}
|
||
|
||
.spinner {
|
||
width: 34px;
|
||
height: 34px;
|
||
flex: none;
|
||
border: 3px solid #4b5570;
|
||
border-top-color: #74a5ff;
|
||
border-radius: 50%;
|
||
animation: spin var(--spinner-duration, .7s) linear infinite;
|
||
}
|
||
|
||
.spinner.off { animation: none; }
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
margin-top: 20px;
|
||
border-collapse: collapse;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
th, td {
|
||
padding: 8px;
|
||
border-bottom: 1px solid #30384c;
|
||
text-align: right;
|
||
}
|
||
|
||
th:first-child, td:first-child { text-align: left; }
|
||
.bad { color: #ff9e9e; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Nested composite-transform frame pacing</h1>
|
||
<p>
|
||
A CSS border spinner rotates inside a route whose parent transform is driven by
|
||
<code>requestAnimationFrame</code>. On iOS WebKit, changing only the spinner duration can
|
||
cause repeatable 33–34 ms frame gaps.
|
||
</p>
|
||
|
||
<div class="controls">
|
||
<label>
|
||
Spinner duration
|
||
<span><input id="duration" type="number" min="0.1" max="3" step="0.05" value="0.7"> s</span>
|
||
</label>
|
||
<label>
|
||
Parent transform
|
||
<select id="parent-mode">
|
||
<option value="moving">rAF-driven</option>
|
||
<option value="static">Static translate3d</option>
|
||
<option value="none">None</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
Spinner enabled
|
||
<input id="spinner-enabled" type="checkbox" checked>
|
||
</label>
|
||
<div class="buttons">
|
||
<button id="run" type="button">Run once</button>
|
||
<button id="sweep" type="button">Sweep 0.5–1.1 s</button>
|
||
<button id="stop" type="button" disabled>Stop</button>
|
||
</div>
|
||
<output id="status">Ready. Each run measures 1.8 seconds.</output>
|
||
</div>
|
||
|
||
<div class="viewport">
|
||
<div class="under">Existing route</div>
|
||
<div id="route" class="route">
|
||
<strong>Incoming route</strong>
|
||
<div class="card">
|
||
<span id="spinner" class="spinner"></span>
|
||
<span>Waiting for asynchronous data…</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<table>
|
||
<thead>
|
||
<tr><th>Spinner</th><th>p95</th><th>Max</th><th>Frames > 30 ms</th></tr>
|
||
</thead>
|
||
<tbody id="results"><tr><td colspan="4">No runs yet.</td></tr></tbody>
|
||
</table>
|
||
|
||
<script>
|
||
const durations = [.5, .6, .7, .8, .9, 1, 1.1]
|
||
const sampleTime = 1800
|
||
const route = document.querySelector('#route')
|
||
const spinner = document.querySelector('#spinner')
|
||
const duration = document.querySelector('#duration')
|
||
const parentMode = document.querySelector('#parent-mode')
|
||
const spinnerEnabled = document.querySelector('#spinner-enabled')
|
||
const runButton = document.querySelector('#run')
|
||
const sweepButton = document.querySelector('#sweep')
|
||
const stopButton = document.querySelector('#stop')
|
||
const status = document.querySelector('#status')
|
||
const results = document.querySelector('#results')
|
||
let token = 0
|
||
let rows = []
|
||
|
||
const percentile = (values, amount) => {
|
||
const sorted = [...values].sort((a, b) => a - b)
|
||
return sorted[Math.floor((sorted.length - 1) * amount)] || 0
|
||
}
|
||
|
||
function configure(seconds) {
|
||
document.documentElement.style.setProperty('--spinner-duration', `${seconds}s`)
|
||
spinner.classList.toggle('off', !spinnerEnabled.checked)
|
||
spinner.style.animation = 'none'
|
||
void spinner.offsetWidth
|
||
spinner.style.animation = ''
|
||
route.style.willChange = parentMode.value === 'none' ? 'auto' : 'transform'
|
||
route.style.transform = parentMode.value === 'moving'
|
||
? 'translate3d(100%, 0, 0)'
|
||
: parentMode.value === 'static' ? 'translate3d(0, 0, 0)' : 'none'
|
||
}
|
||
|
||
function measure(seconds, currentToken) {
|
||
configure(seconds)
|
||
status.value = `Measuring ${seconds.toFixed(2)} s…`
|
||
|
||
return new Promise(resolve => {
|
||
let started
|
||
let previous
|
||
const deltas = []
|
||
|
||
function frame(now) {
|
||
if (currentToken !== token) return resolve(null)
|
||
started ??= now
|
||
previous ??= now
|
||
const elapsed = now - started
|
||
if (elapsed > 0) deltas.push(now - previous)
|
||
previous = now
|
||
|
||
if (parentMode.value === 'moving') {
|
||
const progress = Math.min(1, elapsed / 700)
|
||
const eased = 1 - Math.pow(1 - progress, 3)
|
||
route.style.transform = `translate3d(${(1 - eased) * 100}%, 0, 0)`
|
||
}
|
||
|
||
if (elapsed < sampleTime) requestAnimationFrame(frame)
|
||
else resolve({
|
||
seconds,
|
||
p95: percentile(deltas, .95),
|
||
max: Math.max(...deltas),
|
||
over30: deltas.filter(value => value > 30).length,
|
||
})
|
||
}
|
||
|
||
requestAnimationFrame(frame)
|
||
})
|
||
}
|
||
|
||
function render() {
|
||
results.innerHTML = rows.map(row => `
|
||
<tr class="${row.over30 ? 'bad' : ''}">
|
||
<td>${row.seconds.toFixed(2)} s</td>
|
||
<td>${row.p95.toFixed(1)} ms</td>
|
||
<td>${row.max.toFixed(1)} ms</td>
|
||
<td>${row.over30}</td>
|
||
</tr>`).join('') || '<tr><td colspan="4">No runs yet.</td></tr>'
|
||
}
|
||
|
||
async function run(values) {
|
||
const currentToken = ++token
|
||
rows = []
|
||
render()
|
||
runButton.disabled = sweepButton.disabled = true
|
||
stopButton.disabled = false
|
||
|
||
for (const value of values) {
|
||
const result = await measure(value, currentToken)
|
||
if (!result) break
|
||
rows.push(result)
|
||
render()
|
||
}
|
||
|
||
if (currentToken === token) status.value = 'Finished.'
|
||
runButton.disabled = sweepButton.disabled = false
|
||
stopButton.disabled = true
|
||
}
|
||
|
||
runButton.addEventListener('click', () => run([Number(duration.value)]))
|
||
sweepButton.addEventListener('click', () => run(durations))
|
||
stopButton.addEventListener('click', () => {
|
||
token++
|
||
status.value = 'Stopped.'
|
||
})
|
||
duration.addEventListener('input', () => configure(Number(duration.value)))
|
||
parentMode.addEventListener('change', () => configure(Number(duration.value)))
|
||
spinnerEnabled.addEventListener('change', () => configure(Number(duration.value)))
|
||
configure(Number(duration.value))
|
||
</script>
|
||
</body>
|
||
</html>
|