Inspect the morph before Play

HTML for this example

Select the code and use your copy shortcut. Changes in this preview are temporary.

<!doctype html>
<html lang="en-US"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Inspect the morph before Play</title><style>*{box-sizing:border-box}body{margin:0;background:#f3f4f2;color:#202c32;font:16px/1.5 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}main{max-width:1120px;margin:auto;padding:30px 28px 60px}header{margin-bottom:22px}.eyebrow{font-size:12px;letter-spacing:.14em;text-transform:uppercase;font-weight:750;color:#59676b}h1{font-size:clamp(25px,4vw,36px);line-height:1.15;letter-spacing:-.035em;margin:7px 0 10px}h2{font-size:21px;line-height:1.3;margin:0 0 16px}h3{font-size:17px;margin:0 0 10px}p{margin:8px 0 16px}label{display:block;font-size:14px;font-weight:650;margin:12px 0 5px}input,select,textarea,button{font:inherit}input,select,textarea{width:100%;min-width:0;border:1px solid #b6c0bf;border-radius:7px;background:#fff;color:#202c32;padding:10px 12px}textarea{resize:vertical;min-height:88px}input[type=checkbox]{width:18px;height:18px;vertical-align:middle;margin-right:8px}button{border:1px solid #b8c3c1;border-radius:7px;padding:10px 15px;background:#fff;color:#203632;font-weight:650;cursor:pointer}button.primary{background:#243f3c;color:white;border-color:#243f3c}button:hover{filter:brightness(.95)}button:disabled{opacity:.48;cursor:not-allowed}:focus-visible{outline:3px solid #b05814;outline-offset:3px}.buttons{display:flex;gap:8px;flex-wrap:wrap;margin-top:18px}.panel{background:#fff;border:1px solid #d7ddda;border-radius:12px;padding:24px}.split{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.1fr);gap:22px}.row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.result{background:#e4eee8;border:1px solid #c5d6ca;border-radius:12px;padding:28px}.big{font-size:clamp(32px,5vw,56px);font-weight:740;line-height:1.14;letter-spacing:-.04em;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.muted{color:#59686a;font-size:14px}.tag{display:inline-block;background:#edf0ed;border-radius:4px;padding:3px 8px;font-size:12px;font-weight:700}.error{color:#9d301b;font-weight:650;min-height:24px;margin-top:10px}.note{border-left:3px solid #9a794a;padding:10px 15px;background:#faf5e9;margin:18px 0}.below{margin-top:28px}.record{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.65 ui-monospace,SFMono-Regular,Consolas,monospace;background:#f2f4f1;padding:18px;border-radius:8px}table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #dce1dd;padding:12px 10px;overflow-wrap:anywhere}th{color:#59686a;font-size:12px}a{color:#2c5a6a;overflow-wrap:anywhere}details{margin-top:20px}summary{cursor:pointer;font-weight:650}hr{border:0;border-top:1px solid #d6dcd6;margin:22px 0}[hidden]{display:none!important}ol,ul{padding-left:23px}.statline{display:flex;gap:20px;flex-wrap:wrap;margin:18px 0}.statline strong{display:block;font-size:24px}.inline{display:flex;align-items:center;gap:8px}.inline label{margin:0} .blank{min-height:36px;border-bottom:1px dashed #aebbb5;color:#66736d;font-size:13px;padding:8px 0}@media(max-width:720px){main{padding:22px 18px 45px}.split{grid-template-columns:1fr}.panel,.result{padding:20px}.row{gap:12px}th,td{padding:10px 6px}}@media(max-width:400px){main{padding:18px 14px 40px}.big{font-size:34px}button{padding:10px 12px}.row{grid-template-columns:1fr}.panel,.result{padding:17px}}.inline{flex-wrap:wrap}input[type=range]{padding:0}pre{white-space:pre-wrap;overflow-wrap:anywhere;margin:0}.metric{font-size:42px;font-weight:750;line-height:1.15;letter-spacing:-.035em}.caption{font-size:13px;color:#55656b}.readout{font:15px/1.6 ui-monospace,Consolas,monospace;white-space:pre-wrap;overflow-wrap:anywhere}.strip{display:flex;gap:24px;align-items:center;flex-wrap:wrap}.chip{padding:5px 10px;background:#e5eaf0;border-radius:5px;font-size:13px}button[aria-pressed=true]{background:#263b50;color:white}.subtle{background:#f6f7f7}.fileCode{min-height:230px;font:14px/1.6 ui-monospace,Consolas,monospace}.wideButton{width:100%}.visuallyHidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}svg{max-width:100%}.nowrap{white-space:nowrap}.geometry{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:24px}.drawing{background:#f9fbfc;border:1px solid #d1dce5;border-radius:12px;padding:20px}.drawing svg{display:block;width:100%;max-height:410px;aspect-ratio:1}.legend{display:flex;flex-wrap:wrap;justify-content:space-around;gap:10px;font-size:13px;margin-top:15px}.geometry .panel{display:flex;flex-direction:column;justify-content:center}.geometry .record{font-size:13px}.geometry .buttons{margin-top:12px}.geometry input[type=range]{margin:10px 0}@media(max-width:720px){.geometry{grid-template-columns:1fr}.drawing{padding:10px}.drawing svg{max-height:300px}.geometry .panel{padding:18px}}</style></head><body><main><header><div class="eyebrow">Four paired vertices · M L L L Z</div><h1>Inspect the morph before Play</h1></header>
<section class="geometry"><div class="drawing"><svg id="geometry" viewBox="0 0 100 100" role="img" aria-labelledby="geometryTitle"><title id="geometryTitle">Endpoint A, endpoint B, and the current interpolated four-point shape</title><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M10 0H0V10" fill="none" stroke="#dbe1e8" stroke-width=".25"/></pattern></defs><rect width="100" height="100" fill="url(#grid)"/><path id="shapeA" fill="none" stroke="#657587" stroke-width=".8" stroke-dasharray="2 1"/><path id="shapeB" fill="none" stroke="#9d6a34" stroke-width=".8" stroke-dasharray=".6 1.2"/><path id="shapeCurrent" fill="#64829833" stroke="#284e70" stroke-width="1.1"/><g id="pointLabels"></g></svg><div class="legend"><span>A · dashed</span><span>B · dotted</span><strong>Current · solid</strong></div></div><div class="panel"><div class="strip"><span class="eyebrow">Current fraction</span><strong id="fraction" class="metric">0</strong></div><label for="t">Interpolation t · 0 to 1</label><input id="t" type="range" min="0" max="1" step="0.001" value="0"><div class="buttons"><button id="zero">A · 0</button><button id="half">Midpoint · 0.5</button><button id="one">B · 1</button></div><div class="buttons"><button class="primary" id="play">Play 2-second pass</button><button id="pause" disabled>Pause</button><button id="reset">Reset fixture</button></div><p id="feedback" class="muted" role="status">Paused at A.</p><h3 class="below">Current d</h3><pre id="readout" class="record">M20 20 L80 20 L80 80 L20 80 Z</pre></div></section>
<section class="panel below"><h2>Endpoint contract</h2><div class="row"><div><label for="a">A · four points in fixed order</label><textarea id="a" spellcheck="false">M20 20 L80 20 L80 80 L20 80 Z</textarea></div><div><label for="b">B · corresponding point order</label><textarea id="b" spellcheck="false">M30 10 L90 30 L70 90 L10 70 Z</textarea></div></div><div class="buttons"><button id="apply" class="primary">Validate endpoints</button></div><p id="error" class="error" role="status"></p><div id="coordinates"></div><p class="muted">Each coordinate is a + (b − a) × t from the original endpoints. The current path retains unrounded numbers; the table uses up to six decimals. This bounded helper accepts uppercase M L L L Z only; it does not align points or normalize curves. The 0–100 view fits the fixture; other coordinates can lie outside it.</p><details><summary>Playback and compatibility</summary><p>No autoplay. A complete pass advances t by 0.5 per active second, stopping at B. Manual selection, invalid endpoints, hidden-page suspension and reduced-motion changes stop playback. Reduced motion keeps manual selection available.</p><p>Matching structure does not guarantee topology or prevent every self-intersection. Incompatible paths are outside this helper; they are not universally invalid SVG.</p><a href="https://www.w3.org/TR/SVG2/paths.html#PathData">W3C SVG path structure and animation rules</a></details></section><footer class="muted below">Local working copy · Changes are not saved after this page closes.</footer></main><script data-app>
(()=>{
const $=id=>document.getElementById(id);const text=(id,v)=>$(id).textContent=String(v);const on=(id,type,fn)=>$(id).addEventListener(type,fn);const escapeHTML=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));const decimal=s=>{const raw=String(s).trim();if(!/^[0-9]+(?:[.][0-9]{1,6})?$/.test(raw)||!Number.isFinite(+raw)||+raw>1000000)throw Error('Use a number from 0 to 1,000,000 with at most six decimal places. Blank is missing.');return +raw};const display=n=>n>0&&n<0.000001?n.toExponential(6):Number(n.toFixed(6)).toString();const download=(name,content,type='text/plain')=>{const url=URL.createObjectURL(new Blob([content],{type})),a=document.createElement('a');a.href=url;a.download=name;document.body.append(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000)};

const originalA='M20 20 L80 20 L80 80 L20 80 Z',originalB='M30 10 L90 30 L70 90 L10 70 Z';let A=null,B=null,t=0,playing=false,frame=null,epoch=0,startTime=null,startT=0;const motion=window.matchMedia('(prefers-reduced-motion: reduce)');
function parse(s){const n='(-?(?:[0-9]+(?:[.][0-9]+)?|[.][0-9]+))';const re=new RegExp('^\\s*M\\s*'+n+'[ ,]+'+n+'\\s*L\\s*'+n+'[ ,]+'+n+'\\s*L\\s*'+n+'[ ,]+'+n+'\\s*L\\s*'+n+'[ ,]+'+n+'\\s*Z\\s*$');const m=s.match(re);if(!m)throw Error('Use exactly M L L L Z with four explicit finite coordinate pairs.');const v=m.slice(1).map(Number);if(v.some(x=>!Number.isFinite(x)))throw Error('Every coordinate must be finite.');return v}
function interpolate(a,b,f){if(a.length!==8||b.length!==8||a.some(x=>!Number.isFinite(x))||b.some(x=>!Number.isFinite(x))||!Number.isFinite(f)||f<0||f>1)throw Error('Four finite pairs and t from 0 to 1 are required.');const values=a.map((x,i)=>f===0?x:f===1?b[i]:x+(b[i]-x)*f);if(values.some(x=>!Number.isFinite(x)))throw Error('Intermediate coordinates overflow.');return values}
function d(values){return values.reduce((s,v,i)=>s+(i%2===0?(i===0?'M':' L'):' ')+String(Object.is(v,-0)?0:v),'')+' Z'}
function stop(){playing=false;epoch++;if(frame!==null)cancelAnimationFrame(frame);frame=null;$('pause').disabled=true;$('play').disabled=motion.matches||!A||!B;startTime=null}
function render(){if(!A||!B)return;let values;try{values=interpolate(A,B,t)}catch(e){invalidate(e.message);return}text('fraction',display(t));$('t').value=t;$('shapeA').setAttribute('d',d(A));$('shapeB').setAttribute('d',d(B));$('shapeCurrent').setAttribute('d',d(values));text('readout',d(values));$('pointLabels').innerHTML=[0,1,2,3].map(i=>'<text x="'+values[i*2]+'" y="'+(values[i*2+1]-2)+'" font-size="3.3" text-anchor="middle" fill="#183b59">P'+(i+1)+'</text>').join('');$('coordinates').innerHTML='<table><thead><tr><th>Point</th><th>A</th><th>B</th><th>Current</th></tr></thead><tbody>'+[0,1,2,3].map(i=>'<tr><td>P'+(i+1)+'</td><td>'+A.slice(i*2,i*2+2).map(display).join(', ')+'</td><td>'+B.slice(i*2,i*2+2).map(display).join(', ')+'</td><td>'+values.slice(i*2,i*2+2).map(display).join(', ')+'</td></tr>').join('')+'</tbody></table>'}
function invalidate(message){stop();A=B=null;for(const id of ['shapeA','shapeB','shapeCurrent'])$(id).removeAttribute('d');$('pointLabels').innerHTML='';$('coordinates').innerHTML='';text('readout','—');text('fraction','—');text('error',message);$('play').disabled=true;text('feedback','Accepted output cleared. Validate the endpoints before continuing.')}
function validate(){stop();try{const a=parse($('a').value),b=parse($('b').value);A=a;B=b;interpolate(A,B,t);text('error','');$('play').disabled=motion.matches;render();text('feedback',motion.matches?'Reduced motion: manual selection only.':'Compatible structure accepted. Paused.')}catch(e){invalidate(e.message)}}
function select(value){stop();const f=Number(value);if(!Number.isFinite(f)||f<0||f>1){invalidate('t must be finite and between 0 and 1.');return}t=f;render();if(A&&B)text('feedback',motion.matches?'Reduced motion: manual selection only.':'Paused at t = '+display(t)+'.')}
function play(){if(playing||motion.matches||!A||!B)return;if(t>=1)t=0;playing=true;startT=t;startTime=null;const myEpoch=++epoch;$('pause').disabled=false;$('play').disabled=true;text('feedback','Playing · one two-second pass.');function tick(now){if(!playing||myEpoch!==epoch)return;if(startTime===null)startTime=now;t=Math.min(1,startT+(now-startTime)/2000);render();if(!playing)return;if(t>=1){stop();text('feedback','Paused at B · pass complete.')}else frame=requestAnimationFrame(tick)}frame=requestAnimationFrame(tick)}
on('t','input',()=>select($('t').value));on('zero','click',()=>select(0));on('half','click',()=>select(.5));on('one','click',()=>select(1));on('play','click',play);on('pause','click',()=>{stop();text('feedback','Paused at t = '+display(t)+'.')});on('apply','click',validate);['a','b'].forEach(id=>on(id,'input',()=>invalidate('Endpoints changed. Validate before accepting a new preview.')));on('reset','click',()=>{stop();$('a').value=originalA;$('b').value=originalB;t=0;validate()});document.addEventListener('visibilitychange',()=>{if(document.hidden){stop();text('feedback','Page hidden: paused. Resume manually.')}});motion.addEventListener('change',()=>{stop();text('feedback',motion.matches?'Reduced motion: manual selection only.':'Motion preference changed: paused. Resume manually.')});window.app={parse,interpolate,d,select,play,stop,validate,getState:()=>({t,playing,epoch})};validate();
})();
</script></body></html>