diff --git a/assets/app.js b/assets/app.js index 4df66e6a..c7840361 100644 --- a/assets/app.js +++ b/assets/app.js @@ -747,18 +747,18 @@ function formatDetailRow(rowData) { function initDataTable(data) { const tableData = data.map(row => [ null, - { display: truncateText(row.title_o || row.author_o, 50), search: `${row.title_o || ''} ${row.author_o || ''} ${row.journal_o || ''}` }, + { display: truncateText(row.title_o || row.author_o, 50), search: `${row.title_o || ''} ${searchAuthorNames(row.author_o)} ${row.journal_o || ''}` }, row.year_o || '-', - { display: truncateText(row.title_r || row.author_r, 50), search: `${row.title_r || ''} ${row.author_r || ''} ${row.journal_r || ''}` }, + { display: truncateText(row.title_r || row.author_r, 50), search: `${row.title_r || ''} ${searchAuthorNames(row.author_r)} ${row.journal_r || ''}` }, row.year_r || '-', - { display: getOutcomeBadgeShort(row.outcome), search: `${row.outcome || ''} ${row.outcome_quote || ''}` }, + { display: getOutcomeBadgeShort(row.outcome), search: row.outcome_quote || '' }, truncateText(row.type, 15) || '-', { display: formatDOI(row.doi_o, true), search: row.doi_o || '' }, - { display: formatUrlOrDoiShort(row.url_r, row.doi_r), search: `${row.url_r || ''} ${row.doi_r || ''}` } + { display: formatUrlOrDoiShort(row.url_r, row.doi_r), search: row.doi_r || '' } ]); dataTable = $('#flora-table').DataTable({ - data: tableData, responsive: false, autoWidth: false, pageLength: 25, dom: 'lrtip', + data: tableData, responsive: false, autoWidth: false, deferRender: true, pageLength: 25, dom: 'lrtip', lengthMenu: [[10, 25, 50, 100, -1], [10, 25, 50, 100, "All"]], order: [[4, 'desc']], language: { search: "Search:", searchPlaceholder: "Filter studies (searches full references)...", zeroRecords: "No matching reference pairs", emptyTable: "No matching reference pairs" }, @@ -773,7 +773,7 @@ function initDataTable(data) { createdRow: (row, d, dataIndex) => { $(row).attr('data-index', dataIndex); row.querySelector('.study-details').setAttribute('aria-label', 'Show evidence for ' + (data[dataIndex].title_o || 'original report')); } }); - dataTable.on('order.dt', () => { if (bmInitialized) updateBrowseResults(); syncBrowseUrl(); }); + dataTable.on('order.dt', () => { if (bmInitialized) updateBrowseResults(false); syncBrowseUrl(); }); $('#flora-table tbody').on('click', 'button.study-details', function() { const tr = $(this).closest('tr'); @@ -787,12 +787,19 @@ function initDataTable(data) { // ===== Browse mobile ===== const BM_PAGE_SIZE = 20; +const bmSortCollator = new Intl.Collator(undefined, {numeric:true}); let bmFiltered = []; let bmPage = 0; let bmInitialized = false; +const bmSearchText = new WeakMap(); function bmSearchableText(row) { - return [row.title_o, row.author_o, row.journal_o, row.year_o, row.doi_o, - row.title_r, row.author_r, row.journal_r, row.year_r, row.doi_r, row.url_r, - row.outcome, row.outcome_quote, row.type].filter(Boolean).join(' | ').toLowerCase(); + if (!bmSearchText.has(row)) { + // Keep quotations searchable, but omit raw outcome values and non-DOI URLs. + const urlDoi = /^https?:\/\/(?:dx\.)?doi\.org\//i.test(row.url_r || '') ? row.url_r : ''; + bmSearchText.set(row, normalizedQuery([row.title_o, searchAuthorNames(row.author_o), row.journal_o, row.year_o, row.doi_o, + row.title_r, searchAuthorNames(row.author_r), row.journal_r, row.year_r, row.doi_r, urlDoi, + row.outcome_quote, row.type].filter(Boolean).join(' | '))); + } + return bmSearchText.get(row); } function bmHref(doi, url) { return doi ? safeHref(String(doi).startsWith('http') ? doi : 'https://doi.org/' + encodeURI(doi)) : safeHref(url); } function bmAuthorYear(authorData, year) { const a = shortAuthors(authorData); const y = year ? `(${year})` : ''; return [a, y].filter(Boolean).join(' '); } @@ -851,30 +858,52 @@ function setupBrowseMobile(data) { const prev = document.getElementById('bm-prev'); const next = document.getElementById('bm-next'); let debounceTimer; - input.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => bmApplySearch(input.value), 150); }); + input.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => bmApplySearch(input.value), 200); }); prev.addEventListener('click', () => { if (bmPage > 0) { bmPage--; bmRender(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); } }); next.addEventListener('click', () => { bmPage++; bmRender(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); }); input.value = browseQuery; bmApplySearch(browseQuery); } function normalizedQuery(query) { - return String(query || '').toLowerCase().replace(/https?:\/\/(?:dx\.)?doi\.org\//g, '').replace(/\bdoi:\s*/g, '').trim(); + return String(query || '').toLowerCase().normalize('NFD').replace(/\p{M}/gu, '') + .replace(/[‘’]/g, "'").replace(/[“”]/g, '"') + .replace(/https?:\/\/(?:dx\.)?doi\.org\//g, '').replace(/\bdoi:\s*/g, '').replace(/\s+/g, ' ').trim(); +} +// Search all names, without indexing Crossref keys, ORCIDs or affiliations. +function searchAuthorNames(raw) { + if (!raw) return ''; + try { + const authors = typeof raw === 'string' ? JSON.parse(raw) : raw; + const name = a => typeof a === 'string' ? a : a && (a.name || [a.given, a.family].filter(Boolean).join(' ')) || ''; + return (Array.isArray(authors) ? authors : [authors]).map(name).join(', '); + } catch { return String(raw); } +} +let lastSearchQuery, lastSearchTokens = []; +function searchTokens(query) { + if (query !== lastSearchQuery) { + lastSearchQuery = query; + lastSearchTokens = (normalizedQuery(query).match(/"[^"\n]+"|\S+/g) || []).map(token => token.replace(/^"|"$/g, '')); + } + return lastSearchTokens; +} +function matchesSearchTokens(text, tokens) { + return tokens.every(token => text.includes(token)); } function matchesBrowseQuery(row, query) { - const text = bmSearchableText(row); - const tokens = normalizedQuery(query).match(/"[^"\n]+"|\S+/g) || []; - return tokens.every(token => text.includes(token.replace(/^"|"$/g, ''))); + const tokens = searchTokens(query); + return !tokens.length || matchesSearchTokens(bmSearchableText(row), tokens); } let updatingBrowse = false; -function updateBrowseResults() { +function updateBrowseResults(redraw = true) { if (updatingBrowse) return; updatingBrowse = true; bmFiltered = getChartData(); if (dataTable) { const [column, direction] = dataTable.order()[0] || [4, 'desc']; const field = {1:'title_o',2:'year_o',3:'title_r',4:'year_r',5:'outcome',6:'type',7:'doi_o',8:'doi_r'}[column] || 'year_r'; - bmFiltered.sort((a,b) => String(a[field] || '').localeCompare(String(b[field] || ''), undefined, {numeric:true}) * (direction === 'desc' ? -1 : 1)); - dataTable.draw(false); + bmFiltered.sort((a,b) => bmSortCollator.compare(String(a[field] || ''), String(b[field] || '')) * (direction === 'desc' ? -1 : 1)); + // order.dt also fires during a draw: refresh cards/charts without a second draw. + if (redraw) dataTable.draw(false); } bmPage = 0; if (bmInitialized) bmRender(); diff --git a/assets/citation-impact.js b/assets/citation-impact.js index 4cbc086f..5835e698 100644 --- a/assets/citation-impact.js +++ b/assets/citation-impact.js @@ -70,6 +70,29 @@ }); } + function buildSearchIndex(index) { + // The citation snapshot's display names stop after six authors. The + // already-loaded FLoRA pairs retain full names; collect all records for + // each original and keep the snapshot names as a fallback. + const authorsByDoi = new Map(); + for (const row of fullRowData) { + const doi = normalizeDoi(row.doi_o); + const names = searchAuthorNames(row.author_o); + if (!doi || !names) continue; + if (!authorsByDoi.has(doi)) authorsByDoi.set(doi, new Set()); + authorsByDoi.get(doi).add(names); + } + return index.map(s => { + // Denominator is citations since the first replication, not lifetime + // citations — citing works published before any replication existed + // could never have co-cited one. + const cocit_prop = s.n_citations_post_first_rep > 0 ? s.n_cocitations / s.n_citations_post_first_rep : 0; + const authors = [...(authorsByDoi.get(normalizeDoi(s.doi)) || [])]; + const searchText = normalizedQuery([s.title, searchAuthorNames(s.author), ...authors, s.year, s.venue, s.doi].filter(Boolean).join(' | ')); + return { ...s, cocit_prop, searchText }; + }); + } + async function init() { if (CI.loaded || CI.loading) return; CI.loading = true; @@ -83,13 +106,7 @@ if (!metaRes.ok || !aggRes.ok || !idxRes.ok) { showPlaceholder(); return; } CI.meta = await metaRes.json(); CI.agg = await aggRes.json(); - CI.index = (await idxRes.json()).map(s => { - // Denominator is citations since the first replication, not lifetime - // citations — citing works published before any replication existed - // could never have co-cited one. - const cocit_prop = s.n_citations_post_first_rep > 0 ? s.n_cocitations / s.n_citations_post_first_rep : 0; - return { ...s, cocit_prop }; - }); + CI.index = buildSearchIndex(await idxRes.json()); // Optional: ETWFE results (may not exist on first run) try { @@ -386,14 +403,12 @@ } function renderTable() { - const q = (document.getElementById('search-input').value || '').toLowerCase(); + const q = document.getElementById('search-input').value || ''; + const tokens = searchTokens(q); const outFilter = document.getElementById('filter-outcome').value; let rows = CI.index.filter(s => { - if (q) { - const hay = `${s.title} ${s.author} ${s.doi}`.toLowerCase(); - if (!hay.includes(q)) return false; - } + if (!matchesSearchTokens(s.searchText, tokens)) return false; if (outFilter && !(s.outcome_mix && s.outcome_mix[outFilter])) return false; return true; }); @@ -416,6 +431,10 @@ }); const total = rows.length; + const empty = document.getElementById('ci-search-empty'); + empty.hidden = total > 0; + document.getElementById('ci-search-empty-message').textContent = q.trim() ? `No originals match “${q.trim()}” with the current outcome selection.` : 'No originals match the current outcome selection.'; + document.getElementById('ci-clear-search').hidden = !q.trim(); const pages = Math.max(1, Math.ceil(total / CI.perPage)); if (CI.page > pages) CI.page = 1; const start = (CI.page - 1) * CI.perPage; @@ -438,6 +457,7 @@ }).join(''); const pag = document.getElementById('pagination'); + if (!total) { pag.innerHTML = ''; return; } const winSize = 5; const winStart = Math.max(1, CI.page - Math.floor(winSize / 2)); const winEnd = Math.min(pages, winStart + winSize - 1); @@ -604,7 +624,12 @@ CI.outcome = btn.dataset.value; renderAggregate(); }); }); - document.getElementById('search-input').addEventListener('input', () => { CI.page = 1; renderTable(); }); + const input = document.getElementById('search-input'); + let searchTimer; + input.addEventListener('input', () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => { CI.page = 1; renderTable(); }, 200); }); + document.getElementById('ci-clear-search').addEventListener('click', () => { + clearTimeout(searchTimer); input.value = ''; input.dispatchEvent(new Event('input')); input.focus(); + }); document.getElementById('filter-outcome').addEventListener('change', () => { CI.page = 1; renderTable(); }); function sortBy(col) { if (CI.sortCol === col) { @@ -708,6 +733,13 @@ // Lazy-load when the user opens the tab document.getElementById('citation-tab').addEventListener('shown.bs.tab', ensureInit); + // A DOI deep link can load citation data before the main CSV finishes. + document.addEventListener('flora-ready', () => { + if (!CI.index) return; + CI.index = buildSearchIndex(CI.index); + if (CI.loaded) renderTable(); + }); + setupStudyTypeSelect('ci-study-type', kind => { ciKind = kind; renderCiKindGate(kind); }); // Open a study popup straight away if arrived via /citations/?doi=… diff --git a/index.html b/index.html index 9245e0d1..0bee7b33 100644 --- a/index.html +++ b/index.html @@ -639,7 +639,7 @@

Browse all originals

- +
+
diff --git a/tests/browser.test.cjs b/tests/browser.test.cjs index 9d81f81d..29a1344d 100644 --- a/tests/browser.test.cjs +++ b/tests/browser.test.cjs @@ -26,6 +26,18 @@ async function page(query, width=1280, setup){ return p; } async function browseRows(p){return p.evaluate(()=>getChartData().map(r=>[r.title_o,r.title_r,r.outcome]));} +async function citationSearch(p,q){ + await p.evaluate(()=>{ + window.citationRenderFinished=false; + const observer=new MutationObserver(()=>{observer.disconnect();window.citationRenderFinished=true;}); + observer.observe(document.querySelector('#originals-table tbody'),{childList:true}); + // The message is updated even when both the previous and next result sets are empty. + observer.observe(document.getElementById('ci-search-empty-message'),{childList:true}); + }); + await p.fill('#search-input',q); + await p.waitForFunction(()=>window.citationRenderFinished); + return p.locator('#originals-table tbody tr[data-doi]').evaluateAll(rows=>rows.map(r=>r.dataset.doi)); +} test('desktop/mobile search, DOI URLs, empty results, shared view and evidence agree',async()=>{ const p=await page('?tab=browse&kind=replication&q=power+posing',1280,p=>p.route('**/data/flora.csv',route=>route.fulfill({path:'tests/fixtures/browse.csv',contentType:'text/csv'}))); await p.waitForFunction(()=>getChartData().length===4); @@ -346,3 +358,100 @@ test('narrow citation timelines number replication events to match the list',asy assert.deepEqual(marks,list); assert.deepEqual(p.errors,[]);await p.close(); }); + +test('Browse and Citation Impact share accent, quote, token and DOI search rules',async()=>{ + const p=await page('?tab=browse'); + async function browseSearch(q){ + await p.fill('#browse-mobile-input',q); + await p.waitForFunction(q=>browseQuery===q,q); + return p.evaluate(()=>getChartData().map(r=>r.doi_o)); + } + for(const [q,doi] of [['Bem 2011','10.1037/a0021524'],['Strack 1988','10.1037/0022-3514.54.5.768']]){ + const rows=await browseSearch(q);assert.ok(rows.length>0,q);assert.ok(rows.includes(doi),q); + } + for(const [a,b] of [['Müller','Muller'],["O'Brien",'O’Brien'],['"power posing"','“power posing”']]){ + const rows=await browseSearch(a);assert.ok(rows.length>0,a);assert.deepEqual(await browseSearch(b),rows,b); + } + for(const q of ['family','given','sequence']) assert.ok((await browseSearch(q)).length<100,`${q} must not match author JSON keys`); + // Search every author's name, including given-only and group names, without metadata. + assert.deepEqual(await p.evaluate(()=>{ + const row={author_o:JSON.stringify([{given:'Zoë',family:'Müller',ORCID:'metadata-only'},{given:'Søren'},{name:'Research collective'}]),outcome:'raw_outcome_code',url_r:'https://url-only.invalid',outcome_quote:'A distinctive quotation'}; + return ['Zoe Muller','Søren','Research collective','metadata-only','raw_outcome_code','url-only.invalid','distinctive quotation'].map(q=>matchesBrowseQuery(row,q)); + }),[true,true,true,false,false,false,true]); + await p.click('#citation-tab');await p.waitForSelector('.timeline-open'); + for(const [q,doi] of [['Bem 2011','10.1037/a0021524'],['Strack 1988','10.1037/0022-3514.54.5.768'],['"Feeling the Future"','10.1037/a0021524'],['“Feeling the Future”','10.1037/a0021524'],['https://doi.org/10.1037/a0021524','10.1037/a0021524'],['doi: 10.1037/a0021524','10.1037/a0021524']]){ + assert.ok((await citationSearch(p,q)).includes(doi),q); + } + for(const [a,b] of [['Müller','Muller'],["O'Brien",'O’Brien']]){ + const rows=await citationSearch(p,a);assert.ok(rows.length>0,a);assert.deepEqual(await citationSearch(p,b),rows,b); + } + assert.deepEqual(p.errors,[]);await p.close(); +}); +test('citation search waits for rendering even when the debounce fires late',async()=>{ + const p=await page('?tab=citations');await p.waitForSelector('.timeline-open'); + await p.evaluate(()=>{ + const original=window.setTimeout; + window.setTimeout=(fn,delay,...args)=>original(fn,delay===200?800:delay,...args); + }); + assert.deepEqual(await citationSearch(p,'Bem 2011'),['10.1037/a0021524']); + // Both an empty render and a second empty render must finish before the helper returns. + assert.deepEqual(await citationSearch(p,'zz-no-original'),[]); + assert.deepEqual(await citationSearch(p,'zz-another-missing-original'),[]); + assert.deepEqual(p.errors,[]);await p.close(); +}); +test('Citation Impact searches full author lists, including after an early DOI deep link',async()=>{ + const doi='10.1037/a0021524', fallbackDoi='10.1037/0022-3514.54.5.768'; + const index=JSON.parse(fs.readFileSync('data/originals_index.json')); + const authors=['Firstauthor','Secondauthor','Thirdauthor','Fourthauthor','Fifthauthor','Sixthauthor','Lastauthor'].map((family,i)=>({family,given:i===6?'Zoë':'A',ORCID:'metadata-only'})); + const row={doi_o:'https://doi.org/10.1037/A0021524',title_o:'Feeling the Future',author_o:JSON.stringify(authors),journal_o:'Journal of Personality and Social Psychology',year_o:2011,doi_r:'10.test/replication',title_r:'Replication',author_r:'Other researcher',year_r:2015,type:'replication',outcome:'failed'}; + const csv=[Object.keys(row),Object.values(row)].map(fields=>fields.map(v=>'"'+String(v).replace(/"/g,'""')+'"').join(',')).join('\n'); + const compact=[{...index.find(s=>s.doi===doi),author:authors.slice(0,6).map(a=>`${a.family}, A.`).join('; ')+', … (+1)'},{...index.find(s=>s.doi===fallbackDoi),author:'Fallbackauthor, F.'}]; + for(const early of [false,true]){ + const p=await page('?tab=citations'+(early?'&doi='+encodeURIComponent(doi):''),1280,async p=>{ + await p.route('**/data/originals_index.json',route=>route.fulfill({json:compact})); + await p.route('**/data/flora.csv',async route=>{ + // Force the citation index to load before the full author records on a deep link. + if(early) await p.waitForSelector('#ci-modal:not([hidden])'); + await route.fulfill({contentType:'text/csv',body:csv}); + }); + }); + await p.waitForSelector('.timeline-open'); + if(early) await p.keyboard.press('Escape'); + assert.deepEqual(await citationSearch(p,'Zoe Lastauthor'),[doi],`seventh author, early=${early}`); + assert.deepEqual(await citationSearch(p,'Firstauthor'),[doi]); + assert.deepEqual(await citationSearch(p,'metadata-only'),[]); + assert.deepEqual(await citationSearch(p,'Fallbackauthor'),[fallbackDoi]); + assert.match(await p.locator('#search-input').getAttribute('placeholder'),/journal/i); + assert.deepEqual(p.errors,[]);await p.close(); + } +}); +test('search input is debounced, Show all draws once and Citation Impact offers empty-state recovery',async()=>{ + const p=await page('?tab=browse'); + // deferRender creates only the first page until more rows are requested. + assert.ok(await p.evaluate(()=>dataTable.rows().nodes().length{window.searchDraws=0;dataTable.on('draw.dt',()=>searchDraws++);dataTable.page.len(-1).draw();}); + assert.equal(await p.evaluate(()=>searchDraws),1); + assert.equal(await p.locator('#flora-table tbody tr').count(),await p.evaluate(()=>fullRowData.length)); + const immediate=await p.evaluate(()=>{ + searchDraws=0;const el=document.getElementById('browse-mobile-input'); + for(const q of ['B','Bem','Bem 2011']){el.value=q;el.dispatchEvent(new Event('input'));} + return searchDraws; + }); + assert.equal(immediate,0);await p.waitForFunction(()=>browseQuery==='Bem 2011'); + assert.equal(await p.evaluate(()=>searchDraws),1); + await p.click('#citation-tab');await p.waitForSelector('.timeline-open'); + await p.evaluate(()=>{ + window.citationRenders=0;new MutationObserver(()=>citationRenders++).observe(document.querySelector('#originals-table tbody'),{childList:true}); + const el=document.getElementById('search-input');for(const q of ['z','zz','zz-no-such-original']){el.value=q;el.dispatchEvent(new Event('input'));} + }); + assert.ok(await p.locator('.timeline-open').count()>0); + await p.waitForSelector('#ci-search-empty:not([hidden])'); + assert.equal(await p.evaluate(()=>citationRenders),1); + assert.match(await p.locator('#ci-search-empty').innerText(),/No originals match/); + assert.equal(await p.locator('#pagination button').count(),0); + await p.locator('#ci-clear-search').focus();await p.keyboard.press('Enter'); + await p.waitForSelector('.timeline-open');assert.ok(await p.locator('#ci-search-empty').isHidden()); + assert.equal(await p.inputValue('#search-input'),'');assert.equal(await p.evaluate(()=>document.activeElement.id),'search-input'); + assert.ok(!new URL(p.url()).searchParams.has('search-input')); + assert.deepEqual(p.errors,[]);await p.close(); +});