bruAristimunha commited on
Commit
1155fb2
·
1 Parent(s): 3713752

Add MOABB-style chart visualization to leaderboard

Browse files

Add a Table/Chart toggle with a publication-grade dot plot view
inspired by MOABB ranking figures. Models are grouped by adapter
type with Tol's colorblind-friendly palette, hollow circle markers,
dashed median reference line, and per-dataset selector buttons.
Average view shows error bars (±1 std across benchmarks). Chart is
lazy-loaded via React.lazy and the view mode persists in the URL
(?view=chart).

frontend/package.json CHANGED
@@ -20,8 +20,10 @@
20
  "cors": "^2.8.5",
21
  "express": "^4.18.2",
22
  "http-proxy-middleware": "^2.0.6",
 
23
  "react": "^18.3.1",
24
  "react-dom": "^18.3.1",
 
25
  "react-router-dom": "^6.28.0",
26
  "react-scripts": "5.0.1",
27
  "serve-static": "^1.15.0",
 
20
  "cors": "^2.8.5",
21
  "express": "^4.18.2",
22
  "http-proxy-middleware": "^2.0.6",
23
+ "plotly.js-basic-dist": "^3.4.0",
24
  "react": "^18.3.1",
25
  "react-dom": "^18.3.1",
26
+ "react-plotly.js": "^2.6.0",
27
  "react-router-dom": "^6.28.0",
28
  "react-scripts": "5.0.1",
29
  "serve-static": "^1.15.0",
frontend/src/pages/LeaderboardPage/components/Leaderboard/Leaderboard.js CHANGED
@@ -1,5 +1,13 @@
1
- import React, { useMemo, useEffect, useCallback } from "react";
2
- import { Box, Typography } from "@mui/material";
 
 
 
 
 
 
 
 
3
  import { useSearchParams } from "react-router-dom";
4
 
5
  import { TABLE_DEFAULTS } from "./constants/defaults";
@@ -15,6 +23,8 @@ import QuickFilters, {
15
  QuickFiltersSkeleton,
16
  } from "./components/Filters/QuickFilters";
17
 
 
 
18
  const FilterAccordion = ({ expanded, quickFilters, advancedFilters }) => {
19
  const advancedFiltersRef = React.useRef(null);
20
  const quickFiltersRef = React.useRef(null);
@@ -213,6 +223,15 @@ const Leaderboard = () => {
213
  actions.resetFilters();
214
  }, [actions]);
215
 
 
 
 
 
 
 
 
 
 
216
  // Memoize loading states
