Sunriver · Six-piece jigsaw
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>Sunriver · Six-piece jigsaw</title><style>*{box-sizing:border-box}body{margin:0;background:#f3f1eb;color:#202e35;font:16px/1.5 system-ui,sans-serif}main{max-width:1160px;margin:auto;padding:32px 28px 70px}h1{font-size:clamp(26px,4vw,42px);line-height:1.12;margin:8px 0 12px;letter-spacing:-.04em}h2{font-size:22px;margin:0 0 16px}h3{font-size:17px;margin:0 0 8px}p{margin:8px 0 16px}.eyebrow{text-transform:uppercase;letter-spacing:.15em;font-size:12px;font-weight:750;color:#45665e}.lede{color:#55636a;max-width:780px}.panel{background:#fff;border:1px solid #d9dfd9;border-radius:16px;padding:24px;margin:20px 0}.hero{background:#193f38;color:white}.hero .muted{color:#bfd6cb}.split{display:grid;grid-template-columns:1.1fr 1fr;gap:24px}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.row{display:flex;flex-wrap:wrap;gap:12px;align-items:center}.big{font-size:clamp(32px,5vw,58px);line-height:1.15;font-weight:750;letter-spacing:-.04em}.muted,small{color:#61716e}.badge{display:inline-block;padding:4px 9px;border-radius:20px;background:#e5efe8;color:#284e40;font-size:13px;font-weight:650}.warn{background:#fff2d2;color:#795213}.rule{border-top:1px solid #dce2dd;padding-top:16px;margin-top: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%;border:1px solid #aebbb5;border-radius:7px;background:white;color:#20332c;padding:10px;min-height:44px}textarea{min-height:96px;resize:vertical}button{min-height:44px;border:1px solid #80968c;border-radius:7px;padding:9px 14px;background:white;color:#1c4637;cursor:pointer}button.primary{background:#245d47;color:white;border-color:#245d47}button:disabled{opacity:.5;cursor:default}button[aria-pressed=true]{background:#d8eb90;color:#1c3d29;border-color:#536a2d}button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible{outline:3px solid #d5782a;outline-offset:3px}.error{color:#913920;min-height:24px}pre,.record{white-space:pre-wrap;overflow-wrap:anywhere;font:14px/1.6 ui-monospace,monospace;background:#eef2ec;border-radius:8px;padding:16px}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border-bottom:1px solid #dce2dd;text-align:left;padding:12px 8px;vertical-align:top}th{font-size:12px;text-transform:uppercase;letter-spacing:.06em}.scroll{overflow:auto}details{margin:24px 0}summary{cursor:pointer;min-height:44px;font-weight:650}svg{max-width:100%;height:auto}.small{font-size:13px}.stack>*+*{margin-top:12px}@media(max-width:720px){main{padding:22px 16px 48px}.split{grid-template-columns:1fr}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.panel{padding:18px;margin:16px 0}h1{font-size:32px}.desktop-table{min-width:680px}}@media(max-width:400px){.grid{grid-template-columns:1fr}main{padding:18px 14px}.panel{padding:16px}.row>button{flex:1}h1{font-size:29px}}.puzzlebuttons{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.puzzlebuttons button{padding:8px;font-weight:650}#reference{display:block;width:100%;max-width:400px;margin:auto}</style></head><body><main><div class="eyebrow">Original SVG puzzle · Six complementary pieces</div><h1>Bring Sunriver together</h1><p class="lede">Drag a piece to its matching outline, or select a piece and activate a labeled slot.</p><section class="panel"><div class="row"><strong id="progress" class="badge">0 of 6 placed</strong><span id="selected" style="flex:1">No piece selected</span><button id="reset">Reset puzzle</button></div><svg id="workbench" viewBox="0 0 740 300" role="img" aria-label="Six-piece interlocking puzzle. Board on the left and tray on the right; native selection and slot buttons follow." style="width:100%;display:block;touch-action:none;user-select:none;margin:12px 0"></svg><p id="status" role="status">All six pieces are in the tray. Select a piece to begin.</p><div class="split"><div><h3>Select a tray piece</h3><div id="pieces" class="puzzlebuttons"></div></div><div><h3>Place selected piece in a slot</h3><div id="slots" class="puzzlebuttons"></div></div></div></section><section class="split"><div class="panel"><h2>Reference picture</h2><svg id="reference" viewBox="0 0 300 200" role="img" aria-label="Original abstract landscape: a golden sun in the upper left, a dark green hill across the right and a winding blue river flowing down the center."></svg></div><div class="panel"><h2>One picture, six contours</h2><p>Fixed orientation; no rotations. Correctly placed pieces lock until Reset. Every slot accepts only its matching piece. Wrong drops return to the tray without changing progress.</p><p>Escape, pointer cancellation or lost capture cancels an unfinished drag. Finish or cancel a drag before using the selection buttons. Resizing cancels a drag and recomputes the coordinate mapping.</p><p class="small">Original art and shared edge geometry. Local-only state; reload starts over. Browser seam and assistive interaction inspection remains separate from the embedded logic checks.</p></div></section></main><script>const $=id=>document.getElementById(id),put=(id,v)=>$(id).textContent=String(v),esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const art='<rect width="300" height="200" fill="#eddfb8"/><circle cx="62" cy="52" r="29" fill="#dd9134"/><path d="M110 130Q160 25 300 62V200H75Z" fill="#476956"/><path d="M148 76Q95 125 163 143Q223 165 116 200H42Q177 151 115 146Q70 132 148 76Z" fill="#4c8d99"/><path d="M0 158Q50 130 86 170L68 200H0Z" fill="#76926c"/>';
function cubic(a,b,c,d){const p=[];for(let i=1;i<=20;i++){const t=i/20,u=1-t;p.push([u*u*u*a[0]+3*u*u*t*b[0]+3*u*t*t*c[0]+t*t*t*d[0],u*u*u*a[1]+3*u*u*t*b[1]+3*u*t*t*c[1]+t*t*t*d[1]])}return p}
function segment(x,y,vertical){let p=[[0,0],[35,0]];p.push(...cubic([35,0],[45,0],[45,-5],[40,-8]),...cubic([40,-8],[22,-30],[78,-30],[60,-8]),...cubic([60,-8],[55,-5],[55,0],[65,0]),[100,0]);return p.map(([a,b])=>vertical?[x+b,y+a]:[x+a,y+b])}
const edges={};for(let r=0;r<2;r++)for(let c=1;c<3;c++)edges['V'+r+c]=segment(c*100,r*100,true);for(let c=0;c<3;c++)edges['H'+c]=segment(c*100,100,false);
const line=(a,b)=>[a,b],rev=a=>a.slice().reverse(),contours=[],uses=[];for(let i=0;i<6;i++){const c=i%3,r=Math.floor(i/3),x=c*100,y=r*100,top=r?edges['H'+c]:line([x,y],[x+100,y]),right=c<2?edges['V'+r+(c+1)]:line([x+100,y],[x+100,y+100]),bottom=r===0?rev(edges['H'+c]):line([x+100,y+100],[x,y+100]),left=c?rev(edges['V'+r+c]):line([x,y+100],[x,y]);uses.push([top,right,bottom,left]);contours.push([top,right,bottom,left].flatMap((a,j)=>j?a.slice(1):a))}
function inside(p,poly){let yes=false;for(let i=0,j=poly.length-1;i<poly.length;j=i++){const a=poly[i],b=poly[j],cross=(p.x-a[0])*(b[1]-a[1])-(p.y-a[1])*(b[0]-a[0]);if(Math.abs(cross)<1e-7&&p.x>=Math.min(a[0],b[0])-1e-7&&p.x<=Math.max(a[0],b[0])+1e-7&&p.y>=Math.min(a[1],b[1])-1e-7&&p.y<=Math.max(a[1],b[1])+1e-7)return true;if((a[1]>p.y)!==(b[1]>p.y)&&p.x<(b[0]-a[0])*(p.y-a[1])/(b[1]-a[1])+a[0])yes=!yes}return yes}
function hit(p){if(p.x<0||p.x>300||p.y<0||p.y>200)return -1;return contours.findIndex(poly=>inside(p,poly))}const pid=i=>'P'+String(i+1).padStart(2,'0'),sid=i=>'S'+String(i+1).padStart(2,'0');const fresh=()=>({locations:Array(6).fill('tray'),occupants:Array(6).fill(null),selected:null,drag:null});let state=fresh(),narrow=false,board={x:20,y:30};
function valid(s){if(s.locations.length!==6||s.occupants.length!==6)throw Error('Invalid puzzle size.');for(let i=0;i<6;i++){if(!['tray',sid(i)].includes(s.locations[i]))throw Error('Noncanonical location.');if(s.occupants[i]!==null&&s.occupants[i]!==pid(i))throw Error('Duplicate or incorrect occupant.');if((s.locations[i]===sid(i))!==(s.occupants[i]===pid(i)))throw Error('Location mismatch.')}return true}function count(s){valid(s);return s.locations.filter((x,i)=>x===sid(i)).length}
function place(s,p,slot,fromPointer=false){valid(s);if(s.drag&&!fromPointer)return{ok:false,message:'Finish or cancel the active drag first.'};if(!Number.isInteger(p)||p<0||p>5||!Number.isInteger(slot)||slot<0||slot>5)return{ok:false,message:'Drop inside a board outline.'};if(s.locations[p]!=='tray'||s.occupants[slot]!==null||p!==slot)return{ok:false,message:pid(p)+' does not fit '+sid(slot)+'. Returned to its prior location.'};s.locations[p]=sid(slot);s.occupants[slot]=pid(p);s.selected=null;return{ok:true,message:pid(p)+' committed to '+sid(slot)+'.'+(count(s)===6?' Picture complete!':'')}}
function begin(s,p,pointer,point){if(s.drag||!Number.isInteger(p)||p<0||p>5||s.locations[p]!=='tray')return false;s.selected=p;s.drag={piece:p,pointer,prior:'tray',phase:'dragging',point};return true}function finish(s,pointer,slot){if(!s.drag||s.drag.pointer!==pointer)return null;const d=s.drag;s.drag=null;const result=place(s,d.piece,slot,true);s.selected=null;return result}function cancelState(s,pointer){if(!s.drag||(pointer!==undefined&&s.drag.pointer!==pointer))return null;const id=s.drag.pointer;s.drag=null;s.selected=null;return id}
function transform(i){if(state.locations[i]!=='tray')return[board.x,board.y];const c=i%3,r=Math.floor(i/3),x=(narrow?20:400)+c*105,y=(narrow?275:35)+r*112;return[x-c*100,y-r*100]}
function release(id){if(id!==null&&$('workbench').hasPointerCapture&&$('workbench').hasPointerCapture(id))$('workbench').releasePointerCapture(id)}function cancelDrag(message){const id=cancelState(state);release(id);if(id!==null)render(message||'Drag canceled. Piece returned to its prior location.')}
function render(message){valid(state);const paths=contours.map(p=>'M'+p.map(a=>a.map(v=>v.toFixed(4)).join(',')).join('L')+'Z');let defs='<defs><g id="landscape">'+art+'</g>'+paths.map((p,i)=>'<clipPath id="clip'+i+'" clipPathUnits="userSpaceOnUse"><path d="'+p+'"/></clipPath>').join('')+'</defs>';const slots=paths.map((p,i)=>'<g transform="translate('+board.x+' '+board.y+')"><path d="'+p+'" fill="#edf1e8" stroke="#7d9181" stroke-width="1"/><text x="'+((i%3)*100+50)+'" y="'+(Math.floor(i/3)*100+56)+'" text-anchor="middle" fill="#667e6b" font-size="14">'+sid(i)+'</text></g>').join('');const pieces=paths.map((p,i)=>{const t=transform(i);return'<g id="piece'+i+'" data-piece="'+i+'" transform="translate('+t[0]+' '+t[1]+')" style="cursor:'+(state.locations[i]==='tray'?'grab':'default')+'"><g clip-path="url(#clip'+i+')"><use href="#landscape"/></g><path d="'+p+'" fill="transparent" stroke="'+(state.selected===i?'#c34e26':'#294b3c')+'" stroke-width="'+(state.selected===i?3:1.2)+'"/>'+(state.locations[i]==='tray'?'<text pointer-events="none" x="'+((i%3)*100+50)+'" y="'+(Math.floor(i/3)*100+56)+'" font-size="14" text-anchor="middle" fill="#fff" stroke="#173b2d" stroke-width=".7">'+pid(i)+'</text>':'')+'</g>'}).join('');$('workbench').innerHTML=defs+slots+pieces;$('workbench').setAttribute('viewBox',narrow?'0 0 340 500':'0 0 740 300');put('progress',count(state)+' of 6 placed'+(count(state)===6?' · Complete':''));put('selected',state.selected===null?'No piece selected':pid(state.selected)+' selected');$('pieces').innerHTML=Array.from({length:6},(_,i)=>'<button data-piece="'+i+'" aria-pressed="'+(state.selected===i)+'" '+(state.locations[i]!=='tray'?'disabled':'')+'>'+pid(i)+(state.locations[i]!=='tray'?' ✓':'')+'</button>').join('');$('slots').innerHTML=Array.from({length:6},(_,i)=>'<button id="slot'+i+'" data-slot="'+i+'" '+(state.occupants[i]!==null?'disabled':'')+'>'+sid(i)+'</button>').join('');if(message)put('status',message)}
function point(e){const svg=$('workbench'),p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(svg.getScreenCTM().inverse())}
$('workbench').addEventListener('pointerdown',e=>{if(e.button!==0||state.drag)return;const target=e.target.closest('[data-piece]');if(!target)return;const i=Number(target.dataset.piece),p=point(e),t=transform(i);if(!begin(state,i,e.pointerId,{x:p.x-t[0],y:p.y-t[1]}))return;e.preventDefault();$('workbench').setPointerCapture(e.pointerId);render('Dragging '+pid(i)+'. Release over its board outline; Escape cancels.')});$('workbench').addEventListener('pointermove',e=>{if(!state.drag||state.drag.pointer!==e.pointerId)return;const p=point(e),d=state.drag;$('piece'+d.piece).setAttribute('transform','translate('+(p.x-d.point.x)+' '+(p.y-d.point.y)+')')});$('workbench').addEventListener('pointerup',e=>{if(!state.drag||state.drag.pointer!==e.pointerId)return;const p=point(e),r=finish(state,e.pointerId,hit({x:p.x-board.x,y:p.y-board.y}));release(e.pointerId);render(r.message);$('reset').focus()});for(const type of ['pointercancel','lostpointercapture'])$('workbench').addEventListener(type,e=>{if(state.drag?.pointer===e.pointerId)cancelDrag()});$('pieces').addEventListener('click',e=>{if(e.target.dataset.piece===undefined)return;if(state.drag){put('status','Finish or cancel the active drag first.');return}const i=Number(e.target.dataset.piece);if(state.locations[i]!=='tray')return;state.selected=i;render(pid(i)+' selected. Activate its labeled slot.');$('slots').querySelector?.('button:not(:disabled)')?.focus()});$('slots').addEventListener('click',e=>{if(e.target.dataset.slot===undefined)return;const r=place(state,state.selected,Number(e.target.dataset.slot));render(r.message);const next=state.occupants.findIndex(x=>x===null);if(next>=0)$('slot'+next).focus();else $('reset').focus()});$('reset').addEventListener('click',()=>{const id=cancelState(state);state=fresh();release(id);render('Reset. All six pieces returned to the tray; 0 of 6 placed.');$('reset').focus()});if(document.addEventListener)document.addEventListener('keydown',e=>{if(e.key==='Escape'&&state.drag){e.preventDefault();cancelDrag();$('reset').focus()}});function layout(){cancelDrag('Resize canceled the unfinished drag.');narrow=$('workbench').clientWidth<600;board={x:20,y:narrow?20:30};$('workbench').setAttribute('aria-label',narrow?'Puzzle board above the six tray pieces. Native piece and slot controls follow.':'Puzzle board left of the six tray pieces. Native piece and slot controls follow.');render()};if(typeof window!=='undefined')window.addEventListener('resize',layout);$('reference').innerHTML=art;globalThis.Tool={edges,uses,contours,inside,hit,fresh,valid,count,place,begin,finish,cancelState,getState:()=>state,layout,point};layout();</script></body></html>