⚠️ Co-citation can't be measured for ${s.cocit_conflated > 1 ? 'some replications of ' : ''}this study: OpenCitations groups the original and ${s.cocit_conflated > 1 ? 'those replications' : 'its replication'} under one record, so citations of the two can't be told apart. The timeline below counts all of them as citations of the original.
` : ''}
+ ${s.cocit_conflated ? `
Co-citation can't be measured for ${s.cocit_conflated > 1 ? 'some replications of ' : ''}this study: OpenCitations groups the original and ${s.cocit_conflated > 1 ? 'those replications' : 'its replication'} under one record, so citations of the two can't be told apart. The timeline below counts all of them as citations of the original.
` : ''}
Replications (${s.n_replications})
${reps}
@@ -566,11 +569,15 @@
}
const TIMELINE_NARROW = 600;
- // Label style depends on width, so redraw an open timeline when it crosses the breakpoint.
+ // Label style and collision spacing depend on the current chart width.
+ let timelineResizeTimer;
window.addEventListener('resize', () => {
- const shown = CI.openStudy;
- if (!shown || shown.doi !== CI.currentDoi || !document.getElementById('study-plot')) return;
- if ((window.innerWidth < TIMELINE_NARROW) !== CI.timelineNarrow) drawStudyTimeline(shown);
+ clearTimeout(timelineResizeTimer);
+ timelineResizeTimer = setTimeout(() => {
+ const shown = CI.openStudy;
+ if (!shown || shown.doi !== CI.currentDoi || !document.getElementById('study-plot')) return;
+ drawStudyTimeline(shown);
+ }, 120);
});
function drawStudyTimeline(s) {
@@ -587,28 +594,36 @@
{ x: years, y: tl.map(t => t.with_mixed), name: 'Co-cites mixed rep', type: 'bar', marker: { color: OUTCOME_COLORS.mixed } },
{ x: years, y: tl.map(t => t.with_successful), name: 'Co-cites successful rep',type: 'bar', marker: { color: OUTCOME_COLORS.successful } },
{ x: years, y: tl.map(t => t.with_multiple || 0), name: 'Co-cites multiple outcome categories', type: 'bar', marker: {color: FloraCharts.token('--color-chart-qualified')} }
- ];
+ ].filter(trace=>trace.y.some(v=>v>0));
+ // The coverage year stays partial even if this snapshot is viewed in a later year.
+ const partialYear = Number((CI.meta?.last_updated || '').slice(0,4)) || new Date().getFullYear();
+ traces.forEach(trace=>{
+ trace.marker.opacity = years.map(year=>year===partialYear ? 0.45 : 1);
+ trace.hovertemplate = '%{x} %{y} citing works%{fullData.name}';
+ });
const t = plotlyTheme();
const shapes = []; const annotations = [];
const narrow = window.innerWidth < TIMELINE_NARROW;
- CI.timelineNarrow = narrow;
- // Labels in the same year stack in their own rows; otherwise rows cycle so neighbouring years alternate.
- const sameYear = new Map();
- (s.replications || []).forEach((r, i) => {
+ // Place nearby events in separate pixel-spaced lanes, including same-year ties.
+ const lanes = [];
+ const width = document.getElementById('study-plot').clientWidth - (narrow ? 80 : 60);
+ const minYear = Math.min(...years), span = Math.max(...years)-minYear+1;
+ chronologicalReplications(s).forEach((r, i) => {
if (!r.year) return;
shapes.push({ type: 'line', x0: r.year, x1: r.year, yref: 'paper', y0: 0, y1: 1, line: { color: OUTCOME_COLORS[r.outcome] || t.primary, width: 2, dash: 'dash' } });
- // Narrow screens show only the number, so labels for nearby years do not collide.
const label = narrow ? String(i + 1) : `${i + 1} · ${r.outcome} ${r.year}`;
- const seen = sameYear.get(r.year);
- const row = seen ? seen.base + seen.count : i % 3;
- sameYear.set(r.year, seen ? { base: seen.base, count: seen.count + 1 } : { base: row, count: 1 });
- annotations.push({ x: r.year, yref: 'paper', y: 1.02 - row * 0.06, text: label, showarrow: false, font: { size: narrow ? 11 : 10, color: OUTCOME_COLORS[r.outcome] || t.primary }, bgcolor: t.paper });
+ const x = (r.year-minYear)*width/span, half = label.length*(narrow ? 3.5 : 3)+5;
+ let row = lanes.findIndex(end=>end+6 < x-half);
+ if (row < 0) row = lanes.length;
+ lanes[row] = x+half;
+ annotations.push({ name:'replication-event', x: r.year, yref: 'paper', y: 1.02, yshift: -row*22, text: label, showarrow: false, borderpad:2, font: { size: narrow ? 11 : 10, color: OUTCOME_COLORS[r.outcome] || t.primary }, bgcolor: t.paper });
});
+ if (years.includes(partialYear)) annotations.push({name:'partial-year',x:partialYear,xref:'x',yref:'paper',y:1.14,xanchor:'right',text:`${partialYear}: partial year`,showarrow:false,font:{size:11,color:t.muted}});
const layout = {
barmode: 'stack', margin: { t: 50, r: 10, b: 40, l: 50 },
xaxis: { title: 'Year', gridcolor: t.grid, color: t.font },
yaxis: { title: 'Citing preprints / papers', gridcolor: t.grid, color: t.font },
- shapes, annotations,
+ shapes, annotations, hoverlabel:{namelength:-1},
plot_bgcolor: t.plot, paper_bgcolor: t.paper,
legend: { orientation: 'h', y: -0.18, font: { color: t.font } },
font: { family: t.fontFamily, size: 12, color: t.font }
@@ -662,7 +677,7 @@
const link = citationLink(btn.dataset.doi);
navigator.clipboard.writeText(link).then(() => {
const orig = btn.textContent;
- btn.textContent = '✓ Link copied';
+ btn.textContent = 'Link copied';
setTimeout(() => { btn.textContent = orig; }, 1500);
}).catch(() => window.prompt('Copy this link:', link));
});
diff --git a/assets/styles.css b/assets/styles.css
index 47c7683c..c3d566ea 100644
--- a/assets/styles.css
+++ b/assets/styles.css
@@ -551,6 +551,17 @@ tr.shown td.details-control .study-details::before { transform: rotate(90deg); }
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 0.75rem; margin: 0 0 1.25rem;
}
+/* Keep the remaining Mean Citedness tiles in balanced rows. */
+#mc-overview { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+#mc-overview:has(> :nth-child(5):last-child),
+#mc-overview:has(> :nth-child(6):last-child),
+#mc-overview:has(> :nth-child(7):last-child),
+#mc-overview:has(> :nth-child(9):last-child) { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+#mc-overview:has(> :nth-child(7):last-child) > :first-child { grid-column: 1 / -1; }
+@media (max-width: 767px) {
+ #mc-overview, #mc-overview:has(> :nth-child(n):last-child) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ #mc-overview:has(> :nth-child(odd):last-child) > :first-child { grid-column: 1 / -1; }
+}
.mc-stat {
background: var(--color-surface-subtle); border: 1px solid var(--color-border);
border-radius: 8px; padding: 0.8rem 1rem; text-align: center;
diff --git a/tests/browser.test.cjs b/tests/browser.test.cjs
index 29a1344d..7b138022 100644
--- a/tests/browser.test.cjs
+++ b/tests/browser.test.cjs
@@ -154,6 +154,9 @@ test('all seven tabs, percentages, venue evidence, responsive redraws and chart
await p.click('#'+tab);await p.waitForTimeout(650);
}
await p.click('#pub-tab');await p.waitForFunction(()=>document.getElementById('pub-chart')?.data?.length>0);
+ assert.ok(await p.evaluate(()=>['overview-outcome-chart','trend-orig-year','trend-field'].every(id=>{
+ const c=Chart.getChart(id);return JSON.stringify(c.legend.legendItems.map(d=>d.text))===JSON.stringify(c.getSortedVisibleDatasetMetas().map(d=>c.data.datasets[d.index].label));
+ })));
const sums=await p.evaluate(()=>{const t=document.getElementById('pub-chart').data;return t[0].x.map((_,i)=>t.reduce((n,s)=>n+s.y[i],0));});
assert.ok(sums.every(s=>Math.abs(s-100)<1e-8));
const unknown=await p.evaluate(csv=>Papa.parse(csv,{header:true,skipEmptyLines:true}).data.filter(r=>r.type.toLowerCase().includes('replication')&&!r.type.toLowerCase().includes('reproduc')&&['','na','nan','none','null'].includes((r.journal_r||'').trim().toLowerCase())).length,fs.readFileSync('data/flora.csv','utf8'));
@@ -351,11 +354,131 @@ test('Trends follow the selected study type, keep calendar spacing and reflow af
test('narrow citation timelines number replication events to match the list',async()=>{
const p=await page('?tab=citations&doi=10.1126%2Fscience.7455683',390);
await p.waitForFunction(()=>document.getElementById('study-plot')?.layout?.annotations?.length>0);
- const marks=await p.evaluate(()=>document.getElementById('study-plot').layout.annotations.map(a=>a.text));
+ const marks=await p.evaluate(()=>document.getElementById('study-plot').layout.annotations.filter(a=>/^\d+$/.test(a.text)).map(a=>a.text));
const list=await p.locator('.rep-list .rep-marker').allInnerTexts();
// Every replication of this study is dated, so each list entry has exactly one marker, in order.
assert.ok(marks.length>=2);
assert.deepEqual(marks,list);
+ const years=await p.locator('.rep-list strong').allInnerTexts();
+ assert.deepEqual(years.map(s=>+(s.match(/\((\d{4})\)/)||[])[1]),[2014,2018,2021,2022]);
+ for(const width of [390,320]){
+ await p.setViewportSize({width,height:844});await p.waitForTimeout(500);
+ const details=await p.evaluate(()=>{
+ const el=document.getElementById('study-plot');
+ const boxes=[...el.querySelectorAll('.annotation')].filter(e=>/^\d+$/.test(e.textContent)).map(e=>e.getBoundingClientRect());
+ return {overlap:boxes.some((a,i)=>boxes.slice(i+1).some(b=>a.left!t.y.some(v=>v>0)).map(t=>t.name),legend:el._fullLayout.legend.traceorder,
+ partial:el.layout.annotations.some(a=>/2026.*partial year/i.test(a.text))&&el.data.every(t=>t.marker.opacity.every((v,i)=>v===(t.x[i]===2026 ? 0.45 : 1))),hover:el._fullLayout.hoverlabel.namelength};
+ });
+ assert.equal(details.overlap,false,`timeline markers touch at ${width}px`);
+ assert.deepEqual(details.empty,[]);assert.equal(details.legend,'normal');assert.ok(details.partial);assert.equal(details.hover,-1);
+ }
+ await p.context().grantPermissions(['clipboard-read','clipboard-write'],{origin:base});
+ await p.getByRole('button',{name:'Copy link to this chart',exact:true}).click();
+ await p.getByRole('button',{name:'Link copied',exact:true}).waitFor({state:'visible'});
+ assert.ok(await p.getByRole('button',{name:'Link copied',exact:true}).isVisible());
+ assert.equal(await p.evaluate(()=>navigator.clipboard.readText()),p.url());
+ assert.deepEqual(p.errors,[]);await p.close();
+});
+
+test('citation timeline redraws wait for 120 ms without another resize',async()=>{
+ const p=await page('?tab=citations&doi=10.1126%2Fscience.7455683',390,p=>p.clock.install({time:new Date('2026-10-21T00:00:00Z')}));
+ await p.waitForFunction(()=>document.getElementById('study-plot')?.layout?.annotations?.length>0);
+ await p.clock.pauseAt(new Date('2026-10-21T01:00:00Z'));
+ await p.evaluate(()=>{
+ const plot=FloraCharts.plot;
+ window.timelineDraws=0;
+ FloraCharts.plot=function(target,...args){
+ if(target==='study-plot') window.timelineDraws++;
+ return plot.call(this,target,...args);
+ };
+ for(let i=0;i<5;i++) window.dispatchEvent(new Event('resize'));
+ });
+ await p.clock.runFor(119);
+ assert.equal(await p.evaluate(()=>window.timelineDraws),0);
+ await p.evaluate(()=>window.dispatchEvent(new Event('resize')));
+ await p.clock.runFor(119);
+ assert.equal(await p.evaluate(()=>window.timelineDraws),0,'a later resize restarts the delay');
+ await p.clock.runFor(1);
+ assert.equal(await p.evaluate(()=>window.timelineDraws),1,'the burst produces one redraw');
+ await p.evaluate(()=>window.dispatchEvent(new Event('resize')));
+ await p.keyboard.press('Escape');
+ await p.clock.runFor(120);
+ assert.equal(await p.evaluate(()=>window.timelineDraws),1,'a closed modal is not redrawn');
+ assert.deepEqual(p.errors,[]);await p.close();
+});
+
+test('Mean Citedness omits empty KPI tiles and avoids a lone tile on the last row',async()=>{
+ const p=await page('?tab=mean-citedness');await p.waitForSelector('#mc-overview .mc-stat');
+ for(const kind of ['replication','reproduction-numerical','reproduction-robustness']){
+ await p.click(`#mc-study-type [data-value="${kind}"]`);
+ assert.ok((await p.locator('#mc-overview .mc-stat-value').allTextContents()).every(s=>Number(s.replaceAll(',',''))>0));
+ for(const width of [1280,1024,768,390]){
+ await p.setViewportSize({width,height:900});
+ const rows=await p.locator('#mc-overview .mc-stat').evaluateAll(els=>Object.values(els.reduce((rows,el)=>{const y=el.getBoundingClientRect().top;rows[y]=(rows[y]||0)+1;return rows;},{})));
+ assert.ok(rows.length===1||rows.at(-1)>1,`${kind} orphan at ${width}px: ${rows}`);
+ }
+ }
+ assert.deepEqual(p.errors,[]);await p.close();
+});
+
+test('seven Mean Citedness tiles fill desktop rows as 1 + 3 + 3',async()=>{
+ // Give one outcome an empty count so the real renderer produces seven tiles.
+ const p=await page('?tab=mean-citedness',1280,p=>p.route('**/data/impact_factor_data.json',route=>{
+ const data=JSON.parse(fs.readFileSync('data/impact_factor_data.json','utf8'));
+ data.overview.n_success+=data.overview.n_qualified;
+ data.overview.n_qualified=0;
+ data.histogram.forEach(bin=>{bin.successful+=bin.qualified;bin.qualified=0;});
+ return route.fulfill({json:data});
+ }));
+ await p.waitForSelector('#mc-overview .mc-stat');
+ assert.equal(await p.locator('#mc-overview .mc-stat').count(),7);
+ for(const width of [1280,1024,768,390,1280]){
+ await p.setViewportSize({width,height:900});
+ const rows=await p.locator('#mc-overview').evaluate(grid=>{
+ const bounds=grid.getBoundingClientRect();
+ return Object.values([...grid.children].reduce((rows,el)=>{
+ const r=el.getBoundingClientRect();
+ (rows[r.top]??=[]).push({left:r.left,right:r.right});
+ return rows;
+ },{})).map(row=>({count:row.length,filled:Math.abs(row[0].left-bounds.left)<1&&Math.abs(row.at(-1).right-bounds.right)<1}));
+ });
+ assert.deepEqual(rows.map(row=>row.count),width<768?[1,2,2,2]:[1,3,3],`row counts at ${width}px`);
+ assert.ok(rows.every(row=>row.filled),`each row fills the grid at ${width}px`);
+ }
+ assert.deepEqual(p.errors,[]);await p.close();
+});
+test('comparison N labels and Plotly axis titles stay clear of legends and ticks',async()=>{
+ const p=await page('?tab=publication-type',1366);await p.waitForFunction(()=>document.getElementById('pub-chart')?.data?.length);
+ for(const width of [1366,1280,390,320,1280]){
+ await p.setViewportSize({width,height:900});await p.waitForTimeout(700);
+ const issues=await p.evaluate(()=>['pub-chart','rr-chart','ls-chart'].flatMap(id=>{
+ const el=document.getElementById(id), svg=el.getBoundingClientRect(), legend=el.querySelector('.legend')?.getBoundingClientRect();
+ const ticks=[...el.querySelectorAll(innerWidth<600?'.ytick':'.xtick')];
+ if (legend && legend.bottom>svg.bottom+1) return [[id,'Legend below chart container']];
+ return ticks.filter(t=>{const r=t.getBoundingClientRect();return !/N=\d+/.test(t.textContent)||r.bottom>svg.bottom||r.leftlegend.left&&r.toplegend.top);}).map(t=>[id,t.textContent]);
+ }));
+ assert.deepEqual(issues,[],`N labels clipped/overlapped at ${width}px`);
+ assert.ok(await p.evaluate(()=>['pub-chart','rr-chart','ls-chart'].every(id=>{
+ const el=document.getElementById(id);return JSON.stringify([...el.querySelectorAll('.legendtext')].map(e=>e.textContent))===JSON.stringify(el.data.map(t=>t.name));
+ })));
+ const clippedTitles=await p.evaluate(()=>['pub-chart','rr-chart','ls-chart'].filter(id=>{
+ const el=document.getElementById(id), r=el.getBoundingClientRect(), title=el.querySelector('.g-xtitle')?.getBoundingClientRect();
+ return title&&(title.leftr.right);
+ }));
+ assert.deepEqual(clippedTitles,[],`comparison axis titles clipped at ${width}px`);
+ }
+ await p.setViewportSize({width:390,height:900});
+ await p.selectOption('#mobile-navigation','citation-tab');await p.waitForFunction(()=>document.getElementById('plot-cit')?.data?.length);
+ for(const width of [390,320]){
+ await p.setViewportSize({width,height:900});await p.waitForTimeout(700);
+ const issues=await p.evaluate(()=>['plot-cit','plot-cocit'].flatMap(id=>{
+ const el=document.getElementById(id), r=el.getBoundingClientRect();
+ const titles=[...el.querySelectorAll('.g-ytitle,.g-y2title')];
+ return titles.filter(t=>{const b=t.getBoundingClientRect();return b.leftr.right||[...el.querySelectorAll('.ytick,.y2tick')].some(tick=>{const a=tick.getBoundingClientRect();return a.leftb.left&&a.topb.top;});}).map(t=>[id,t.textContent]);
+ }));
+ assert.deepEqual(issues,[],`axis titles clipped/overlapped at ${width}px`);
+ }
assert.deepEqual(p.errors,[]);await p.close();
});