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 + + + + + + + +
+

Pollinator quiz

+

Do your best to recognize these pollinators!

+

+ Source code + · + License + · + Feedback +

+
+
+ +
+
+ Include/exclude species +
+
+
+ + + + +
+ +
+ + + + + +
+
+
+ + + + 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')