Collapse the ranking boards to ten rows and add a jump menu beside them
Browse files- src/app/globals.css +11 -0
- src/app/page.tsx +221 -182
- src/components/RankingTableElo.tsx +47 -13
- src/components/SectionNav.tsx +101 -0
src/app/globals.css
CHANGED
|
@@ -126,3 +126,14 @@ body {
|
|
| 126 |
background: #f9fafb;
|
| 127 |
font-weight: 600;
|
| 128 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
background: #f9fafb;
|
| 127 |
font-weight: 600;
|
| 128 |
}
|
| 129 |
+
|
| 130 |
+
/*
|
| 131 |
+
* In-page anchor jumps (the rankings page section menu) glide rather than
|
| 132 |
+
* teleport, so the reader keeps their bearings. Motion is opt-out: readers who
|
| 133 |
+
* have asked for less of it keep the instant jump.
|
| 134 |
+
*/
|
| 135 |
+
@media (prefers-reduced-motion: no-preference) {
|
| 136 |
+
html {
|
| 137 |
+
scroll-behavior: smooth;
|
| 138 |
+
}
|
| 139 |
+
}
|
src/app/page.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
|
| 5 |
import { Info } from 'lucide-react';
|
| 6 |
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 7 |
import RankingTableElo from '@/src/components/RankingTableElo';
|
|
|
|
| 8 |
import TimeSeriesChart from '@/src/components/TimeSeriesChart';
|
| 9 |
import { getFilteredRankings, getRankingFilters, collectSqlEligible, ModelRanking, FilterOptions, ChallengeDefinition } from '@/src/services/modelService';
|
| 10 |
import { getDefinitionRounds } from '@/src/services/definitionService';
|
|
@@ -12,6 +13,33 @@ import { getDefinitionRounds } from '@/src/services/definitionService';
|
|
| 12 |
const DEFINITION_ID:number = parseInt(process.env.NEXT_PUBLIC_DEFINITION_ID || '225');
|
| 13 |
const SERIES_ID:number = parseInt(process.env.NEXT_PUBLIC_SERIES_ID || '1373');
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
interface RankingsData {
|
| 16 |
overall: ModelRanking[];
|
| 17 |
byDefinition: Record<number, ModelRanking[]>;
|
|
@@ -295,11 +323,15 @@ export default function Home() {
|
|
| 295 |
fetchAllData();
|
| 296 |
}, [selectedCalculationDate, isMounted]);
|
| 297 |
|
| 298 |
-
// `loading` covers the rankings only, and is applied to the
|
| 299 |
-
//
|
| 300 |
-
// unmount TimeSeriesChart every time the flag moves, throwing away
|
| 301 |
-
// it had already fetched and making it start over. Nothing in the
|
| 302 |
-
// depends on the rankings, so it renders on its own schedule.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
return (
|
| 304 |
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
| 305 |
<main className="max-w-7xl mx-auto">
|
|
@@ -313,192 +345,199 @@ export default function Home() {
|
|
| 313 |
</p>
|
| 314 |
</div>
|
| 315 |
|
| 316 |
-
{/*
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
| 321 |
-
<span className="ml-3 text-gray-600">Loading time series data...</span>
|
| 322 |
-
</div>
|
| 323 |
-
</div>
|
| 324 |
-
) : oldestActiveRound ? (
|
| 325 |
-
<div className="mb-8">
|
| 326 |
-
<TimeSeriesChart
|
| 327 |
-
challengeId={oldestActiveRound.id}
|
| 328 |
-
challengeName={oldestActiveRound.name || oldestActiveRound.round_name}
|
| 329 |
-
challengeDescription={oldestActiveRound.description}
|
| 330 |
-
frequency={oldestActiveRound.frequency}
|
| 331 |
-
horizon={oldestActiveRound.horizon}
|
| 332 |
-
seriesId={SERIES_ID}
|
| 333 |
-
on_title_page={true}
|
| 334 |
-
definitionId={DEFINITION_ID}
|
| 335 |
-
/>
|
| 336 |
-
</div>
|
| 337 |
-
) : (
|
| 338 |
-
<div className="bg-white rounded-lg shadow-md p-4 sm:p-6 lg:p-8 text-center text-gray-500 mb-8">
|
| 339 |
-
<p>No active rounds available at the moment.</p>
|
| 340 |
-
</div>
|
| 341 |
-
)}
|
| 342 |
-
|
| 343 |
-
{/* The three ranking sections share one loading state. Only this
|
| 344 |
-
subtree swaps while the rankings load — the chart above keeps
|
| 345 |
-
its mount and its in-flight requests. */}
|
| 346 |
-
{loading ? (
|
| 347 |
-
<div className="text-center py-12">
|
| 348 |
-
<div className="text-lg text-gray-600">Loading ranking...</div>
|
| 349 |
-
</div>
|
| 350 |
-
) : (
|
| 351 |
-
<>
|
| 352 |
-
{/* Overall Ranking (Full Table) */}
|
| 353 |
-
<div className="mb-8">
|
| 354 |
-
<div className="flex items-center justify-between mb-1">
|
| 355 |
-
<h2 className="text-2xl font-semibold text-gray-900">Overall Ranking</h2>
|
| 356 |
-
|
| 357 |
-
{/* Calculation Month Filter */}
|
| 358 |
-
{/* `relative` sits here, not on the Popover: the Popover box is only as
|
| 359 |
-
wide as its 16px icon, so a `right-0` panel anchored to it starts
|
| 360 |
-
off the left edge of a phone screen. Anchoring to this row instead
|
| 361 |
-
gives the panel the full card width to grow into. `group` stays on
|
| 362 |
-
the Popover so hover still only triggers from the icon. */}
|
| 363 |
-
<div className="relative flex items-center gap-2">
|
| 364 |
-
<label className="text-xs text-gray-500">Period</label>
|
| 365 |
-
<Popover className="group">
|
| 366 |
-
{({ open }) => (
|
| 367 |
-
<>
|
| 368 |
-
<PopoverButton
|
| 369 |
-
className="flex items-center justify-center w-11 h-11 -m-[14px] rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
| 370 |
-
aria-label="About the calculation period"
|
| 371 |
-
>
|
| 372 |
-
<Info className="w-4 h-4" aria-hidden="true" />
|
| 373 |
-
</PopoverButton>
|
| 374 |
-
<PopoverPanel
|
| 375 |
-
static
|
| 376 |
-
className={`absolute right-0 top-6 w-72 max-w-[calc(100vw-2rem)] p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg transition-all duration-200 z-10 ${
|
| 377 |
-
open ? 'opacity-100 visible' : 'opacity-0 invisible group-hover:opacity-100 group-hover:visible'
|
| 378 |
-
}`}
|
| 379 |
-
>
|
| 380 |
-
<div className="font-medium mb-1">Calculation Period</div>
|
| 381 |
-
<div className="text-gray-200 space-y-1">
|
| 382 |
-
<p>Rankings are recalculated multiple times a day as new ground truth data arrives.</p>
|
| 383 |
-
<p>Historical standings are snapshotted once per month. Select a month to view that period's snapshot.</p>
|
| 384 |
-
<p>“Recent” shows today's current ranking.</p>
|
| 385 |
-
</div>
|
| 386 |
-
</PopoverPanel>
|
| 387 |
-
</>
|
| 388 |
-
)}
|
| 389 |
-
</Popover>
|
| 390 |
-
<select
|
| 391 |
-
value={selectedCalculationDate}
|
| 392 |
-
onChange={(e) => setSelectedCalculationDate(e.target.value)}
|
| 393 |
-
className="px-3 py-1 text-base sm:text-xs bg-white border border-gray-200 rounded text-gray-600 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer"
|
| 394 |
-
>
|
| 395 |
-
{monthOptions.map((option) => (
|
| 396 |
-
<option key={option.value} value={option.value}>
|
| 397 |
-
{option.label}
|
| 398 |
-
</option>
|
| 399 |
-
))}
|
| 400 |
-
</select>
|
| 401 |
-
</div>
|
| 402 |
-
</div>
|
| 403 |
-
<p className="text-sm text-gray-600 mb-4">
|
| 404 |
-
Aggregated scores across all challenge definitions and time series. ELO: higher is
|
| 405 |
-
better. MASE and SQL: lower is better. SQL is shown only for models that submit
|
| 406 |
-
quantile forecasts. Updated multiple times a day.
|
| 407 |
-
</p>
|
| 408 |
-
<RankingTableElo
|
| 409 |
-
rankings={rankingsData.overall}
|
| 410 |
-
sqlEligibleModelIds={rankingsData.sqlEligible.overall}
|
| 411 |
-
/>
|
| 412 |
-
</div>
|
| 413 |
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
<
|
| 426 |
-
{
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
definitionId={selectedDefinition.id}
|
| 442 |
-
/>
|
| 443 |
-
) : (
|
| 444 |
-
<p className="text-sm text-gray-500">No challenge definitions available.</p>
|
| 445 |
-
)}
|
| 446 |
-
</div>
|
| 447 |
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
<select
|
| 471 |
-
value={
|
| 472 |
-
onChange={(e) =>
|
| 473 |
-
disabled={
|
| 474 |
-
className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer disabled:opacity-50"
|
| 475 |
>
|
| 476 |
-
{
|
| 477 |
-
<option key={
|
| 478 |
-
{
|
| 479 |
</option>
|
| 480 |
))}
|
| 481 |
</select>
|
| 482 |
-
</
|
| 483 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
</div>
|
| 485 |
-
<p className="text-sm text-gray-600 mb-4">
|
| 486 |
-
Rankings for the selected forecast frequency / horizon combination.
|
| 487 |
-
</p>
|
| 488 |
-
{selectedFh ? (
|
| 489 |
-
<RankingTableElo
|
| 490 |
-
key={selectedFh}
|
| 491 |
-
rankings={rankingsData.byFrequencyHorizon[selectedFh] || []}
|
| 492 |
-
sqlEligibleModelIds={rankingsData.sqlEligible.byFrequencyHorizon[selectedFh]}
|
| 493 |
-
limit={10}
|
| 494 |
-
title={formatFrequencyHorizon(selectedFh)}
|
| 495 |
-
/>
|
| 496 |
-
) : (
|
| 497 |
-
<p className="text-sm text-gray-500">No frequency / horizon combinations available.</p>
|
| 498 |
-
)}
|
| 499 |
</div>
|
| 500 |
-
</>
|
| 501 |
-
)}
|
| 502 |
</main>
|
| 503 |
</div>
|
| 504 |
);
|
|
|
|
| 5 |
import { Info } from 'lucide-react';
|
| 6 |
import Breadcrumbs from '@/src/components/Breadcrumbs';
|
| 7 |
import RankingTableElo from '@/src/components/RankingTableElo';
|
| 8 |
+
import SectionNav, { NavSection } from '@/src/components/SectionNav';
|
| 9 |
import TimeSeriesChart from '@/src/components/TimeSeriesChart';
|
| 10 |
import { getFilteredRankings, getRankingFilters, collectSqlEligible, ModelRanking, FilterOptions, ChallengeDefinition } from '@/src/services/modelService';
|
| 11 |
import { getDefinitionRounds } from '@/src/services/definitionService';
|
|
|
|
| 13 |
const DEFINITION_ID:number = parseInt(process.env.NEXT_PUBLIC_DEFINITION_ID || '225');
|
| 14 |
const SERIES_ID:number = parseInt(process.env.NEXT_PUBLIC_SERIES_ID || '1373');
|
| 15 |
|
| 16 |
+
/**
|
| 17 |
+
* Jump-menu entries, in page order — the `id` of each has to match the
|
| 18 |
+
* corresponding `<section>` below. Module scope, not an inline literal:
|
| 19 |
+
* SectionNav re-subscribes its observer whenever this array's identity changes.
|
| 20 |
+
*/
|
| 21 |
+
const PAGE_SECTIONS: NavSection[] = [
|
| 22 |
+
{ id: 'live-forecast', label: 'Live Forecast' },
|
| 23 |
+
{ id: 'overall-ranking', label: 'Overall Ranking' },
|
| 24 |
+
{ id: 'rankings-by-challenge', label: 'By Challenge' },
|
| 25 |
+
{ id: 'rankings-by-frequency-horizon', label: 'By Frequency & Horizon' },
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Rows each board shows before the reader expands it. The three sections used to
|
| 30 |
+
* run to full length, which pushed the two lower ones out of sight entirely.
|
| 31 |
+
*/
|
| 32 |
+
const COLLAPSED_ROWS = 10;
|
| 33 |
+
|
| 34 |
+
/** Stands in for a board while its rankings are in flight. */
|
| 35 |
+
function RankingPlaceholder() {
|
| 36 |
+
return (
|
| 37 |
+
<div className="bg-white rounded-lg shadow-md p-8 text-center text-gray-500">
|
| 38 |
+
Loading ranking...
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
interface RankingsData {
|
| 44 |
overall: ModelRanking[];
|
| 45 |
byDefinition: Record<number, ModelRanking[]>;
|
|
|
|
| 323 |
fetchAllData();
|
| 324 |
}, [selectedCalculationDate, isMounted]);
|
| 325 |
|
| 326 |
+
// `loading` covers the rankings only, and is applied to the boards inside the
|
| 327 |
+
// ranking sections rather than to the whole page. Returning a different tree
|
| 328 |
+
// here would unmount TimeSeriesChart every time the flag moves, throwing away
|
| 329 |
+
// the data it had already fetched and making it start over. Nothing in the
|
| 330 |
+
// chart depends on the rankings, so it renders on its own schedule.
|
| 331 |
+
//
|
| 332 |
+
// The section wrappers are outside the flag as well, so their ids exist from
|
| 333 |
+
// the first paint — SectionNav resolves its targets on mount and would find
|
| 334 |
+
// nothing to observe if the sections appeared only once the boards had loaded.
|
| 335 |
return (
|
| 336 |
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8">
|
| 337 |
<main className="max-w-7xl mx-auto">
|
|
|
|
| 345 |
</p>
|
| 346 |
</div>
|
| 347 |
|
| 348 |
+
{/* The jump menu sits beside the content, not above it, so the boards
|
| 349 |
+
further down the page are visible as destinations from the top. */}
|
| 350 |
+
<div className="lg:grid lg:grid-cols-[13rem_minmax(0,1fr)] lg:gap-x-8 lg:items-start">
|
| 351 |
+
<SectionNav sections={PAGE_SECTIONS} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
|
| 353 |
+
<div className="min-w-0 lg:col-start-2 lg:row-start-1">
|
| 354 |
+
{/* Time Series Chart Section */}
|
| 355 |
+
<section id="live-forecast" className="mb-8 scroll-mt-20 lg:scroll-mt-8">
|
| 356 |
+
{roundLoading ? (
|
| 357 |
+
<div className="bg-white rounded-lg shadow-md p-4 sm:p-6 lg:p-8">
|
| 358 |
+
<div className="flex items-center justify-center">
|
| 359 |
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
| 360 |
+
<span className="ml-3 text-gray-600">Loading time series data...</span>
|
| 361 |
+
</div>
|
| 362 |
+
</div>
|
| 363 |
+
) : oldestActiveRound ? (
|
| 364 |
+
<TimeSeriesChart
|
| 365 |
+
challengeId={oldestActiveRound.id}
|
| 366 |
+
challengeName={oldestActiveRound.name || oldestActiveRound.round_name}
|
| 367 |
+
challengeDescription={oldestActiveRound.description}
|
| 368 |
+
frequency={oldestActiveRound.frequency}
|
| 369 |
+
horizon={oldestActiveRound.horizon}
|
| 370 |
+
seriesId={SERIES_ID}
|
| 371 |
+
on_title_page={true}
|
| 372 |
+
definitionId={DEFINITION_ID}
|
| 373 |
+
/>
|
| 374 |
+
) : (
|
| 375 |
+
<div className="bg-white rounded-lg shadow-md p-4 sm:p-6 lg:p-8 text-center text-gray-500">
|
| 376 |
+
<p>No active rounds available at the moment.</p>
|
| 377 |
+
</div>
|
| 378 |
+
)}
|
| 379 |
+
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
|
| 381 |
+
{/* Overall Ranking */}
|
| 382 |
+
<section id="overall-ranking" className="mb-8 scroll-mt-20 lg:scroll-mt-8">
|
| 383 |
+
<div className="flex items-center justify-between mb-1">
|
| 384 |
+
<h2 className="text-2xl font-semibold text-gray-900">Overall Ranking</h2>
|
| 385 |
+
|
| 386 |
+
{/* Calculation Month Filter */}
|
| 387 |
+
{/* `relative` sits here, not on the Popover: the Popover box is only as
|
| 388 |
+
wide as its 16px icon, so a `right-0` panel anchored to it starts
|
| 389 |
+
off the left edge of a phone screen. Anchoring to this row instead
|
| 390 |
+
gives the panel the full card width to grow into. `group` stays on
|
| 391 |
+
the Popover so hover still only triggers from the icon. */}
|
| 392 |
+
<div className="relative flex items-center gap-2">
|
| 393 |
+
<label className="text-xs text-gray-500">Period</label>
|
| 394 |
+
<Popover className="group">
|
| 395 |
+
{({ open }) => (
|
| 396 |
+
<>
|
| 397 |
+
<PopoverButton
|
| 398 |
+
className="flex items-center justify-center w-11 h-11 -m-[14px] rounded-full text-gray-400 hover:text-gray-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
|
| 399 |
+
aria-label="About the calculation period"
|
| 400 |
+
>
|
| 401 |
+
<Info className="w-4 h-4" aria-hidden="true" />
|
| 402 |
+
</PopoverButton>
|
| 403 |
+
<PopoverPanel
|
| 404 |
+
static
|
| 405 |
+
className={`absolute right-0 top-6 w-72 max-w-[calc(100vw-2rem)] p-3 bg-gray-900 text-white text-xs rounded-lg shadow-lg transition-all duration-200 z-10 ${
|
| 406 |
+
open ? 'opacity-100 visible' : 'opacity-0 invisible group-hover:opacity-100 group-hover:visible'
|
| 407 |
+
}`}
|
| 408 |
+
>
|
| 409 |
+
<div className="font-medium mb-1">Calculation Period</div>
|
| 410 |
+
<div className="text-gray-200 space-y-1">
|
| 411 |
+
<p>Rankings are recalculated multiple times a day as new ground truth data arrives.</p>
|
| 412 |
+
<p>Historical standings are snapshotted once per month. Select a month to view that period's snapshot.</p>
|
| 413 |
+
<p>“Recent” shows today's current ranking.</p>
|
| 414 |
+
</div>
|
| 415 |
+
</PopoverPanel>
|
| 416 |
+
</>
|
| 417 |
+
)}
|
| 418 |
+
</Popover>
|
| 419 |
+
<select
|
| 420 |
+
value={selectedCalculationDate}
|
| 421 |
+
onChange={(e) => setSelectedCalculationDate(e.target.value)}
|
| 422 |
+
disabled={monthOptions.length === 0}
|
| 423 |
+
className="px-3 py-1 text-base sm:text-xs bg-white border border-gray-200 rounded text-gray-600 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer disabled:opacity-50"
|
| 424 |
+
>
|
| 425 |
+
{monthOptions.map((option) => (
|
| 426 |
+
<option key={option.value} value={option.value}>
|
| 427 |
+
{option.label}
|
| 428 |
+
</option>
|
| 429 |
+
))}
|
| 430 |
+
</select>
|
| 431 |
+
</div>
|
| 432 |
+
</div>
|
| 433 |
+
<p className="text-sm text-gray-600 mb-4">
|
| 434 |
+
Aggregated scores across all challenge definitions and time series. ELO: higher is
|
| 435 |
+
better. MASE and SQL: lower is better. SQL is shown only for models that submit
|
| 436 |
+
quantile forecasts. Updated multiple times a day.
|
| 437 |
+
</p>
|
| 438 |
+
{loading ? (
|
| 439 |
+
<RankingPlaceholder />
|
| 440 |
+
) : (
|
| 441 |
+
<RankingTableElo
|
| 442 |
+
rankings={rankingsData.overall}
|
| 443 |
+
sqlEligibleModelIds={rankingsData.sqlEligible.overall}
|
| 444 |
+
limit={COLLAPSED_ROWS}
|
| 445 |
+
/>
|
| 446 |
+
)}
|
| 447 |
+
</section>
|
| 448 |
+
|
| 449 |
+
{/* Rankings by Challenge Definition */}
|
| 450 |
+
<section id="rankings-by-challenge" className="mb-8 scroll-mt-20 lg:scroll-mt-8">
|
| 451 |
+
<div className="flex flex-wrap items-center justify-between gap-3 mb-2">
|
| 452 |
+
<h2 className="text-2xl font-semibold text-gray-900">Rankings by Challenge</h2>
|
| 453 |
<select
|
| 454 |
+
value={effectiveDefinitionId ?? ''}
|
| 455 |
+
onChange={(e) => setSelectedDefinitionId(Number(e.target.value))}
|
| 456 |
+
disabled={filterOptions.definitions.length === 0}
|
| 457 |
+
className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer max-w-full sm:max-w-sm disabled:opacity-50"
|
| 458 |
>
|
| 459 |
+
{filterOptions.definitions.map((def) => (
|
| 460 |
+
<option key={def.id} value={def.id}>
|
| 461 |
+
{def.name}
|
| 462 |
</option>
|
| 463 |
))}
|
| 464 |
</select>
|
| 465 |
+
</div>
|
| 466 |
+
<p className="text-sm text-gray-600 mb-4">
|
| 467 |
+
Rankings evaluated for the selected challenge definition.
|
| 468 |
+
</p>
|
| 469 |
+
{loading ? (
|
| 470 |
+
<RankingPlaceholder />
|
| 471 |
+
) : selectedDefinition ? (
|
| 472 |
+
<RankingTableElo
|
| 473 |
+
key={selectedDefinition.id}
|
| 474 |
+
rankings={rankingsData.byDefinition[selectedDefinition.id] || []}
|
| 475 |
+
sqlEligibleModelIds={rankingsData.sqlEligible.byDefinition[selectedDefinition.id]}
|
| 476 |
+
limit={COLLAPSED_ROWS}
|
| 477 |
+
title={selectedDefinition.name}
|
| 478 |
+
definitionId={selectedDefinition.id}
|
| 479 |
+
/>
|
| 480 |
+
) : (
|
| 481 |
+
<p className="text-sm text-gray-500">No challenge definitions available.</p>
|
| 482 |
+
)}
|
| 483 |
+
</section>
|
| 484 |
+
|
| 485 |
+
{/* Rankings by Frequency/Horizon */}
|
| 486 |
+
<section id="rankings-by-frequency-horizon" className="mb-8 scroll-mt-20 lg:scroll-mt-8">
|
| 487 |
+
<div className="flex flex-wrap items-center justify-between gap-3 mb-2">
|
| 488 |
+
<h2 className="text-2xl font-semibold text-gray-900">Rankings by Frequency & Horizon Combinations</h2>
|
| 489 |
+
<div className="flex flex-wrap items-center gap-3">
|
| 490 |
+
<label className="flex items-center gap-1.5 text-sm text-gray-600">
|
| 491 |
+
<span>Frequency</span>
|
| 492 |
+
<select
|
| 493 |
+
value={effectiveFrequency ?? ''}
|
| 494 |
+
onChange={(e) => setSelectedFrequency(e.target.value)}
|
| 495 |
+
disabled={frequencyOptions.length === 0}
|
| 496 |
+
className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer disabled:opacity-50"
|
| 497 |
+
>
|
| 498 |
+
{frequencyOptions.map((f) => (
|
| 499 |
+
<option key={f} value={f}>
|
| 500 |
+
{formatFrequency(f)}
|
| 501 |
+
</option>
|
| 502 |
+
))}
|
| 503 |
+
</select>
|
| 504 |
+
</label>
|
| 505 |
+
<label className="flex items-center gap-1.5 text-sm text-gray-600">
|
| 506 |
+
<span>Horizon</span>
|
| 507 |
+
<select
|
| 508 |
+
value={effectiveHorizon ?? ''}
|
| 509 |
+
onChange={(e) => setSelectedHorizon(e.target.value)}
|
| 510 |
+
disabled={horizonOptions.length === 0}
|
| 511 |
+
className="px-3 py-2 text-base sm:text-sm bg-white border border-gray-200 rounded-md text-gray-700 hover:border-gray-300 focus:outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 cursor-pointer disabled:opacity-50"
|
| 512 |
+
>
|
| 513 |
+
{horizonOptions.map((h) => (
|
| 514 |
+
<option key={h} value={h}>
|
| 515 |
+
{h}
|
| 516 |
+
</option>
|
| 517 |
+
))}
|
| 518 |
+
</select>
|
| 519 |
+
</label>
|
| 520 |
+
</div>
|
| 521 |
+
</div>
|
| 522 |
+
<p className="text-sm text-gray-600 mb-4">
|
| 523 |
+
Rankings for the selected forecast frequency / horizon combination.
|
| 524 |
+
</p>
|
| 525 |
+
{loading ? (
|
| 526 |
+
<RankingPlaceholder />
|
| 527 |
+
) : selectedFh ? (
|
| 528 |
+
<RankingTableElo
|
| 529 |
+
key={selectedFh}
|
| 530 |
+
rankings={rankingsData.byFrequencyHorizon[selectedFh] || []}
|
| 531 |
+
sqlEligibleModelIds={rankingsData.sqlEligible.byFrequencyHorizon[selectedFh]}
|
| 532 |
+
limit={COLLAPSED_ROWS}
|
| 533 |
+
title={formatFrequencyHorizon(selectedFh)}
|
| 534 |
+
/>
|
| 535 |
+
) : (
|
| 536 |
+
<p className="text-sm text-gray-500">No frequency / horizon combinations available.</p>
|
| 537 |
+
)}
|
| 538 |
+
</section>
|
| 539 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
</div>
|
|
|
|
|
|
|
| 541 |
</main>
|
| 542 |
</div>
|
| 543 |
);
|
src/components/RankingTableElo.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
-
import { useMemo } from 'react';
|
| 4 |
import { useRouter } from 'next/navigation';
|
| 5 |
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
| 6 |
import {
|
|
@@ -12,13 +12,18 @@ import {
|
|
| 12 |
flexRender,
|
| 13 |
SortDirection,
|
| 14 |
} from '@tanstack/react-table';
|
| 15 |
-
import { Info } from 'lucide-react';
|
| 16 |
import { ModelRanking } from '@/src/services/modelService';
|
| 17 |
|
| 18 |
interface RankingTableEloProps {
|
| 19 |
rankings: ModelRanking[];
|
| 20 |
compact?: boolean;
|
| 21 |
title?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
limit?: number;
|
| 23 |
definitionId?: number;
|
| 24 |
/**
|
|
@@ -106,6 +111,13 @@ function HeaderWithInfo({
|
|
| 106 |
);
|
| 107 |
}
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
/** Tooltip body for the SQL column. Kept next to the header that uses it. */
|
| 110 |
function SqlExplanation() {
|
| 111 |
return (
|
|
@@ -127,18 +139,17 @@ export default function RankingTableElo({
|
|
| 127 |
sqlEligibleModelIds,
|
| 128 |
}: RankingTableEloProps) {
|
| 129 |
const router = useRouter();
|
|
|
|
| 130 |
|
| 131 |
const handleRowClick = (modelId: string, modelName: string) => {
|
| 132 |
router.push(`/models/${modelId}`);
|
| 133 |
};
|
| 134 |
|
| 135 |
-
//
|
| 136 |
-
//
|
| 137 |
-
//
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
[rankings, limit]
|
| 141 |
-
);
|
| 142 |
|
| 143 |
const fullColumns = useMemo<ColumnDef<ModelRanking>[]>(
|
| 144 |
() => [
|
|
@@ -381,7 +392,7 @@ export default function RankingTableElo({
|
|
| 381 |
const columns = compact ? compactColumns : fullColumns;
|
| 382 |
|
| 383 |
const table = useReactTable({
|
| 384 |
-
data
|
| 385 |
columns,
|
| 386 |
getCoreRowModel: getCoreRowModel(),
|
| 387 |
getSortedRowModel: getSortedRowModel(),
|
|
@@ -396,6 +407,10 @@ export default function RankingTableElo({
|
|
| 396 |
},
|
| 397 |
});
|
| 398 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
const getSortIcon = (isSorted: false | SortDirection) => {
|
| 400 |
if (!isSorted) {
|
| 401 |
return <span className="ml-1 text-gray-400">↕</span>;
|
|
@@ -423,7 +438,7 @@ export default function RankingTableElo({
|
|
| 423 |
horizontal room, which pushes the ELO score (the point of the page)
|
| 424 |
off-screen on a phone. */}
|
| 425 |
<div className="md:hidden p-3 space-y-2">
|
| 426 |
-
{
|
| 427 |
const model = row.original;
|
| 428 |
const eloUpperDiff = model.elo_ci_upper - model.elo_rating_median;
|
| 429 |
const eloLowerDiff = model.elo_rating_median - model.elo_ci_lower;
|
|
@@ -528,7 +543,7 @@ export default function RankingTableElo({
|
|
| 528 |
))}
|
| 529 |
</thead>
|
| 530 |
<tbody className="bg-white divide-y divide-gray-200">
|
| 531 |
-
{
|
| 532 |
<tr
|
| 533 |
key={row.id}
|
| 534 |
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
|
@@ -548,11 +563,30 @@ export default function RankingTableElo({
|
|
| 548 |
</table>
|
| 549 |
</div>
|
| 550 |
|
| 551 |
-
{
|
| 552 |
<div className="text-center py-8 text-gray-500">
|
| 553 |
No rankings found.
|
| 554 |
</div>
|
| 555 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
</div>
|
| 557 |
);
|
| 558 |
}
|
|
|
|
| 1 |
'use client';
|
| 2 |
|
| 3 |
+
import { useMemo, useState } from 'react';
|
| 4 |
import { useRouter } from 'next/navigation';
|
| 5 |
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
| 6 |
import {
|
|
|
|
| 12 |
flexRender,
|
| 13 |
SortDirection,
|
| 14 |
} from '@tanstack/react-table';
|
| 15 |
+
import { ChevronDown, Info } from 'lucide-react';
|
| 16 |
import { ModelRanking } from '@/src/services/modelService';
|
| 17 |
|
| 18 |
interface RankingTableEloProps {
|
| 19 |
rankings: ModelRanking[];
|
| 20 |
compact?: boolean;
|
| 21 |
title?: string;
|
| 22 |
+
/**
|
| 23 |
+
* How many rows to show while collapsed. The board still holds every model —
|
| 24 |
+
* sorting and the column filters act on all of them, and a footer control
|
| 25 |
+
* expands to the full list. Omit to always show everything.
|
| 26 |
+
*/
|
| 27 |
limit?: number;
|
| 28 |
definitionId?: number;
|
| 29 |
/**
|
|
|
|
| 111 |
);
|
| 112 |
}
|
| 113 |
|
| 114 |
+
/**
|
| 115 |
+
* Stand-in for an absent board. Callers routinely write `rankings={map[id] || []}`,
|
| 116 |
+
* which mints a new empty array every render; funnelling those through one
|
| 117 |
+
* constant keeps the `data` reference react-table sees stable. See the memo below.
|
| 118 |
+
*/
|
| 119 |
+
const NO_RANKINGS: ModelRanking[] = [];
|
| 120 |
+
|
| 121 |
/** Tooltip body for the SQL column. Kept next to the header that uses it. */
|
| 122 |
function SqlExplanation() {
|
| 123 |
return (
|
|
|
|
| 139 |
sqlEligibleModelIds,
|
| 140 |
}: RankingTableEloProps) {
|
| 141 |
const router = useRouter();
|
| 142 |
+
const [expanded, setExpanded] = useState(false);
|
| 143 |
|
| 144 |
const handleRowClick = (modelId: string, modelName: string) => {
|
| 145 |
router.push(`/models/${modelId}`);
|
| 146 |
};
|
| 147 |
|
| 148 |
+
// The whole board goes to react-table; `limit` cuts the *rendered* rows further
|
| 149 |
+
// down, after sorting and filtering, so both act on every model rather than on
|
| 150 |
+
// the first ten. Memoized so the `data` reference is stable across renders — an
|
| 151 |
+
// unstable one makes react-table's auto-reset loop infinitely (freezing the page).
|
| 152 |
+
const data = useMemo(() => (rankings?.length ? rankings : NO_RANKINGS), [rankings]);
|
|
|
|
|
|
|
| 153 |
|
| 154 |
const fullColumns = useMemo<ColumnDef<ModelRanking>[]>(
|
| 155 |
() => [
|
|
|
|
| 392 |
const columns = compact ? compactColumns : fullColumns;
|
| 393 |
|
| 394 |
const table = useReactTable({
|
| 395 |
+
data,
|
| 396 |
columns,
|
| 397 |
getCoreRowModel: getCoreRowModel(),
|
| 398 |
getSortedRowModel: getSortedRowModel(),
|
|
|
|
| 407 |
},
|
| 408 |
});
|
| 409 |
|
| 410 |
+
const allRows = table.getRowModel().rows;
|
| 411 |
+
const collapsible = limit !== undefined && allRows.length > limit;
|
| 412 |
+
const rows = collapsible && !expanded ? allRows.slice(0, limit) : allRows;
|
| 413 |
+
|
| 414 |
const getSortIcon = (isSorted: false | SortDirection) => {
|
| 415 |
if (!isSorted) {
|
| 416 |
return <span className="ml-1 text-gray-400">↕</span>;
|
|
|
|
| 438 |
horizontal room, which pushes the ELO score (the point of the page)
|
| 439 |
off-screen on a phone. */}
|
| 440 |
<div className="md:hidden p-3 space-y-2">
|
| 441 |
+
{rows.map((row) => {
|
| 442 |
const model = row.original;
|
| 443 |
const eloUpperDiff = model.elo_ci_upper - model.elo_rating_median;
|
| 444 |
const eloLowerDiff = model.elo_rating_median - model.elo_ci_lower;
|
|
|
|
| 543 |
))}
|
| 544 |
</thead>
|
| 545 |
<tbody className="bg-white divide-y divide-gray-200">
|
| 546 |
+
{rows.map((row) => (
|
| 547 |
<tr
|
| 548 |
key={row.id}
|
| 549 |
className="hover:bg-gray-50 cursor-pointer transition-colors"
|
|
|
|
| 563 |
</table>
|
| 564 |
</div>
|
| 565 |
|
| 566 |
+
{allRows.length === 0 && (
|
| 567 |
<div className="text-center py-8 text-gray-500">
|
| 568 |
No rankings found.
|
| 569 |
</div>
|
| 570 |
)}
|
| 571 |
+
|
| 572 |
+
{collapsible && (
|
| 573 |
+
<div className="border-t border-gray-200 bg-gray-50">
|
| 574 |
+
<button
|
| 575 |
+
type="button"
|
| 576 |
+
onClick={() => setExpanded((wasExpanded) => !wasExpanded)}
|
| 577 |
+
aria-expanded={expanded}
|
| 578 |
+
className="flex w-full min-h-[44px] items-center justify-center gap-1.5 px-6 text-sm font-medium text-blue-600 transition-colors hover:bg-gray-100 hover:text-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-400"
|
| 579 |
+
>
|
| 580 |
+
<span>
|
| 581 |
+
{expanded ? `Show top ${limit}` : `Show all ${allRows.length} models`}
|
| 582 |
+
</span>
|
| 583 |
+
<ChevronDown
|
| 584 |
+
className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
| 585 |
+
aria-hidden="true"
|
| 586 |
+
/>
|
| 587 |
+
</button>
|
| 588 |
+
</div>
|
| 589 |
+
)}
|
| 590 |
</div>
|
| 591 |
);
|
| 592 |
}
|
src/components/SectionNav.tsx
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
|
| 5 |
+
export interface NavSection {
|
| 6 |
+
/** `id` of the element on the page this entry jumps to. */
|
| 7 |
+
id: string;
|
| 8 |
+
label: string;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
interface SectionNavProps {
|
| 12 |
+
/**
|
| 13 |
+
* The sections, in page order. Pass a value with a stable identity (a module
|
| 14 |
+
* constant, or memoized) — a fresh array each render re-subscribes the
|
| 15 |
+
* observer on every render.
|
| 16 |
+
*/
|
| 17 |
+
sections: NavSection[];
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* In-page jump menu.
|
| 22 |
+
*
|
| 23 |
+
* The rankings page stacks three boards below a full-height chart, far enough
|
| 24 |
+
* down that readers were not finding the per-challenge and
|
| 25 |
+
* per-frequency/horizon ones at all. This lists them all above the fold.
|
| 26 |
+
*
|
| 27 |
+
* One component, two layouts: a rail in the left gutter from `lg` up, and a
|
| 28 |
+
* strip of chips across the top below it, where there is no gutter to put a
|
| 29 |
+
* rail in. Both stick while the reader scrolls the sections past them.
|
| 30 |
+
*/
|
| 31 |
+
export default function SectionNav({ sections }: SectionNavProps) {
|
| 32 |
+
const [activeId, setActiveId] = useState<string>(sections[0]?.id ?? '');
|
| 33 |
+
|
| 34 |
+
useEffect(() => {
|
| 35 |
+
const elements = sections
|
| 36 |
+
.map((section) => document.getElementById(section.id))
|
| 37 |
+
.filter((element): element is HTMLElement => element !== null);
|
| 38 |
+
if (elements.length === 0) return;
|
| 39 |
+
|
| 40 |
+
// Ids currently in the band, kept across callbacks: an IntersectionObserver
|
| 41 |
+
// reports only what *changed*, so the full picture has to be accumulated.
|
| 42 |
+
const inBand = new Set<string>();
|
| 43 |
+
|
| 44 |
+
const observer = new IntersectionObserver(
|
| 45 |
+
(entries) => {
|
| 46 |
+
entries.forEach((entry) => {
|
| 47 |
+
if (entry.isIntersecting) inBand.add(entry.target.id);
|
| 48 |
+
else inBand.delete(entry.target.id);
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
// Later sections win: scrolling down, the one that just arrived at the
|
| 52 |
+
// top of the screen is the one the reader has moved to.
|
| 53 |
+
for (let i = sections.length - 1; i >= 0; i--) {
|
| 54 |
+
if (inBand.has(sections[i].id)) {
|
| 55 |
+
setActiveId(sections[i].id);
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
},
|
| 60 |
+
// Squeeze the root to a band across the top fifth of the viewport, so
|
| 61 |
+
// "in view" means "at the top of the screen". Against the whole viewport
|
| 62 |
+
// a board as tall as these would stay active long after the next one had
|
| 63 |
+
// scrolled into place.
|
| 64 |
+
{ rootMargin: '0px 0px -80% 0px' }
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
elements.forEach((element) => observer.observe(element));
|
| 68 |
+
return () => observer.disconnect();
|
| 69 |
+
}, [sections]);
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<nav
|
| 73 |
+
aria-label="Sections on this page"
|
| 74 |
+
className="sticky top-0 z-20 -mx-4 mb-6 border-b border-gray-200 bg-gray-50/95 px-4 py-2 backdrop-blur sm:-mx-6 sm:px-6 lg:-mx-0 lg:mb-0 lg:self-start lg:top-8 lg:border-0 lg:bg-transparent lg:px-0 lg:py-0 lg:backdrop-blur-none"
|
| 75 |
+
>
|
| 76 |
+
<p className="hidden lg:block pl-3 pb-2 text-xs font-semibold uppercase tracking-wider text-gray-400">
|
| 77 |
+
On this page
|
| 78 |
+
</p>
|
| 79 |
+
<ul className="flex gap-2 overflow-x-auto lg:flex-col lg:gap-0.5 lg:overflow-visible">
|
| 80 |
+
{sections.map((section) => {
|
| 81 |
+
const isActive = section.id === activeId;
|
| 82 |
+
return (
|
| 83 |
+
<li key={section.id} className="shrink-0 lg:shrink">
|
| 84 |
+
<a
|
| 85 |
+
href={`#${section.id}`}
|
| 86 |
+
aria-current={isActive ? 'true' : undefined}
|
| 87 |
+
className={`flex min-h-[44px] items-center whitespace-nowrap rounded-md px-3 text-sm transition-colors lg:min-h-0 lg:whitespace-normal lg:rounded-none lg:border-l-2 lg:py-2 ${
|
| 88 |
+
isActive
|
| 89 |
+
? 'bg-blue-50 font-medium text-blue-700 lg:bg-transparent lg:border-blue-500'
|
| 90 |
+
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900 lg:border-transparent lg:hover:bg-transparent lg:hover:border-gray-300'
|
| 91 |
+
}`}
|
| 92 |
+
>
|
| 93 |
+
{section.label}
|
| 94 |
+
</a>
|
| 95 |
+
</li>
|
| 96 |
+
);
|
| 97 |
+
})}
|
| 98 |
+
</ul>
|
| 99 |
+
</nav>
|
| 100 |
+
);
|
| 101 |
+
}
|