217
  const loadingStates = useMemo(() => {
218
  const isInitialLoading = dataLoading || !data;
@@ -422,6 +441,37 @@ const Leaderboard = () => {
422
  </Box>
423
  </Box>
424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  <Box
426
  sx={{
427
  display: "flex",
@@ -437,7 +487,29 @@ const Leaderboard = () => {
437
  px: 1,
438
  }}
439
  >
440
- {tableComponent}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
  </Box>
442
  </Box>
443
  </Box>
 
1
+ import React, { useMemo, useEffect, useCallback, lazy, Suspense } from "react";
2
+ import {
3
+ Box,
4
+ Typography,
5
+ ToggleButtonGroup,
6
+ ToggleButton,
7
+ CircularProgress,
8
+ } from "@mui/material";
9
+ import TableChartIcon from "@mui/icons-material/TableChart";
10
+ import BarChartIcon from "@mui/icons-material/BarChart";
11
  import { useSearchParams } from "react-router-dom";
12
 
13
  import { TABLE_DEFAULTS } from "./constants/defaults";
 
23
  QuickFiltersSkeleton,
24
  } from "./components/Filters/QuickFilters";
25
 
26
+ const Chart = lazy(() => import("./components/Chart/Chart"));
27
+
28
  const FilterAccordion = ({ expanded, quickFilters, advancedFilters }) => {
29
  const advancedFiltersRef = React.useRef(null);
30
  const quickFiltersRef = React.useRef(null);
 
223
  actions.resetFilters();
224
  }, [actions]);
225
 
226
+ const onViewModeChange = useCallback(
227
+ (_, value) => {
228
+ if (value !== null) {
229
+ actions.setDisplayOption("viewMode", value);
230
+ }
231
+ },
232
+ [actions]
233
+ );
234
+
235
  // Memoize loading states
236
  const loadingStates = useMemo(() => {
237
  const isInitialLoading = dataLoading || !data;
 
441
  </Box>
442
  </Box>
443
 
444
+ <Box
445
+ sx={{
446
+ display: "flex",
447
+ justifyContent: "center",
448
+ width: {
449
+ xs: "100%",
450
+ sm: "100%",
451
+ md: "80%",
452
+ },
453
+ maxWidth: "1200px",
454
+ mt: 1,
455
+ mb: 0.5,
456
+ }}
457
+ >
458
+ <ToggleButtonGroup
459
+ value={state.display.viewMode}
460
+ exclusive
461
+ onChange={onViewModeChange}
462
+ size="small"
463
+ >
464
+ <ToggleButton value="table">
465
+ <TableChartIcon sx={{ mr: 0.5, fontSize: 18 }} />
466
+ Table
467
+ </ToggleButton>
468
+ <ToggleButton value="chart">
469
+ <BarChartIcon sx={{ mr: 0.5, fontSize: 18 }} />
470
+ Chart
471
+ </ToggleButton>
472
+ </ToggleButtonGroup>
473
+ </Box>
474
+
475
  <Box
476
  sx={{
477
  display: "flex",
 
487
  px: 1,
488
  }}
489
  >
490
+ {state.display.viewMode === "chart" ? (
491
+ <Suspense
492
+ fallback={
493
+ <Box
494
+ sx={{
495
+ display: "flex",
496
+ justifyContent: "center",
497
+ alignItems: "center",
498
+ minHeight: 400,
499
+ }}
500
+ >
501
+ <CircularProgress />
502
+ </Box>
503
+ }
504
+ >
505
+ <Chart
506
+ filteredData={memoizedFilteredData}
507
+ scoreDisplay={state.display.scoreDisplay}
508
+ />
509
+ </Suspense>
510
+ ) : (
511
+ tableComponent
512
+ )}
513
  </Box>
514
  </Box>
515
  </Box>
frontend/src/pages/LeaderboardPage/components/Leaderboard/components/Chart/Chart.js ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, lazy, Suspense } from "react";
2
+ import { Box, Typography, Chip, CircularProgress } from "@mui/material";
3
+ import { useTheme } from "@mui/material/styles";
4
+ import useChartData from "./useChartData";
5
+ import { DATASET_OPTIONS, REFERENCE_LINE_COLOR } from "../../constants/chartDefaults";
6
+
7
+ const Plot = lazy(() =>
8
+ import("react-plotly.js").then((mod) => ({ default: mod.default }))
9
+ );
10
+
11
+ const Chart = ({ filteredData, scoreDisplay }) => {
12
+ const theme = useTheme();
13
+ const [selectedDataset, setSelectedDataset] = useState("average");
14
+
15
+ const { traces, layout } = useChartData(
16
+ filteredData,
17
+ scoreDisplay,
18
+ selectedDataset
19
+ );
20
+
21
+ const isDark = theme.palette.mode === "dark";
22
+ const textColor = theme.palette.text.primary;
23
+ const lineColor = isDark ? "rgba(255,255,255,0.25)" : "rgba(0,0,0,0.3)";
24
+ const tickColor = isDark ? "rgba(255,255,255,0.15)" : "rgba(0,0,0,0.15)";
25
+
26
+ const themedLayout = {
27
+ ...layout,
28
+ paper_bgcolor: "transparent",
29
+ plot_bgcolor: "transparent",
30
+ font: { color: textColor },
31
+ title: {
32
+ ...layout.title,
33
+ font: { ...layout.title?.font, color: textColor },
34
+ },
35
+ xaxis: {
36
+ ...layout.xaxis,
37
+ linecolor: lineColor,
38
+ tickcolor: tickColor,
39
+ tickfont: { ...layout.xaxis?.tickfont, color: textColor },
40
+ title: {
41
+ ...layout.xaxis?.title,
42
+ font: { ...layout.xaxis?.title?.font, color: textColor },
43
+ },
44
+ },
45
+ yaxis: {
46
+ ...layout.yaxis,
47
+ tickcolor: tickColor,
48
+ tickfont: { ...layout.yaxis?.tickfont, color: textColor },
49
+ },
50
+ legend: {
51
+ ...layout.legend,
52
+ font: { ...layout.legend?.font, color: textColor },
53
+ },
54
+ };
55
+
56
+ if (!filteredData || filteredData.length === 0) {
57
+ return (
58
+ <Box
59
+ sx={{
60
+ display: "flex",
61
+ justifyContent: "center",
62
+ alignItems: "center",
63
+ minHeight: 300,
64
+ }}
65
+ >
66
+ <Typography color="text.secondary">
67
+ No models match the current filters.
68
+ </Typography>
69
+ </Box>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <Box sx={{ width: "100%", maxWidth: 960, mx: "auto" }}>
75
+ {/* Dataset selector */}
76
+ <Box
77
+ sx={{
78
+ display: "flex",
79
+ flexWrap: "wrap",
80
+ gap: 0.75,
81
+ justifyContent: "center",
82
+ mb: 2,
83
+ }}
84
+ >
85
+ {DATASET_OPTIONS.map((opt) => {
86
+ const active = selectedDataset === opt.value;
87
+ return (
88
+ <Chip
89
+ key={opt.value}
90
+ label={opt.label}
91
+ size="small"
92
+ variant={active ? "filled" : "outlined"}
93
+ onClick={() => setSelectedDataset(opt.value)}
94
+ sx={{
95
+ fontWeight: active ? 600 : 400,
96
+ fontSize: "0.75rem",
97
+ height: 28,
98
+ borderRadius: "14px",
99
+ cursor: "pointer",
100
+ borderColor: active ? "transparent" : "divider",
101
+ bgcolor: active
102
+ ? isDark
103
+ ? "grey.800"
104
+ : "grey.900"
105
+ : "transparent",
106
+ color: active
107
+ ? isDark
108
+ ? "grey.100"
109
+ : "common.white"
110
+ : "text.secondary",
111
+ "&:hover": {
112
+ bgcolor: active
113
+ ? isDark
114
+ ? "grey.700"
115
+ : "grey.800"
116
+ : isDark
117
+ ? "grey.800"
118
+ : "grey.100",
119
+ },
120
+ }}
121
+ />
122
+ );
123
+ })}
124
+ </Box>
125
+
126
+ {/* Plot */}
127
+ <Suspense
128
+ fallback={
129
+ <Box
130
+ sx={{
131
+ display: "flex",
132
+ justifyContent: "center",
133
+ alignItems: "center",
134
+ minHeight: 400,
135
+ }}
136
+ >
137
+ <CircularProgress size={28} />
138
+ </Box>
139
+ }
140
+ >
141
+ <Plot
142
+ data={traces}
143
+ layout={themedLayout}
144
+ config={{
145
+ responsive: true,
146
+ displayModeBar: false,
147
+ staticPlot: false,
148
+ }}
149
+ style={{ width: "100%" }}
150
+ useResizeHandler
151
+ />
152
+ </Suspense>
153
+
154
+ {/* Reference line legend */}
155
+ <Box
156
+ sx={{
157
+ display: "flex",
158
+ justifyContent: "center",
159
+ alignItems: "center",
160
+ gap: 0.75,
161
+ mt: -1,
162
+ }}
163
+ >
164
+ <Box
165
+ sx={{
166
+ width: 24,
167
+ height: 0,
168
+ borderTop: `2px dashed ${REFERENCE_LINE_COLOR}`,
169
+ }}
170
+ />
171
+ <Typography
172
+ variant="caption"
173
+ sx={{ color: "text.secondary", fontSize: "0.7rem" }}
174
+ >
175
+ Median score
176
+ </Typography>
177
+ </Box>
178
+ </Box>
179
+ );
180
+ };
181
+
182
+ export default Chart;
frontend/src/pages/LeaderboardPage/components/Leaderboard/components/Chart/useChartData.js ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react";
2
+ import {
3
+ BENCHMARK_KEYS,
4
+ BENCHMARK_LABELS,
5
+ REFERENCE_LINE_COLOR,
6
+ getAdapterKey,
7
+ getAdapterLabel,
8
+ getAdapterColor,
9
+ } from "../../constants/chartDefaults";
10
+ import { MODEL_TYPE_ORDER } from "../../constants/modelTypes";
11
+
12
+ const getScore = (model, key, display) => {
13
+ const ev = model.evaluations?.[key];
14
+ if (!ev) return null;
15
+ const v = display === "normalized" ? ev.normalized_score : ev.value;
16
+ return v != null && !isNaN(v) ? v : null;
17
+ };
18
+
19
+ const shortName = (model) => {
20
+ const id = model.id || model.model?.name || "?";
21
+ const parts = id.split("/");
22
+ const name = parts.length > 1 ? parts.slice(1).join("/") : id;
23
+ return name.length > 32 ? name.slice(0, 29) + "..." : name;
24
+ };
25
+
26
+ const useChartData = (filteredData, scoreDisplay, selectedDataset) => {
27
+ return useMemo(() => {
28
+ if (!filteredData || filteredData.length === 0) {
29
+ return { traces: [], layout: {} };
30
+ }
31
+
32
+ const isAverage = selectedDataset === "average";
33
+
34
+ // 1. Compute score (+ std for average) per model
35
+ const scored = filteredData
36
+ .map((m) => {
37
+ let score, std = 0;
38
+ if (isAverage) {
39
+ const vals = BENCHMARK_KEYS
40
+ .map((k) => getScore(m, k, scoreDisplay))
41
+ .filter(Boolean);
42
+ if (vals.length === 0) return null;
43
+ score = vals.reduce((a, b) => a + b, 0) / vals.length;
44
+ std = Math.sqrt(
45
+ vals.reduce((s, v) => s + (v - score) ** 2, 0) / vals.length
46
+ );
47
+ } else {
48
+ score = getScore(m, selectedDataset, scoreDisplay);
49
+ if (score === null) return null;
50
+ }
51
+ return {
52
+ ...m,
53
+ _score: score,
54
+ _std: std,
55
+ _adapterKey: getAdapterKey(m.model?.type),
56
+ };
57
+ })
58
+ .filter(Boolean);
59
+
60
+ // 2. Group by adapter type in MODEL_TYPE_ORDER
61
+ const groups = new Map();
62
+ MODEL_TYPE_ORDER.forEach((t) => groups.set(t, []));
63
+ scored.forEach((m) => {
64
+ const arr = groups.get(m._adapterKey);
65
+ if (arr) arr.push(m);
66
+ else {
67
+ if (!groups.has("unknown")) groups.set("unknown", []);
68
+ groups.get("unknown").push(m);
69
+ }
70
+ });
71
+ for (const [k, v] of groups) {
72
+ if (v.length === 0) groups.delete(k);
73
+ }
74
+
75
+ // 3. Sort within group: ascending score (worst at lowest y, best at highest y within group)
76
+ for (const [, arr] of groups) {
77
+ arr.sort((a, b) => a._score - b._score);
78
+ }
79
+
80
+ // 4. Build traces with y positions; process groups in reverse so first adapter type is at top
81
+ const GAP = 1.0;
82
+ let y = 0;
83
+ const traces = [];
84
+ const shapes = [];
85
+ const allYTicks = [];
86
+ const allYLabels = [];
87
+ const allScores = [];
88
+
89
+ // Reverse so MODEL_TYPE_ORDER[0] (LoRA) ends up at the top (highest y)
90
+ const groupEntries = Array.from(groups.entries()).reverse();
91
+
92
+ groupEntries.forEach(([adapterKey, models], gi) => {
93
+ const color = getAdapterColor(adapterKey);
94
+ const label = getAdapterLabel(adapterKey);
95
+
96
+ const ys = [];
97
+ const xs = [];
98
+ const stds = [];
99
+ const names = [];
100
+
101
+ models.forEach((m) => {
102
+ ys.push(y);
103
+ xs.push(m._score);
104
+ stds.push(m._std);
105
+ const n = shortName(m);
106
+ names.push(n);
107
+ allYTicks.push(y);
108
+ allYLabels.push(n);
109
+ allScores.push(m._score);
110
+ y++;
111
+ });
112
+
113
+ const trace = {
114
+ type: "scatter",
115
+ mode: "markers",
116
+ name: label,
117
+ x: xs,
118
+ y: ys,
119
+ marker: {
120
+ symbol: "circle-open",
121
+ size: 10,
122
+ line: { width: 2.5, color },
123
+ },
124
+ hovertemplate: names.map(
125
+ (n, i) =>
126
+ `<b>${n}</b><br>Score: ${xs[i].toFixed(4)}<extra>${label}</extra>`
127
+ ),
128
+ };
129
+
130
+ // Error bars only for average view
131
+ if (isAverage && stds.some((s) => s > 0)) {
132
+ trace.error_x = {
133
+ type: "data",
134
+ array: stds,
135
+ arrayminus: stds,
136
+ visible: true,
137
+ color,
138
+ thickness: 2,
139
+ width: 4,
140
+ };
141
+ }
142
+
143
+ traces.push(trace);
144
+
145
+ // Add spacing between groups
146
+ if (gi < groupEntries.length - 1) {
147
+ y += GAP;
148
+ }
149
+ });
150
+
151
+ // 5. Median reference line
152
+ const sorted = [...allScores].sort((a, b) => a - b);
153
+ const mid = Math.floor(sorted.length / 2);
154
+ const median =
155
+ sorted.length === 0
156
+ ? 0
157
+ : sorted.length % 2 === 0
158
+ ? (sorted[mid - 1] + sorted[mid]) / 2
159
+ : sorted[mid];
160
+
161
+ const yMin = -0.8;
162
+ const yMax = y - 1 + GAP + 0.3;
163
+
164
+ shapes.push({
165
+ type: "line",
166
+ x0: median,
167
+ x1: median,
168
+ y0: yMin,
169
+ y1: yMax,
170
+ line: { color: REFERENCE_LINE_COLOR, width: 2, dash: "dash" },
171
+ });
172
+
173
+ // 6. Layout — stripped-down, academic
174
+ const height = Math.max(420, (y + 1) * 28 + 120);
175
+
176
+ const datasetLabel = isAverage
177
+ ? "Average"
178
+ : BENCHMARK_LABELS[selectedDataset] || selectedDataset;
179
+
180
+ const layout = {
181
+ height,
182
+ margin: { l: 170, r: 30, t: 40, b: 60 },
183
+ title: {
184
+ text: `<b>${datasetLabel}</b>`,
185
+ x: 0.5,
186
+ xanchor: "center",
187
+ font: { size: 18 },
188
+ },
189
+ xaxis: {
190
+ title: {
191
+ text: scoreDisplay === "normalized" ? "Normalized Score" : "Raw Score",
192
+ font: { size: 12 },
193
+ standoff: 8,
194
+ },
195
+ showgrid: false,
196
+ showline: true,
197
+ linewidth: 1,
198
+ ticks: "outside",
199
+ ticklen: 5,
200
+ zeroline: false,
201
+ side: "bottom",
202
+ tickfont: { size: 11 },
203
+ },
204
+ yaxis: {
205
+ tickvals: allYTicks,
206
+ ticktext: allYLabels,
207
+ showgrid: false,
208
+ showline: false,
209
+ ticks: "outside",
210
+ ticklen: 3,
211
+ zeroline: false,
212
+ range: [yMin, yMax],
213
+ tickfont: { size: 11 },
214
+ },
215
+ shapes,
216
+ legend: {
217
+ orientation: "h",
218
+ y: -0.13,
219
+ x: 0.5,
220
+ xanchor: "center",
221
+ font: { size: 11 },
222
+ tracegroupgap: 5,
223
+ },
224
+ hovermode: "closest",
225
+ dragmode: false,
226
+ };
227
+
228
+ return { traces, layout };
229
+ }, [filteredData, scoreDisplay, selectedDataset]);
230
+ };
231
+
232
+ export default useChartData;
frontend/src/pages/LeaderboardPage/components/Leaderboard/constants/chartDefaults.js ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MODEL_TYPE_ORDER, MODEL_TYPES } from "./modelTypes";
2
+
3
+ // Tol's qualitative palette — colorblind-friendly, publication-grade
4
+ // https://personal.sron.nl/~pault/data/colourschemes.pdf
5
+ export const ADAPTER_PALETTE = {
6
+ lora: "#4477AA",
7
+ ia3: "#EE6677",
8
+ adalora: "#228833",
9
+ dora: "#CCBB44",
10
+ oft: "#66CCEE",
11
+ probe: "#AA3377",
12
+ full_finetune: "#999999",
13
+ };
14
+
15
+ // Human-readable benchmark labels
16
+ export const BENCHMARK_LABELS = {
17
+ bcic2a: "BCIC IV-2a",
18
+ physionet: "PhysioNet MI",
19
+ isruc_sleep: "ISRUC Sleep",
20
+ tuab: "TUAB",
21
+ tuev: "TUEV",
22
+ chbmit: "CHB-MIT",
23
+ faced: "FACED",
24
+ seedv: "SEED-V",
25
+ };
26
+
27
+ export const BENCHMARK_KEYS = Object.keys(BENCHMARK_LABELS);
28
+
29
+ // Dataset selector options: Average + each benchmark
30
+ export const DATASET_OPTIONS = [
31
+ { value: "average", label: "Average" },
32
+ ...BENCHMARK_KEYS.map((key) => ({ value: key, label: BENCHMARK_LABELS[key] })),
33
+ ];
34
+
35
+ // Reference line color (amber, matching MOABB Dummy baseline style)
36
+ export const REFERENCE_LINE_COLOR = "#E67E22";
37
+
38
+ export const getAdapterKey = (type) => {
39
+ const clean = type?.toLowerCase().trim();
40
+ return MODEL_TYPE_ORDER.find((key) => clean?.includes(key)) || "unknown";
41
+ };
42
+
43
+ export const getAdapterLabel = (type) => {
44
+ const key = getAdapterKey(type);
45
+ return key !== "unknown" && MODEL_TYPES[key] ? MODEL_TYPES[key].label : type || "Unknown";
46
+ };
47
+
48
+ export const getAdapterColor = (adapterKey) => {
49
+ return ADAPTER_PALETTE[adapterKey] || "#999999";
50
+ };
frontend/src/pages/LeaderboardPage/components/Leaderboard/context/LeaderboardContext.js CHANGED
@@ -29,6 +29,7 @@ const DEFAULT_DISPLAY = {
29
  averageMode: TABLE_DEFAULTS.AVERAGE_MODE,
30
  rankingMode: TABLE_DEFAULTS.RANKING_MODE,
31
  visibleColumns: TABLE_DEFAULTS.COLUMNS.DEFAULT_VISIBLE,
 
32
  };
33
 
34
  // Create initial counter structure
@@ -416,6 +417,15 @@ const LeaderboardProvider = ({ children }) => {
416
  value: rankingModeFromUrl,
417
  });
418
  }
 
 
 
 
 
 
 
 
 
419
  };
