diff --git a/assets/app.js b/assets/app.js index 16149f7b..592b0218 100644 --- a/assets/app.js +++ b/assets/app.js @@ -773,6 +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('draw.dt', syncBrowseUrl); dataTable.on('order.dt', () => { if (bmInitialized) updateBrowseResults(false); syncBrowseUrl(); }); $('#flora-table tbody').on('click', 'button.study-details', function() { @@ -859,8 +860,8 @@ function setupBrowseMobile(data) { const next = document.getElementById('bm-next'); let debounceTimer; 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' }); }); + prev.addEventListener('click', () => { if (bmPage > 0) { bmPage--; bmRender(); syncBrowseUrl(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); } }); + next.addEventListener('click', () => { bmPage++; bmRender(); syncBrowseUrl(); document.querySelector('.app-main').scrollTo({ top: 0, behavior: 'smooth' }); }); input.value = browseQuery; bmApplySearch(browseQuery); } @@ -924,11 +925,7 @@ document.getElementById('bm-sort')?.addEventListener('change', e => { if (dataTable && direction) dataTable.order([[Number(column), direction]]).draw(); }); function syncBrowseUrl() { - const url = new URL(location.href); - browseQuery ? url.searchParams.set('q', browseQuery) : url.searchParams.delete('q'); - browseKind !== 'all' ? url.searchParams.set('kind', browseKind) : url.searchParams.delete('kind'); - if (dataTable) url.searchParams.set('sort', dataTable.order()[0].join(':')); - history.replaceState(null, '', url); + window.FloraView?.recordState(); } // ===== Browse kind filter ===== @@ -2158,28 +2155,10 @@ const TAB_ID_TO_PARAM = { // tab fires shown.bs.tab, which lazy-loads that tab's content as usual. function applyTabFromUrl() { const tab = (new URLSearchParams(window.location.search).get('tab') || '').toLowerCase(); - if (!tab) return; - const btn = document.getElementById(TAB_PARAM_MAP[tab] || ''); + const btn = document.getElementById(TAB_PARAM_MAP[tab] || 'overview-tab'); if (btn && window.bootstrap) bootstrap.Tab.getOrCreateInstance(btn).show(); } -// Reflect the active tab in the address bar so it stays shareable as the user -// navigates. Fires for both clicks and programmatic shows. -function syncTabToUrl(tabId) { - const name = TAB_ID_TO_PARAM[tabId]; - if (!name) return; - const params = new URLSearchParams(window.location.search); - if (name !== 'citations') params.delete('doi'); // doi only applies to Citation Impact - if (name === 'overview') params.delete('tab'); // keep the home URL clean - else params.set('tab', name); - const qs = params.toString(); - history.replaceState(null, '', new URL('./' + (qs ? '?' + qs : ''), window.location.href).href); -} -Object.keys(TAB_ID_TO_PARAM).forEach(id => { - const btn = document.getElementById(id); - if (btn) btn.addEventListener('shown.bs.tab', () => syncTabToUrl(id)); -}); - // Called directly (not via $(document).ready) so the CSV fetch starts as soon as this // script runs, rather than waiting for the whole document - including later, lazily-used // libraries like Plotly - to finish loading. Safe because this tag sits at the end of diff --git a/assets/chart-accessibility.js b/assets/chart-accessibility.js index 7ea4aa82..004783db 100644 --- a/assets/chart-accessibility.js +++ b/assets/chart-accessibility.js @@ -200,6 +200,10 @@ window.FloraCharts = (() => { const plotHeight = (layout.height || el.clientHeight || 380) - (layout.margin.t || 0) - layout.margin.b; layout.legend={...layout.legend,yanchor:'top',y:-axisSpace/Math.max(100,plotHeight)}; el.style.height = layout.height ? layout.height+'px' : heights.get(id); + // Charts keep hover details, while page swipes and drags never zoom or pan. + layout.dragmode = false; + for (const key of Object.keys(layout)) if (/^[xy]axis\d*$/.test(key)) layout[key] = {...layout[key],fixedrange:true}; + config = {...config,scrollZoom:false,doubleClick:false}; const title = layout.title ? plain(layout.title.text || layout.title) : visibleTitle(el); el.setAttribute('role','img');el.setAttribute('aria-label',title+'. Values in the following data table.'); if (comparisonIds.has(id)) { diff --git a/assets/citation-impact.js b/assets/citation-impact.js index f254bf61..009295e6 100644 --- a/assets/citation-impact.js +++ b/assets/citation-impact.js @@ -486,7 +486,7 @@ ${escapeHtml(entry.doi)}
`; } - async function showStudy(doi) { + async function showStudy(doi, fromDeepLink = false) { const entry = CI.index && CI.index.find(s => s.doi === doi); if (!entry) return; CI.currentDoi = doi; @@ -498,8 +498,9 @@ document.getElementById('ci-modal').hidden = false; setBackgroundInert(true); document.getElementById('ci-modal-close').focus(); - // Reflect the open chart in the address bar so it's directly shareable. - history.replaceState(null, '', citationLink(doi)); + // Add a closable history entry for user opens; keep initial share links in place. + if (fromDeepLink) history.replaceState(null, '', citationLink(doi)); + else history.pushState(null, '', citationLink(doi)); let s; try { @@ -555,18 +556,21 @@ document.querySelectorAll('body > .top-bar, body > main, body > .footer-bar').forEach(el => el.inert = value); } - function closeModal() { + function closeModal(updateUrl = true) { const modal = document.getElementById('ci-modal'); if (modal.hidden) return; modal.hidden = true; setBackgroundInert(false); CI.currentDoi = null; // Drop ?doi= but keep the user on the Citation Impact tab. - const url = new URL(location.href);url.searchParams.delete('doi');url.searchParams.set('tab','citations');history.replaceState(null,'',url); - // Return focus to whatever opened the modal. - if (CI.lastFocus && typeof CI.lastFocus.focus === 'function') CI.lastFocus.focus(); + if (updateUrl) { + const url = new URL(location.href);url.searchParams.delete('doi');history.replaceState(null,'',url); + } + // History restoration focuses the destination pane after switching tabs. + if (updateUrl && CI.lastFocus && typeof CI.lastFocus.focus === 'function') CI.lastFocus.focus(); CI.lastFocus = null; } + document.addEventListener('flora-history', () => closeModal(false)); const TIMELINE_NARROW = 600; // Label style and collision spacing depend on the current chart width. @@ -731,11 +735,12 @@ // study's chart popup. Returns true if a matching study was found. async function openStudyByDoi(doi) { await ensureInit(); + if (normalizeDoi(new URLSearchParams(location.search).get('doi')) !== normalizeDoi(doi)) return false; const matchDoi = findStudyDoi(doi); if (!matchDoi) return false; const tabBtn = document.getElementById('citation-tab'); if (tabBtn && window.bootstrap) bootstrap.Tab.getOrCreateInstance(tabBtn).show(); - await showStudy(matchDoi); + await showStudy(matchDoi, true); return true; } diff --git a/assets/styles.css b/assets/styles.css index c3d566ea..d86acbf7 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -447,7 +447,7 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); } #search-input:focus { outline: none; border-color: var(--color-primary); } #filter-outcome, #sort-by { padding: 0.55rem 0.7rem; border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.9rem; background: var(--color-surface-raised); color: var(--color-text); } -.table-wrap { background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: 8px; overflow: hidden; } +.table-wrap { background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: 8px; overflow-x: auto; } #originals-table { width: 100%; border-collapse: collapse; font-size: 0.92rem; } #originals-table thead { background: var(--color-surface-subtle); } #originals-table th, #originals-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--color-border); } @@ -506,11 +506,13 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); } .modal-backdrop[hidden] { display: none; } .modal-card { background: var(--color-surface-raised); color: var(--color-text); - border-radius: 10px; padding: 1.75rem 2rem; max-width: 1000px; + border-radius: 10px; padding: 0; max-width: 1000px; max-height: calc(100dvh - 80px); overflow-y: auto; width: 100%; position: relative; box-shadow: var(--shadow-lg); } +.modal-toolbar { position: sticky; top: 0; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.5rem 1rem; background: var(--color-surface-raised); border-bottom: 1px solid var(--color-border); } +#ci-modal-body { padding: 0.75rem 1rem; } .modal-close { - position: absolute; top: 12px; right: 16px; background: none; border: none; + min-width: 44px; min-height: 44px; background: none; border: none; font-size: 1.75rem; cursor: pointer; color: var(--color-muted-fg); } .modal-close:hover { color: var(--color-text); } @@ -637,6 +639,9 @@ h1,.card-header,.mc-chart-title {font-family:var(--font-display);} .badge-other,.badge-unknown,.badge-inconclusive {background:#626875!important;color:#fff!important;} .bm-evidence {margin-top:.8rem;} .mobile-navigation {display:none;} +.top-bar.navigation-collapsed .mobile-navigation {display:flex;gap:.5rem;align-items:center;color:white;flex:1;min-width:0;} +.mobile-navigation select {max-width:100%;min-width:0;width:100%;padding:.4rem;background:#333;color:white;border:1px solid #aaa;border-radius:.3rem;} +.top-bar.navigation-collapsed .nav-tabs {display:none;} .coefficient-plot {min-height:340px;margin-top:1rem;} .tab-cite-block {margin:1rem 0;} .tab-cite-block:not([open]) {padding:.6rem 1rem;} diff --git a/assets/view-controls.js b/assets/view-controls.js index 1ba4be3c..305e7534 100644 --- a/assets/view-controls.js +++ b/assets/view-controls.js @@ -1,68 +1,113 @@ /* Shareable filters, exports, and keyboard-visible navigation. */ (() => { - let restoring = false; + let restoring = false, ready = false; function pressed() { document.querySelectorAll('.chip, .trend-filter-btn').forEach(b => b.setAttribute('aria-pressed',String(b.classList.contains('active')))); } const initial = new URL(location.href); - const selectGroups = {'mc-study-type':'mc-kind','ao-study-type':'ao-kind','pubtype-study-type':'pub-kind','ci-study-type':'ci-kind','outcome-chips':'ci-outcome'}; + // A DOI-only share link targets Citation Impact even before its data arrives. + if (!initial.searchParams.has('tab') && initial.searchParams.get('doi')) initial.searchParams.set('tab','citations'); + const selectGroups = {'mc-study-type':['mc-kind','mc-tab','replication'],'ao-study-type':['ao-kind','overlap-tab','replication'],'pubtype-study-type':['pub-kind','pub-tab','replication'],'ci-study-type':['ci-kind','citation-tab','replication'],'outcome-chips':['ci-outcome','citation-tab','all']}; + const controls = {'journal-top-n':['journal-top-n','trends-tab','15'],'rep-journal-top-n':['rep-journal-top-n','trends-tab','15'],'filter-outcome':['filter-outcome','citation-tab',''],'search-input':['ci-q','citation-tab','']}; function recordState() { - if (restoring) return; - const url = new URL(location.href); - for (const [id,key] of Object.entries(selectGroups)) { - const value = document.querySelector('#'+id+' .active')?.dataset.value; - if (value) url.searchParams.set(key,value); + if (restoring || !ready) return; + const url = new URL(location.href), tab = document.querySelector('#floraTabs .active').id; + const put = (key,value,fallback) => { if(String(value)!==String(fallback))url.searchParams.set(key,value); }; + // Remove known state first: a shared URL contains only the active view's choices. + for (const key of ['tab','q','kind','sort','page','page-size','mobile-page','trends-kind','search-input',...Object.values(selectGroups).map(v=>v[0]),...Object.values(controls).map(v=>v[0])]) url.searchParams.delete(key); + put('tab',TAB_ID_TO_PARAM[tab],'overview'); + if (tab !== 'citation-tab') url.searchParams.delete('doi'); + if (tab === 'browse-tab') { + put('q',browseQuery,'');put('kind',browseKind,'all'); + put('sort',dataTable.order()[0].join(':'),'4:desc'); + put('page',dataTable.page()+1,1);put('page-size',dataTable.page.len(),25); + put('mobile-page',bmPage+1,1); } - url.searchParams.set('trends-kind',trendsKind); - for (const id of ['journal-top-n','rep-journal-top-n','filter-outcome','search-input']) { - const el=document.getElementById(id);if(el?.value) url.searchParams.set(id,el.value);else url.searchParams.delete(id); + if (tab === 'trends-tab') put('trends-kind',trendsKind,'replication'); + for (const [id,[key,owner,fallback]] of Object.entries(selectGroups)) { + if(tab===owner) put(key,document.querySelector('#'+id+' .active')?.dataset.value || fallback,fallback); + } + for (const [id,[key,owner,fallback]] of Object.entries(controls)) { + if(tab===owner) put(key,document.getElementById(id).value,fallback); } history.replaceState(null,'',url); pressed(); } - function restore(url) { + window.FloraView = {recordState}; + function restore(url, fromHistory = false) { restoring = true; + const hadOpenTimeline = !document.getElementById('ci-modal').hidden; + if(fromHistory) { + document.dispatchEvent(new Event('flora-history')); + // Back/forward always dismisses a timeline, including its shareable DOI. + url.searchParams.delete('doi'); + } const kinds=['all','replication','reproduction-numerical','reproduction-robustness']; const kind=url.searchParams.get('kind') || 'all';browseKind=kinds.includes(kind)?kind:'all'; browseQuery=url.searchParams.get('q') || '';document.getElementById('browse-mobile-input').value=browseQuery; document.querySelectorAll('.browse-kind-btn').forEach(b=>b.classList.toggle('active',b.dataset.kind===browseKind)); const sort=url.searchParams.get('sort')?.split(':'); - if (sort && /^[1-8]$/.test(sort[0]) && ['asc','desc'].includes(sort[1])) dataTable.order([[Number(sort[0]),sort[1]]]); + dataTable.order(sort && /^[1-8]$/.test(sort[0]) && ['asc','desc'].includes(sort[1]) ? [[Number(sort[0]),sort[1]]] : [[4,'desc']]); + const length=Number(url.searchParams.get('page-size') || 25); + dataTable.page.len([10,25,50,100,-1].includes(length)?length:25); updateBrowseResults(); - for (const [id,key] of Object.entries(selectGroups)) { - const value=url.searchParams.get(key); - const btn=[...document.querySelectorAll('#'+id+' .chip')].find(b=>b.dataset.value===value); + const page=Number(url.searchParams.get('page') || 1); + dataTable.page(Number.isSafeInteger(page) && page>0 ? Math.min(page-1,Math.max(0,dataTable.page.info().pages-1)) : 0).draw('page'); + const mobilePage=Number(url.searchParams.get('mobile-page') || 1); + bmPage=Number.isSafeInteger(mobilePage) && mobilePage>0 ? mobilePage-1 : 0;bmRender(); + for (const [id,[key,,fallback]] of Object.entries(selectGroups)) { + const value=url.searchParams.get(key) || fallback; + const buttons=[...document.querySelectorAll('#'+id+' .chip')]; + const btn=buttons.find(b=>b.dataset.value===value) || buttons.find(b=>b.dataset.value===fallback); if(btn && !btn.classList.contains('active'))btn.click(); } const trend=url.searchParams.get('trends-kind') || 'replication'; - document.querySelectorAll('#trends .trend-filter-btn').forEach(b=>{ - if(b.dataset.kind===trend)b.click(); - }); - for (const id of ['journal-top-n','rep-journal-top-n','filter-outcome','search-input']) { - const el=document.getElementById(id);const value=url.searchParams.get(id); - if(el && value!==null){el.value=value;el.dispatchEvent(new Event(id==='search-input'?'input':'change'));} + const trendButtons=[...document.querySelectorAll('#trends .trend-filter-btn')]; + const trendButton=trendButtons.find(b=>b.dataset.kind===trend) || trendButtons.find(b=>b.dataset.kind==='replication'); + if(trendButton && !trendButton.classList.contains('active'))trendButton.click(); + for (const [id,[key,,fallback]] of Object.entries(controls)) { + const el=document.getElementById(id); + let value=url.searchParams.get(key) ?? (id==='search-input' ? url.searchParams.get('search-input') : null) ?? fallback; + if(el.tagName==='SELECT' && ![...el.options].some(o=>o.value===value))value=fallback; + if(el.value!==value){el.value=value;el.dispatchEvent(new Event(id==='search-input'?'input':'change'));} } history.replaceState(null,'',url); - applyTabFromUrl();pressed();restoring=false; + applyTabFromUrl();pressed();restoring=false;ready=true;recordState(); + if (fromHistory && hadOpenTimeline) { + const pane = document.querySelector('.tab-pane.active'); + pane.setAttribute('tabindex','-1');pane.focus({preventScroll:true}); + } + document.getElementById('mobile-navigation').value=document.querySelector('#floraTabs .active').id; } document.addEventListener('flora-ready',()=>restore(initial),{once:true}); document.addEventListener('click',e=>{ if(e.target.closest('.chip, .trend-filter-btn')) {pressed();recordState();} }); - document.addEventListener('change',e=>{if(['journal-top-n','rep-journal-top-n','filter-outcome'].includes(e.target.id))recordState();}); + document.addEventListener('change',e=>{if(Object.keys(controls).includes(e.target.id))recordState();}); document.getElementById('search-input')?.addEventListener('input',recordState); document.getElementById('mobile-navigation').addEventListener('change',e=>bootstrap.Tab.getOrCreateInstance(document.getElementById(e.target.value)).show()); + const bar=document.querySelector('.top-bar'), tabs=document.getElementById('floraTabs'), mobile=document.querySelector('.mobile-navigation'); + function fitNavigation() { + // Measure the full strip with the select temporarily hidden, including after + // fonts load or the active label becomes bold. No viewport breakpoint needed. + tabs.style.display='flex';mobile.style.display='none'; + bar.classList.toggle('navigation-collapsed',tabs.scrollWidth>tabs.clientWidth+1); + tabs.style.display='';mobile.style.display=''; + } + new ResizeObserver(fitNavigation).observe(document.querySelector('.top-bar-inner')); + document.fonts.ready.then(fitNavigation);fitNavigation(); document.querySelectorAll('#floraTabs button').forEach(btn=>{ btn.addEventListener('show.bs.tab',()=>{ - if(!restoring && document.querySelector('#floraTabs .active')!==btn) history.pushState(null,'',location.href); + if(ready && !restoring && document.querySelector('#floraTabs .active')!==btn) history.pushState(null,'',location.href); }); btn.addEventListener('shown.bs.tab',()=>{ document.getElementById('mobile-navigation').value=btn.id; document.querySelector('.app-main').scrollTop = 0; + recordState();fitNavigation(); requestAnimationFrame(()=>window.dispatchEvent(new Event('resize'))); }); }); - window.addEventListener('popstate',()=>restore(new URL(location.href))); + window.addEventListener('popstate',()=>restore(new URL(location.href),true)); // In-page links to another tab switch tabs without reloading the data. // Modified or non-primary clicks keep normal link behaviour (new tab or window). document.addEventListener('click',e=>{ @@ -78,7 +123,7 @@ else if(pane){pane.setAttribute('tabindex','-1');pane.focus({preventScroll:true});} }); document.getElementById('share-view').onclick=async()=>{ - syncBrowseUrl();recordState(); + recordState(); try { await navigator.clipboard.writeText(location.href);document.getElementById('view-feedback').textContent='Link copied'; } catch { document.getElementById('view-feedback').textContent='Copy the current address from your browser.'; } }; diff --git a/index.html b/index.html index 0bee7b33..452b3d4e 100644 --- a/index.html +++ b/index.html @@ -1007,7 +1007,7 @@