(() => { 'use strict'; const NS='http://www.w3.org/2000/svg',GRID=20,clone=o=>JSON.parse(JSON.stringify(o)),snap=v=>Math.min(100000,Math.max(0,Math.round(v/GRID)*GRID)); let active; function init(){ document.querySelectorAll('[data-preview]:not([data-bound])').forEach(button=>{button.dataset.bound='1';button.addEventListener('click',()=>{document.querySelectorAll('[data-preview]').forEach(b=>b.setAttribute('aria-pressed',String(b===button)));document.getElementById('mock-detail-image').src=button.dataset.preview;document.getElementById('mock-open-preview').href=button.dataset.preview;});}); const root=document.getElementById('mock-editor');if(!root||active?.root===root)return;active?.dispose(); const $=id=>root.querySelector('#mock-'+id),svg=$('svg'),canvas=$('canvas'),api='/api/mockups/graphs/'+encodeURIComponent(root.dataset.key); let graph,catalog=[],screens=new Map(),selected,dirty=false,busy=true,disposed=false,undo=[],redo=[],connecting=false,origin,drag,pan,wire,counter=0,scale=1,fitActive=true; const camera={x:0,y:0},measure=document.createElement('canvas').getContext('2d'),layoutCache=new Map(); const segmenter=typeof Intl.Segmenter==='function'?new Intl.Segmenter('th',{granularity:'word'}):null; function el(tag,attrs={},text){const node=document.createElementNS(NS,tag);for(const [key,value] of Object.entries(attrs))node.setAttribute(key,String(value));if(text!==undefined)node.textContent=text;return node;} const uid=()=> 'm-'+Date.now().toString(36)+'-'+(++counter)+'-'+Math.random().toString(36).slice(2,7); const status=(message,error=false)=>{$('status').textContent=message;$('status').dataset.error=String(error);}; function controls(){ for(const id of ['add','decision','process','connect','arrange','fit','reset','reload','focus','zoom','add-screen','export'])$(id).disabled=busy||!graph; $('save').disabled=busy||!dirty||!graph;$('undo').disabled=busy||!undo.length;$('redo').disabled=busy||!redo.length; $('properties').querySelectorAll('input,select,textarea,button').forEach(e=>e.disabled=busy||!graph); document.getElementById('mock-count').textContent=graph?graph.nodes.filter(visible).length+' / '+graph.nodes.length+' shapes · '+graph.edges.length+' connections':'Loading…'; } function mark(){dirty=true;root.dataset.dirty='1';status('มีการแก้ไข · ยังไม่ได้บันทึก');controls();} function mutate(fn){if(busy||!graph)return;undo.push(clone(graph));if(undo.length>70)undo.shift();redo=[];fn();mark();draw();properties();} function wrap(text,width,font=11){measure.font=font+'px "IBM Plex Sans Thai", sans-serif';const output=[];for(const para of String(text||'').split('\n')){let line='';const parts=segmenter?[...segmenter.segment(para)].map(x=>x.segment):Array.from(para);for(const part of parts){if(line&&measure.measureText(line+part).width>width){output.push(line);line='';}if(measure.measureText(part).width>width){for(const ch of Array.from(part)){if(line&&measure.measureText(line+ch).width>width){output.push(line);line='';}line+=ch;}}else line+=part;}if(line)output.push(line);}return output;} function layout(n){const key=JSON.stringify([n.type,n.title,n.description,n.screen_id,n.variant]);if(layoutCache.has(key))return layoutCache.get(key);const decision=n.type==='decision',title=wrap(n.title,decision?148:248,13),description=wrap(n.description,decision?148:248,11),textHeight=title.length*18+description.length*16+12;let h=n.type==='screen'?title.length*18+description.length*16+252:decision?Math.max(200,textHeight*2+60):Math.max(110,(n.type==='start'||n.type==='end'?textHeight*1.5+44:textHeight+48));h=Math.ceil(h/20)*20;const result={w:280,h,title,description};layoutCache.set(key,result);return result;} function arrange(){ // Collapse strongly connected components, then rank the resulting DAG. // Navigation and retry cycles remain valid and never hang auto-layout. const nodes=graph.nodes,ids=new Set(nodes.map(n=>n.id)),adj=new Map(nodes.map(n=>[n.id,[]]));for(const e of graph.edges)if(ids.has(e.source)&&ids.has(e.target))adj.get(e.source).push(e.target); let index=0;const indices=new Map(),low=new Map(),stack=[],on=new Set(),components=[],which=new Map(); function visit(id){indices.set(id,index);low.set(id,index++);stack.push(id);on.add(id);for(const to of adj.get(id)){if(!indices.has(to)){visit(to);low.set(id,Math.min(low.get(id),low.get(to)));}else if(on.has(to))low.set(id,Math.min(low.get(id),indices.get(to)));}if(low.get(id)===indices.get(id)){const comp=[];let value;do{value=stack.pop();on.delete(value);which.set(value,components.length);comp.push(value);}while(value!==id);components.push(comp);}} for(const n of nodes)if(!indices.has(n.id))visit(n.id); const edges=components.map(()=>new Set()),degree=components.map(()=>0),ranks=components.map(()=>0); for(const e of graph.edges){const a=which.get(e.source),b=which.get(e.target);if(a!==undefined&&b!==undefined&&a!==b&&!edges[a].has(b)){edges[a].add(b);degree[b]++;}} const queue=[];degree.forEach((v,i)=>{if(!v)queue.push(i);});for(let i=0;ia-b),horizontal=graph.direction==='LR'; const crossMax=Math.max(0,...keys.map(k=>horizontal?levels.get(k).reduce((sum,n)=>sum+layout(n).h+100,0):Math.min(6,levels.get(k).length)*380));let along=100; for(const k of keys){const row=levels.get(k); if(!horizontal){for(let offset=0;offsetsum+layout(n).h+100,0);let cross=100+(crossMax-rowSize)/2,max=0;for(const n of row){const l=layout(n);n.x=snap(along);n.y=snap(cross);cross+=l.h+100;max=Math.max(max,l.w);}along+=max+160; } } function route(a,b,index){const A=layout(a),B=layout(b),horizontal=graph.direction==='LR';let p,q,points; if(horizontal){p=[a.x+A.w,a.y+A.h/2];q=[b.x,b.y+B.h/2];const mid=(p[0]+q[0])/2;points=[p,[mid,p[1]],[mid,q[1]],q];} else{p=[a.x+A.w/2,a.y+A.h];q=[b.x+B.w/2,b.y];const mid=(p[1]+q[1])/2;points=[p,[p[0],mid],[q[0],mid],q];} const blocked=points.some((q,i)=>{if(!i)return false;const p=points[i-1];return graph.nodes.filter(visible).some(n=>{if(n.id===a.id||n.id===b.id)return false;const l=layout(n),left=n.x-8,right=n.x+l.w+8,top=n.y-8,bottom=n.y+l.h+8;return p[0]===q[0]?p[0]>left&&p[0]top&&Math.min(p[1],q[1])top&&p[1]left&&Math.min(p[0],q[0])n.y))-80-index*12;return [p,[p[0]+40,p[1]],[p[0]+40,lane],[q[0]-40,lane],[q[0]-40,q[1]],q];}const lane=Math.max(...graph.nodes.filter(visible).map(n=>n.x+layout(n).w))+80+index*12;return [p,[p[0],p[1]+40],[lane,p[1]+40],[lane,q[1]-40],[q[0],q[1]-40],q];}return points; } const path=points=>points.map((p,i)=>(i?'L':'M')+p.join(' ')).join(' '); function visible(n){const screen=screens.get(n.screen_id),module=$('scope-module')?.value,channel=$('scope-channel')?.value;return n.type!=='screen'||!!screen&&(!module||screen.modules.includes(module))&&(!channel||screen.channel===channel);} function draw(){if(!graph||disposed)return;svg.replaceChildren();const width=Math.max(1,canvas.clientWidth),height=Math.max(1,canvas.clientHeight);svg.setAttribute('width',width);svg.setAttribute('height',height);svg.setAttribute('viewBox',`${camera.x-width/scale/2} ${camera.y-height/scale/2} ${width/scale} ${height/scale}`); const defs=el('defs'),pattern=el('pattern',{id:'mock-grid',width:GRID,height:GRID,patternUnits:'userSpaceOnUse'});pattern.append(el('circle',{cx:0,cy:0,r:Math.min(2,1/scale),fill:'var(--line)'}));defs.append(pattern); for(const type of ['arrow','open','diamond']){const m=el('marker',{id:'mock-marker-'+type,viewBox:'0 0 12 12',refX:11,refY:6,markerWidth:11,markerHeight:11,orient:'auto-start-reverse',markerUnits:'userSpaceOnUse'});m.append(el('path',{d:type==='diamond'?'M1 6L6 1L11 6L6 11Z':'M1 1L11 6L1 11'+(type==='arrow'?'Z':''),fill:type==='open'?'none':'var(--muted)',stroke:'var(--muted)','stroke-width':1.5}));defs.append(m);}svg.append(defs,el('rect',{x:camera.x-width/scale/2,y:camera.y-height/scale/2,width:width/scale,height:height/scale,fill:'url(#mock-grid)'}));const content=el('g',{'class':'mock-graph'});svg.append(content);const byID=new Map(graph.nodes.filter(visible).map(n=>[n.id,n])); graph.edges.filter(e=>byID.has(e.source)&&byID.has(e.target)).forEach((e,index)=>{const a=byID.get(e.source),b=byID.get(e.target);if(!a||!b)return;const points=route(a,b,index),d=path(points),g=el('g',{'class':'mock-edge'+(selected?.kind==='edge'&&selected.id===e.id?' is-selected':''),'data-edge':e.id,tabindex:0,role:'button','aria-label':'Connection '+(e.label||e.source+' → '+e.target)});g.append(el('path',{d,'class':'mock-edge-hit'}),el('path',{d,'class':'mock-edge-line','marker-start':e.tail==='none'?'none':`url(#mock-marker-${e.tail})`,'marker-end':e.head==='none'?'none':`url(#mock-marker-${e.head})`}));if(e.label){let longest=-1,pos;for(let i=1;ilongest){longest=len;pos=[(p[0]+q[0])/2,(p[1]+q[1])/2-10];}}g.append(el('text',{x:pos[0],y:pos[1],'text-anchor':'middle'},e.label));}content.append(g);}); for(const n of graph.nodes.filter(visible)){const l=layout(n),g=el('g',{'class':'mock-node'+(selected?.kind==='node'&&selected.id===n.id?' is-selected':''),'data-node':n.id,'data-type':n.type,'data-screen':n.screen_id||'',transform:`translate(${n.x} ${n.y})`,tabindex:0,role:'button','aria-label':n.title});let shape; if(n.type==='decision')shape=el('polygon',{points:`${l.w/2},0 ${l.w},${l.h/2} ${l.w/2},${l.h} 0,${l.h/2}`});else if(n.type==='start'||n.type==='end')shape=el('ellipse',{cx:l.w/2,cy:l.h/2,rx:l.w/2,ry:l.h/2});else shape=el('rect',{width:l.w,height:l.h,rx:10});shape.setAttribute('class','mock-node-shape');g.append(shape); let titleY=26,descriptionY=titleY+l.title.length*18+12; if(n.type==='screen'){const screen=screens.get(n.screen_id),variant=screen?.variants.find(v=>v.id===n.variant);g.append(el('text',{x:16,y:22,'class':'mock-node-sub'},n.screen_id+' · '+(variant?.name||n.variant)));titleY=44;const imageY=titleY+l.title.length*18+8;if(variant)g.append(el('image',{x:12,y:imageY,width:256,height:171,preserveAspectRatio:'xMidYMid meet',href:'/mockup-assets/'+variant.preview,'class':'mock-node-image'}));descriptionY=imageY+192; }else{const total=l.title.length*18+l.description.length*16+12;titleY=(l.h-total)/2+14;descriptionY=titleY+l.title.length*18+12;} const center=n.type!=='screen';for(const [i,line] of l.title.entries())g.append(el('text',{x:center?l.w/2:16,y:titleY+i*18,'text-anchor':center?'middle':'start','class':'mock-node-title'},line));for(const [i,line] of l.description.entries())g.append(el('text',{x:center?l.w/2:16,y:descriptionY+i*16,'text-anchor':center?'middle':'start','class':'mock-node-body'},line)); if(n.type==='screen')g.append(el('text',{x:16,y:l.h-12,'class':'mock-node-sub'},(n.step_id?n.step_id+' · ':'')+(n.mapping==='manual'?'Mapped manually':'Proposed mapping'))); const px=graph.direction==='LR'?l.w:l.w/2,py=graph.direction==='LR'?l.h/2:l.h;g.append(el('circle',{cx:px,cy:py,r:7,'class':'mock-port','data-port':n.id}));g.append(el('title',{},n.title+'\n'+n.description));content.append(g); } } function fit(){if(!graph)return;draw();const b=svg.querySelector('.mock-graph').getBBox();camera.x=b.x+b.width/2;camera.y=b.y+b.height/2;scale=Math.min(1,Math.max(1,canvas.clientWidth-90)/Math.max(1,b.width),Math.max(1,canvas.clientHeight-90)/Math.max(1,b.height));fitActive=true;$('zoom').value='fit';$('zoom').options[0].text='Fit ('+Math.round(scale*100)+'%)';draw();} const picked=()=>selected&&(selected.kind==='node'?graph.nodes:graph.edges).find(n=>n.id===selected.id); function properties(){const item=picked();$('no-selection').hidden=!!item;$('node-fields').hidden=!item||selected.kind!=='node';$('edge-fields').hidden=!item||selected.kind!=='edge';$('delete').hidden=!item;if(!item)return; if(selected.kind==='node'){for(const key of ['type','title','description'])$(key).value=item[key];$('screen-fields').hidden=item.type!=='screen';$('screen').value=item.screen_id||catalog[0]?.id;variants(item);$('step-ref').textContent=(item.step_id?'Source: '+root.dataset.key+' / '+item.step_id+' · ':'')+(item.mapping==='manual'?'ผู้ใช้แก้ mapping แล้ว':'mapping ร่าง ยังไม่ใช่การยืนยัน requirement');} else{for(const key of ['source','target']){$(key).replaceChildren(...graph.nodes.map(n=>new Option(n.title||n.id,n.id)));$(key).value=item[key];}for(const key of ['label','head','tail'])$(key).value=item[key];} } function variants(item){const screen=screens.get(item.screen_id||$('screen').value);$('variant').replaceChildren(...(screen?.variants||[]).map(v=>new Option(v.name,v.id)));$('variant').value=item.variant||'default';$('screen-detail').href='/mockup/screens/'+encodeURIComponent(screen?.id||'');} function select(kind,id){if(busy)return;if(connecting&&kind==='node'){if(!origin){origin=id;status('เลือก shape ปลายทาง');}else{const from=origin;origin=null;connecting=false;$('connect').setAttribute('aria-pressed','false');mutate(()=>{graph.edges.push({id:uid(),source:from,target:id,label:'',head:'arrow',tail:'none'});});}selected={kind,id};}else selected={kind,id};draw();properties();} function point(e){const p=svg.createSVGPoint();p.x=e.clientX;p.y=e.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());} svg.addEventListener('pointerdown',e=>{if(!graph||busy||e.button!==0)return;const port=e.target.closest('[data-port]');if(port){const n=graph.nodes.find(n=>n.id===port.dataset.port),l=layout(n);wire={id:n.id,p:graph.direction==='LR'?[n.x+l.w,n.y+l.h/2]:[n.x+l.w/2,n.y+l.h]};svg.setPointerCapture(e.pointerId);e.preventDefault();return;} const node=e.target.closest('[data-node]'),edge=e.target.closest('[data-edge]');if(node){const was=connecting;select('node',node.dataset.node);if(!was){const n=picked();drag={id:n.id,p:point(e),x:n.x,y:n.y,before:clone(graph),moved:false};fitActive=false;svg.setPointerCapture(e.pointerId);}} else if(edge)select('edge',edge.dataset.edge);else{selected=null;properties();pan={x:e.clientX,y:e.clientY,cx:camera.x,cy:camera.y};fitActive=false;svg.setPointerCapture(e.pointerId);}e.preventDefault(); }); svg.addEventListener('pointermove',e=>{if(pan){camera.x=pan.cx-(e.clientX-pan.x)/scale;camera.y=pan.cy-(e.clientY-pan.y)/scale;draw();return;}if(wire){const q=point(e);svg.querySelector('.mock-wire')?.remove();svg.append(el('path',{d:path([wire.p,[wire.p[0],q.y],[q.x,q.y]]),fill:'none',stroke:'var(--accent)','stroke-width':2,'class':'mock-wire','pointer-events':'none'}));return;}if(drag){const p=point(e),n=graph.nodes.find(n=>n.id===drag.id);if(!drag.moved&&Math.hypot(p.x-drag.p.x,p.y-drag.p.y)<3)return;drag.moved=true;n.x=snap(drag.x+p.x-drag.p.x);n.y=snap(drag.y+p.y-drag.p.y);draw();}}); function stop(e){pan=null;if(wire){const from=wire.id,target=e.type==='pointerup'?document.elementFromPoint(e.clientX,e.clientY)?.closest('[data-node]')?.dataset.node:null;wire=null;svg.querySelector('.mock-wire')?.remove();if(target)mutate(()=>{graph.edges.push({id:uid(),source:from,target,label:'',head:'arrow',tail:'none'});});}if(drag?.moved){undo.push(drag.before);redo=[];mark();}drag=null;} svg.addEventListener('pointerup',stop);svg.addEventListener('pointercancel',stop); svg.addEventListener('click',e=>{if(e.detail===0){const n=e.target.closest('[data-node]'),ed=e.target.closest('[data-edge]');if(n)select('node',n.dataset.node);else if(ed)select('edge',ed.dataset.edge);}}); svg.addEventListener('keydown',e=>{const n=e.target.closest('[data-node]'),ed=e.target.closest('[data-edge]');if(e.key==='Enter'||e.key===' '){if(n)select('node',n.dataset.node);if(ed)select('edge',ed.dataset.edge);e.preventDefault();}if(e.key==='Delete'&&selected){$('delete').click();e.preventDefault();}}); svg.addEventListener('dblclick',e=>{const n=graph?.nodes.find(n=>n.id===e.target.closest('[data-node]')?.dataset.node);if(n?.screen_id)window.open('/mockup/screens/'+encodeURIComponent(n.screen_id),'_blank','noopener');}); canvas.addEventListener('wheel',e=>{if(!graph)return;e.preventDefault();const rect=canvas.getBoundingClientRect();if(e.ctrlKey){const p=point(e),next=Math.min(3,Math.max(0.005,scale*Math.exp(-e.deltaY*0.002)));camera.x=p.x-(e.clientX-rect.left-rect.width/2)/next;camera.y=p.y-(e.clientY-rect.top-rect.height/2)/next;scale=next;fitActive=false;let option=$('zoom').querySelector('[data-custom]');if(!option){option=new Option('','');option.dataset.custom='1';$('zoom').append(option);}option.value=String(scale);option.text=Math.round(scale*100)+'%';$('zoom').value=option.value;}else{camera.x+=e.deltaX/scale;camera.y+=e.deltaY/scale;fitActive=false;}draw();},{passive:false}); $('zoom').onchange=()=>{if($('zoom').value==='fit')fit();else{scale=Number($('zoom').value);fitActive=false;draw();}};$('fit').onclick=fit; $('arrange').onclick=()=>{mutate(arrange);fit();}; function add(type){mutate(()=>{const s=screens.get($('add-screen').value),n={id:uid(),type,title:type==='screen'?s.title:type==='decision'?'เงื่อนไข / Decision':'ขั้นตอนใหม่',description:'',x:snap(camera.x),y:snap(camera.y)};if(type==='screen'){n.screen_id=s.id;n.variant='default';n.mapping='manual';}graph.nodes.push(n);selected={kind:'node',id:n.id};});} $('add').onclick=()=>add('screen');$('decision').onclick=()=>add('decision');$('process').onclick=()=>add('process'); $('connect').onclick=()=>{connecting=!connecting;origin=null;$('connect').setAttribute('aria-pressed',String(connecting));status(connecting?'เลือก shape ต้นทาง แล้วเลือกปลายทาง':'ยกเลิกการเชื่อม');}; $('undo').onclick=()=>{if(!undo.length||busy)return;redo.push(clone(graph));const revision=graph.revision;graph=undo.pop();graph.revision=revision;selected=null;mark();draw();properties();}; $('redo').onclick=()=>{if(!redo.length||busy)return;undo.push(clone(graph));const revision=graph.revision;graph=redo.pop();graph.revision=revision;selected=null;mark();draw();properties();}; for(const field of ['title','description'])$(field).addEventListener('input',()=>{const item=picked();if(item&&selected.kind==='node')mutate(()=>{item[field]=$(field).value;});}); $('type').onchange=()=>{const item=picked();if(!item)return;const type=$('type').value;mutate(()=>{item.type=type;if(type==='screen'){item.screen_id=item.screen_id||catalog[0].id;item.variant=item.variant||'default';item.mapping='manual';}else{delete item.screen_id;delete item.variant;}});}; $('screen').onchange=()=>{const item=picked();if(!item)return;const value=$('screen').value;mutate(()=>{item.screen_id=value;item.variant='default';item.mapping='manual';});}; $('variant').onchange=()=>{const item=picked();if(!item)return;const value=$('variant').value;mutate(()=>{item.variant=value;item.mapping='manual';});}; for(const field of ['source','target','label','head','tail'])$(field).addEventListener(field==='label'?'input':'change',()=>{const item=picked();if(item&&selected.kind==='edge')mutate(()=>{item[field]=$(field).value;});}); $('delete').onclick=()=>{if(!selected)return;mutate(()=>{if(selected.kind==='node'){graph.nodes=graph.nodes.filter(n=>n.id!==selected.id);graph.edges=graph.edges.filter(e=>e.source!==selected.id&&e.target!==selected.id);}else graph.edges=graph.edges.filter(e=>e.id!==selected.id);selected=null;});}; for(const id of ['scope-module','scope-channel'])if($(id))$(id).onchange=()=>{selected=null;properties();fit();controls();}; $('focus').onclick=()=>{if($('scope-module'))$('scope-module').value='';if($('scope-channel'))$('scope-channel').value='';const q=$('find').value.trim().toLowerCase(),node=graph?.nodes.find(n=>q&&(n.title+' '+n.screen_id+' '+n.id).toLowerCase().includes(q));if(!node){status('ไม่พบ shape ตามคำค้น',true);return;}const l=layout(node);camera.x=node.x+l.w/2;camera.y=node.y+l.h/2;scale=0.9;fitActive=false;select('node',node.id);}; const beforeUnload=e=>{if(dirty||busy&&graph){e.preventDefault();e.returnValue='';}};window.addEventListener('beforeunload',beforeUnload); async function request(url,options){const res=await fetch(url,{cache:'no-store',...options});if(!res.ok)throw Error(await res.text());return res.json();} function sourceNotice(data){const note=document.getElementById('mock-source-note');note.dataset.stale=String(data.source_changed);note.textContent=data.source_changed?'ต้นฉบับหรือ catalog เปลี่ยนหลังบันทึก · diagram เดิมยังอยู่ครบ กรุณาตรวจเทียบก่อนเลือก Reset to source':'หน้าจอใช้ร่วมจาก Screen Listing · mapping และเส้นทางร่างยังต้องตรวจยืนยันตาม Use case';} async function load(){busy=true;controls();status('กำลังโหลด…');try{const data=await request(api);if(disposed)return;graph=data.graph;if(graph.revision===0)arrange();selected=null;undo=[];redo=[];dirty=false;delete root.dataset.dirty;delete $('properties').dataset.dirty;sourceNotice(data);fit();properties();status('Revision '+graph.revision+(graph.revision?' · Saved diagram':' · Source draft'));await history();}catch(e){status(e.message,true);}finally{busy=false;controls();}} $('save').onclick=async()=>{if(!dirty||busy)return;busy=true;controls();status('กำลังบันทึก…');try{const data=await request(api,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(graph)});if(disposed)return;graph=data.graph;dirty=false;delete root.dataset.dirty;delete $('properties').dataset.dirty;sourceNotice(data);status('บันทึกแล้ว · Revision '+graph.revision);await history();}catch(e){status(e.message,true);}finally{busy=false;controls();}}; $('reload').onclick=()=>{if(!dirty||confirm('ทิ้งการแก้ไขในหน้านี้ แล้วโหลด diagram ที่บันทึก?'))load();}; $('reset').onclick=async()=>{if(busy||!confirm('คืนร่างจากต้นฉบับ? ข้อมูลส่วนกลางจะเปลี่ยนเมื่อกด Save diagram'))return;busy=true;controls();try{const data=await request(api+'?draft=1');busy=false;const revision=graph.revision;mutate(()=>{graph=data.graph;graph.revision=revision;selected=null;arrange();});fit();sourceNotice(data);}catch(e){status(e.message,true);}finally{busy=false;controls();}}; $('export').onclick=()=>{const url=URL.createObjectURL(new Blob([JSON.stringify(graph,null,2)],{type:'application/json'})),a=document.createElement('a');a.href=url;a.download=root.dataset.key+'-mockup.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}; async function history(){try{const rows=await request(api+'/revisions');if(disposed)return;$('revision-count').textContent=rows.length;const target=$('history');target.replaceChildren();if(!rows.length){target.textContent='ยังไม่มี revision · Save diagram เพื่อเก็บเวอร์ชันแรก';return;}for(const row of rows.slice().reverse()){const item=document.createElement('div');item.className='mock-history-item';const title=document.createElement('strong');title.textContent='v'+row.revision+(row.revision===graph.revision?' · Current':'');const when=document.createElement('span');when.textContent=new Intl.DateTimeFormat('th-TH',{dateStyle:'medium',timeStyle:'short',timeZone:'Asia/Bangkok',calendar:'buddhist'}).format(new Date(row.saved_at));const button=document.createElement('button');button.textContent='Restore';button.dataset.restore=String(row.revision);button.disabled=row.revision===graph.revision;button.onclick=()=>restore(row.revision);item.append(title,when,button);target.append(item);}}catch(e){status('อ่านประวัติไม่สำเร็จ: '+e.message,true);}} async function restore(target){if(busy||!confirm('Restore revision '+target+' เป็น revision ใหม่?'+(dirty?' การแก้ไขที่ยังไม่บันทึกจะถูกแทนที่':'')))return;busy=true;controls();try{const data=await request(api+'/restore',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({revision:graph.revision,target_revision:target})});graph=data.graph;dirty=false;delete root.dataset.dirty;delete $('properties').dataset.dirty;undo=[];redo=[];selected=null;sourceNotice(data);fit();properties();status('Restored · Revision '+graph.revision);await history();}catch(e){status(e.message,true);}finally{busy=false;controls();}} $('refresh-history').onclick=history; const resize=new ResizeObserver(()=>{if(graph){if(fitActive)fit();else draw();}});resize.observe(canvas);active={root,dispose:()=>{disposed=true;resize.disconnect();window.removeEventListener('beforeunload',beforeUnload);}}; controls();request('/api/mockups/catalog').then(data=>{if(disposed)return;catalog=data.screens;screens=new Map(catalog.map(s=>[s.id,s]));for(const [id,values] of [['scope-module',[...new Set(catalog.flatMap(s=>s.modules))].sort()],['scope-channel',[...new Set(catalog.map(s=>s.channel))].sort()]])if($(id))$(id).replaceChildren(new Option(id==='scope-module'?'ทุก module':'ทุก channel',''),...values.map(value=>new Option(id==='scope-module'?(data.module_names[value]||'โมดูลที่ยังไม่ระบุชื่อ'):value,value)));for(const select of [$('add-screen'),$('screen')])select.replaceChildren(...catalog.map(s=>new Option(s.id+' · '+s.title,s.id)));return load();}).catch(e=>{busy=false;status(e.message,true);controls();}); document.fonts?.ready.then(()=>{layoutCache.clear();if(graph&&!dirty&&graph.revision===0)arrange();if(graph&&fitActive)fit();}); } document.addEventListener('DOMContentLoaded',init);document.addEventListener('tracker:updated',init); })();