@@ -50,7 +50,6 @@ const state = {
5050 osmAvatars : new Map ( ) ,
5151 editorStats : null ,
5252} ;
53- state . userEditors = new Map ( ) ;
5453
5554function fetchOsmAvatar ( uid ) {
5655 if ( uid == null ) return Promise . resolve ( null ) ;
@@ -179,7 +178,7 @@ function sumTagKey(ts, k) {
179178
180179function transform ( row ) {
181180 const ts = row . tag_stats || { } ;
182- const n = ( v ) => v || 0 ; // coalesce so a field the API omits never renders as NaN
181+ const n = ( v ) => v || 0 ;
183182 const b = sumTagKey ( ts , "building" ) ,
184183 h = sumTagKey ( ts , "highway" ) ;
185184 const lu = sumTagKey ( ts , "landuse" ) ,
@@ -232,7 +231,7 @@ function renderChips() {
232231 chipsEl . innerHTML = state . hashtags
233232 . map (
234233 ( h , i ) =>
235- `<span class="chip">#${ escapeHtml ( h ) } <button type="button" data-i="${ i } " aria-label="Remove ${ escapeHtml ( h ) } "><i data-lucide="x"></i></button></span>`
234+ `<span class="chip"><span class="chip-label"> #${ escapeHtml ( h ) } </span> <button type="button" data-i="${ i } " aria-label="Remove ${ escapeHtml ( h ) } "><i data-lucide="x"></i></button></span>`
236235 )
237236 . join ( "" ) ;
238237 chipsEl . querySelectorAll ( "button" ) . forEach (
@@ -590,7 +589,7 @@ async function runQuery() {
590589 setOverviewLoading ( ) ;
591590 showLoading ( ) ; // skeleton the table now; the leaderboard fetch only starts after summary resolves
592591 $ ( "#podium" ) ?. closest ( "section" ) ?. style . setProperty ( "display" , "" ) ;
593- $ ( "#podium" ) . innerHTML = "" ;
592+ setPodiumLoading ( ) ;
594593 if ( typeof setChartsLoading === "function" ) setChartsLoading ( ) ;
595594 const base = windowParams ( ) ;
596595 const alive = ( ) => state . query === ctrl ;
@@ -600,8 +599,7 @@ async function runQuery() {
600599 return p ;
601600 } ;
602601 setBusy ( true ) ;
603- // Clear the button spinner once the primary content (summary + leaderboard) is up; secondary sections
604- // keep loading behind their own inline status.
602+ // The spinner and its stage label stay up across the whole fetch sequence; released in finally.
605603 let _released = false ;
606604 const releasePrimary = ( ) => { if ( ! _released ) { _released = true ; setBusy ( false ) ; } } ;
607605 try {
@@ -614,7 +612,6 @@ async function runQuery() {
614612
615613 setStatus ( "Fetching leaderboard…" ) ;
616614 await loadLeaderboardPage ( true ) ;
617- releasePrimary ( ) ;
618615 if ( ! alive ( ) ) return ;
619616
620617 // Each secondary section is isolated so one failure can't blank the others or the primary content.
@@ -727,7 +724,6 @@ function onSectionError(section, err, ctrl) {
727724 console . warn ( `OSMSG ${ section } fetch failed:` , err ) ;
728725}
729726
730- // Run one secondary section in isolation so its failure can't abort the query or blank another section.
731727async function runSection ( name , ctrl , fn ) {
732728 try {
733729 await fn ( ) ;
@@ -820,7 +816,7 @@ const OV_CELLS_TOTALS = [
820816 [ "Created" , "created" , "plus-square" , "ov-add" , "Elements created (nodes + ways + relations)" ] ,
821817 [ "Modified" , "modified" , "edit-3" , "ov-mod" , "Elements modified" ] ,
822818 [ "Deleted" , "deleted" , "trash-2" , "ov-del" , "Elements deleted" ] ,
823- [ "Mappers " , "mappers" , "users" , "" , "Distinct contributors" ] ,
819+ [ "Contributors " , "mappers" , "users" , "" , "Distinct contributors" ] ,
824820 [ "Changesets" , "changesets" , "git-commit-horizontal" , "" , "Number of changesets" ] ,
825821] ;
826822const OV_CELLS = [
@@ -963,6 +959,18 @@ function renderOverviewDetails() {
963959 refreshIcons ( $ ( "#ov-details" ) ) ;
964960}
965961
962+ function setPodiumLoading ( ) {
963+ const el = $ ( "#podium" ) ;
964+ if ( ! el ) return ;
965+ el . innerHTML = Array . from ( { length : 3 } , ( _ , i ) => `
966+ <div class="pod pod-${ i + 1 } ">
967+ <span class="pod-rank">${ i + 1 } </span>
968+ <span class="pod-avatar skeleton" style="border:0"></span>
969+ <span class="pod-name"><span class="skeleton" style="display:inline-block;width:82px;height:12px"></span></span>
970+ <span class="pod-score-wrap"><span class="skeleton" style="display:inline-block;width:60px;height:18px;margin-top:4px"></span></span>
971+ </div>` ) . join ( "" ) ;
972+ }
973+
966974function renderPodium ( ) {
967975 const top3 = state . podium . slice ( 0 , 3 ) ;
968976 const el = $ ( "#podium" ) ;
@@ -1012,6 +1020,9 @@ function renderPodium() {
10121020 </div>` ;
10131021
10141022 applyAvatar ( div . querySelector ( ".pod-avatar" ) , r . uid , initials ( r . username ) ) ;
1023+ div . tabIndex = 0 ;
1024+ div . setAttribute ( "role" , "button" ) ;
1025+ div . setAttribute ( "aria-label" , `${ r . username } , rank ${ place } ` ) ;
10151026 div . addEventListener ( "click" , ( ) => openUserModal ( r . username ) ) ;
10161027 div . addEventListener ( "keydown" , ( e ) => {
10171028 if ( e . key === "Enter" || e . key === " " ) { e . preventDefault ( ) ; openUserModal ( r . username ) ; }
@@ -1133,6 +1144,26 @@ const cellsHtml = (cells, r) =>
11331144 } )
11341145 . join ( "" ) ;
11351146
1147+ let _modalReturnFocus = null ;
1148+ // Keep Tab inside an open modal and hand focus back to the trigger on close.
1149+ function trapModalFocus ( modal ) {
1150+ _modalReturnFocus = document . activeElement ;
1151+ modal . _trap = ( e ) => {
1152+ if ( e . key !== "Tab" ) return ;
1153+ const f = modal . querySelectorAll ( 'a[href],button:not([disabled]),input,select,textarea,[tabindex]:not([tabindex="-1"])' ) ;
1154+ if ( ! f . length ) return ;
1155+ const first = f [ 0 ] , last = f [ f . length - 1 ] ;
1156+ if ( e . shiftKey && document . activeElement === first ) { e . preventDefault ( ) ; last . focus ( ) ; }
1157+ else if ( ! e . shiftKey && document . activeElement === last ) { e . preventDefault ( ) ; first . focus ( ) ; }
1158+ } ;
1159+ modal . addEventListener ( "keydown" , modal . _trap ) ;
1160+ }
1161+ function releaseModalFocus ( modal ) {
1162+ if ( modal . _trap ) { modal . removeEventListener ( "keydown" , modal . _trap ) ; modal . _trap = null ; }
1163+ _modalReturnFocus ?. focus ?. ( ) ;
1164+ _modalReturnFocus = null ;
1165+ }
1166+
11361167function openUserModal ( username ) {
11371168 // Podium holds the global top 3, which may not be on the current leaderboard page: search both.
11381169 const r =
@@ -1201,13 +1232,15 @@ function openUserModal(username) {
12011232 document . body . style . overflow = "hidden" ;
12021233 refreshIcons ( modal ) ;
12031234 $ ( "#user-modal-close" ) . focus ( ) ;
1235+ trapModalFocus ( modal ) ;
12041236}
12051237
12061238function closeUserModal ( ) {
12071239 const m = $ ( "#user-modal" ) ;
12081240 m . hidden = true ;
12091241 m . classList . remove ( "open" ) ;
12101242 document . body . style . overflow = "" ;
1243+ releaseModalFocus ( m ) ;
12111244}
12121245
12131246function renderTable ( ) {
@@ -1307,19 +1340,24 @@ $("#search").addEventListener("input", (e) => {
13071340 // Search is server-side across all users -> refetch a fresh batch.
13081341 searchTimer = setTimeout ( ( ) => loadLeaderboardPage ( false , true ) , 350 ) ;
13091342} ) ;
1310- $$ ( "th.sortable" ) . forEach (
1311- ( th ) => ( th . onclick = ( ) => {
1343+ $$ ( "th.sortable" ) . forEach ( ( th ) => {
1344+ const doSort = ( ) => {
13121345 if ( ! state . hashtags . length ) return ;
13131346 const k = th . dataset . sort ;
13141347 if ( state . sort . key === k )
13151348 state . sort . dir = state . sort . dir === "asc" ? "desc" : "asc" ;
13161349 else { state . sort . key = k ; state . sort . dir = k === "username" ? "asc" : "desc" ; }
13171350 state . page = 1 ;
13181351 writeURL ( ) ;
1319- // Sorting is server-side across all users -> refetch a fresh batch.
1320- loadLeaderboardPage ( false , true ) ;
1321- } )
1322- ) ;
1352+ loadLeaderboardPage ( false , true ) ; // server-side sort across all users -> refetch a fresh batch
1353+ } ;
1354+ th . tabIndex = 0 ;
1355+ th . setAttribute ( "role" , "button" ) ;
1356+ th . onclick = doSort ;
1357+ th . addEventListener ( "keydown" , ( e ) => {
1358+ if ( e . key === "Enter" || e . key === " " ) { e . preventDefault ( ) ; doSort ( ) ; }
1359+ } ) ;
1360+ } ) ;
13231361
13241362// Overview tiles read compact (2.3M) by default; clicking a tile flips its numbers to the exact value.
13251363$ ( "#overview" ) . addEventListener ( "click" , ( e ) => {
@@ -1581,7 +1619,7 @@ function readURL() {
15811619 if ( tags . length )
15821620 state . hashtags = [ ...new Set ( tags . map ( ( t ) => t . replace ( / ^ # / , "" ) . toLowerCase ( ) ) ) ] ;
15831621 const ps = parseInt ( p . get ( "size" ) || "" , 10 ) ;
1584- if ( [ 10 , 25 , 50 , 100 ] . includes ( ps ) ) {
1622+ if ( [ 10 , 20 , 50 ] . includes ( ps ) ) {
15851623 state . pageSize = ps ;
15861624 $ ( "#pg-size" ) . value = String ( ps ) ;
15871625 }
@@ -1594,7 +1632,7 @@ function writeURL() {
15941632 p . set ( "end" , isoUTC ( state . customEnd ) ) ;
15951633 }
15961634 state . hashtags . forEach ( ( h ) => p . append ( "hashtag" , h ) ) ;
1597- if ( state . pageSize !== 25 ) p . set ( "size" , String ( state . pageSize ) ) ;
1635+ if ( state . pageSize !== 10 ) p . set ( "size" , String ( state . pageSize ) ) ;
15981636 history . replaceState ( null , "" , `${ location . pathname } ?${ p } ` ) ;
15991637}
16001638
@@ -1663,13 +1701,15 @@ function openMethodology() {
16631701 document . body . style . overflow = "hidden" ;
16641702 refreshIcons ( mthModal ) ;
16651703 $ ( "#methodology-close" ) ?. focus ( ) ;
1704+ trapModalFocus ( mthModal ) ;
16661705 if ( location . hash !== "#methodology" ) history . replaceState ( null , "" , "#methodology" ) ;
16671706}
16681707function closeMethodology ( ) {
16691708 if ( ! mthModal ) return ;
16701709 mthModal . hidden = true ;
16711710 mthModal . classList . remove ( "open" ) ;
16721711 document . body . style . overflow = "" ;
1712+ releaseModalFocus ( mthModal ) ;
16731713 if ( location . hash === "#methodology" ) history . replaceState ( null , "" , location . pathname + location . search ) ;
16741714}
16751715$ ( "#methodology-link" ) ?. addEventListener ( "click" , ( e ) => { e . preventDefault ( ) ; openMethodology ( ) ; } ) ;
@@ -1680,15 +1720,8 @@ window.addEventListener("hashchange", () => { if (location.hash === "#methodolog
16801720if ( location . hash === "#methodology" ) openMethodology ( ) ;
16811721
16821722function boot ( ) {
1683- const swaggerURL = `${ API_BASE } /docs/swagger` ;
1684- const apiLink = $ ( "#api-link" ) ;
1685- if ( apiLink ) {
1686- apiLink . href = swaggerURL ;
1687- const host = $ ( "#api-host" ) ;
1688- if ( host ) host . textContent = new URL ( API_BASE ) . host ;
1689- }
16901723 const apiDocsLink = $ ( "#api-docs-link" ) ;
1691- if ( apiDocsLink ) apiDocsLink . href = swaggerURL ;
1724+ if ( apiDocsLink ) apiDocsLink . href = ` ${ API_BASE } /docs/swagger` ;
16921725 readURL ( ) ;
16931726 renderChips ( ) ;
16941727 renderRecentSearches ( ) ;
0 commit comments