420
 
421
  loadFromUrl();
@@ -439,6 +449,7 @@ const LeaderboardProvider = ({ children }) => {
439
  const currentScoreDisplay = searchParams.get("scoreDisplay");
440
  const currentAverageMode = searchParams.get("averageMode");
441
  const currentRankingMode = searchParams.get("rankingMode");
 
442
  const currentPrecisions =
443
  searchParams.get("precision")?.split(",").filter(Boolean) || [];
444
  const currentTypes =
@@ -469,6 +480,7 @@ const LeaderboardProvider = ({ children }) => {
469
  state.display.scoreDisplay !== currentScoreDisplay;
470
  const averageModeChanged = state.display.averageMode !== currentAverageMode;
471
  const rankingModeChanged = state.display.rankingMode !== currentRankingMode;
 
472
  const precisionsChanged =
473
  state.filters.precisions.length !== currentPrecisions.length ||
474
  state.filters.precisions.some((p) => !currentPrecisions.includes(p));
@@ -576,6 +588,14 @@ const LeaderboardProvider = ({ children }) => {
576
  }
577
  }
578
 
 
 
 
 
 
 
 
 
579
  if (
580
  paramsChanged ||
581
  filtersChanged ||
@@ -587,7 +607,8 @@ const LeaderboardProvider = ({ children }) => {
587
  averageModeChanged ||
588
  rankingModeChanged ||
589
  precisionsChanged ||
590
- typesChanged
 
591
  ) {
592
  // Update search params and let HashRouter handle the URL
593
  setSearchParams(newSearchParams);
 
29
  averageMode: TABLE_DEFAULTS.AVERAGE_MODE,
30
  rankingMode: TABLE_DEFAULTS.RANKING_MODE,
31
  visibleColumns: TABLE_DEFAULTS.COLUMNS.DEFAULT_VISIBLE,
32
+ viewMode: "table",
33
  };
34
 
35
  // Create initial counter structure
 
417
  value: rankingModeFromUrl,
418
  });
