diff --git a/src/assets/data/countries.csv b/src/assets/data/countries.csv index c1b4dd7..251dbdf 100644 --- a/src/assets/data/countries.csv +++ b/src/assets/data/countries.csv @@ -1,210 +1,210 @@ -id,names,tags -4,Afghanistan,Afghan -8,Albania,Albanian -12,Algeria,Algerian -20,Andorra,Andorran -24,Angola,Angolan -28,Antigua and Barbuda,Antiguan -31,Azerbaijan,Azerbaijani -32,Argentina,Argentinian -36,Australia,Australian -40,Austria,Austrian -44,Bahamas,Bahamian -48,Bahrain,Bahraini -50,Bangladesh,Bangladeshi -51,Armenia,Armenian -52,Barbados,Barbadian -56,Belgium,Belgian -64,Bhutan,Bhutanese -68,Bolivia,Bolivian -70,Bosnia-Herzegovina,Bosnian -72,Botswana,Botswanan -76,Brazil|Brasil,Brazilian -84,Belize,Belizean -90,Solomon Islands, -100,Bulgaria,Bulgarian -108,Burundi,Burundian -112,Belarus,Belarusian -116,Cambodia,Cambodian -120,Cameroon,Cameroonian -124,Canada,Canadian -132,Cape Verde,Cape Verdean -144,Sri Lanka,Sri Lankan -148,Chad,Chadian -152,Chile,Chilean -156,China,Chinese -158,Taiwan,Taiwanese -170,Colombia,Colombian -174,Comoros,Comoran -178,Congo-Brazzaville,Congolese -180,DR Congo,Congolese -184,Cook Islands,Cook Islander -188,Costa Rica,Costa Rican -192,Cuba,Cuban -196,Cyprus,Cypriot -203,Czech Republic,Czech -204,Benin,Beninese -208,Denmark,Danish -212,Dominica,Dominican -214,Dominican Republic,Dominican -218,Ecuador,Ecuadorean -222,El Salvador,Salvadorean -231,Ethiopia,Ethiopian -232,Eritrea,Eritrean -233,Estonia,Estonian -410,South Korea,Korean -242,Fiji,Fijian -246,Finland,Finnish -250,France,French -262,Djibouti,Djiboutian -266,Gabon,Gabonese -268,Georgia,Georgian -270,Gambia,Gambian -276,Germany,German -288,Ghana,Ghanaian -300,Greece,Greek -308,Grenada,Grenadian -320,Guatemala,Guatemalan -324,Guinea,Guinean -328,Guyana,Guyanese -332,Haiti,Haitian -340,Honduras,Honduran -348,Hungary,Hungarian -352,Iceland,Icelandic -356,India,Indian -360,Indonesia,Indonesian -364,Iran,Iranian -368,Iraq,Iraqi -372,Ireland,Irish -380,Italy,Italian -191,Croatia,Croatian -388,Jamaica,Jamaican -392,Japan,Japanese -398,Kazakhstan,Kazakh -400,Jordan,Jordanian -404,Kenya,Kenyan -414,Kuwait,Kuwaiti -418,Laos,Laotian -422,Lebanon,Lebanese -428,Latvia,Latvian -430,Liberia,Liberian -434,Libya,Libyan -438,Liechtenstein, -440,Lithuania,Lithuanian -442,Luxembourg, -807,Macedonia,Macedonian -450,Madagascar,Madagascan -454,Malawi,Malawian -458,Malaysia,Malaysian -462,Maldives,Maldivian -466,Mali,Malian -470,Malta,Maltese -478,Mauritania,Mauritanian -480,Mauritius,Mauritian -484,Mexico,Mexican -492,Monaco,Monacan -496,Mongolia,Mongolian -499,Montenegro,Montenegrin -504,Morocco,Moroccan -508,Mozambique,Mozambican -512,Oman,Omani -516,Namibia,Namibian -520,Nauru,Nauruan -524,Nepal,Nepalese -528,Netherlands,Dutch -548,Vanuatu,Vanuatuan -554,New Zealand,New Zealand -558,Nicaragua,Nicaraguan -562,Niger,Nigerien -566,Nigeria,Nigerian -408,North Korea,North Korean -570,Niue,Niuean -578,Norway|Norge,Norwegian -840,United States|US|USA,American -498,Moldova,Moldovan -583,Federated States of Micronesia,Micronesian -584,Marshall Islands,Marshallese -296,Kiribati,Kiribatian -585,Palau,Palauan -586,Pakistan,Pakistani -591,Panama,Panamanian -598,Papua New Guinea,Papa New Guinean -600,Paraguay,Paraguayan -604,Peru,Peruvian -616,Poland,Polish -620,Portugal,Portuguese -634,Qatar,Qatari -642,Romania,Romanian -643,Russia,Russian -646,Rwanda,Rwandan -659,Saint Kitts and Nevis,Kittitian -662,Saint Lucia,Saint Lucian -670,Saint Vincent and the Grenadines,Vincentian -682,Saudi Arabia,Saudi -686,Senegal,Senegalese -688,Serbia,Serbian -690,Seychelles,Seychellois -694,Sierra Leone,Sierra Leonean -702,Singapore,Singaporean -703,Slovakia,Slovak -704,Vietnam,Vietnamese -705,Slovenia,Slovenian -706,Somalia,Somali -710,South Africa,South African -716,Zimbabwe,Zimbabwean -724,Spain,Spanish -882,Samoa,Samoan -729,Sudan,Sudanese -740,Suriname,Surinamese -748,Eswatini,Swazi -752,Sweden|Sverige,Swedish|Svensk -756,Switzerland,Swiss -760,Syria,Syrian -762,Tajikistan,Tajik -776,Tonga,Tongan -834,Tanzania,Tanzanian -608,Philippines,Philippine -764,Thailand,Thai -768,Togo,Togolese -780,Trinidad and Tobago,Trinidadian -784,United Arab Emirates,Emirati -788,Tunisia,Tunisian -792,Turkey,Turkish -795,Turkmenistan,Turkmen -798,Tuvalu,Tuvaluan -800,Uganda,Ugandan -804,Ukraine,Ukrainian -818,Egypt,Egyptian -826,United Kingdom|UK|England|Wales|Scotland,British|English|Welsh|Scottish -854,Burkina Faso,Burkinese|Burkinabe -104,Myanmar|Burma,Burmese -858,Uruguay,Uruguayan -860,Uzbekistan,Uzbek -862,Venezuela,Venezuelan -887,Yemen,Yemeni -894,Zambia,Zambian -426,Lesotho,Lesothian -140,Central African Republic,Central African -728,South Sudan,South Sudanese -384,Cote d'Ivoire|Cote Divoire,Ivorian|Ivory Coast -678,Sao Tome and Principe,Sao Tomean -226,Equatorial Guinea,Equatoguinean -304,Greenland,Greenlandic -10,Antarctica,Antarctican -732,Western Sahara,West Saharan -624,Guinea-Bissau,Guinea-Bissauan -376,Israel,Israeli -417,Kyrgyzstan,Kyrgyz -275,Palestine,Palestinian -630,Puerto Rico,Puerto Rican -248,Aland,Alandish -626,Timor-Leste,Timorese -234,Faroe Islands,Faroese -344,Hong Kong,Hongkongese -96,Brunei,Bruneian -904,Kosovo,Kosovan -901,North Cyprus,Turkish Cypriot -905,Somaliland,Somali -540,New Caledonia,New Caledonian +id,names,tags,continent +4,Afghanistan,Afghan,Asia +8,Albania,Albanian,Europe +12,Algeria,Algerian,Africa +20,Andorra,Andorran,Europe +24,Angola,Angolan,Africa +28,Antigua and Barbuda,Antiguan,North America +31,Azerbaijan,Azerbaijani,Asia +32,Argentina,Argentinian,South America +36,Australia,Australian,Oceania +40,Austria,Austrian,Europe +44,Bahamas,Bahamian,North America +48,Bahrain,Bahraini,Asia +50,Bangladesh,Bangladeshi,Asia +51,Armenia,Armenian,Asia +52,Barbados,Barbadian,North America +56,Belgium,Belgian,Europe +64,Bhutan,Bhutanese,Asia +68,Bolivia,Bolivian,South America +70,Bosnia-Herzegovina,Bosnian,Europe +72,Botswana,Botswanan,Africa +76,Brazil|Brasil,Brazilian,South America +84,Belize,Belizean,North America +90,Solomon Islands,,Oceania +100,Bulgaria,Bulgarian,Europe +108,Burundi,Burundian,Africa +112,Belarus,Belarusian,Europe +116,Cambodia,Cambodian,Asia +120,Cameroon,Cameroonian,Africa +124,Canada,Canadian,North America +132,Cape Verde,Cape Verdean,Africa +144,Sri Lanka,Sri Lankan,Asia +148,Chad,Chadian,Africa +152,Chile,Chilean,South America +156,China,Chinese,Asia +158,Taiwan,Taiwanese,Asia +170,Colombia,Colombian,South America +174,Comoros,Comoran,Africa +178,Congo-Brazzaville,Congolese,Africa +180,DR Congo,Congolese,Africa +184,Cook Islands,Cook Islander,Oceania +188,Costa Rica,Costa Rican,North America +192,Cuba,Cuban,North America +196,Cyprus,Cypriot,Asia +203,Czech Republic,Czech,Europe +204,Benin,Beninese,Africa +208,Denmark,Danish,Europe +212,Dominica,Dominican,North America +214,Dominican Republic,Dominican,North America +218,Ecuador,Ecuadorean,South America +222,El Salvador,Salvadorean,North America +231,Ethiopia,Ethiopian,Africa +232,Eritrea,Eritrean,Africa +233,Estonia,Estonian,Europe +410,South Korea,Korean,Asia +242,Fiji,Fijian,Oceania +246,Finland,Finnish,Europe +250,France,French,Europe +262,Djibouti,Djiboutian,Africa +266,Gabon,Gabonese,Africa +268,Georgia,Georgian,Asia +270,Gambia,Gambian,Africa +276,Germany,German,Europe +288,Ghana,Ghanaian,Africa +300,Greece,Greek,Europe +308,Grenada,Grenadian,North America +320,Guatemala,Guatemalan,North America +324,Guinea,Guinean,Africa +328,Guyana,Guyanese,South America +332,Haiti,Haitian,North America +340,Honduras,Honduran,North America +348,Hungary,Hungarian,Europe +352,Iceland,Icelandic,Europe +356,India,Indian,Asia +360,Indonesia,Indonesian,Asia +364,Iran,Iranian,Asia +368,Iraq,Iraqi,Asia +372,Ireland,Irish,Europe +380,Italy,Italian,Europe +191,Croatia,Croatian,Europe +388,Jamaica,Jamaican,North America +392,Japan,Japanese,Asia +398,Kazakhstan,Kazakh,Asia +400,Jordan,Jordanian,Asia +404,Kenya,Kenyan,Africa +414,Kuwait,Kuwaiti,Asia +418,Laos,Laotian,Asia +422,Lebanon,Lebanese,Asia +428,Latvia,Latvian,Europe +430,Liberia,Liberian,Africa +434,Libya,Libyan,Africa +438,Liechtenstein,,Europe +440,Lithuania,Lithuanian,Europe +442,Luxembourg,,Europe +807,Macedonia,Macedonian,Europe +450,Madagascar,Madagascan,Africa +454,Malawi,Malawian,Africa +458,Malaysia,Malaysian,Asia +462,Maldives,Maldivian,Asia +466,Mali,Malian,Africa +470,Malta,Maltese,Europe +478,Mauritania,Mauritanian,Africa +480,Mauritius,Mauritian,Africa +484,Mexico,Mexican,North America +492,Monaco,Monacan,Europe +496,Mongolia,Mongolian,Asia +499,Montenegro,Montenegrin,Europe +504,Morocco,Moroccan,Africa +508,Mozambique,Mozambican,Africa +512,Oman,Omani,Asia +516,Namibia,Namibian,Africa +520,Nauru,Nauruan,Oceania +524,Nepal,Nepalese,Asia +528,Netherlands,Dutch,Europe +548,Vanuatu,Vanuatuan,Oceania +554,New Zealand,New Zealand,Oceania +558,Nicaragua,Nicaraguan,North America +562,Niger,Nigerien,Africa +566,Nigeria,Nigerian,Africa +408,North Korea,North Korean,Asia +570,Niue,Niuean,Oceania +578,Norway|Norge,Norwegian,Europe +840,United States|US|USA,American,North America +498,Moldova,Moldovan,Europe +583,Federated States of Micronesia,Micronesian,Oceania +584,Marshall Islands,Marshallese,Oceania +296,Kiribati,Kiribatian,Oceania +585,Palau,Palauan,Oceania +586,Pakistan,Pakistani,Asia +591,Panama,Panamanian,North America +598,Papua New Guinea,Papa New Guinean,Oceania +600,Paraguay,Paraguayan,South America +604,Peru,Peruvian,South America +616,Poland,Polish,Europe +620,Portugal,Portuguese,Europe +634,Qatar,Qatari,Asia +642,Romania,Romanian,Europe +643,Russia,Russian,Europe +646,Rwanda,Rwandan,Africa +659,Saint Kitts and Nevis,Kittitian,North America +662,Saint Lucia,Saint Lucian,North America +670,Saint Vincent and the Grenadines,Vincentian,North America +682,Saudi Arabia,Saudi,Asia +686,Senegal,Senegalese,Africa +688,Serbia,Serbian,Europe +690,Seychelles,Seychellois,Africa +694,Sierra Leone,Sierra Leonean,Africa +702,Singapore,Singaporean,Asia +703,Slovakia,Slovak,Europe +704,Vietnam,Vietnamese,Asia +705,Slovenia,Slovenian,Europe +706,Somalia,Somali,Africa +710,South Africa,South African,Africa +716,Zimbabwe,Zimbabwean,Africa +724,Spain,Spanish,Europe +882,Samoa,Samoan,Oceania +729,Sudan,Sudanese,Africa +740,Suriname,Surinamese,South America +748,Eswatini,Swazi,Africa +752,Sweden|Sverige,Swedish|Svensk,Europe +756,Switzerland,Swiss,Europe +760,Syria,Syrian,Asia +762,Tajikistan,Tajik,Asia +776,Tonga,Tongan,Oceania +834,Tanzania,Tanzanian,Africa +608,Philippines,Philippine,Asia +764,Thailand,Thai,Asia +768,Togo,Togolese,Africa +780,Trinidad and Tobago,Trinidadian,North America +784,United Arab Emirates,Emirati,Asia +788,Tunisia,Tunisian,Africa +792,Turkey,Turkish,Asia +795,Turkmenistan,Turkmen,Asia +798,Tuvalu,Tuvaluan,Oceania +800,Uganda,Ugandan,Africa +804,Ukraine,Ukrainian,Europe +818,Egypt,Egyptian,Africa +826,United Kingdom|UK|England|Wales|Scotland,British|English|Welsh|Scottish,Europe +854,Burkina Faso,Burkinese|Burkinabe,Africa +104,Myanmar|Burma,Burmese,Asia +858,Uruguay,Uruguayan,South America +860,Uzbekistan,Uzbek,Asia +862,Venezuela,Venezuelan,South America +887,Yemen,Yemeni,Asia +894,Zambia,Zambian,Africa +426,Lesotho,Lesothian,Africa +140,Central African Republic,Central African,Africa +728,South Sudan,South Sudanese,Africa +384,Cote d'Ivoire|Cote Divoire,Ivorian|Ivory Coast,Africa +678,Sao Tome and Principe,Sao Tomean,Africa +226,Equatorial Guinea,Equatoguinean,Africa +304,Greenland,Greenlandic,North America +10,Antarctica,Antarctican,Antarctica +732,Western Sahara,West Saharan,Africa +624,Guinea-Bissau,Guinea-Bissauan,Africa +376,Israel,Israeli,Asia +417,Kyrgyzstan,Kyrgyz,Asia +275,Palestine,Palestinian,Asia +630,Puerto Rico,Puerto Rican,North America +248,Aland,Alandish,Europe +626,Timor-Leste,Timorese,Asia +234,Faroe Islands,Faroese,Europe +344,Hong Kong,Hongkongese,Asia +96,Brunei,Bruneian,Asia +904,Kosovo,Kosovan,Europe +901,North Cyprus,Turkish Cypriot,Asia +905,Somaliland,Somali,Africa +540,New Caledonia,New Caledonian,Oceania 260,French Southern Territories, -674,San Marino,Sammarinese +674,San Marino,Sammarinese,Europe diff --git a/src/assets/js/api/api.js b/src/assets/js/api/api.js index 2499649..2526d91 100644 --- a/src/assets/js/api/api.js +++ b/src/assets/js/api/api.js @@ -27,6 +27,7 @@ var superCount = 0; d.mainName = d.names[0]; d.tag = d.tags[0]; d.name = d.mainName; + d.continent = d.continent || ''; }); res(data); diff --git a/src/assets/js/auditory-feedback.js b/src/assets/js/auditory-feedback.js index 301519f..4908e49 100644 --- a/src/assets/js/auditory-feedback.js +++ b/src/assets/js/auditory-feedback.js @@ -34,6 +34,10 @@ const auditoryFeedback = (function() { document.addEventListener('keydown', function(e) { // Toggle audio feedback with 'A' key if (e.key.toLowerCase() === 'a' && !e.repeat) { + // Don't trigger if we're in an input field or if keyboard mode is not active + if (e.target.tagName === "INPUT" || !window.keyboardMode || !window.keyboardMode.isActive()) { + return; + } toggleAudioFeedback(); } diff --git a/src/assets/js/country-list.js b/src/assets/js/country-list.js new file mode 100644 index 0000000..69e8aa6 --- /dev/null +++ b/src/assets/js/country-list.js @@ -0,0 +1,272 @@ +// Country List Dialog Logic with Accessible Tabs +(function() { + const button = document.getElementById('country-list-button'); + const dialog = document.getElementById('country-list-dialog'); + const closeBtn = dialog.querySelector('.close'); + const continentsDiv = dialog.querySelector('.country-list__continents'); + + const CONTINENTS = ['All','Europe','North America','South America','Asia','Africa','Oceania','Antarctica','Other']; + + // Helper: get scrobbles for a country + function getCountryScrobbles(country) { + if (!country || !country.id) return 0; + if (!window.countryCountObj || !window.countryCountObj[country.id]) return 0; + let total = 0; + Object.values(window.countryCountObj[country.id]).forEach(artistArr => { + artistArr.forEach(a => { total += a.playcount || 0; }); + }); + return total; + } + + // Helper: get number of artists for a country + function getCountryArtistCount(country) { + if (!country || !country.id) return 0; + if (!window.countryCountObj || !window.countryCountObj[country.id]) return 0; + return Object.values(window.countryCountObj[country.id]).flat().length; + } + + // Helper: group countries by continent + function groupByContinent(countries) { + const result = {}; + countries.forEach(c => { + const cont = c.continent || 'Other'; + if (!result[cont]) result[cont] = []; + result[cont].push(c); + }); + return result; + } + + // Helper: sort countries by number of artists + function sortCountriesByArtists(countries) { + return countries.slice().sort((a, b) => getCountryArtistCount(b) - getCountryArtistCount(a)); + } + + // Helper: sort countries alphabetically + function sortCountriesAlpha(countries) { + return countries.slice().sort((a, b) => a.name.localeCompare(b.name)); + } + + // Helper: sort countries by number of scrobbles (existing) + function sortCountriesByScrobbles(countries) { + return countries.slice().sort((a, b) => getCountryScrobbles(b) - getCountryScrobbles(a)); + } + + // Store sort order (persist while dialog is open) + let currentSort = 'artists'; // 'artists', 'scrobbles', 'alpha' + let lastFocusedTabIndex = 0; + let tabRefs = []; + let panelRefs = []; + let tablistRef = null; + + // Create sort select element + function createSortSelect(onChange) { + const select = document.createElement('select'); + select.className = 'country-sort-select'; + select.setAttribute('aria-label', 'Sort countries'); + [ + { value: 'artists', label: 'Most artists' }, + { value: 'scrobbles', label: 'Most scrobbles' }, + { value: 'alpha', label: 'A–Z' } + ].forEach(opt => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + if (opt.value === currentSort) option.selected = true; + select.appendChild(option); + }); + select.addEventListener('change', e => { + currentSort = select.value; + onChange(); + }); + return select; + } + + // Main renderTabs logic + function renderTabs(grouped, selectedIdx = 0, focusTabIndex = null, scrollDirection = 'center') { + // Only render tablist and panels once + if (!tablistRef) { + tablistRef = document.createElement('div'); + tablistRef.setAttribute('role', 'tablist'); + tablistRef.setAttribute('aria-label', 'Continents'); + tablistRef.className = 'country-tabs'; + tabRefs = []; + CONTINENTS.forEach((cont, i) => { + if (cont !== 'All' && !grouped[cont]) return; + const tab = document.createElement('button'); + tab.setAttribute('role', 'tab'); + tab.setAttribute('id', `tab-${cont}`); + tab.setAttribute('aria-controls', `tabpanel-${cont}`); + tab.className = 'country-tab'; + tab.textContent = cont === 'All' ? 'All' : cont; + tab.addEventListener('click', () => activateTab(i)); + tab.addEventListener('keydown', e => handleTabKeydown(e, i)); + tabRefs.push(tab); + tablistRef.appendChild(tab); + }); + continentsDiv.appendChild(tablistRef); + // Panels + panelRefs = []; + CONTINENTS.forEach((cont, i) => { + if (cont !== 'All' && !grouped[cont]) return; + const panel = document.createElement('div'); + panel.setAttribute('role', 'tabpanel'); + panel.setAttribute('id', `tabpanel-${cont}`); + panel.setAttribute('aria-labelledby', `tab-${cont}`); + panel.className = 'country-tabpanel'; + panelRefs.push(panel); + continentsDiv.appendChild(panel); + }); + } + // Update tabs and panels + tabRefs.forEach((tab, i) => { + tab.setAttribute('aria-selected', i === selectedIdx ? 'true' : 'false'); + tab.setAttribute('tabindex', i === selectedIdx ? '0' : '-1'); + if (i === selectedIdx && focusTabIndex !== null) { + setTimeout(() => { + tab.focus(); + }, 0); + } + }); + panelRefs.forEach((panel, i) => { + panel.hidden = i !== selectedIdx; + if (i === selectedIdx) { + // Render panel content + panel.innerHTML = ''; + const cont = CONTINENTS[i]; + // Heading row: h2 and sort select + const headingRow = document.createElement('div'); + headingRow.className = 'country-tabpanel-heading-row'; + const heading = document.createElement('h2'); + heading.className = 'country-tabpanel-heading'; + heading.textContent = cont === 'All' ? 'All countries' : cont; + headingRow.appendChild(heading); + // Sort select + const sortContainer = document.createElement('div'); + sortContainer.className = 'country-sort-container'; + const sortLabel = document.createElement('span'); + sortLabel.className = 'country-sort-label'; + sortLabel.textContent = 'Sort'; + sortContainer.appendChild(sortLabel); + const sortSelect = createSortSelect(() => { + renderTabs(grouped, i, i, 'center'); + }); + sortContainer.appendChild(sortSelect); + headingRow.appendChild(sortContainer); + panel.appendChild(headingRow); + // Country list + const countryList = document.createElement('ul'); + countryList.className = 'country-list'; + let countriesToShow; + if (cont === 'All') { + countriesToShow = Object.values(grouped).flat(); + } else { + countriesToShow = grouped[cont]; + } + if (currentSort === 'artists') { + countriesToShow = sortCountriesByArtists(countriesToShow); + } else if (currentSort === 'scrobbles') { + countriesToShow = sortCountriesByScrobbles(countriesToShow); + } else { + countriesToShow = sortCountriesAlpha(countriesToShow); + } + countriesToShow.forEach(country => { + const li = document.createElement('li'); + li.className = 'country-list__country'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'country-list__country-btn'; + // Country name + const nameSpan = document.createElement('span'); + nameSpan.className = 'country-list__country-name'; + nameSpan.textContent = country.name; + // Secondary text: artist count or scrobbles + const countSpan = document.createElement('span'); + countSpan.className = 'country-list__country-count'; + if (currentSort === 'scrobbles') { + const nScrobbles = getCountryScrobbles(country); + countSpan.textContent = nScrobbles.toLocaleString() + ' scrobbles'; + } else { + const nArtists = getCountryArtistCount(country); + countSpan.textContent = nArtists + ' artists'; + } + btn.appendChild(nameSpan); + btn.appendChild(countSpan); + btn.onclick = function() { + dialog.close(); + setTimeout(() => { + const el = document.getElementById('c'+country.id); + if (el) el.dispatchEvent(new Event('click')); + }, 100); + }; + li.appendChild(btn); + countryList.appendChild(li); + }); + panel.appendChild(countryList); + } else { + panel.innerHTML = ''; + } + }); + } + + // Tab activation logic + function activateTab(idx) { + let scrollDirection = 'center'; + if (typeof lastFocusedTabIndex === 'number') { + if (idx > lastFocusedTabIndex) scrollDirection = 'end'; + else if (idx < lastFocusedTabIndex) scrollDirection = 'start'; + } + renderTabs(window.map && window.map.countryNames ? groupByContinent(window.map.countryNames) : {}, idx, idx, scrollDirection); + lastFocusedTabIndex = idx; + } + + // Keyboard navigation for tabs + function handleTabKeydown(e, idx) { + let newIdx = idx; + if (e.key === 'ArrowRight') { + do { newIdx = (newIdx + 1) % tabRefs.length; } while (!tabRefs[newIdx]); + tabRefs[newIdx].focus(); + e.preventDefault(); + } else if (e.key === 'ArrowLeft') { + do { newIdx = (newIdx - 1 + tabRefs.length) % tabRefs.length; } while (!tabRefs[newIdx]); + tabRefs[newIdx].focus(); + e.preventDefault(); + } else if (e.key === 'Home') { + tabRefs[0].focus(); + e.preventDefault(); + } else if (e.key === 'End') { + tabRefs[tabRefs.length - 1].focus(); + e.preventDefault(); + } else if (e.key === 'Enter' || e.key === ' ') { + activateTab(idx); + e.preventDefault(); + } + } + + // Open dialog + button.addEventListener('click', function() { + if (!window.map || !window.map.countryNames) return; + const grouped = groupByContinent(window.map.countryNames); + lastFocusedTabIndex = 0; + tabRefs = []; + panelRefs = []; + tablistRef = null; + continentsDiv.innerHTML = ''; + renderTabs(grouped, 0, 0, 'center'); + dialog.showModal(); + setTimeout(() => dialog.querySelector('h1').focus(), 100); + }); + // Close dialog + closeBtn.addEventListener('click', function() { + dialog.close(); + button.focus(); + currentSort = 'artists'; + }); + // ESC closes dialog + dialog.addEventListener('keydown', function(e) { + if (e.key === 'Escape') { + dialog.close(); + button.focus(); + currentSort = 'artists'; + } + }); +})(); \ No newline at end of file diff --git a/src/assets/js/keyboard-mode.js b/src/assets/js/keyboard-mode.js index 820fef7..50eadab 100644 --- a/src/assets/js/keyboard-mode.js +++ b/src/assets/js/keyboard-mode.js @@ -8,8 +8,8 @@ const keyboardMode = keyboardMode || {}; const MIN_ZOOM_LEVEL_FOR_KEYBOARD_MODE = 7; const MAX_COUNTRY_SUGGESTIONS = 20; let KEYBOARD_MODE_ACTIVE = false; -// Exclude A, L and H because they are used for other purposes -const ALPHABET = 'BCDEFGIJKMNOPQRSTUVWXYZ'.split(''); +// Use numbers 1–99 for country shortcuts +const NUMBER_POOL = Array.from({length: 99}, (_, i) => (i + 1).toString()); let visibleCountries = []; let keyBuffer = ''; @@ -27,38 +27,79 @@ const EXCLUDED_COUNTRY_IDS = [ 796, // Turks and Caicos Islands ]; -// Add a map to store country-to-letter assignments -let countryLetterMap = {}; +// Add a map to store country-to-number assignments +let countryNumberMap = {}; -const handleLetterKeyPress = (e) => { - // Check if user has pressed a letter key from A to Z - if (e.key.match(/[a-zA-Z]/) && e.target.tagName !== "INPUT") { - // Check if it's a single key press with no modifier keys - if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) { +// Buffer for multi-digit number input +let numberBuffer = ''; +let numberBufferTimer = null; +const NUMBER_BUFFER_TIMEOUT = 1000; // ms + +const handleNumberKeyPress = (e) => { + // Only handle number keys, and not if in an input + if (!e.key.match(/^[0-9]$/) || e.target.tagName === "INPUT") return; + if (e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return; + + numberBuffer += e.key; + // Remove leading zeros + numberBuffer = numberBuffer.replace(/^0+/, ''); + + // If buffer is empty or >2 digits, reset + if (!numberBuffer || numberBuffer.length > 2) { + numberBuffer = ''; + return; + } + + // If two digits, try to select immediately + if (numberBuffer.length === 2) { + const targetCountryId = Object.keys(countryNumberMap).find( + id => countryNumberMap[id] === numberBuffer + ); + if (targetCountryId) { + const targetCountry = visibleCountries.find(country => country.id === targetCountryId); + if (targetCountry) { + // Find the corresponding SVG country element using D3 + const svgCountry = d3.select(`#${targetCountry.id}`).node(); + if (svgCountry) { + svgCountry.dispatchEvent(new Event('click')); + keyboardMode.cleanup(); + } + ga('send', { + hitType: 'event', + eventCategory: 'Keyboard', + eventAction: 'Opened country', + eventLabel: 'test' + }); + } + numberBuffer = ''; + clearTimeout(numberBufferTimer); + numberBufferTimer = null; return; } - - // Convert the key to uppercase - const key = e.key.toUpperCase(); - - // Find the country with this letter - const targetCountryId = Object.keys(countryLetterMap).find( - id => countryLetterMap[id] === key + // If not valid, wait for timeout and then clear + clearTimeout(numberBufferTimer); + numberBufferTimer = setTimeout(() => { + numberBuffer = ''; + }, NUMBER_BUFFER_TIMEOUT); + return; + } + + // If single digit, check if any two-digit number starting with this digit is assigned + const hasTwoDigit = Object.values(countryNumberMap).some(num => num.length === 2 && num.startsWith(numberBuffer)); + if (!hasTwoDigit) { + // No possible two-digit, select immediately if valid + const targetCountryId = Object.keys(countryNumberMap).find( + id => countryNumberMap[id] === numberBuffer ); - if (targetCountryId) { - // Find the country element const targetCountry = visibleCountries.find(country => country.id === targetCountryId); - - // Generate a click on the target country if (targetCountry) { - targetCountry.dispatchEvent(new Event('click')); - // Focus the country name - setTimeout(() => { - document.querySelector('#cnameCont h1').setAttribute("tabindex", "-1"); - document.querySelector('#cnameCont h1').focus(); - }, 250); - + // Find the corresponding SVG country element using D3 + const svgCountry = d3.select(`#${targetCountry.id}`).node(); + if (svgCountry) { + svgCountry.dispatchEvent(new Event('click')); + keyboardMode.cleanup(); + } ga('send', { hitType: 'event', eventCategory: 'Keyboard', @@ -66,9 +107,41 @@ const handleLetterKeyPress = (e) => { eventLabel: 'test' }); } + numberBuffer = ''; + clearTimeout(numberBufferTimer); + numberBufferTimer = null; + return; } } -} + // Otherwise, wait for next digit or timeout + clearTimeout(numberBufferTimer); + numberBufferTimer = setTimeout(() => { + // If still single digit and valid, select now + if (numberBuffer.length === 1) { + const targetCountryId = Object.keys(countryNumberMap).find( + id => countryNumberMap[id] === numberBuffer + ); + if (targetCountryId) { + const targetCountry = visibleCountries.find(country => country.id === targetCountryId); + if (targetCountry) { + // Find the corresponding SVG country element using D3 + const svgCountry = d3.select(`#${targetCountry.id}`).node(); + if (svgCountry) { + svgCountry.dispatchEvent(new Event('click')); + keyboardMode.cleanup(); + } + ga('send', { + hitType: 'event', + eventCategory: 'Keyboard', + eventAction: 'Opened country', + eventLabel: 'test' + }); + } + } + } + numberBuffer = ''; + }, NUMBER_BUFFER_TIMEOUT); +}; function getCurrentlyVisibleCountries() { const userName = window.location.href.split("username=")[1]; @@ -90,7 +163,7 @@ function getCurrentlyVisibleCountries() { return; } - const letter = countryLetterMap[country.id]; + const number = countryNumberMap[country.id]; // Add null checks for data[countryId] and data[countryId][userName] const artistCount = data[countryId] && data[countryId][userName] ? @@ -101,7 +174,7 @@ function getCurrentlyVisibleCountries() { if (countryName && countryName !== "undefined") { formattedCountries.push({ name: countryName, - number: letter, + number: number, artistCount: artistCount, id: countryId }); @@ -212,7 +285,7 @@ function displayKeyboardModeMessage() { const message = document.getElementById("keyboard-mode-message"); message.classList.remove("hidden"); const innerMessage = document.createElement("div"); - innerMessage.innerHTML = "
Type a letter to select a country.
Move around with ←→↑↓ keys.
Exit by zooming out with - key.
Toggle audio feedback with A key.
"; + innerMessage.innerHTML = "Type a number to select a country.
Move around with ←→↑↓ keys.
Zoom out with - key.
Toggle audio feedback with A key.
"; message.appendChild(innerMessage); // Add the visual indicator for the 400x400 box @@ -253,22 +326,7 @@ function addViewportBoxIndicator() { indicator.style.pointerEvents = "none"; // Make sure it doesn't interfere with clicks indicator.style.zIndex = "1000"; // Make sure it's above the map but below other UI indicator.style.boxSizing = "border-box"; - - // Add a tooltip/label - const label = document.createElement("div"); - label.textContent = "Active Area"; - label.style.position = "absolute"; - label.style.top = "-25px"; - label.style.left = "50%"; - label.style.transform = "translateX(-50%)"; - label.style.backgroundColor = "rgba(0, 0, 0, 0.7)"; - label.style.color = "white"; - label.style.padding = "3px 8px"; - label.style.borderRadius = "3px"; - label.style.fontSize = "12px"; - - indicator.appendChild(label); - document.body.appendChild(indicator); + // Update position on window resize window.addEventListener('resize', updateViewportBoxPosition); @@ -370,16 +428,16 @@ function updateVisibleCountries(zoom) { document.querySelector(".no-countries").classList.add("hidden"); document.getElementById("friends").classList.add("hidden"); - // Assign letters to countries if they don't already have one - assignLettersToCountries(); + // Assign numbers to countries if they don't already have one + assignNumbersToCountries(); // display a number on the center of each country visibleCountries.forEach((country) => { - window.addEventListener('keydown', handleLetterKeyPress); + window.addEventListener('keydown', handleNumberKeyPress); var center = getPathCenter(country); const countryId = country.id; - const letter = countryLetterMap[countryId]; + const number = countryNumberMap[countryId]; // Append a circle d3.select(country.parentElement).append("rect") @@ -399,7 +457,7 @@ function updateVisibleCountries(zoom) { .attr("alignment-baseline", "middle") .attr("x", center.x) // position the text .attr("y", center.y + 0.2) // position the text - .text(letter); + .text(number); // Append a text for the country name d3.select(country.parentElement).append("text") @@ -414,35 +472,32 @@ function updateVisibleCountries(zoom) { } } -// New function to assign letters to countries -function assignLettersToCountries() { - // For any new countries that don't have a letter yet, assign them one +// New function to assign numbers to countries +function assignNumbersToCountries() { + // Try to keep previous assignments + const usedNumbers = new Set(); visibleCountries.forEach((country) => { const countryId = country.id; - - // If this country doesn't have a letter assigned yet - if (!countryLetterMap[countryId]) { - // Find the first available letter - for (let i = 0; i < ALPHABET.length; i++) { - const letter = ALPHABET[i]; - - // Check if this letter is already used - const isLetterUsed = Object.values(countryLetterMap).includes(letter); - - // If letter is not used, assign it to this country - if (!isLetterUsed) { - countryLetterMap[countryId] = letter; - break; - } + // If already assigned and not used, keep it + if (countryNumberMap[countryId] && !usedNumbers.has(countryNumberMap[countryId])) { + usedNumbers.add(countryNumberMap[countryId]); + return; + } + // Assign the first available number + for (let i = 0; i < NUMBER_POOL.length; i++) { + const num = NUMBER_POOL[i]; + if (!Object.values(countryNumberMap).includes(num) && !usedNumbers.has(num)) { + countryNumberMap[countryId] = num; + usedNumbers.add(num); + break; } } }); - - // Clean up letters for countries that are no longer visible - Object.keys(countryLetterMap).forEach(id => { + // Clean up numbers for countries that are no longer visible + Object.keys(countryNumberMap).forEach(id => { const isVisible = visibleCountries.some(country => country.id === id); if (!isVisible) { - delete countryLetterMap[id]; + delete countryNumberMap[id]; } }); } @@ -466,6 +521,10 @@ function getAnnouncementText(baseText) { // Set keyboard listeners for zoom and pan window.addEventListener('keydown', function(e) { + // If any dialog is open, do not process keyboard mode events + if (document.querySelector('dialog[open]')) { + return; + } const param = window.location.href.split("username=")[1]; @@ -531,7 +590,6 @@ function getAnnouncementText(baseText) { setTimeout(() => { const message = getAnnouncementText("Panning north"); announcer.announce(message, "assertive", 100); - console.log(message); }, 100); ga('send', { hitType: 'event', @@ -549,7 +607,6 @@ function getAnnouncementText(baseText) { setTimeout(() => { const message = getAnnouncementText("Panning south"); announcer.announce(message, "assertive", 100); - console.log(message); }, 100); ga('send', { hitType: 'event', @@ -567,7 +624,6 @@ function getAnnouncementText(baseText) { setTimeout(() => { const message = getAnnouncementText("Panning west"); announcer.announce(message, "assertive", 100); - console.log(message); }, 100); ga('send', { hitType: 'event', @@ -585,7 +641,6 @@ function getAnnouncementText(baseText) { setTimeout(() => { const message = getAnnouncementText("Panning east"); announcer.announce(message, "assertive", 100); - console.log(message); }, 100); ga('send', { hitType: 'event', @@ -619,7 +674,6 @@ function getAnnouncementText(baseText) { const baseMessage = `Zoom ${e.key === '+' ? "in" : "out"} level ${parseInt(newScale)}`; const message = getAnnouncementText(baseMessage); announcer.announce(message, "assertive", 100); - console.log(message); }, 100); ga('send', { hitType: 'event', @@ -631,7 +685,6 @@ function getAnnouncementText(baseText) { case 'h': // Help for screen reader users. Read out the contents of #a11y-info-text announcer.announce(document.getElementById("a11y-info-text").textContent, "polite"); - console.log(document.getElementById("a11y-info-text").textContent); ga('send', { hitType: 'event', eventCategory: 'Keyboard', @@ -655,14 +708,13 @@ function getAnnouncementText(baseText) { let message = ""; const countries = getCurrentlyVisibleCountries(); - // Sort countries by their assigned letter + // Sort countries by their assigned number countries.sort((a, b) => a.number.localeCompare(b.number)); countries.forEach((country) => { message += `${country.number}: ${country.name} (${country.artistCount} artists), `; }); announcer.announce(message, "assertive", 100); - console.log(message); ga('send', { hitType: 'event', eventCategory: 'Keyboard', @@ -700,7 +752,7 @@ function getAnnouncementText(baseText) { KEYBOARD_MODE_ACTIVE = false; // Reset the letter map when exiting keyboard mode completely if (keyboardMode.zoomBehavior && keyboardMode.zoomBehavior.scale() < MIN_ZOOM_LEVEL_FOR_KEYBOARD_MODE) { - countryLetterMap = {}; + countryNumberMap = {}; } d3.selectAll(".a11y-number").remove(); d3.selectAll(".a11y-number-bg").remove(); @@ -713,7 +765,7 @@ function getAnnouncementText(baseText) { document.getElementById("filter").classList.remove("hidden"); document.querySelector(".no-countries").classList.remove("hidden"); // remove keyboard listeners - window.removeEventListener('keydown', handleLetterKeyPress); + window.removeEventListener('keydown', handleNumberKeyPress); // Remove the visual indicator removeViewportBoxIndicator(); } diff --git a/src/assets/js/map.js b/src/assets/js/map.js index 4f426cc..8893a82 100644 --- a/src/assets/js/map.js +++ b/src/assets/js/map.js @@ -91,6 +91,20 @@ const COUNTRY_BBOX_OVERRIDES = { map.COUNTRY_BBOX_OVERRIDES = COUNTRY_BBOX_OVERRIDES; +// At the top, after requires or before main logic +window.lastInputWasKeyboard = false; + +// Listen for keyboard and mouse input globally +window.addEventListener('keydown', function(e) { + // Only set for navigation/keyboard mode relevant keys + if ([37,38,39,40, 65, 76, 27, 13, 32].includes(e.keyCode) || (e.key >= '0' && e.key <= '9')) { + window.lastInputWasKeyboard = true; + } +}); +window.addEventListener('mousedown', function() { window.lastInputWasKeyboard = false; }); +window.addEventListener('click', function() { window.lastInputWasKeyboard = false; }); +window.addEventListener('wheel', function() { window.lastInputWasKeyboard = false; }); + (function(window, document) { d3.select(window).on("resize", throttle); @@ -549,8 +563,11 @@ map.COUNTRY_BBOX_OVERRIDES = COUNTRY_BBOX_OVERRIDES; function move(tr, sc, animate, withKeyboard) { // Check if we should activate keyboard mode if (sc >= MIN_ZOOM_LEVEL_FOR_KEYBOARD_MODE) { - // Pass the zoom object to updateVisibleCountries - keyboardMode.updateVisibleCountries(zoom); + if (window.lastInputWasKeyboard) { + keyboardMode.updateVisibleCountries(zoom); + } else { + keyboardMode.cleanup(); + } } else { keyboardMode.cleanup(); } @@ -1064,8 +1081,9 @@ map.COUNTRY_BBOX_OVERRIDES = COUNTRY_BBOX_OVERRIDES; function clicked(d) { //d är det en har klickat på - - keyboardMode.cleanup(); + if (window.keyboardMode && window.keyboardMode.getStatus && window.keyboardMode.getStatus()) { + window.keyboardMode.cleanup(); + } var x, y, k; //bounding box for clicked country diff --git a/src/assets/js/no-countries.js b/src/assets/js/no-countries.js index c1badd6..2322256 100644 --- a/src/assets/js/no-countries.js +++ b/src/assets/js/no-countries.js @@ -60,7 +60,7 @@ var addArtistsWithNoCountry = function (data) { .on("change", handleCheckboxChange); listItem.append("label") .attr("for", _art.artist) - .html('' + _art.artist + '' + _art.playcount + ' scrobbles'); + .html('' + _art.artist + '' + _art.playcount + ' scrobbles'); if (document.querySelector("#hide-checked")?.checked && artistState.checked) { listItem.style("display", "none"); } diff --git a/src/assets/scss/components/_search.scss b/src/assets/scss/components/_search.scss index c1a5427..bfa0d75 100644 --- a/src/assets/scss/components/_search.scss +++ b/src/assets/scss/components/_search.scss @@ -174,4 +174,157 @@ input.search { padding-right: 12px; margin-bottom: 4px; color: var(--textPrimary); +} + +// Country List Dialog styles + +// Tab styles +.country-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--borderSecondary); + margin-bottom: 1.5em; + padding: 0 12px; + overflow-x: auto; + white-space: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--borderSecondary) transparent; +} + +.country-tab { + display: inline-block; + padding: 12px 16px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: var(--textPrimary); + font-size: 1em; + cursor: pointer; + transition: all 0.15s ease; + opacity: 0.7; + position: relative; + margin-bottom: -1px; + + &:hover { + opacity: 0.9; + } + + &:focus-visible { + outline: none; + box-shadow: inset 0 0 0 3px var(--focus); + } + + &[aria-selected="true"] { + opacity: 1; + border-bottom-color: var(--textPrimary); + font-weight: 600; + } +} + +.country-tabpanel { + // padding: 0 12px; + + &:focus { + outline: none; + } +} + +.country-tabpanel-heading-row { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.5em; + gap: 1em; +} + +.country-tabpanel-heading { + font-size: 1.1em; + font-weight: 700; + margin: 0; +} + +.country-sort-container { + display: flex; + align-items: center; + gap: 0.5em; +} + +.country-sort-label { + font-size: 1em; + color: var(--textPrimary, #fff); + opacity: 0.7; +} + +.country-sort-select { + font-size: 1em; + padding: 4px 10px; + border-radius: 4px; + border: 1px solid var(--borderSecondary); + background: var(--backgroundInput, #222); + color: var(--textPrimary, #fff); + outline: none; + transition: border 0.15s; +} + +.country-sort-select:focus-visible { + border-color: var(--focus); +} + +// Existing country list styles +li.country-list__country { + padding: 0!important; +} + +.country-list__country-btn { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 12px 0px; + background: none; + border: none; + border-radius: 0; + font-size: 1.1em; + cursor: pointer; + transition: background 0.15s; + outline: none; + + &:hover { + background: rgba(80, 0, 80, 0.07); + } + &:focus-visible { + background: rgba(80, 0, 80, 0.07); + box-shadow: inset 0 0 0 3px var(--focus); + outline: none; + } +} + +.country-list__country-name { + font-weight: 600; + color: var(--textPrimary); + text-align: left; +} + +.country-list__country-count { + font-weight: 400; + color: var(--textPrimary); + opacity: 0.54; + font-size: 1em; + margin-left: 16px; + text-align: right; + white-space: nowrap; +} + +.country-list__country { + border-bottom: 1px solid var(--borderSecondary); +} + +.country-list { + list-style: none; + margin: 0; + padding: 0; +} + +.country-list__country-btn:active { + background: rgba(80, 0, 80, 0.13); } \ No newline at end of file diff --git a/src/assets/scss/pages/_map.scss b/src/assets/scss/pages/_map.scss index 4817f86..b4a3e27 100644 --- a/src/assets/scss/pages/_map.scss +++ b/src/assets/scss/pages/_map.scss @@ -89,6 +89,7 @@ $controls-padding: 20px; border: 0.1em solid; z-index: 0; border-radius: 50px; + margin-top: 64px; } #progress-bar { @@ -281,6 +282,10 @@ div.colorChange { &__link { font-weight: normal; } + + &__secondary { + opacity: 0.54; + } } dialog { @@ -301,6 +306,7 @@ dialog { padding: 1rem 2rem; overflow: scroll; max-width: 30rem; + height: 80vh; &::backdrop { background-color: rgba(255,255,255,0.4); @@ -359,9 +365,6 @@ dialog { flex-direction: row; justify-content: space-between; } - span { - opacity: 0.54; - } &:first-of-type { border-top: none; } diff --git a/src/index.html b/src/index.html index 70cd17e..e5440bc 100644 --- a/src/index.html +++ b/src/index.html @@ -122,7 +122,7 @@Search: You can search directly for a country or an artist with the shortcut CTRL + F.
Keyboard mode: You can pan and zoom the map with the arrows and plus and minus keys. When you zoom in far enough, each visible country is assigned a letter that you can type on the keyboard.
+Keyboard mode: You can pan and zoom the map with the arrows and plus and minus keys. When you zoom in far enough, each visible country is assigned a number that you can type on the keyboard to select it.
Or you can pan and zoom the map with the arrows and plus and minus keys. When you zoom in far enough, you can list all visible countries by pressing L. Then select a country by pressing the listed letter key.
+Or you can pan and zoom the map with the arrows and plus and minus keys. When you zoom in far enough, you can list all visible countries by pressing L. Then select a country by pressing the listed number key (1–99).
An audio tone will play when you navigate with the keyboard. Higher pitch means more artists among the countries in the current view, lower pitch means less. Press A to disable the tone.
You may need to activate forms mode.