tracker/static/usecases.js
(() => {
let active = null;
const NS = 'http://www.w3.org/2000/svg';
const GRID = 20;
const snap = value => Math.min(100000, Math.max(0, Math.round(value / GRID) * GRID));
function init() {
const root = document.getElementById('uc-editor');
if (!root || root === active?.root) return;
active?.dispose();
const $ = id => root.querySelector('#uc-' + id);
const svg = $('svg'), canvas = $('canvas');
const url = '/api/use-cases/' + encodeURIComponent(root.dataset.uid) + '/diagram';
let graph, baseline, selected, connecting = false, origin = null, dirty = false, saving = false, loading = false, disposed = false;
let history = [], future = [], drag = null, wire = null, scale = 1, counter = 0, pan = null, fitActive = true;
const camera = {x:0,y:0};
let approvalState=null,approvalLoading=false,sourceChanged=false,historyLoading=false;
const api='/api/use-cases/'+encodeURIComponent(root.dataset.uid);
const status = (s, error = false) => { $('status').textContent = s; $('status').dataset.error = String(error); };
const clone = o => JSON.parse(JSON.stringify(o));
const identity = () => 'u-' + Date.now().toString(36) + '-' + (++counter) + '-' + Math.random().toString(36).slice(2, 8);
function controls() {
$('current-revision').textContent=graph?'v'+graph.revision:'—';
$('save').disabled = !graph || !dirty || saving || loading;
$('undo').disabled = !history.length || saving || loading;
$('redo').disabled = !future.length || saving || loading;
for (const id of ['add','connect','reset','reload','layout-horizontal']) $(id).disabled = !graph || saving || loading;
$('properties').querySelectorAll('input,textarea,select,button').forEach(el => el.disabled = saving || loading);
$('approve').disabled=!graph||dirty||saving||loading||approvalLoading||sourceChanged||!!(approvalState?.approved&&approvalState.revision===graph.revision);
$('approval-form').querySelectorAll('input,textarea').forEach(el=>el.disabled=saving||loading);
$('refresh-history').disabled=historyLoading||saving||loading;
root.querySelectorAll('[data-uc-restore]').forEach(el=>el.disabled=saving||loading||!graph||Number(el.dataset.ucRestore)===graph.revision);
}
function mark() {
dirty = true; root.dataset.dirty = '1';renderApproval(); controls(); status('ยังไม่ได้บันทึก');
}
function snapshot() { history.push(clone(graph)); if (history.length > 80) history.shift(); future = []; }
function mutate(fn) { if (!graph || saving || loading) return; snapshot(); fn(); mark(); draw(); properties(); }
function element(tag, attrs = {}, text) {
const e = document.createElementNS(NS, tag);
for (const [k,v] of Object.entries(attrs)) e.setAttribute(k, String(v));
if (text != null) e.textContent = text;
return e;
}
const measure=document.createElement('canvas').getContext('2d'),layouts=new Map();
const fontFamily=getComputedStyle(canvas).fontFamily;
const words=typeof Intl.Segmenter==='function'?new Intl.Segmenter('th',{granularity:'word'}):null;
const graphemes=typeof Intl.Segmenter==='function'?new Intl.Segmenter('th',{granularity:'grapheme'}):null;
function wrapText(text,width,size){
if(!text)return [];
width=Math.max(1,width-10);
measure.font=size+'px '+fontFamily;
const lines=[];let line='';
const tokens=words?[...words.segment(text)].map(x=>x.segment):Array.from(text);
for(const token of tokens){
for(const part of token.split(/(\r\n|\r|\n)/)){
if(!part)continue;
if(/^[\r\n]+$/.test(part)){lines.push(line+part);line='';continue;}
if(line&&measure.measureText(line+part).width>width){lines.push(line);line='';}
const units=measure.measureText(part).width>width?(graphemes?[...graphemes.segment(part)].map(x=>x.segment):Array.from(part)):[part];
for(const unit of units){if(line&&measure.measureText(line+unit).width>width){lines.push(line);line='';}line+=unit;}
}
}
if(line)lines.push(line);return lines;
}
function layoutFor(n){
const key=JSON.stringify([n.type,n.title,n.description]);if(layouts.has(key))return layouts.get(key);
const width=n.type==='decision'?140:n.type==='start'||n.type==='end'?180:218;
const title=wrapText(n.title,width,13),description=wrapText(n.description,width,11);
const textHeight=title.length*18+description.length*16+(title.length&&description.length?8:0);
const height=n.type==='decision'?Math.max(130,textHeight*3+40):n.type==='start'||n.type==='end'?Math.max(100,textHeight*1.5+40):Math.max(100,textHeight+32);
const layout={title,description,textHeight,height,width:250};if(layouts.size>1500)layouts.clear();layouts.set(key,layout);return layout;
}
const nodeSize = n => {const l=layoutFor(n);return [l.width,l.height];};
function arrangeDraft(d){
const height=Math.max(100,...d.nodes.map(n=>nodeSize(n)[1])),branched=d.nodes.some(n=>/^decision-\d+$/.test(n.id));
const row=height+120,baseline=branched?row*3:60;let x=100;
for(const n of d.nodes.filter(n=>!branched||!(/^(outcome-|stopped-)/.test(n.id)))){
const [w,h]=nodeSize(n);n.x=x;
const gated=branched&&/^step-/.test(n.id)&&d.nodes.some(g=>g.id==='decision-'+n.id.slice(5));
n.y=Math.ceil((baseline+(gated?row:0)+(height-h)/2)/GRID)*GRID;x=Math.ceil((x+w+100)/GRID)*GRID;
}
if(branched){const lanes=new Map();for(const n of d.nodes.filter(n=>/^(outcome-|stopped-)/.test(n.id))){
const incoming=d.edges.find(e=>e.target===n.id),parent=d.nodes.find(p=>p.id===incoming?.source),column=parent?.x??100;
const lane=lanes.get(column)||0;lanes.set(column,lane+1);n.x=column+360;n.y=Math.ceil((60+lane*row+(height-nodeSize(n)[1])/2)/GRID)*GRID;
}}return d;
}
function basicRoute(a, b, reverse) {
const [aw,ah]=nodeSize(a),[bw,bh]=nodeSize(b);
const ac={top:[a.x+aw/2,a.y],bottom:[a.x+aw/2,a.y+ah],left:[a.x,a.y+ah/2],right:[a.x+aw,a.y+ah/2]};
const bc={top:[b.x+bw/2,b.y],bottom:[b.x+bw/2,b.y+bh],left:[b.x,b.y+bh/2],right:[b.x+bw,b.y+bh/2]};
if(a.id===b.id){const p=ac.right,q=ac.top;return [p,[p[0]+60,p[1]],[p[0]+60,q[1]-40],[q[0],q[1]-40],q];}
if(reverse){
const right=a.id<b.id,p=right?ac.right:ac.left,q=right?bc.right:bc.left;
const lane=right?Math.max(a.x+aw,b.x+bw)+60:Math.min(a.x,b.x)-60;
return [p,[lane,p[1]],[lane,q[1]],q];
}
if(b.y>=a.y+ah+40){const p=ac.bottom,q=bc.top,mid=Math.round((p[1]+q[1])/2/GRID)*GRID;return [p,[p[0],mid],[q[0],mid],q];}
if(a.y>=b.y+bh+40){const p=ac.top,q=bc.bottom,mid=Math.round((p[1]+q[1])/2/GRID)*GRID;return [p,[p[0],mid],[q[0],mid],q];}
if(b.x>=a.x+aw+40){const p=ac.right,q=bc.left,mid=Math.round((p[0]+q[0])/2/GRID)*GRID;return [p,[mid,p[1]],[mid,q[1]],q];}
if(a.x>=b.x+bw+40){const p=ac.left,q=bc.right,mid=Math.round((p[0]+q[0])/2/GRID)*GRID;return [p,[mid,p[1]],[mid,q[1]],q];}
const p=ac.right,q=bc.right,lane=Math.max(a.x+aw,b.x+bw)+60;
return [p,[lane,p[1]],[lane,q[1]],q];
}
function orthogonalRoute(a,b,reverse){
const route=basicRoute(a,b,reverse);
const crosses=route.some((q,i)=>{if(!i)return false;const p=route[i-1];return graph.nodes.some(n=>{
if(n.id===a.id||n.id===b.id)return false;const [w,h]=nodeSize(n),l=n.x-10,r=n.x+w+10,t=n.y-10,bt=n.y+h+10;
return p[0]===q[0]?p[0]>l&&p[0]<r&&Math.max(p[1],q[1])>t&&Math.min(p[1],q[1])<bt:p[1]>t&&p[1]<bt&&Math.max(p[0],q[0])>l&&Math.min(p[0],q[0])<r;
});});
if(!crosses)return route;
const [aw,ah]=nodeSize(a),[,bh]=nodeSize(b),p=[a.x+aw,a.y+ah/2],q=[b.x,b.y+bh/2];
const lane=Math.min(...graph.nodes.map(n=>n.y))-80-graph.edges.findIndex(e=>e.source===a.id&&e.target===b.id)*24;
return [p,[p[0]+40,p[1]],[p[0]+40,lane],[q[0]-40,lane],[q[0]-40,q[1]],q];
}
const pathFor = points => points.map((p,i)=>(i?'L ':'M ')+p[0]+' '+p[1]).join(' ');
function draw() {
if (!graph) 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=element('defs');
const pattern=element('pattern',{id:'uc-grid',width:GRID,height:GRID,patternUnits:'userSpaceOnUse'});
pattern.append(element('circle',{cx:0,cy:0,r:Math.min(3,1/scale),fill:'var(--line)'}));defs.append(pattern);
for (const type of ['arrow','open','diamond']) {
const m=element('marker',{id:'uc-marker-'+type,viewBox:'0 0 12 12',refX:11,refY:6,markerWidth:10,markerHeight:10,orient:'auto-start-reverse',markerUnits:'userSpaceOnUse'});
m.append(element('path',{d:type==='diamond'?'M 1 6 L 6 1 L 11 6 L 6 11 Z':'M 1 1 L 11 6 L 1 11'+(type==='arrow'?' Z':''),fill:type==='open'?'none':'var(--muted)',stroke:'var(--muted)','stroke-width':1.5}));defs.append(m);
}
svg.append(defs);
svg.append(element('rect',{x:camera.x-width/scale/2,y:camera.y-height/scale/2,width:width/scale,height:height/scale,fill:'url(#uc-grid)','class':'uc-grid'}));
const content=element('g',{'class':'uc-graph'});svg.append(content);
for (const e of graph.edges) {
const a=graph.nodes.find(n=>n.id===e.source),b=graph.nodes.find(n=>n.id===e.target);if(!a||!b)continue;
const reverse=graph.edges.some(o=>o.source===e.target&&o.target===e.source);
const route=orthogonalRoute(a,b,reverse),path=pathFor(route);
let labelStart=route[0],labelEnd=route[1],length=-1;
for(let i=1;i<route.length;i++){const p=route[i-1],q=route[i],len=Math.abs(q[0]-p[0])+Math.abs(q[1]-p[1]);if(len>length){length=len;labelStart=p;labelEnd=q;}}
const lx=(labelStart[0]+labelEnd[0])/2,ly=(labelStart[1]+labelEnd[1])/2-9;
const group=element('g',{'class':'uc-edge'+(selected?.id===e.id&&selected.kind==='edge'?' is-selected':''),'data-edge':e.id,tabindex:0,role:'button','aria-label':'เส้นเชื่อม '+(e.label||e.source+' → '+e.target)});
group.append(element('path',{'class':'uc-edge-hit',d:path}));
group.append(element('path',{'class':'uc-edge-line',d:path,'marker-end':e.head==='none'?'none':`url(#uc-marker-${e.head})`,'marker-start':e.tail==='none'?'none':`url(#uc-marker-${e.tail})`}));
if(e.label)group.append(element('text',{x:lx,y:ly,'text-anchor':'middle'},e.label));
content.append(group);
}
for(const n of graph.nodes){
const [w,h]=nodeSize(n),g=element('g',{'class':'uc-node'+(selected?.id===n.id&&selected.kind==='node'?' is-selected':''),'data-node':n.id,'data-type':n.type,transform:`translate(${n.x} ${n.y})`,tabindex:0,role:'button','aria-label':n.type+': '+n.title});
let shape;
if(n.type==='decision')shape=element('polygon',{points:`${w/2},0 ${w},${h/2} ${w/2},${h} 0,${h/2}`});
else if(n.type==='start'||n.type==='end')shape=element('ellipse',{cx:w/2,cy:h/2,rx:w/2,ry:h/2});
else shape=element('rect',{width:w,height:h,rx:10});
shape.setAttribute('class','uc-shape');g.append(shape);
const layout=layoutFor(n),top=(h-layout.textHeight)/2;
const title=element('text',{'text-anchor':'middle','class':'uc-node-title','xml:space':'preserve'});
layout.title.forEach((line,i)=>title.append(element('tspan',{x:w/2,y:top+13+i*18},line)));g.append(title);
const description=element('text',{'text-anchor':'middle','class':'uc-node-desc','xml:space':'preserve'});
const descriptionTop=top+layout.title.length*18+(layout.title.length&&layout.description.length?8:0);
layout.description.forEach((line,i)=>description.append(element('tspan',{x:w/2,y:descriptionTop+11+i*16},line)));g.append(description);
g.append(element('title',{},n.title+'\n'+n.description));
g.append(element('circle',{cx:w,cy:h/2,r:7,'class':'uc-port','data-port':n.id,'aria-label':'ลากเพื่อเชื่อม shape'}));content.append(g);
}
}
function fitGraph() {
if(!graph)return;
draw();
const box=svg.querySelector('.uc-graph').getBBox(),padding=50;
camera.x=box.x+box.width/2;camera.y=box.y+box.height/2;
scale=Math.min(1,Math.max(1,canvas.clientWidth-padding*2)/Math.max(1,box.width),Math.max(1,canvas.clientHeight-padding*2)/Math.max(1,box.height));
fitActive=true;$('zoom').value='fit';$('zoom').querySelector('[value="fit"]').textContent='Fit to view ('+Math.round(scale*100)+'%)';draw();
}
function picked() { return selected && (selected.kind==='node'?graph.nodes:graph.edges).find(x=>x.id===selected.id); }
function properties() {
if(!graph)return;
const item=picked();
$('empty').hidden=!!item;$('delete').hidden=!item;
$('node-fields').hidden=!item||selected.kind!=='node';$('edge-fields').hidden=!item||selected.kind!=='edge';
if(!item)return;
if(selected.kind==='node') { for(const field of ['title','description','type'])$(field).value=item[field]; }
else {
for(const field of ['source','target']) { $(field).replaceChildren(...graph.nodes.map(n=>new Option(n.title||n.id,n.id)));$(field).value=item[field]; }
for(const field of ['label','head','tail'])$(field).value=item[field];
}
controls();
}
function choose(kind,id) {
if(saving||loading)return;
if(connecting&&kind==='node'){
if(!origin){origin=id;selected={kind,id};status('เลือก shape ปลายทาง');draw();properties();return;}
const source=origin;origin=null;connecting=false;$('connect').setAttribute('aria-pressed','false');
mutate(()=>{const edge={id:identity(),source,target:id,label:'',head:'arrow',tail:'none'};graph.edges.push(edge);selected={kind:'edge',id:edge.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||saving||loading||e.button!==0)return;
const port=e.target.closest('[data-port]');
if(port){const source=graph.nodes.find(n=>n.id===port.dataset.port),[w,h]=nodeSize(source);wire={source:source.id,p:{x:source.x+w,y:source.y+h/2}};svg.setPointerCapture(e.pointerId);status('ลากไปปล่อยที่ shape ปลายทาง');e.preventDefault();return;}
const n=e.target.closest('[data-node]'),edge=e.target.closest('[data-edge]');
if(!n){if(edge)choose('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);draw();e.preventDefault();}return;}
const id=n.dataset.node,wasConnecting=connecting;choose('node',id);if(wasConnecting)return;
const item=graph.nodes.find(n=>n.id===id),p=point(e);
fitActive=false;drag={id,p,x:item.x,y:item.y,moved:false,before:clone(graph)};
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 p=point(e);svg.querySelector('.uc-wire-preview')?.remove();const mid=Math.round((wire.p.x+p.x)/2/GRID)*GRID;svg.append(element('path',{d:pathFor([[wire.p.x,wire.p.y],[mid,wire.p.y],[mid,p.y],[p.x,p.y]]),'class':'uc-edge-line uc-wire-preview','pointer-events':'none'}));return;}
if(!drag)return;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();
});
const stopDrag=e=>{pan=null;if(wire){const source=wire.source,target=e.type==='pointerup'?document.elementFromPoint(e.clientX,e.clientY)?.closest('[data-node]')?.dataset.node:null;wire=null;svg.querySelector('.uc-wire-preview')?.remove();if(target)mutate(()=>{const edge={id:identity(),source,target,label:'',head:'arrow',tail:'none'};graph.edges.push(edge);selected={kind:'edge',id:edge.id};});else status(dirty?'ยังไม่ได้บันทึก':'พร้อมแก้ไข');}
if(drag?.moved){history.push(drag.before);if(history.length>80)history.shift();future=[];mark();}drag=null;};
svg.addEventListener('pointerup',stopDrag);svg.addEventListener('pointercancel',stopDrag);
svg.addEventListener('keydown',e=>{
const n=e.target.closest('[data-node]'),edge=e.target.closest('[data-edge]');
if((e.key==='Enter'||e.key===' ')&&(n||edge)){e.preventDefault();choose(n?'node':'edge',n?n.dataset.node:edge.dataset.edge);}
if(n&&['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){
e.preventDefault();mutate(()=>{const item=graph.nodes.find(x=>x.id===n.dataset.node);item.x=snap(item.x+(e.key==='ArrowRight'?GRID:e.key==='ArrowLeft'?-GRID:0));item.y=snap(item.y+(e.key==='ArrowDown'?GRID:e.key==='ArrowUp'?-GRID:0));});svg.querySelector(`[data-node="${CSS.escape(n.dataset.node)}"]`)?.focus();
}
});
for(const key of ['title','description','type','label','head','tail','source','target']) {
$(key).addEventListener('input',()=>{
const item=picked();if(!item||saving||loading)return;
snapshot();item[key]=$(key).value;mark();draw();
});
}
$('add').onclick=()=>mutate(()=>{
const n={id:identity(),type:$('add-type').value,title:'ขั้นตอนใหม่',description:'',x:snap(camera.x-125+(graph.nodes.length%3)*40),y:snap(camera.y-50+(graph.nodes.length%3)*40)};
graph.nodes.push(n);selected={kind:'node',id:n.id};
});
$('connect').onclick=()=>{if(!graph)return;connecting=!connecting;origin=null;$('connect').setAttribute('aria-pressed',String(connecting));status(connecting?'เลือก shape ต้นทาง แล้วเลือกปลายทาง':dirty?'ยังไม่ได้บันทึก':'พร้อมแก้ไข');};
$('delete').onclick=()=>mutate(()=>{
if(!selected)return;
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;
});
$('undo').onclick=()=>{if(!history.length||saving||loading)return;future.push(clone(graph));graph=history.pop();selected=null;mark();draw();properties();};
$('redo').onclick=()=>{if(!future.length||saving||loading)return;history.push(clone(graph));graph=future.pop();selected=null;mark();draw();properties();};
$('zoom').onchange=()=>{if($('zoom').value==='fit'){fitGraph();return;}fitActive=false;scale=Number($('zoom').value);draw();};
$('fit').onclick=fitGraph;
$('layout-horizontal').onclick=()=>{mutate(()=>arrangeDraft(graph));fitGraph();};
canvas.addEventListener('wheel',e=>{
if(!graph)return;e.preventDefault();if(drag||wire||pan)return;fitActive=false;
if(e.ctrlKey){
if(!e.deltaY)return;
const anchor=point(e),rect=svg.getBoundingClientRect();
const delta=e.deltaY*(e.deltaMode===1?16:e.deltaMode===2?canvas.clientHeight:1);
scale=Math.min(3,Math.max(0.01,scale*Math.exp(-Math.max(-240,Math.min(240,delta))*0.002)));
camera.x=anchor.x-(e.clientX-rect.left-rect.width/2)/scale;
camera.y=anchor.y-(e.clientY-rect.top-rect.height/2)/scale;
$('zoom').querySelector('[data-custom-zoom]')?.remove();
const option=new Option(Math.round(scale*100)+'%',String(scale),true,true);option.dataset.customZoom='1';$('zoom').append(option);
}else{camera.x+=e.deltaX/scale;camera.y+=e.deltaY/scale;}
draw();
},{passive:false});
const resize=new ResizeObserver(()=>{if(!graph||drag||wire||pan)return;if(fitActive)fitGraph();else draw();});resize.observe(canvas);
$('save').onclick=async()=>{
if(!graph||saving||loading||drag||wire)return;
saving=true;controls();status('กำลังบันทึก…');
try{
const res=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(graph)});
if(!res.ok)throw new Error(await res.text());
graph=await res.json();history=[];future=[];dirty=false;delete root.dataset.dirty;delete $('properties').dataset.dirty;
status('บันทึกแล้ว · เวอร์ชัน '+graph.revision);
}catch(err){status(err.message||'บันทึกไม่สำเร็จ งานยังอยู่ในหน้านี้',true);}
finally{saving=false;controls();loadApproval();loadHistory();}
};
function reviewElement(tag,className,text){const el=document.createElement(tag);if(className)el.className=className;if(text!=null)el.textContent=text;return el;}
function reviewDate(value){const date=new Date(value);if(!value||Number.isNaN(date.getTime()))return {date:'Existing save',time:''};return {date:date.toLocaleDateString('th-TH',{day:'numeric',month:'short',year:'numeric',timeZone:'Asia/Bangkok'}),time:date.toLocaleTimeString('th-TH',{hour:'2-digit',minute:'2-digit',timeZone:'Asia/Bangkok'})};}
function renderApproval(){
const badge=$('approval-badge');if(!approvalState){badge.textContent='Not approved';badge.dataset.approvalState='pending';return;}
const current=graph&&approvalState.revision===graph.revision;
const state=sourceChanged?'stale':dirty?'unsaved':!current?'stale':approvalState.state;
badge.dataset.approvalState=state;badge.closest('.uc-approval').dataset.state=state;
badge.textContent=state==='approved'?'Approved':state==='unsaved'?'Unsaved changes':state==='stale'?'Needs reapproval':state==='unavailable'?'Unavailable':'Awaiting review';
const latest=approvalState.latest;
$('approval-status').textContent=sourceChanged?'เนื้อหาต้นฉบับเปลี่ยนแล้ว กรุณาโหลดหน้านี้ใหม่ก่อนตรวจยืนยัน':dirty?'มีการแก้ไขที่ยังไม่บันทึก กรุณากด Save diagram ก่อนยืนยัน':state==='approved'?'ยืนยันแล้ว · Agent สามารถใช้ Use case และ diagram เวอร์ชันนี้พัฒนาระบบได้':state==='stale'?'ข้อมูลเปลี่ยนจากเวอร์ชันที่เคยยืนยัน กรุณาตรวจสอบและยืนยันใหม่':state==='unavailable'?'อ่านข้อมูลการยืนยันไม่สำเร็จ กรุณาลองใหม่':'ตรวจขั้นตอนและ diagram แล้วกรอกชื่อผู้ตรวจเพื่อยืนยันให้ใช้พัฒนาระบบ';
$('approval-meta').hidden=!latest;$('approved-note-box').hidden=!latest?.note;
if(latest){const when=reviewDate(latest.at);$('approved-by').textContent=latest.reviewer;$('approved-at').textContent=when.date+' · '+when.time;$('approved-revision').textContent='v'+latest.revision;$('approved-note').textContent=latest.note||'';}
$('approval-form').hidden=state==='approved';
const list=$('approval-history');list.replaceChildren();const entries=approvalState.history||[];
$('approval-history-count').textContent=entries.length;$('approval-history-empty').hidden=entries.length>0;
for(const entry of [...entries].reverse()){
const li=reviewElement('li'),head=reviewElement('div','uc-audit-entry'),when=reviewDate(entry.at);
head.append(reviewElement('strong','',entry.reviewer),reviewElement('span','uc-current-tag','v'+entry.revision));
const time=reviewElement('time','',when.date+' · '+when.time);time.dateTime=entry.at;head.append(time);li.append(head);
if(entry.note)li.append(reviewElement('p','',entry.note));list.append(li);
}
}
async function loadApproval(){
if(approvalLoading||disposed)return;approvalLoading=true;controls();
try{const res=await fetch(api+'/approval',{cache:'no-store'});if(!res.ok)throw new Error(await res.text());const data=await res.json();if(disposed)return;approvalState=data;sourceChanged=data.source_hash!==root.dataset.sourceHash;renderApproval();}
catch(err){if(!disposed){approvalState={state:'unavailable',history:[],revision:graph?.revision};renderApproval();$('approval-status').textContent=err.message;}}
finally{approvalLoading=false;controls();}
}
async function loadHistory(){
if(historyLoading||disposed)return;historyLoading=true;$('history').setAttribute('aria-busy','true');controls();$('history-status').dataset.error='false';$('history-status').textContent='กำลังโหลดประวัติ…';
try{const res=await fetch(api+'/revisions',{cache:'no-store'});if(!res.ok)throw new Error(await res.text());const revisions=await res.json();if(disposed)return;
const rows=$('history-rows');rows.replaceChildren();$('history-count').textContent=revisions.length;
$('history-empty').hidden=revisions.length>0;$('history-table').hidden=!revisions.length;
for(const rev of [...revisions].reverse()){
const current=rev.revision===graph?.revision,tr=reviewElement('tr',current?'is-current':'');tr.dataset.revision=rev.revision;
const revision=reviewElement('td');revision.append(reviewElement('div','uc-revision-code','v'+rev.revision));if(current)revision.append(reviewElement('span','uc-current-tag','Current'));tr.append(revision);
const saved=reviewElement('td'),when=reviewDate(rev.saved_at),date=reviewElement('time','uc-history-date',when.date);if(rev.saved_at)date.dateTime=rev.saved_at;saved.append(date);if(when.time)saved.append(reviewElement('span','uc-history-time',when.time));tr.append(saved);
const details=reviewElement('td');details.append(reviewElement('span','uc-history-action',rev.action==='restore'?'Restored from v'+rev.restored_from:rev.action==='existing'?'Previous save':'Saved changes'),reviewElement('span','uc-history-content',rev.node_count+' shapes · '+rev.edge_count+' connections'));tr.append(details);
const actions=reviewElement('td'),buttons=reviewElement('div','uc-history-actions'),view=reviewElement('a','','JSON ↗');view.href=api+'/revisions/'+rev.revision;view.target='_blank';view.rel='noopener';view.setAttribute('aria-label','View JSON for revision '+rev.revision);buttons.append(view);
const restore=reviewElement('button','','Restore');restore.type='button';restore.dataset.ucRestore=rev.revision;restore.setAttribute('aria-label','Restore revision '+rev.revision);buttons.append(restore);actions.append(buttons);tr.append(actions);rows.append(tr);
}
$('history-status').textContent=revisions.length?'อัปเดตประวัติล่าสุดแล้ว · '+revisions.length+' revisions':'เริ่มต้นด้วย Save diagram เพื่อสร้าง revision แรก';
}catch(err){if(!disposed){$('history-status').dataset.error='true';$('history-status').textContent='โหลดประวัติไม่สำเร็จ กรุณากด Refresh อีกครั้ง';}}
finally{historyLoading=false;$('history').setAttribute('aria-busy','false');controls();}
}
$('refresh-history').onclick=loadHistory;
$('history').addEventListener('toggle',()=>{if($('history').open)loadHistory();});
$('history-rows').addEventListener('click',async e=>{
const button=e.target.closest('[data-uc-restore]');if(!button||saving||loading||!graph)return;
const target=Number(button.dataset.ucRestore);
if(!confirm('Restore revision '+target+' as a new revision?'+(dirty?' Unsaved changes on this page will be replaced.':'')))return;
saving=true;controls();status('Restoring revision…');
try{const res=await fetch(api+'/restore',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({revision:graph.revision,target_revision:target})});if(!res.ok)throw new Error(await res.text());graph=await res.json();selected=null;history=[];future=[];dirty=false;delete root.dataset.dirty;delete $('properties').dataset.dirty;fitGraph();properties();status('Restored revision '+target+' · new revision '+graph.revision);}
catch(err){status(err.message,true);}
finally{saving=false;controls();loadApproval();loadHistory();}
});
$('approval-form').addEventListener('submit',async e=>{
e.preventDefault();if(!graph||saving||loading||dirty||sourceChanged||!$('approval-form').reportValidity())return;
saving=true;controls();status('Recording approval…');
try{
// Persist the exact initial layout before approving it, including its positions.
if(graph.revision===0){const saved=await fetch(url,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(graph)});if(!saved.ok)throw new Error(await saved.text());graph=await saved.json();history=[];future=[];}
const response=await fetch(api+'/approval',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({revision:graph.revision,source_hash:root.dataset.sourceHash,reviewer:$('reviewer').value,note:$('approval-note').value,confirmed:$('approval-confirm').checked})});
if(!response.ok)throw new Error(await response.text());approvalState=await response.json();renderApproval();$('approval-confirm').checked=false;delete $('approval-form').dataset.dirty;status('Approved for development · revision '+graph.revision);
}catch(err){status(err.message,true);}
finally{saving=false;controls();loadApproval();loadHistory();}
});
$('export').onclick=()=>{
if(!graph)return;const blob=new Blob([JSON.stringify(graph,null,2)],{type:'application/json'}),href=URL.createObjectURL(blob),a=document.createElement('a');a.href=href;a.download=root.dataset.uid+'-diagram.json';a.click();setTimeout(()=>URL.revokeObjectURL(href),1000);
};
async function load(){
if(loading||saving)return;
loading=true;root.dataset.busy='1';controls();status('กำลังโหลด…');
try{const res=await fetch(url,{cache:'no-store'});if(!res.ok)throw new Error(await res.text());const data=await res.json();if(disposed)return;graph=data.revision===0?arrangeDraft(data):data;baseline=makeDraft();history=[];future=[];selected=null;dirty=false;delete root.dataset.dirty;delete $('properties').dataset.dirty;fitGraph();properties();status('เวอร์ชัน '+graph.revision+(graph.revision===0?' · ร่างจากขั้นตอนต้นฉบับ':''));}
catch(err){if(!disposed)status(err.message||'โหลดไม่สำเร็จ ลองโหลดใหม่',true);}
finally{loading=false;delete root.dataset.busy;controls();$('reload').disabled=false;loadApproval();if($('history').open)loadHistory();}
}
function makeDraft(){
return arrangeDraft(JSON.parse(document.getElementById('uc-source-diagram').textContent));
}
$('reload').onclick=()=>{if(!dirty||confirm('ทิ้งการแก้ไขในหน้านี้และโหลดเวอร์ชันล่าสุด? หากต้องการเก็บงาน ให้ส่งออก JSON ก่อน'))load();};
$('reset').onclick=()=>{if(graph&&confirm('คืน diagram เป็นร่างจากขั้นตอนต้นฉบับ? จะมีผลส่วนกลางเมื่อกดบันทึก'))mutate(()=>{graph={...clone(baseline),revision:graph.revision};selected=null;});};
const before=e=>{if(dirty||saving){e.preventDefault();e.returnValue='';}};
window.addEventListener('beforeunload',before);
active={root,dispose:()=>{disposed=true;resize.disconnect();window.removeEventListener('beforeunload',before);}};
document.fonts?.ready.then(()=>{if(disposed||!graph)return;layouts.clear();if(graph.revision===0&&!dirty)arrangeDraft(graph);if(fitActive)fitGraph();else draw();});
controls();load();
}
init();document.addEventListener('tracker:updated',init);
})();