diff --git a/README.md b/README.md
index fd4a48ca..bc28f73c 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# FLoRA Explorer
-A static dashboard for the [FORRT Library of Replication Attempts (FLoRA)](https://forrt.org/replication-hub/flora). Each database row is a **reference pair**, linking an original report to a replication or reproduction report. Reports can occur in multiple pairs.
+A static dashboard for the [FORRT Library of Reproduction and Replication Attempts (FLoRA)](https://forrt.org/replication-hub/flora). Each database row is a **reference pair**, linking an original report to a replication or reproduction report. Reports can occur in multiple pairs.
| View | Purpose | Data refresh |
|---|---|---|
diff --git a/assets/app.js b/assets/app.js
index 592b0218..e5006b13 100644
--- a/assets/app.js
+++ b/assets/app.js
@@ -206,9 +206,9 @@ function applyKindText(root, kind) {
}
function studyTypeLabel(kind) {
- if (kind === 'reproduction-numerical') return 'Numerical Reproductions';
- if (kind === 'reproduction-robustness') return 'Robustness Reproductions';
- return 'Replications';
+ if (kind === 'reproduction-numerical') return 'numerical reproductions';
+ if (kind === 'reproduction-robustness') return 'robustness reproductions';
+ return 'replications';
}
let mcKind = 'replication';
@@ -415,7 +415,7 @@ function computeKindChartData(data, kind) {
// readers need not open the data table to see what the bar is a share of.
function kindChartCaption(data, kind) {
const { datasets, total } = computeKindChartData(data, kind);
- const pct = n => total ? Math.round(100 * n / total) : 0;
+ const pct = n => FloraCharts.percent(n, total);
const counts = datasets.map(d => `${d.label} ${d.data[0].toLocaleString()} (${pct(d.data[0])}%)`).join(' · ');
if (kind === 'replicability') return `${total.toLocaleString()} replication ${total === 1 ? 'pair' : 'pairs'}: ${counts}.`;
const eligible = data.filter(r => classifyKind(r) === 'reproduction').length;
@@ -446,7 +446,7 @@ function renderKindStackedBar(canvasId, existingChart, data, kind, categoryLabel
plugins: {
legend: { position: 'bottom', labels: { color: ac.legend, boxWidth: 14, padding: 14, font: { size: 12 } } },
tooltip: { callbacks: { label: ctx => {
- const v = ctx.parsed.x; const pct = total ? ((v / total) * 100).toFixed(1) : 0;
+ const v = ctx.parsed.x; const pct = FloraCharts.percent(v, total, 1);
return `${ctx.dataset.label}: ${v.toLocaleString()} (${pct}%)`;
}}}
},
@@ -500,7 +500,7 @@ function renderRandomExamples(data) {
${origMeta ? `${escapeHtml(origMeta)} ` : ''}
`;
const repInner = `
- Replication
+ ${reportKindLabel(r)}
${escapeHtml(truncateText(repTitle, 110))}
${repMeta ? `${escapeHtml(repMeta)} ` : ''}
`;
@@ -608,7 +608,7 @@ document.getElementById('website-citation-copy-btn').addEventListener('click', (
// ===== FAQ =====
function renderInlineMd(text) {
- let s = escapeHtml(text);
+ let s = escapeHtml(text.replace(/\\([#_\-])/g, '$1'));
s = s.replace(/`([^`]+)`/g, (_, c) => `${c}`);
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, t, u) => {
// Strict allow-list: the whole trimmed URL must start with an allowed
@@ -617,13 +617,34 @@ function renderInlineMd(text) {
// javascript: — renders as plain text.
const clean = u.trim();
const safe = /^(https?:|mailto:)/i.test(clean);
- return safe ? `${t} ` : `${t} (${u})`;
+ const label = /^here$/i.test(t) && clean.includes('forrt-replications.shinyapps.io/fred_explorer/') ? 'FORRT Replication Database explorer' : t;
+ return safe ? `${label} ` : `${t} (${u})`;
});
s = s.replace(/\*\*([^*]+)\*\*/g, '$1 ');
s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1$2 ');
return s;
}
+// The live FAQ is shared with other tools. Correct reader-facing copy here without
+// changing the upstream coding guidance or exposing its spreadsheet column names.
+function readerFaqCopy(md) {
+ return md.replace(/\\([#_\-])/g, '$1')
+ .replace(/computational reproducible/g, 'computationally reproducible')
+ .replace(/This we would code as/g, 'We would code this as')
+ .replace(/versionising/g, 'versioning').replace(/Pubmed/g, 'PubMed').replace(/Google docs/g, 'Google Docs')
+ .replace(/regularly-updated/g, 'regularly updated')
+ .replace('click [here](https://forrt-replications.shinyapps.io/fred_explorer/)', 'visit the [FORRT Replication Database explorer](https://forrt-replications.shinyapps.io/fred_explorer/)')
+ .replace('in alt_id_o and alt_id_r columns', 'as alternative identifiers for the original and repetition reports')
+ .replace('will have the specific published version in doi_r and the other alt_id_r', 'use the specific published version as the main repetition reference and keep the other version as an alternative identifier')
+ .replace('and the individual reports or preprints as doi_r', 'and the individual reports or preprints as alternative identifiers')
+ .replace(/\bjournal_r\b/g, 'the repetition report’s venue')
+ .replace(/\bdoi_r\b/g, 'the repetition report’s main DOI')
+ .replace(/\bdoi_o\b/g, 'the original report’s main DOI')
+ .replace(/\balt_id_r\b/g, 'the repetition report’s alternative identifiers')
+ .replace(/\balt_id_o\b/g, 'the original report’s alternative identifiers')
+ .replace('The plugin tags replication outcomes as Successful, Failed, or Mixed,', 'Most replication outcomes are coded as Successful, Failed, or Mixed,');
+}
+
function renderFaqAnswer(lines) {
const out = []; let i = 0;
while (i < lines.length) {
@@ -649,9 +670,19 @@ function renderFaqAnswer(lines) {
}
function parseFaqMarkdown(md) {
- const lines = md.replace(/\r\n/g, '\n').split('\n');
+ const lines = readerFaqCopy(md).replace(/\r\n/g, '\n').split('\n');
const items = []; let currentQA = null; let currentSection = null;
- const flushQA = () => { if (currentQA) { items.push(currentQA); currentQA = null; } };
+ const venueQuestion = 'How is the peer review status determined?';
+ let venueAnswerCorrected = false;
+ const flushQA = () => { if (currentQA) {
+ if (currentQA.question === venueQuestion) {
+ currentQA.body = [
+ 'The Publication Type tab groups reports by their recorded venue: repository or preprint, conference output, thesis or dissertation, other named venue, or unknown venue. A named venue does not confirm peer review. Missing venue information remains unknown.'
+ ];
+ venueAnswerCorrected = true;
+ }
+ items.push(currentQA); currentQA = null;
+ } };
const flushSection = () => { if (currentSection) { items.push(currentSection); currentSection = null; } };
for (const ln of lines) {
const h1 = /^#\s+(.+)$/.exec(ln);
@@ -663,6 +694,7 @@ function parseFaqMarkdown(md) {
else if (currentSection) currentSection.body.push(ln);
}
flushQA(); flushSection();
+ if (!venueAnswerCorrected) console.warn(`FAQ venue-status correction was not applied: expected heading "${venueQuestion}" was not found. Review the upstream FAQ.`);
return items;
}
@@ -847,7 +879,7 @@ function bmRender() {
if (bmPage >= totalPages) bmPage = totalPages - 1;
const start = bmPage * BM_PAGE_SIZE; const end = Math.min(total, start + BM_PAGE_SIZE);
list.innerHTML = bmFiltered.slice(start, end).map(bmCardHtml).join('');
- meta.innerHTML = `Showing ${start + 1}–${end} of ${total.toLocaleString()} reference ${total === 1 ? "pair" : "pairs"}`;
+ meta.innerHTML = `Showing ${(start + 1).toLocaleString('en-GB')}–${end.toLocaleString('en-GB')} of ${total.toLocaleString()} reference ${total === 1 ? "pair" : "pairs"}`;
info.textContent = `Page ${bmPage + 1} of ${totalPages}`;
prev.disabled = bmPage === 0; next.disabled = bmPage >= totalPages - 1;
pager.style.display = totalPages > 1 ? 'flex' : 'none';
@@ -990,7 +1022,8 @@ document.getElementById('browse-empty')?.addEventListener('click', e => {
function updateBrowseKindCount() {
const el = document.getElementById('browse-kind-count'); if (!el) return;
const n = getChartData().length; const total = fullRowData.length;
- el.textContent = browseKind === 'all' ? `${n.toLocaleString()} reference ${n === 1 ? "pair" : "pairs"}` : `${n.toLocaleString()} of ${total.toLocaleString()} reference pairs`;
+ el.textContent = browseKind === 'all' ? `${n.toLocaleString()} reference ${n === 1 ? "pair" : "pairs"}` : `${n.toLocaleString()} of ${total.toLocaleString()} reference ${total === 1 ? "pair" : "pairs"}`;
+ document.querySelectorAll('[data-browse-report-kind]').forEach(el => { el.textContent = browseKind === 'all' ? 'Replication or reproduction' : browseKind === 'replication' ? 'Replication' : 'Reproduction'; });
document.querySelector('#export-browse span').textContent = browseQuery.trim() || browseKind !== 'all' ? 'Download filtered CSV' : 'Download full CSV';
}
@@ -1023,7 +1056,7 @@ function trendsFilteredData() { return filterByKind(fullRowData, trendsKind); }
function updateTrendsCount() {
const el = document.getElementById('trend-filter-count'); if (!el) return;
const n = trendsFilteredData().length; const total = fullRowData.length;
- el.textContent = trendsKind === 'all' ? `${n.toLocaleString()} reference ${n === 1 ? "pair" : "pairs"}` : `${n.toLocaleString()} of ${total.toLocaleString()} reference pairs`;
+ el.textContent = trendsKind === 'all' ? `${n.toLocaleString()} reference ${n === 1 ? "pair" : "pairs"}` : `${n.toLocaleString()} of ${total.toLocaleString()} reference ${total === 1 ? "pair" : "pairs"}`;
}
// Plain per-category counts (year/journal/field) - no outcome breakdown. A prior version
@@ -1312,19 +1345,19 @@ function renderMcCharts() {
const ov = d.overview;
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 `
${n.toLocaleString()} ${b.label} (${pct}%)
`;
+ const pct = FloraCharts.percent(n, ov.n_total);
+ return `${n.toLocaleString()} ${b.label} (${escapeHtml(pct)}%)
`;
}).join('');
document.getElementById('mc-overview').innerHTML = `
${ov.n_total.toLocaleString()} Reference pairs with OMC
${statCards}
${ov.n_journals.toLocaleString()} Journals matched
- ${ov.n_disciplines} Disciplines
`;
+ ${ov.n_disciplines.toLocaleString('en-GB')} Disciplines
`;
// ── Distribution chart (Plotly stacked bar) ───────────────────────────
const bins = d.histogram || [];
const xMids = bins.map(b => +((b.bin_lo + b.bin_hi) / 2).toFixed(2));
- const hTpl = 'OMC %{x:.2f} %{y} reference pairs%{fullData.name} ';
+ const hTpl = 'OMC %{x:.2f} %{y:,.0f} reference pairs%{fullData.name} ';
FloraCharts.plot('mc-dist-chart', buckets.map(b => ({
x: xMids, y: bins.map(row => row[b.histKey] || 0), name: b.label, type: 'bar',
marker: { color: b.color }, hovertemplate: hTpl,
@@ -1332,7 +1365,7 @@ function renderMcCharts() {
barmode: 'stack', bargap: 0.05,
margin: { t: 10, r: 10, b: 50, l: 55 },
xaxis: { title: 'OpenAlex Mean Citedness (OMC)', gridcolor: t.grid, color: t.font, tickfont: { color: t.font } },
- yaxis: { title: 'Reference pairs', gridcolor: t.grid, color: t.font, tickfont: { color: t.font } },
+ yaxis: { title: 'Reference pairs', tickformat: ',d', gridcolor: t.grid, color: t.font, tickfont: { color: t.font } },
plot_bgcolor: t.plot, paper_bgcolor: t.paper,
font: { family: t.fontFamily, size: 12, color: t.font },
legend: { orientation: 'h', y: -0.2, font: { color: t.font } },
@@ -1418,7 +1451,7 @@ function renderMcCharts() {
', χ² = ' + st.chi_sq +
(pNote ? ', ' + pNote : '') +
'; ' + glossR2 + ' = ' + st.r2 +
- '; N = ' + st.n_model + ' (successful vs. failed)';
+ '; N = ' + st.n_model.toLocaleString('en-GB') + ' (successful vs. failed)';
}
async function loadMeanCitedness() {
@@ -1495,11 +1528,11 @@ function renderOverlapCharts() {
'' +
'' +
'
' + (ov.n_overlap || 0).toLocaleString() + '
' +
- '
With author overlap (' + (ov.pct_overlap || 0) + '%)
' +
+ '
With author overlap (' + escapeHtml(FloraCharts.percent(ov.n_overlap, ov.n_total)) + '%)
' +
'
' +
'' +
'
' + (ov.n_no_overlap || 0).toLocaleString() + '
' +
- '
Without author overlap (' + (ov.pct_no_overlap || 0) + '%)
' +
+ '
Without author overlap (' + escapeHtml(FloraCharts.percent(ov.n_no_overlap, ov.n_total)) + '%)
' +
'
' +
'' +
'
' + (ov.n_unknown || 0).toLocaleString() + '
' +
@@ -1594,7 +1627,7 @@ function renderRRCharts() {
if (!d || !d.overview || d.overview.n_total < ANALYSIS_MIN_N) {
const n = d && d.overview ? d.overview.n_total : 0;
if (insufficientEl) {
- insufficientEl.textContent = `Not enough ${studyTypeLabel(pubTypeKind)} checked against the RR library yet (n=${n}; need at least ${ANALYSIS_MIN_N}).`;
+ insufficientEl.textContent = `Not enough ${studyTypeLabel(pubTypeKind)} checked against the Registered Reports database yet (n=${n}; need at least ${ANALYSIS_MIN_N}).`;
insufficientEl.style.display = '';
}
if (overviewEl) overviewEl.style.display = 'none';
@@ -1620,11 +1653,11 @@ function renderRRCharts() {
'
' +
'' +
'
' + (ov.n_rr || 0).toLocaleString() + '
' +
- '
Registered Reports (' + (ov.pct_rr || 0) + '%)
' +
+ '
Registered Reports (' + escapeHtml(FloraCharts.percent(ov.n_rr, ov.n_total)) + '%)
' +
'
' +
'' +
'
' + (ov.n_non_rr || 0).toLocaleString() + '
' +
- '
Rest (' + (ov.pct_non_rr || 0) + '%)
' +
+ '
Not found in RR database (' + escapeHtml(FloraCharts.percent(ov.n_non_rr, ov.n_total)) + '%)
' +
'
' +
'' +
'
' + (ov.n_unknown || 0).toLocaleString() + '
' +
@@ -1636,7 +1669,7 @@ function renderRRCharts() {
// ── Grouped bar chart ──────────────────────────────────────────────────────
const buckets = studyTypeOutcomeBuckets(pubTypeKind);
const groups = ['rr', 'non_rr'];
- const GROUP_LABELS = { rr: 'Registered Report', non_rr: 'Rest' };
+ const GROUP_LABELS = { rr: 'Registered Report', non_rr: 'Not found in RR database' };
const traces = buckets.map(b => ({
name: b.label,
@@ -1759,8 +1792,8 @@ function renderPubStatusCharts() {
const ovEl = document.getElementById('pub-overview');
if (ovEl) {
const venueLabels = {journal:'Other named venue', preprint:'Repository / preprint', conference:'Conference output', thesis:'Thesis / dissertation', unknown:'Unknown venue'};
- ovEl.innerHTML = `
${ov.n_total}
Reference pairs
` +
- Object.entries(venueLabels).map(([key,label])=>`
${ov['n_'+key] || 0}
${label} (${ov.n_total ? (100*(ov['n_'+key] || 0)/ov.n_total).toFixed(1) : 0}%)
`).join('');
+ ovEl.innerHTML = `
${ov.n_total.toLocaleString('en-GB')}
Reference pairs
` +
+ Object.entries(venueLabels).map(([key,label])=>`
${(ov['n_'+key] || 0).toLocaleString('en-GB')}
${label} (${escapeHtml(FloraCharts.percent(ov['n_'+key] || 0, ov.n_total))}%)
`).join('');
ovEl.style.display = '';
}
@@ -1875,7 +1908,7 @@ function renderLargeScaleCharts() {
if (!d || !d.overview || d.overview.n_total < ANALYSIS_MIN_N) {
const n = d && d.overview ? d.overview.n_total : 0;
if (insufficientEl) {
- insufficientEl.textContent = `Not enough ${studyTypeLabel(pubTypeKind)} with project-scale data yet (n=${n}; need at least ${ANALYSIS_MIN_N}).`;
+ insufficientEl.textContent = `Not enough ${studyTypeLabel(pubTypeKind)} with report-size data yet (n=${n}; need at least ${ANALYSIS_MIN_N}).`;
insufficientEl.style.display = '';
}
if (overviewEl) overviewEl.style.display = 'none';
@@ -1900,11 +1933,11 @@ function renderLargeScaleCharts() {
'
' +
'' +
'
' + (ov.n_individual || 0).toLocaleString() + '
' +
- '
Up to 3 targets / unknown (' + (ov.pct_individual || 0) + '%)
' +
+ '
Up to 3 targets / unknown (' + escapeHtml(FloraCharts.percent(ov.n_individual, ov.n_total)) + '%)
' +
'
' +
'' +
'
' + (ov.n_large_scale || 0).toLocaleString() + '
' +
- '
Multiple-target report (' + (ov.pct_large_scale || 0) + '%)
' +
+ '
Multiple-target report (' + escapeHtml(FloraCharts.percent(ov.n_large_scale, ov.n_total)) + '%)
' +
'
';
overviewEl.style.display = '';
}
@@ -2038,7 +2071,7 @@ async function loadDataStamps() {
const ageDays = ageMs / (1000 * 60 * 60 * 24);
const stale = (src === 'citations' || src === 'reproduction_citations' || src === 'impact_factor' || src === 'rr_status') ? ageDays > 14 : ageDays > 3;
el.classList.toggle('stale', stale);
- const fmt = dt.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
+ const fmt = dt.toLocaleString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' });
el.innerHTML = `${label} last updated: ${fmt} `;
// Machine timestamps are shown as a readable date; the exact identifier stays in the tooltip.
if (meta.input_snapshot) {
@@ -2048,7 +2081,7 @@ async function loadDataStamps() {
}
if (meta.recalculated_at) el.innerHTML += ` · recalculated: ${new Date(meta.recalculated_at).toLocaleDateString()}`;
if (meta.partial_run) el.innerHTML += ' · Partial refresh: some records are unavailable';
- if (meta.source_url) el.innerHTML += ` · source `;
+ if (meta.source_url) el.innerHTML += ` · ${escapeHtml(label)} source `;
} else {
el.classList.add('missing');
el.innerHTML = `${label}: no snapshot yet `;
diff --git a/assets/chart-accessibility.js b/assets/chart-accessibility.js
index 004783db..e5eb3078 100644
--- a/assets/chart-accessibility.js
+++ b/assets/chart-accessibility.js
@@ -1,8 +1,16 @@
/* Every chart exposes the same plotted values as a keyboard-accessible table and CSV. */
window.FloraCharts = (() => {
const escape = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
- const plain = s => String(s ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
- const fmt = v => typeof v === 'number' ? (Number.isInteger(v) ? String(v) : Number(v.toFixed(5)).toString()) : plain(v);
+ const plain = s => (Array.isArray(s) ? s.join(' ') : String(s ?? '')).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
+ const number = (v, digits = 0) => Number(v).toLocaleString('en-GB', {minimumFractionDigits:digits,maximumFractionDigits:digits}).replace(/-/g, '−');
+ // Integer formatting alone rounds fractional auto-ticks into repeated labels.
+ // Keep small count axes integral; let Plotly choose readable steps for larger counts.
+ const countAxis = max => ({tickformat:',d', dtick:max <= 10 ? 1 : undefined});
+ const percent = (n, total, digits = 0) => {
+ const share = total > 0 ? 100 * (n || 0) / total : 0;
+ return share > 0 && share < 1 ? '<1' : number(share, digits);
+ };
+ const fmt = (v, header = '') => typeof v === 'number' ? v.toLocaleString('en-GB', {useGrouping:!/year|X \/ category/i.test(header),maximumFractionDigits:5}).replace(/-/g, '−') : plain(v);
const comparisonIds = new Set(['ao-chart', 'rr-chart', 'pub-chart', 'ls-chart']);
// Chart colours resolve from CSS tokens at render time, so every chart follows the theme.
const token = (name, fallback = '') => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
@@ -30,7 +38,7 @@ window.FloraCharts = (() => {
});
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, '""') + '"';
+ const cell = v => '"' + (typeof v === 'number' ? String(Number(v.toFixed(5))) : (Array.isArray(v) ? plain(v) : String(v ?? '')).replace(/^[=+@-]/, m => "'" + m)).replace(/"/g, '""') + '"';
return [notes ? ['Export context', ...headers] : headers, ...rows.map(row => notes ? [notes,...row] : row)].map(row => row.map(cell).join(',')).join('\r\n');
}
function download(name, headers, rows, notes = '') {
@@ -76,7 +84,7 @@ window.FloraCharts = (() => {
anchor.insertAdjacentElement('afterend', details);
}
const wasOpen = details.open;
- details.innerHTML = `View chart data ${escape(summary)}
${escape(context(el))}
`;
+ details.innerHTML = `View chart data ${escape(summary)}
${escape(context(el))}
`;
details.open = wasOpen;
const label = el.closest('.outcome-chart-row')?.querySelector('.outcome-chart-label') || el.closest('.trend-block')?.querySelector('.trend-title');
let action = document.getElementById(id + '-copy');
@@ -118,7 +126,7 @@ window.FloraCharts = (() => {
const total = datasets.reduce((n,d)=>n+d.data.reduce((sum,v)=>sum+(Number(v)||0),0),0);
const isOutcome = /outcome|computational|robustness/.test(el.id);
const eligible = el.id.startsWith("trend-") ? trendsFilteredData().length : el.id.startsWith("browse-") ? getChartData().length : fullRowData.filter(r => classifyKind(r) === (/computational|robustness/.test(el.id) ? "reproduction" : "replication")).length;
- 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.');
+ const sample = `Included in the plot: ${number(total)}; excluded from this selection: ${number(Math.max(0,eligible-total))} reference ${eligible-total === 1 ? 'pair' : '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;
@@ -156,14 +164,15 @@ window.FloraCharts = (() => {
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));
- display = display.map(t=>({...t, x:t.x.map((v,i)=>plain(v).replace(/(.{1,18})(?:\s|$)/g, '$1 ').replace(/ $/, '')+` N=${totals[i]}`),
+ display = display.map(t=>({...t, x:t.x.map((v,i)=>plain(v).replace(/(.{1,18})(?:\s|$)/g, '$1 ').replace(/ $/, '')+` N=${number(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} %{y} reference pairs%{fullData.name} ' : '%{x} %{y:.1f}% (%{customdata[0]} pairs)%{fullData.name} '}));
+ customdata:t.y.map((v,i)=>[v,totals[i], escape(percent(v,totals[i],1))]),
+ hovertemplate:counts ? '%{x} %{y:,.0f} reference pairs%{fullData.name} ' : '%{x} %{customdata[2]}% (%{customdata[0]:,.0f} pairs)%{fullData.name} '}));
layout.barmode = counts ? 'group' : 'stack';
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.';
+ const maxCount = Math.max(0,...traces.flatMap(t=>t.y));
+ layout.yaxis = {...layout.yaxis,title:{text:counts ? 'Reference pairs' : 'Within-group percentage',standoff:12}, ...(counts ? countAxis(maxCount) : {tickformat:'.0f',dtick:undefined}), 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 authorship or Registered Report 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) {
toggle=document.createElement('button');toggle.id=id+'-mode';toggle.type='button';toggle.className='chart-mode';el.before(toggle);
@@ -204,6 +213,8 @@ window.FloraCharts = (() => {
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};
+ // Initial loading messages are not Plotly-owned nodes and otherwise survive react().
+ if (!el.data) el.replaceChildren();
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)) {
@@ -211,7 +222,7 @@ window.FloraCharts = (() => {
traces.forEach(t => (t.x || []).forEach((group, i) => {
const count = Number(t.y?.[i]) || 0;
const denominator = traces.reduce((n, series) => n + (Number(series.y?.[i]) || 0), 0);
- rows.push([plain(group), plain(t.name), count, denominator, denominator ? Number((100 * count / denominator).toFixed(1)) : 'Not available']);
+ rows.push([plain(group), plain(t.name), count, denominator, denominator ? count > 0 && 100 * count / denominator < 1 ? '<1' : Number((100 * count / denominator).toFixed(1)) : 'Not available']);
}));
table(el, rows, ['Group', 'Outcome', 'Reference pairs', 'Group denominator', 'Percentage (%)'], summary);
return Plotly.react(el, display, layout, config);
@@ -236,5 +247,5 @@ window.FloraCharts = (() => {
if(el)d.querySelector('.chart-context').textContent=context(el);
});
}
- return {chart,plot,clear,download,updateContexts, token, theme};
+ return {chart,plot,clear,download,updateContexts, token, theme, number, percent, countAxis};
})();
diff --git a/assets/citation-impact.js b/assets/citation-impact.js
index 009295e6..e3753bad 100644
--- a/assets/citation-impact.js
+++ b/assets/citation-impact.js
@@ -4,7 +4,7 @@
(function() {
const CI = {
- meta: null, agg: null, index: null, fect: null, cocitBreakdown: null,
+ meta: null, agg: null, index: null, cocitBreakdown: null,
studyCache: {}, currentDoi: null,
outcome: ['all','failed','mixed','successful'].includes(new URLSearchParams(location.search).get('ci-outcome')) ? new URLSearchParams(location.search).get('ci-outcome') : 'all', page: 1, perPage: 20,
sortCol: 'n_citations', sortDir: 'desc',
@@ -108,12 +108,6 @@
CI.agg = await aggRes.json();
CI.index = buildSearchIndex(await idxRes.json());
- // Optional: ETWFE results (may not exist on first run)
- try {
- const fectRes = await fetch('data/fect_results.json');
- if (fectRes.ok) CI.fect = await fectRes.json();
- } catch (_) {}
-
// Optional: co-citation breakdown by pub status / outcome (may not exist on first run)
try {
const breakdownRes = await fetch('data/cocit_breakdown.json');
@@ -195,10 +189,10 @@
const row = data[b.key] || {};
return '' +
'' + escapeHtml(b.label) + ' ' +
- '' + (row.n_originals || 0).toLocaleString() + ' ' +
- '' + (row.n_citations_to_original || 0).toLocaleString() + ' ' +
- '' + (row.n_citations_to_reproduction || 0).toLocaleString() + ' ' +
- '' + (row.n_cocitations || 0).toLocaleString() + ' ' +
+ '' + FloraCharts.number(row.n_originals || 0) + ' ' +
+ '' + FloraCharts.number(row.n_citations_to_original || 0) + ' ' +
+ '' + FloraCharts.number(row.n_citations_to_reproduction || 0) + ' ' +
+ '' + FloraCharts.number(row.n_cocitations || 0) + ' ' +
' ';
}).join('');
}
@@ -223,9 +217,7 @@
⏳ Citation data not yet available.
- The first weekly refresh has not completed yet (or the workflow secrets are not set).
- This panel will populate automatically once the GitHub Action
- refresh-data.yml finishes.
+ Citation data could not be loaded. Please try again later.
`;
const kpis = document.getElementById('kpis'); if (kpis) kpis.innerHTML = msg;
['plot-cit', 'plot-cocit'].forEach(id => {
@@ -238,7 +230,7 @@
function renderKPIs() {
const c = (CI.meta && CI.meta.outcome_counts) || {};
- const fmt = n => (n == null ? '—' : Number(n).toLocaleString());
+ const fmt = n => (n == null ? '—' : FloraCharts.number(n));
document.getElementById('kpis').innerHTML = `
${fmt(CI.meta && CI.meta.n_originals)}
Original reports with complete citation data
${fmt(CI.meta && CI.meta.n_replications)}
Eligible replication reference pairs
@@ -257,12 +249,12 @@
const hasModel = model.att != null && Number.isFinite(model.att);
if (hasModel) {
const interval = model.att_ci || [];
- const ci = interval.length === 2 && interval.every(Number.isFinite) ? ` (95% CI ${interval[0].toFixed(3)} to ${interval[1].toFixed(3)})` : '';
- html = `Adjusted post-replication association: ${model.att.toFixed(3)} log(1 + citations) units${ci}, based on ${model.n_units} originals. This is not a percentage change in raw citations or evidence that replication caused the change.`;
+ const ci = interval.length === 2 && interval.every(Number.isFinite) ? ` (95% CI ${FloraCharts.number(interval[0],3)} to ${FloraCharts.number(interval[1],3)})` : '';
+ html = `Adjusted post-replication association: ${FloraCharts.number(model.att,3)} log(1 + citations) units${ci}, based on ${FloraCharts.number(model.n_units)} originals. This is not a percentage change in raw citations or evidence that replication caused the change.`;
} else if (desc.n_units && desc.n_units.length) {
const maxN = Math.max(...desc.n_units);
html = `Descriptive trajectory shown.
- ${maxN.toLocaleString()} originals contribute at peak. Each line shows the average citation count by years relative to the first replication. `;
+ ${FloraCharts.number(maxN)} originals contribute at peak. Each line shows the average citation count by years relative to the first replication. `;
} else {
html = `Insufficient data for the selected outcome group.`;
}
@@ -279,7 +271,8 @@
traces.push({
x: desc.event_time, y: desc[descField], type: 'scatter', mode: 'lines+markers',
line: { color, width: 2.5 }, marker: { color, size: 7 },
- name: 'Mean (raw)', hovertemplate: 't=%{x} years: %{y:.2f} '
+ name: 'Mean (raw)', text:desc.event_time.map((x,i)=>`Years from first replication: ${FloraCharts.number(x)} ${ylabel}: ${FloraCharts.number(desc[descField][i],2)}`),
+ hovertemplate: '%{text} '
});
}
if (desc.event_time && desc.n_units && desc.n_units.length) {
@@ -287,7 +280,8 @@
x: desc.event_time, y: desc.n_units, type: 'scatter', mode: 'lines',
line: { color: plotlyTheme().rule, width: 1, dash: 'dot' },
name: 'N (right axis)', yaxis: 'y2',
- hovertemplate: 't=%{x}: N=%{y} '
+ text:desc.event_time.map((x,i)=>`Years from first replication: ${FloraCharts.number(x)} Original reports: ${FloraCharts.number(desc.n_units[i])}`),
+ hovertemplate: '%{text} '
});
}
const modelId = divId + '-model';
@@ -301,7 +295,8 @@
if (valid.length) {
const t = plotlyTheme();
FloraCharts.plot(modelId, [{name:'OLS coefficient (95% CI)', x:valid.map(p=>p.x), y:valid.map(p=>p.y),
- type:'scatter', mode:'markers', error_y:{type:'data', symmetric:false,
+ type:'scatter', mode:'markers', text:valid.map(p=>`Years from first replication: ${FloraCharts.number(p.x)} Coefficient: ${FloraCharts.number(p.y,3)} 95% CI: ${FloraCharts.number(p.lo,3)} to ${FloraCharts.number(p.hi,3)}`),
+ hovertemplate:'%{text} ', error_y:{type:'data', symmetric:false,
array:valid.map(p=>p.hi-p.y), arrayminus:valid.map(p=>p.y-p.lo)}, marker:{color:t.font}}], {
title:{text:'Adjusted association: log(1 + count)', font:{size:14}}, height:340,
xaxis:{title:'Years from first replication', automargin:true, color:t.font},
@@ -325,7 +320,7 @@
xaxis: { title: 'Years relative to first replication', zeroline: false, gridcolor: t.grid, color: t.font },
yaxis: { title: ylabel, gridcolor: t.grid, color: t.font, rangemode: 'tozero' },
yaxis2: {
- title: 'N studies', overlaying: 'y', side: 'right', showgrid: false, rangemode: 'tozero',
+ title: 'N studies', ...FloraCharts.countAxis(Math.max(0,...(desc.n_units || []))), overlaying: 'y', side: 'right', showgrid: false, rangemode: 'tozero',
tickfont: { color: t.muted, size: 10 }, titlefont: { color: t.muted, size: 10 }
},
shapes: [{ type: 'line', x0: 0, x1: 0, yref: 'paper', y0: 0, y1: 1, line: { color: t.primary, width: 1.5, dash: 'dash' } }],
@@ -345,13 +340,13 @@
const count = outcomeMix[o] || 0;
if (!count) return '';
const pct = (count / total * 100).toFixed(1);
- const label = `${count} ${o}`;
+ const label = `${FloraCharts.number(count)} ${o}`;
return `
`;
}).join('');
- const tooltipParts = ORDERS.filter(o => outcomeMix[o]).map(o => `${outcomeMix[o]} ${o}`).join(', ');
+ const tooltipParts = ORDERS.filter(o => outcomeMix[o]).map(o => `${FloraCharts.number(outcomeMix[o])} ${o}`).join(', ');
return `
${segments}
-
${total}
+
${FloraCharts.number(total)}
`;
}
@@ -363,10 +358,10 @@
function renderBreakdownTable(title, dim, labels, note) {
const rows = Object.keys(labels).map(key => {
const d = dim[key] || {};
- const fmt = v => v == null ? '—' : (v * 100).toFixed(1) + '%';
+ const fmt = v => v == null ? '—' : escapeHtml(FloraCharts.percent(v,1,1)) + '%';
return `
${labels[key]}
- ${d.n_originals != null ? d.n_originals.toLocaleString() : '—'}
+ ${d.n_originals != null ? FloraCharts.number(d.n_originals) : '—'}
${fmt(d.mean_rate)}
${fmt(d.median_rate)}
${fmt(d.grand_mean_rate)}
@@ -395,11 +390,11 @@
function renderCocitCell(s) {
if (!s.n_cocitations && s.n_cocitations !== 0) return '—';
const denom = s.n_citations_post_first_rep;
- const pct = denom > 0 ? (s.cocit_prop * 100).toFixed(1) + '%' : '—';
+ const pct = denom > 0 ? escapeHtml(FloraCharts.percent(s.cocit_prop,1,1)) + '%' : '—';
const tooltip = denom
- ? `${s.n_cocitations.toLocaleString()} of ${denom.toLocaleString()} citations since the first replication (${s.first_replication_year}) also cite a replication`
- : `${s.n_cocitations.toLocaleString()} co-citations`;
- return `${s.n_cocitations.toLocaleString()} (${pct}${denom > 0 ? ` of ${denom.toLocaleString()}` : ""}) `;
+ ? `${FloraCharts.number(s.n_cocitations)} of ${FloraCharts.number(denom)} citations since the first replication (${s.first_replication_year}) also cite a replication`
+ : `${FloraCharts.number(s.n_cocitations)} co-citations`;
+ return `${FloraCharts.number(s.n_cocitations)} (${pct}${denom > 0 ? ` of ${FloraCharts.number(denom)}` : ""}) `;
}
function renderTable() {
@@ -450,7 +445,7 @@
${s.year || '—'}
${renderOutcomeBar(s.outcome_mix, s.n_replications)}
- ${(s.n_citations || 0).toLocaleString()}
+ ${FloraCharts.number(s.n_citations || 0)}
${renderCocitCell(s)}
Timeline
`;
@@ -538,7 +533,7 @@
Citation timeline
${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})
+ Replications (${FloraCharts.number(s.n_replications)})
`;
// Remember what had focus so we can restore it when the modal closes.
@@ -603,8 +598,9 @@
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} ';
+ trace.hovertemplate = 'Year: %{x} Citations: %{y:,.0f}%{fullData.name} ';
});
+ const maxYearlyCount = Math.max(0,...years.map((_,i)=>traces.reduce((n,trace)=>n+trace.y[i],0)));
const t = plotlyTheme();
const shapes = []; const annotations = [];
const narrow = window.innerWidth < TIMELINE_NARROW;
@@ -626,7 +622,7 @@
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 },
+ yaxis: { title: 'Citing preprints / papers', ...FloraCharts.countAxis(maxYearlyCount), gridcolor: t.grid, color: t.font },
shapes, annotations, hoverlabel:{namelength:-1},
plot_bgcolor: t.plot, paper_bgcolor: t.paper,
legend: { orientation: 'h', y: -0.18, font: { color: t.font } },
diff --git a/assets/view-controls.js b/assets/view-controls.js
index 305e7534..66db441b 100644
--- a/assets/view-controls.js
+++ b/assets/view-controls.js
@@ -132,7 +132,7 @@
const stamp=document.querySelector('#browse .data-stamp')?.textContent || '';
const filtered=browseQuery.trim() || browseKind !== 'all';
FloraCharts.download(filtered ? 'flora-filtered-reference-pairs.csv' : 'flora-reference-pairs.csv',keys,rows.map(row=>keys.map(k=>row[k])),
- `Unit: reference pair\n${stamp}\nStudy kind: ${browseKind}\nQuery: ${browseQuery}\nIncluded: ${rows.length}; excluded by filters: ${fullRowData.length-rows.length}\nQualified success is separate from unqualified success; reproduction dimensions overlap.`);
+ `Unit: reference pair\n${stamp}\nStudy kind: ${browseKind}\nQuery: ${browseQuery}\nIncluded: ${rows.length.toLocaleString('en-GB')}; excluded by filters: ${(fullRowData.length-rows.length).toLocaleString('en-GB')}\nQualified success is separate from unqualified success; reproduction dimensions overlap.`);
};
// Associate static and dynamically inserted glossary definitions with their triggers.
let tipNumber = 0;
diff --git a/index.html b/index.html
index 452b3d4e..2dc41cad 100644
--- a/index.html
+++ b/index.html
@@ -4,13 +4,13 @@
- FLoRA Explorer: FORRT Library of Replication Attempts
-
+ FLoRA Explorer: FORRT Library of Reproduction and Replication Attempts
+
-
-
+
+
@@ -39,7 +39,7 @@
FLoRA
- Replication Explorer
+ Explorer
@@ -89,7 +89,7 @@
-
Explore replication and reproduction evidence Find original reports, attempts to repeat them, and the evidence behind each recorded outcome. Each row is a reference pair : one original report and one repetition report. A report can occur in several pairs. Use Browse Studies to search and filter reference pairs here, or the Replication Atlas to see the replication history of one original study.
+
Explore replication and reproduction evidence The FORRT Library of Reproduction and Replication Attempts (FLoRA) is a project of the Framework for Open and Reproducible Research Training (FORRT).
Find original reports, attempts to repeat them, and the evidence behind each recorded outcome. Each row is a reference pair : one original report and one repetition report. A report can occur in several pairs. Use Browse Studies to search and filter reference pairs here, or the Replication Atlas to see the replication history of one original study.
@@ -182,7 +182,7 @@
What is a reproduction?
Structure of FLoRA
-
FLoRA links each repetition reference to its original, and assigns a standardised outcome based on the repetition report. This simple structure lets us describe recorded replication and reproduction outcomes across time, disciplines and venues, and powers tools like the FLoRA Annotator, which lists replication and reproduction attempts for any reference you provide.
+
FLoRA links each repetition reference to its original, and assigns a standardised outcome based on the repetition report. This simple structure lets us describe recorded replication and reproduction outcomes across time, disciplines and venues, and powers tools like the FLoRA Zotero Plugin, which lists replication and reproduction attempts for any reference you provide.
Is FLoRA representative and comprehensive?
Honestly — neither. We are still working on a systematic literature search, and several structural factors limit coverage:
@@ -403,12 +403,12 @@
Original Study
Year
-
Replication Study
+
Replication or reproduction study
Year
Outcome
Type
Original DOI
-
Replication Report
+
Replication or reproduction report
@@ -483,7 +483,7 @@
Years & Disciplines
Outcomes by original journal
The journals whose articles have most frequently been the target of replication attempts. For each journal, the bar shows the outcome breakdown of those attempts.
-
Frequency here reflects where replication attention has been focused, not necessarily a journal's overall replicability .
+
Frequency shows where replication effort has gone, not how replicable a journal's research is.
Show top:
@@ -500,16 +500,16 @@ Years & Disciplines
Outcomes by recorded replication venue
-
Each bar groups reference pairs by the venue recorded for the replication report, which can be a journal or a repository such as OSF, split by the selected outcome.
+
Each bar groups reference pairs by the venue recorded for the replication report, which can be a journal or a repository such as OSF, split by recorded outcome.
Counts reflect FLoRA’s coverage, and one report may contribute several pairs, so bars do not rank how actively venues publish replication research.
Annotation of replication -journal data is a work in progress; some journals may be missing or under-represented at the moment.
Show top:
- 10 journals
- 15 journals
- 25 journals
- 50 journals
+ 10 venues
+ 15 venues
+ 25 venues
+ 50 venues
@@ -520,8 +520,8 @@
Years & Disciplines
Outcomes by field
A bird's-eye view grouping reference pairs by research discipline. Each bar shows the outcome breakdown of replication attempts targeting work originally published in journals from that field.
-
Disciplines are inferred from the original study's journal name using a hand-curated dictionary (data/disciplines.json ). Journals that have not been mapped yet are collected under Uncategorized .
-
Coverage is still growing. Many journals are not yet classified, so some fields may be under-represented and Uncategorized may dominate. The dictionary is straightforward to extend.
+
Disciplines are inferred from the original study's journal name using a hand-curated dictionary (journal-to-discipline mapping ). Journals that have not been mapped yet are collected under Uncategorized .
+
Coverage is still growing. Journals that are not yet classified may leave some fields under-represented.
@@ -635,7 +635,7 @@