| |
| let allData=[], filteredData=[], currentIndex=0; |
| let assignmentId=null, reviewsMap={}, currentReviewStatus=null; |
| let editMode={emotion:false, distortions:false, empathy:false}; |
| let editData={emotion:null, distortions:null, empathy:null}; |
| let selectingEvidence=null; |
|
|
| const DIST_COLORS=['#fb923c','#f87171','#fbbf24','#a78bfa','#34d399']; |
| const EMP_COLORS=['#60a5fa','#a78bfa','#34d399','#fbbf24','#fb923c','#f87171','#818cf8','#2dd4bf','#e879f9']; |
| const INTENSITY_OPTS=['Low','Medium','High']; |
| const DISTORTION_OPTS=[ |
| 'All-or-Nothing Thinking', 'Overgeneralization', 'Mental Filter', |
| 'Disqualifying the Positive', 'Mind Reading', 'Fortune Telling', |
| 'Catastrophizing', 'Minimization', 'Emotional Reasoning', |
| 'Should Statements', 'Labeling', 'Personalization' |
| ]; |
|
|
| |
| function safeJSON(s){try{return JSON.parse(s);}catch(e){return null;}} |
| function escA(s){return (s||'').replace(/"/g,'"').replace(/'/g,''');} |
| function escH(s){return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} |
| function parseAnno(row){ |
| return { |
| emotion:safeJSON(row['Emotion']), |
| distortions:safeJSON(row['Distortions']), |
| empathy:safeJSON(row['Empathy_Level']) |
| }; |
| } |
| function getEffectiveAnno(row){ |
| const review=reviewsMap[currentIndex]; |
| if(review&&review.annotations_edit){ |
| const ed=safeJSON(review.annotations_edit); |
| if(ed) return ed; |
| } |
| return parseAnno(row); |
| } |
| function emotionCls(e){ |
| if(!e)return 'emotion-default'; |
| const l=e.toLowerCase(); |
| if(l.includes('fear'))return 'emotion-fear'; |
| if(l.includes('sad'))return 'emotion-sadness'; |
| if(l.includes('anger')||l.includes('angry'))return 'emotion-anger'; |
| if(l.includes('joy')||l.includes('happy'))return 'emotion-joy'; |
| if(l.includes('anxi'))return 'emotion-anxiety'; |
| if(l.includes('shame'))return 'emotion-shame'; |
| if(l.includes('depress'))return 'emotion-depression'; |
| return 'emotion-default'; |
| } |
| function displayEmotion(row){ |
| return row?.emotion_type || '—'; |
| } |
| function scoreColor(s,m=7){const r=s/m;return r>=.7?'#34d399':r>=.4?'#fbbf24':'#f87171';} |
| function deepCopy(o){return JSON.parse(JSON.stringify(o));} |
|
|
| |
| function parseConv(s){ |
| if(!s)return []; |
| const msgs=[]; |
| s.split('\n').forEach(line=>{ |
| const t=line.trim(); |
| if(!t)return; |
| if(t.startsWith('speaker:')||t.startsWith('listener:')){ |
| const isL=t.startsWith('listener:'); |
| const c=t.replace(/^(speaker|listener):\s*/,''); |
| if(c)msgs.push({role:isL?'listener':'speaker',text:c}); |
| } else if(msgs.length>0) msgs[msgs.length-1].text+=' '+t; |
| }); |
| return msgs; |
| } |
| function applyHighlights(msgs,quotes){ |
| return msgs.map(msg=>{ |
| let text=msg.text; |
| quotes.forEach(({quote,cls})=>{ |
| if(!quote||quote==='No evidence present')return; |
| const q=quote.replace(/^(listener|speaker):\s*/i,'').trim(); |
| if(!q)return; |
| const i=text.indexOf(q); |
| if(i!==-1)text=text.slice(0,i)+`<mark class="evidence-hl ${cls}" data-quote="${escA(q)}">`+text.slice(i,i+q.length)+'</mark>'+text.slice(i+q.length); |
| }); |
| return {...msg,htmlText:text}; |
| }); |
| } |
| function buildQuotes(anno){ |
| const quotes=[]; |
| (anno.distortions?.distortions||[]).forEach((d,i)=>{ |
| if(d.evidence_quote&&d.evidence_quote!=='No evidence present') |
| quotes.push({quote:d.evidence_quote,cls:`dist-hl-${i%3}`,type:'dist',idx:i}); |
| }); |
| (anno.empathy?.empathy_dimensions||[]).forEach((dim,i)=>{ |
| if(dim.evidence_quote&&dim.evidence_quote!=='No evidence present') |
| quotes.push({quote:dim.evidence_quote,cls:`emp-hl-${i%9}`,type:'emp',idx:i}); |
| }); |
| return quotes; |
| } |
| function scrollToEvidence(prefix,idx){ |
| const m=document.querySelector(`.conversation-view mark.${prefix}-${idx}`); |
| if(!m)return; |
| document.querySelectorAll('mark.evidence-pulse').forEach(x=>x.classList.remove('evidence-pulse')); |
| m.scrollIntoView({behavior:'smooth',block:'center'}); |
| m.classList.add('evidence-pulse'); |
| setTimeout(()=>m.classList.remove('evidence-pulse'),2000); |
| } |
|
|
| |
| function renderConversation(row){ |
| const anno=getEffectiveAnno(row); |
| const quotes=buildQuotes(anno); |
| const msgs=applyHighlights(parseConv(row['conversation_cut']),quotes); |
| const view=document.getElementById('conversationView'); |
| view.innerHTML=msgs.map(m=>`<div class="message-bubble ${m.role}"><div class="message-label">${m.role==='listener'?'Listener':'Speaker'}</div><div class="message-text">${m.htmlText||escH(m.text)}</div></div>`).join(''); |
| view.querySelectorAll('mark.evidence-hl').forEach(mk=>{ |
| mk.addEventListener('mouseenter',()=>showTooltip(mk,anno)); |
| mk.addEventListener('mouseleave',hideTooltip); |
| }); |
| } |
| function renderConvHeader(row){ |
| const anno=getEffectiveAnno(row); |
| const e=anno.emotion; |
| const en=displayEmotion(row); |
| const ei=e?.intensity||''; |
| const intCls=ei.toLowerCase().includes('high')?'intensity-high':ei.toLowerCase().includes('medium')?'intensity-medium':'intensity-low'; |
| document.getElementById('convMeta').innerHTML=` |
| <span class="meta-badge ${emotionCls(en)}">${en}</span> |
| ${ei?`<span class="intensity-badge ${intCls}">${ei}</span>`:''} |
| ${row.problem_type?`<span class="problem-badge">${row.problem_type}</span>`:''} |
| ${row.experience_type?`<span class="experience-badge">${row.experience_type}</span>`:''}`; |
| } |
|
|
| |
| function showTooltip(mk,anno){ |
| const cls=Array.from(mk.classList).find(c=>c.startsWith('dist-hl-')||c.startsWith('emp-hl-')); |
| let html=''; |
| if(cls?.startsWith('dist-hl-')){ |
| const i=parseInt(cls.split('-').pop()); |
| const d=(anno.distortions?.distortions||[])[i]; |
| if(d){const c=DIST_COLORS[i%DIST_COLORS.length];html=`<div class="tooltip-title" style="color:${c}">⚠️ ${d.cognitive_distortion||'Distortion'}</div><div class="tooltip-label">Reasoning</div><div class="tooltip-text">${d.reasoning||'—'}</div>`;} |
| } else if(cls?.startsWith('emp-hl-')){ |
| const i=parseInt(cls.split('-').pop()); |
| const dim=(anno.empathy?.empathy_dimensions||[])[i]; |
| if(dim){const c=EMP_COLORS[i%EMP_COLORS.length],sc=scoreColor(dim.score||0);html=`<div class="tooltip-title" style="color:${c}">💡 ${dim.dimension_name}</div><div class="tooltip-label">Score</div><span class="tooltip-score" style="background:${sc}22;color:${sc};border:1px solid ${sc}44">${dim.score}/7</span><div class="tooltip-label">Reasoning</div><div class="tooltip-text">${dim.reasoning||'—'}</div>`;} |
| } |
| if(!html)return; |
| const tip=document.getElementById('tooltip'); |
| document.getElementById('tooltipContent').innerHTML=html; |
| tip.classList.add('visible'); |
| const r=mk.getBoundingClientRect(); |
| let top=r.bottom+8,left=r.left; |
| if(left+320>window.innerWidth)left=window.innerWidth-330; |
| if(top+200>window.innerHeight)top=r.top-200; |
| tip.style.top=top+'px';tip.style.left=left+'px'; |
| } |
| function hideTooltip(){document.getElementById('tooltip').classList.remove('visible');} |
|
|
| |
| function renderEmotion(row){ |
| const anno=getEffectiveAnno(row); |
| const e=anno.emotion; |
| const goldEmotion=displayEmotion(row); |
| const predictedEmotion=e?.primary_emotion || '—'; |
| const emotionsMatch=predictedEmotion!=='—' && |
| predictedEmotion.trim().toLowerCase()===goldEmotion.trim().toLowerCase(); |
| const el=document.getElementById('emotionContent'); |
| const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit); |
| const btn=document.getElementById('editEmotionBtn'); |
| btn.classList.toggle('active',editMode.emotion); |
| btn.textContent=editMode.emotion?'✓ Done':'✏️ Edit'; |
|
|
| if(editMode.emotion){ |
| const d=editData.emotion; |
| |
| const stdReason = d?.standard_reasoning || d?.reasoning || ''; |
| const appReason = d?.appraisal_reasoning || ''; |
| el.innerHTML=` |
| <div class="edit-group"><div class="edit-label">Gold Emotion</div> |
| <input class="edit-field" value="${escA(goldEmotion)}" readonly/></div> |
| <div class="edit-group"><div class="edit-label">LLM Predicted Emotion</div> |
| <input class="edit-field" value="${escA(predictedEmotion)}" readonly/></div> |
| <div class="edit-group"><div class="edit-label">Intensity</div> |
| <select class="edit-field" id="ee-intensity"> |
| ${INTENSITY_OPTS.map(o=>`<option value="${o}"${(d?.intensity||'').toLowerCase()===o.toLowerCase()?' selected':''}>${o}</option>`).join('')} |
| </select></div> |
| <div class="edit-group"><div class="edit-label">Specific Term</div> |
| <input class="edit-field" id="ee-term" value="${escA(d?.specific_emotion_term||'')}"/></div> |
| <div class="edit-group"><div class="edit-label">Standard Reasoning</div> |
| <textarea class="edit-field" id="ee-std-reason" rows="3">${stdReason}</textarea></div> |
| <div class="edit-group"><div class="edit-label">Appraisal Reasoning</div> |
| <textarea class="edit-field" id="ee-app-reason" rows="5">${appReason}</textarea></div>`; |
| ['ee-intensity','ee-term','ee-std-reason','ee-app-reason'].forEach(id=>{ |
| document.getElementById(id)?.addEventListener('input',()=>{ |
| editData.emotion={ |
| |
| primary_emotion:editData.emotion?.primary_emotion || d?.primary_emotion || '', |
| intensity:document.getElementById('ee-intensity').value, |
| specific_emotion_term:document.getElementById('ee-term').value, |
| standard_reasoning:document.getElementById('ee-std-reason').value, |
| appraisal_reasoning:document.getElementById('ee-app-reason').value, |
| }; |
| }); |
| }); |
| } else { |
| if(!e){ |
| el.innerHTML=`<div class="emotion-comparison"> |
| <div class="emotion-comparison-item"> |
| <div class="emotion-comparison-label">Gold Emotion</div> |
| <span class="emotion-badge ${emotionCls(goldEmotion)}">${escH(goldEmotion)}</span> |
| </div> |
| <div class="emotion-comparison-item"> |
| <div class="emotion-comparison-label">LLM Prediction</div> |
| <span class="emotion-badge emotion-unavailable">—</span> |
| </div> |
| </div> |
| <p style="color:var(--text-muted);font-size:.8rem">No LLM annotation details.</p>`; |
| return; |
| } |
| const intCls=(e.intensity||'').toLowerCase().includes('high')?'intensity-high':(e.intensity||'').toLowerCase().includes('medium')?'intensity-medium':'intensity-low'; |
| |
| const hasNewFormat = !!(e.standard_reasoning || e.appraisal_reasoning); |
| const stdR = e.standard_reasoning || (!hasNewFormat ? e.reasoning : ''); |
| const appR = e.appraisal_reasoning || ''; |
| el.innerHTML=` |
| <div class="emotion-comparison"> |
| <div class="emotion-comparison-item"> |
| <div class="emotion-comparison-label">Gold Emotion</div> |
| <span class="emotion-badge ${emotionCls(goldEmotion)}">${escH(goldEmotion)}</span> |
| </div> |
| <div class="emotion-comparison-item"> |
| <div class="emotion-comparison-label">LLM Prediction</div> |
| <span class="emotion-badge ${emotionCls(predictedEmotion)}">${escH(predictedEmotion)}</span> |
| </div> |
| </div> |
| <div class="emotion-prediction-meta"> |
| <span class="emotion-match-status ${emotionsMatch?'is-match':'is-different'}"> |
| ${emotionsMatch?'✓ Match':'≠ Different'} |
| </span> |
| <span class="intensity-badge ${intCls}">${escH(e.intensity||'—')}</span> |
| ${hasEdit?'<span class="edited-badge">Edited</span>':''} |
| </div> |
| ${e.specific_emotion_term?`<p class="specific-term">Term: <span>${e.specific_emotion_term}</span></p>`:''} |
| ${stdR?`<div class="emotion-reasoning-block"> |
| <div class="reasoning-label">Standard Reasoning</div> |
| <p class="emotion-reasoning">${escH(stdR)}</p> |
| </div>`:''} |
| ${appR?`<div class="emotion-reasoning-block" style="margin-top:8px"> |
| <div class="reasoning-label">Appraisal Reasoning</div> |
| <p class="emotion-reasoning" style="border-left-color:var(--accent)">${escH(appR)}</p> |
| </div>`:''}`; |
| } |
| } |
|
|
| |
| function renderDistortions(row){ |
| const anno=getEffectiveAnno(row); |
| const el=document.getElementById('distortionsContent'); |
| const dists=editMode.distortions ? editData.distortions : (anno.distortions?.distortions||[]); |
| const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit); |
| const btn=document.getElementById('editDistBtn'); |
| btn.classList.toggle('active',editMode.distortions); |
| btn.textContent=editMode.distortions?'✓ Done':'✏️ Edit'; |
|
|
| if(editMode.distortions){ |
| if(!dists.length){ |
| el.innerHTML=`<div class="no-distortions"><span>No distortions</span></div>`; |
| } else { |
| el.innerHTML=dists.map((d,i)=>{ |
| const c=DIST_COLORS[i%DIST_COLORS.length]; |
| return `<div class="distortion-item editing" data-dist-idx="${i}"> |
| <div class="edit-row"> |
| <span class="dist-color-dot" style="background:${c}"></span> |
| <select class="edit-field" data-d-field="name" data-d-idx="${i}" style="flex:1"> |
| <option value="" disabled ${!d.cognitive_distortion?'selected':''}>Select Distortion...</option> |
| ${DISTORTION_OPTS.map(o=>`<option value="${o}" ${d.cognitive_distortion===o?'selected':''}>${o}</option>`).join('')} |
| <option value="${escA(d.cognitive_distortion||'')}" ${!DISTORTION_OPTS.includes(d.cognitive_distortion||'')&&d.cognitive_distortion?'selected':''} hidden>${escA(d.cognitive_distortion||'Custom')}</option> |
| </select> |
| <button class="remove-item-btn" data-remove-dist="${i}">✕</button> |
| </div> |
| <div class="edit-label" style="margin-top:6px">Evidence Quote</div> |
| <div class="edit-row"> |
| <input class="edit-field" data-d-field="quote" data-d-idx="${i}" value="${escA(d.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/> |
| <button class="select-evidence-btn" data-sel-card="distortions" data-sel-idx="${i}">📌 Select</button> |
| </div> |
| <div class="edit-label" style="margin-top:6px">Reasoning</div> |
| <textarea class="edit-field" data-d-field="reason" data-d-idx="${i}" rows="2" placeholder="Reasoning...">${d.reasoning||''}</textarea> |
| </div>`; |
| }).join(''); |
| } |
| el.innerHTML+=`<button class="add-item-btn" id="addDistBtn">+ Add Distortion</button>`; |
| |
| el.querySelectorAll('[data-d-field]').forEach(inp=>{ |
| inp.addEventListener('input',()=>{ |
| const idx=parseInt(inp.dataset.dIdx); |
| const f=inp.dataset.dField; |
| if(f==='name') editData.distortions[idx].cognitive_distortion=inp.value; |
| else if(f==='quote') editData.distortions[idx].evidence_quote=inp.value; |
| else if(f==='reason') editData.distortions[idx].reasoning=inp.value; |
| }); |
| }); |
| el.querySelectorAll('[data-remove-dist]').forEach(btn=>{ |
| btn.addEventListener('click',()=>{ |
| editData.distortions.splice(parseInt(btn.dataset.removeDist),1); |
| renderDistortions(row); |
| }); |
| }); |
| el.querySelectorAll('[data-sel-card]').forEach(btn=>{ |
| btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn)); |
| }); |
| document.getElementById('addDistBtn')?.addEventListener('click',()=>{ |
| editData.distortions.push({cognitive_distortion:'',evidence_quote:'',reasoning:''}); |
| renderDistortions(row); |
| }); |
| } else { |
| if(!dists.length){el.innerHTML=`<div class="no-distortions"><span>No distortions detected</span></div>`;return;} |
| el.innerHTML=dists.map((d,i)=>{ |
| const c=DIST_COLORS[i%DIST_COLORS.length]; |
| const q=(d.evidence_quote||'').replace(/^(listener|speaker):\s*/i,''); |
| return `<div class="distortion-item" data-dist-idx="${i}"> |
| <div class="distortion-name"><span class="dist-color-dot" style="background:${c}"></span>${d.cognitive_distortion||'—'} ${hasEdit&&i===0?'<span class="edited-badge">Edited</span>':''}</div> |
| ${q?`<div class="distortion-quote" style="border-color:${c}">${q}</div>`:''} |
| ${d.reasoning?`<div class="distortion-reasoning">${d.reasoning}</div>`:''} |
| </div>`; |
| }).join(''); |
| el.querySelectorAll('.distortion-item').forEach(item=>{ |
| item.addEventListener('click',()=>scrollToEvidence('dist-hl',parseInt(item.dataset.distIdx)%3)); |
| }); |
| } |
| } |
|
|
| |
| function renderEmpathy(row){ |
| const anno=getEffectiveAnno(row); |
| const el=document.getElementById('empathyContent'); |
| const scoreEl=document.getElementById('overallScore'); |
| const dims=editMode.empathy ? editData.empathy : (anno.empathy?.empathy_dimensions||[]); |
| const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit); |
| const btn=document.getElementById('editEmpBtn'); |
| btn.classList.toggle('active',editMode.empathy); |
| btn.textContent=editMode.empathy?'✓ Done':'✏️ Edit'; |
|
|
| if(!anno.empathy&&!editMode.empathy){el.innerHTML='<p style="color:var(--text-muted);font-size:.8rem">No annotation.</p>';scoreEl.textContent='';return;} |
| const total=dims.reduce((s,d)=>s+(d.score||0),0); |
| scoreEl.textContent=dims.length?`${total} / ${dims.length*7}`:''; |
|
|
| if(editMode.empathy){ |
| el.innerHTML=dims.map((dim,i)=>{ |
| const c=EMP_COLORS[i%EMP_COLORS.length]; |
| const sc=dim.score||0; |
| return `<div class="empathy-item editing" data-emp-idx="${i}"> |
| <div class="edit-row"> |
| <span class="emp-color-dot" style="background:${c}"></span> |
| <input class="edit-field" data-e-field="name" data-e-idx="${i}" value="${escA(dim.dimension_name||'')}" placeholder="Dimension name" style="flex:1"/> |
| <button class="remove-item-btn" data-remove-emp="${i}">✕</button> |
| </div> |
| <div class="edit-label" style="margin-top:6px">Score (0–7)</div> |
| <div class="edit-row"> |
| <input type="range" class="score-slider" data-e-field="score" data-e-idx="${i}" min="0" max="7" value="${sc}"/> |
| <span class="score-display" id="score-disp-${i}" style="color:${scoreColor(sc)}">${sc}</span> |
| </div> |
| <div class="edit-label" style="margin-top:6px">Evidence Quote</div> |
| <div class="edit-row"> |
| <input class="edit-field" data-e-field="quote" data-e-idx="${i}" value="${escA(dim.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/> |
| <button class="select-evidence-btn" data-sel-card="empathy" data-sel-idx="${i}">📌 Select</button> |
| </div> |
| <div class="edit-label" style="margin-top:6px">Reasoning</div> |
| <textarea class="edit-field" data-e-field="reason" data-e-idx="${i}" rows="2">${dim.reasoning||''}</textarea> |
| </div>`; |
| }).join(''); |
| el.innerHTML+=`<button class="add-item-btn" id="addEmpBtn">+ Add Dimension</button>`; |
| |
| el.querySelectorAll('[data-e-field]').forEach(inp=>{ |
| inp.addEventListener('input',()=>{ |
| const idx=parseInt(inp.dataset.eIdx); |
| const f=inp.dataset.eField; |
| if(f==='name') editData.empathy[idx].dimension_name=inp.value; |
| else if(f==='score'){ |
| const v=parseInt(inp.value); |
| editData.empathy[idx].score=v; |
| const disp=document.getElementById(`score-disp-${idx}`); |
| if(disp){disp.textContent=v;disp.style.color=scoreColor(v);} |
| } |
| else if(f==='quote') editData.empathy[idx].evidence_quote=inp.value; |
| else if(f==='reason') editData.empathy[idx].reasoning=inp.value; |
| }); |
| }); |
| el.querySelectorAll('[data-remove-emp]').forEach(btn=>{ |
| btn.addEventListener('click',()=>{editData.empathy.splice(parseInt(btn.dataset.removeEmp),1);renderEmpathy(row);}); |
| }); |
| el.querySelectorAll('[data-sel-card]').forEach(btn=>{ |
| btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn)); |
| }); |
| document.getElementById('addEmpBtn')?.addEventListener('click',()=>{ |
| editData.empathy.push({dimension_name:'',score:0,evidence_quote:'',reasoning:''}); |
| renderEmpathy(row); |
| }); |
| } else { |
| el.innerHTML=dims.map((dim,i)=>{ |
| const c=EMP_COLORS[i%EMP_COLORS.length]; |
| const sc=dim.score||0; |
| const pct=Math.min(100,(sc/7)*100); |
| const sC=scoreColor(sc); |
| const q=(dim.evidence_quote||'').replace(/^(listener|speaker):\s*/i,''); |
| return `<div class="empathy-item" data-emp-idx="${i}"> |
| <div class="empathy-item-header"> |
| <span class="emp-color-dot" style="background:${c}"></span> |
| <span class="empathy-name">${dim.dimension_name||'—'}</span> |
| <div class="score-bar-container"> |
| <div class="score-bar"><div class="score-bar-fill" style="width:${pct}%;background:${sC}"></div></div> |
| <span class="score-value" style="color:${sC}">${sc}</span> |
| </div> |
| </div> |
| ${q&&q!=='No evidence present'?`<div class="empathy-quote" style="border-color:${c}" title="${escA(q)}">"${q}"</div>`:''} |
| ${dim.reasoning?`<div class="empathy-reasoning">${dim.reasoning}</div>`:''} |
| </div>`; |
| }).join(''); |
| el.querySelectorAll('.empathy-item').forEach(item=>{ |
| item.addEventListener('click',()=>{ |
| item.classList.toggle('active'); |
| scrollToEvidence('emp-hl',parseInt(item.dataset.empIdx)%9); |
| }); |
| }); |
| } |
| } |
|
|
| |
| function startSelectingEvidence(card, itemIdx, triggerBtn){ |
| selectingEvidence={card, itemIdx}; |
| const panel=document.querySelector('.conversation-panel'); |
| panel.classList.add('selecting-mode'); |
| const hint=document.getElementById('selectionHint'); |
| hint.classList.add('visible'); |
| |
| document.querySelectorAll('.select-evidence-btn').forEach(b=>b.classList.remove('selecting')); |
| triggerBtn?.classList.add('selecting'); |
| } |
| function stopSelectingEvidence(){ |
| selectingEvidence=null; |
| document.querySelector('.conversation-panel')?.classList.remove('selecting-mode'); |
| const hint=document.getElementById('selectionHint'); |
| hint?.classList.remove('visible'); |
| document.querySelectorAll('.select-evidence-btn').forEach(b=>b.classList.remove('selecting')); |
| } |
| function captureSelection(){ |
| if(!selectingEvidence)return; |
| const sel=window.getSelection(); |
| if(!sel||sel.rangeCount===0||sel.isCollapsed)return; |
| const text=sel.toString().trim(); |
| if(!text)return; |
| const {card,itemIdx}=selectingEvidence; |
| |
| if(card==='distortions'&&editData.distortions[itemIdx]!==undefined){ |
| editData.distortions[itemIdx].evidence_quote=text; |
| } else if(card==='empathy'&&editData.empathy[itemIdx]!==undefined){ |
| editData.empathy[itemIdx].evidence_quote=text; |
| } |
| sel.removeAllRanges(); |
| stopSelectingEvidence(); |
| |
| const row=filteredData[currentIndex]; |
| if(card==='distortions') renderDistortions(row); |
| else if(card==='empathy') renderEmpathy(row); |
| } |
|
|
| |
| async function saveAnnotations(){ |
| const row=filteredData[currentIndex]; |
| const anno=getEffectiveAnno(row); |
| |
| const toSave={ |
| emotion: editData.emotion || anno.emotion, |
| distortions: anno.distortions |
| ? {...anno.distortions, distortions: editData.distortions||anno.distortions?.distortions||[]} |
| : {distortions: editData.distortions||[]}, |
| empathy: anno.empathy |
| ? {...anno.empathy, empathy_dimensions: editData.empathy||anno.empathy?.empathy_dimensions||[]} |
| : {empathy_dimensions: editData.empathy||[]} |
| }; |
| try { |
| const res=await fetch('/api/reviews/annotations',{ |
| method:'PATCH', |
| headers:{'Content-Type':'application/json'}, |
| body:JSON.stringify({assignment_id:assignmentId,sample_index:currentIndex,annotations:toSave}) |
| }); |
| const data=await res.json(); |
| if(res.ok){ |
| reviewsMap[currentIndex]=data; |
| return true; |
| } |
| } catch(e){} |
| return false; |
| } |
|
|
| |
| function toggleEdit(card){ |
| const row=filteredData[currentIndex]; |
| const anno=getEffectiveAnno(row); |
| if(editMode[card]){ |
| |
| editMode[card]=false; |
| stopSelectingEvidence(); |
| saveAnnotations().then(ok=>{ |
| if(ok){ |
| |
| renderAll(); |
| } |
| }); |
| } else { |
| |
| if(card==='emotion') editData.emotion=deepCopy(anno.emotion||{primary_emotion:'',intensity:'',specific_emotion_term:'',reasoning:''}); |
| else if(card==='distortions') editData.distortions=deepCopy(anno.distortions?.distortions||[]); |
| else if(card==='empathy') editData.empathy=deepCopy(anno.empathy?.empathy_dimensions||[]); |
| editMode[card]=true; |
| if(card==='emotion') renderEmotion(row); |
| else if(card==='distortions') renderDistortions(row); |
| else if(card==='empathy') renderEmpathy(row); |
| } |
| } |
|
|
| |
| function renderReviewState(){ |
| const review=reviewsMap[currentIndex]; |
| const badge=document.getElementById('reviewStatusBadge'); |
| const acceptBtn=document.getElementById('acceptBtn'); |
| const rejectBtn=document.getElementById('rejectBtn'); |
| const comment=document.getElementById('reviewComment'); |
| const submitBtn=document.getElementById('submitReviewBtn'); |
| acceptBtn.classList.remove('active'); |
| rejectBtn.classList.remove('active'); |
| currentReviewStatus=null; |
| if(review&&review.status&&review.status!=='reject'||(review&&review.status==='accept')){ |
| |
| } |
| if(review){ |
| badge.style.display='inline-block'; |
| badge.textContent=review.status==='accept'?'✓ Accepted':'✗ Rejected'; |
| badge.className='review-status-badge '+(review.status==='accept'?'review-status-accept':'review-status-reject'); |
| currentReviewStatus=review.status; |
| if(review.status==='accept') acceptBtn.classList.add('active'); |
| else rejectBtn.classList.add('active'); |
| comment.value=review.comment||''; |
| submitBtn.textContent='Update Review'; |
| } else { |
| badge.style.display='none'; |
| comment.value=''; |
| submitBtn.textContent='Submit Review'; |
| } |
| submitBtn.disabled=false; |
| } |
|
|
| |
| function renderSampleList(){ |
| const list=document.getElementById('sampleList'); |
| list.innerHTML=''; |
| filteredData.forEach((row,i)=>{ |
| const en=displayEmotion(row); |
| const review=reviewsMap[i]; |
| const dotCls=review?(review.status==='accept'?'dot-accept':'dot-reject'):'dot-pending'; |
| const hasEdit=!!(review?.annotations_edit); |
| const item=document.createElement('div'); |
| item.className='sample-item'+(i===currentIndex?' active':''); |
| item.dataset.idx=i; |
| item.innerHTML=`<div class="sample-item-idx">#${i+1} <span class="sample-review-dot ${dotCls}"></span>${hasEdit?'<span style="color:#fbbf24;font-size:.6rem">✏</span>':''}</div> |
| <div class="sample-item-emotion" style="color:var(--emotion-color)">${en}</div> |
| <div class="sample-item-problem">${row.problem_type||''}</div>`; |
| item.addEventListener('click',()=>{currentIndex=i;renderAll();}); |
| list.appendChild(item); |
| }); |
| } |
|
|
| |
| function updateNav(){ |
| document.getElementById('prevBtn').disabled=currentIndex<=0; |
| document.getElementById('nextBtn').disabled=currentIndex>=filteredData.length-1; |
| document.getElementById('currentIdx').textContent=currentIndex+1; |
| document.getElementById('totalSamples').textContent=filteredData.length; |
| document.querySelectorAll('.sample-item').forEach(it=>it.classList.toggle('active',parseInt(it.dataset.idx)===currentIndex)); |
| document.querySelector('.sample-item.active')?.scrollIntoView({block:'nearest'}); |
| } |
|
|
| |
| function renderAll(){ |
| if(!filteredData.length)return; |
| |
| editMode={emotion:false,distortions:false,empathy:false}; |
| stopSelectingEvidence(); |
| const row=filteredData[currentIndex]; |
| renderConvHeader(row); |
| renderConversation(row); |
| renderEmotion(row); |
| renderDistortions(row); |
| renderEmpathy(row); |
| renderReviewState(); |
| updateNav(); |
| } |
|
|
| |
| function applyFilter(q){ |
| q=q.toLowerCase().trim(); |
| filteredData=q?allData.filter(row=>{ |
| return displayEmotion(row).toLowerCase().includes(q)|| |
| (row.problem_type||'').toLowerCase().includes(q); |
| }):[...allData]; |
| currentIndex=0;renderSampleList();renderAll(); |
| } |
|
|
| |
| async function submitReview(){ |
| if(!currentReviewStatus){alert('Please select Accept or Reject');return;} |
| const comment=document.getElementById('reviewComment').value.trim(); |
| const btn=document.getElementById('submitReviewBtn'); |
| btn.disabled=true;btn.textContent='Submitting...'; |
| try{ |
| const res=await fetch('/api/reviews',{ |
| method:'POST',headers:{'Content-Type':'application/json'}, |
| body:JSON.stringify({assignment_id:assignmentId,sample_index:currentIndex,status:currentReviewStatus,comment}) |
| }); |
| const data=await res.json(); |
| if(res.ok){ |
| reviewsMap[currentIndex]=data; |
| renderReviewState();renderSampleList(); |
| btn.textContent='✓ Saved!'; |
| setTimeout(()=>{ |
| btn.textContent='Update Review';btn.disabled=false; |
| const next=filteredData.findIndex((_,i)=>i>currentIndex&&!reviewsMap[i]); |
| if(next!==-1){currentIndex=next;renderAll();} |
| },800); |
| } else {alert(data.error||'Failed');btn.textContent='Submit Review';btn.disabled=false;} |
| } catch(e){alert('Network error');btn.textContent='Submit Review';btn.disabled=false;} |
| } |
|
|
| |
| function closeHistory(){ |
| const modal=document.getElementById('historyModal'); |
| modal.classList.remove('visible'); |
| modal.setAttribute('aria-hidden','true'); |
| } |
|
|
| function historySnapshotHtml(entry,version,row){ |
| const a=entry.annotations||{}; |
| const emotion=a.emotion||{}; |
| const distortions=a.distortions?.distortions||[]; |
| const empathy=a.empathy?.empathy_dimensions||[]; |
| const when=entry.created_at |
| ? new Date(entry.created_at).toLocaleString() |
| : 'Unknown time'; |
| const emotionParts=[ |
| displayEmotion(row), |
| emotion.specific_emotion_term, |
| emotion.intensity |
| ].filter(Boolean).map(v=>escH(String(v))); |
| const distortionNames=distortions |
| .map(d=>d.cognitive_distortion).filter(Boolean).map(v=>escH(String(v))); |
| const empathyNames=empathy.map(d=>{ |
| const name=escH(String(d.dimension_name||'Dimension')); |
| return d.score===undefined||d.score===null ? name : `${name} (${escH(String(d.score))}/7)`; |
| }); |
| const fullJson=escH(JSON.stringify(a,null,2)); |
|
|
| return `<article class="history-entry"> |
| <div class="history-entry-head"> |
| <span class="history-version">Version ${version}</span> |
| <span class="history-meta">${escH(entry.edited_by||'unknown')} · ${escH(when)}</span> |
| </div> |
| <div class="history-summary"> |
| <div class="history-field"> |
| <div class="history-field-label">Emotion</div> |
| <div>${emotionParts.join(' · ')||'—'}</div> |
| </div> |
| ${emotion.standard_reasoning?`<div class="history-field"><div class="history-field-label">Standard Reasoning</div><div class="history-reasoning">${escH(emotion.standard_reasoning)}</div></div>`:''} |
| ${emotion.appraisal_reasoning?`<div class="history-field"><div class="history-field-label">Appraisal Reasoning</div><div class="history-reasoning">${escH(emotion.appraisal_reasoning)}</div></div>`:''} |
| <div class="history-field"> |
| <div class="history-field-label">Distortions (${distortions.length})</div> |
| <div>${distortionNames.join(', ')||'None'}</div> |
| </div> |
| <div class="history-field"> |
| <div class="history-field-label">Empathy dimensions (${empathy.length})</div> |
| <div>${empathyNames.join(', ')||'None'}</div> |
| </div> |
| <details class="history-json"><summary>View full snapshot</summary><pre>${fullJson}</pre></details> |
| </div> |
| </article>`; |
| } |
|
|
| async function openHistory(){ |
| if(!assignmentId||!filteredData.length)return; |
| const modal=document.getElementById('historyModal'); |
| const content=document.getElementById('historyContent'); |
| const row=filteredData[currentIndex]; |
| document.getElementById('historySubtitle').textContent=`Sample #${currentIndex+1} · ${displayEmotion(row)}`; |
| content.innerHTML='<div class="history-loading">Loading history...</div>'; |
| modal.classList.add('visible'); |
| modal.setAttribute('aria-hidden','false'); |
| try{ |
| const res=await fetch(`/api/assignments/${assignmentId}/samples/${currentIndex}/annotation-history`); |
| const entries=await res.json(); |
| if(!res.ok)throw new Error(entries.error||'Failed to load history'); |
| if(!entries.length){ |
| content.innerHTML='<div class="history-empty">No edit history yet. A version will appear after an annotation is changed and saved.</div>'; |
| return; |
| } |
| content.innerHTML=entries.map((entry,i)=>historySnapshotHtml(entry,entries.length-i,row)).join(''); |
| }catch(err){ |
| content.innerHTML=`<div class="history-empty">${escH(err.message||'Failed to load history')}</div>`; |
| } |
| } |
|
|
| |
| async function loadAssignmentData(){ |
| try{ |
| let fileId,filename; |
| const r1=await fetch('/api/my-assignments'); |
| const myAssign=await r1.json(); |
| let a=myAssign.find(x=>x.id===assignmentId); |
| if(!a){ |
| const r2=await fetch('/api/assignments'); |
| if(r2.ok){const all=await r2.json();a=all.find(x=>x.id===assignmentId);} |
| } |
| if(!a){document.getElementById('reviewSubtitle').textContent='Assignment not found';return;} |
| fileId=a.file_id;filename=a.filename; |
| const r3=await fetch(`/api/files/${fileId}/data`); |
| const fd=await r3.json(); |
| allData=fd.samples||[];filteredData=[...allData]; |
| const r4=await fetch(`/api/assignments/${assignmentId}/reviews`); |
| reviewsMap=await r4.json(); |
| document.getElementById('reviewTitle').textContent='Review'; |
| document.getElementById('reviewSubtitle').textContent=filename||'Annotation Review'; |
| currentIndex=0;renderSampleList();renderAll(); |
| } catch(err){console.error(err);document.getElementById('reviewSubtitle').textContent='Failed to load';} |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded',async()=>{ |
| const parts=window.location.pathname.split('/'); |
| assignmentId=parseInt(parts[parts.length-1]); |
| if(isNaN(assignmentId))return; |
| try{const m=await fetch('/api/me').then(r=>r.json());document.getElementById('userBadge').textContent=m.username;}catch(e){} |
| document.getElementById('logoutBtn').addEventListener('click',async()=>{await fetch('/api/logout',{method:'POST'});window.location.href='/login';}); |
| document.getElementById('prevBtn').addEventListener('click',()=>{if(currentIndex>0){currentIndex--;renderAll();}}); |
| document.getElementById('nextBtn').addEventListener('click',()=>{if(currentIndex<filteredData.length-1){currentIndex++;renderAll();}}); |
| document.getElementById('searchInput').addEventListener('input',e=>applyFilter(e.target.value)); |
| document.getElementById('acceptBtn').addEventListener('click',()=>{currentReviewStatus='accept';document.getElementById('acceptBtn').classList.add('active');document.getElementById('rejectBtn').classList.remove('active');}); |
| document.getElementById('rejectBtn').addEventListener('click',()=>{currentReviewStatus='reject';document.getElementById('rejectBtn').classList.add('active');document.getElementById('acceptBtn').classList.remove('active');}); |
| document.getElementById('submitReviewBtn').addEventListener('click',submitReview); |
| document.getElementById('historyBtn').addEventListener('click',openHistory); |
| document.getElementById('historyCloseBtn').addEventListener('click',closeHistory); |
| document.getElementById('historyModal').addEventListener('click',e=>{ |
| if(e.target===e.currentTarget)closeHistory(); |
| }); |
| |
| ['editEmotionBtn','editDistBtn','editEmpBtn'].forEach(id=>{ |
| document.getElementById(id)?.addEventListener('click',()=>{ |
| const card={'editEmotionBtn':'emotion','editDistBtn':'distortions','editEmpBtn':'empathy'}[id]; |
| toggleEdit(card); |
| }); |
| }); |
| |
| document.getElementById('conversationView').addEventListener('mouseup',()=>{ |
| if(selectingEvidence) captureSelection(); |
| }); |
| |
| document.addEventListener('keydown',e=>{ |
| if(e.key==='Escape'){ |
| if(document.getElementById('historyModal').classList.contains('visible'))closeHistory(); |
| else stopSelectingEvidence(); |
| return; |
| } |
| if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return; |
| if(e.key==='ArrowLeft'&¤tIndex>0){currentIndex--;renderAll();} |
| if(e.key==='ArrowRight'&¤tIndex<filteredData.length-1){currentIndex++;renderAll();} |
| }); |
| await loadAssignmentData(); |
| }); |
|
|
| |
| function doExport(format){ |
| if(!assignmentId)return; |
| const url=`/api/assignments/${assignmentId}/export?format=${format}`; |
| if(format==='csv'){ |
| |
| const a=document.createElement('a'); |
| a.href=url;a.download='';a.click(); |
| } else { |
| |
| fetch(url).then(r=>r.json()).then(data=>{ |
| const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); |
| const a=document.createElement('a'); |
| a.href=URL.createObjectURL(blob); |
| a.download=`export_assignment_${assignmentId}.json`; |
| a.click();URL.revokeObjectURL(a.href); |
| }).catch(e=>alert('Export failed')); |
| } |
| document.getElementById('exportMenu').style.display='none'; |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded',()=>{ |
| const exportBtn=document.getElementById('exportBtn'); |
| const exportMenu=document.getElementById('exportMenu'); |
| if(exportBtn&&exportMenu){ |
| exportBtn.addEventListener('click',(e)=>{ |
| e.stopPropagation(); |
| exportMenu.style.display=exportMenu.style.display==='none'?'block':'none'; |
| }); |
| document.getElementById('exportJSON')?.addEventListener('click',()=>doExport('json')); |
| document.getElementById('exportCSV')?.addEventListener('click',()=>doExport('csv')); |
| |
| document.addEventListener('click',()=>{exportMenu.style.display='none';}); |
| } |
| }); |
|
|