Hugdrigo's picture
Upload 27 files
2816118 verified
Raw History Blame Contribute Delete
34.5 kB
import {PROJECT_VERSION,PUBLICATION_API,WB_API,INDICATORS,COUNTRIES,uniqueId,clean,doiId,doiLink,validateUrl,parseCrossref,parseWorldBank,validGraph,normalizeSession,audit,nodeCounts,relevantLinks,shortestPath,graphCSV,linksCSV,graphML,reportMarkdown} from './engine.js';
import {DEMO} from './demo.js';
const $=(sel)=>document.querySelector(sel); const esc=v=>String(v??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;').replaceAll("'",'&#39;');
let state={graph:structuredClone(DEMO.graph),demo:true,source:'fictional demo',note:DEMO.notes,view:'network',selected:'c_access',path:[],results:[],query:'',queryError:'',searching:false,fetching:false,indicatorError:'',filter:'all',term:'',flash:'',flashBad:false};
const kindColor={publication:'#70b7e5',claim:'#6fe1c6',indicator:'#c2a0f1',hypothesis:'#f4bd80'};
const kindLabel={publication:'Publication',claim:'Claim',indicator:'Indicator',hypothesis:'Hypothesis'};
const byId=id=>state.graph.nodes.find(n=>n.id===id); const label=n=>n?.title||'(missing)';
const setFlash=(text,bad=false)=>{state.flash=String(text);state.flashBad=bad;const f=$('#flash');f.textContent=state.flash;f.className='flash'+(bad?' bad':'');};
const stamp=()=>new Date().toISOString().slice(0,10);
function updateState(fn,msg=''){try{fn();validGraph(state.graph);render();if(msg)setFlash(msg)}catch(e){setFlash(e.message,true)}}
function download(name,text,mime='text/plain;charset=utf-8'){const blob=new Blob([text],{type:mime});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(a.href),1200)}
function exportData(type){try{const g=state.graph;const suffix=new Date().toISOString().slice(0,10);if(type==='json')download(`evidencegraph-session-${suffix}.json`,JSON.stringify({schemaVersion:1,exportedAt:new Date().toISOString(),source:state.source,demo:state.demo,notes:state.note,graph:g},null,2),'application/json');if(type==='nodes')download(`evidencegraph-nodes-${suffix}.csv`,graphCSV(g),'text/csv');if(type==='edges')download(`evidencegraph-evidence-${suffix}.csv`,linksCSV(g),'text/csv');if(type==='graphml')download(`evidencegraph-network-${suffix}.graphml`,graphML(g),'application/xml');if(type==='md')download(`evidencegraph-report-${suffix}.md`,reportMarkdown(g,{origin:state.source,note:state.note}),'text/markdown');setFlash('File download initiated. Check your Downloads folder.');}catch(e){setFlash(e.message,true)}}
function setView(view){if(!['network','desk','literature','indicators','hypotheses','audit','exports'].includes(view))return;state.view=view;document.querySelectorAll('.nav-link').forEach(b=>b.classList.toggle('active',b.dataset.view===view));document.querySelectorAll('.view').forEach(p=>p.classList.toggle('active',p.id==='panel'+view[0].toUpperCase()+view.slice(1)));render();}
function render(){const a=audit(state.graph);$('#summary').innerHTML=`<article class="metric"><div class="m-label">Research nodes</div><div class="m-value">${a.nodeTotal}</div><div class="m-note">${a.counts.publication} papers · ${a.counts.indicator} indicators</div></article><article class="metric"><div class="m-label">Evidence links</div><div class="m-value">${a.linkTotal}</div><div class="m-note">Sources linked to claims and hypotheses</div></article><article class="metric"><div class="m-label">Located excerpts</div><div class="m-value">${a.completeness}%</div><div class="m-note">Links with both excerpt and locator</div></article><article class="metric"><div class="m-label">Review flags</div><div class="m-value">${a.flags.length}</div><div class="m-note">Unsubstantiated or mixed-evidence claims</div></article>`;
const badge=$('#dataStatus');badge.textContent=state.demo?'● SYNTHETIC DEMO / MIXED SESSION':'● USER / API METADATA SESSION';badge.className='dataset-status'+(!state.demo?' live':'');
const panels={network:renderNetwork,desk:renderDesk,literature:renderLiterature,indicators:renderIndicators,hypotheses:renderHypotheses,audit:renderAudit,exports:renderExports};panels[state.view]?.(a);
}
const viewHead=(title,desc,action='')=>`<div class="view-heading"><div><h3>${title}</h3><p>${desc}</p></div>${action}</div>`;
const tag=(t)=>`<span class="tag ${esc(t)}">${esc(t)}</span>`;
const option=(n,selected='')=>`<option value="${esc(n.id)}" ${selected===n.id?'selected':''}>${esc(n.title.slice(0,85))}</option>`;
const rowHTML=(n,withButton=true)=>`<div class="node-row"><div class="flex">${tag(n.kind)}${n.origin==='SYNTHETIC DEMO'?tag('fictional'):''}<strong>${esc(n.title)}</strong></div><small>${esc(n.origin||'User-entered')} ${n.year?'· '+esc(n.year):''}${n.doi?' · DOI '+esc(n.doi):''}${n.kind==='indicator'?' · '+esc(n.value)+' '+esc(n.unit):''}</small>${withButton?`<button class="btn sm subtle" data-select-node="${esc(n.id)}">Inspect node ↗</button>`:''}</div>`;
function graphSVG(g){const kinds=['publication','indicator','claim','hypothesis'];const groups={};for(const k of kinds)groups[k]=g.nodes.filter(n=>n.kind===k&&((state.filter==='all'||state.filter===k)&&(!state.term||n.title.toLowerCase().includes(state.term.toLowerCase()))));const visible=Object.values(groups).flat();const visibleIds=new Set(visible.map(n=>n.id));const W=970,H=520;const cols=[130,335,575,810];const pos=new Map();kinds.forEach((k,col)=>{const ns=groups[k];ns.forEach((n,i)=>pos.set(n.id,{x:cols[col],y:ns.length<=1?260:65+(390/(ns.length-1))*i}));});
const edges=g.links.filter(l=>visibleIds.has(l.from)&&visibleIds.has(l.to)).map(l=>{const a=pos.get(l.from),b=pos.get(l.to);const color=l.relation==='supports'?'#59d7b8':l.relation==='challenges'?'#ed8f91':'#95a7ef';return `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${color}" stroke-width="${state.path.includes(l.from)&&state.path.includes(l.to)?4:2}" opacity=".7"/>`}).join('');const nodes=visible.map(n=>{const p=pos.get(n.id);const selected=n.id===state.selected;const r=n.kind==='claim'?24:n.kind==='hypothesis'?23:18;const title=n.title.length>24?n.title.slice(0,22)+'…':n.title;return `<g class="graph-node" role="button" tabindex="0" data-select-node="${esc(n.id)}" aria-label="Inspect ${esc(n.kind)} ${esc(n.title)}"><circle cx="${p.x}" cy="${p.y}" r="${r+(selected?4:0)}" fill="#102c3e" stroke="${kindColor[n.kind]}" stroke-width="${selected?4:2.4}"/><circle cx="${p.x}" cy="${p.y}" r="5" fill="${kindColor[n.kind]}"/><text x="${p.x}" y="${p.y+35}" text-anchor="middle" fill="#e0edf3" font-size="12" font-weight="600">${esc(title)}</text></g>`}).join('');return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="Evidence relationship map, color coded by node type"><defs><pattern id="dots" width="26" height="26" patternUnits="userSpaceOnUse"><circle cx="2" cy="2" r="1" fill="#193d4c"/></pattern></defs><rect width="970" height="520" fill="url(#dots)"/>${kinds.map((k,i)=>`<text x="${cols[i]}" y="30" text-anchor="middle" fill="#7297aa" font-size="11" letter-spacing="2">${k.toUpperCase()}S</text>`).join('')}${edges}${nodes}</svg>`}
function renderNetwork(a){const root=$('#panelNetwork');const n=byId(state.selected)||state.graph.nodes[0];if(n&&!byId(state.selected))state.selected=n.id;const links=n?relevantLinks(state.graph,n.id):[];root.innerHTML=viewHead('Evidence network','Inspect how publication metadata, located excerpts and policy hypotheses connect.',`<div class="view-actions"><button class="btn sm" data-action="demo">Load fictional demo</button><button class="btn sm subtle" data-action="reset">New empty session</button></div>`)+`<div class="panel"><div class="panel-header"><h4>Network map <span class="audit-badge">· ${a.nodeTotal} nodes / ${a.linkTotal} links</span></h4><div class="view-actions"><select id="graphFilter" aria-label="Node type filter" class="btn sm"><option value="all">All types</option>${['publication','indicator','claim','hypothesis'].map(k=>`<option value="${k}" ${state.filter===k?'selected':''}>${kindLabel[k]}</option>`).join('')}</select></div></div><div class="graph-frame">${graphSVG(state.graph)}</div><div class="legend">${Object.entries(kindColor).map(([k,c])=>`<span><b style="background:${c}"></b>${kindLabel[k]}</span>`).join('')}<span><b style="background:#59d7b8"></b>Supports</span><span><b style="background:#ed8f91"></b>Challenges</span><span><b style="background:#95a7ef"></b>Context</span></div><p class="mini">Network connections document research judgments. They are not independent verification or causal proof. Select a node to inspect its provenance.</p></div>`+
`<div class="panel"><div class="panel-header"><h4>Trace a research connection</h4><span class="pill">Shortest evidence path · undirected</span></div><div class="grid2"><div class="field"><label>From node</label><select id="traceFrom">${state.graph.nodes.map(x=>option(x)).join('')}</select></div><div class="field"><label>To node</label><select id="traceTo">${state.graph.nodes.map(x=>option(x,state.selected)).join('')}</select></div></div><button class="btn sm primary" data-action="trace-path" ${state.graph.nodes.length<2?'disabled':''}>Trace path through links</button>${state.path.length?`<div class="notice info">${state.path.map(id=>esc(label(byId(id)))).join(' → ')}</div>`:'<p class="mini">Trace shows graph connectivity only, not proof that one proposition implies another.</p>'}</div>`+
`<div class="grid2"><div class="panel"><h4>Selected node</h4>${n?`<div class="flex" style="margin-bottom:12px">${tag(n.kind)} <strong>${esc(n.title)}</strong></div><div class="statline"><span>Origin</span><strong>${esc(n.origin||'User-entered')}</strong></div>${n.year?`<div class="statline"><span>Year</span><strong>${esc(n.year)}</strong></div>`:''}${n.doi?`<div class="statline"><span>DOI</span><strong>${esc(n.doi)}</strong></div>`:''}${n.kind==='indicator'?`<div class="statline"><span>Value</span><strong>${esc(n.value)} ${esc(n.unit)}</strong></div>`:''}<p class="small-info">${esc(n.description||n.scope||'No detailed description available.')}</p>${n.url&&validateUrl(n.url)?`<p><a href="${esc(validateUrl(n.url))}" target="_blank" rel="noopener noreferrer" class="text-button">Open original source ↗</a></p>`:''}`:'<div class="empty">No nodes yet. Create one in the Evidence desk.</div>'}</div><div class="panel"><h4>Direct links <span class="audit-badge">(${links.length})</span></h4><div class="scroll-list">${links.length?links.map(l=>`<div class="node-row"><div class="flex">${tag(l.relation)}<strong>${esc(label(byId(l.from===n.id?l.to:l.from)))}</strong></div><small>${esc(l.locator||'No source locator supplied')}</small><small>${esc(l.excerpt||'No excerpt supplied')}</small></div>`).join(''):'<div class="empty">This node has no documented links.</div>'}</div></div></div>`;
root.querySelector('#graphFilter').addEventListener('change',e=>{state.filter=e.target.value;render()});}
function renderDesk(a){const g=state.graph;const sources=g.nodes.filter(n=>n.kind==='publication'||n.kind==='indicator'||n.kind==='claim');const targets=g.nodes.filter(n=>n.kind==='claim'||n.kind==='hypothesis');const links=g.links.slice().reverse();$('#panelDesk').innerHTML=viewHead('Evidence desk','Write explicit research claims and attach source-located supporting, challenging or contextual passages.')+
`<div class="grid2"><div class="panel"><h4>01 / Add claim or hypothesis</h4><form id="nodeForm"><div class="field"><label for="newKind">Node type</label><select id="newKind" name="kind"><option value="claim">Research claim</option><option value="hypothesis">Policy hypothesis</option></select></div><div class="field"><label for="newTitle">Statement</label><input id="newTitle" name="title" maxlength="260" placeholder="e.g. Digital access influences completion rates" required/></div><div class="field"><label for="newDescription">Research notes (optional)</label><textarea id="newDescription" name="description" maxlength="1500" placeholder="Define scope, period, and uncertainty..."></textarea></div><button class="btn primary" type="submit">+ Add research node</button></form></div><div class="panel"><h4>02 / Link located evidence</h4><form id="linkForm"><div class="field"><label>Source / upstream node</label><select name="from" required>${sources.map(n=>option(n)).join('')}</select></div><div class="field"><label>Claim / hypothesis being evaluated</label><select name="to" required>${targets.map(n=>option(n)).join('')}</select></div><div class="field"><label>Relation</label><select name="relation"><option value="supports">Supports (researcher judgment)</option><option value="challenges">Challenges (researcher judgment)</option><option value="context">Provides context (no causal claim)</option></select></div><div class="field"><label>Exact supporting excerpt (do not invent)</label><textarea name="excerpt" maxlength="1800" required placeholder="Copy an excerpt from a source you have actually read..."></textarea></div><div class="field"><label>Page / section / source locator</label><input name="locator" maxlength="220" required placeholder="e.g. Section 3.2, p. 14"/></div><div class="field"><label>Review status</label><select name="review"><option value="unverified">Not checked against original</option><option value="researcher-checked">Researcher checked original source</option></select></div><button class="btn primary" type="submit" ${!sources.length||!targets.length?'disabled':''}>+ Connect evidence</button></form><p class="filehint">Metadata alone cannot support a finding. Only user-provided excerpts are linked, with a locator and explicit review status.</p></div></div><div class="panel"><div class="panel-header"><h4>Evidence registry</h4><span class="pill">${links.length} recorded connections</span></div><div class="scroll-list">${links.length?links.map(l=>`<div class="node-row"><div class="flex">${tag(l.relation)}${tag(l.review||'unverified')}<strong>${esc(label(byId(l.from)))} → ${esc(label(byId(l.to)))}</strong></div><small><b>Excerpt:</b> ${esc(l.excerpt||'[missing]')}</small><small><b>Locator:</b> ${esc(l.locator||'[missing]')}</small><div class="flex"><button class="btn sm subtle" data-select-node="${esc(l.to)}">Inspect claim</button><button class="btn sm amber" data-remove-link="${esc(l.id)}">Remove this link</button></div></div>`).join(''):'<div class="empty">No links yet. Start by creating a claim and adding a source excerpt.</div>'}</div></div>`;
}
function renderLiterature(a){const pubs=state.graph.nodes.filter(n=>n.kind==='publication');$('#panelLiterature').innerHTML=viewHead('Literature hub','Search Crossref publication metadata or enter a bibliographic source manually. Titles are not evidence.')+
`<div class="grid2"><div class="panel"><h4>Search real publication metadata</h4><form id="crossrefForm"><div class="field"><label>Research topic, title or DOI</label><input name="query" minlength="3" maxlength="140" required placeholder="e.g. digital education inequality" value="${esc(state.query)}"/></div><button class="btn primary" type="submit" ${state.searching?'disabled':''}>${state.searching?'Searching...':'Search Crossref API ↗'}</button></form><p class="filehint">Live metadata requires internet and the public Crossref service. Abstracts/full texts are not automatically retrieved.</p>${state.queryError?`<div class="notice alert">${esc(state.queryError)}</div>`:''}</div><div class="panel"><h4>Add a publication manually</h4><form id="manualPubForm"><div class="field"><label>Publication title</label><input name="title" maxlength="260" required placeholder="Exact title from the original work"/></div><div class="grid2"><div class="field"><label>Author(s)</label><input name="authors" maxlength="220" placeholder="Names, separated by semicolons"/></div><div class="field"><label>Publication year</label><input name="year" type="number" min="1600" max="2200" placeholder="2024"/></div></div><div class="field"><label>DOI (optional)</label><input name="doi" maxlength="180" placeholder="10.1234/example"/></div><button class="btn primary" type="submit">+ Save metadata</button></form></div></div><div class="panel"><div class="panel-header"><h4>Search results <span class="audit-badge">${state.results.length} returned</span></h4></div>${state.results.length?state.results.map((n,i)=>`<div class="node-row"><strong>${esc(n.title)}</strong><small>${esc((n.authors||[]).slice(0,3).join(', ')||'Authors unavailable')} · ${esc(n.year||'Year unknown')} · ${esc(n.venue||'Venue unspecified')}</small><div class="flex">${n.doi?`<span class="tag">${esc(n.doi)}</span>`:''}<button data-add-result="${i}" class="btn sm primary">+ Add to research library</button></div></div>`).join(''):'<div class="empty">Results will appear here after a successful public search.</div>'}</div><div class="panel"><div class="panel-header"><h4>Saved bibliography</h4><span class="pill">${pubs.length} records</span></div>${pubs.length?pubs.map(n=>rowHTML(n)).join(''):'<div class="empty">No publication metadata saved.</div>'}</div>`;}
function renderIndicators(a){const indicators=state.graph.nodes.filter(n=>n.kind==='indicator');$('#panelIndicators').innerHTML=viewHead('Indicator hub','Bring quantitative context into your literature map without mistaking correlation for causal evidence.')+
`<div class="grid2"><div class="panel"><h4>Import latest available World Bank observation</h4><form id="wbForm"><div class="field"><label>Country</label><select name="country">${Object.entries(COUNTRIES).map(([code,title])=>`<option value="${code}">${title}</option>`).join('')}</select></div><div class="field"><label>Indicator</label><select name="indicator">${Object.entries(INDICATORS).map(([code,m])=>`<option value="${code}">${esc(m.label)}</option>`).join('')}</select></div><button class="btn primary" type="submit" ${state.fetching?'disabled':''}>${state.fetching?'Loading...':'Load latest official observation'}</button></form>${state.indicatorError?`<div class="notice alert">${esc(state.indicatorError)}</div>`:''}<div class="notice info">Reports the latest observed year, not an inferred current value. Data may lag by several years.</div></div><div class="panel"><h4>Add an explicitly sourced custom indicator</h4><form id="manualIndicatorForm"><div class="field"><label>Title and country</label><input name="title" required maxlength="200" placeholder="e.g. Public school connectivity · Province X"/></div><div class="grid2"><div class="field"><label>Numeric value</label><input name="value" required type="number" step="any" placeholder="67.3"/></div><div class="field"><label>Observed year</label><input name="year" required type="number" min="1800" max="2200" placeholder="2023"/></div></div><div class="field"><label>Unit</label><input name="unit" required maxlength="35" placeholder="%, US$, persons"/></div><div class="field"><label>Official source URL (optional)</label><input name="url" maxlength="300" placeholder="https://..."/></div><button class="btn primary" type="submit">+ Add sourced indicator</button></form></div></div><div class="panel"><h4>Indicator registry</h4><p class="mini">These numbers provide quantitative context. Any links to hypotheses must include a researcher's explanation.</p>${indicators.length?indicators.map(n=>rowHTML(n)).join(''):'<div class="empty">No indicators imported yet.</div>'}</div>`;}
function renderHypotheses(a){const h=state.graph.nodes.filter(n=>n.kind==='hypothesis');const all=a.claimSummaries;$('#panelHypotheses').innerHTML=viewHead('Hypothesis comparison','Compare competing propositions and identify under-documented or contested links. This is not a causal estimator.')+
`<div class="grid2">${h.length?h.map(n=>{const k=all.find(s=>s.id===n.id);return `<div class="panel"><div class="panel-header"><h4>${esc(n.title)}</h4>${tag('hypothesis')}</div><p class="mini">${esc(n.description||'Research hypothesis awaiting examination.')}</p><div class="statline"><span>Supportive links</span><strong>${k?.positive||0}</strong></div><div class="statline"><span>Challenging links</span><strong>${k?.negative||0}</strong></div><div class="statline"><span>Context links</span><strong>${k?.context||0}</strong></div><div class="statline"><span>Located excerpts</span><strong>${k?.documented||0}</strong></div><div class="notice ${k?.documented?'info':'alert'}">${k?.documented?'Links recorded for researcher review; factual accuracy not independently verified.':'No located evidence yet. Do not infer support from this graph.'}</div></div>`}).join(''):'<div class="panel"><div class="empty">Create competing hypotheses in the Evidence desk to compare them.</div></div>'}</div><div class="panel"><h4>Methodological caution</h4><p class="small-info">Simple counts are a discovery aid only. A single high-quality study might outweigh many weak or duplicated claims. No automatic validity or evidence-weight inference is performed. Mixed evidence is flagged rather than silently resolved.</p><button class="btn" data-goto="desk">Add or link a hypothesis →</button></div>`;}
function renderAudit(a){const counts=a.counts;const all=a.claimSummaries;$('#panelAudit').innerHTML=viewHead('Quality audit','Transparent documentation diagnostics, missing-source review and contradiction signals. No truth or safety certification.')+
`<div class="grid3"><div class="tile"><div class="num">${a.completeness}%</div><h5>Excerpt + locator coverage</h5><p>Share of links with a nonempty excerpt and source locator.</p></div><div class="tile"><div class="num">${a.unresolved.length}</div><h5>Incomplete links</h5><p>Links missing the documented passage or its exact location.</p></div><div class="tile"><div class="num">${a.orphans.length}</div><h5>Unconnected nodes</h5><p>Records not yet linked to the research network.</p></div></div><div class="panel" style="margin-top:14px"><h4>Documentation coverage</h4><div class="progress" aria-label="Coverage ${a.completeness} percent"><span style="width:${a.completeness}%"></span></div><p class="mini">This is documentation completeness, not research correctness or strength of evidence.</p></div><div class="panel"><div class="panel-header"><h4>Audit flags</h4><span class="pill ${a.flags.length?'alert':'good'}">${a.flags.length} items</span></div>${a.flags.length?a.flags.map(f=>`<div class="node-row"><div class="flex">${tag(f.kind)}<strong>${esc(f.text)}</strong></div><button class="btn sm subtle" data-select-node="${esc(f.nodeId)}">Review related node →</button></div>`).join(''):'<div class="notice info">No missing-documentation or mixed-relation flags detected. This does not validate the underlying research.</div>'}</div><div class="panel"><h4>Source audit</h4><div class="table-wrap"><table><thead><tr><th>Statement</th><th>Supports</th><th>Challenges</th><th>Located</th><th>Distinct upstream nodes</th></tr></thead><tbody>${all.map(n=>`<tr><td>${esc(n.title)}</td><td>${n.positive}</td><td>${n.negative}</td><td>${n.documented}</td><td>${n.sourceCount}</td></tr>`).join('')}</tbody></table></div></div>`;}
function renderExports(a){$('#panelExports').innerHTML=viewHead('Export & methods','Download a fully portable research session or transparent evidence and graph exports.')+
`<div class="panel"><h4>Research data exports</h4><div class="grid2"><button class="btn primary" data-export="json">↓ Complete session (.json)</button><button class="btn" data-export="md">↓ Research brief (.md)</button><button class="btn" data-export="nodes">↓ Node registry (.csv)</button><button class="btn" data-export="edges">↓ Source evidence (.csv)</button><button class="btn" data-export="graphml">↓ Network for Gephi (.graphml)</button></div><div class="notice info">Download your session to keep it. The app deliberately does not upload notes or browser documents to an EvidenceGraph server.</div></div><div class="grid2"><div class="panel"><h4>Restore a saved session</h4><div class="field"><label>Select a previously exported JSON session</label><input type="file" id="sessionFile" accept="application/json,.json"/></div><p class="mini">Imported files are validated. Invalid links, duplicated IDs and unsupported shapes are rejected without replacing your current session.</p><button class="btn" data-action="demo">Restore fictional demo</button></div><div class="panel"><h4>Field notes</h4><div class="field"><label>Research notes (included in JSON and Markdown exports)</label><textarea id="notesInput" rows="7" maxlength="5000" placeholder="Record limitations, inclusion criteria, and open questions...">${esc(state.note)}</textarea></div><button class="btn primary" data-action="save-notes">Save notes in current session</button><p class="filehint">Notes remain in memory until exported. Closing this tab without downloading the session loses changes.</p></div></div><div class="panel"><h4>Research integrity rules</h4><div class="grid2"><div class="tile"><h5>01 · Source metadata</h5><p>Crossref records identify publications. A DOI or title never automatically establishes what the study concludes.</p></div><div class="tile"><h5>02 · Located passages</h5><p>Supporting and challenging connections require researcher-entered excerpts and locators. Review status is explicit.</p></div><div class="tile"><h5>03 · Official indicators</h5><p>World Bank figures preserve observed years and values; they are not a substitute for causal identification.</p></div><div class="tile"><h5>04 · Fictional examples</h5><p>Demo records are fabricated to teach the workflow and carry synthetic provenance labels.</p></div><div class="tile"><h5>05 · Privacy and security</h5><p>No secret keys, no local browser storage, no server database. External API searches send only the requested query.</p></div><div class="tile"><h5>06 · Reproducibility</h5><p>JSON round trips, CSV registers, GraphML interoperability, and tested deterministic audit logic.</p></div></div></div>`;}
// Navigation and delegated controls.
document.querySelectorAll('[data-view]').forEach(b=>b.addEventListener('click',()=>setView(b.dataset.view)));
$('#saveSessionHeader').addEventListener('click',()=>exportData('json'));
$('#application').addEventListener('click',async e=>{const n=e.target.closest('[data-select-node]');if(n){state.selected=n.dataset.selectNode;setView('network');return}const g=e.target.closest('[data-goto]');if(g){setView(g.dataset.goto);return}const ex=e.target.closest('[data-export]');if(ex){exportData(ex.dataset.export);return}const rm=e.target.closest('[data-remove-link]');if(rm){if(!window.confirm('Remove this evidence link from the current session?'))return;updateState(()=>{state.graph.links=state.graph.links.filter(l=>l.id!==rm.dataset.removeLink)},'Connection removed from this in-memory session.');return}const ar=e.target.closest('[data-add-result]');if(ar){const item=state.results[Number(ar.dataset.addResult)];if(!item)return;updateState(()=>{if(item.doi&&state.graph.nodes.some(n=>n.kind==='publication'&&n.doi===item.doi))throw new Error('A publication with this DOI is already in your library.');state.graph.nodes.push(structuredClone(item));state.selected=item.id;},'Publication metadata added. Evidence still requires a researcher-entered excerpt.');return}const act=e.target.closest('[data-action]');if(act){if(act.dataset.action==='demo'){if(!window.confirm('Replace the current in-memory session with the fictional demonstration? Export changes first if needed.'))return;state.graph=structuredClone(DEMO.graph);state.note=DEMO.notes;state.demo=true;state.source='fictional demo';state.selected='c_access';state.path=[];state.results=[];render();setFlash('Fictional demonstration restored. All example values and papers are synthetic.');}
if(act.dataset.action==='trace-path'){const from=$('#traceFrom')?.value,to=$('#traceTo')?.value;state.path=shortestPath(state.graph,from,to);render();setFlash(state.path.length?`Research path found with ${state.path.length-1} link(s). Connectivity is not proof.`:'No connection found between these nodes.',!state.path.length);}
if(act.dataset.action==='reset'){if(!window.confirm('Start a new empty session? Export any changes first.'))return;state.graph={nodes:[],links:[]};state.note='';state.demo=false;state.source='manual research session';state.selected='';state.results=[];render();setFlash('Empty session started. Existing Hugging Face projects are unaffected.');}
if(act.dataset.action==='save-notes'){state.note=$('#notesInput')?.value||'';setFlash('Notes retained in memory. Export JSON to save them to your computer.');}
return;}});
$('#application').addEventListener('keydown',e=>{if((e.key==='Enter'||e.key===' ')&&e.target.matches('[data-select-node]')&&e.target.tagName.toLowerCase()==='g'){e.preventDefault();state.selected=e.target.dataset.selectNode;setView('network');}});
$('#application').addEventListener('submit',async e=>{const form=e.target;e.preventDefault();const f=Object.fromEntries(new FormData(form));try{
if(form.id==='nodeForm'){const title=clean(f.title,260);if(!title)throw new Error('Enter a statement.');updateState(()=>{const n={id:uniqueId(f.kind==='claim'?'c':'h'),kind:f.kind,title,description:clean(f.description,1500),origin:'Researcher-entered',dateAdded:stamp()};state.graph.nodes.push(n);state.selected=n.id},'Research statement added. It still needs located evidence.');}
if(form.id==='linkForm'){if(f.from===f.to)throw new Error('A statement cannot link to itself.');if(!clean(f.excerpt)||!clean(f.locator))throw new Error('An excerpt and a precise locator are required.');updateState(()=>{state.graph.links.push({id:uniqueId('e'),from:f.from,to:f.to,relation:f.relation,excerpt:clean(f.excerpt,1800),locator:clean(f.locator,220),review:f.review,origin:'Researcher-entered',dateAdded:stamp()});state.selected=f.to},'Located evidence link saved. Relation is a research judgment, not automatic verification.');}
if(form.id==='manualPubForm'){const title=clean(f.title,260),doi=f.doi?doiId(f.doi):'';if(!title)throw new Error('Enter a publication title.');if(f.doi&&!doi)throw new Error('The DOI is invalid. Check its format.');if(doi&&state.graph.nodes.some(n=>n.doi===doi))throw new Error('Publication DOI already added.');updateState(()=>{const n={id:uniqueId('p'),kind:'publication',title,doi,url:doiLink(doi),year:f.year?Number(f.year):null,authors:clean(f.authors,220).split(';').map(x=>x.trim()).filter(Boolean),origin:'User-provided metadata',scope:'unverified bibliography record',dateAdded:stamp()};state.graph.nodes.push(n);state.selected=n.id},'Publication metadata saved. Verify it against the original work.');}
if(form.id==='manualIndicatorForm'){const value=Number(f.value),year=Number(f.year);if(!Number.isFinite(value)||year<1800||year>2200)throw new Error('Enter a numeric value and a valid observed year.');if(f.url&&!validateUrl(f.url))throw new Error('Indicator URL must begin with http:// or https://');updateState(()=>{const n={id:uniqueId('i'),kind:'indicator',title:clean(f.title,200),value,year,unit:clean(f.unit,35),url:validateUrl(f.url),origin:'User-supplied indicator (not independently verified)',scope:'descriptive observation',dateAdded:stamp()};state.graph.nodes.push(n);state.selected=n.id;},'Indicator saved with user-supplied provenance.');}
if(form.id==='crossrefForm'){const query=clean(f.query,140);state.query=query;state.queryError='';state.searching=true;render();try{const ctrl=new AbortController();const tm=setTimeout(()=>ctrl.abort(),16000);let result;try{const isDoi=doiId(query);const url=isDoi?`${PUBLICATION_API}/${encodeURIComponent(isDoi).replace(/%2F/ig,'/')}`:`${PUBLICATION_API}?query.bibliographic=${encodeURIComponent(query)}&rows=7&select=DOI,title,author,published,issued,created,container-title,URL`;const res=await fetch(url,{signal:ctrl.signal,headers:{Accept:'application/json'}});if(!res.ok)throw new Error(`Crossref HTTP ${res.status}`);result=await res.json();}finally{clearTimeout(tm)}const items=Array.isArray(result?.message?.items)?result.message.items:[result.message];state.results=items.map(x=>{try{return parseCrossref(x)}catch{return null}}).filter(Boolean);if(!state.results.length)throw new Error('No usable bibliography results. Try a broader topic or manual entry.');setFlash(`Crossref returned ${state.results.length} metadata records. Review before using.`);}catch(error){state.queryError=error?.name==='AbortError'?'Search timed out. Try again later or enter metadata manually.':`Crossref unavailable: ${error.message}. You can add records manually.`;state.results=[];setFlash(state.queryError,true)}finally{state.searching=false;render();}}
if(form.id==='wbForm'){state.fetching=true;state.indicatorError='';render();try{const {country,indicator}=f;const url=`${WB_API}/country/${country}/indicator/${indicator}?format=json&per_page=80&date=2005:2026`;const ctrl=new AbortController();const tm=setTimeout(()=>ctrl.abort(),16000);let json;try{const res=await fetch(url,{signal:ctrl.signal});if(!res.ok)throw new Error(`World Bank HTTP ${res.status}`);json=await res.json();}finally{clearTimeout(tm)}const n=parseWorldBank(json,{country,indicator});updateState(()=>{const match=state.graph.nodes.find(x=>x.kind==='indicator'&&x.code===n.code&&x.country===n.country&&x.year===n.year);if(match)throw new Error('This indicator/country/year combination is already in the network.');state.graph.nodes.push(n);state.selected=n.id;},`Official World Bank observation imported (year ${n.year}). Indicators do not establish causal effects.`);}catch(error){state.indicatorError=error?.name==='AbortError'?'World Bank request timed out.':'Could not import an official observation: '+error.message;setFlash(state.indicatorError,true)}finally{state.fetching=false;render();}}
}catch(error){setFlash(error.message,true)}});
$('#application').addEventListener('change',async e=>{if(e.target.id==='sessionFile'){const file=e.target.files?.[0];if(!file)return;try{if(file.size>2_000_000)throw new Error('Session JSON is too large (2 MB limit).');const obj=JSON.parse(await file.text());const loaded=normalizeSession(obj);const source=typeof obj.source==='string'?clean(obj.source,100):'restored JSON session';if(!window.confirm('Replace this in-memory session with the selected validated JSON?'))return;state.graph=loaded.graph;state.note=loaded.notes;state.demo=Boolean(obj.demo||state.graph.nodes.some(n=>n.origin==='SYNTHETIC DEMO'));state.source=source;state.selected=state.graph.nodes[0]?.id||'';render();setFlash('Validated JSON research session restored. Review all imported provenance.');}catch(error){setFlash('Session import rejected: '+error.message,true)}finally{e.target.value=''}}});
render();