diff --git a/pollinators/index.html b/pollinators/index.html
new file mode 100644
index 0000000..80c7e65
--- /dev/null
+++ b/pollinators/index.html
@@ -0,0 +1,92 @@
+
+
+
+
+ Pollinator quiz
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pollinators/index.js b/pollinators/index.js
new file mode 100644
index 0000000..dfa09b3
--- /dev/null
+++ b/pollinators/index.js
@@ -0,0 +1,338 @@
+const guessData = {}
+const cache = {}
+
+const data = {
+ 'Anthophila (bijen)': {
+ 'Anthidium/Anthidiellum (wolbijen)': 504741,
+ 'Andrena (zandbijen)': {
+ 'Andrena hattorfiana (knautiabij)': 424814,
+ other: 57669
+ },
+ 'Apis mellifera (honingbij)': 47219,
+ 'Bombus (hommels)': {
+ 'Bombus hortorum (tuinhommel)': 121989,
+ 'Bombus hypnorum (boomhommel)': 61803,
+ 'Bombus lapidarius (steenhommel)': 57619,
+ 'Bombus pascuorum (akkerhommel)': 55637,
+ 'Bombus pratorum (weidehommel)': 124910,
+ 'Bombus terrestris (aardhommel)': 57516,
+ 'Psithyrus (koekoekshommels)': 538893,
+ other: 52775
+ },
+ 'Chelostoma (klokjesbijen)': 357471,
+ 'Dasypoda hirtipes (pluimvoetbij)': 746682,
+ 'Hylaeus (maskerbijen)': 359239,
+ 'Lasioglossum/Halictus (groefbijen)': {
+ 'Halictus scabiosae (breedbandgroefbij)': 415589,
+ other: 335597
+ },
+ 'Macropis europaea (slobkousbij)': 188686,
+ 'Megachile (behangersbijen)': 125786,
+ 'Nomada (wespbijen)': 53648,
+ 'Osmia/Hoplitis (metselbijen)': 465612,
+ 'Sphecodes (bloedbijen)': 61891,
+ other: 630955
+ },
+ 'Syrphidae (zweefvliegen)': {
+ 'Anasimyia/Eurimyia lineata': 1144215,
+ 'Cheilosia (gitjes)': 124483,
+ 'Chrysotoxum (fopwespen)': 119997,
+ 'Episyrphus balteatus (snor-/marmelade-/pyjamazweefvlieg)': 52482,
+ 'Eristalis (bijvliegen)': {
+ 'Eristalis intricaria (hommelbijvlieg)': 497713,
+ other: 52491
+ },
+ 'Eristalinus (vlekogen)': 145541,
+ 'Eupeodes (kommazwevers)': 69190,
+ 'Helophilus (pendelzweefvliegen)': 52487,
+ 'Melanostoma (driehoekzweefvliegen)': 70406,
+ 'Merodon equestris (narcissenvlieg)': 194786,
+ 'Myathropa florea (doodskopzweefvlieg)': 70211,
+ 'Rhingia campestris (gewone snuitvlieg)': 355014,
+ 'Scaeva pyrastri (witte halvemaanzwever)': 52160,
+ 'Sericomyia (veenzweefvliegen)': 68133,
+ 'Sphaerophoria (langlijven)': 52964,
+ 'Syritta pipiens (menuetzweefvlieg)': 81979,
+ 'Syrphus (bandzweefvliegen)': 52489,
+ 'Volucella (reuzen)': {
+ 'Volucella pellucens (ivoorzweefvlieg)': 52480,
+ 'Volucella zonaria (stadreus)': 343983,
+ other: 52481
+ },
+ 'Xylota/Chalcosyrphus (bladlopers)': 488420,
+ other: 49995
+ }
+}
+
+function getPaths (data, prefix) {
+ if (typeof data === 'object') {
+ const paths = []
+ for (const taxon in data) {
+ paths.push(...getPaths(data[taxon], prefix.concat(taxon)))
+ }
+ return paths
+ } else {
+ return [prefix.concat(data)]
+ }
+}
+
+const taxaPaths = getPaths(data, []).reduce((paths, path) => {
+ paths[path.pop()] = path
+ return paths
+}, {})
+
+function appendTaxaToForm (taxa, element, type) {
+ for (const taxon in taxa) {
+ if (typeof taxa[taxon] === 'object') {
+ let group
+ let legend
+ if (type === 'checkbox') {
+ group = document.createElement('fieldset')
+ legend = document.createElement('legend')
+ const label = document.createElement('label')
+ const input = document.createElement('input')
+ input.type = type
+ input.checked = true
+ input.addEventListener('change', function () {
+ group.disabled = !group.disabled
+ const guessEquivalent = document.querySelector(`[data-taxon="${taxon}"]`)
+ guessEquivalent.disabled = group.disabled
+ })
+ label.innerHTML = taxon
+ label.prepend(input)
+ legend.appendChild(label)
+ } else {
+ group = document.createElement('details')
+ legend = document.createElement('summary')
+ group.dataset.taxon = taxon
+ legend.innerHTML = taxon
+ }
+ group.appendChild(legend)
+ appendTaxaToForm(taxa[taxon], group, type)
+ element.appendChild(group)
+ } else {
+ const label = document.createElement('label')
+ const box = document.createElement('input')
+ box.type = type
+ box.name = 'taxon'
+ box.checked = true
+ box.value = taxa[taxon]
+ label.innerHTML = taxon
+ label.prepend(box)
+ element.appendChild(label)
+ }
+ }
+}
+
+function randomSample (array) {
+ return array[Math.floor(Math.random() * array.length)]
+}
+
+function isEnabled (element) {
+ if (element.disabled) {
+ return false
+ } else if (element.tagName === 'FORM') {
+ return true
+ }
+
+ return isEnabled(element.parentNode)
+}
+
+function getTaxon (form) {
+ const taxa = Array.prototype.filter
+ .call(selection.taxon, input => input.checked && isEnabled(input))
+ .map(input => input.value)
+
+ return randomSample(taxa)
+}
+
+const selection = document.getElementById('selection')
+const taxaSelect = document.getElementById('taxaSelect')
+const guess = document.getElementById('guess')
+const giveup = document.getElementById('giveup')
+const taxaGuess = document.getElementById('taxaGuess')
+
+const licenses = ['cc-by', 'cc-by-nc', 'cc0', 'cc-by-sa', 'cc-by-nc-sa']
+
+async function getGuessData (taxon) {
+ const key = taxon
+
+ if (!(key in cache) || cache[key].length === 0) {
+ const exclude = []
+ if (taxaPaths[taxon].slice(-1)[0] === 'other') {
+ let group = taxa
+ for (const name of taxaPaths[taxon]) {
+ if (!group[name] || name === 'other') {
+ break
+ }
+
+ group = group[name]
+ }
+ for (const name in group) {
+ if (name !== 'other') {
+ exclude.push(group[name])
+ }
+ }
+ }
+
+ const url = new URL('https://api.inaturalist.org/v1/observations')
+ const options = {
+ photos: true,
+ taxon_id: taxon,
+ place_id: 7506,
+ license: licenses,
+ photo_license: licenses,
+ quality_grade: 'research',
+ locale: 'nl',
+ per_page: 10
+ }
+
+ if (exclude.length) {
+ options.without_taxon_id = exclude
+ }
+ url.search = new URLSearchParams(options).toString()
+
+ const data = await fetch(url).then(response => response.json())
+ if (data.results == null || data.results.length === 0) {
+ return undefined
+ }
+ cache[key] = data.results
+ }
+
+ const guessData = randomSample(cache[key])
+ cache[key].splice(cache[key].indexOf(guessData), 1)
+
+ return guessData
+}
+
+selection.onsubmit = async function (e) {
+ e.preventDefault()
+
+ delete selection.images.dataset.success
+ while (selection.images.firstChild) {
+ selection.images.removeChild(selection.images.lastChild)
+ }
+ while (guess.result.firstChild) {
+ guess.result.removeChild(guess.result.lastChild)
+ }
+
+ // Get data
+ const taxon = getTaxon(selection)
+ const taxonName = taxaPaths[taxon].slice(-1)[0]
+ const data = await getGuessData(taxon)
+
+ if (!data) {
+ selection.images.dataset.success = false
+ selection.images.innerHTML = `No images found for ${taxonName}`
+ return
+ }
+
+ // Update state
+ guessData.data = {
+ taxon: {
+ name: taxonName,
+ label: taxonName,
+ value: taxon
+ },
+ species: {
+ name: data.taxon.name,
+ label: data.taxon.preferred_common_name
+ }
+ }
+
+ // Update images
+ const figure = document.createElement('figure')
+ const container = document.createElement('div')
+ for (const photo of data.photos) {
+ const figure = document.createElement('figure')
+
+ const img = document.createElement('img')
+ img.src = photo.url.replace('square', 'large')
+ figure.appendChild(img)
+
+ const figcaption = document.createElement('figcaption')
+ figcaption.innerText = photo.attribution
+ figure.appendChild(figcaption)
+
+ container.appendChild(figure)
+ }
+ figure.appendChild(container)
+ const figcaption = document.createElement('figcaption')
+ const a = document.createElement('a')
+ a.href = data.uri
+ a.innerText = 'Observation link (spoilers)'
+ figcaption.appendChild(a)
+ figure.appendChild(figcaption)
+
+ selection.images.appendChild(figure)
+}
+
+function createTaxonLabel (taxon) {
+ const base = `${taxon.name}`
+ return taxon.name === taxon.label || taxon.label === undefined ? base : base + ` (${taxon.label})`
+}
+
+function createSpeciesLabel (species, taxon) {
+ return `This is a ${createTaxonLabel(species)}`
+}
+
+function getCommonParent (guess, expected) {
+ let i = 0
+ while (taxaPaths[guess][i] === taxaPaths[expected][i]) {
+ i++
+ }
+
+ return taxaPaths[guess][i - 1]
+}
+
+guess.onsubmit = function (e) {
+ e.preventDefault()
+ const { taxon, species } = guessData.data
+ if (taxon.value === guess.taxon.value) {
+ guess.result.dataset.success = true
+ guess.result.innerHTML = `That is correct! ${createSpeciesLabel(species, taxon)}`
+ return
+ }
+
+ guess.result.dataset.success = false
+ const commonParent = getCommonParent(guess.taxon.value, taxon.value)
+
+ if (commonParent) {
+ guess.result.innerHTML = `This is indeed ${commonParent}, but a different subtaxon.`
+ } else {
+ guess.result.innerHTML = `Guess again.`
+ }
+}
+
+giveup.onclick = function () {
+ guess.result.dataset.success = false
+ const { taxon, species } = guessData.data
+ guess.result.innerHTML = createSpeciesLabel(species, taxon)
+}
+
+const params = new URLSearchParams(location.search)
+let taxa = data
+
+if (params.has('taxon')) {
+ taxa = params.getAll('taxon').reduce((taxa, taxon) => {
+ const steps = taxon.replace(/\b./g, m => m.toUpperCase()).split('.')
+ const last = steps.pop()
+
+ let cursor = data
+ let copy = taxa
+ for (const step of steps) {
+ cursor = cursor[step]
+ copy = copy[step] || (copy[step] = {})
+ }
+ copy[last] = cursor[last]
+
+ return taxa
+ }, {})
+}
+
+if (params.has('season')) {
+ selection.season.value = params.get('season')
+}
+
+appendTaxaToForm(taxa, taxaSelect, 'checkbox')
+appendTaxaToForm(taxa, taxaGuess, 'radio')