419
  }
420
+
421
+ const viewFromUrl = searchParams.get("view");
422
+ if (viewFromUrl && (viewFromUrl === "table" || viewFromUrl === "chart")) {
423
+ dispatch({
424
+ type: "SET_DISPLAY_OPTION",
425
+ key: "viewMode",
426
+ value: viewFromUrl,
427
+ });
428
+ }
429
  };
430
 
431
  loadFromUrl();
 
449
  const currentScoreDisplay = searchParams.get("scoreDisplay");
450
  const currentAverageMode = searchParams.get("averageMode");
451
  const currentRankingMode = searchParams.get("rankingMode");
452
+ const currentView = searchParams.get("view");
453
  const currentPrecisions =
454
  searchParams.get("precision")?.split(",").filter(Boolean) || [];
455
  const currentTypes =
 
480
  state.display.scoreDisplay !== currentScoreDisplay;
481
  const averageModeChanged = state.display.averageMode !== currentAverageMode;
482
  const rankingModeChanged = state.display.rankingMode !== currentRankingMode;
483
+ const viewChanged = state.display.viewMode !== currentView;
484
  const precisionsChanged =
485
  state.filters.precisions.length !== currentPrecisions.length ||
486
  state.filters.precisions.some((p) => !currentPrecisions.includes(p));
 
588
  }
589
  }
590
 
591
+ if (viewChanged) {
592
+ if (state.display.viewMode !== "table") {
593
+ newSearchParams.set("view", state.display.viewMode);
594
+ } else {
595
+ newSearchParams.delete("view");
596
+ }
597
+ }
598
+
599
  if (
600
  paramsChanged ||
601
  filtersChanged ||
 
607
  averageModeChanged ||
608
  rankingModeChanged ||
609
  precisionsChanged ||
610
+ typesChanged ||
611
+ viewChanged
612
  ) {
613
  // Update search params and let HashRouter handle the URL
614
  setSearchParams(newSearchParams);