From c07bf7120dba90736b01e9212c05a642069ff06b Mon Sep 17 00:00:00 2001 From: Rohan Balkondekar Date: Tue, 24 Mar 2026 19:07:13 +0800 Subject: [PATCH] Add downloadable table feature with CSV, JSON, and Excel export Users can now download the leaderboard table data via a Download button next to Clear Filters. A two-column panel lets users choose scope (Current View or Full Data) and format (CSV, JSON, Excel - multi-select). - Export values are numeric (not formatted strings) for proper use in spreadsheets and programmatic consumption - Full Data export includes all models even without metadata - xlsx dependency added for Excel export via SheetJS --- package-lock.json | 106 ++++++++++++++++++++++++++++++++- package.json | 3 +- src/App.css | 87 ++++++++++++++++++++++++++- src/Table/CSVTable.jsx | 86 ++++++++++++++++++++++++++- src/Table/downloadTable.js | 118 +++++++++++++++++++++++++++++++++++++ 5 files changed, 393 insertions(+), 7 deletions(-) create mode 100644 src/Table/downloadTable.js diff --git a/package-lock.json b/package-lock.json index f6f1b71..80882c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,8 @@ "react-router-dom": "^6.23.1", "react-scripts": "^5.0.1", "react-select": "^5.8.3", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "xlsx": "^0.18.5" }, "devDependencies": { "gh-pages": "^6.2.0" @@ -4474,6 +4475,15 @@ "node": ">=8.9" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -5551,6 +5561,19 @@ "node": ">=4" } }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5763,6 +5786,15 @@ "node": ">=4" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -6001,6 +6033,18 @@ "node": ">=10" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -8452,6 +8496,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -15380,6 +15433,18 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stable": { "version": "0.1.8", "resolved": "https://registry.npmjs.org/stable/-/stable-0.1.8.tgz", @@ -17365,6 +17430,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -17771,6 +17854,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xml-name-validator": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", diff --git a/package.json b/package.json index 99f04d5..55c5772 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,8 @@ "react-router-dom": "^6.23.1", "react-scripts": "^5.0.1", "react-select": "^5.8.3", - "web-vitals": "^2.1.4" + "web-vitals": "^2.1.4", + "xlsx": "^0.18.5" }, "scripts": { "start": "react-scripts start", diff --git a/src/App.css b/src/App.css index 95906ef..2fec97d 100644 --- a/src/App.css +++ b/src/App.css @@ -242,16 +242,97 @@ td { } .other-controls { + margin-bottom: 0.5rem; +} + +.action-buttons { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + gap: 0.5rem; margin-bottom: 1rem; + overflow: visible; +} + +.clear-filters-button, +.download-button { + background-color: #007bff; + color: #fff; + border: none; + padding: 0.3rem 0.75rem; + font-size: 0.85rem; + border-radius: 3px; + cursor: pointer; +} + +.clear-filters-button:hover, +.download-button:hover { + background-color: #0056b3; +} + +.download-dropdown { + position: relative; + display: inline-block; +} + +.download-panel { + position: fixed; + z-index: 1000; + background: #fff; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + padding: 0.8rem; + min-width: 280px; +} + +.download-panel-body { + display: flex; + gap: 1.5rem; } -.clear-filters-button { +.download-panel-heading { + font-size: 0.75rem; + font-weight: 600; + color: #666; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.download-panel-scope label, +.download-panel-formats label { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.25rem 0; + cursor: pointer; + font-size: 0.9rem; + color: #333; + white-space: nowrap; +} + +.download-panel-action { + display: block; + width: 100%; + margin-top: 0.8rem; + padding: 0.45rem 0; background-color: #007bff; color: #fff; border: none; - padding: 0.5rem 1rem; + border-radius: 3px; cursor: pointer; - margin-left: 1rem; + font-size: 0.9rem; +} + +.download-panel-action:hover:not(:disabled) { + background-color: #0056b3; +} + +.download-panel-action:disabled { + background-color: #ccc; + cursor: not-allowed; } .section { diff --git a/src/Table/CSVTable.jsx b/src/Table/CSVTable.jsx index 7afd893..8b3c720 100644 --- a/src/Table/CSVTable.jsx +++ b/src/Table/CSVTable.jsx @@ -1,11 +1,12 @@ // src/Table/CSVTable.jsx -import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import Papa from 'papaparse'; import { calculateAverage, getGlobalAverage } from './Averaging'; import { useTable } from "./SortTable"; import { getModelInfo, getVariantGroup } from './modelLinks'; import { useSearchParams } from 'react-router-dom'; import Select from 'react-select'; +import { buildCurrentViewData, buildFullData, downloadCSV, downloadJSON, downloadExcel } from './downloadTable'; const CSVTable = ({dateStr}) => { @@ -24,7 +25,25 @@ const CSVTable = ({dateStr}) => { const [showVariants, setShowVariants] = useState(false); const [showHighUnseenBias, setShowHighUnseenBias] = useState(true); - const updateURL = (checkedCategories, newFilter, newSortField = null, newSortOrder = null, newShowProvider = null, newShowApiName = null, newShowReasoners = null, newShowOpenWeights = null, newShowVariants = null, newShowHighUnseenBias = null, newSearchQuery = null) => { + const [showDownloadMenu, setShowDownloadMenu] = useState(false); + const [downloadScope, setDownloadScope] = useState('current'); + const [downloadFormats, setDownloadFormats] = useState({ csv: false, json: false, xlsx: false }); + const [panelPos, setPanelPos] = useState({ top: 0, left: 0 }); + const downloadMenuRef = useRef(null); + const downloadBtnRef = useRef(null); + + useEffect(() => { + if (!showDownloadMenu) return; + const handleClickOutside = (e) => { + if (downloadMenuRef.current && !downloadMenuRef.current.contains(e.target)) { + setShowDownloadMenu(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [showDownloadMenu]); + + const updateURL =(checkedCategories, newFilter, newSortField = null, newSortOrder = null, newShowProvider = null, newShowApiName = null, newShowReasoners = null, newShowOpenWeights = null, newShowVariants = null, newShowHighUnseenBias = null, newSearchQuery = null) => { const params = new URLSearchParams(); let allAverages = true; @@ -426,6 +445,18 @@ const CSVTable = ({dateStr}) => { updateURL(defaultCategories, {}, 'ga', 'desc', true, false, true, false, false, false, ''); } + const handleDownload = () => { + const rows = downloadScope === 'current' + ? buildCurrentViewData(displayedData, checkedCategories, categories, { showProvider, showApiName, displayNameCounts }) + : buildFullData(data, categories); + const filename = `livebench_${downloadScope === 'current' ? 'current_view' : 'full_data'}`; + if (downloadFormats.csv) downloadCSV(rows, `${filename}.csv`); + if (downloadFormats.json) downloadJSON(rows, `${filename}.json`); + if (downloadFormats.xlsx) downloadExcel(rows, `${filename}.xlsx`); + setDownloadFormats({ csv: false, json: false, xlsx: false }); + setShowDownloadMenu(false); + }; + // Utility to compute class for sorting const getSortClass = (accessor) => { return sortField === accessor ? (sortOrder === "asc" ? "up" : "down") : "default"; @@ -559,7 +590,58 @@ const CSVTable = ({dateStr}) => { setShowHighUnseenBias(!showHighUnseenBias)} id="showHighUnseenBias" /> Show High Unseen Question Bias Models + +
+
+ + {showDownloadMenu && ( +
+
+
+
Data
+ + +
+
+
Format
+ + + +
+
+ +
+ )} +
{ + if (!columns) return null; + const validValues = columns.map(col => parseFloat(row[col])).filter(val => !isNaN(val)); + if (validValues.length === 0) return null; + return Math.round((validValues.reduce((a, b) => a + b, 0) / validValues.length) * 100) / 100; +}; + +const numericGlobalAverage = (row, checkedCategories, categories) => { + if (row['model'] === 'grok-3-thinking') return 72; + if (row['model'] === 'grok-3') return 58; + const averages = Object.entries(checkedCategories).flatMap(([category, checks]) => + (checks.average || checks.allSubcategories) ? [numericAverage(row, categories[category])] : [] + ).filter(v => v !== null); + if (averages.length === 0) return null; + return Math.round((averages.reduce((a, b) => a + b, 0) / averages.length) * 100) / 100; +}; + +export const buildCurrentViewData = (displayedData, checkedCategories, categories, { showProvider, showApiName, displayNameCounts }) => { + const numCheckedCategories = Object.values(checkedCategories).filter(cat => cat.average || cat.allSubcategories).length; + return displayedData.map(row => { + const info = getModelInfo(row.model); + + const displayName = showApiName ? row.model : (() => { + const name = info?.displayName ?? row.model; + const version = info?.version; + if (displayNameCounts[name] > 1 && version !== undefined) { + return `${name} (${version})`; + } + return name; + })(); + + const obj = { Model: displayName }; + + if (showProvider) { + obj['Organization'] = info?.organization ?? ''; + } + + if (numCheckedCategories > 1) { + obj['Global Average'] = numericGlobalAverage(row, checkedCategories, categories); + } + + Object.entries(checkedCategories).forEach(([category, checks]) => { + if (checks.average) { + obj[`${category} Average`] = numericAverage(row, categories[category]); + } + if (checks.allSubcategories) { + categories[category].forEach(subCat => { + const val = parseFloat(row[subCat]); + obj[subCat] = isNaN(val) ? null : val; + }); + } + }); + + return obj; + }); +}; + +export const buildFullData = (data, categories) => { + return data.map(row => { + const info = getModelInfo(row.model); + + const allChecked = Object.keys(categories).reduce((acc, cat) => { + acc[cat] = { average: true, allSubcategories: false }; + return acc; + }, {}); + + const obj = { + Model: info?.displayName ?? row.model, + 'API Name': row.model, + Organization: info?.organization ?? '', + 'Global Average': numericGlobalAverage(row, allChecked, categories), + }; + + Object.entries(categories).forEach(([category, columns]) => { + obj[`${category} Average`] = numericAverage(row, columns); + columns.forEach(col => { + const val = parseFloat(row[col]); + obj[col] = isNaN(val) ? null : val; + }); + }); + + return obj; + }); +}; + +const triggerDownload = (blob, filename) => { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(url), 200); +}; + +export const downloadCSV = (rows, filename) => { + const csv = Papa.unparse(rows); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + triggerDownload(blob, filename); +}; + +export const downloadJSON = (rows, filename) => { + const json = JSON.stringify(rows, null, 2); + const blob = new Blob([json], { type: 'application/json;charset=utf-8;' }); + triggerDownload(blob, filename); +}; + +export const downloadExcel = async (rows, filename) => { + const XLSX = await import('xlsx'); + const ws = XLSX.utils.json_to_sheet(rows); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, 'LiveBench'); + XLSX.writeFile(wb, filename); +};