1(function () { 2 var dataEl = document.getElementById('programmes-data') 3 if (!dataEl) return 4 5 var programmes = JSON.parse(dataEl.textContent) 6 var byId = {} 7 programmes.forEach(function (p) { byId[p.id] = p }) 8 9 var NEW_THRESHOLD_DAYS = 60 10 var INTERNATIONAL_KEY = '__INTERNATIONAL__' 11 12 var gridEl = document.getElementById('programmes-grid') 13 var countEl = document.getElementById('programmes-count') 14 var searchInput = document.getElementById('pf-search') 15 var sortSelect = document.getElementById('pf-sort') 16 var recentlyAddedBtn = document.getElementById('pf-recently-added') 17 var activeFiltersEl = document.getElementById('programmes-active-filters') 18 19 var SEARCH_LOG_ENDPOINT = 'https://script.google.com/macros/s/AKfycbw0WHnAeTs4HA15oKqIWzB8GOSjfR6McZabFNsGRSNHWOqQIQFCb5BY8i8ECkuwvvJ0/exec' 20 var searchLogDebounceTimer = null 21 var lastLoggedSearch = '' 22 23 function logSearchQuery (query) { 24 var trimmed = query.trim() 25 if (trimmed.length < 2 || trimmed === lastLoggedSearch) return 26 lastLoggedSearch = trimmed 27 fetch(SEARCH_LOG_ENDPOINT, { 28 method: 'POST', 29 mode: 'no-cors', 30 headers: { 'Content-Type': 'text/plain;charset=utf-8' }, 31 body: JSON.stringify({ query: trimmed, page: 'Funding programmes' }) 32 }).catch(function () {}) 33 } 34 var viewGridBtn = document.getElementById('pf-view-grid') 35 var viewListBtn = document.getElementById('pf-view-list') 36 var mapSection = document.getElementById('programmes-map-section') 37 var mapBody = document.getElementById('programmes-map-body') 38 var mapCollapseBtn = document.getElementById('pf-map-collapse') 39 var mapCountryCountEl = document.getElementById('programmes-map-country-count') 40 var topCountriesListEl = document.getElementById('programmes-top-countries-list') 41 var viewAllCountriesBtn = document.getElementById('pf-view-all-countries') 42 var tooltip = document.getElementById('programmes-map-tooltip') 43 var detailEl = document.getElementById('programme-detail') 44 var resultsAreaEl = document.getElementById('programmes-results-area') 45 46 // maps a filter's DOM id prefix to the state array it controls 47 var MULTISELECT_FILTERS = { 48 'pf-country': 'country', 49 'pf-discipline': 'discipline', 50 'pf-open-for': 'openFor', 51 'pf-type': 'type' 52 } 53 54 var state = { search: '', country: [], discipline: [], openFor: [], type: [], sort: 'newest', recentlyAdded: false, view: 'grid', pageSize: 20, page: 1 } 55 var showingAllCountries = false 56 57 function daysAgo (dateStr) { 58 if (!dateStr) return Infinity 59 return (Date.now() - new Date(dateStr).getTime()) / (1000 * 60 * 60 * 24) 60 } 61 62 function isNew (p) { return daysAgo(p.dateAdded) <= NEW_THRESHOLD_DAYS } 63 64 function isUpdated (p) { 65 if (isNew(p)) return false 66 if (!p.dateUpdated) return false 67 if (daysAgo(p.dateUpdated) > NEW_THRESHOLD_DAYS) return false 68 if (p.dateAdded && p.dateUpdated <= p.dateAdded) return false 69 return true 70 } 71 72 // matches everything EXCEPT the country filter â used by the map, so hovering 73 // and clicking a country always show the same number (previously these could 74 // disagree, since the map ignored whatever other filters were already active) 75 function matchesExceptCountry (p) { 76 if (state.discipline.length && !state.discipline.some(function (d) { return p.disciplines[d] })) return false 77 if (state.openFor.length && !state.openFor.some(function (o) { return p.openFor[o] })) return false 78 if (state.type.length && !state.type.some(function (t) { return p.types.indexOf(t) !== -1 })) return false 79 if (state.recentlyAdded && !isNew(p)) return false 80 if (state.search) { 81 var q = state.search.toLowerCase() 82 var haystack = [p.title, p.description].join(' ').toLowerCase() 83 if (haystack.indexOf(q) === -1) return false 84 } 85 return true 86 } 87 88 function matches (p) { 89 if (state.country.length) { 90 var countryMatch = state.country.some(function (c) { 91 return c === INTERNATIONAL_KEY ? !p.country : p.country === c 92 }) 93 if (!countryMatch) return false 94 } 95 return matchesExceptCountry(p) 96 } 97 98 var SORTERS = { 99 newest: function (a, b) { return (b.dateAdded || '').localeCompare(a.dateAdded || '') }, 100 oldest: function (a, b) { return (a.dateAdded || '').localeCompare(b.dateAdded || '') }, 101 title: function (a, b) { return a.title.localeCompare(b.title) }, 102 'title-desc': function (a, b) { return b.title.localeCompare(a.title) }, 103 country: function (a, b) { return (a.country || '').localeCompare(b.country || '') }, 104 'country-desc': function (a, b) { return (b.country || '').localeCompare(a.country || '') }, 105 type: function (a, b) { return a.primaryType.localeCompare(b.primaryType) }, 106 'type-desc': function (a, b) { return b.primaryType.localeCompare(a.primaryType) }, 107 id: function (a, b) { return a.id.localeCompare(b.id, undefined, { numeric: true }) }, 108 'id-desc': function (a, b) { return b.id.localeCompare(a.id, undefined, { numeric: true }) }, 109 deadline: function (a, b) { return (a.deadline || '9999').localeCompare(b.deadline || '9999') }, 110 'deadline-desc': function (a, b) { return (b.deadline || '0000').localeCompare(a.deadline || '0000') } 111 } 112 113 function colorForCount (count) { 114 if (count === 0) return '#D9DEE5' 115 if (count <= 2) return '#DCEEFF' 116 if (count <= 5) return '#9DC7F0' 117 if (count <= 15) return '#4A90D9' 118 return '#0057B7' 119 } 120 121 function deadlineStatus (p) { 122 if (!p.deadline) return null 123 var deadline = new Date(p.deadline) 124 var today = new Date() 125 today.setHours(0, 0, 0, 0) 126 var daysUntil = (deadline.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) 127 if (daysUntil < 0) return 'passed' 128 if (daysUntil <= 30) return 'soon' 129 return 'ahead' 130 } 131 132 function deadlineHtml (p, label) { 133 if (!p.deadline) return '' 134 var status = deadlineStatus(p) 135 var statusLabel = status === 'passed' ? ' (passed)' : status === 'soon' ? ' (approaching)' : '' 136 return '<span class="deadline-badge deadline-badge--' + status + '">' + (label || '') + escapeHtml(p.deadline) + statusLabel + '</span>' 137 } 138 139 function tagsHtml (p) { 140 var tags = ['<span class="programme-tag">' + escapeHtml(p.primaryType) + '</span>']
141 p.types.forEach(function (t) { 142 if (t !== p.primaryType) tags.push('<span class="programme-tag programme-tag--secondary">' + escapeHtml(t) + '</span>') 143 }) 144 return tags.join('') 145 } 146 147 function disciplineLabel (p) { 148 if (p.disciplines.unspecified) return 'All disciplines' 149 var labels = window.DISCIPLINE_LABELS 150 var matched = Object.keys(labels).filter(function (k) { return p.disciplines[k] }) 151 return matched.length ? matched.map(function (k) { return labels[k] }).join(', ') : 'Not specified' 152 } 153 154 function cardHtml (p) { 155 return '' + 156 '<a class="programme-card" href="#' + encodeURIComponent(p.id) + '">' + 157 '<div class="programme-card__top">' + 158 (isNew(p) ? '<span class="programme-card__new">NEW</span>' : (isUpdated(p) ? '<span class="programme-card__updated">UPDATED</span>' : '<span></span>')) + 159 '<span class="programme-card__id">' + escapeHtml(p.id) + '</span>' + 160 '</div>' + 161 '<div class="programme-card__title">' + escapeHtml(p.title) + '</div>' + 162 '<div class="programme-card__country">' + icon_pin() + escapeHtml(p.country || 'International') + '</div>' + 163 '<div class="programme-card__description">' + escapeHtml(p.description || '') + '</div>' + 164 '<div class="programme-card__meta-label">Discipline</div><div class="programme-card__meta-value">' + escapeHtml(disciplineLabel(p)) + '</div>' + 165 (p.deadline ? '<div class="programme-card__meta-label">Deadline</div><div class="programme-card__meta-value">' + deadlineHtml(p) + '</div>' : '') + 166 '<div class="programme-card__tags">' + tagsHtml(p) + '</div>' + 167 ((p.dateUpdated || p.dateAdded) ? '<div class="programme-card__added">Last update: ' + escapeHtml(p.dateUpdated || p.dateAdded) + '</div>' : '') + 168 '<span class="programme-card__link">View details →</span>' + 169 '</a>' 170 } 171 172 function icon_pin () { 173 return '<svg class="programme-card__pin-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-7-6.2-7-11a7 7 0 0 1 14 0c0 4.8-7 11-7 11z"/><circle cx="12" cy="10" r="2.5"/></svg>' 174 } 175 176 var COLUMN_SORT_MAP = { id: 'id', title: 'title', country: 'country', type: 'type', deadline: 'deadline', added: 'newest' } 177 178 function isActiveSortColumn (column) { 179 var mappedSort = COLUMN_SORT_MAP[column] 180 var current = state.sort 181 if (current === mappedSort) return true 182 if (column === 'added' && current === 'oldest') return true 183 if (column === 'title' && current === 'title-desc') return true 184 if (column === 'country' && current === 'country-desc') return true 185 if (column === 'type' && current === 'type-desc') return true 186 if (column === 'id' && current === 'id-desc') return true 187 if (column === 'deadline' && current === 'deadline-desc') return true 188 return false 189 } 190 191 function sortIndicator (column) { 192 if (!isActiveSortColumn(column)) return '' 193 var descending = ['oldest', 'title-desc', 'country-desc', 'type-desc', 'id-desc', 'deadline-desc'].indexOf(state.sort) !== -1 194 return descending ? ' ↑' : ' ↓' 195 } 196 197 function sortHeaderClass (column) { 198 return 'is-sortable' + (isActiveSortColumn(column) ? ' is-active-sort' : '') 199 } 200 201 function listTableHtml (results) { 202 var rows = results.map(function (p) { 203 return '' + 204 '<tr class="programme-row" data-id="' + escapeHtml(p.id) + '">' + 205 '<td class="programme-row__new-cell">' + (isNew(p) ? '<span class="programme-card__new">NEW</span>' : (isUpdated(p) ? '<span class="programme-card__updated">UPDATED</span>' : '')) + '</td>' + 206 '<td class="programme-row__id">' + escapeHtml(p.id) + '</td>' + 207 '<td class="programme-row__title-cell">' + 208 '<div class="programme-row__title">' + escapeHtml(p.title) + '</div>' + 209 '<div class="programme-row__description">' + escapeHtml(p.description || '') + '</div>' + 210 '</td>' + 211 '<td class="programme-row__country">' + escapeHtml(p.country || 'International') + '</td>' + 212 '<td class="programme-row__type"><span class="programme-tag">' + escapeHtml(p.primaryType) + '</span></td>' + 213 '<td class="programme-row__deadline">' + (p.deadline ? deadlineHtml(p) : '') + '</td>' + 214 '<td class="programme-row__added">' + escapeHtml(p.dateUpdated || p.dateAdded || '') + '</td>' + 215 '</tr>' 216 }).join('') 217 218 return '' + 219 '<table class="programmes-table">' + 220 '<thead><tr>' + 221 '<th></th>' + 222 '<th class="' + sortHeaderClass('id') + '" data-sort-col="id">ID' + sortIndicator('id') + '</th>' + 223 '<th class="' + sortHeaderClass('title') + '" data-sort-col="title">Title' + sortIndicator('title') + '</th>' + 224 '<th class="' + sortHeaderClass('country') + '" data-sort-col="country">Country' + sortIndicator('country') + '</th>' + 225 '<th class="' + sortHeaderClass('type') + '" data-sort-col="type">Type' + sortIndicator('type') + '</th>' + 226 '<th class="' + sortHeaderClass('deadline') + '" data-sort-col="deadline">Deadline' + sortIndicator('deadline') + '</th>' + 227 '<th class="' + sortHeaderClass('added') + '" data-sort-col="added">Last update' + sortIndicator('added') + '</th>' + 228 '</tr></thead>' + 229 '<tbody>' + rows + '</tbody>' + 230 '</table>' 231 } 232 233 function checkboxLabelFor (filterId, value) { 234 var input = document.querySelector('#' + filterId + '-panel input[value="' + value.replace(/"/g, '\\"') + '"]') 235 if (!input) return value 236 return input.parentElement.textContent.trim() 237 } 238 239 function activeFilterChips () { 240 var chips = []
241 state.country.forEach(function (c) { 242 chips.push({ key: 'country', value: c, label: (c === INTERNATIONAL_KEY ? 'International' : c) }) 243 }) 244 state.discipline.forEach(function (d) { 245 chips.push({ key: 'discipline', value: d, label: 'Discipline: ' + checkboxLabelFor('pf-discipline', d) }) 246 }) 247 state.openFor.forEach(function (o) { 248 chips.push({ key: 'openFor', value: o, label: 'Open for: ' + checkboxLabelFor('pf-open-for', o) }) 249 }) 250 state.type.forEach(function (t) { 251 chips.push({ key: 'type', value: t, label: 'Type: ' + t }) 252 }) 253 if (state.recentlyAdded) chips.push({ key: 'recentlyAdded', value: '', label: 'Recently added' }) 254 if (state.search) chips.push({ key: 'search', value: '', label: 'Search: "' + state.search + '"' }) 255 return chips 256 } 257 258 function renderActiveFilters () { 259 var chips = activeFilterChips() 260 if (!chips.length) { activeFiltersEl.innerHTML = ''; return } 261 activeFiltersEl.innerHTML = '' + 262 '<span class="programmes-active-filters__label">Active filters:</span>' + 263 chips.map(function (c) { 264 return '<button type="button" class="programmes-filter-chip" data-clear="' + c.key + '" data-clear-value="' + escapeHtml(c.value) + '">' + escapeHtml(c.label) + ' ×</button>' 265 }).join('') + 266 '<button type="button" class="programmes-filter-chip programmes-filter-chip--clear-all" id="pf-clear-all">Clear all</button>' 267 268 activeFiltersEl.querySelectorAll('[data-clear]').forEach(function (btn) { 269 btn.addEventListener('click', function () { clearFilter(btn.getAttribute('data-clear'), btn.getAttribute('data-clear-value')) }) 270 }) 271 var clearAllBtn = document.getElementById('pf-clear-all') 272 if (clearAllBtn) clearAllBtn.addEventListener('click', clearAllFilters) 273 } 274 275 function removeFromArray (arr, value) { 276 var i = arr.indexOf(value) 277 if (i !== -1) arr.splice(i, 1) 278 } 279 280 function clearFilter (key, value) { 281 if (key === 'country') removeFromArray(state.country, value) 282 if (key === 'discipline') removeFromArray(state.discipline, value) 283 if (key === 'openFor') removeFromArray(state.openFor, value) 284 if (key === 'type') removeFromArray(state.type, value) 285 if (key === 'recentlyAdded') { state.recentlyAdded = false; recentlyAddedBtn.classList.remove('is-active') } 286 if (key === 'search') { state.search = ''; searchInput.value = '' } 287 state.page = 1 288 render() 289 } 290 291 function clearAllFilters () { 292 state.search = ''; state.country = []; state.discipline = []; state.openFor = []; state.type = []; state.recentlyAdded = false; state.page = 1 293 searchInput.value = '' 294 recentlyAddedBtn.classList.remove('is-active') 295 render() 296 } 297 298 // keeps every checkbox's checked state in sync with the underlying arrays â 299 // needed because selections can also change from the map, the top-countries 300 // list, or removing a filter chip, not just checking a box directly 301 function syncCheckboxPanels () { 302 Object.keys(MULTISELECT_FILTERS).forEach(function (filterId) { 303 var arr = state[MULTISELECT_FILTERS[filterId]] 304 document.querySelectorAll('#' + filterId + '-panel input[type="checkbox"]').forEach(function (input) { 305 input.checked = arr.indexOf(input.value) !== -1 306 }) 307 }) 308 } 309 310 function computeCountryCounts () { 311 var counts = {} 312 var intlCount = 0 313 programmes.filter(matchesExceptCountry).forEach(function (p) { 314 if (p.country) counts[p.country] = (counts[p.country] || 0) + 1 315 else intlCount++ 316 }) 317 return { counts: counts, intlCount: intlCount } 318 } 319 320 function updateMap () { 321 var result = computeCountryCounts() 322 var counts = result.counts 323 324 document.querySelectorAll('.world-map__country').forEach(function (path) { 325 var country = path.getAttribute('data-country') 326 var count = counts[country] || 0 327 path.setAttribute('data-count', count) 328 path.style.fill = colorForCount(count)
329 path.classList.toggle('world-map__country--selected', state.country.indexOf(country) !== -1) 330 path.classList.toggle('world-map__country--has-data', count > 0) 331 }) 332 333 var marker = document.querySelector('.world-map__marker') 334 if (marker) { 335 marker.setAttribute('data-count', result.intlCount) 336 marker.classList.toggle('world-map__marker--selected', state.country.indexOf(INTERNATIONAL_KEY) !== -1) 337 } 338 339 var countriesWithData = Object.keys(counts).filter(function (c) { return counts[c] > 0 }) 340 if (mapCountryCountEl) mapCountryCountEl.innerHTML = countriesWithData.length + ' countries' 341 342 var sorted = Object.entries(counts).sort(function (a, b) { return b[1] - a[1] }) 343 if (result.intlCount > 0) { 344 sorted.push([INTERNATIONAL_KEY, result.intlCount]) 345 sorted.sort(function (a, b) { return b[1] - a[1] }) 346 } 347 var toShow = showingAllCountries ? sorted : sorted.slice(0, 5) 348 topCountriesListEl.innerHTML = toShow.map(function (entry) { 349 var isIntl = entry[0] === INTERNATIONAL_KEY 350 var label = isIntl ? 'International (not country-specific)' : entry[0] 351 var isSelected = state.country.indexOf(entry[0]) !== -1 352 return '<button type="button" class="programmes-top-countries__item' + (isSelected ? ' is-active' : '') + '" data-country="' + escapeHtml(entry[0]) + '"><span>' + escapeHtml(label) + '</span><span class="programmes-top-countries__count">' + entry[1] + '</span></button>' 353 }).join('') 354 topCountriesListEl.querySelectorAll('.programmes-top-countries__item').forEach(function (btn) { 355 btn.addEventListener('click', function () { toggleCountry(btn.getAttribute('data-country')) }) 356 }) 357 viewAllCountriesBtn.textContent = showingAllCountries ? 'Show top 5 only' : 'View all countries (' + countriesWithData.length + ') \u2192' 358 } 359 360 function toggleCountry (country) { 361 var i = state.country.indexOf(country) 362 if (i === -1) state.country.push(country) 363 else state.country.splice(i, 1) 364 state.page = 1 365 render() 366 } 367 368 function paginate (results) { 369 if (state.pageSize === 'all') return results 370 var totalPages = Math.max(1, Math.ceil(results.length / state.pageSize)) 371 if (state.page > totalPages) state.page = totalPages 372 var start = (state.page - 1) * state.pageSize 373 return results.slice(start, start + state.pageSize) 374 } 375 376 function renderPagination (totalCount) { 377 var container = document.getElementById('programmes-pagination-controls') 378 if (state.pageSize === 'all' || totalCount === 0) { container.innerHTML = ''; return } 379 var totalPages = Math.max(1, Math.ceil(totalCount / state.pageSize)) 380 var html = '' 381 html += '<button type="button" class="programmes-page-btn" data-page="' + (state.page - 1) + '"' + (state.page <= 1 ? ' disabled' : '') + '>← Prev</button>' 382 html += '<span class="programmes-page-status">Page ' + state.page + ' of ' + totalPages + '</span>' 383 html += '<button type="button" class="programmes-page-btn" data-page="' + (state.page + 1) + '"' + (state.page >= totalPages ? ' disabled' : '') + '>Next →</button>' 384 container.innerHTML = html 385 container.querySelectorAll('[data-page]').forEach(function (btn) { 386 btn.addEventListener('click', function () { 387 state.page = parseInt(btn.getAttribute('data-page'), 10) 388 render() 389 gridEl.scrollIntoView({ behavior: 'smooth', block: 'start' }) 390 }) 391 }) 392 } 393 394 function updateFilterBadge (key, count) { 395 var badge = document.getElementById('pf-' + key + '-badge') 396 var wrap = badge ? badge.closest('.programmes-multiselect') : null 397 if (!badge || !wrap) return 398 if (count > 0) { 399 badge.textContent = count 400 badge.hidden = false 401 wrap.classList.add('has-selection') 402 } else { 403 badge.textContent = '' 404 badge.hidden = true 405 wrap.classList.remove('has-selection') 406 } 407 } 408 409 function updateFilterBadges () { 410 updateFilterBadge('country', state.country.length) 411 updateFilterBadge('discipline', state.discipline.length) 412 updateFilterBadge('open-for', state.openFor.length) 413 updateFilterBadge('type', state.type.length) 414 } 415 416 function render () { 417 var results = programmes.filter(matches).sort(SORTERS[state.sort] || SORTERS.newest) 418 countEl.textContent = results.length + ' programme' + (results.length === 1 ? '' : 's') 419 var pageResults = paginate(results) 420 if (state.view === 'list') { 421 gridEl.className = 'programmes-list' 422 gridEl.innerHTML = pageResults.length ? listTableHtml(pageResults) : '<div class="programmes-empty">
422No programmes match your filters.</div>' 423 sortSelect.parentElement.hidden = true 424 } else { 425 gridEl.className = 'programmes-grid' 426 gridEl.innerHTML = pageResults.length ? pageResults.map(cardHtml).join('') : '<div class="programmes-empty">No programmes match your filters.</div>' 427 sortSelect.parentElement.hidden = false 428 } 429 renderPagination(results.length) 430 updateMap() 431 renderActiveFilters() 432 updateFilterBadges() 433 syncCheckboxPanels() 434 } 435 436 function copyLinkButtonHtml () { 437 return '<button type="button" class="detail-action-btn" id="detail-copy-link" aria-label="Copy link to this programme" title="Copy link to this programme">' + 438 '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>' + 439 '</button>' 440 } 441 442 function detailUrl (id) { 443 var basePath = document.body.getAttribute('data-base-path') || '' 444 return window.location.origin + basePath + '/funding-programmes/' + encodeURIComponent(id) + '/' 445 } 446 447 function shareRowHtml (id, title) { 448 var url = detailUrl(id) 449 var encodedUrl = encodeURIComponent(url) 450 var text = encodeURIComponent(title + ' â ' + url) 451 var subject = encodeURIComponent(title + ' â via #ScienceForUkraine') 452 var body = encodeURIComponent('Thought this might be relevant:\n\n' + url) 453 454 return '' + 455 '<div class="share-row">' + 456 '<span class="share-row__label">Share this programme</span>' + 457 '<a class="share-row__link" href="mailto:?subject=' + subject + '&body=' + body + '">' + 458 '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22 6 12 13 2 6"/></svg>' + 459 '<span>Email</span>' + 460 '</a>' + 461 '<a class="share-row__link" href="https://www.linkedin.com/sharing/share-offsite/?url=' + encodedUrl + '" target="_blank" rel="noopener">' + 462 '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"/><rect x="2" y="9" width="4" height="12"/><circle cx="4" cy="4" r="2"/></svg>' + 463 '<span>LinkedIn</span>' + 464 '</a>' + 465 '<a class="share-row__link" href="https://www.facebook.com/sharer/sharer.php?u=' + encodedUrl + '" target="_blank" rel="noopener">' + 466 '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"/></svg>' + 467 '<span>Facebook</span>' + 468 '</a>' + 469 '<a class="share-row__link" href="https://wa.me/?text=' + text + '" target="_blank" rel="noopener">' + 470 '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>' + 471 '<span>WhatsApp</span>' + 472 '</a>' + 473 '</div>' 474 } 475 476 function wireCopyLink (id) { 477 var btn = document.getElementById('detail-copy-link') 478 if (!btn) return 479 btn.addEventListener('click', function (e) { 480 e.preventDefault() 481 e.stopPropagation() 482 483 var url = detailUrl(id) 484 var originalHtml = btn.innerHTML 485 486 function showCopied () { 487 btn.classList.add('is-copied') 488 btn.innerHTML = '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>' 489 setTimeout(function () { 490 btn.classList.remove('is-copied') 491 btn.innerHTML = originalHtml 492 }, 1500) 493 } 494 495 if (navigator.clipboard && navigator.clipboard.writeText) { 496 navigator.clipboard.writeText(url).then(showCopied) 497 } else { 498 var tmp = document.createElement('textarea') 499 tmp.value = url 500 tmp.style.position = 'fixed' 501 tmp.style.opacity = '0' 502 document.body.appendChild(tmp) 503 tmp.select() 504 document.execCommand('copy') 505 document.body.removeChild(tmp) 506 showCopied() 507 } 508 }) 509 } 510 511 function renderDetail (id) { 512 var p = byId[id] 513 if (!p) { 514 detailEl.innerHTML = '<button type="button" class="listing-detail__back" id="detail-back">← All programmes</button><div class="listing-detail__not-found">This programme could not be found.</div>' 515 } else { 516 detailEl.innerHTML = '' + 517 '<button type="button" class="listing-detail__back" id="detail-back">← All programmes</button>' + 518 copyLinkButtonHtml() + 519 '<span class="listing-detail__id">' + escapeHtml(p.id) + '</span>' +
520 '<div class="programme-detail__country">' + icon_pin() + escapeHtml(p.country || 'International') + '</div>' + 521 '<div class="listing-detail__institution">' + escapeHtml(p.title) + '</div>' + 522 '<div class="listing-detail__tags">' + tagsHtml(p) + '</div>' + 523 '<p class="listing-detail__description">' + escapeHtml(p.description || '') + '</p>' + 524 '<div class="listing-detail__field-label">Discipline</div><div class="listing-detail__field-value">' + escapeHtml(disciplineLabel(p)) + '</div>' + 525 (p.deadline ? '<div class="listing-detail__field-label">Deadline</div><div class="listing-detail__field-value">' + deadlineHtml(p) + '</div>' : '') + 526 ((p.dateUpdated || p.dateAdded) ? '<div class="listing-detail__field-label">Last update</div><div class="listing-detail__field-value listing-detail__field-value--muted">' + escapeHtml(p.dateUpdated || p.dateAdded) + '</div>' : '') + 527 (p.link ? '<div class="listing-detail__field-label">Link</div><div class="listing-detail__field-value"><a href="' + escapeHtml(p.link) + '" target="_blank" rel="noopener">' + escapeHtml(p.link) + '</a></div>' : '') + 528 shareRowHtml(p.id, p.title) 529 } 530 if (p) wireCopyLink(p.id) 531 var backBtn = document.getElementById('detail-back') 532 if (backBtn) backBtn.addEventListener('click', function () { window.location.hash = '' }) 533 } 534 535 var headerExtra1 = document.getElementById('programmes-header-extra') 536 var headerExtra2 = document.getElementById('programmes-header-extra-2') 537 538 function syncView () { 539 var id = decodeURIComponent(window.location.hash.replace(/^#/, '')) 540 if (id === 'submit-programme') { 541 resultsAreaEl.hidden = false 542 if (headerExtra1) headerExtra1.hidden = false 543 if (headerExtra2) headerExtra2.hidden = false 544 detailEl.hidden = true 545 if (submitForm) { 546 submitForm.hidden = false 547 submitForm.scrollIntoView({ behavior: 'smooth', block: 'center' }) 548 } 549 } else if (id) { 550 resultsAreaEl.hidden = true 551 if (headerExtra1) headerExtra1.hidden = true 552 if (headerExtra2) headerExtra2.hidden = true 553 detailEl.hidden = false 554 renderDetail(id) 555 window.scrollTo(0, 0) 556 } else { 557 resultsAreaEl.hidden = false 558 if (headerExtra1) headerExtra1.hidden = false 559 if (headerExtra2) headerExtra2.hidden = false 560 detailEl.hidden = true 561 } 562 } 563 564 // --- wiring --- 565 566 var pageSizeSelect = document.getElementById('pf-page-size') 567 568 searchInput.addEventListener('input', function () { 569 state.search = searchInput.value.trim() 570 state.page = 1 571 render() 572 573 var value = searchInput.value 574 clearTimeout(searchLogDebounceTimer) 575 searchLogDebounceTimer = setTimeout(function () { logSearchQuery(value) }, 1500) 576 }) 577 function closeAllPanels (exceptId) { 578 Object.keys(MULTISELECT_FILTERS).forEach(function (filterId) { 579 if (filterId === exceptId) return 580 var panel = document.getElementById(filterId + '-panel') 581 var btn = document.getElementById(filterId + '-button') 582 if (panel) panel.hidden = true 583 if (btn) btn.setAttribute('aria-expanded', 'false') 584 }) 585 } 586 587 Object.keys(MULTISELECT_FILTERS).forEach(function (filterId) { 588 var button = document.getElementById(filterId + '-button') 589 var panel = document.getElementById(filterId + '-panel') 590 if (!button || !panel) return 591 592 button.addEventListener('click', function (e) { 593 e.stopPropagation() 594 var willOpen = panel.hidden 595 closeAllPanels(filterId) 596 panel.hidden = !willOpen 597 button.setAttribute('aria-expanded', String(willOpen)) 598 }) 599 600 panel.querySelectorAll('input[type="checkbox"]').forEach(function (input) { 601 input.addEventListener('change', function () { 602 var arr = state[MULTISELECT_FILTERS[filterId]] 603 if (input.checked) { 604 if (arr.indexOf(input.value) === -1) arr.push(input.value) 605 } else { 606 removeFromArray(arr, input.value) 607 } 608 state.page = 1 609 render() 610 }) 611 }) 612 }) 613 614 document.addEventListener('click', function (e) { 615 if (!e.target.closest('.programmes-multiselect')) closeAllPanels() 616 }) 617 sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; render() }) 618 pageSizeSelect.addEventListener('change', function () { 619 state.pageSize = pageSizeSelect.value === 'all' ? 'all' : parseInt(pageSizeSelect.value, 10) 620 state.page = 1 621 render() 622 }) 623 624 recentlyAddedBtn.addEventListener('click', function () { 625 state.recentlyAdded = !state.recentlyAdded 626 recentlyAddedBtn.classList.toggle('is-active', state.recentlyAdded) 627 state.page = 1 628 render() 629 }) 630 631 viewGridBtn.addEventListener('click', function () { 632 state.view = 'grid' 633 viewGridBtn.classList.add('is-active') 634 viewListBtn.classList.remove('is-active') 635 render() 636 }) 637 viewListBtn.addEventListener('click', function () { 638 state.view = 'list' 639 viewListBtn.classList.add('is-active') 640 viewGridBtn.classList.remove('is-active') 641 render() 642 }) 643 644 mapCollapseBtn.addEventListener('click', function () { 645 var isCollapsed = mapBody.hasAttribute('hidden') 646 if (isCollapsed) { 647 mapBody.removeAttribute('hidden') 648 } else { 649 mapBody.setAttribute('hidden', '') 650 } 651 var label = mapCollapseBtn.querySelector('.programmes-map-collapse__label') 652 if (label) label.textContent = isCollapsed ? 'Collapse map' : 'Expand map' 653 }) 654 655 viewAllCountriesBtn.addEventListener('click', function () { 656 showingAllCountries = !showingAllCountries 657 updateMap() 658 }) 659 660 var mapDragMoved = false 661 662 document.querySelectorAll('.world-map__country, .world-map__marker').forEach(function (el) { 663 el.addEventListener('mousemove', function (e) { 664 var count = el.getAttribute('data-count') 665 if (!tooltip || !count || count === '0') { if (tooltip) tooltip.style.display = 'none'; return } 666 var label = el.getAttribute('data-region') || el.getAttribute('data-country') 667 tooltip.textContent = label + ': ' + count + ' programme' + (count === '1' ? '' : 's') 668 tooltip.style.display = 'block' 669 tooltip.style.left = (e.clientX + 12) + 'px' 670 tooltip.style.top = (e.clientY + 12) + 'px' 671 }) 672 el.addEventListener('mouseleave', function () { if (tooltip) tooltip.style.display = 'none' }) 673 el.addEventListener('click', function () { 674 if (mapDragMoved) return 675 var count = el.getAttribute('data-count') 676 if (!count || count === '0') return 677 var region = el.getAttribute('data-region') 678 toggleCountry(region === 'International' ? INTERNATIONAL_KEY : el.getAttribute('data-country')) 679 }) 680 }) 681 682 // --- map pan/zoom --- 683 var mapViewport = document.getElementById('world-map-viewport') 684 var mapSvgEl = mapViewport ? mapViewport.querySelector('.world-map') : null 685 if (mapViewport && mapSvgEl) { 686 var defaultZoomState = { 687 scale: parseFloat(mapViewport.getAttribute('data-default-scale')) || 1, 688 x: parseFloat(mapViewport.getAttribute('data-default-x')) || 0, 689 y: parseFloat(mapViewport.getAttribute('data-default-y')) || 0 690 } 691 var zoomState = { scale: defaultZoomState.scale, x: defaultZoomState.x, y: defaultZoomState.y } 692 var isDragging = false 693 var dragStartMouse = { x: 0, y: 0 } 694 var dragStartOffset = { x: 0, y: 0 } 695 696 function clampZoom (s) { return Math.max(0.6, Math.min(8, s)) } 697 function applyZoomTransform () { 698 mapSvgEl.style.transform = 'translate(' + zoomState.x + 'px,' + zoomState.y + 'px) scale(' + zoomState.scale + ')' 699 } 700 applyZoomTransform() 701 702 mapViewport.addEventListener('wheel', function (e) { 703 e.preventDefault() 704 var factor = e.deltaY < 0 ? 1.15 : 1 / 1.15 705 zoomState.scale = clampZoom(zoomState.scale * factor) 706 applyZoomTransform() 707 }, { passive: false }) 708 709 mapViewport.addEventListener('mousedown', function (e) { 710 isDragging = true 711 mapDragMoved = false 712 dragStartMouse = { x: e.clientX, y: e.clientY } 713 dragStartOffset = { x: zoomState.x, y: zoomState.y } 714 mapViewport.classList.add('is-dragging') 715 }) 716 window.addEventListener('mousemove', function (e) { 717 if (!isDragging) return 718 var dx = e.clientX - dragStartMouse.x 719 var dy = e.clientY - dragStartMouse.y 720 if (Math.abs(dx) > 3 || Math.abs(dy) > 3) mapDragMoved = true 721 zoomState.x = dragStartOffset.x + dx 722 zoomState.y = dragStartOffset.y + dy 723 applyZoomTransform() 724 }) 725 window.addEventListener('mouseup', function () { 726 isDragging = false 727 mapViewport.classList.remove('is-dragging') 728 setTimeout(function () { mapDragMoved = false }, 0) 729 }) 730 731 var zoomInBtn = document.getElementById('pf-map-zoom-in') 732 var zoomOutBtn = document.getElementById('pf-map-zoom-out') 733 var zoomResetBtn = document.getElementById('pf-map-zoom-reset') 734 if (zoomInBtn) zoomInBtn.addEventListener('click', function () { zoomState.scale = clampZoom(zoomState.scale * 1.3); applyZoomTransform() }) 735 if (zoomOutBtn) zoomOutBtn.addEventListener('click', function () { zoomState.scale = clampZoom(zoomState.scale / 1.3); applyZoomTransform() }) 736 if (zoomResetBtn) zoomResetBtn.addEventListener('click', function () { zoomState = { scale: defaultZoomState.scale, x: defaultZoomState.x, y: defaultZoomState.y }; applyZoomTransform() }) 737 } 738 739 gridEl.addEventListener('click', function (e) { 740 var sortHeader = e.target.closest('[data-sort-col]') 741 if (sortHeader) { 742 var col = sortHeader.getAttribute('data-sort-col') 743 var ascKey = COLUMN_SORT_MAP[col]
744 var descKey = (col === 'added') ? 'oldest' : (ascKey + '-desc') 745 state.sort = (state.sort === ascKey) ? descKey : ascKey 746 render() 747 return 748 } 749 var row = e.target.closest('.programme-row') 750 if (row && row.tagName === 'TR') { 751 window.location.hash = encodeURIComponent(row.getAttribute('data-id')) 752 } 753 }) 754 755 var MOBILE_BREAKPOINT = 700 756 function enforceGridOnMobile () { 757 if (window.innerWidth <= MOBILE_BREAKPOINT && state.view === 'list') { 758 state.view = 'grid' 759 viewGridBtn.classList.add('is-active') 760 viewListBtn.classList.remove('is-active') 761 render() 762 } 763 } 764 window.addEventListener('resize', enforceGridOnMobile) 765 enforceGridOnMobile() 766 767 var SUBMIT_ENDPOINT = 'https://script.google.com/macros/s/AKfycbyvegcSDnFslhN9F8CTZMvUnHVfPI8GYw2MpekL73ku9T_Bk7xpP9-2rwSnrclM8xz1/exec' 768 var submitToggleBtn = document.getElementById('pf-submit-toggle') 769 var submitForm = document.getElementById('pf-submit-form') 770 var submitButton = document.getElementById('pf-submit-button') 771 var submitStatus = document.getElementById('pf-submit-status') 772 var formLoadedAt = Date.now() 773 774 if (submitToggleBtn && submitForm) { 775 submitToggleBtn.addEventListener('click', function () { 776 submitForm.hidden = !submitForm.hidden 777 if (!submitForm.hidden) { 778 submitForm.scrollIntoView({ behavior: 'smooth', block: 'center' }) 779 } 780 }) 781 782 submitForm.addEventListener('submit', function (e) { 783 e.preventDefault() 784 785 var honeypot = submitForm.querySelector('input[name="hp_extra_field_2847"]').value 786 if (honeypot) return 787 788 var payload = { 789 title: submitForm.querySelector('input[name="title"]').value.trim(), 790 link: submitForm.querySelector('input[name="link"]').value.trim(), 791 country: submitForm.querySelector('input[name="country"]').value.trim(), 792 notes: submitForm.querySelector('textarea[name="notes"]').value.trim(), 793 hp_extra_field_2847: honeypot, 794 loadedAt: formLoadedAt 795 } 796 797 if (!payload.title || !payload.link) { 798 submitStatus.textContent = 'Please fill in both the title and link.' 799 submitStatus.className = 'programmes-submit-form__status programmes-submit-form__status--error' 800 return 801 } 802 803 submitButton.disabled = true 804 submitStatus.textContent = 'Sending...' 805 submitStatus.className = 'programmes-submit-form__status' 806 807 fetch(SUBMIT_ENDPOINT, { 808 method: 'POST', 809 mode: 'no-cors', 810 headers: { 'Content-Type': 'text/plain;charset=utf-8' }, 811 body: JSON.stringify(payload) 812 }) 813 .then(function () { 814 submitButton.disabled = false 815 submitStatus.textContent = 'Thank you. We will take a look!' 816 submitStatus.className = 'programmes-submit-form__status programmes-submit-form__status--success' 817 submitForm.reset() 818 setTimeout(function () { submitForm.hidden = true; submitStatus.textContent = '' }, 2500) 819 }) 820 .catch(function () { 821 submitButton.disabled = false 822 submitStatus.textContent = 'Something went wrong. Please try again. If the issue persists, please contact us at info@scienceforu
822kraine.eu.' 823 submitStatus.className = 'programmes-submit-form__status programmes-submit-form__status--error' 824 }) 825 }) 826 } 827 828 window.addEventListener('hashchange', syncView) 829 830 render() 831 syncView() 832})()
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.