diff --git a/.Gitignore b/.Gitignore new file mode 100644 index 0000000..d8b763b --- /dev/null +++ b/.Gitignore @@ -0,0 +1,5 @@ +.clj-kondo/ +target/ +.cpcache +.nrepl-port +.lsp diff --git a/deps.edn b/deps.edn index c81738b..9ed0707 100644 --- a/deps.edn +++ b/deps.edn @@ -8,6 +8,7 @@ com.taoensso/telemere-slf4j {:mvn/version "1.1.0"} hiccup/hiccup {:mvn/version "2.0.0"} cheshire/cheshire {:mvn/version "6.1.0"} + camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"} io.github.paintparty/bling {:mvn/version "0.8.8"}} :aliases {:repl/conjure {:extra-deps {nrepl/nrepl {:mvn/version "1.0.0"} diff --git a/src/core.clj b/src/core.clj index a75499b..6db4309 100644 --- a/src/core.clj +++ b/src/core.clj @@ -1,18 +1,35 @@ (ns core - (:require [wikidata :as wd] - [cheshire.core :as json] - [reitit.ring :as ring] - [ring.adapter.jetty] - [ring.middleware.reload] - [ring.logger :refer [wrap-with-logger]] - [clojure.java.io :refer [reader]] - [bling.core :as bling] - [bling.hifi] - [hiccup.page :refer [html5]] - [taoensso.telemere :as tel])) + (:require + [bling.core :as bling] + [bling.hifi] + [cheshire.core :as json] + [clojure.java.io :refer [reader]] + [clojure.math :as math] + [clojure.string :refer [lower-case]] + [ebird] + [hiccup.page :refer [html5]] + [reitit.ring :as ring] + [ring.adapter.jetty] + [ring.logger :refer [wrap-with-logger]] + [ring.middleware.reload] + [wikidata :as wd])) (def known-species (atom {})) (def observations (atom [])) +(def nearby (atom {})) + +(defn get-nearby [species] + (let [species (lower-case (species :latin)) + nearby-info (@nearby species) + since (.minusDays (java.time.LocalDateTime/now) 1)] + (if (and nearby-info (.isBefore since (:time nearby-info))) + nearby-info + (let [nearby-info (-> species + (ebird/get-recent) + (ebird/summarise-recent) + (assoc :time (java.time.LocalDateTime/now)))] + (swap! nearby #(assoc % species nearby-info)) + nearby-info)))) (defn add-observation* [bird time certainty] (swap! observations #(conj % {:bird bird :time time :certainty certainty})) @@ -36,18 +53,13 @@ (add-observations birds now) {:status 200})) -(defn head [] - [:head - [:link {:rel "preconnect" :href "https://fonts.googleapis.com"}] - [:link {:rel "preconnect" :href "https://fonts.gstatic" :crossorigin true}] - [:link {:rel "stylesheet" :href "https://fonts.googleapis.com/css2?family=B612+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Cinzel:wght@400..900&display=swap"}] - [:title "Bird observations"]]) - (defn summarise-species [s] - {:bird (:bird (first s)) - :certainty (apply max (map :certainty s)) - :count (count s) - :first-seen (apply min (map #(.toEpochSecond (.atZone (:time %) (java.time.ZoneId/of "Europe/Berlin"))) s))}) + (let [bird (:bird (first s))] + {:bird bird + :certainty (apply max (map :certainty s)) + :count (count s) + :nearby (get-nearby bird) + :first-seen (apply min (map #(.toEpochSecond (.atZone (:time %) (java.time.ZoneId/of "Europe/Berlin"))) s))})) (defn summarise-observations [observations since] (->> observations @@ -69,9 +81,45 @@ :green "#bccb88" :dark-green "#528026" :blue "#31A0C5"}) -(defn template-observation [{bird :bird certainty :certainty count :count}] - [:li - (get-in bird [:name :en]) +(def script + (str + "function onExpand(element) {" + "const info = element.querySelector('.info');" + "const classes = element.classList;" + "if (classes.contains('open')) {" + "classes.replace('open', 'closed');" + "info.style.display = 'none';" + "} else {" + "classes.replace('closed', 'open');" + "info.style.removeProperty('display');" + "}" + "}")) + +(defn head [] + [:head + [:link {:rel "preconnect" :href "https://fonts.googleapis.com"}] + [:link {:rel "preconnect" :href "https://fonts.gstatic" :crossorigin true}] + [:link {:rel "stylesheet" :href "https://fonts.googleapis.com/css2?family=B612+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Cinzel:wght@400..900&display=swap"}] + [:style (str "li.closed::marker {content: '⛦ '; font-size: 1.2em; color:" (colours :blue) "}" + "li.open::marker {content: '⚝ '; font-size: 1.2em; color:" (colours :blue) "}")] + [:script {:type "text/javascript"} script] + [:title "🐦 Vogel-Himbeere 🍓"]]) +(def template-concern + {:least-concern [:span {:style (str "color:" (colours :dark-green))} "(♪♪)"] + :near-threatened [:span {:style (str "color:" (colours :green))} "(♪)"] + :vulnerable [:span {:style (str "color:" (colours :yellow))} "(〜)"] + :endangered [:span {:style (str "color:" (colours :light-orange))} "(!)"] + :critical [:span {:style (str "color:" (colours :orange))} "(!!)"]}) +(defn template-observation [{bird :bird certainty :certainty count :count nearby :nearby}] + (let [observation-count (:observation-count nearby)] + [:li.closed + {:onclick "onExpand(this)" + } + [:span {:style (when (or (not observation-count) (= 0 observation-count)) + "text-decoration-style:double;text-decoration-line:underline;text-decoration-skip-ink:all;")} + (or (get-in bird [:name :de]) + (ebird/get-name (:latin bird)) + (get-in bird [:name :en]))] " " [:span.latin {:style (str @@ -81,37 +129,42 @@ "text-decoration-style: dashed;" "text-decoration-line: underline;" "color:" (:brown colours) ";")} - (:latin bird)] + (:latin bird)] + " " + (template-concern (:iucn bird)) + [:div.info {:style "display: none;font-size: 0.8em;"} + (str (or observation-count "no") " nearby observations")] [:br] [:span.certainty - {:style (str + {:style (str "font-size: 0.8em;" - "font-style: italic;" - )} + "font-style: italic;")} (str "Seen " count " times with maximum likelihood ") [:span {:style (condp < certainty 0.8 (str "color:" (:orange colours) ";") 0.5 (str "color:" (:light-orange colours) ";") - nil)} certainty]]]) + nil)} (double (/ (math/round (* 10000 certainty)) 100.0))] + " %"]])) (defn list-observation [_] (let [since (.minusDays (java.time.LocalDateTime/now) 1) observations (summarise-observations @observations since)] + (print @nearby) {:status 200 - :body (str (html5 {:mode :html} - [:html - (head) - [:body - {:style - (str "max-width: 800px;" - "margin-left: auto;" - "margin-right: auto;" - "font-family: \"B612 Mono\";" - "background-color:" (:background colours) ";" - "color:" (:text colours) ";")} - [:section - [:h1 "Birds today"] - [:ul - (map template-observation observations)]]]]))})) + :headers {"charset" "utf-8" + "Content-Type" "text/html; charset=utf-8"} + :body (str (html5 + (head) + [:body + {:style + (str "max-width: 800px;" + "margin-left: auto;" + "margin-right: auto;" + "font-family: \"B612 Mono\";" + "background-color:" (:background colours) ";" + "color:" (:text colours) ";")} + [:h1 "Birds today 🐦"] + [:ul + (map template-observation observations)]]))})) (def router (wrap-with-logger @@ -121,6 +174,4 @@ ["/observation" {:post post-observation}]]) (ring/create-default-handler)))) -(ring.middleware.reload/wrap-reload (ring.adapter.jetty/run-jetty #'router {:port 8080})) - - +(ring.adapter.jetty/run-jetty #'router {:port 8080}) diff --git a/src/ebird.clj b/src/ebird.clj new file mode 100644 index 0000000..6bd68e2 --- /dev/null +++ b/src/ebird.clj @@ -0,0 +1,49 @@ +(ns ebird + (:require [clojure.string :refer [lower-case]]) + (:require [camel-snake-kebab.core :as camel]) + (:require [babashka.http-client :as http]) + (:require [cheshire.core :as json])) + +(def api-key "a2bhg726mt4r") +(def headers {"x-ebirdapitoken" api-key}) +(def latitude "52.29") +(def longitude "8.04") + +(defn- call-api + ([url] (call-api url {})) + ([url query-params] (-> (str "https://api.ebird.org/v2/" url) + (http/get {:headers headers + :query-params query-params}) + (:body) + (json/parse-string (comp camel/->kebab-case keyword))))) + +(defn- associate-by [f coll] + (into {} (map (juxt f identity) coll))) +(def taxonomy (->> (call-api "ref/taxonomy/ebird" {"fmt" "json" + "locale" "de"}) + (filter #(= "species" (:category %))) + (associate-by (comp lower-case :sci-name)))) + +(defn get-name [species] + (-> species + (lower-case) + (taxonomy) + (:com-name))) + +(defn get-code [latin] + (get-in taxonomy [(lower-case latin) :species-code])) + +(defn get-recent + ([lat lng] (call-api "data/obs/geo/recent" {"lat" lat "lng" lng "dist" 50})) + ([species] (get-recent species latitude longitude)) + ([species lat lng] + (call-api (str "data/obs/geo/recent/" (get-code species)) + {"lat" lat "lng" lng "dist" 50}))) +(defn summarise-recent [recent] + (reduce + (fn [acc obs] {:bird-count (+ (or 1 (:how-many obs)) (:bird-count acc)) + :observation-count (inc (:observation-count acc)) + :locations (conj (:locations acc) (:loc-name obs))}) + {:bird-count 0 :observation-count 0 :locations #{}} + recent)) + diff --git a/src/wikidata.clj b/src/wikidata.clj index 2bbc52f..e2c5ff4 100644 --- a/src/wikidata.clj +++ b/src/wikidata.clj @@ -5,25 +5,41 @@ (def wikidata-query-url "https://query.wikidata.org/sparql") +(defn entity-url [name] (str "http://www.wikidata.org/entity/" name)) + +(def concerns + (-> {"Q211005" :least-concern + "Q719675" :near-threatened + "Q278113" :vulnerable + "Q96377276" :endangered + "Q219127" :critical} + (update-keys entity-url))) + (defn- query [latin] (-> `{:prefixes {:wdt "" :wd "" :bd "" + :rdfs "" + :wdno "" + :p "" + :ps "" + :psv "" :wikibase ""} - :select [~'?name] + :select [~'?name ~'?concern] :where [[~'?bird :wdt/P31 :wd/Q16521] + [~'?bird :wdt/P105 :wd/Q7432] [~'?bird :wdt/P225 ~latin] [~'?bird :wdt/P1843 ~'?name] - [:service :wikibase/label [[:bd/serviceParam :wikibase/language "en,de,fr"]]]] - } + [~'?bird :wdt/P141 ~'?concern]]} (f/format-query :pretty true))) - (defn get-bird [latin] (let [bindings (-> (http/get wikidata-query-url {:query-params {:query (query latin) :format "json"}}) (:body) (json/parse-string true) (:results) (:bindings))] - {:name (apply hash-map (flatten (map (fn [{name :name}] [(keyword (:xml:lang name)) (:value name)]) bindings))) :latin latin})) - + (print bindings) + {:name (apply hash-map (flatten (map (fn [{name :name}] [(keyword (:xml:lang name)) (:value name)]) bindings))) + :latin latin + :iucn (concerns (get-in (first bindings) [:concern :value]))})) diff --git a/todos.md b/todos.md index f1c011a..0fec0be 100644 --- a/todos.md +++ b/todos.md @@ -4,3 +4,4 @@ - Tidy memory periodically - Split log and notification - Get user for fun & profit +- persistence