Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1313,7 +1313,7 @@ function renderMcCharts() {

// ── Overview grid ─────────────────────────────────────────────────────
const ov = d.overview;
const statCards = buckets.map(b => {
const statCards = buckets.filter(b => ov[b.overviewKey] > 0).map(b => {
const n = ov[b.overviewKey] || 0;
const pct = ov.n_total ? Math.round(100 * n / ov.n_total) : 0;
return `<div class="mc-stat"><span class="mc-stat-value" style="color:${b.color}">${n.toLocaleString()}</span><span class="mc-stat-label">${b.label} (${pct}%)</span></div>`;
Expand Down
43 changes: 38 additions & 5 deletions assets/chart-accessibility.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ window.FloraCharts = (() => {
}
const modes = new Map();
const originals = new Map();
const plots = new Map();
const heights = new Map();
let resizeTimer;
window.addEventListener('resize',()=>{
clearTimeout(resizeTimer);
resizeTimer=setTimeout(()=>plots.forEach((args,id)=>{
const el=document.getElementById(id);
if (id !== 'study-plot' && el?.getClientRects().length) plot(el,...args);
}),120);
});
function csvText(headers, rows, notes = '') {
// Prefix formula-like text so spreadsheet programs treat it as data.
const cell = v => '"' + (typeof v === 'number' ? String(v) : String(v ?? '').replace(/^[=+@-]/, m => "'" + m)).replace(/"/g, '""') + '"';
Expand Down Expand Up @@ -53,6 +63,7 @@ window.FloraCharts = (() => {
for (const suffix of ['', '-copy', '-copy-status']) document.getElementById(id + suffix)?.remove();
// A comparison toggle would otherwise replot the saved traces into the cleared chart.
document.getElementById(el.id + '-mode')?.remove(); originals.delete(el.id);
plots.delete(el.id);
el.removeAttribute('aria-describedby'); el.removeAttribute('role'); el.removeAttribute('aria-label');
if (typeof Plotly !== 'undefined' && el.data) Plotly.purge(el);
}
Expand Down Expand Up @@ -110,6 +121,7 @@ window.FloraCharts = (() => {
const sample = `Included in the plot: ${total}; excluded from this selection: ${Math.max(0,eligible-total)} reference pairs. ` + (isOutcome ? `${total ? '' : 'No matching assessed outcomes. '}${/computational|robustness/.test(el.id) ? 'Unchecked or uncoded values for this dimension are excluded; the two reproduction subsets overlap.' : 'All replication outcomes are included; qualified success and other/uncoded outcomes are separate.'}` : 'Counts are reference pairs in the displayed categories; journal charts show only the selected top N.');
table(el,rows,['Category',...datasets.map(d=>d.label)],sample);
if (config.options.scales) for (const axis of Object.values(config.options.scales)) if (axis.ticks) axis.ticks.autoSkip = !config.options.indexAxis || config.options.indexAxis !== 'y';
if (config.options.plugins?.legend) config.options.plugins.legend.reverse = false;
return new Chart(ctx, config);
}
// options.table = {headers, rows, summary} replaces the generic per-trace table, for
Expand All @@ -118,6 +130,9 @@ window.FloraCharts = (() => {
const el = typeof target === 'string' ? document.getElementById(target) : target;
if (!el) return Promise.resolve();
const id = el.id;
plots.set(id,[traces,layout,config,options]);
if (!heights.has(id)) heights.set(id,el.style.height);
el.style.height = heights.get(id);
// Every Plotly chart uses the page font unless a renderer sets one.
layout = {...layout, font: {family: theme().fontFamily, ...layout.font}};
// Size a plot only after its analysis card is visible. Hidden cards otherwise
Expand All @@ -130,19 +145,24 @@ window.FloraCharts = (() => {
return Promise.resolve();
}
let display = traces.map(t=>({...t}));
layout = {...layout, autosize:true, xaxis:{...layout.xaxis,automargin:true}, yaxis:{...layout.yaxis,automargin:true}};
layout = {...layout, autosize:true, legend:{...layout.legend,traceorder:'normal'}};
for (const key of Object.keys(layout).filter(k=>/^[xy]axis\d*$/.test(k))) {
const axis = layout[key], title = typeof axis.title === 'string' ? {text:axis.title} : axis.title;
layout[key] = {...axis,automargin:true,title:{...title,standoff:12}};
}
const counts = modes.get(id) === 'count';
let summary = 'Values below include every plotted series; interval limits are shown where available.';
if (comparisonIds.has(id)) {
originals.set(id, [traces,{...layout,xaxis:{...layout.xaxis},yaxis:{...layout.yaxis}},config]);
const nGroups = traces[0]?.x?.length || 0;
const totals = Array.from({length:nGroups}, (_,i)=>traces.reduce((sum,t)=>sum+(Number(t.y?.[i])||0),0));
const counts = modes.get(id) === 'count';
display = display.map(t=>({...t, x:t.x.map((v,i)=>plain(v).replace(/(.{1,18})(?:\s|$)/g, '$1<br>')+`<br>N=${totals[i]}`),
display = display.map(t=>({...t, x:t.x.map((v,i)=>plain(v).replace(/(.{1,18})(?:\s|$)/g, '$1<br>').replace(/<br>$/, '')+`<br>N=${totals[i]}`),
y:t.y.map((v,i)=>counts ? v : totals[i] ? 100*v/totals[i] : 0),
customdata:t.y.map((v,i)=>[v,totals[i]]),
hovertemplate:counts ? '%{x}<br>%{y} reference pairs<extra>%{fullData.name}</extra>' : '%{x}<br>%{y:.1f}% (%{customdata[0]} pairs)<extra>%{fullData.name}</extra>'}));
layout.barmode = counts ? 'group' : 'stack';
layout.yaxis = {...layout.yaxis,title:counts ? 'Reference pairs' : 'Within-group percentage', range:counts ? undefined : [0,100]};
layout.xaxis = {...layout.xaxis,tickangle:0};
layout.yaxis = {...layout.yaxis,title:{text:counts ? 'Reference pairs' : 'Within-group percentage',standoff:12}, range:counts ? undefined : [0,100]};
summary = 'Denominator: all reference pairs in each group with the metadata required by this analysis. Other and qualified outcomes are retained. Missing author/RR metadata are reported in the summary above. Repeated reports and targets mean these observations are not independent.';
let toggle = document.getElementById(id+'-mode');
if (!toggle) {
Expand All @@ -152,7 +172,7 @@ window.FloraCharts = (() => {
toggle.textContent=counts?'Show percentages':'Show counts';
}
if (window.innerWidth < 600) {
layout.margin={...layout.margin,l:55,r:20,b:100};
layout.margin={...layout.margin,l:60,r:layout.yaxis2 ? 65 : 20,b:100};
layout.legend={...layout.legend,orientation:'h',y:-0.32,font:{...layout.legend?.font,size:10}};
layout.height=Math.max(layout.height || 380,420);
layout.font={...layout.font,size:11};
Expand All @@ -162,11 +182,24 @@ window.FloraCharts = (() => {
hovertemplate:t.hovertemplate.replace(/%\{x\}/g,'%{category}').replace(/%\{y/g,'%{x').replace(/%\{category\}/g,'%{y}')}));
layout.xaxis={...layout.yaxis,automargin:true};
layout.yaxis={title:'',automargin:true,autorange:'reversed',type:'category',tickangle:0};
layout.xaxis.title={...layout.xaxis.title,text:counts ? 'Reference pairs' : 'Within-group<br>percentage'};
layout.margin={...layout.margin,l:110,b:140};
layout.legend={...layout.legend,y:-0.25};
layout.height=Math.max(440,(display[0]?.y.length || 0)*65+200);
}
}
// Reserve separate space for category labels, axis titles and the legend.
// Paper-relative legend positions otherwise sit on wrapped labels (including N).
const horizontal = display[0]?.orientation === 'h';
const labelLines = comparisonIds.has(id) && !horizontal ? Math.max(...display[0].x.map(v=>v.split('<br>').length)) : 1;
const axisSpace = labelLines * 17 + (layout.xaxis?.title?.text ? 45 : 15);
const legendItems = display.filter(t=>t.showlegend !== false).length;
const legendRows = Math.ceil(legendItems / (window.innerWidth < 600 ? 1 : 4));
const legendSpace = legendItems > 1 ? legendRows * 20 + 15 : 0;
layout.margin={...layout.margin,b:Math.max(layout.margin?.b || 0,axisSpace + legendSpace)};
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);
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)) {
Expand Down
53 changes: 34 additions & 19 deletions assets/citation-impact.js
Original file line number Diff line number Diff line change
Expand Up @@ -517,9 +517,12 @@
renderStudy(s);
}

// Sort a copy for display; stored order still defines ties in the aggregate model.
const chronologicalReplications = s => [...(s.replications || [])].sort((a,b)=>(Number(a.year) || Infinity)-(Number(b.year) || Infinity));

function renderStudy(s) {
// Each replication is numbered; the same number marks its event line in the timeline.
const reps = (s.replications || []).map((r, i) => `
const reps = chronologicalReplications(s).map((r, i) => `
<li>
<span class="rep-marker" role="img" style="color:${OUTCOME_COLORS[r.outcome] || 'inherit'}" aria-label="Marker ${i + 1}">${i + 1}</span>
<span class="outcome-badge ${r.outcome}">${r.outcome}</span>
Expand All @@ -530,9 +533,9 @@
document.getElementById('ci-modal-body').innerHTML = `
<div class="modal-body">
${modalHeader(s)}
<button type="button" class="ci-share-btn" data-doi="${escapeHtml(s.doi)}">🔗 Copy link to this chart</button>
<button type="button" class="ci-share-btn" data-doi="${escapeHtml(s.doi)}">Copy link to this chart</button>
<h3 style="margin-top:18px">Citation timeline</h3>
${s.cocit_conflated ? `<p class="cocit-warning">⚠️ 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.</p>` : ''}
${s.cocit_conflated ? `<p class="cocit-warning">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.</p>` : ''}
<div id="study-plot" style="width:100%;height:380px"></div>
<h3 style="margin-top:18px">Replications (${s.n_replications})</h3>
<ul class="rep-list">${reps}</ul>
Expand Down Expand Up @@ -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) {
Expand All @@ -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}<br>%{y} citing works<extra>%{fullData.name}</extra>';
});
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 }
Expand Down Expand Up @@ -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));
});
Expand Down
11 changes: 11 additions & 0 deletions assets/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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)); }
Comment thread
LukasWallrich marked this conversation as resolved.
#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;
Expand Down
Loading
Loading