import {distance,angle,circleFrom3,signedAngle,transformPoint,inverseTransformPoint,pointInPolygon} from './geometry.mjs'; import {createProject,validateProject} from './project.mjs'; import {createDemo} from './demo.mjs'; const $=id=>document.getElementById(id), canvas=$('canvas'),ctx=canvas.getContext('2d'); const identity=()=>({cx:0,cy:0,x:0,y:0,rotation:0,scale:1,flipX:false}); let state=createProject(),image=null,selected=null,tool='select',draft=[],hover=null,drag=null; let view={zoom:1,x:0,y:0,w:0,h:0},history=[],future=[],compare=false,dirty=false,toastTimer; const symbols={length:'↔',angle:'∠',axis:'╱',circle:'◯',fragment:'⬡',template:'▯'}; const names={length:'Length',angle:'Angle',axis:'Axis',circle:'Circle',fragment:'Fragment',template:'Template'}; const hints={select:'Select an overlay to adjust it · Scroll to zoom',pan:'Drag to pan the image · Scroll to zoom',calibrate:'Click both ends of the known reference',length:'Click the start and end of a distance',angle:'Click first arm, vertex, then second arm',axis:'Click two points to define an axis',circle:'Click three points on the circle',fragment:'Click around a fragment · Enter to finish · Esc to cancel'}; const cloneState=()=>({...state,image:state.image,calibration:structuredClone(state.calibration),objects:structuredClone(state.objects),caseInfo:{...state.caseInfo},adjustments:{...state.adjustments}}); const selectedObject=()=>state.objects.find(o=>o.id===selected); const scale=()=>state.calibration?.pixelsPerMm||null; const escapeHtml=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const toast=msg=>{$('toast').textContent=msg;$('toast').hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>$('toast').hidden=true,4500);}; function remember(){history.push(cloneState());if(history.length>40)history.shift();future=[];dirty=true;} function commit(){refresh();draw();} function clearDrawing(){draft=[];hover=null;$('draftControls').hidden=true;draw();} function setTool(value){tool=value;if(compare&&value!=='select'&&value!=='pan'){compare=false;$('compareBtn').setAttribute('aria-pressed','false');}clearDrawing();document.querySelectorAll('[data-tool]').forEach(b=>{b.classList.toggle('active',b.dataset.tool===value);b.setAttribute('aria-pressed',String(b.dataset.tool===value));});$('canvasHint').textContent=hints[value];canvas.style.cursor=value==='pan'?'grab':value==='select'?'default':'crosshair';} function imagePoint(event){const r=canvas.getBoundingClientRect();return{x:(event.clientX-r.left-view.x)/view.zoom,y:(event.clientY-r.top-view.y)/view.zoom};} function screenPoint(p){return{x:p.x*view.zoom+view.x,y:p.y*view.zoom+view.y};} function inImage(p){return state.image&&p.x>=0&&p.y>=0&&p.x<=state.image.width&&p.y<=state.image.height;} function path(c,points,close=false){c.beginPath();points.forEach((p,i)=>i?c.lineTo(p.x,p.y):c.moveTo(p.x,p.y));if(close)c.closePath();} function applyTransform(c,t){c.translate(t.cx+t.x,t.cy+t.y);c.rotate(t.rotation*Math.PI/180);c.scale(t.scale*(t.flipX?-1:1),t.scale);c.translate(-t.cx,-t.cy);} function annotationPoints(o){return o.transform?o.points.map(p=>transformPoint(p,o.transform)):o.points;} function formatDistance(px){return scale()?`${(px/scale()).toFixed(1)} mm`:`${px.toFixed(1)} px`;} function valueFor(o){ if(o.kind==='length')return formatDistance(distance(...annotationPoints(o))); if(o.kind==='angle'){const n=angle(...annotationPoints(o));return n===null?'Undefined':`${n.toFixed(1)}°`;} if(o.kind==='circle'){const c=circleFrom3(...annotationPoints(o));return c?`Ø ${formatDistance(2*c.radius)}`:'Collinear';} if(o.kind==='axis')return 'Reference'; if(o.kind==='fragment')return `${(o.transform?.rotation||0).toFixed(0)}°`; return scale()?`${o.params.lengthMm||o.params.sizeMm} mm*`:'Hidden · no scale'; } function label(c,text,p,color,z){c.save();const fontSize=11/z;c.font=`500 ${fontSize}px -apple-system, sans-serif`;const width=c.measureText(text).width+14/z;const height=24/z;c.fillStyle='#112a32e8';c.strokeStyle=color+'88';c.lineWidth=.6/z;c.beginPath();c.roundRect(p.x+10/z,p.y-height/2,width,height,4/z);c.fill();c.stroke();c.fillStyle=color;c.fillText(text,p.x+17/z,p.y+3.5/z);c.restore();} function templateDimensions(o){const p=scale();if(!p)return null;const l=(o.params.lengthMm||o.params.sizeMm||100)*p,w=(o.params.widthMm||12)*p;return{l,w,p};} function drawTemplate(c,o,z,active){ const dims=templateDimensions(o);if(!dims)return;const{l,w,p}=dims;c.save();applyTransform(c,o.transform||identity());c.strokeStyle=active?'#89d9cb':'#60bcb1';c.fillStyle='#55c1b31c';c.lineWidth=(active?2:1.4)/z;c.setLineDash([]); if(o.templateType==='plate'){ c.beginPath();c.roundRect(-w/2,-l/2,w,l,w/2);c.fill();c.stroke();let holes=o.params.holes||6; for(let i=0;i0){const nx=-(b.y-a.y)/d*5/z,ny=(b.x-a.x)/d*5/z;for(const p of pts){path(c,[{x:p.x+nx,y:p.y+ny},{x:p.x-nx,y:p.y-ny}]);c.stroke();}} label(c,valueFor(o),{x:(a.x+b.x)/2,y:(a.y+b.y)/2},color,z); }else if(o.kind==='angle'){ const b=pts[1],a1=Math.atan2(pts[0].y-b.y,pts[0].x-b.x),a2=Math.atan2(pts[2].y-b.y,pts[2].x-b.x),cross=(pts[0].x-b.x)*(pts[2].y-b.y)-(pts[0].y-b.y)*(pts[2].x-b.x);c.beginPath();c.arc(b.x,b.y,Math.min(30/z,distance(pts[0],b)*.4,distance(pts[2],b)*.4),a1,a2,cross<0);c.stroke();label(c,valueFor(o),b,color,z); } } if(interactive&&(active||o.kind!=='fragment'))for(const p of pts){c.beginPath();c.arc(p.x,p.y,(active?3.5:2.3)/z,0,Math.PI*2);c.fillStyle=active?'#e5fff7':color;c.fill();} c.restore(); } function renderScene(c,z,interactive=true){ if(!image)return; c.save();c.filter=`brightness(${state.adjustments.brightness}%) contrast(${state.adjustments.contrast}%) invert(${state.adjustments.invert?1:0})`;c.drawImage(image,0,0);c.restore(); if(compare&&interactive)return; // Each fragment is an image cutout. Its source is masked before transformed copies are drawn. const fragments=state.objects.filter(o=>o.kind==='fragment'); for(const o of fragments){c.save();path(c,o.points,true);c.fillStyle='#10171d';c.fill();c.restore();} for(const o of fragments){c.save();applyTransform(c,o.transform||identity());path(c,o.points,true);c.clip();c.filter=`brightness(${state.adjustments.brightness}%) contrast(${state.adjustments.contrast}%) invert(${state.adjustments.invert?1:0})`;c.drawImage(image,0,0);c.restore();} if(state.calibration){const p=state.calibration.points;c.save();c.strokeStyle='#59a28b99';c.lineWidth=1/z;c.setLineDash([3/z,3/z]);path(c,p);c.stroke();c.restore();} state.objects.forEach(o=>drawObject(c,o,z,interactive)); const axes=state.objects.filter(o=>o.kind==='axis');if(axes.length===2){const a=annotationPoints(axes[0]),b=annotationPoints(axes[1]);const n=signedAngle(...a,...b);if(n!==null){const acute=Math.min(Math.abs(n),180-Math.abs(n));label(c,`Axes: ${acute.toFixed(1)}° (acute)`,b[0],'#a6bbd9',z);}} } function draw(){ const dpr=window.devicePixelRatio||1;ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,view.w,view.h);if(!image)return; ctx.save();ctx.translate(view.x,view.y);ctx.scale(view.zoom,view.zoom);renderScene(ctx,view.zoom,true); if(draft.length){const pts=[...draft];if(hover&&inImage(hover))pts.push(hover);ctx.strokeStyle='#d8d4a6';ctx.lineWidth=1.5/view.zoom;ctx.setLineDash([4/view.zoom,4/view.zoom]);path(ctx,pts,tool==='fragment'&&pts.length>2);ctx.stroke();ctx.setLineDash([]);for(const p of draft){ctx.beginPath();ctx.arc(p.x,p.y,3/view.zoom,0,Math.PI*2);ctx.fillStyle='#ece2a0';ctx.fill();}} ctx.restore();$('zoomLabel').textContent=$('zoomStatus').textContent=`${Math.round(view.zoom*100)}%`; } function resize(fit=false){const r=$('canvasWrap').getBoundingClientRect(),dpr=window.devicePixelRatio||1;const oldCenter={x:(view.w/2-view.x)/view.zoom,y:(view.h/2-view.y)/view.zoom};view.w=r.width;view.h=r.height;canvas.width=Math.round(r.width*dpr);canvas.height=Math.round(r.height*dpr);if(fit&&state.image)fitImage();else{view.x=view.w/2-oldCenter.x*view.zoom;view.y=view.h/2-oldCenter.y*view.zoom;draw();}} function fitImage(){if(!state.image)return;view.zoom=Math.min((view.w-75)/state.image.width,(view.h-75)/state.image.height);view.x=(view.w-state.image.width*view.zoom)/2;view.y=(view.h-state.image.height*view.zoom)/2;draw();} function zoomAt(factor,sx=view.w/2,sy=view.h/2){const next=Math.min(8,Math.max(.03,view.zoom*factor)),ix=(sx-view.x)/view.zoom,iy=(sy-view.y)/view.zoom;view.zoom=next;view.x=sx-ix*next;view.y=sy-iy*next;draw();} function refresh(){ const cal=state.calibration,demo=state.image?.isDemo; $('caseTitle').textContent=state.caseInfo.label||'Untitled study';$('procedureLabel').textContent=state.caseInfo.procedure||'General measurements';$('sideLabel').textContent=state.caseInfo.laterality?`${state.caseInfo.laterality} side`:'Laterality unspecified';$('imageSize').textContent=state.image?`${state.image.width} × ${state.image.height} px`:''; $('viewType').textContent=demo?'AP VIEW':'IMAGE VIEW';$('demoTag').textContent=demo?'SYNTHETIC':'LOCAL IMAGE';$('imageName').textContent=demo?'DEMO / FEMUR':state.image?.name||'';$('viewerMode').textContent=`${state.caseInfo.laterality||'UNSPECIFIED'} · MANUAL LATERALITY`; $('scaleStatus').textContent=cal?(demo?'Synthetic scale':'Manual calibration'):'Uncalibrated';$('scaleDetail').textContent=cal?`${cal.pixelsPerMm.toFixed(3)} px/mm`:'Lengths in pixels';$('calibrationTag').textContent=cal?(demo?'DEMO SCALE':'MANUAL'):'REQUIRED';$('calibrationTag').classList.toggle('teal',!!cal);$('calibrationValue').textContent=cal?`${cal.knownMm} mm reference`:'No physical scale';$('calibrationDescription').textContent=cal?(demo?'Known synthetic marker':'Uniform scale · verify reference depth'):'Mark a known image reference';$('clearScale').disabled=!cal;$('calibrateStep').classList.toggle('done',!!cal);$('scaleCheck').textContent=cal?'✓':'!'; $('objectCount').textContent=state.objects.length;$('objectList').replaceChildren();$('emptyObjects').hidden=!!state.objects.length; for(const o of state.objects){const b=document.createElement('button');b.className='object-row'+(o.id===selected?' selected':'');b.setAttribute('aria-pressed',String(o.id===selected));const symbol=document.createElement('span');symbol.className='object-symbol';symbol.textContent=symbols[o.kind];const name=document.createElement('span');name.className='object-text';name.textContent=o.label||names[o.kind];const val=document.createElement('span');val.className='object-value';val.textContent=valueFor(o);b.append(symbol,name,val);b.onclick=()=>{selected=o.id;setTool('select');commit();};$('objectList').append(b);} const o=selectedObject();$('selectionEditor').hidden=!o;$('deleteBtn').disabled=!o; if(o){$('objectLabel').value=o.label||'';const movable=['fragment','template'].includes(o.kind);$('transformEditor').hidden=!movable;$('templateEditor').hidden=o.kind!=='template';if(movable){const t=o.transform||identity();$('rotation').value=t.rotation;$('rotationValue').textContent=`${t.rotation.toFixed(0)}°`;const factor=scale()||1;$('translateX').value=(t.x/factor).toFixed(1);$('translateY').value=(t.y/factor).toFixed(1);$('translationUnitX').textContent=$('translationUnitY').textContent=scale()?'mm':'px';}if(o.kind==='template'){$('templateLength').value=o.params.lengthMm||o.params.sizeMm;$('templateWidth').value=o.params.widthMm||12;}} $('undoBtn').disabled=$('undoTop').disabled=!history.length;$('redoBtn').disabled=!future.length; for(const id of ['brightness','contrast']){$(id).value=state.adjustments[id];$(id+'Value').textContent=state.adjustments[id]+'%';}$('invert').checked=state.adjustments.invert; for(const [id,key] of [['caseLabel','label'],['procedure','procedure'],['laterality','laterality'],['notes','notes']]){$(id).value=state.caseInfo[key]||'';} $('trayTitle').textContent=demo?'AP femur · right':state.caseInfo.label||'Imported image';$('traySub').textContent=demo?'Synthetic demonstration image':'Browser-local image';$('draftControls').hidden=tool!=='fragment'||!draft.length;$('draftCount').textContent=`${draft.length} points`;$('finishFragment').disabled=draft.length<3; } function addObject(kind,points){ if(kind==='length'||kind==='axis'){if(distance(...points)<1){toast('Choose two distinct points.');return false;}} if(kind==='angle'&&angle(...points)===null){toast('Use three points with distinct arms.');return false;} if(kind==='circle'&&!circleFrom3(...points)){toast('Choose three points that do not lie on one line.');return false;} remember();const o={id:crypto.randomUUID(),kind,points:structuredClone(points),label:`${names[kind]} ${state.objects.filter(o=>o.kind===kind).length+1}`}; if(kind==='fragment'){const cx=points.reduce((s,p)=>s+p.x,0)/points.length,cy=points.reduce((s,p)=>s+p.y,0)/points.length;o.transform={...identity(),cx,cy};} state.objects.push(o);selected=o.id;draft=[];commit();if(kind==='fragment')setTool('select');return true; } function finishFragment(){if(tool!=='fragment'||draft.length<3)return;let area=0;for(let i=0;i(b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x);for(let i=0;i{ if(e.button!==0)return;canvas.focus();if(!image)return;const p=imagePoint(e);canvas.setPointerCapture(e.pointerId); if(tool==='pan'){drag={type:'pan',sx:e.clientX,sy:e.clientY,x:view.x,y:view.y};canvas.style.cursor='grabbing';return;} if(tool==='select'){ const hit=compare?null:hitObject(p);selected=hit?.o.id||null;if(hit){const movable=['fragment','template'].includes(hit.o.kind);drag={type:hit.point!==undefined?'point':movable?'object':'click',o:hit.o,point:hit.point,start:p,initial:structuredClone(hit.o),saved:false};}else drag={type:'pan',sx:e.clientX,sy:e.clientY,x:view.x,y:view.y};commit();return; } if(!inImage(p)){toast('Choose a point inside the image.');return;} if(tool==='calibrate'){ const known=Number($('knownMm').value);if(!Number.isFinite(known)||known<.1||known>10000){toast('Enter a reference from 0.1 to 10,000 mm.');return;} draft.push(p);if(draft.length===2){const d=distance(...draft);if(d<3){draft=[];toast('Reference points must be at least 3 image pixels apart.');draw();return;}remember();state.calibration={knownMm:known,pixelsPerMm:d/known,points:structuredClone(draft)};draft=[];setTool('select');commit();toast('Manual scale applied. Check marker depth and projection before interpreting measurements.');}else draw();return; } if(draft.length>=256){toast('Maximum 256 outline points. Finish with Enter.');return;} draft.push(p);const count={length:2,axis:2,angle:3,circle:3}[tool];if(count&&draft.length===count){const kind=tool;addObject(kind,draft);draft=[];}commit(); }); canvas.addEventListener('pointermove',e=>{const p=imagePoint(e);hover=p;$('cursorPosition').textContent=inImage(p)?`X ${Math.round(p.x)} · Y ${Math.round(p.y)} px`:'Image coordinates'; if(drag){if(drag.type==='pan'){view.x=drag.x+e.clientX-drag.sx;view.y=drag.y+e.clientY-drag.sy;draw();return;}if(drag.type==='object'||drag.type==='point'){if(!drag.saved&&distance(p,drag.start)>2/view.zoom){remember();drag.saved=true;}if(drag.saved){if(drag.type==='object'){drag.o.transform.x=drag.initial.transform.x+p.x-drag.start.x;drag.o.transform.y=drag.initial.transform.y+p.y-drag.start.y;}else{const q={x:Math.max(0,Math.min(state.image.width,p.x)),y:Math.max(0,Math.min(state.image.height,p.y))};drag.o.points[drag.point]=drag.o.transform?inverseTransformPoint(q,drag.o.transform):q;}draw();}return;}} if(draft.length)draw(); }); function stopDrag(){if(drag?.saved)commit();drag=null;canvas.style.cursor=tool==='pan'?'grab':tool==='select'?'default':'crosshair';} canvas.addEventListener('pointerup',stopDrag);canvas.addEventListener('pointercancel',stopDrag); canvas.addEventListener('dblclick',()=>{if(tool==='fragment'){if(draft.length>1&&distance(draft.at(-1),draft.at(-2))<4/view.zoom)draft.pop();finishFragment();}}); canvas.addEventListener('wheel',e=>{e.preventDefault();const r=canvas.getBoundingClientRect();zoomAt(Math.exp(-e.deltaY*.0015),e.clientX-r.left,e.clientY-r.top);},{passive:false}); function deleteSelection(){const o=selectedObject();if(!o)return;remember();state.objects=state.objects.filter(x=>x.id!==selected);selected=null;commit();} function undo(){if(!history.length)return;future.push(cloneState());state=history.pop();selected=null;draft=[];dirty=true;syncImageAndRender();} function redo(){if(!future.length)return;history.push(cloneState());state=future.pop();selected=null;draft=[];dirty=true;syncImageAndRender();} let imageGeneration=0; async function syncImageAndRender(fit=false){const generation=++imageGeneration;if(state.image&&image?.src!==state.image.src){const img=await decodeImage(state.image.src);if(generation!==imageGeneration)return;image=img;}$('thumb').src=state.image?.src||'';if(fit)fitImage();commit();} function decodeImage(src){return new Promise((resolve,reject)=>{const img=new Image();img.onload=()=>resolve(img);img.onerror=()=>reject(new Error('The image could not be decoded.'));img.src=src;});} function mayReplace(){return !dirty||confirm('Replace this session? Save your plan first if you need to keep the current changes.');} async function loadDemo(check=true){if(check&&!mayReplace())return;const generation=++imageGeneration;const demo=await createDemo();if(generation!==imageGeneration)return;state=createProject({image:demo,calibration:{pixelsPerMm:4,knownMm:25,points:[{x:775,y:1040},{x:875,y:1040}]},objects:[{id:'demo-plate',kind:'template',label:'Generic plate',templateType:'plate',params:{lengthMm:120,widthMm:12,holes:7},transform:{...identity(),x:570,y:640,rotation:-5}},{id:'demo-length',kind:'length',label:'Example distance',points:[{x:720,y:360},{x:720,y:840}],color:'#dfbc72'}],caseInfo:{label:'Synthetic femoral shaft study',procedure:'Trauma · fracture fixation',laterality:'Right',notes:'Synthetic workflow demonstration. Plate outline and dimensions are illustrative.'}});selected='demo-plate';history=[];future=[];dirty=false;compare=false;$('compareBtn').setAttribute('aria-pressed','false');setTool('select');await syncImageAndRender(true);} async function importImage(file){ if(!file)return;if(!['image/png','image/jpeg','image/webp'].includes(file.type)){toast('Use PNG, JPEG or WebP. DICOM is not supported in this version.');return;}if(file.size>23*1024*1024){toast('Choose an image smaller than 23 MB.');return;}if(!mayReplace())return; const generation=++imageGeneration; try{const src=await new Promise((resolve,reject)=>{const reader=new FileReader();reader.onload=()=>resolve(reader.result);reader.onerror=reject;reader.readAsDataURL(file);});const img=await decodeImage(src);if(generation!==imageGeneration)return;const next=createProject({image:{src,width:img.naturalWidth,height:img.naturalHeight,name:file.name,isDemo:false},caseInfo:{label:'Untitled local study',procedure:'Trauma · fracture fixation',laterality:'Unspecified',notes:''}});state=next;image=img;selected=null;history=[];future=[];draft=[];dirty=true;compare=false;$('compareBtn').setAttribute('aria-pressed','false');$('thumb').src=src;fitImage();setTool('calibrate');commit();toast('Image opened locally. Add a known reference to enable millimetres and templates.');}catch(e){if(generation===imageGeneration)toast(e.message||'Image could not be opened.');} } function download(blob,name){const url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),2000);} function safeFilename(){return(state.caseInfo.label||'orthoplan').replace(/[^a-z0-9_-]+/gi,'-').slice(0,60)||'orthoplan';} function savePlan(){try{const clean=validateProject(state);download(new Blob([JSON.stringify(clean,null,2)],{type:'application/json'}),safeFilename()+'.orthoplan.json');dirty=false;toast('Editable plan exported with its embedded image.');}catch(e){toast(e.message);}} async function openPlan(file){if(!file)return;if(file.size>36*1024*1024){toast('Plan exceeds the 36 MB limit.');return;}if(!mayReplace())return;const generation=++imageGeneration;try{const clean=validateProject(JSON.parse(await file.text()));if(!clean.image)throw new Error('This plan has no image.');const img=await decodeImage(clean.image.src);if(generation!==imageGeneration)return;if(img.naturalWidth!==clean.image.width||img.naturalHeight!==clean.image.height)throw new Error('Image dimensions do not match the saved plan.');state=clean;image=img;selected=null;draft=[];history=[];future=[];dirty=false;compare=false;$('compareBtn').setAttribute('aria-pressed','false');$('thumb').src=state.image.src;setTool('select');fitImage();commit();toast('Plan opened. Verify calibration, orientation, and annotations.');}catch(e){if(generation===imageGeneration)toast(e.message||'Plan could not be opened.');}} function annotatedCanvas(){const out=document.createElement('canvas'),factor=Math.min(1,2400/state.image.width,2400/state.image.height);out.width=Math.max(1,Math.round(state.image.width*factor));out.height=Math.max(1,Math.round(state.image.height*factor));const c=out.getContext('2d');if(!c)throw new Error('Image export is unavailable for this image size.');c.fillStyle='#0a121b';c.fillRect(0,0,out.width,out.height);c.save();c.scale(factor,factor);renderScene(c,factor,false);c.restore();const font=Math.max(5,Math.min(14,out.width/75)),footer=font*4;c.fillStyle='#091823e8';c.fillRect(0,out.height-footer,out.width,footer);c.fillStyle='#d5dddf';c.font=`${font}px -apple-system,sans-serif`;c.textAlign='center';c.fillText('ORTHOPLAN · UNVALIDATED PROTOTYPE · NOT FOR CLINICAL USE',out.width/2,out.height-font*2,out.width-12);c.font=`${font*.72}px -apple-system,sans-serif`;c.fillStyle='#99acb6';c.fillText(state.image.isDemo?'SYNTHETIC IMAGE · GENERIC TEMPLATES':'MANUAL IMAGE REVIEW · GENERIC TEMPLATES',out.width/2,out.height-font*.7,out.width-12);return out;} function showReport(){if(!image)return;const rows=state.objects.map(o=>`${escapeHtml(o.label||names[o.kind])}${escapeHtml(o.kind==='template'?'Generic '+o.templateType:names[o.kind])}${escapeHtml(valueFor(o))}`).join(''); $('reportContent').innerHTML=`
UNVALIDATED PROTOTYPE · NOT FOR CLINICAL USE

${escapeHtml(state.caseInfo.label||'Untitled study')}

${escapeHtml(state.caseInfo.procedure)} · Laterality: ${escapeHtml(state.caseInfo.laterality||'Unspecified')} (manually entered)
Generated ${escapeHtml(new Date().toLocaleString('en-IN',{timeZone:'Asia/Kolkata'}))} IST · ${state.image.isDemo?'Synthetic image':'Imported raster image'}

Calibration: ${state.calibration?`${state.calibration.knownMm} mm reference; ${state.calibration.pixelsPerMm.toFixed(4)} pixels/mm. ${state.image.isDemo?'Known synthetic scale.':'Manual uniform image scale.'}`:'Uncalibrated. Distances are in pixels.'}

Annotated prototype plan

Measurements and objects

${rows||''}
LABELTYPEVALUE
No annotations

* Generic implant dimensions are illustrative; these are not licensed manufacturer templates or implant recommendations. Fragment transforms are image manipulations; measurements remain independent of fragments.

Notes

${escapeHtml(state.caseInfo.notes||'No notes entered.')}

Reference depth, radiographic projection, landmark selection, and image resizing can affect measurements. No clinical accuracy or suitability has been established. Use approved clinical software for patient care.

`;$('reportDialog').showModal();} function addTemplate(type){if(!scale()){toast('Calibrate the image before adding a template.');setTool('calibrate');return;}remember();const sizes={plate:[120,12,7],screw:[70,7,0],nail:[220,10,0],cup:[52,4,0],stem:[140,18,0]},[lengthMm,widthMm,holes]=sizes[type],center={x:(view.w/2-view.x)/view.zoom,y:(view.h/2-view.y)/view.zoom};const o={id:crypto.randomUUID(),kind:'template',label:`Generic ${type}`,templateType:type,params:{lengthMm,widthMm,holes},transform:{...identity(),x:center.x,y:center.y}};state.objects.push(o);selected=o.id;setTool('select');commit();} function editSelected(callback){const o=selectedObject();if(!o)return;remember();callback(o);commit();} document.querySelectorAll('[data-tool]').forEach(b=>b.onclick=()=>setTool(b.dataset.tool)); document.querySelectorAll('[data-template]').forEach(b=>b.onclick=()=>addTemplate(b.dataset.template)); document.querySelectorAll('[data-panel]').forEach(b=>b.onclick=()=>{document.querySelectorAll('[data-panel]').forEach(x=>x.classList.toggle('active',x===b));$('planningPanel').hidden=b.dataset.panel!=='planning';$('studyPanel').hidden=b.dataset.panel!=='study';}); for(const id of ['importBtn','importStep'])$(id).onclick=()=>$('imageFile').click();$('imageFile').onchange=e=>{importImage(e.target.files[0]);e.target.value='';};$('loadBtn').onclick=()=>$('planFile').click();$('planFile').onchange=e=>{openPlan(e.target.files[0]);e.target.value='';};$('saveBtn').onclick=savePlan;$('demoBtn').onclick=()=>loadDemo(); for(const id of ['calibrateBtn','calibrateStep'])$(id).onclick=()=>setTool('calibrate');$('planStep').onclick=()=>setTool('select'); $('clearScale').onclick=()=>{if(!state.calibration)return;remember();state.calibration=null;commit();toast('Scale cleared. Template overlays are hidden until the image is calibrated.');}; for(const id of ['undoBtn','undoTop'])$(id).onclick=undo;$('redoBtn').onclick=redo;$('deleteBtn').onclick=deleteSelection; $('fitBtn').onclick=$('thumbnailBtn').onclick=fitImage;$('zoomIn').onclick=()=>zoomAt(1.25);$('zoomOut').onclick=()=>zoomAt(.8); $('compareBtn').onclick=()=>{compare=!compare;$('compareBtn').setAttribute('aria-pressed',String(compare));if(compare)clearDrawing();draw();}; $('snapshotBtn').onclick=()=>{try{if(image)annotatedCanvas().toBlob(blob=>{if(blob)download(blob,safeFilename()+'.png');else toast('Image export failed. Try a smaller source image.');});}catch(e){toast(e.message||'Image export failed. Try a smaller source image.');}}; for(const id of ['reportBtn','reportStep'])$(id).onclick=()=>{try{showReport();}catch(e){toast(e.message||'Report creation failed. Try a smaller source image.');}};$('printBtn').onclick=()=>window.print();$('helpBtn').onclick=()=>$('helpDialog').showModal(); document.querySelectorAll('.close-dialog').forEach(b=>b.onclick=()=>b.closest('dialog').close()); $('finishFragment').onclick=finishFragment;$('cancelDraw').onclick=clearDrawing; $('objectLabel').onchange=e=>editSelected(o=>o.label=e.target.value); let rangeSnapshot=false;$('rotation').addEventListener('pointerdown',()=>{if(selectedObject()){remember();rangeSnapshot=true;}});$('rotation').oninput=e=>{const o=selectedObject();if(!o?.transform)return;if(!rangeSnapshot){remember();rangeSnapshot=true;}o.transform.rotation=Number(e.target.value);$('rotationValue').textContent=e.target.value+'°';draw();};$('rotation').onchange=()=>{rangeSnapshot=false;commit();}; for(const [id,key] of [['translateX','x'],['translateY','y']])$(id).onchange=e=>{const n=Number(e.target.value);if(Number.isFinite(n)&&Math.abs(n*(scale()||1))<=1000000)editSelected(o=>o.transform[key]=n*(scale()||1));else refresh();}; $('flipBtn').onclick=()=>editSelected(o=>o.transform.flipX=!o.transform.flipX); for(const [id,key,min,max] of [['templateLength','lengthMm',5,500],['templateWidth','widthMm',1,50]])$(id).onchange=e=>{const n=Number(e.target.value);if(Number.isFinite(n)&&n>=min&&n<=max)editSelected(o=>o.params[key]=n);else{toast(`Enter a value from ${min} to ${max} mm.`);refresh();}}; let appearanceSnapshot=false;for(const id of ['brightness','contrast']){$(id).addEventListener('pointerdown',()=>{remember();appearanceSnapshot=true;});$(id).oninput=e=>{if(!appearanceSnapshot){remember();appearanceSnapshot=true;}state.adjustments[id]=Number(e.target.value);$(id+'Value').textContent=e.target.value+'%';draw();};$(id).onchange=()=>{appearanceSnapshot=false;refresh();};} $('invert').onchange=e=>{remember();state.adjustments.invert=e.target.checked;commit();};$('resetAppearance').onclick=()=>{remember();state.adjustments={brightness:100,contrast:100,invert:false};commit();}; for(const [id,key] of [['caseLabel','label'],['procedure','procedure'],['laterality','laterality'],['notes','notes']])$(id).onchange=e=>{remember();state.caseInfo[key]=e.target.value;commit();}; document.addEventListener('keydown',e=>{if(document.querySelector('dialog[open]'))return;if(['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName))return;const key=e.key.toLowerCase();if((e.ctrlKey||e.metaKey)&&key==='z'){e.preventDefault();e.shiftKey?redo():undo();return;}if((e.ctrlKey||e.metaKey)&&key==='y'){e.preventDefault();redo();return;}if(e.ctrlKey||e.metaKey||e.altKey)return;if(e.key==='Escape')clearDrawing();if(e.key==='Enter')finishFragment();if(e.key==='Delete'||e.key==='Backspace'){e.preventDefault();deleteSelection();}const mapping={v:'select',h:'pan',c:'calibrate',l:'length',a:'angle',f:'fragment'};if(mapping[key]){e.preventDefault();setTool(mapping[key]);}}); window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}}); new ResizeObserver(()=>resize(false)).observe($('canvasWrap'));resize();await loadDemo(false);