add web-ui
60 changed files+12572−0
web-ui/.gitignoreadded+24−0View file
@@ -0,0 +1,24 @@
1+# Logs
2+logs
3+*.log
4+npm-debug.log*
5+yarn-debug.log*
6+yarn-error.log*
7+pnpm-debug.log*
8+lerna-debug.log*
9+
10+node_modules
11+dist
12+dist-ssr
13+*.local
14+
15+# Editor directories and files
16+.vscode/*
17+!.vscode/extensions.json
18+.idea
19+.DS_Store
20+*.suo
21+*.ntvs*
22+*.njsproj
23+*.sln
24+*.sw?
web-ui/README.mdadded+50−0View file
@@ -0,0 +1,50 @@
1+# React + TypeScript + Vite
2+
3+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4+
5+Currently, two official plugins are available:
6+
7+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
8+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9+
10+## Expanding the ESLint configuration
11+
12+If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
13+
14+- Configure the top-level `parserOptions` property like this:
15+
16+```js
17+export default tseslint.config({
18+ languageOptions: {
19+ // other options...
20+ parserOptions: {
21+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
22+ tsconfigRootDir: import.meta.dirname,
23+ },
24+ },
25+})
26+```
27+
28+- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
29+- Optionally add `...tseslint.configs.stylisticTypeChecked`
30+- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
31+
32+```js
33+// eslint.config.js
34+import react from 'eslint-plugin-react'
35+
36+export default tseslint.config({
37+ // Set the react version
38+ settings: { react: { version: '18.3' } },
39+ plugins: {
40+ // Add the react plugin
41+ react,
42+ },
43+ rules: {
44+ // other rules...
45+ // Enable its recommended rules
46+ ...react.configs.recommended.rules,
47+ ...react.configs['jsx-runtime'].rules,
48+ },
49+})
50+```
web-ui/eslint.config.jsadded+28−0View file
@@ -0,0 +1,28 @@
1+import js from '@eslint/js'
2+import globals from 'globals'
3+import reactHooks from 'eslint-plugin-react-hooks'
4+import reactRefresh from 'eslint-plugin-react-refresh'
5+import tseslint from 'typescript-eslint'
6+
7+export default tseslint.config(
8+ { ignores: ['dist'] },
9+ {
10+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
11+ files: ['**/*.{ts,tsx}'],
12+ languageOptions: {
13+ ecmaVersion: 2020,
14+ globals: globals.browser,
15+ },
16+ plugins: {
17+ 'react-hooks': reactHooks,
18+ 'react-refresh': reactRefresh,
19+ },
20+ rules: {
21+ ...reactHooks.configs.recommended.rules,
22+ 'react-refresh/only-export-components': [
23+ 'warn',
24+ { allowConstantExport: true },
25+ ],
26+ },
27+ },
28+)
web-ui/index.htmladded+26−0View file
@@ -0,0 +1,26 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <script>
6+ // Single Page Apps for GitHub Pages
7+ // MIT License
8+ // https://github.com/rafgraph/spa-github-pages
9+ (function(l) {
10+ if (l.search[1] === '/' ) {
11+ var decoded = l.search.slice(1).split('&')[0].split('=')[0].replace(/~and~/g, '&');
12+ window.history.replaceState(null, null,
13+ l.pathname.slice(0, -1) + decoded + l.hash
14+ );
15+ }
16+ }(window.location))
17+ </script>
18+ <link rel="icon" type="image/svg+xml" href="./favicon.svg" />
19+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
20+ <title>Ephys Compression Tests</title>
21+ </head>
22+ <body>
23+ <div id="root"></div>
24+ <script type="module" src="./src/main.tsx"></script>
25+ </body>
26+</html>
web-ui/package-lock.jsonadded+7968−0View file
This diff is 7,973 lines long and is not shown.
web-ui/package.jsonadded+47−0View file
@@ -0,0 +1,47 @@
1+{
2+ "name": "web-ui",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "../devel/generate_posts_index.sh && vite",
8+ "build": "tsc -b && vite build",
9+ "lint": "eslint .",
10+ "preview": "vite preview",
11+ "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx}\"",
12+ "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx}\""
13+ },
14+ "dependencies": {
15+ "@tanstack/react-table": "^8.20.6",
16+ "@types/plotly.js": "^2.35.2",
17+ "axios": "^1.7.9",
18+ "katex": "^0.16.21",
19+ "plotly.js-dist-min": "^2.35.3",
20+ "react": "^18.3.1",
21+ "react-dom": "^18.3.1",
22+ "react-markdown": "^9.0.3",
23+ "react-plotly.js": "^2.6.0",
24+ "react-router-dom": "^7.1.3",
25+ "rehype-katex": "^7.0.1",
26+ "remark-math": "^6.0.0",
27+ "yaml": "^2.7.0"
28+ },
29+ "devDependencies": {
30+ "@eslint/js": "^9.17.0",
31+ "@types/react": "^18.3.18",
32+ "@types/react-dom": "^18.3.5",
33+ "@types/react-plotly.js": "^2.6.3",
34+ "@vitejs/plugin-react": "^4.3.4",
35+ "autoprefixer": "^10.4.20",
36+ "eslint": "^9.17.0",
37+ "eslint-plugin-react-hooks": "^5.0.0",
38+ "eslint-plugin-react-refresh": "^0.4.16",
39+ "globals": "^15.14.0",
40+ "postcss": "^8.5.1",
41+ "prettier": "^3.4.2",
42+ "tailwindcss": "^4.0.0",
43+ "typescript": "~5.6.2",
44+ "typescript-eslint": "^8.18.2",
45+ "vite": "^6.0.5"
46+ }
47+}
web-ui/public/.gitignoreadded+1−0View file
@@ -0,0 +1 @@
1+*.pdf
web-ui/public/404.htmladded+26−0View file
@@ -0,0 +1,26 @@
1+<!DOCTYPE html>
2+<html>
3+ <head>
4+ <meta charset="utf-8">
5+ <title>Ephys Compression Tests</title>
6+ <script>
7+ // Single Page Apps for GitHub Pages
8+ // MIT License
9+ // https://github.com/rafgraph/spa-github-pages
10+ (function(){
11+ var pathSegmentsToKeep = 1;
12+
13+ var l = window.location;
14+ l.replace(
15+ l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '') +
16+ l.pathname.split('/').slice(0, 1 + pathSegmentsToKeep).join('/') + '/?/' +
17+ l.pathname.slice(1).split('/').slice(pathSegmentsToKeep).join('/').replace(/&/g, '~and~') +
18+ (l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
19+ l.hash
20+ );
21+ }());
22+ </script>
23+ </head>
24+ <body>
25+ </body>
26+</html>
web-ui/public/favicon.svgadded+31−0View file
@@ -0,0 +1,31 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
3+ <!-- Background shape -->
4+ <rect width="32" height="32" rx="6" fill="#f8f9fa"/>
5+
6+ <!-- Scientific waveform representing data -->
7+ <path d="M6 16 L10 16 L12 12 L14 20 L16 12 L18 20 L20 16 L24 16"
8+ stroke="#2563eb"
9+ stroke-width="2"
10+ fill="none"
11+ stroke-linecap="round"
12+ stroke-linejoin="round"/>
13+
14+ <!-- Compression brackets -->
15+ <path d="M4 8 L4 24 L8 20 M8 12 L4 8"
16+ stroke="#1e40af"
17+ stroke-width="2"
18+ fill="none"
19+ stroke-linecap="round"
20+ stroke-linejoin="round"/>
21+
22+ <path d="M28 8 L28 24 L24 20 M24 12 L28 8"
23+ stroke="#1e40af"
24+ stroke-width="2"
25+ fill="none"
26+ stroke-linecap="round"
27+ stroke-linejoin="round"/>
28+
29+ <!-- Central dot -->
30+ <circle cx="16" cy="16" r="1.5" fill="#1e40af"/>
31+</svg>
web-ui/public/logo.svgadded+31−0View file
@@ -0,0 +1,31 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
3+ <!-- Background shape -->
4+ <rect width="32" height="32" rx="6" fill="#f8f9fa"/>
5+
6+ <!-- Scientific waveform representing data -->
7+ <path d="M6 16 L10 16 L12 12 L14 20 L16 12 L18 20 L20 16 L24 16"
8+ stroke="#2563eb"
9+ stroke-width="2"
10+ fill="none"
11+ stroke-linecap="round"
12+ stroke-linejoin="round"/>
13+
14+ <!-- Compression brackets -->
15+ <path d="M4 8 L4 24 L8 20 M8 12 L4 8"
16+ stroke="#1e40af"
17+ stroke-width="2"
18+ fill="none"
19+ stroke-linecap="round"
20+ stroke-linejoin="round"/>
21+
22+ <path d="M28 8 L28 24 L24 20 M24 12 L28 8"
23+ stroke="#1e40af"
24+ stroke-width="2"
25+ fill="none"
26+ stroke-linecap="round"
27+ stroke-linejoin="round"/>
28+
29+ <!-- Central dot -->
30+ <circle cx="16" cy="16" r="1.5" fill="#1e40af"/>
31+</svg>
web-ui/public/vite.svgadded+1−0View file
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
web-ui/src/App.cssadded+35−0View file
@@ -0,0 +1,35 @@
1+/* Existing styles */
2+
3+/* Markdown content styles */
4+.markdown-content {
5+ font-family: system-ui, -apple-system, sans-serif;
6+ line-height: 1.6;
7+ color: #333;
8+}
9+
10+.markdown-content h1 {
11+ font-size: 2.2rem;
12+ margin-bottom: 1.5rem;
13+ color: #1a1a1a;
14+}
15+
16+.markdown-content h2 {
17+ font-size: 1.8rem;
18+ margin: 2rem 0 1rem;
19+ color: #1a1a1a;
20+}
21+
22+.markdown-content p {
23+ margin-bottom: 1.2rem;
24+ font-size: 1.1rem;
25+}
26+
27+.markdown-content ul {
28+ margin: 1rem 0;
29+ padding-left: 2rem;
30+}
31+
32+.markdown-content li {
33+ margin: 0.5rem 0;
34+ font-size: 1.1rem;
35+}
web-ui/src/App.tsxadded+173−0View file
@@ -0,0 +1,173 @@
1+import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
2+import { useEffect, useState } from "react";
3+import axios from "axios";
4+import { ScrollToTop } from "./components/ScrollToTop";
5+import "./components/AppHeader.css";
6+import Home from "./pages/Home";
7+import BenchmarkView from "./pages/BenchmarkView";
8+import Monitor from "./pages/Monitor";
9+import Submit from "./pages/Submit";
10+import { BenchmarkData } from "./types";
11+
12+function App() {
13+ const [benchmarkData, setBenchmarkData] = useState<BenchmarkData | null>(
14+ null,
15+ );
16+ const [isLoading, setIsLoading] = useState(true);
17+ const [error, setError] = useState<string | null>(null);
18+
19+ useEffect(() => {
20+ const fetchData = async () => {
21+ try {
22+ setIsLoading(true);
23+ setError(null);
24+ const cacheBust = Math.random().toString(36).substring(2, 15);
25+ const response = await axios.get(
26+ `https://tempory.net/f/memobin/ephys_compression_tests/global/results.json?cachebust=${cacheBust}`,
27+ );
28+ setBenchmarkData(response.data);
29+ } catch (error) {
30+ const message =
31+ error instanceof Error ? error.message : "Failed to fetch data";
32+ setError(message);
33+ console.error("Error fetching benchmark data:", error);
34+ } finally {
35+ setIsLoading(false);
36+ }
37+ };
38+
39+ fetchData();
40+ }, []);
41+
42+ return (
43+ <BrowserRouter basename="/ephys_compression_tests/">
44+ <ScrollToTop />
45+ <div
46+ style={{
47+ paddingTop: "3rem",
48+ padding: "3rem 2rem 2rem 2rem",
49+ }}
50+ >
51+ <nav
52+ style={{
53+ position: "fixed",
54+ top: 0,
55+ left: 0,
56+ right: 0,
57+ padding: "0.35rem min(2rem, 4%)",
58+ backgroundColor: "white",
59+ zIndex: 1000,
60+ }}
61+ >
62+ <div
63+ style={{
64+ display: "flex",
65+ justifyContent: "space-between",
66+ alignItems: "center",
67+ minHeight: "32px",
68+ }}
69+ >
70+ <Link
71+ to="/"
72+ style={{
73+ display: "flex",
74+ alignItems: "center",
75+ textDecoration: "none",
76+ minWidth: 0,
77+ maxWidth: "calc(100% - 80px)",
78+ }}
79+ >
80+ <img
81+ src="/ephys_compression_tests/logo.svg"
82+ alt="Ephys Compression Tests Logo"
83+ style={{
84+ width: "28px",
85+ height: "28px",
86+ marginRight: "10px",
87+ flexShrink: 0,
88+ }}
89+ />
90+ <span
91+ style={{
92+ minWidth: 0,
93+ whiteSpace: "nowrap",
94+ overflow: "hidden",
95+ textOverflow: "ellipsis",
96+ }}
97+ >
98+ <span
99+ style={{
100+ fontSize: "1rem",
101+ fontWeight: "500",
102+ color: "#2c2c2c",
103+ }}
104+ >
105+ Ephys Compression Tests
106+ </span>
107+ <span className="app-header-subtitle">
108+ {" · "}
109+ <span style={{ fontSize: "1rem", color: "#777" }}>
110+ Comparing compression algorithms for ephys data
111+ </span>
112+ </span>
113+ </span>
114+ </Link>
115+ <div style={{ display: "flex", gap: "1.5rem" }}>
116+ <Link
117+ to="/datasets"
118+ style={{
119+ color: "#0066cc",
120+ textDecoration: "none",
121+ fontWeight: "500",
122+ }}
123+ >
124+ Datasets
125+ </Link>
126+ <Link
127+ to="/algorithms"
128+ style={{
129+ color: "#0066cc",
130+ textDecoration: "none",
131+ fontWeight: "500",
132+ }}
133+ >
134+ Algorithms
135+ </Link>
136+ </div>
137+ </div>
138+ </nav>
139+ <main>
140+ {isLoading ? (
141+ <div>Loading benchmark data...</div>
142+ ) : error ? (
143+ <div>Error: {error}</div>
144+ ) : (
145+ <Routes>
146+ <Route path="/" element={<Home />} />
147+ <Route
148+ path="/datasets"
149+ element={<BenchmarkView benchmarkData={benchmarkData} />}
150+ />
151+ <Route
152+ path="/algorithms"
153+ element={<BenchmarkView benchmarkData={benchmarkData} />}
154+ />
155+ <Route
156+ path="/dataset/:datasetName"
157+ element={<BenchmarkView benchmarkData={benchmarkData} />}
158+ />
159+ <Route
160+ path="/algorithm/:algorithmName"
161+ element={<BenchmarkView benchmarkData={benchmarkData} />}
162+ />
163+ <Route path="/monitor" element={<Monitor />} />
164+ <Route path="/submit" element={<Submit />} />
165+ </Routes>
166+ )}
167+ </main>
168+ </div>
169+ </BrowserRouter>
170+ );
171+}
172+
173+export default App;
web-ui/src/assets/react.svgadded+1−0View file
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
web-ui/src/components/AppHeader.cssadded+9−0View file
@@ -0,0 +1,9 @@
1+.app-header-subtitle {
2+ display: none;
3+}
4+
5+@media (min-width: 640px) {
6+ .app-header-subtitle {
7+ display: inline;
8+ }
9+}
web-ui/src/components/BenchmarkTable.tsxadded+1−0View file
@@ -0,0 +1 @@
1+export { BenchmarkTable } from "./benchmark/table/BenchmarkTable";
web-ui/src/components/Button.cssadded+18−0View file
@@ -0,0 +1,18 @@
1+.soft-button {
2+ display: inline-block;
3+ padding: 0.6rem 1.2rem;
4+ background-color: #2b7de9;
5+ color: white;
6+ text-decoration: none;
7+ border-radius: 20px;
8+ font-weight: 500;
9+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
10+ transition: all 0.2s ease;
11+ cursor: pointer;
12+}
13+
14+.soft-button:hover {
15+ background-color: #1a68d4;
16+ transform: translateY(-1px);
17+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
18+}
web-ui/src/components/ScrollToTop.tsxadded+12−0View file
@@ -0,0 +1,12 @@
1+import { useEffect } from "react";
2+import { useLocation } from "react-router-dom";
3+
4+export function ScrollToTop() {
5+ const { pathname } = useLocation();
6+
7+ useEffect(() => {
8+ window.scrollTo(0, 0);
9+ }, [pathname]);
10+
11+ return null;
12+}
web-ui/src/components/TagFilter.tsxadded+45−0View file
@@ -0,0 +1,45 @@
1+interface TagFilterProps {
2+ availableTags: string[];
3+ selectedTags: string[];
4+ onTagToggle: (tag: string) => void;
5+ label: string;
6+}
7+
8+export function TagFilter({
9+ availableTags,
10+ selectedTags,
11+ onTagToggle,
12+ label,
13+}: TagFilterProps) {
14+ return (
15+ <div style={{ marginTop: "1rem" }}>
16+ <div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
17+ <div
18+ style={{ fontSize: "0.9rem", color: "#666", whiteSpace: "nowrap" }}
19+ >
20+ {label}:
21+ </div>
22+ <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
23+ {availableTags.map((tag) => (
24+ <button
25+ key={tag}
26+ onClick={() => onTagToggle(tag)}
27+ style={{
28+ padding: "4px 8px",
29+ border: "1px solid #ddd",
30+ borderRadius: "4px",
31+ background: selectedTags.includes(tag) ? "#0066cc" : "white",
32+ color: selectedTags.includes(tag) ? "white" : "#333",
33+ cursor: "pointer",
34+ fontSize: "0.8rem",
35+ transition: "all 0.2s ease",
36+ }}
37+ >
38+ {tag}
39+ </button>
40+ ))}
41+ </div>
42+ </div>
43+ </div>
44+ );
45+}
web-ui/src/components/algorithm/AlgorithmContent.tsxadded+33−0View file
@@ -0,0 +1,33 @@
1+import { Algorithm, BenchmarkData } from "../../types";
2+import { BaseContent } from "../shared/BaseContent";
3+import "../shared/ContentStyles.css";
4+
5+interface AlgorithmContentProps {
6+ algorithm: Algorithm;
7+ benchmarkData: BenchmarkData | null;
8+ chartData: Array<{
9+ algorithmOrDataset: string;
10+ compression_ratio: number;
11+ reference_compression_ratio: number | null;
12+ encode_speed: number;
13+ decode_speed: number;
14+ }>;
15+}
16+
17+export const AlgorithmContent = ({
18+ algorithm,
19+ benchmarkData,
20+ chartData,
21+}: AlgorithmContentProps) => {
22+ return (
23+ <BaseContent
24+ item={algorithm}
25+ benchmarkData={benchmarkData}
26+ chartData={chartData}
27+ tagNavigationPrefix="/algorithms"
28+ filterKey="algorithm"
29+ showSortByCompressionRatio={false}
30+ showNormalizeByReference={true}
31+ />
32+ );
33+};
web-ui/src/components/benchmark/charts/BenchmarkCharts.tsxadded+187−0View file
@@ -0,0 +1,187 @@
1+import { useState } from "react";
2+import Plot from "react-plotly.js";
3+
4+interface BenchmarkBarChartProps {
5+ title: string;
6+ data: ChartData[];
7+ dataKey: keyof Pick<
8+ ChartData,
9+ "compression_ratio" | "encode_speed" | "decode_speed"
10+ >;
11+ color: string;
12+ xAxisTitle: string;
13+ normalize?: boolean;
14+}
15+
16+function BenchmarkBarChart({
17+ title,
18+ data,
19+ dataKey,
20+ color,
21+ xAxisTitle,
22+ normalize,
23+}: BenchmarkBarChartProps) {
24+ const normalizedData =
25+ normalize && dataKey === "compression_ratio"
26+ ? data.map((d) => ({
27+ ...d,
28+ compression_ratio: d.reference_compression_ratio
29+ ? d.compression_ratio / d.reference_compression_ratio
30+ : d.compression_ratio,
31+ reference_compression_ratio: d.reference_compression_ratio ? 1 : null,
32+ }))
33+ : data;
34+
35+ return (
36+ <div style={{ margin: "0 20px 20px 0" }}>
37+ <h3 style={{ marginBottom: "10px" }}>{title}</h3>
38+ <Plot
39+ data={[
40+ {
41+ type: "bar",
42+ orientation: "h",
43+ y: normalizedData.map((d) => d.algorithmOrDataset),
44+ x: normalizedData.map((d) => d[dataKey]),
45+ marker: { color },
46+ name: title,
47+ hovertemplate:
48+ normalize && dataKey === "compression_ratio"
49+ ? "%{x:.3f}×<extra></extra>"
50+ : "%{x:.2f}<extra></extra>",
51+ },
52+ ...(dataKey === "compression_ratio" &&
53+ normalizedData.some((d) => d.reference_compression_ratio !== null)
54+ ? [
55+ ...normalizedData
56+ .filter((d) => d.reference_compression_ratio !== null)
57+ .flatMap((d) => [
58+ {
59+ type: "scatter" as const,
60+ mode: "lines" as const,
61+ y: [d.algorithmOrDataset, d.algorithmOrDataset],
62+ x: [0, d.reference_compression_ratio],
63+ line: { color, width: 1 },
64+ showlegend: false,
65+ hoverinfo: "skip" as const,
66+ },
67+ {
68+ type: "scatter" as const,
69+ mode: "markers" as const,
70+ y: [d.algorithmOrDataset],
71+ x: [d.reference_compression_ratio],
72+ marker: { color: "#aaaaaa", size: 8 },
73+ name: "Best Compression",
74+ hovertemplate: normalize
75+ ? "Best: 1.000×<extra></extra>"
76+ : "Best: %{x:.2f}<extra></extra>",
77+ showlegend:
78+ d.algorithmOrDataset ===
79+ normalizedData[0].algorithmOrDataset,
80+ },
81+ ]),
82+ ]
83+ : []),
84+ ]}
85+ layout={{
86+ width: 700,
87+ height: Math.max(300, data.length * 23 + 40),
88+ margin: { t: 5, r: 30, l: 200, b: 30 },
89+ xaxis: { title: xAxisTitle },
90+ yaxis: { automargin: true, ticksuffix: " " },
91+ dragmode: false,
92+ }}
93+ config={{ displayModeBar: false }}
94+ />
95+ </div>
96+ );
97+}
98+
99+interface ChartData {
100+ algorithmOrDataset: string;
101+ compression_ratio: number;
102+ reference_compression_ratio: number | null; // the highest compression ratio for the dataset (if algorithmOrDataset is a dataset)
103+ encode_speed: number;
104+ decode_speed: number;
105+}
106+
107+interface BenchmarkChartsProps {
108+ chartData: ChartData[];
109+ showSortByCompressionRatio?: boolean;
110+ showNormalizeByReference?: boolean;
111+}
112+
113+export function BenchmarkCharts({
114+ chartData,
115+ showSortByCompressionRatio,
116+ showNormalizeByReference,
117+}: BenchmarkChartsProps) {
118+ const [sortByRatio, setSortByRatio] = useState(
119+ showSortByCompressionRatio ? true : false,
120+ );
121+ const [normalize, setNormalize] = useState(false);
122+
123+ if (!chartData.length) return null;
124+
125+ const sortedData = sortByRatio
126+ ? [...chartData].sort((a, b) => a.compression_ratio - b.compression_ratio)
127+ : chartData;
128+
129+ return (
130+ <div>
131+ {showSortByCompressionRatio && (
132+ <div style={{ marginBottom: "10px" }}>
133+ <label style={{ display: "flex", alignItems: "center", gap: "8px" }}>
134+ <input
135+ type="checkbox"
136+ checked={sortByRatio}
137+ onChange={(e) => setSortByRatio(e.target.checked)}
138+ />
139+ Sort by compression ratio
140+ </label>
141+ </div>
142+ )}
143+ {showNormalizeByReference && (
144+ <div style={{ marginBottom: "10px" }}>
145+ <label style={{ display: "flex", alignItems: "center", gap: "8px" }}>
146+ <input
147+ type="checkbox"
148+ checked={normalize}
149+ onChange={(e) => setNormalize(e.target.checked)}
150+ />
151+ Normalize to best compression
152+ </label>
153+ </div>
154+ )}
155+ <div
156+ style={{
157+ display: "flex",
158+ flexWrap: "wrap",
159+ gap: "20px",
160+ }}
161+ >
162+ <BenchmarkBarChart
163+ title="Compression Ratio"
164+ data={sortedData}
165+ dataKey="compression_ratio"
166+ color="#8884d8"
167+ xAxisTitle={normalize ? "Fraction of Best Compression" : "Ratio"}
168+ normalize={normalize}
169+ />
170+ <BenchmarkBarChart
171+ title="Encode Speed (MB/s)"
172+ data={sortedData}
173+ dataKey="encode_speed"
174+ color="#82ca9d"
175+ xAxisTitle="MB/s"
176+ />
177+ <BenchmarkBarChart
178+ title="Decode Speed (MB/s)"
179+ data={sortedData}
180+ dataKey="decode_speed"
181+ color="#ff7300"
182+ xAxisTitle="MB/s"
183+ />
184+ </div>
185+ </div>
186+ );
187+}
web-ui/src/components/benchmark/charts/BenchmarkScatterPlots.tsxadded+161−0View file
@@ -0,0 +1,161 @@
1+import Plot from "react-plotly.js";
2+import { useState } from "react";
3+
4+interface ChartData {
5+ algorithmOrDataset: string;
6+ compression_ratio: number;
7+ encode_speed: number;
8+ decode_speed: number;
9+}
10+
11+interface BenchmarkScatterPlotsProps {
12+ chartData: ChartData[];
13+}
14+
15+export function BenchmarkScatterPlots({
16+ chartData,
17+}: BenchmarkScatterPlotsProps) {
18+ const [showLabels, setShowLabels] = useState(false);
19+
20+ if (!chartData.length) return null;
21+
22+ const uniqueAlgorithms = Array.from(
23+ new Set(chartData.map((d) => d.algorithmOrDataset)),
24+ );
25+
26+ const colors = [
27+ "#1f77b4", // blue
28+ "#ff7f0e", // orange
29+ "#2ca02c", // green
30+ "#d62728", // red
31+ "#9467bd", // purple
32+ "#8c564b", // brown
33+ "#e377c2", // pink
34+ "#7f7f7f", // gray
35+ ];
36+
37+ const markers = ["circle", "square", "diamond", "triangle-up", "star"];
38+
39+ // Create traces for each algorithm
40+ const traces = uniqueAlgorithms.flatMap((algo, i) => {
41+ const algoData = chartData.filter((d) => d.algorithmOrDataset === algo);
42+ const baseTrace = {
43+ name: algo,
44+ mode: showLabels ? ("markers+text" as const) : ("markers" as const),
45+ marker: {
46+ color: colors[i % colors.length],
47+ symbol: markers[Math.floor(i / colors.length) % markers.length],
48+ size: 10,
49+ },
50+ text: showLabels ? algoData.map(() => algo) : [],
51+ textposition: "top center" as const,
52+ showlegend: true,
53+ legendgroup: algo,
54+ };
55+
56+ return [
57+ // Compression Ratio vs Decode Speed (upper left)
58+ {
59+ ...baseTrace,
60+ x: algoData.map((d) => d.compression_ratio),
61+ y: algoData.map((d) => d.decode_speed),
62+ xaxis: "x" as const,
63+ yaxis: "y" as const,
64+ showlegend: true,
65+ },
66+ // Compression Ratio vs Encode Speed (lower left)
67+ {
68+ ...baseTrace,
69+ x: algoData.map((d) => d.compression_ratio),
70+ y: algoData.map((d) => d.encode_speed),
71+ xaxis: "x2" as const,
72+ yaxis: "y2" as const,
73+ showlegend: false,
74+ },
75+ // Decode Speed vs Encode Speed (lower right)
76+ {
77+ ...baseTrace,
78+ x: algoData.map((d) => d.decode_speed),
79+ y: algoData.map((d) => d.encode_speed),
80+ xaxis: "x3" as const,
81+ yaxis: "y3" as const,
82+ showlegend: false,
83+ },
84+ ];
85+ });
86+
87+ return (
88+ <div style={{ margin: "20px 0" }}>
89+ <div style={{ marginBottom: "10px" }}>
90+ <h2 style={{ marginBottom: "10px" }}>Performance Relationships</h2>
91+ <label style={{ display: "flex", alignItems: "center", gap: "8px" }}>
92+ <input
93+ type="checkbox"
94+ checked={showLabels}
95+ onChange={(e) => setShowLabels(e.target.checked)}
96+ />
97+ Show point labels
98+ </label>
99+ </div>
100+ <Plot
101+ data={traces}
102+ layout={{
103+ width: 1000,
104+ height: 670,
105+ grid: {
106+ rows: 2,
107+ columns: 2,
108+ pattern: "independent",
109+ },
110+ xaxis: {
111+ title: "Compression Ratio",
112+ domain: [0, 0.45],
113+ },
114+ yaxis: {
115+ title: "Decode Speed (MB/s)",
116+ domain: [0.55, 1],
117+ },
118+ xaxis2: {
119+ title: "Compression Ratio",
120+ domain: [0, 0.45],
121+ },
122+ yaxis2: {
123+ title: "Encode Speed (MB/s)",
124+ domain: [0, 0.45],
125+ },
126+ xaxis3: {
127+ title: "Decode Speed (MB/s)",
128+ domain: [0.55, 1],
129+ },
130+ yaxis3: {
131+ title: "Encode Speed (MB/s)",
132+ domain: [0, 0.45],
133+ },
134+ showlegend: true,
135+ legend: {
136+ x: 1.08,
137+ y: 1,
138+ xanchor: "left" as const,
139+ yanchor: "top" as const,
140+ },
141+ margin: {
142+ l: 60,
143+ r: 40,
144+ t: 20,
145+ b: 60,
146+ },
147+ }}
148+ config={{
149+ displayModeBar: true,
150+ displaylogo: false,
151+ modeBarButtonsToRemove: [
152+ "lasso2d",
153+ "select2d",
154+ "hoverClosestCartesian",
155+ "hoverCompareCartesian",
156+ ],
157+ }}
158+ />
159+ </div>
160+ );
161+}
web-ui/src/components/benchmark/export/csvExport.tsadded+49−0View file
@@ -0,0 +1,49 @@
1+import { BenchmarkResult } from "../../../types";
2+import { formatNumber, formatSize } from "../utils/formatters";
3+import { columns } from "../table/columns";
4+
5+export const exportToCsv = (
6+ data: BenchmarkResult[],
7+ selectedDataset: string,
8+) => {
9+ // Convert data to CSV
10+ const headers = columns.map((col) => col.header).join(",");
11+ const rows = data
12+ .map((row) =>
13+ columns
14+ .map((col) => {
15+ const value = row[col.accessorKey as keyof BenchmarkResult];
16+ // Format numbers according to their display format
17+ if (col.accessorKey === "compression_ratio") {
18+ return `${formatNumber(value as number)}x`;
19+ } else if (
20+ col.accessorKey === "encode_time" ||
21+ col.accessorKey === "decode_time"
22+ ) {
23+ return formatNumber(value as number, 4);
24+ } else if (
25+ col.accessorKey === "original_size" ||
26+ col.accessorKey === "compressed_size"
27+ ) {
28+ return formatSize(value as number);
29+ } else if (typeof value === "number") {
30+ return formatNumber(value);
31+ }
32+ return value;
33+ })
34+ .join(","),
35+ )
36+ .join("\n");
37+ const csv = `${headers}\n${rows}`;
38+
39+ // Create and trigger download
40+ const blob = new Blob([csv], { type: "text/csv" });
41+ const url = window.URL.createObjectURL(blob);
42+ const a = document.createElement("a");
43+ a.href = url;
44+ a.download = `benchmark-results${selectedDataset ? `-${selectedDataset}` : ""}.csv`;
45+ document.body.appendChild(a);
46+ a.click();
47+ document.body.removeChild(a);
48+ window.URL.revokeObjectURL(url);
49+};
web-ui/src/components/benchmark/table/BenchmarkTable.tsxadded+98−0View file
@@ -0,0 +1,98 @@
1+import {
2+ flexRender,
3+ getCoreRowModel,
4+ getSortedRowModel,
5+ useReactTable,
6+} from "@tanstack/react-table";
7+import { BenchmarkResult } from "../../../types";
8+import { exportToCsv } from "../export/csvExport";
9+import { columns } from "./columns";
10+
11+interface BenchmarkTableProps {
12+ results: BenchmarkResult[];
13+}
14+
15+export function BenchmarkTable({ results }: BenchmarkTableProps) {
16+ const table = useReactTable({
17+ data: results,
18+ columns,
19+ getCoreRowModel: getCoreRowModel(),
20+ getSortedRowModel: getSortedRowModel(),
21+ });
22+
23+ return (
24+ <div className="table-container">
25+ <table>
26+ <thead>
27+ {table.getHeaderGroups().map((headerGroup) => (
28+ <tr key={headerGroup.id}>
29+ {headerGroup.headers.map((header) => (
30+ <th
31+ key={header.id}
32+ onClick={header.column.getToggleSortingHandler()}
33+ style={{ cursor: "pointer" }}
34+ >
35+ {flexRender(
36+ header.column.columnDef.header,
37+ header.getContext(),
38+ )}
39+ {header.column.getIsSorted() && (
40+ <span style={{ marginLeft: "4px" }}>
41+ {header.column.getIsSorted() === "asc" ? "↑" : "↓"}
42+ </span>
43+ )}
44+ </th>
45+ ))}
46+ </tr>
47+ ))}
48+ </thead>
49+ <tbody>
50+ {table.getRowModel().rows.map((row) => (
51+ <tr key={row.id}>
52+ {row.getVisibleCells().map((cell) => (
53+ <td key={cell.id}>
54+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
55+ </td>
56+ ))}
57+ </tr>
58+ ))}
59+ </tbody>
60+ </table>
61+
62+ <div
63+ style={{
64+ marginTop: "12px",
65+ display: "flex",
66+ justifyContent: "flex-end",
67+ }}
68+ >
69+ <button
70+ onClick={() => exportToCsv(results, "benchmark_results")}
71+ style={{
72+ padding: "8px 16px",
73+ backgroundColor: "#4CAF50",
74+ color: "white",
75+ border: "none",
76+ borderRadius: "4px",
77+ cursor: "pointer",
78+ display: "flex",
79+ alignItems: "center",
80+ gap: "8px",
81+ }}
82+ >
83+ <svg
84+ width="16"
85+ height="16"
86+ viewBox="0 0 16 16"
87+ fill="none"
88+ xmlns="http://www.w3.org/2000/svg"
89+ >
90+ <path d="M8 12L3 7H6V1H10V7H13L8 12Z" fill="currentColor" />
91+ <path d="M2 14V15H14V14H2Z" fill="currentColor" />
92+ </svg>
93+ Download CSV
94+ </button>
95+ </div>
96+ </div>
97+ );
98+}
web-ui/src/components/benchmark/table/columns.tsxadded+102−0View file
@@ -0,0 +1,102 @@
1+import { createColumnHelper } from "@tanstack/react-table";
2+import { BenchmarkResult } from "../../../types";
3+import { formatNumber, formatSize } from "../utils/formatters";
4+import { Link } from "react-router-dom";
5+
6+const columnHelper = createColumnHelper<BenchmarkResult>();
7+
8+export const columns = [
9+ columnHelper.accessor("dataset", {
10+ header: "Dataset",
11+ cell: (info) => (
12+ <Link
13+ to={`/dataset/${info.getValue()}`}
14+ style={{ color: "#2563eb", textDecoration: "none" }}
15+ onMouseEnter={(e) =>
16+ (e.currentTarget.style.textDecoration = "underline")
17+ }
18+ onMouseLeave={(e) => (e.currentTarget.style.textDecoration = "none")}
19+ >
20+ {info.getValue()}
21+ </Link>
22+ ),
23+ }),
24+ columnHelper.accessor("algorithm", {
25+ header: "Algorithm",
26+ cell: (info) => (
27+ <Link
28+ to={`/algorithm/${info.getValue()}`}
29+ style={{ color: "#2563eb", textDecoration: "none" }}
30+ onMouseEnter={(e) =>
31+ (e.currentTarget.style.textDecoration = "underline")
32+ }
33+ onMouseLeave={(e) => (e.currentTarget.style.textDecoration = "none")}
34+ >
35+ {info.getValue()}
36+ </Link>
37+ ),
38+ }),
39+ columnHelper.accessor("compression_ratio", {
40+ header: "Compression Ratio",
41+ cell: (info) => `${formatNumber(info.getValue())}`,
42+ sortingFn: (rowA, rowB) => {
43+ const a = rowA.original.compression_ratio;
44+ const b = rowB.original.compression_ratio;
45+ return a - b;
46+ },
47+ }),
48+ columnHelper.accessor("encode_time", {
49+ header: "Encode Time (s)",
50+ cell: (info) => formatNumber(info.getValue(), 4),
51+ sortingFn: (rowA, rowB) => {
52+ const a = rowA.original.encode_time;
53+ const b = rowB.original.encode_time;
54+ return a - b;
55+ },
56+ }),
57+ columnHelper.accessor("decode_time", {
58+ header: "Decode Time (s)",
59+ cell: (info) => formatNumber(info.getValue(), 4),
60+ sortingFn: (rowA, rowB) => {
61+ const a = rowA.original.decode_time;
62+ const b = rowB.original.decode_time;
63+ return a - b;
64+ },
65+ }),
66+ columnHelper.accessor("encode_mb_per_sec", {
67+ header: "Encode Speed (MB/s)",
68+ cell: (info) => formatNumber(info.getValue()),
69+ sortingFn: (rowA, rowB) => {
70+ const a = rowA.original.encode_mb_per_sec;
71+ const b = rowB.original.encode_mb_per_sec;
72+ return a - b;
73+ },
74+ }),
75+ columnHelper.accessor("decode_mb_per_sec", {
76+ header: "Decode Speed (MB/s)",
77+ cell: (info) => formatNumber(info.getValue()),
78+ sortingFn: (rowA, rowB) => {
79+ const a = rowA.original.decode_mb_per_sec;
80+ const b = rowB.original.decode_mb_per_sec;
81+ return a - b;
82+ },
83+ }),
84+ columnHelper.accessor("original_size", {
85+ header: "Original Size",
86+ cell: (info) => formatSize(info.getValue()),
87+ sortingFn: (rowA, rowB) => {
88+ const a = rowA.original.original_size;
89+ const b = rowB.original.original_size;
90+ return a - b;
91+ },
92+ }),
93+ columnHelper.accessor("compressed_size", {
94+ header: "Compressed Size",
95+ cell: (info) => formatSize(info.getValue()),
96+ sortingFn: (rowA, rowB) => {
97+ const a = rowA.original.compressed_size;
98+ const b = rowB.original.compressed_size;
99+ return a - b;
100+ },
101+ }),
102+];
web-ui/src/components/benchmark/utils/formatters.tsadded+11−0View file
@@ -0,0 +1,11 @@
1+export const formatNumber = (num: number, decimals = 2) => {
2+ return new Intl.NumberFormat("en-US", {
3+ minimumFractionDigits: decimals,
4+ maximumFractionDigits: decimals,
5+ }).format(num);
6+};
7+
8+export const formatSize = (bytes: number) => {
9+ const mb = bytes / (1024 * 1024);
10+ return `${formatNumber(mb)} MB`;
11+};
web-ui/src/components/dataset/DatasetContent.tsxadded+99−0View file
@@ -0,0 +1,99 @@
1+import { Dataset, BenchmarkData } from "../../types";
2+import { useEffect, useRef, useState } from "react";
3+import TimeseriesView from "./TimeseriesView";
4+import { BaseContent } from "../shared/BaseContent";
5+import "../shared/ContentStyles.css";
6+
7+interface DatasetContentProps {
8+ dataset: Dataset;
9+ benchmarkData: BenchmarkData | null;
10+ chartData: Array<{
11+ algorithmOrDataset: string;
12+ compression_ratio: number;
13+ reference_compression_ratio: number | null;
14+ encode_speed: number;
15+ decode_speed: number;
16+ }>;
17+}
18+
19+export const DatasetContent = ({
20+ dataset,
21+ benchmarkData,
22+ chartData,
23+}: DatasetContentProps) => {
24+ const containerRef = useRef<HTMLDivElement>(null);
25+ const [containerWidth, setContainerWidth] = useState(1200);
26+
27+ useEffect(() => {
28+ if (!containerRef.current) return;
29+
30+ const resizeObserver = new ResizeObserver((entries) => {
31+ for (const entry of entries) {
32+ setContainerWidth(entry.contentRect.width - 32);
33+ }
34+ });
35+
36+ resizeObserver.observe(containerRef.current);
37+
38+ return () => {
39+ resizeObserver.disconnect();
40+ };
41+ }, []);
42+
43+ const downloadSection =
44+ dataset.data_url_npy || dataset.data_url_raw ? (
45+ <div>
46+ <span className="metadata-label">Download: </span>
47+ <span style={{ display: "inline-flex", gap: "0.5rem" }}>
48+ {dataset.data_url_npy && (
49+ <a
50+ href={dataset.data_url_npy}
51+ download={`${dataset.name}-${dataset.version}.npy`}
52+ className="download-link"
53+ >
54+ NPY
55+ </a>
56+ )}
57+ {dataset.data_url_raw && (
58+ <a
59+ href={dataset.data_url_raw}
60+ download={`${dataset.name}-${dataset.version}.dat`}
61+ className="download-link"
62+ >
63+ RAW
64+ </a>
65+ )}
66+ </span>
67+ </div>
68+ ) : null;
69+
70+ const timeseriesSection = (
71+ <div className="content-container">
72+ <div
73+ ref={containerRef}
74+ style={{
75+ width: "100%",
76+ height: "300px",
77+ backgroundColor: "#f5f5f5",
78+ borderRadius: "4px",
79+ padding: "1rem",
80+ }}
81+ >
82+ <TimeseriesView width={containerWidth} height={250} dataset={dataset} />
83+ </div>
84+ </div>
85+ );
86+
87+ return (
88+ <BaseContent
89+ item={dataset}
90+ benchmarkData={benchmarkData}
91+ chartData={chartData}
92+ tagNavigationPrefix="/datasets"
93+ filterKey="dataset"
94+ downloadSection={downloadSection}
95+ additionalContent={timeseriesSection}
96+ showSortByCompressionRatio={true}
97+ />
98+ );
99+};
web-ui/src/components/dataset/TimeseriesNavigationBar.tsxadded+98−0View file
@@ -0,0 +1,98 @@
1+import React, { useRef } from "react";
2+import { Range } from "./WorkerTypes";
3+
4+interface TimeseriesNavigationBarProps {
5+ width: number;
6+ height: number;
7+ totalRange: Range;
8+ viewRange: Range;
9+ onViewRangeChange: (range: Range) => void;
10+}
11+
12+const TimeseriesNavigationBar: React.FC<TimeseriesNavigationBarProps> = ({
13+ width,
14+ height,
15+ totalRange,
16+ viewRange,
17+ onViewRangeChange,
18+}) => {
19+ const containerRef = useRef<HTMLDivElement>(null);
20+
21+ // Constants
22+ const minMarkerWidth = 25; // Minimum width of the marker in pixels
23+ const padding = 10; // Padding on left and right
24+ const barWidth = width - 2 * padding;
25+
26+ // Convert data range to pixel coordinates
27+ const rangeToPixel = (value: number): number => {
28+ const ratio = (value - totalRange.min) / (totalRange.max - totalRange.min);
29+ return padding + ratio * barWidth;
30+ };
31+
32+ // Convert pixel coordinates to data range
33+ const pixelToRange = (pixel: number): number => {
34+ const ratio = (pixel - padding) / barWidth;
35+ return totalRange.min + ratio * (totalRange.max - totalRange.min);
36+ };
37+
38+ // Calculate marker position and width
39+ const markerLeft = rangeToPixel(viewRange.min);
40+ const rawMarkerWidth = rangeToPixel(viewRange.max) - markerLeft;
41+ const markerWidth = Math.max(rawMarkerWidth, minMarkerWidth);
42+
43+ const handleClick = (e: React.MouseEvent) => {
44+ if (!containerRef.current) return;
45+
46+ const rect = containerRef.current.getBoundingClientRect();
47+ const clickX = e.clientX - rect.left;
48+
49+ // Click on the bar - center the view on click position
50+ const clickedValue = pixelToRange(clickX);
51+ const currentSize = viewRange.max - viewRange.min;
52+ const halfSize = currentSize / 2;
53+
54+ let newMin = clickedValue - halfSize;
55+ let newMax = clickedValue + halfSize;
56+
57+ // Clamp to total range bounds
58+ if (newMin < totalRange.min) {
59+ newMin = totalRange.min;
60+ newMax = newMin + currentSize;
61+ }
62+ if (newMax > totalRange.max) {
63+ newMax = totalRange.max;
64+ newMin = newMax - currentSize;
65+ }
66+
67+ onViewRangeChange({ min: newMin, max: newMax });
68+ };
69+
70+ return (
71+ <div
72+ ref={containerRef}
73+ style={{
74+ width,
75+ height,
76+ position: "relative",
77+ backgroundColor: "#f0f0f0",
78+ borderRadius: 4,
79+ cursor: "pointer",
80+ }}
81+ onClick={handleClick}
82+ >
83+ <div
84+ style={{
85+ position: "absolute",
86+ left: markerLeft,
87+ width: markerWidth,
88+ height: "100%",
89+ backgroundColor: "#007bff",
90+ borderRadius: 4,
91+ pointerEvents: "none",
92+ }}
93+ />
94+ </div>
95+ );
96+};
97+
98+export default TimeseriesNavigationBar;
web-ui/src/components/dataset/TimeseriesView.tsxadded+437−0View file
@@ -0,0 +1,437 @@
1+import { useEffect, useMemo, useReducer, useState, useCallback } from "react";
2+import TimeseriesNavigationBar from "./TimeseriesNavigationBar";
3+import { SupportedTypedArray } from "../../hooks/TimeseriesDataClient";
4+import { useTimeseriesDataClient } from "../../hooks/useTimeseriesDataClient";
5+import { Dataset } from "../../types";
6+import { Margins, Range, WorkerMessage } from "./WorkerTypes";
7+import { initialState, timeseriesViewReducer } from "./timeseriesViewReducer";
8+
9+interface TimeseriesViewProps {
10+ width: number;
11+ height: number;
12+ dataset: Dataset;
13+}
14+
15+const TimeseriesView: React.FC<TimeseriesViewProps> = ({
16+ width,
17+ height,
18+ dataset,
19+}) => {
20+ const { client, error: clientError } = useTimeseriesDataClient(dataset);
21+ const [dataT, setDataT] = useState<number[] | null>(null);
22+ const [dataY, setDataY] = useState<SupportedTypedArray | null>(null);
23+ const [error, setError] = useState<string | null>(clientError);
24+ const [isLoading, setIsLoading] = useState(false);
25+
26+ const [canvasElement, setCanvasElement] = useState<HTMLCanvasElement | null>(
27+ null,
28+ );
29+ const [overlayCanvasElement, setOverlayCanvasElement] =
30+ useState<HTMLCanvasElement | null>(null);
31+ const [state, dispatch] = useReducer(timeseriesViewReducer, initialState);
32+ const { selectedIndex, isDragging, lastDragX, xRange } = state;
33+ const [isWheelEnabled, setIsWheelEnabled] = useState(false);
34+ const [showHint, setShowHint] = useState(true);
35+
36+ // Hide hint when user interacts with the graph
37+ const hideHint = useCallback(() => {
38+ setShowHint(false);
39+ }, []);
40+
41+ // Auto-hide hint after 4 seconds
42+ useEffect(() => {
43+ if (showHint) {
44+ const timer = setTimeout(() => {
45+ setShowHint(false);
46+ }, 5000);
47+ return () => clearTimeout(timer);
48+ }
49+ }, [showHint]);
50+
51+ const [container, setContainer] = useState<HTMLDivElement | null>(null);
52+ const [worker, setWorker] = useState<Worker | null>(null);
53+ const [margins] = useState<Margins>({
54+ left: 50,
55+ right: 20,
56+ top: 20,
57+ bottom: 50,
58+ });
59+
60+ // Load data for current range
61+ useEffect(() => {
62+ if (!client || !xRange) return;
63+
64+ const loadRangeData = async () => {
65+ try {
66+ setIsLoading(true);
67+ const start = Math.floor(xRange.min);
68+ const end = Math.ceil(xRange.max) + 1;
69+ const rangeData = await client.fetchRange(start, end);
70+ setDataY(rangeData);
71+ const dT = Array.from(
72+ { length: rangeData.length },
73+ (_, i) => i + start,
74+ );
75+ setDataT(dT);
76+ setError(null);
77+ } catch (err) {
78+ setError(
79+ err instanceof Error ? err.message : "Failed to load data range",
80+ );
81+ } finally {
82+ setIsLoading(false);
83+ }
84+ };
85+
86+ loadRangeData();
87+ }, [client, xRange]);
88+
89+ // Update xRange when client is initialized
90+ useEffect(() => {
91+ if (client) {
92+ const shape = client.getShape();
93+ dispatch({
94+ type: "SET_X_RANGE",
95+ range: { min: 0, max: Math.min(999, shape - 1) },
96+ });
97+ }
98+ }, [client]);
99+
100+ // Set up wheel event listener
101+ useEffect(() => {
102+ if (!container || !client) return;
103+
104+ const handleWheel = (e: WheelEvent) => {
105+ if (!isWheelEnabled) {
106+ return; // Allow page scrolling if wheel zoom not enabled
107+ }
108+ e.preventDefault();
109+
110+ const rect = container.getBoundingClientRect();
111+ const x = e.clientX - rect.left;
112+ const xRatio =
113+ (x - margins.left) / (width - margins.left - margins.right);
114+
115+ // Calculate zoom center in data coordinates
116+ const zoomCenter = xRange.min + (xRange.max - xRange.min) * xRatio;
117+
118+ // Calculate new range
119+ const zoomFactor = e.deltaY > 0 ? 1.1 : 1 / 1.1;
120+ const shape = client.getShape();
121+
122+ // Ensure we don't zoom out beyond data bounds
123+ const newMin = Math.max(
124+ 0,
125+ zoomCenter - (zoomCenter - xRange.min) * zoomFactor,
126+ );
127+ const newMax = Math.min(
128+ shape - 1,
129+ zoomCenter + (xRange.max - zoomCenter) * zoomFactor,
130+ );
131+
132+ dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
133+ };
134+
135+ container.addEventListener("wheel", handleWheel, { passive: false });
136+ return () => {
137+ container.removeEventListener("wheel", handleWheel);
138+ };
139+ }, [container, client, width, margins, xRange, isWheelEnabled]);
140+
141+ // Set up mouse event listeners for panning
142+ useEffect(() => {
143+ if (!container || !client) return;
144+
145+ const handleMouseDown = (e: MouseEvent) => {
146+ dispatch({ type: "SET_IS_DRAGGING", isDragging: true });
147+ dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
148+ };
149+
150+ const handleMouseMove = (e: MouseEvent) => {
151+ if (!isDragging || lastDragX === 0) return;
152+
153+ const deltaX = e.clientX - lastDragX;
154+ const xRatio = deltaX / (width - margins.left - margins.right);
155+ const dataDelta = (xRange.max - xRange.min) * xRatio;
156+ const shape = client.getShape();
157+
158+ if (xRange.min - dataDelta < 0) return;
159+ if (xRange.max - dataDelta > shape - 1) return;
160+
161+ const newMin = xRange.min - dataDelta;
162+ const newMax = xRange.max - dataDelta;
163+
164+ // Only update if we're still within bounds
165+ if (newMin >= 0 && newMax <= shape - 1) {
166+ dispatch({ type: "SET_X_RANGE", range: { min: newMin, max: newMax } });
167+ }
168+
169+ dispatch({ type: "SET_LAST_DRAG_X", x: e.clientX });
170+ };
171+
172+ const handleMouseUp = () => {
173+ dispatch({ type: "SET_IS_DRAGGING", isDragging: false });
174+ dispatch({ type: "SET_LAST_DRAG_X", x: 0 });
175+ };
176+
177+ container.addEventListener("mousedown", handleMouseDown);
178+ window.addEventListener("mousemove", handleMouseMove);
179+ window.addEventListener("mouseup", handleMouseUp);
180+
181+ return () => {
182+ container.removeEventListener("mousedown", handleMouseDown);
183+ window.removeEventListener("mousemove", handleMouseMove);
184+ window.removeEventListener("mouseup", handleMouseUp);
185+ };
186+ }, [container, client, width, margins, xRange, isDragging, lastDragX]);
187+
188+ // Set worker
189+ useEffect(() => {
190+ if (!canvasElement) return;
191+ const worker = new Worker(
192+ new URL("./TimeseriesViewWorker", import.meta.url),
193+ {
194+ type: "module",
195+ },
196+ );
197+ let offscreenCanvas: OffscreenCanvas;
198+ try {
199+ offscreenCanvas = canvasElement.transferControlToOffscreen();
200+ } catch (err) {
201+ console.warn(err);
202+ console.warn(
203+ "Unable to transfer control to offscreen canvas (expected during dev)",
204+ );
205+ return;
206+ }
207+ const msg: WorkerMessage = {
208+ type: "initialize",
209+ canvas: offscreenCanvas,
210+ };
211+ worker.postMessage(msg, [offscreenCanvas]);
212+
213+ setWorker(worker);
214+
215+ return () => {
216+ worker.terminate();
217+ };
218+ }, [canvasElement]);
219+
220+ // Calculate yRange from data
221+ const yRange = useMemo<Range>(() => {
222+ if (!dataY) return { min: 0, max: 1 };
223+ return {
224+ min: computeMin(dataY),
225+ max: computeMax(dataY),
226+ };
227+ }, [dataY]);
228+
229+ // Handle dimension changes
230+ useEffect(() => {
231+ if (!worker) return;
232+ if (!dataY) return;
233+ if (!dataT) return;
234+
235+ const msg: WorkerMessage = {
236+ type: "render",
237+ timeseriesT: dataT,
238+ timeseriesY: Array.from(dataY),
239+ width,
240+ height,
241+ margins,
242+ xRange,
243+ yRange,
244+ };
245+ worker.postMessage(msg);
246+ }, [width, height, dataT, dataY, worker, margins, xRange, yRange]);
247+
248+ // Render cursor on overlay canvas
249+ useEffect(() => {
250+ if (!overlayCanvasElement || selectedIndex === null || !dataY) return;
251+ const ctx = overlayCanvasElement.getContext("2d");
252+ if (!ctx) return;
253+
254+ // Clear overlay canvas
255+ ctx.clearRect(0, 0, width, height);
256+
257+ // Draw cursor line
258+ const xRatio = (selectedIndex - xRange.min) / (xRange.max - xRange.min);
259+ const x = margins.left + xRatio * (width - margins.left - margins.right);
260+ ctx.beginPath();
261+ ctx.strokeStyle = "#ff0000";
262+ ctx.lineWidth = 1;
263+ ctx.setLineDash([4, 4]);
264+ ctx.moveTo(x, margins.top);
265+ ctx.lineTo(x, height - margins.bottom);
266+ ctx.stroke();
267+ }, [
268+ selectedIndex,
269+ overlayCanvasElement,
270+ width,
271+ height,
272+ margins,
273+ dataT,
274+ dataY,
275+ xRange,
276+ ]);
277+
278+ const selectedValue = useMemo(() => {
279+ if (selectedIndex === -1 || !dataT || !dataY) return null;
280+ for (let i = 0; i < dataT.length; i++) {
281+ if (dataT[i] === selectedIndex) {
282+ return dataY[i];
283+ }
284+ }
285+ return null;
286+ }, [selectedIndex, dataT, dataY]);
287+
288+ if (error || clientError) {
289+ return <div>Error loading data: {error || clientError}</div>;
290+ }
291+
292+ if (isLoading && !dataY) {
293+ return <div>Loading...</div>;
294+ }
295+
296+ const handleCanvasClick = (e: React.MouseEvent<HTMLDivElement>) => {
297+ if (!overlayCanvasElement || !dataY || isDragging) return;
298+
299+ // Enable wheel zooming on first click
300+ if (!isWheelEnabled) {
301+ setIsWheelEnabled(true);
302+ }
303+
304+ const rect = overlayCanvasElement.getBoundingClientRect();
305+ const x = e.clientX - rect.left;
306+ const xRatio = (x - margins.left) / (width - margins.left - margins.right);
307+ const index = Math.round(xRange.min + xRatio * (xRange.max - xRange.min));
308+ if (index >= 0) {
309+ dispatch({ type: "SET_SELECTED_INDEX", index });
310+ }
311+ };
312+
313+ return (
314+ <div style={{ position: "relative", width, height: height + 50 }}>
315+ <div style={{ marginBottom: 10, height: 20 }}>
316+ <TimeseriesNavigationBar
317+ width={width}
318+ height={20}
319+ totalRange={{ min: 0, max: client ? client.getShape() - 1 : 999 }}
320+ viewRange={xRange}
321+ onViewRangeChange={(range) =>
322+ dispatch({ type: "SET_X_RANGE", range })
323+ }
324+ />
325+ </div>
326+ {showHint && (
327+ <div
328+ style={{
329+ position: "absolute",
330+ top: margins.top + 10,
331+ right: margins.right + 10,
332+ display: "flex",
333+ flexDirection: "column",
334+ alignItems: "flex-end",
335+ gap: "8px",
336+ zIndex: 10,
337+ opacity: showHint ? 0.8 : 0,
338+ transition: "opacity 0.5s ease-out",
339+ pointerEvents: "none",
340+ fontSize: "12px",
341+ color: "#666",
342+ }}
343+ >
344+ <div
345+ style={{
346+ display: "flex",
347+ alignItems: "center",
348+ gap: "4px",
349+ backgroundColor: "rgba(255, 255, 255, 0.9)",
350+ padding: "2px 6px",
351+ borderRadius: "4px",
352+ }}
353+ >
354+ <span>Drag to pan</span>
355+ <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
356+ <path d="M15 3h2v5h-2V3zm4 0h2v5h-2V3zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5zm-4 7h2v5h-2v-5zm4 0h2v5h-2v-5z" />
357+ </svg>
358+ </div>
359+ <div
360+ style={{
361+ display: "flex",
362+ alignItems: "center",
363+ gap: "4px",
364+ backgroundColor: "rgba(255, 255, 255, 0.9)",
365+ padding: "2px 6px",
366+ borderRadius: "4px",
367+ }}
368+ >
369+ <span>Scroll to zoom</span>
370+ <svg width="14" height="14" viewBox="0 0 24 24" fill="#666">
371+ <path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm0 16c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7zm1-11h-2v3H8v2h3v3h2v-3h3v-2h-3V8z" />
372+ </svg>
373+ </div>
374+ </div>
375+ )}
376+ <div
377+ ref={setContainer}
378+ style={{ position: "relative", width, height }}
379+ onClick={(e) => {
380+ handleCanvasClick(e);
381+ hideHint();
382+ }}
383+ onMouseDown={hideHint}
384+ >
385+ <canvas
386+ ref={setCanvasElement}
387+ key={`canvas-${width}-${height}`}
388+ width={width}
389+ height={height}
390+ style={{
391+ position: "absolute",
392+ width: "100%",
393+ height: "100%",
394+ }}
395+ />
396+ <canvas
397+ ref={setOverlayCanvasElement}
398+ width={width}
399+ height={height}
400+ style={{
401+ position: "absolute",
402+ width: "100%",
403+ height: "100%",
404+ pointerEvents: "none",
405+ }}
406+ />
407+ </div>
408+ {selectedIndex !== -1 && dataY && (
409+ <div style={{ height: 30, padding: "5px 0", color: "#666" }}>
410+ Index: {selectedIndex}, Value: {selectedValue?.toFixed(3)}
411+ </div>
412+ )}
413+ </div>
414+ );
415+};
416+
417+const computeMin = (data: SupportedTypedArray) => {
418+ let min = Infinity;
419+ for (let i = 0; i < data.length; i++) {
420+ if (data[i] < min) {
421+ min = data[i];
422+ }
423+ }
424+ return min;
425+};
426+
427+const computeMax = (data: SupportedTypedArray) => {
428+ let max = -Infinity;
429+ for (let i = 0; i < data.length; i++) {
430+ if (data[i] > max) {
431+ max = data[i];
432+ }
433+ }
434+ return max;
435+};
436+
437+export default TimeseriesView;
web-ui/src/components/dataset/TimeseriesViewWorker.tsadded+239−0View file
@@ -0,0 +1,239 @@
1+// Web worker for rendering timeseries data to canvas
2+
3+import { Margins, Range, WorkerMessage } from "./WorkerTypes";
4+
5+// Helper function to find a nice integer tick interval
6+function getNiceTickInterval(range: number, maxTicks: number): number {
7+ const minInterval = Math.ceil(range / maxTicks);
8+ if (minInterval <= 1) return 1;
9+
10+ const magnitude = Math.pow(10, Math.floor(Math.log10(minInterval)));
11+ const niceIntervals = [1, 2, 5, 10];
12+
13+ for (const interval of niceIntervals) {
14+ const tickInterval = interval * magnitude;
15+ if (tickInterval >= minInterval) {
16+ return Math.ceil(tickInterval);
17+ }
18+ }
19+ return Math.ceil(niceIntervals[niceIntervals.length - 1] * magnitude * 10);
20+}
21+
22+// Helper function to estimate the width of a number in pixels
23+// This is an approximation since we can't measure text width directly in a worker
24+function estimateNumberWidth(num: number): number {
25+ const numStr = Math.abs(num).toString();
26+ const digitWidth = 8; // Approximate width of a digit in pixels
27+ const padding = 4; // Padding between numbers
28+ return (numStr.length + (num < 0 ? 1 : 0)) * digitWidth + padding;
29+}
30+
31+// Helper function to get tick positions
32+function getTickPositions(
33+ range: Range,
34+ width: number,
35+ considerNumberWidth = false, // Only true for x-axis where we need to handle large integers
36+): { value: number; x: number }[] {
37+ let pixelsPerTick = 20; // Default minimum pixels between ticks
38+
39+ if (considerNumberWidth) {
40+ // For x-axis, calculate spacing based on largest number width
41+ const maxAbsValue = Math.max(Math.abs(range.min), Math.abs(range.max));
42+ const maxNumberWidth = estimateNumberWidth(maxAbsValue);
43+ pixelsPerTick = Math.max(maxNumberWidth, 20); // Use the larger of estimated width or minimum spacing
44+ }
45+ const maxTicks = Math.floor(width / pixelsPerTick);
46+ const tickInterval = getNiceTickInterval(range.max - range.min, maxTicks);
47+
48+ const firstTick = Math.ceil(range.min / tickInterval) * tickInterval;
49+ const lastTick = Math.floor(range.max);
50+
51+ const ticks: { value: number; x: number }[] = [];
52+ for (let value = firstTick; value <= lastTick; value += tickInterval) {
53+ const x = (value - range.min) / (range.max - range.min);
54+ if (Number.isInteger(value)) {
55+ ticks.push({ value, x });
56+ }
57+ }
58+
59+ return ticks;
60+}
61+
62+let canvas: OffscreenCanvas | null = null;
63+let ctx: OffscreenCanvasRenderingContext2D | null = null;
64+
65+function renderTimeseries(
66+ timeseriesT: number[],
67+ timeseriesY: number[],
68+ width: number,
69+ height: number,
70+ margins: Margins,
71+ xRange: Range,
72+ yRange: Range,
73+) {
74+ if (!ctx || !canvas) return;
75+
76+ const context = ctx; // Create a stable reference to satisfy TypeScript
77+
78+ // Clear canvas
79+ context.clearRect(0, 0, width, height);
80+
81+ // Draw axes
82+ context.strokeStyle = "#666666";
83+ context.lineWidth = 1;
84+ context.beginPath();
85+
86+ // Y axis
87+ context.moveTo(margins.left, margins.top);
88+ context.lineTo(margins.left, height - margins.bottom);
89+
90+ // X axis
91+ context.moveTo(margins.left, height - margins.bottom);
92+ context.lineTo(width - margins.right, height - margins.bottom);
93+
94+ context.stroke();
95+
96+ // Calculate the drawing area dimensions
97+ const drawingWidth = width - margins.left - margins.right;
98+ const drawingHeight = height - margins.top - margins.bottom;
99+
100+ // Set up clipping region for timeseries
101+ context.save();
102+ context.beginPath();
103+ context.rect(margins.left, margins.top, drawingWidth, drawingHeight);
104+ context.clip();
105+
106+ // Set up drawing style for timeseries
107+ context.strokeStyle = "#2196f3";
108+ context.lineWidth = 2;
109+ context.beginPath();
110+
111+ // Calculate scaling factors
112+ const xScale = drawingWidth / (xRange.max - xRange.min);
113+ const yScale = drawingHeight / (yRange.max - yRange.min);
114+
115+ // Draw the path
116+ let isFirst = true;
117+ for (let i = 0; i < timeseriesT.length; i++) {
118+ const x = margins.left + (timeseriesT[i] - xRange.min) * xScale;
119+ const y =
120+ margins.top + drawingHeight - (timeseriesY[i] - yRange.min) * yScale;
121+ if (isFirst) {
122+ context.moveTo(x, y);
123+ isFirst = false;
124+ } else {
125+ context.lineTo(x, y);
126+ }
127+ }
128+
129+ context.stroke();
130+
131+ // Remove clipping before drawing ticks
132+ context.restore();
133+
134+ // Draw Y-axis ticks and labels
135+ const yTicks = getTickPositions(yRange, drawingHeight);
136+
137+ context.textAlign = "right";
138+ context.textBaseline = "middle";
139+ context.fillStyle = "#666666";
140+ context.font = "12px Arial";
141+
142+ yTicks.forEach((tick) => {
143+ const y = margins.top + drawingHeight - tick.x * drawingHeight;
144+
145+ // Draw tick mark
146+ context.beginPath();
147+ context.moveTo(margins.left - 6, y);
148+ context.lineTo(margins.left, y);
149+ context.stroke();
150+
151+ // Draw label
152+ context.fillText(tick.value.toString(), margins.left - 8, y);
153+ });
154+
155+ // Draw X-axis ticks and labels
156+ const ticks = getTickPositions(xRange, drawingWidth, true); // Consider number width for x-axis
157+
158+ context.textAlign = "center";
159+ context.textBaseline = "top";
160+ context.fillStyle = "#666666";
161+ context.font = "12px Arial";
162+
163+ ticks.forEach((tick) => {
164+ const x = margins.left + tick.x * drawingWidth;
165+
166+ // Draw tick mark
167+ context.beginPath();
168+ context.moveTo(x, height - margins.bottom);
169+ context.lineTo(x, height - margins.bottom + 6);
170+ context.stroke();
171+
172+ // Draw label
173+ context.fillText(tick.value.toString(), x, height - margins.bottom + 8);
174+ });
175+}
176+
177+self.onmessage = (evt: MessageEvent) => {
178+ const message = evt.data as WorkerMessage;
179+
180+ if (message.type === "initialize") {
181+ canvas = message.canvas;
182+ ctx = canvas.getContext("2d");
183+ if (!ctx) {
184+ self.postMessage({
185+ type: "error",
186+ error: "Failed to get canvas context",
187+ });
188+ return;
189+ }
190+ self.postMessage({ type: "initialized" });
191+ return;
192+ }
193+
194+ if (message.type === "render") {
195+ throttleRender(() => {
196+ const {
197+ timeseriesT,
198+ timeseriesY,
199+ width,
200+ height,
201+ margins,
202+ xRange,
203+ yRange,
204+ } = message;
205+ renderTimeseries(
206+ timeseriesT,
207+ timeseriesY,
208+ width,
209+ height,
210+ margins,
211+ xRange,
212+ yRange,
213+ );
214+ self.postMessage({ type: "render_complete" });
215+ });
216+ return;
217+ }
218+};
219+
220+let renderStack: (() => void)[] = [];
221+let lastRenderTime = 0;
222+
223+const throttleRender = (callback: () => void) => {
224+ renderStack.push(callback);
225+ const checkRender = () => {
226+ if (renderStack.length === 0) return;
227+ const elapsed = Date.now() - lastRenderTime;
228+ if (elapsed > 100) {
229+ lastRenderTime = Date.now();
230+ renderStack[renderStack.length - 1]();
231+ renderStack = [];
232+ } else {
233+ setTimeout(checkRender, 150);
234+ }
235+ };
236+ checkRender();
237+};
238+
239+export {}; // Needed for TypeScript modules
web-ui/src/components/dataset/WorkerTypes.tsadded+24−0View file
@@ -0,0 +1,24 @@
1+export interface Range {
2+ min: number;
3+ max: number;
4+}
5+
6+export interface Margins {
7+ left: number;
8+ right: number;
9+ top: number;
10+ bottom: number;
11+}
12+
13+export type WorkerMessage =
14+ | { type: "initialize"; canvas: OffscreenCanvas }
15+ | {
16+ type: "render";
17+ timeseriesT: number[];
18+ timeseriesY: number[];
19+ width: number;
20+ height: number;
21+ margins: Margins;
22+ xRange: Range;
23+ yRange: Range;
24+ };
web-ui/src/components/dataset/timeseriesViewReducer.tsadded+55−0View file
@@ -0,0 +1,55 @@
1+import { Range } from "./WorkerTypes";
2+
3+// State Type
4+export interface TimeseriesViewState {
5+ selectedIndex: number;
6+ isDragging: boolean;
7+ lastDragX: number;
8+ xRange: Range;
9+}
10+
11+// Initial State
12+export const initialState: TimeseriesViewState = {
13+ selectedIndex: -1,
14+ isDragging: false,
15+ lastDragX: 0,
16+ xRange: { min: 0, max: 999 },
17+};
18+
19+// Action Types Union
20+type TimeseriesViewAction =
21+ | { type: "SET_SELECTED_INDEX"; index: number }
22+ | { type: "SET_IS_DRAGGING"; isDragging: boolean }
23+ | { type: "SET_LAST_DRAG_X"; x: number }
24+ | { type: "SET_X_RANGE"; range: Range };
25+
26+// Reducer
27+export const timeseriesViewReducer = (
28+ state: TimeseriesViewState = initialState,
29+ action: TimeseriesViewAction,
30+): TimeseriesViewState => {
31+ switch (action.type) {
32+ case "SET_SELECTED_INDEX":
33+ return {
34+ ...state,
35+ selectedIndex: action.index,
36+ };
37+ case "SET_IS_DRAGGING":
38+ return {
39+ ...state,
40+ isDragging: action.isDragging,
41+ };
42+ case "SET_LAST_DRAG_X":
43+ return {
44+ ...state,
45+ lastDragX: action.x,
46+ };
47+ case "SET_X_RANGE":
48+ return {
49+ ...state,
50+ xRange: action.range,
51+ };
52+ default:
53+ return state;
54+ }
55+};
web-ui/src/components/shared/BaseContent.tsxadded+137−0View file
@@ -0,0 +1,137 @@
1+import { useNavigate } from "react-router-dom";
2+import { useState } from "react";
3+import ReactMarkdown from "react-markdown";
4+import remarkMath from "remark-math";
5+import rehypeKatex from "rehype-katex";
6+import { BenchmarkData } from "../../types";
7+import { BenchmarkCharts } from "../benchmark/charts/BenchmarkCharts";
8+import { BenchmarkScatterPlots } from "../benchmark/charts/BenchmarkScatterPlots";
9+import { BenchmarkTable } from "../benchmark/table/BenchmarkTable";
10+import "./ContentStyles.css";
11+
12+export interface BaseItem {
13+ name: string;
14+ description: string;
15+ long_description?: string;
16+ version: string;
17+ tags: string[];
18+ source_file?: string;
19+}
20+
21+interface BaseContentProps {
22+ item: BaseItem;
23+ benchmarkData: BenchmarkData | null;
24+ chartData: Array<{
25+ algorithmOrDataset: string;
26+ compression_ratio: number;
27+ reference_compression_ratio: number | null;
28+ encode_speed: number;
29+ decode_speed: number;
30+ }>;
31+ tagNavigationPrefix: string;
32+ filterKey: "dataset" | "algorithm";
33+ downloadSection?: React.ReactNode;
34+ additionalContent?: React.ReactNode;
35+ showSortByCompressionRatio?: boolean;
36+ showNormalizeByReference?: boolean;
37+}
38+
39+export const BaseContent = ({
40+ item,
41+ benchmarkData,
42+ chartData,
43+ tagNavigationPrefix,
44+ filterKey,
45+ downloadSection,
46+ additionalContent,
47+ showSortByCompressionRatio,
48+ showNormalizeByReference,
49+}: BaseContentProps) => {
50+ const navigate = useNavigate();
51+ const [isExpanded, setIsExpanded] = useState(false);
52+
53+ return (
54+ <div>
55+ <div className="content-container">
56+ <p className="content-header">
57+ <strong>{item.name}</strong> | {item.description}
58+ </p>
59+ {item.long_description && (
60+ <>
61+ <button
62+ className="description-toggle"
63+ onClick={() => setIsExpanded(!isExpanded)}
64+ >
65+ {isExpanded ? "View less" : "Read more"}
66+ </button>
67+ {isExpanded && (
68+ <div className="long-description">
69+ <ReactMarkdown
70+ remarkPlugins={[remarkMath]}
71+ rehypePlugins={[rehypeKatex]}
72+ >
73+ {item.long_description}
74+ </ReactMarkdown>
75+ </div>
76+ )}
77+ </>
78+ )}
79+ </div>
80+ <div className="metadata-section">
81+ <div>
82+ <span className="metadata-label">Version: </span>
83+ <span className="metadata-value">{item.version}</span>
84+ </div>
85+ <div>
86+ <span className="metadata-label">Tags: </span>
87+ {item.tags.map((tag) => (
88+ <span
89+ key={tag}
90+ className="tag"
91+ onClick={() => navigate(`${tagNavigationPrefix}?tag=${tag}`)}
92+ >
93+ {tag}
94+ </span>
95+ ))}
96+ </div>
97+ {downloadSection}
98+ {item.source_file && (
99+ <div>
100+ <span className="metadata-label">Source: </span>
101+ <a
102+ href={item.source_file}
103+ target="_blank"
104+ rel="noopener noreferrer"
105+ className="source-link"
106+ >
107+ View
108+ </a>
109+ </div>
110+ )}
111+ </div>
112+ {additionalContent}
113+ {benchmarkData && (
114+ <>
115+ <div className="benchmark-section">
116+ <h2 className="benchmark-title">Benchmark Results</h2>
117+ <BenchmarkCharts
118+ chartData={chartData}
119+ showSortByCompressionRatio={showSortByCompressionRatio}
120+ showNormalizeByReference={showNormalizeByReference}
121+ />
122+ {filterKey === "dataset" && (
123+ <BenchmarkScatterPlots chartData={chartData} />
124+ )}
125+ </div>
126+ <div className="benchmark-section">
127+ <BenchmarkTable
128+ results={benchmarkData.results.filter(
129+ (result) => result[filterKey] === item.name,
130+ )}
131+ />
132+ </div>
133+ </>
134+ )}
135+ </div>
136+ );
137+};
web-ui/src/components/shared/ContentStyles.cssadded+178−0View file
@@ -0,0 +1,178 @@
1+.content-container {
2+ margin-bottom: 1.5rem;
3+}
4+
5+.content-header {
6+ font-size: 0.9rem;
7+ line-height: 1.5;
8+ margin-bottom: 0.5rem;
9+}
10+
11+.description-toggle {
12+ color: #0066cc;
13+ text-decoration: none;
14+ font-size: 0.8rem;
15+ cursor: pointer;
16+ background: none;
17+ border: none;
18+ padding: 0;
19+ margin-top: 0.25rem;
20+ display: block;
21+}
22+
23+.description-toggle:hover {
24+ text-decoration: underline;
25+}
26+
27+.long-description {
28+ font-size: 0.9rem;
29+ line-height: 1.5;
30+ margin-top: 0.5rem;
31+ padding: 0.5rem;
32+ background-color: #f8f8f8;
33+ border-radius: 4px;
34+}
35+
36+/* Markdown styles */
37+.long-description h1,
38+.long-description h2,
39+.long-description h3,
40+.long-description h4,
41+.long-description h5,
42+.long-description h6 {
43+ margin-top: 1.5em;
44+ margin-bottom: 0.5em;
45+ font-weight: 600;
46+}
47+
48+.long-description h1 { font-size: 1.5em; }
49+.long-description h2 { font-size: 1.3em; }
50+.long-description h3 { font-size: 1.1em; }
51+
52+.long-description p {
53+ margin-bottom: 1em;
54+}
55+
56+.long-description ul,
57+.long-description ol {
58+ margin: 1em 0;
59+ padding-left: 2em;
60+}
61+
62+.long-description li {
63+ margin: 0.5em 0;
64+}
65+
66+.long-description code {
67+ background-color: #eee;
68+ padding: 0.2em 0.4em;
69+ border-radius: 3px;
70+ font-family: monospace;
71+ font-size: 0.9em;
72+}
73+
74+.long-description pre {
75+ background-color: #eee;
76+ padding: 1em;
77+ border-radius: 4px;
78+ overflow-x: auto;
79+ margin: 1em 0;
80+}
81+
82+.long-description pre code {
83+ background-color: transparent;
84+ padding: 0;
85+}
86+
87+.long-description blockquote {
88+ border-left: 4px solid #ddd;
89+ margin: 1em 0;
90+ padding-left: 1em;
91+ color: #666;
92+}
93+
94+.long-description a {
95+ color: #0066cc;
96+ text-decoration: none;
97+}
98+
99+.long-description a:hover {
100+ text-decoration: underline;
101+}
102+
103+.long-description img {
104+ max-width: 100%;
105+ height: auto;
106+ margin: 1em 0;
107+}
108+
109+.long-description table {
110+ border-collapse: collapse;
111+ width: 100%;
112+ margin: 1em 0;
113+}
114+
115+.long-description th,
116+.long-description td {
117+ border: 1px solid #ddd;
118+ padding: 0.5em;
119+ text-align: left;
120+}
121+
122+.long-description th {
123+ background-color: #f0f0f0;
124+}
125+
126+.metadata-section {
127+ margin-bottom: 1.5rem;
128+ display: flex;
129+ gap: 2rem;
130+ flex-wrap: wrap;
131+}
132+
133+.metadata-label {
134+ font-weight: bold;
135+ font-size: 0.9rem;
136+}
137+
138+.metadata-value {
139+ font-size: 0.9rem;
140+}
141+
142+.tag {
143+ color: #0066cc;
144+ text-decoration: none;
145+ padding: 2px 6px;
146+ background-color: #f0f0f0;
147+ border-radius: 4px;
148+ font-size: 0.9rem;
149+ cursor: pointer;
150+}
151+
152+.source-link {
153+ color: #0066cc;
154+ text-decoration: none;
155+ padding: 2px 6px;
156+ background-color: #f0f0f0;
157+ border-radius: 4px;
158+ font-size: 0.9rem;
159+}
160+
161+.benchmark-section {
162+ margin-bottom: 1.5rem;
163+}
164+
165+.benchmark-title {
166+ font-size: 1.2rem;
167+ font-weight: bold;
168+ margin-bottom: 0.5rem;
169+}
170+
171+.download-link {
172+ color: #0066cc;
173+ text-decoration: none;
174+ padding: 2px 6px;
175+ background-color: #f0f0f0;
176+ border-radius: 4px;
177+ font-size: 0.9rem;
178+}
web-ui/src/components/tables/DatasetAlgorithmTables.tsxadded+432−0View file
@@ -0,0 +1,432 @@
1+import { Link } from "react-router-dom";
2+import { TagFilter } from "../TagFilter";
3+import { Dataset, Algorithm, BenchmarkResult } from "../../types";
4+
5+interface DatasetTableProps {
6+ filteredDatasets: Dataset[];
7+ availableDatasetTags: string[];
8+ selectedTags: string[];
9+ toggleTag: (tag: string) => void;
10+ benchmarkResults: BenchmarkResult[];
11+}
12+
13+interface AlgorithmTableProps {
14+ filteredAlgorithms: Algorithm[];
15+ availableAlgorithmTags: string[];
16+ selectedTags: string[];
17+ toggleTag: (tag: string) => void;
18+}
19+
20+export const DatasetTable = ({
21+ filteredDatasets,
22+ availableDatasetTags,
23+ selectedTags,
24+ toggleTag,
25+ benchmarkResults,
26+}: DatasetTableProps) => {
27+ const getBestCompressionResult = (datasetName: string) => {
28+ const datasetResults = benchmarkResults.filter(
29+ (result) => result.dataset === datasetName,
30+ );
31+ if (datasetResults.length === 0) return { algorithm: "N/A", ratio: 0 };
32+
33+ const bestResult = datasetResults.reduce((best, current) =>
34+ current.compression_ratio > best.compression_ratio ? current : best,
35+ );
36+ return {
37+ algorithm: bestResult.algorithm,
38+ ratio: bestResult.compression_ratio,
39+ };
40+ };
41+
42+ return (
43+ <div style={{ overflowX: "auto" }}>
44+ <div style={{ marginBottom: "1rem" }}>
45+ <TagFilter
46+ availableTags={availableDatasetTags}
47+ selectedTags={selectedTags}
48+ onTagToggle={toggleTag}
49+ label="Filter datasets"
50+ />
51+ </div>
52+ <table style={{ width: "100%", borderCollapse: "collapse" }}>
53+ <thead>
54+ <tr style={{ backgroundColor: "#f5f5f5" }}>
55+ <th
56+ style={{
57+ padding: "8px 12px",
58+ textAlign: "left",
59+ borderBottom: "1px solid #ddd",
60+ fontSize: "0.9rem",
61+ whiteSpace: "nowrap",
62+ }}
63+ >
64+ Name
65+ </th>
66+ <th
67+ style={{
68+ padding: "8px 12px",
69+ textAlign: "left",
70+ borderBottom: "1px solid #ddd",
71+ fontSize: "0.9rem",
72+ whiteSpace: "nowrap",
73+ }}
74+ >
75+ Version
76+ </th>
77+ <th
78+ style={{
79+ padding: "8px 12px",
80+ textAlign: "left",
81+ borderBottom: "1px solid #ddd",
82+ fontSize: "0.9rem",
83+ whiteSpace: "nowrap",
84+ }}
85+ >
86+ Description
87+ </th>
88+ <th
89+ style={{
90+ padding: "8px 12px",
91+ textAlign: "left",
92+ borderBottom: "1px solid #ddd",
93+ fontSize: "0.9rem",
94+ whiteSpace: "nowrap",
95+ }}
96+ >
97+ Tags
98+ </th>
99+ <th
100+ style={{
101+ padding: "8px 12px",
102+ textAlign: "left",
103+ borderBottom: "1px solid #ddd",
104+ fontSize: "0.9rem",
105+ whiteSpace: "nowrap",
106+ }}
107+ >
108+ Source
109+ </th>
110+ <th
111+ style={{
112+ padding: "8px 12px",
113+ textAlign: "left",
114+ borderBottom: "1px solid #ddd",
115+ fontSize: "0.9rem",
116+ whiteSpace: "nowrap",
117+ }}
118+ >
119+ Data
120+ </th>
121+ <th
122+ style={{
123+ padding: "8px 12px",
124+ textAlign: "left",
125+ borderBottom: "1px solid #ddd",
126+ fontSize: "0.9rem",
127+ whiteSpace: "nowrap",
128+ }}
129+ >
130+ Best Compression
131+ </th>
132+ </tr>
133+ </thead>
134+ <tbody>
135+ {filteredDatasets.map((dataset, index) => (
136+ <tr
137+ key={`${dataset.name}-${dataset.version}`}
138+ style={{ backgroundColor: index % 2 === 0 ? "white" : "#fafafa" }}
139+ >
140+ <td
141+ style={{
142+ padding: "6px 12px",
143+ borderBottom: "1px solid #ddd",
144+ fontSize: "0.9rem",
145+ }}
146+ >
147+ <Link
148+ to={`/dataset/${encodeURIComponent(dataset.name)}`}
149+ style={{
150+ color: "#0066cc",
151+ textDecoration: "none",
152+ fontWeight: "500",
153+ }}
154+ >
155+ {dataset.name}
156+ </Link>
157+ </td>
158+ <td
159+ style={{
160+ padding: "6px 12px",
161+ borderBottom: "1px solid #ddd",
162+ fontSize: "0.9rem",
163+ }}
164+ >
165+ {dataset.version}
166+ </td>
167+ <td
168+ style={{
169+ padding: "6px 12px",
170+ borderBottom: "1px solid #ddd",
171+ fontSize: "0.9rem",
172+ }}
173+ >
174+ {dataset.description}
175+ </td>
176+ <td
177+ style={{
178+ padding: "6px 12px",
179+ borderBottom: "1px solid #ddd",
180+ fontSize: "0.9rem",
181+ }}
182+ >
183+ {dataset.tags.map((tag) => (
184+ <span
185+ key={tag}
186+ style={{
187+ display: "inline-block",
188+ backgroundColor: "#e1e1e1",
189+ padding: "2px 6px",
190+ borderRadius: "3px",
191+ margin: "1px",
192+ fontSize: "0.8rem",
193+ }}
194+ >
195+ {tag}
196+ </span>
197+ ))}
198+ </td>
199+ <td
200+ style={{
201+ padding: "6px 12px",
202+ borderBottom: "1px solid #ddd",
203+ fontSize: "0.9rem",
204+ }}
205+ >
206+ {dataset.source_file && (
207+ <a
208+ href={dataset.source_file}
209+ target="_blank"
210+ rel="noopener noreferrer"
211+ style={{ color: "#0066cc", textDecoration: "none" }}
212+ >
213+ View Source
214+ </a>
215+ )}
216+ </td>
217+ <td
218+ style={{
219+ padding: "6px 12px",
220+ borderBottom: "1px solid #ddd",
221+ fontSize: "0.9rem",
222+ }}
223+ >
224+ {dataset.data_url_npy && (
225+ <a
226+ href={dataset.data_url_npy}
227+ download={`${dataset.name}-${dataset.version}.npy`}
228+ style={{ color: "#0066cc", textDecoration: "none" }}
229+ >
230+ Download
231+ </a>
232+ )}
233+ </td>
234+ <td
235+ style={{
236+ padding: "6px 12px",
237+ borderBottom: "1px solid #ddd",
238+ fontSize: "0.9rem",
239+ }}
240+ >
241+ {(() => {
242+ const result = getBestCompressionResult(dataset.name);
243+ return (
244+ <>
245+ <Link
246+ to={`/algorithm/${encodeURIComponent(result.algorithm)}`}
247+ style={{
248+ color: "#0066cc",
249+ textDecoration: "none",
250+ fontWeight: "500",
251+ }}
252+ >
253+ {result.algorithm}
254+ </Link>
255+ {result.algorithm !== "N/A" &&
256+ ` (${result.ratio.toFixed(1)})`}
257+ </>
258+ );
259+ })()}
260+ </td>
261+ </tr>
262+ ))}
263+ </tbody>
264+ </table>
265+ </div>
266+ );
267+};
268+
269+export const AlgorithmTable = ({
270+ filteredAlgorithms,
271+ availableAlgorithmTags,
272+ selectedTags,
273+ toggleTag,
274+}: AlgorithmTableProps) => (
275+ <div style={{ overflowX: "auto" }}>
276+ <div style={{ marginBottom: "1rem" }}>
277+ <TagFilter
278+ availableTags={availableAlgorithmTags}
279+ selectedTags={selectedTags}
280+ onTagToggle={toggleTag}
281+ label="Filter algorithms"
282+ />
283+ </div>
284+ <table style={{ width: "100%", borderCollapse: "collapse" }}>
285+ <thead>
286+ <tr style={{ backgroundColor: "#f5f5f5" }}>
287+ <th
288+ style={{
289+ padding: "8px 12px",
290+ textAlign: "left",
291+ borderBottom: "1px solid #ddd",
292+ fontSize: "0.9rem",
293+ whiteSpace: "nowrap",
294+ }}
295+ >
296+ Name
297+ </th>
298+ <th
299+ style={{
300+ padding: "8px 12px",
301+ textAlign: "left",
302+ borderBottom: "1px solid #ddd",
303+ fontSize: "0.9rem",
304+ whiteSpace: "nowrap",
305+ }}
306+ >
307+ Version
308+ </th>
309+ <th
310+ style={{
311+ padding: "8px 12px",
312+ textAlign: "left",
313+ borderBottom: "1px solid #ddd",
314+ fontSize: "0.9rem",
315+ whiteSpace: "nowrap",
316+ }}
317+ >
318+ Description
319+ </th>
320+ <th
321+ style={{
322+ padding: "8px 12px",
323+ textAlign: "left",
324+ borderBottom: "1px solid #ddd",
325+ fontSize: "0.9rem",
326+ whiteSpace: "nowrap",
327+ }}
328+ >
329+ Tags
330+ </th>
331+ <th
332+ style={{
333+ padding: "8px 12px",
334+ textAlign: "left",
335+ borderBottom: "1px solid #ddd",
336+ fontSize: "0.9rem",
337+ whiteSpace: "nowrap",
338+ }}
339+ >
340+ Source
341+ </th>
342+ </tr>
343+ </thead>
344+ <tbody>
345+ {filteredAlgorithms.map((algorithm, index) => (
346+ <tr
347+ key={`${algorithm.name}-${algorithm.version}`}
348+ style={{ backgroundColor: index % 2 === 0 ? "white" : "#fafafa" }}
349+ >
350+ <td
351+ style={{
352+ padding: "6px 12px",
353+ borderBottom: "1px solid #ddd",
354+ fontSize: "0.9rem",
355+ }}
356+ >
357+ <Link
358+ to={`/algorithm/${encodeURIComponent(algorithm.name)}`}
359+ style={{
360+ color: "#0066cc",
361+ textDecoration: "none",
362+ fontWeight: "500",
363+ }}
364+ >
365+ {algorithm.name}
366+ </Link>
367+ </td>
368+ <td
369+ style={{
370+ padding: "6px 12px",
371+ borderBottom: "1px solid #ddd",
372+ fontSize: "0.9rem",
373+ }}
374+ >
375+ {algorithm.version}
376+ </td>
377+ <td
378+ style={{
379+ padding: "6px 12px",
380+ borderBottom: "1px solid #ddd",
381+ fontSize: "0.9rem",
382+ }}
383+ >
384+ {algorithm.description}
385+ </td>
386+ <td
387+ style={{
388+ padding: "6px 12px",
389+ borderBottom: "1px solid #ddd",
390+ fontSize: "0.9rem",
391+ }}
392+ >
393+ {algorithm.tags.map((tag) => (
394+ <span
395+ key={tag}
396+ style={{
397+ display: "inline-block",
398+ backgroundColor: "#e1e1e1",
399+ padding: "2px 6px",
400+ borderRadius: "3px",
401+ margin: "1px",
402+ fontSize: "0.8rem",
403+ }}
404+ >
405+ {tag}
406+ </span>
407+ ))}
408+ </td>
409+ <td
410+ style={{
411+ padding: "6px 12px",
412+ borderBottom: "1px solid #ddd",
413+ fontSize: "0.9rem",
414+ }}
415+ >
416+ {algorithm.source_file && (
417+ <a
418+ href={algorithm.source_file}
419+ target="_blank"
420+ rel="noopener noreferrer"
421+ style={{ color: "#0066cc", textDecoration: "none" }}
422+ >
423+ View Source
424+ </a>
425+ )}
426+ </td>
427+ </tr>
428+ ))}
429+ </tbody>
430+ </table>
431+ </div>
432+);
web-ui/src/content/home-content.ymladded+48−0View file
@@ -0,0 +1,48 @@
1+title: Welcome to Ephys Compression Tests
2+description:
3+ Ephys Compression Tests is an open-source project for comparing compression algorithms
4+ on electrophysiology data.
5+
6+sections:
7+ algorithms:
8+ title: Algorithms
9+ description:
10+ Explore the supported compression algorithms, including traditional methods
11+ like zlib and modern approaches like ANS.
12+ link: /algorithms
13+ linkText: View Algorithms
14+
15+ datasets:
16+ title: Datasets
17+ description:
18+ Explore the curated collection of ephys datasets.
19+ See the performance of the various compression algorithms on each dataset.
20+ link: /datasets
21+ linkText: View Datasets
22+
23+ monitor:
24+ title: Live Monitor
25+ description:
26+ The benchmarking runs automatically in a GitHub Actions workflow. Track
27+ current progress, view completed benchmarks, and monitor performance
28+ metrics as they are generated.
29+ link: /monitor
30+ linkText: View Monitor
31+
32+ source:
33+ title: Source Code
34+ description:
35+ Explore the GitHub repository to view the source code, contribute to the
36+ project, or run your own benchmarks locally.
37+ link: https://github.com/magland/ephys_compression_tests
38+ linkText: View on GitHub
39+ external: true
40+
41+ submit:
42+ title: Submit an Algorithm or Dataset
43+ description:
44+ Want to contribute? Learn how to add your own compression algorithm
45+ or ephys dataset to the benchmark suite. Follow the step-by-step
46+ guide for preparing and submitting your contribution.
47+ link: /submit
48+ linkText: Submission Guide
web-ui/src/env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+declare const __BUILD_DATE__: string;
web-ui/src/hooks/TimeseriesDataClient.tsadded+175−0View file
@@ -0,0 +1,175 @@
1+export type SupportedTypedArray =
2+ | Uint8Array
3+ | Uint16Array
4+ | Uint32Array
5+ | Int16Array
6+ | Int32Array
7+ | Float32Array;
8+
9+interface ChunkCache {
10+ [key: number]: SupportedTypedArray;
11+}
12+
13+type DType = "uint8" | "uint16" | "uint32" | "int16" | "int32" | "float32";
14+
15+const TypedArrayConstructors = {
16+ uint8: Uint8Array,
17+ uint16: Uint16Array,
18+ uint32: Uint32Array,
19+ int16: Int16Array,
20+ int32: Int32Array,
21+ float32: Float32Array,
22+} as const;
23+
24+export class TimeseriesDataClient {
25+ private shape: number = 0;
26+ private dtype: DType | null = null;
27+ private chunkSize: number;
28+ private cache: ChunkCache = {};
29+ private inProgressFetches: { [key: number]: Promise<SupportedTypedArray> } =
30+ {};
31+ private datasetJsonUrl: string;
32+ private datasetDataUrl: string;
33+
34+ constructor(
35+ datasetJsonUrl: string,
36+ datasetDataUrl: string,
37+ chunkSize: number = 100000,
38+ ) {
39+ this.datasetJsonUrl = datasetJsonUrl;
40+ this.datasetDataUrl = datasetDataUrl;
41+ this.chunkSize = chunkSize;
42+ }
43+
44+ static async create(
45+ datasetJsonUrl: string,
46+ datasetDataUrl: string,
47+ chunkSize: number = 1000,
48+ ): Promise<TimeseriesDataClient> {
49+ const client = new TimeseriesDataClient(
50+ datasetJsonUrl,
51+ datasetDataUrl,
52+ chunkSize,
53+ );
54+ await client.initialize();
55+ return client;
56+ }
57+
58+ private async initialize() {
59+ const infoUrl = this.datasetJsonUrl;
60+ const response = await fetch(infoUrl);
61+ if (!response.ok) {
62+ throw new Error(`Failed to fetch dataset info: ${response.statusText}`);
63+ }
64+ const info = await response.json();
65+ this.shape = info.shape[0];
66+
67+ if (!this.isValidDType(info.dtype)) {
68+ throw new Error(`Unsupported data type: ${info.dtype}`);
69+ }
70+ this.dtype = info.dtype;
71+ }
72+
73+ private isValidDType(dtype: string): dtype is DType {
74+ return dtype in TypedArrayConstructors;
75+ }
76+
77+ private getChunkIndices(start: number, end: number): number[] {
78+ const startChunk = Math.floor(start / this.chunkSize);
79+ const endChunk = Math.floor(end / this.chunkSize);
80+ const chunks: number[] = [];
81+ for (let i = startChunk; i <= endChunk; i++) {
82+ chunks.push(i);
83+ }
84+ return chunks;
85+ }
86+
87+ private async fetchChunk(chunkIndex: number): Promise<SupportedTypedArray> {
88+ // Return cached chunk if available
89+ if (this.cache[chunkIndex]) {
90+ return this.cache[chunkIndex];
91+ }
92+
93+ // If this chunk is already being fetched, wait for it to complete
94+ const inProgressFetch = this.inProgressFetches[chunkIndex];
95+ if (inProgressFetch !== undefined) {
96+ return inProgressFetch;
97+ }
98+
99+ // Start new fetch and track it
100+ const fetchPromise = (async () => {
101+ if (!this.dtype) {
102+ throw new Error("Data type not initialized");
103+ }
104+
105+ const start = chunkIndex * this.chunkSize;
106+ const end = Math.min(start + this.chunkSize, this.shape);
107+ const url = this.datasetDataUrl;
108+ const itemSize = TypedArrayConstructors[this.dtype].BYTES_PER_ELEMENT;
109+ const byteStart = start * itemSize;
110+ const byteEnd = end * itemSize;
111+
112+ try {
113+ const response = await fetch(url, {
114+ headers: {
115+ Range: `bytes=${byteStart}-${byteEnd - 1}`,
116+ },
117+ });
118+ if (!response.ok) {
119+ throw new Error(`Failed to fetch chunk: ${response.statusText}`);
120+ }
121+
122+ const buffer = await response.arrayBuffer();
123+ const ArrayConstructor = TypedArrayConstructors[this.dtype];
124+ const data = new ArrayConstructor(buffer);
125+ this.cache[chunkIndex] = data;
126+ return data;
127+ } finally {
128+ // Clean up the in-progress fetch regardless of success/failure
129+ delete this.inProgressFetches[chunkIndex];
130+ }
131+ })();
132+
133+ // Store the promise for other requests to wait on
134+ this.inProgressFetches[chunkIndex] = fetchPromise;
135+ return fetchPromise;
136+ }
137+
138+ async fetchRange(start: number, end: number): Promise<SupportedTypedArray> {
139+ if (!this.dtype) {
140+ throw new Error("Data type not initialized");
141+ }
142+
143+ const chunkIndices = this.getChunkIndices(start, end);
144+ const chunks = await Promise.all(
145+ chunkIndices.map((idx) => this.fetchChunk(idx)),
146+ );
147+
148+ // Calculate total length needed
149+ const length = end - start;
150+ const ArrayConstructor = TypedArrayConstructors[this.dtype];
151+ const result = new ArrayConstructor(length);
152+
153+ // Copy data from chunks into result array
154+ let resultOffset = 0;
155+ for (let i = 0; i < chunks.length; i++) {
156+ const chunk = chunks[i];
157+ const chunkStart = chunkIndices[i] * this.chunkSize;
158+ const copyStart = Math.max(0, start - chunkStart);
159+ const copyEnd = Math.min(chunk.length, end - chunkStart);
160+ const copyLength = copyEnd - copyStart;
161+ result.set(chunk.subarray(copyStart, copyEnd), resultOffset);
162+ resultOffset += copyLength;
163+ }
164+
165+ return result;
166+ }
167+
168+ getShape(): number {
169+ return this.shape;
170+ }
171+
172+ getDType(): DType | null {
173+ return this.dtype;
174+ }
175+}
web-ui/src/hooks/useBenchmarkChartData.tsadded+37−0View file
@@ -0,0 +1,37 @@
1+import { useMemo } from "react";
2+import { BenchmarkResult } from "../types";
3+
4+export function useBenchmarkChartData(
5+ results: BenchmarkResult[],
6+ selectedDataset?: string | null,
7+ selectedAlgorithm?: string | null,
8+) {
9+ return useMemo(() => {
10+ if (selectedDataset) {
11+ return results
12+ .filter((row) => row.dataset === selectedDataset)
13+ .map((row) => ({
14+ algorithmOrDataset: row.algorithm,
15+ compression_ratio: row.compression_ratio,
16+ reference_compression_ratio: null,
17+ encode_speed: row.encode_mb_per_sec,
18+ decode_speed: row.decode_mb_per_sec,
19+ }));
20+ } else if (selectedAlgorithm) {
21+ return results
22+ .filter((row) => row.algorithm === selectedAlgorithm)
23+ .map((row) => ({
24+ algorithmOrDataset: row.dataset,
25+ compression_ratio: row.compression_ratio,
26+ reference_compression_ratio: Math.max(
27+ ...results
28+ .filter((r) => r.dataset === row.dataset)
29+ .map((r) => r.compression_ratio),
30+ ),
31+ encode_speed: row.encode_mb_per_sec,
32+ decode_speed: row.decode_mb_per_sec,
33+ }));
34+ }
35+ return [];
36+ }, [results, selectedDataset, selectedAlgorithm]);
37+}
web-ui/src/hooks/useMarkdownContent.tsadded+31−0View file
@@ -0,0 +1,31 @@
1+import { useState, useEffect } from "react";
2+
3+export const useMarkdownContent = (path: string) => {
4+ const [content, setContent] = useState<string>("");
5+ const [error, setError] = useState<string | null>(null);
6+
7+ useEffect(() => {
8+ const fetchContent = async () => {
9+ try {
10+ const response = await fetch(path);
11+ if (!response.ok) {
12+ throw new Error(
13+ `Failed to load markdown content: ${response.statusText}`,
14+ );
15+ }
16+ const text = await response.text();
17+ setContent(text);
18+ } catch (err) {
19+ setError(
20+ err instanceof Error
21+ ? err.message
22+ : "Failed to load markdown content",
23+ );
24+ }
25+ };
26+
27+ fetchContent();
28+ }, [path]);
29+
30+ return { content, error };
31+};
web-ui/src/hooks/useMarkdownPosts.tsadded+62−0View file
@@ -0,0 +1,62 @@
1+import { useState, useEffect } from "react";
2+
3+interface Post {
4+ path: string;
5+ content: string;
6+ date: Date;
7+}
8+
9+export const useMarkdownPosts = (directory: string) => {
10+ const [posts, setPosts] = useState<Post[]>([]);
11+ const [error, setError] = useState<string | null>(null);
12+ const [loading, setLoading] = useState(true);
13+
14+ useEffect(() => {
15+ const fetchPosts = async () => {
16+ try {
17+ // First fetch the index
18+ const indexResponse = await fetch(`${directory}/index.txt`);
19+ if (!indexResponse.ok) {
20+ throw new Error(
21+ `Failed to load post index: ${indexResponse.statusText}`,
22+ );
23+ }
24+ const indexContent = await indexResponse.text();
25+ const paths = indexContent.trim().split("\n");
26+
27+ // Then fetch all posts in parallel
28+ const postPromises = paths.map(async (path) => {
29+ const response = await fetch(`${directory}/${path}`);
30+ if (!response.ok) {
31+ throw new Error(
32+ `Failed to load post ${path}: ${response.statusText}`,
33+ );
34+ }
35+ const content = await response.text();
36+
37+ // Parse date from filename (format: YYYY-MM-DD-title.md)
38+ const dateMatch = path.match(/^(\d{4}-\d{2}-\d{2})/);
39+ if (!dateMatch) {
40+ throw new Error(`Invalid post filename format: ${path}`);
41+ }
42+ const date = new Date(dateMatch[1]);
43+
44+ return { path, content, date };
45+ });
46+
47+ const loadedPosts = await Promise.all(postPromises);
48+ // Sort posts by date, newest first
49+ loadedPosts.sort((a, b) => b.date.getTime() - a.date.getTime());
50+ setPosts(loadedPosts);
51+ setLoading(false);
52+ } catch (err) {
53+ setError(err instanceof Error ? err.message : "Failed to load posts");
54+ setLoading(false);
55+ }
56+ };
57+
58+ fetchPosts();
59+ }, [directory]);
60+
61+ return { posts, error, loading };
62+};
web-ui/src/hooks/useTagFilter.tsadded+30−0View file
@@ -0,0 +1,30 @@
1+import { useMemo } from "react";
2+
3+interface TaggableItem {
4+ tags: string[];
5+}
6+
7+export function useTagFilter<T extends TaggableItem>(
8+ items: T[],
9+ selectedTags: string[],
10+) {
11+ const availableTags = useMemo(() => {
12+ const tagSet = new Set<string>();
13+ items.forEach((item) => {
14+ item.tags.forEach((tag) => tagSet.add(tag));
15+ });
16+ return Array.from(tagSet).sort();
17+ }, [items]);
18+
19+ const filteredItems = useMemo(() => {
20+ if (selectedTags.length === 0) return items;
21+ return items.filter((item) =>
22+ selectedTags.every((tag) => item.tags.includes(tag)),
23+ );
24+ }, [items, selectedTags]);
25+
26+ return {
27+ availableTags,
28+ filteredItems,
29+ };
30+}
web-ui/src/hooks/useTimeseriesData.tsadded+89−0View file
@@ -0,0 +1,89 @@
1+import { useEffect, useState } from "react";
2+import { Dataset } from "../types";
3+
4+const getDtypeSize = (dtype: string): number => {
5+ switch (dtype) {
6+ case "uint8":
7+ return 1;
8+ case "uint16":
9+ return 2;
10+ case "uint32":
11+ return 4;
12+ case "int16":
13+ return 2;
14+ case "int32":
15+ return 4;
16+ default:
17+ throw new Error(`Unsupported dtype: ${dtype}`);
18+ }
19+};
20+
21+const createTypedArray = (buffer: ArrayBuffer, dtype: string): number[] => {
22+ switch (dtype) {
23+ case "uint8":
24+ return Array.from(new Uint8Array(buffer));
25+ case "uint16":
26+ return Array.from(new Uint16Array(buffer));
27+ case "uint32":
28+ return Array.from(new Uint32Array(buffer));
29+ case "int16":
30+ return Array.from(new Int16Array(buffer));
31+ case "int32":
32+ return Array.from(new Int32Array(buffer));
33+ default:
34+ throw new Error(`Unsupported dtype: ${dtype}`);
35+ }
36+};
37+
38+export const useTimeseriesData = (dataset: Dataset) => {
39+ const [data, setData] = useState<number[] | null>(null);
40+ const [error, setError] = useState<string | null>(null);
41+
42+ useEffect(() => {
43+ const fetchData = async () => {
44+ if (!dataset.data_url_raw) {
45+ setError("No raw data URL available");
46+ return;
47+ }
48+
49+ const metaJsonUrl = dataset.data_url_json;
50+ if (!metaJsonUrl) {
51+ setError("No JSON metadata URL available");
52+ return;
53+ }
54+
55+ try {
56+ const metaResponse = await fetch(metaJsonUrl);
57+ if (!metaResponse.ok) {
58+ throw new Error(`HTTP error! status: ${metaResponse.status}`);
59+ }
60+
61+ const metaJson = await metaResponse.json();
62+
63+ const dtype = metaJson.dtype;
64+ const bytesPerElement = getDtypeSize(dtype);
65+
66+ const numBytes = bytesPerElement * 1000;
67+
68+ const response = await fetch(dataset.data_url_raw, {
69+ headers: {
70+ Range: `bytes=0-${numBytes - 1}`, // First 1000 elements
71+ },
72+ });
73+
74+ if (!response.ok) {
75+ throw new Error(`HTTP error! status: ${response.status}`);
76+ }
77+
78+ const buffer = await response.arrayBuffer();
79+ const data = createTypedArray(buffer, dtype);
80+ setData(data);
81+ } catch (err) {
82+ setError(err instanceof Error ? err.message : "Failed to fetch data");
83+ }
84+ };
85+ fetchData();
86+ }, [dataset]);
87+
88+ return { data, error };
89+};
web-ui/src/hooks/useTimeseriesDataClient.tsadded+39−0View file
@@ -0,0 +1,39 @@
1+import { useEffect, useState } from "react";
2+import { Dataset } from "../types";
3+import { TimeseriesDataClient } from "./TimeseriesDataClient";
4+
5+interface UseTimeseriesDataClientResult {
6+ client: TimeseriesDataClient | null;
7+ error: string | null;
8+}
9+
10+export const useTimeseriesDataClient = (
11+ dataset: Dataset,
12+ chunkSize: number = 1000,
13+): UseTimeseriesDataClientResult => {
14+ const [client, setClient] = useState<TimeseriesDataClient | null>(null);
15+ const [error, setError] = useState<string | null>(null);
16+
17+ useEffect(() => {
18+ const initClient = async () => {
19+ try {
20+ const newClient = await TimeseriesDataClient.create(
21+ dataset.data_url_json || "",
22+ dataset.data_url_raw || "",
23+ chunkSize,
24+ );
25+ setClient(newClient);
26+ setError(null);
27+ } catch (err) {
28+ setError(
29+ err instanceof Error ? err.message : "Failed to initialize client",
30+ );
31+ setClient(null);
32+ }
33+ };
34+
35+ initClient();
36+ }, [dataset.data_url_json, dataset.data_url_raw, chunkSize]);
37+
38+ return { client, error };
39+};
web-ui/src/index.cssadded+68−0View file
@@ -0,0 +1,68 @@
1+:root {
2+ font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
3+ line-height: 1.5;
4+ font-weight: 400;
5+}
6+
7+body {
8+ margin: 0;
9+ min-width: 320px;
10+ min-height: 100vh;
11+ background-color: #f5f5f5;
12+}
13+
14+table {
15+ width: 100%;
16+ border-collapse: collapse;
17+ background: white;
18+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
19+ border-radius: 4px;
20+ overflow: hidden;
21+ font-size: 0.9rem;
22+ line-height: 1.3;
23+}
24+
25+th {
26+ background-color: #f8f9fa;
27+ padding: 8px 12px;
28+ text-align: left;
29+ font-weight: 600;
30+ color: #333;
31+ border-bottom: 1px solid #dee2e6;
32+ cursor: pointer;
33+ white-space: nowrap;
34+}
35+
36+th:hover {
37+ background-color: #e9ecef;
38+}
39+
40+td {
41+ padding: 6px 12px;
42+ border-bottom: 1px solid #dee2e6;
43+ color: #444;
44+}
45+
46+tr:hover {
47+ background-color: #f8f9fa;
48+}
49+
50+tr:last-child td {
51+ border-bottom: none;
52+}
53+
54+.table-container {
55+ margin: 12px 0;
56+ overflow-x: auto;
57+}
58+
59+/* Make filter controls more compact */
60+select {
61+ padding: 4px 8px !important;
62+ font-size: 0.9rem !important;
63+ min-width: 150px !important;
64+}
65+
66+.table-container label {
67+ font-size: 0.9rem;
68+}
web-ui/src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from "react";
2+import { createRoot } from "react-dom/client";
3+import "./index.css";
4+import App from "./App.tsx";
5+
6+createRoot(document.getElementById("root")!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+);
web-ui/src/pages/BenchmarkView.tsxadded+251−0View file
@@ -0,0 +1,251 @@
1+import { useLocation, useNavigate, useParams } from "react-router-dom";
2+import { useReducer, useEffect } from "react";
3+import { tabsReducer } from "../reducers/tabsReducer";
4+import { AlgorithmContent } from "../components/algorithm/AlgorithmContent";
5+import { DatasetContent } from "../components/dataset/DatasetContent";
6+import {
7+ AlgorithmTable,
8+ DatasetTable,
9+} from "../components/tables/DatasetAlgorithmTables";
10+import { useBenchmarkChartData } from "../hooks/useBenchmarkChartData";
11+import { useTagFilter } from "../hooks/useTagFilter";
12+import { BenchmarkData } from "../types";
13+
14+interface BenchmarkViewProps {
15+ benchmarkData: BenchmarkData | null;
16+}
17+
18+export default function BenchmarkView({ benchmarkData }: BenchmarkViewProps) {
19+ const location = useLocation();
20+ const navigate = useNavigate();
21+ const { datasetName, algorithmName } = useParams<{
22+ datasetName?: string;
23+ algorithmName?: string;
24+ }>();
25+
26+ const [tabsState, dispatch] = useReducer(tabsReducer, {
27+ tabs: [
28+ { id: "datasets", label: "Datasets", route: "/datasets" },
29+ { id: "algorithms", label: "Algorithms", route: "/algorithms" },
30+ ],
31+ activeTabId: "datasets",
32+ });
33+
34+ // Effect to handle URL changes and update tabs
35+ useEffect(() => {
36+ if (datasetName) {
37+ dispatch({
38+ type: "ADD_TAB",
39+ payload: {
40+ id: `dataset-${datasetName}`,
41+ label: datasetName,
42+ route: `/dataset/${datasetName}`,
43+ },
44+ });
45+ } else if (algorithmName) {
46+ dispatch({
47+ type: "ADD_TAB",
48+ payload: {
49+ id: `algorithm-${algorithmName}`,
50+ label: algorithmName,
51+ route: `/algorithm/${algorithmName}`,
52+ },
53+ });
54+ } else if (location.pathname.includes("/algorithms")) {
55+ dispatch({ type: "SET_ACTIVE_TAB", payload: "algorithms" });
56+ } else if (location.pathname.includes("/datasets")) {
57+ dispatch({ type: "SET_ACTIVE_TAB", payload: "datasets" });
58+ }
59+ }, [datasetName, algorithmName, location.pathname]);
60+
61+ // Handle tab click
62+ const handleTabClick = (tabId: string, route: string) => {
63+ dispatch({ type: "SET_ACTIVE_TAB", payload: tabId });
64+ navigate(route);
65+ };
66+
67+ // Get specific dataset or algorithm if viewing one
68+ const dataset = datasetName
69+ ? benchmarkData?.datasets.find((d) => d.name === datasetName)
70+ : undefined;
71+ const algorithm = algorithmName
72+ ? benchmarkData?.algorithms.find((a) => a.name === algorithmName)
73+ : undefined;
74+
75+ // Get chart data for specific dataset or algorithm view
76+ const chartData = useBenchmarkChartData(
77+ benchmarkData?.results || [],
78+ dataset?.name || null,
79+ algorithm?.name || null,
80+ );
81+
82+ // Get selected tags from URL
83+ const searchParams = new URLSearchParams(location.search);
84+ const selectedTags = searchParams.get("tag")?.split(",") || [];
85+
86+ // Set up tag filtering for datasets and algorithms
87+ const {
88+ availableTags: availableDatasetTags,
89+ filteredItems: filteredDatasets,
90+ } = useTagFilter(
91+ benchmarkData?.datasets || [],
92+ location.pathname.includes("/datasets") ? selectedTags : [],
93+ );
94+
95+ const {
96+ availableTags: availableAlgorithmTags,
97+ filteredItems: filteredAlgorithms,
98+ } = useTagFilter(
99+ benchmarkData?.algorithms || [],
100+ location.pathname.includes("/algorithms") ? selectedTags : [],
101+ );
102+
103+ // Handle tag toggling by updating URL
104+ const handleTagToggle = (tag: string) => {
105+ const newTags = selectedTags.includes(tag)
106+ ? selectedTags.filter((t) => t !== tag)
107+ : [...selectedTags, tag];
108+
109+ const params = new URLSearchParams();
110+ if (newTags.length > 0) {
111+ params.set("tag", newTags.join(","));
112+ }
113+ navigate({ search: params.toString() });
114+ };
115+
116+ return (
117+ <div>
118+ <main>
119+ <div
120+ style={{
121+ position: "fixed",
122+ top: "3rem",
123+ left: 0,
124+ right: 0,
125+ backgroundColor: "white",
126+ zIndex: 999,
127+ padding: "0 2rem 0 2rem",
128+ marginTop: "-4px",
129+ boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
130+ borderBottom: "1px solid #eaeaea",
131+ }}
132+ >
133+ <div
134+ style={{
135+ paddingBottom: "2px",
136+ display: "flex",
137+ gap: "4px",
138+ overflowX: "auto",
139+ width: "100%",
140+ backgroundColor: "white",
141+ }}
142+ >
143+ {tabsState.tabs.map((tab) => (
144+ <div
145+ key={tab.id}
146+ style={{
147+ display: "flex",
148+ alignItems: "center",
149+ gap: "4px",
150+ }}
151+ >
152+ <button
153+ onClick={() => handleTabClick(tab.id, tab.route)}
154+ style={{
155+ padding: "8px 16px",
156+ border: "none",
157+ background: "none",
158+ borderBottom:
159+ tabsState.activeTabId === tab.id
160+ ? "2px solid #0066cc"
161+ : "none",
162+ color:
163+ tabsState.activeTabId === tab.id ? "#0066cc" : "#666",
164+ fontWeight:
165+ tabsState.activeTabId === tab.id ? "600" : "normal",
166+ cursor: "pointer",
167+ textDecoration: "none",
168+ whiteSpace: "nowrap",
169+ }}
170+ >
171+ {tab.label}
172+ </button>
173+ {tab.id !== "datasets" && tab.id !== "algorithms" && (
174+ <button
175+ onClick={(e) => {
176+ e.stopPropagation();
177+ const newActiveTab =
178+ tab.id === tabsState.activeTabId
179+ ? tabsState.tabs[0].id // Default to first tab if closing active
180+ : tabsState.activeTabId;
181+ dispatch({ type: "CLOSE_TAB", payload: tab.id });
182+ // Navigate if closing active tab
183+ if (tab.id === tabsState.activeTabId) {
184+ const defaultTab = tabsState.tabs.find(
185+ (t) => t.id === newActiveTab,
186+ );
187+ if (defaultTab) {
188+ navigate(defaultTab.route);
189+ }
190+ }
191+ }}
192+ style={{
193+ padding: "4px",
194+ border: "none",
195+ background: "none",
196+ color: "#666",
197+ cursor: "pointer",
198+ fontSize: "12px",
199+ display: "flex",
200+ alignItems: "center",
201+ justifyContent: "center",
202+ width: "20px",
203+ height: "20px",
204+ borderRadius: "50%",
205+ marginRight: "4px",
206+ marginLeft: "-4px",
207+ }}
208+ aria-label="Close tab"
209+ >
210+ ×
211+ </button>
212+ )}
213+ </div>
214+ ))}
215+ </div>
216+ </div>
217+
218+ <div style={{ padding: "3rem 0 1rem 0" }}>
219+ {dataset ? (
220+ <DatasetContent
221+ dataset={dataset}
222+ benchmarkData={benchmarkData}
223+ chartData={chartData}
224+ />
225+ ) : algorithm ? (
226+ <AlgorithmContent
227+ algorithm={algorithm}
228+ benchmarkData={benchmarkData}
229+ chartData={chartData}
230+ />
231+ ) : tabsState.activeTabId === "datasets" ? (
232+ <DatasetTable
233+ filteredDatasets={filteredDatasets}
234+ availableDatasetTags={availableDatasetTags}
235+ selectedTags={selectedTags}
236+ toggleTag={handleTagToggle}
237+ benchmarkResults={benchmarkData?.results || []}
238+ />
239+ ) : (
240+ <AlgorithmTable
241+ filteredAlgorithms={filteredAlgorithms}
242+ availableAlgorithmTags={availableAlgorithmTags}
243+ selectedTags={selectedTags}
244+ toggleTag={handleTagToggle}
245+ />
246+ )}
247+ </div>
248+ </main>
249+ </div>
250+ );
251+}
web-ui/src/pages/Home.tsxadded+192−0View file
@@ -0,0 +1,192 @@
1+import React, { useEffect, useState } from "react";
2+import axios from "axios";
3+import { Link } from "react-router-dom";
4+import yaml from "yaml";
5+import contentYaml from "../content/home-content.yml?raw";
6+import { HomeContent, HomeSection } from "../types/home-content";
7+import "../components/Button.css";
8+
9+const content = yaml.parse(contentYaml) as HomeContent;
10+
11+const SectionCard: React.FC<{ section: HomeSection }> = ({ section }) => {
12+ if (section.external) {
13+ return (
14+ <div
15+ style={{
16+ padding: "0.75rem",
17+ border: "1px solid #eaeaea",
18+ borderRadius: "8px",
19+ backgroundColor: "#f9f9f9",
20+ display: "flex",
21+ flexDirection: "column",
22+ height: "100%",
23+ }}
24+ >
25+ <h2 style={{ fontSize: "1.25rem", marginBottom: "0.5rem" }}>
26+ {section.title}
27+ </h2>
28+ <p style={{ marginBottom: "0.5rem" }}>{section.description}</p>
29+ <a
30+ href={section.link}
31+ target="_blank"
32+ rel="noopener noreferrer"
33+ className="soft-button"
34+ style={{ marginTop: "auto", alignSelf: "flex-start" }}
35+ >
36+ {section.linkText}
37+ </a>
38+ </div>
39+ );
40+ }
41+
42+ return (
43+ <div
44+ style={{
45+ padding: "0.75rem",
46+ border: "1px solid #eaeaea",
47+ borderRadius: "8px",
48+ backgroundColor: "#f9f9f9",
49+ display: "flex",
50+ flexDirection: "column",
51+ height: "100%",
52+ }}
53+ >
54+ <h2 style={{ fontSize: "1.25rem", marginBottom: "0.5rem" }}>
55+ {section.title}
56+ </h2>
57+ <p style={{ marginBottom: "0.5rem" }}>{section.description}</p>
58+ <Link
59+ to={section.link}
60+ className="soft-button"
61+ style={{ marginTop: "auto", alignSelf: "flex-start" }}
62+ >
63+ {section.linkText}
64+ </Link>
65+ </div>
66+ );
67+};
68+
69+interface BenchmarkStatus {
70+ current_dataset: string;
71+ current_algorithm: string;
72+ completed_count: number;
73+ total_count: number;
74+ progress_percentage: number;
75+ elapsed_time: number;
76+ last_update: string;
77+ completed_benchmarks: Array<{
78+ dataset: string;
79+ algorithm: string;
80+ compression_ratio: number;
81+ encode_time: number;
82+ decode_time: number;
83+ cache_status: string;
84+ }>;
85+}
86+
87+export default function Home() {
88+ const [status, setStatus] = useState<BenchmarkStatus | null>(null);
89+
90+ useEffect(() => {
91+ const fetchStatus = async () => {
92+ try {
93+ const cacheBust = Math.random().toString(36).substring(2, 15);
94+ const response = await axios.get(
95+ `https://tempory.net/f/memobin/benchmark_status/current.json?cachebust=${cacheBust}`,
96+ );
97+ setStatus(response.data);
98+ } catch (error) {
99+ console.error("Error fetching benchmark status:", error);
100+ }
101+ };
102+
103+ fetchStatus();
104+ }, []);
105+
106+ return (
107+ <div style={{ maxWidth: "1200px", margin: "0 auto", padding: "2rem" }}>
108+ <h1
109+ style={{
110+ marginBottom: "2rem",
111+ display: "flex",
112+ alignItems: "center",
113+ gap: "1rem",
114+ }}
115+ >
116+ <img
117+ src={`${import.meta.env.BASE_URL}logo.svg`}
118+ alt="Ephys Compression Tests Logo"
119+ style={{ width: "40px", height: "auto" }}
120+ />
121+ {content.title}
122+ </h1>
123+
124+ <p
125+ style={{ fontSize: "1.1rem", lineHeight: "1.6", marginBottom: "2rem" }}
126+ >
127+ {content.description}
128+ </p>
129+
130+ <div
131+ style={{
132+ display: "grid",
133+ gap: "2rem",
134+ gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
135+ }}
136+ >
137+ {Object.entries(content.sections).map(([key, section]) => (
138+ <SectionCard key={key} section={section} />
139+ ))}
140+ </div>
141+
142+ <hr
143+ style={{
144+ margin: "3rem 0",
145+ border: "none",
146+ borderTop: "1px solid #eaeaea",
147+ }}
148+ />
149+
150+ <footer
151+ style={{ textAlign: "center", color: "#666", fontSize: "0.9rem" }}
152+ >
153+ <p>
154+ Last UI update: {__BUILD_DATE__}
155+ <br />
156+ {status && (
157+ <>
158+ <div
159+ style={{
160+ margin: "1rem 0",
161+ padding: "0.5rem",
162+ border: "1px solid #eaeaea",
163+ borderRadius: "4px",
164+ display: "inline-block",
165+ }}
166+ >
167+ Last benchmark run:{" "}
168+ {new Date(status.last_update).toLocaleString()}
169+ <br />
170+ Status:{" "}
171+ {status.progress_percentage === 100
172+ ? "Completed"
173+ : "In Progress"}{" "}
174+ ({status.completed_count}/{status.total_count} benchmarks)
175+ </div>
176+ <br />
177+ </>
178+ )}
179+ Released under{" "}
180+ <a
181+ href="https://github.com/magland/ephys_compression_tests/blob/main/LICENSE"
182+ target="_blank"
183+ rel="noopener noreferrer"
184+ style={{ color: "#666", textDecoration: "underline" }}
185+ >
186+ Apache License 2.0
187+ </a>
188+ </p>
189+ </footer>
190+ </div>
191+ );
192+}
web-ui/src/pages/Monitor.tsxadded+287−0View file
@@ -0,0 +1,287 @@
1+import { useEffect, useState } from "react";
2+import axios from "axios";
3+
4+interface BenchmarkStatus {
5+ current_dataset: string;
6+ current_algorithm: string;
7+ completed_count: number;
8+ total_count: number;
9+ progress_percentage: number;
10+ elapsed_time: number;
11+ last_update: string;
12+ completed_benchmarks: Array<{
13+ dataset: string;
14+ algorithm: string;
15+ compression_ratio: number;
16+ encode_time: number;
17+ decode_time: number;
18+ cache_status: string;
19+ }>;
20+}
21+
22+export default function Monitor() {
23+ const [status, setStatus] = useState<BenchmarkStatus | null>(null);
24+ const [error, setError] = useState<string | null>(null);
25+ const [loading, setLoading] = useState(true);
26+
27+ const fetchStatus = async () => {
28+ setLoading(true);
29+ try {
30+ const cacheBust = Math.random().toString(36).substring(2, 15);
31+ const response = await axios.get(
32+ `https://tempory.net/f/memobin/benchmark_status/current.json?cachebust=${cacheBust}`,
33+ );
34+ setStatus(response.data);
35+ setError(null);
36+ } catch (error) {
37+ const message =
38+ error instanceof Error ? error.message : "Failed to fetch status";
39+ setError(message);
40+ console.error("Error fetching benchmark status:", error);
41+ } finally {
42+ setLoading(false);
43+ }
44+ };
45+
46+ useEffect(() => {
47+ fetchStatus();
48+ }, []);
49+
50+ if (loading) {
51+ return <div>Loading benchmark status...</div>;
52+ }
53+
54+ if (error) {
55+ return <div>Error: {error}</div>;
56+ }
57+
58+ if (!status) {
59+ return <div>No active benchmark run found.</div>;
60+ }
61+
62+ const formatTime = (seconds: number) => {
63+ const hours = Math.floor(seconds / 3600);
64+ const minutes = Math.floor((seconds % 3600) / 60);
65+ const remainingSeconds = Math.floor(seconds % 60);
66+ return `${hours}h ${minutes}m ${remainingSeconds}s`;
67+ };
68+
69+ return (
70+ <div style={{ padding: "20px" }}>
71+ <div
72+ style={{
73+ display: "flex",
74+ alignItems: "center",
75+ gap: "20px",
76+ marginBottom: "20px",
77+ }}
78+ >
79+ <h1 style={{ margin: 0 }}>Benchmark Progress</h1>
80+ <button
81+ onClick={fetchStatus}
82+ style={{
83+ padding: "8px 16px",
84+ backgroundColor: "#4CAF50",
85+ color: "white",
86+ border: "none",
87+ borderRadius: "4px",
88+ cursor: "pointer",
89+ display: "flex",
90+ alignItems: "center",
91+ gap: "8px",
92+ }}
93+ disabled={loading}
94+ >
95+ {loading ? "Refreshing..." : "Refresh"}
96+ </button>
97+ </div>
98+
99+ <div style={{ marginBottom: "20px" }}>
100+ <h2>Current Status</h2>
101+ <div
102+ style={{
103+ border: "1px solid #eee",
104+ padding: "20px",
105+ borderRadius: "8px",
106+ backgroundColor: "#f9f9f9",
107+ }}
108+ >
109+ <p>
110+ <strong>Current Dataset:</strong> {status.current_dataset}
111+ </p>
112+ <p>
113+ <strong>Current Algorithm:</strong> {status.current_algorithm}
114+ </p>
115+ <p>
116+ <strong>Progress:</strong> {status.completed_count} /{" "}
117+ {status.total_count} ({status.progress_percentage.toFixed(1)}%)
118+ </p>
119+ <p>
120+ <strong>Elapsed Time:</strong> {formatTime(status.elapsed_time)}
121+ </p>
122+ <p>
123+ <strong>Last Update:</strong>{" "}
124+ {new Date(status.last_update).toLocaleString()}
125+ </p>
126+
127+ <div style={{ marginTop: "10px" }}>
128+ <div
129+ style={{
130+ width: "100%",
131+ height: "20px",
132+ backgroundColor: "#eee",
133+ borderRadius: "10px",
134+ overflow: "hidden",
135+ }}
136+ >
137+ <div
138+ style={{
139+ width: `${status.progress_percentage}%`,
140+ height: "100%",
141+ backgroundColor: "#4CAF50",
142+ transition: "width 0.5s ease-in-out",
143+ }}
144+ />
145+ </div>
146+ </div>
147+ </div>
148+ </div>
149+
150+ <div>
151+ <h2>Completed Benchmarks</h2>
152+ <div style={{ overflowX: "auto" }}>
153+ <table
154+ style={{
155+ width: "100%",
156+ borderCollapse: "collapse",
157+ marginTop: "10px",
158+ }}
159+ >
160+ <thead>
161+ <tr style={{ backgroundColor: "#f5f5f5" }}>
162+ <th
163+ style={{
164+ padding: "12px",
165+ textAlign: "left",
166+ borderBottom: "2px solid #ddd",
167+ }}
168+ >
169+ Dataset
170+ </th>
171+ <th
172+ style={{
173+ padding: "12px",
174+ textAlign: "left",
175+ borderBottom: "2px solid #ddd",
176+ }}
177+ >
178+ Algorithm
179+ </th>
180+ <th
181+ style={{
182+ padding: "12px",
183+ textAlign: "right",
184+ borderBottom: "2px solid #ddd",
185+ }}
186+ >
187+ Compression Ratio
188+ </th>
189+ <th
190+ style={{
191+ padding: "12px",
192+ textAlign: "right",
193+ borderBottom: "2px solid #ddd",
194+ }}
195+ >
196+ Encode Time (ms)
197+ </th>
198+ <th
199+ style={{
200+ padding: "12px",
201+ textAlign: "right",
202+ borderBottom: "2px solid #ddd",
203+ }}
204+ >
205+ Decode Time (ms)
206+ </th>
207+ <th
208+ style={{
209+ padding: "12px",
210+ textAlign: "center",
211+ borderBottom: "2px solid #ddd",
212+ }}
213+ >
214+ Cache Status
215+ </th>
216+ </tr>
217+ </thead>
218+ <tbody>
219+ {status.completed_benchmarks.map((benchmark, index) => (
220+ <tr
221+ key={index}
222+ style={{
223+ backgroundColor:
224+ benchmark.cache_status === "cached"
225+ ? "#f5f5f5"
226+ : index % 2 === 0
227+ ? "white"
228+ : "#fafafa",
229+ }}
230+ >
231+ <td
232+ style={{ padding: "12px", borderBottom: "1px solid #ddd" }}
233+ >
234+ {benchmark.dataset}
235+ </td>
236+ <td
237+ style={{ padding: "12px", borderBottom: "1px solid #ddd" }}
238+ >
239+ {benchmark.algorithm}
240+ </td>
241+ <td
242+ style={{
243+ padding: "12px",
244+ textAlign: "right",
245+ borderBottom: "1px solid #ddd",
246+ }}
247+ >
248+ {benchmark.compression_ratio.toFixed(2)}x
249+ </td>
250+ <td
251+ style={{
252+ padding: "12px",
253+ textAlign: "right",
254+ borderBottom: "1px solid #ddd",
255+ }}
256+ >
257+ {(benchmark.encode_time * 1000).toFixed(2)}
258+ </td>
259+ <td
260+ style={{
261+ padding: "12px",
262+ textAlign: "right",
263+ borderBottom: "1px solid #ddd",
264+ }}
265+ >
266+ {(benchmark.decode_time * 1000).toFixed(2)}
267+ </td>
268+ <td
269+ style={{
270+ padding: "12px",
271+ textAlign: "center",
272+ borderBottom: "1px solid #ddd",
273+ color:
274+ benchmark.cache_status === "cached" ? "#888" : "#000",
275+ }}
276+ >
277+ {benchmark.cache_status}
278+ </td>
279+ </tr>
280+ ))}
281+ </tbody>
282+ </table>
283+ </div>
284+ </div>
285+ </div>
286+ );
287+}
web-ui/src/pages/Submit.tsxadded+14−0View file
@@ -0,0 +1,14 @@
1+import submitContent from "./submit.md?raw";
2+import ReactMarkdown from "react-markdown";
3+import remarkMath from "remark-math";
4+import rehypeKatex from "rehype-katex";
5+
6+export default function Submit() {
7+ return (
8+ <div className="content-container">
9+ <ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
10+ {submitContent}
11+ </ReactMarkdown>
12+ </div>
13+ );
14+}
web-ui/src/pages/submit.mdadded+143−0View file
@@ -0,0 +1,143 @@
1+# Contributing to ephys_compression_tests
2+
3+This guide explains how to contribute new algorithms or datasets to ephys_compression_tests.
4+
5+## Overview
6+
7+ephys_compression_tests welcomes contributions of new compression algorithms and ephys datasets. The framework is designed to make it easy to add new components while ensuring consistent benchmarking and evaluation.
8+
9+## Getting Started
10+
11+1. Fork and clone the repository:
12+```bash
13+git clone https://github.com/[your-username]/ephys_compression_tests.git
14+cd ephys_compression_tests
15+```
16+
17+2. Install dependencies:
18+```bash
19+# Install Python package
20+cd ephys_compression_tests
21+pip install -e .
22+
23+# Install pre-commit hooks for code compliance checks
24+pip install pre-commit
25+pre-commit install
26+```
27+
28+## Adding a New Algorithm
29+
30+New algorithms are added in `ephys_compression_tests/src/ephys_compression_tests/algorithms/`. Each algorithm should:
31+
32+1. Create a new directory with:
33+ - `__init__.py`: Algorithm implementation
34+ - `algorithm-name.md`: Documentation and description
35+
36+2. In `__init__.py`:
37+ - Implement compression/decompression functions
38+ - Define metadata (version, tags, compatibility)
39+ - Follow existing algorithms as examples
40+
41+Example structure:
42+```
43+algorithms/
44+└── my_algorithm/
45+ ├── __init__.py
46+ └── my_algorithm.md
47+```
48+
49+## Adding a New Dataset
50+
51+New datasets are added in `ephys_compression_tests/src/ephys_compression_tests/datasets/`. Each dataset should:
52+
53+1. Create a new directory with:
54+ - `__init__.py`: Dataset generation/loading code
55+ - `dataset-name.md`: Documentation and description
56+
57+2. In `__init__.py`:
58+ - Implement data generation/loading
59+ - Define metadata (version, tags)
60+ - Follow existing datasets as examples
61+
62+Example structure:
63+```
64+datasets/
65+└── my_dataset/
66+ ├── __init__.py
67+ └── my_dataset.md
68+```
69+
70+## Testing Locally
71+
72+Run benchmarks for your new component:
73+```bash
74+ephys_compression_tests run --algorithm my_algorithm --dataset my_dataset
75+```
76+
77+The framework will automatically:
78+- Run the benchmarks
79+- Verify results by decompressing and comparing with original data
80+- Measure compression ratios and throughput
81+
82+## Code Formatting
83+
84+The project uses specific formatters for each language:
85+- Python: black formatter
86+- TypeScript/JavaScript: ESLint + Prettier
87+- C++: clang-format
88+
89+To format your code before committing:
90+```bash
91+# From project root
92+./devel/format_code.sh
93+```
94+
95+This will format all code according to project standards.
96+
97+## Code Compliance
98+
99+Pre-commit hooks will check code compliance when you commit changes. They verify:
100+- Code formatting
101+- Import ordering
102+- Type checking
103+- Other project-specific rules
104+
105+If checks fail, format your code using the format script and try again.
106+
107+## Creating a Pull Request
108+
109+1. Create a new branch:
110+```bash
111+git checkout -b add-my-component
112+```
113+
114+2. Format code and ensure it passes compliance checks:
115+```bash
116+./devel/format_code.sh
117+```
118+
119+3. Commit your changes:
120+```bash
121+git add .
122+git commit -m "Add new algorithm/dataset: [name]"
123+```
124+
125+4. Push to your fork:
126+```bash
127+git push origin add-my-component
128+```
129+
130+5. Open a pull request on GitHub with:
131+ - Clear description of the new component
132+ - Any relevant background or references
133+ - Local benchmark results
134+ - Confirmation that code is formatted and passes checks
135+
136+## Guidelines
137+
138+- Follow existing code structure and patterns
139+- Include thorough documentation
140+- Add appropriate tags for filtering
141+- Test compatibility with existing components
142+- Format code using provided script
143+- Ensure all pre-commit checks pass
web-ui/src/reducers/tabsReducer.tsadded+85−0View file
@@ -0,0 +1,85 @@
1+export type TabAction =
2+ | { type: "ADD_TAB"; payload: { id: string; label: string; route: string } }
3+ | { type: "SET_ACTIVE_TAB"; payload: string }
4+ | { type: "CLOSE_TAB"; payload: string }
5+ | { type: "REORDER_TABS"; payload: { fromIndex: number; toIndex: number } };
6+
7+export interface TabItem {
8+ id: string;
9+ label: string;
10+ route: string;
11+}
12+
13+export interface TabsState {
14+ tabs: TabItem[];
15+ activeTabId: string;
16+}
17+
18+const initialState: TabsState = {
19+ tabs: [
20+ { id: "datasets", label: "Datasets", route: "/datasets" },
21+ { id: "algorithms", label: "Algorithms", route: "/algorithms" },
22+ ],
23+ activeTabId: "datasets",
24+};
25+
26+export function tabsReducer(
27+ state: TabsState = initialState,
28+ action: TabAction,
29+): TabsState {
30+ switch (action.type) {
31+ case "ADD_TAB": {
32+ // If tab already exists, just set it as active
33+ if (state.tabs.some((tab) => tab.id === action.payload.id)) {
34+ return {
35+ ...state,
36+ activeTabId: action.payload.id,
37+ };
38+ }
39+
40+ // Otherwise add new tab
41+ return {
42+ ...state,
43+ tabs: [...state.tabs, action.payload],
44+ activeTabId: action.payload.id,
45+ };
46+ }
47+ case "SET_ACTIVE_TAB":
48+ return {
49+ ...state,
50+ activeTabId: action.payload,
51+ };
52+ case "REORDER_TABS": {
53+ const newTabs = [...state.tabs];
54+ const [movedTab] = newTabs.splice(action.payload.fromIndex, 1);
55+ newTabs.splice(action.payload.toIndex, 0, movedTab);
56+ return {
57+ ...state,
58+ tabs: newTabs,
59+ };
60+ }
61+ case "CLOSE_TAB": {
62+ // Don't allow closing of datasets or algorithms tabs
63+ if (action.payload === "datasets" || action.payload === "algorithms") {
64+ return state;
65+ }
66+
67+ const newTabs = state.tabs.filter((tab) => tab.id !== action.payload);
68+
69+ // If we're closing the active tab, activate the last tab in the list
70+ if (state.activeTabId === action.payload) {
71+ return {
72+ tabs: newTabs,
73+ activeTabId: newTabs[newTabs.length - 1].id,
74+ };
75+ }
76+
77+ return {
78+ ...state,
79+ tabs: newTabs,
80+ };
81+ }
82+ default:
83+ return state;
84+ }
85+}
web-ui/src/types.tsadded+60−0View file
@@ -0,0 +1,60 @@
1+export interface BenchmarkResult {
2+ dataset: string;
3+ algorithm: string;
4+ algorithm_version: string;
5+ dataset_version: string;
6+ system_version: string;
7+ compression_ratio: number;
8+ encode_time: number;
9+ decode_time: number;
10+ encode_mb_per_sec: number;
11+ decode_mb_per_sec: number;
12+ original_size: number;
13+ compressed_size: number;
14+ array_shape: number[];
15+ array_dtype: string;
16+ timestamp: number;
17+}
18+
19+export interface Algorithm {
20+ name: string;
21+ description: string;
22+ long_description?: string;
23+ version: string;
24+ tags: string[];
25+ source_file?: string;
26+}
27+
28+export interface Dataset {
29+ name: string;
30+ description: string;
31+ long_description?: string;
32+ version: string;
33+ tags: string[];
34+ source_file?: string;
35+ data_url_npy?: string; // URL to download the dataset as .npy
36+ data_url_raw?: string; // URL to download the raw dataset as .dat
37+ data_url_json?: string; // URL to download the dataset info as .json (dtype and shape)
38+}
39+
40+export interface BenchmarkData {
41+ results: BenchmarkResult[];
42+ algorithms: Algorithm[];
43+ datasets: Dataset[];
44+}
45+
46+export interface TabItem {
47+ id: string;
48+ label: string;
49+ route: string;
50+}
51+
52+export interface TabsState {
53+ tabs: TabItem[];
54+ activeTabId: string;
55+}
56+
57+export type TabAction =
58+ | { type: "ADD_TAB"; payload: { id: string; label: string; route: string } }
59+ | { type: "SET_ACTIVE_TAB"; payload: string }
60+ | { type: "REORDER_TABS"; payload: { fromIndex: number; toIndex: number } };
web-ui/src/types/home-content.tsadded+15−0View file
@@ -0,0 +1,15 @@
1+export interface HomeSection {
2+ title: string;
3+ description: string;
4+ link: string;
5+ linkText: string;
6+ external?: boolean;
7+}
8+
9+export interface HomeContent {
10+ title: string;
11+ description: string;
12+ sections: {
13+ [key: string]: HomeSection;
14+ };
15+}
web-ui/src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
web-ui/tsconfig.app.jsonadded+24−0View file
@@ -0,0 +1,24 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2020",
4+ "useDefineForClassFields": true,
5+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6+ "module": "ESNext",
7+ "skipLibCheck": true,
8+
9+ /* Bundler mode */
10+ "moduleResolution": "bundler",
11+ "allowImportingTsExtensions": true,
12+ "isolatedModules": true,
13+ "moduleDetection": "force",
14+ "noEmit": true,
15+ "jsx": "react-jsx",
16+
17+ /* Linting */
18+ "strict": true,
19+ "noUnusedLocals": true,
20+ "noUnusedParameters": true,
21+ "noFallthroughCasesInSwitch": true
22+ },
23+ "include": ["src"]
24+}
web-ui/tsconfig.app.tsbuildinfoadded+1−0View file
@@ -0,0 +1 @@
1+{"root":["./src/App.tsx","./src/env.d.ts","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/BenchmarkTable.tsx","./src/components/ScrollToTop.tsx","./src/components/TagFilter.tsx","./src/components/algorithm/AlgorithmContent.tsx","./src/components/benchmark/charts/BenchmarkCharts.tsx","./src/components/benchmark/charts/BenchmarkScatterPlots.tsx","./src/components/benchmark/export/csvExport.ts","./src/components/benchmark/table/BenchmarkTable.tsx","./src/components/benchmark/table/columns.tsx","./src/components/benchmark/utils/formatters.ts","./src/components/dataset/DatasetContent.tsx","./src/components/dataset/TimeseriesNavigationBar.tsx","./src/components/dataset/TimeseriesView.tsx","./src/components/dataset/TimeseriesViewWorker.ts","./src/components/dataset/WorkerTypes.ts","./src/components/dataset/timeseriesViewReducer.ts","./src/components/shared/BaseContent.tsx","./src/components/tables/DatasetAlgorithmTables.tsx","./src/hooks/TimeseriesDataClient.ts","./src/hooks/useBenchmarkChartData.ts","./src/hooks/useMarkdownContent.ts","./src/hooks/useMarkdownPosts.ts","./src/hooks/useTagFilter.ts","./src/hooks/useTimeseriesData.ts","./src/hooks/useTimeseriesDataClient.ts","./src/pages/BenchmarkView.tsx","./src/pages/Home.tsx","./src/pages/Monitor.tsx","./src/pages/Submit.tsx","./src/reducers/tabsReducer.ts","./src/types/home-content.ts"],"version":"5.6.3"}
web-ui/tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.app.json" },
5+ { "path": "./tsconfig.node.json" }
6+ ]
7+}
web-ui/tsconfig.node.jsonadded+24−0View file
@@ -0,0 +1,24 @@
1+{
2+ "compilerOptions": {
3+ "incremental": true,
4+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5+ "target": "ES2022",
6+ "lib": ["ES2023"],
7+ "module": "ESNext",
8+ "skipLibCheck": true,
9+
10+ /* Bundler mode */
11+ "moduleResolution": "bundler",
12+ "allowImportingTsExtensions": true,
13+ "isolatedModules": true,
14+ "moduleDetection": "force",
15+ "noEmit": true,
16+
17+ /* Linting */
18+ "strict": true,
19+ "noUnusedLocals": true,
20+ "noUnusedParameters": true,
21+ "noFallthroughCasesInSwitch": true
22+ },
23+ "include": ["vite.config.ts"]
24+}
web-ui/vite.config.tsadded+11−0View file
@@ -0,0 +1,11 @@
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// https://vite.dev/config/
5+export default defineConfig({
6+ plugins: [react()],
7+ base: '/ephys_compression_tests/', // Base URL for GitHub Pages deployment
8+ define: {
9+ __BUILD_DATE__: JSON.stringify(new Date().toLocaleDateString())
10+ }
11+})