Initial commit: connected-papers / linked-papers app

This commit is contained in:
YannAhlgrim
2026-07-09 12:13:48 +00:00
commit 7d76059d0e
12 changed files with 926 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-input');
const statusEl = document.getElementById('status');
const resultsEl = document.getElementById('search-results');
const resultsList = document.getElementById('results-list');
const graphContainer = document.getElementById('graph-container');
const infoPanel = document.getElementById('info-panel');
const infoContent = document.getElementById('info-content');
let network = null;
function showStatus(msg, isError = false) {
statusEl.textContent = msg;
statusEl.className = isError ? 'error' : 'loading';
}
function clearStatus() {
statusEl.textContent = '';
statusEl.className = '';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatAuthors(authors, maxLen = 3) {
if (!authors || authors.length === 0) return 'Unknown';
const names = authors.map(a => a.name || 'Unknown');
if (names.length > maxLen) {
return names.slice(0, maxLen).join(', ') + ' et al.';
}
return names.join(', ');
}
function showResults(papers) {
resultsList.innerHTML = '';
resultsEl.classList.remove('hidden');
graphContainer.innerHTML = '';
infoPanel.classList.add('hidden');
papers.forEach(paper => {
const li = document.createElement('li');
const authors = formatAuthors(paper.authors);
li.innerHTML = `
<strong>${escapeHtml(paper.title || 'Untitled')}</strong>
<span>${escapeHtml(authors)}${paper.year || 'n.d.'} — Citations: ${paper.citationCount || 0}</span>
`;
li.addEventListener('click', () => {
resultsEl.classList.add('hidden');
loadGraph(paper.paperId);
});
resultsList.appendChild(li);
});
}
async function search(query) {
showStatus('Searching...');
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
clearStatus();
if (data.error) throw new Error(data.error);
if (!data.papers || data.papers.length === 0) {
showStatus('No papers found.', true);
return;
}
showResults(data.papers);
} catch (err) {
showStatus(err.message, true);
}
}
async function loadGraph(paperId) {
showStatus('Loading reference graph...');
try {
const res = await fetch(`/api/graph/${encodeURIComponent(paperId)}`);
const data = await res.json();
clearStatus();
if (data.error) throw new Error(data.error);
renderGraph(data);
} catch (err) {
showStatus(err.message, true);
}
}
function renderGraph(graph) {
graphContainer.innerHTML = '';
infoPanel.classList.add('hidden');
const container = document.createElement('div');
container.id = 'graph';
graphContainer.appendChild(container);
if (graph.nodes.length <= 1) {
container.innerHTML = `
<div class="empty-graph">
<p><strong>No references found in OpenAlex for this paper yet.</strong></p>
<p>This often happens with very recent preprints — OpenAlex hasn't ingested the reference list.</p>
</div>
`;
const nodes = new vis.DataSet(graph.nodes);
const edges = new vis.DataSet(graph.edges);
const options = { nodes: { shape: 'dot', color: '#ff6b6b' } };
network = new vis.Network(container, { nodes, edges }, options);
return;
}
const nodes = new vis.DataSet(graph.nodes);
const edges = new vis.DataSet(graph.edges);
const options = {
nodes: {
shape: 'dot',
scaling: {
min: 10,
max: 60,
label: { enabled: true, min: 12, max: 22 }
},
font: { size: 14, color: '#333' }
},
edges: {
width: 1,
color: { color: '#888', highlight: '#000' },
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
smooth: { type: 'continuous' }
},
groups: {
seed: { color: '#ff6b6b' },
reference: { color: '#45b7d1' }
},
physics: {
stabilization: false,
barnesHut: {
gravitationalConstant: -3000,
centralGravity: 0.3,
springLength: 150,
springConstant: 0.04,
damping: 0.09
}
},
interaction: {
hover: true,
tooltipDelay: 200
}
};
network = new vis.Network(container, { nodes, edges }, options);
network.on('click', (params) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
const node = nodes.get(nodeId);
showNodeInfo(node);
}
});
network.on('doubleClick', (params) => {
if (params.nodes.length > 0) {
const nodeId = params.nodes[0];
loadGraph(nodeId);
}
});
}
function showNodeInfo(node) {
infoPanel.classList.remove('hidden');
infoContent.innerHTML = `
<h3>${escapeHtml(node.label)}</h3>
<p><strong>Authors:</strong> ${escapeHtml(node.authors)}</p>
<p><strong>Year:</strong> ${node.year || 'n.d.'}</p>
<p><strong>Citations:</strong> ${node.citationCount}</p>
<button id="recenter-btn">Re-center graph on this paper</button>
<p><em>Tip: you can also double-click a node to re-center.</em></p>
`;
document.getElementById('recenter-btn').addEventListener('click', () => {
loadGraph(node.id);
});
}
searchForm.addEventListener('submit', (e) => {
e.preventDefault();
const query = searchInput.value.trim();
if (query) search(query);
});
+185
View File
@@ -0,0 +1,185 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
#app {
position: relative;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background: #fff;
padding: 1rem 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
z-index: 20;
}
header h1 {
margin: 0;
font-size: 1.5rem;
}
#search-form {
display: flex;
gap: 0.5rem;
flex: 1;
min-width: 300px;
}
#search-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
button {
padding: 0.5rem 1rem;
background: #0066cc;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background: #0052a3;
}
#status {
padding: 0.5rem 2rem;
font-size: 0.9rem;
min-height: 2rem;
}
#status.loading { color: #0066cc; }
#status.error { color: #cc0000; }
#help {
background: #fff;
padding: 0.75rem 2rem;
border-bottom: 1px solid #eee;
font-size: 0.9rem;
color: #555;
}
#help p {
margin: 0;
}
#search-results {
background: #fff;
padding: 1rem 2rem;
border-bottom: 1px solid #ddd;
max-height: 250px;
overflow-y: auto;
z-index: 20;
}
#search-results.hidden,
#info-panel.hidden {
display: none;
}
#search-results h2 {
margin-top: 0;
font-size: 1rem;
}
#results-list {
list-style: none;
padding: 0;
margin: 0;
}
#results-list li {
padding: 0.75rem;
border-bottom: 1px solid #eee;
cursor: pointer;
}
#results-list li:hover {
background: #f0f8ff;
}
#results-list li strong {
display: block;
margin-bottom: 0.25rem;
}
#results-list li span {
font-size: 0.85rem;
color: #666;
}
main {
flex: 1;
position: relative;
overflow: hidden;
background: #fff;
}
#graph {
width: 100%;
height: 100%;
}
.empty-graph {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: #666;
padding: 2rem;
}
.empty-graph p {
margin: 0.5rem 0;
max-width: 600px;
}
#info-panel {
position: absolute;
top: 80px;
right: 20px;
width: 320px;
max-height: calc(100vh - 120px);
overflow-y: auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 1rem;
z-index: 10;
}
#info-panel h2 {
margin-top: 0;
font-size: 1.1rem;
}
#info-content h3 {
margin-top: 0;
font-size: 1rem;
}
#info-content p {
margin: 0.5rem 0;
font-size: 0.9rem;
}