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 @@