PageSourceSearch

https://scienceforukraine.eu/assets/js/programmes.js?v=1789331295656

js scienceforukraine.eu collected 2026-09-24 09:22:39 UTC 38,132 bytes, 832 lines download raw bytes

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 &rarr;</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 ? ' &uarr;' : ' &darr;'
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) + ' &times;</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' : '') + '>&larr; 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 &rarr;</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 + '&amp;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">&larr; 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">&larr; 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.