This commit is contained in:
Astro 2021-08-26 01:13:49 +02:00
commit 8b20c84638
13 changed files with 1574 additions and 0 deletions

20
db.sql Normal file
View File

@ -0,0 +1,20 @@
create extension fuzzystrmatch;
CREATE TABLE trees (
id text primary key,
coord point,
score float,
botanic text,
botanic_pfaf text,
german text,
planted date
);
CREATE INDEX trees_coord ON trees USING GIST (coord);
CREATE TABLE areas (
coords polygon,
src text,
attrs jsonb
);
CREATE INDEX areas_coords ON areas USING GIST (coords);
CREATE INDEX areas_box_coords ON areas USING GIST (box(coords));

46
default.nix Normal file
View File

@ -0,0 +1,46 @@
{ pkgs ? import <nixpkgs> {} }:
rec {
geojsons = {
# https://opendata.dresden.de/informationsportal/#app/mainpage//Stadtb%C3%A4ume
trees = "https://kommisdd.dresden.de/net4/public/ogcapi/collections/L1261/items?limit=10000000";
# Natürliche Funktionen des Bodens: https://kommisdd.dresden.de/net4/public/ogcapi/collections/L859
boden_fkt = "https://kommisdd.dresden.de/net4/public/ogcapi/collections/L859/items?limit=10000000";
# Gebietstypen des natürlichen Wasserhaushaltes: https://kommisdd.dresden.de/net4/public/ogcapi/collections/L1106
wasserhaushalt = "https://kommisdd.dresden.de/net4/public/ogcapi/collections/L1106/items?limit=10000000";
# Wasserspeichervermögen: https://kommisdd.dresden.de/net4/public/ogcapi/collections/L849
wasserspeicher = "https://kommisdd.dresden.de/net4/public/ogcapi/collections/L849/items?limit=10000000";
};
# TODO:
# im Grundwasser
# UmweltWasser
# Gebietstypen des natürlichen Wasserhaushaltes
# UmweltWasser
# Grundwasser - aktuelle Messwerte
# Plants For A Future
pfaf_sql = pkgs.requireFile {
name = "PlantsForAFuture.sql";
sha256 = "1wya1yb3hyybr71znmag7kq0jdc1cwazaqfp375jbfmaql3r92mq";
message = "PlantsForAFuture.sql, compatible with PostgreSQL";
};
init-database = with pkgs; writeShellScript "populate-database" ''
set -e
${lib.concatMapStrings (name: ''
F=${name}.geojson
[ -e $F ] || ${wget}/bin/wget -O $F "${geojsons.${name}}"
ls -l $F
# ${gdal}/bin/ogr2ogr -f "PostgreSQL" PG:"dbname=treeadvisor user=treeadvisor" -nln ${name} $F
'') (builtins.attrNames geojsons)}
${postgresql}/bin/psql -f ${./db.sql} treeadvisor
${deno}/bin/deno run --allow-read --allow-net ${./import_geojson.ts} GEOENV boden_fkt.geojson wasserhaushalt.geojson wasserspeicher.geojson
${deno}/bin/deno run --allow-read --allow-net ${./import_geojson.ts} TREES trees.geojson
${postgresql}/bin/psql -f ${pfaf_sql} treeadvisor
'';
}

174
import_geojson.ts Normal file
View File

@ -0,0 +1,174 @@
import { Pool } from "https://deno.land/x/postgres/mod.ts";
const GEBIETSTYPES: any = {
"Gewässer": 1,
"versickerungsdominiert": 1,
"verdunstungs- und versickerungsbestimmt": 0.8,
"verdunstungsdominiert": 0.5,
"verdunstungs- und abflussbestimmt": 0.2,
"abflussdominiert": 0.1,
"ausgewogen": 0.8,
};
const MODE = Deno.args[0];
if (!MODE) {
throw "No MODE arg";
}
const dbPool = new Pool({
user: "treeadvisor",
password: "123",
database: "treeadvisor",
hostname: "10.233.1.2",
}, 12);
async function withDb(f: any): Promise<any> {
const client = await dbPool.connect();
let result;
try {
result = await f(client);
} finally {
client.release();
}
return result;
}
const insertGeoenv = async (src: string, coords: any, attrs: any) => {
const coords_s = "(" + coords.map((coords: any) => `(${coords[0]},${coords[1]})`).join(",") + ")";
const attrs_s = JSON.stringify(attrs);
await withDb(async (db: any) => db.queryObject`INSERT INTO areas (src, coords, attrs) VALUES (${src}, ${coords_s}::polygon, ${attrs_s})`);
};
let pfafCache: any = {};
const insertTree = async (coords: any, props: any) => { await withDb(async (db: any) => {
const coords_s = `(${coords[0]},${coords[1]})`;
let botanic = props.art_botanisch
.replace(/,\s*/g, ", ")
.replace(/\.\s+/g, ". ")
.replace(/\s+/g, " ")
.replace(/'/g, "\"")
.replace(/\s+$/, "");
const german = props.art_deutsch && props.art_deutsch
.replace(/\s+-\s+/, "-")
.replace(/\.\s+/g, ". ")
.replace(/\s+x\s+/g, " × ")
.replace(/\s+/g, " ")
.replace(/'/g, "\"")
.replace(/\s+$/, "");
const age = parseInt(props.jalter, 10);
let planted = null;
if (age > 0) {
const m = props.aend_dat.match(/^(\d+)\.(\d+)\.(\d+) /);
if (!m) throw `Invalid date: ${props.aend_dat}`;
const y = parseInt(m[3], 10) - age;
planted = `${y}-${m[2]}-${m[1]}`;
};
let botanic_pfaf;
if (botanic == "Baumart noch nicht bestimmt" || botanic == "unbekannt" || !botanic) {
botanic = null;
} else if (!pfafCache[botanic]) {
const res: any = await db.queryObject`SELECT "Latin name" as botanic FROM "PlantsForAFuture" WHERE "Latin name" ILIKE CONCAT(${botanic.slice(0, 3)}::text, '%') ORDER BY levenshtein("Latin name", ${botanic}) ASC LIMIT 1`;
if (res.rows[0]) {
botanic_pfaf = pfafCache[botanic] = res.rows[0].botanic;
if (botanic != botanic_pfaf) console.log(botanic + " = " + botanic_pfaf);
}
} else {
botanic_pfaf = pfafCache[botanic];
}
let areaSrcSeen: any = {};
let areaInfo: any = {};
let res = await db.queryObject`SELECT src, attrs FROM areas WHERE coords @> $1::point ORDER BY coords <-> ${coords_s}::point ASC`;
res.rows.forEach(function(area: any) {
if (areaSrcSeen[area.src]) return;
Object.keys(area.attrs).forEach(function(k) {
areaInfo[k] = area.attrs[k];
});
});
let scores = [];
if (areaInfo.hasOwnProperty("versiegelung")) scores.push(1 - areaInfo.versiegelung);
if (areaInfo.hasOwnProperty("schutt")) scores.push(1 - areaInfo.schutt);
if (areaInfo.bodenqualitaet) scores.push((areaInfo.bodenqualitaet - 1) / 5);
if (areaInfo.wasserspeicher && areaInfo.wasserspeicher > 10) scores.push((50 - areaInfo.wasserspeicher) / 40)
else if (areaInfo.wasserspeicher) scores.push((5 - areaInfo.wasserspeicher) / 4);
if (areaInfo.gebietstyp) scores.push(GEBIETSTYPES[areaInfo.gebietstyp]);
let score: number | null = 0;
for(const s of scores) {
score += s;
}
if (scores.length > 0) {
score /= scores.length;
score = Math.max(0, Math.min(1, score));
} else {
score = null;
}
await db.queryObject`INSERT INTO trees (id, coord, score, botanic, botanic_pfaf, german, planted) VALUES (${props.id}, ${coords_s}, ${score}, ${botanic}, ${botanic_pfaf}, ${german}, ${planted})`;
}) };
const importFeatures = async (src: string, geojson: any) => {
if (geojson.type != "FeatureCollection") {
console.warn("Not a FeatureCollection");
return;
}
// var progress = 0, i = 0;
let promises = [];
for(const feature of geojson.features) { promises.push((async () => {
if (MODE == "GEOENV") {
if (feature.type == "Feature" &&
feature.geometry.type == "Polygon" &&
feature.geometry.crs.properties.name == "urn:ogc:def:crs:EPSG::4326") {
for(const coords of feature.geometry.coordinates) {
await insertGeoenv(src, coords, feature.properties);
}
// await insertGeoenv(feature.geometry.coordinates, feature.properties);
} else if (feature.type == "FeatureCollection") {
await importFeatures(src, feature);
} else if (feature.type == "Feature" &&
feature.geometry.type == "MultiPolygon" &&
feature.geometry.crs.properties.name == "urn:ogc:def:crs:EPSG::4326") {
for(const coordinates of feature.geometry.coordinates) {
for(const coords of coordinates) {
await insertGeoenv(src, coords, feature.properties);
}
}
} else {
console.warn("Not recognized:", feature);
// console.warn(`Not recognized: ${JSON.stringify(geojson)}`);
}
} else if (MODE == "TREES") {
if (feature.type == "Feature" &&
feature.geometry.type == "Point" &&
feature.geometry.crs.properties.name == "urn:ogc:def:crs:EPSG::4326") {
await insertTree(feature.geometry.coordinates, feature.properties);
} else {
console.warn("Not recognized:", feature);
// console.warn(`Not recognized: ${JSON.stringify(geojson)}`);
}
}
// i += 1;
// var newProgress = Math.floor(100 * i / geojson.features.length);
// if (newProgress != progress) {
// progress = newProgress;
// console.log(progress + "%");
// }
})()); }
console.log("wait for " + promises.length + " promises");
await Promise.all(promises);
};
for(const filename of Deno.args.slice(1)) {
console.log(`Load ${filename}`)
const geojson = JSON.parse(await Deno.readTextFile(filename));
await importFeatures(filename, geojson);
}

54
nixos-module.nix Normal file
View File

@ -0,0 +1,54 @@
{ pkgs, config, lib, ... }:
let
in
{
config = {
networking.hostName = "treeadvisor";
users.users.treeadvisor = {
isSystemUser = true;
group = "treeadvisor";
};
users.groups.treeadvisor = {};
services.postgresql = {
enable = true;
extraPlugins = with pkgs; [ postgis ];
# TODO: tmp
enableTCPIP = true;
authentication = ''
host all all 0.0.0.0/0 md5
'';
ensureDatabases = [ "treeadvisor" ];
ensureUsers = [ {
name = "treeadvisor";
ensurePermissions = {
"DATABASE treeadvisor" = "ALL PRIVILEGES";
};
} ];
};
systemd.tmpfiles.rules = [
"d /var/lib/treeadvisor 0755 treeadvisor treeadvisor -"
];
systemd.services.treeadvisor-db-init = {
wantedBy = [ "multi-user.target" ];
unitConfig.ConditionPathExists = [ "!/var/lib/treeadvisor/.db-init" ];
serviceConfig = {
User = "treeadvisor";
Group = "treeadvisor";
};
environment.HOME = "/tmp";
script = ''
cd /var/lib/treeadvisor
${(import ./. { inherit pkgs; }).init-database}
touch /var/lib/treeadvisor/.db-init
'';
};
};
}

18
server/Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "treeadvisor-server"
version = "0.0.0"
authors = ["Astro <astro@spaceboyz.net>"]
edition = "2018"
license = "AGPL-3.0-or-later"
[dependencies]
gotham = "0.5"
gotham_derive = "0.5"
postgres = { version = "0.19", features = ["with-geo-types-0_7", "with-serde_json-1", "with-chrono-0_4"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
mime = "0.3"
http = "0.2"
geo = "0.18"
cairo-rs = { version = "0.14", features = ["png"] }
chrono = { version = "0.4", features = ["serde"] }

162
server/src/area.rs Normal file
View File

@ -0,0 +1,162 @@
use std::collections::HashSet;
use std::io::Cursor;
use gotham::{
helpers::http::response::create_response,
hyper::{Body, Response},
state::{FromState, State},
};
use geo::{Rect, LineString};
use serde::Deserialize;
use mime::{APPLICATION_JSON, IMAGE_PNG};
use http::StatusCode;
use cairo::{ImageSurface, Context};
use crate::{AppState, AreaExtractor, PointExtractor};
pub fn get_details(state: State) -> (State, Response<Body>) {
let pe = PointExtractor::borrow_from(&state);
let app_state = AppState::borrow_from(&state);
let mut seen_srcs = HashSet::new();
let result = {
let mut db = app_state.db.lock().unwrap();
db.query("SELECT src, attrs FROM areas WHERE coords @> $1::point ORDER BY coords <-> $1::point ASC", &[
&pe.to_point()
]).unwrap()
}.into_iter().filter(|row| {
let src: &str = row.get(0);
if seen_srcs.contains(src) {
false
} else {
seen_srcs.insert(src.to_owned());
true
}
}).map(|row| {
let attrs: serde_json::Value = row.get(1);
attrs
}).collect();
let body = serde_json::to_string(&serde_json::Value::Array(result)).unwrap();
let res = create_response(&state, StatusCode::OK, APPLICATION_JSON, body);
(state, res)
}
pub fn get_heatmap(state: State) -> (State, Response<Body>) {
let pe = AreaExtractor::borrow_from(&state);
let app_state = AppState::borrow_from(&state);
let result = {
let mut db = app_state.db.lock().unwrap();
db.query("SELECT path(coords), src, attrs FROM areas WHERE box(coords) ?# $1::box", &[
&pe.to_rect()
]).unwrap()
};
const TILE_MAX: i32 = 256;
let (w, h) = if pe.w() > pe.h() {
(TILE_MAX, (TILE_MAX as f64 * pe.h() / pe.w()) as i32)
} else {
(((TILE_MAX as f64 * pe.w() / pe.h()) as i32), TILE_MAX)
};
println!("{}x{}", w, h);
let s = ImageSurface::create(cairo::Format::ARgb32, w, h).unwrap();
let ctx = Context::new(&s).unwrap();
ctx.set_antialias(cairo::Antialias::Fast);
for row in result {
// let src: String = row.get(1);
let attrs: serde_json::Value = row.get(2);
// println!("src: {:?} attrs: {:?}", src, attrs);
let coords: LineString<f64> = row.get(0);
let points = coords.into_points();
// println!("{} ps", points.len());
ctx.move_to((points[0].x() - pe.x1) * (w as f64) / pe.w(), (pe.y2 - points[0].y()) * (h as f64) / pe.h());
for point in &points[1..] {
ctx.line_to((point.x() - pe.x1) * (w as f64) / pe.w(), (pe.y2 - point.y()) * (h as f64) / pe.h());
}
ctx.close_path();
let (r, g, b, a) = area_color(&attrs);
ctx.set_source_rgba(r, g, b, a);
ctx.fill().unwrap();
}
let mut buffer = Cursor::new(vec![]);
s.write_to_png(&mut buffer).unwrap();
let res = create_response(&state, StatusCode::OK, IMAGE_PNG, buffer.into_inner());
(state, res)
}
// 0.0: green, 0.5: yellow, 1.0: red
fn hue(mut x: f64) -> (f64, f64, f64) {
x = x.max(0.).min(1.);
if x < 0.5 {
(2.0 * x, 1.0, 0.0)
} else {
(1.0, 2.0 - 2.0 * x, 0.0)
}
}
fn area_color(attrs: &serde_json::Value) -> (f64, f64, f64, f64) {
if let serde_json::Value::Object(attrs) = attrs {
// bodenquali
if let Some(serde_json::Value::Number(q)) = attrs.get("bodenqualitaet") {
if let Some(q) = q.as_f64() {
let (r, g, b) = hue(1.0 - q / 6.0);
return (r, g, b, 0.2);
}
}
if let Some(serde_json::Value::Number(q)) = attrs.get("wasserspeicher") {
if let Some(mut q) = q.as_f64() {
if q > 10. {
q /= 10.;
}
let (r, g, b) = hue((5. - q) / 4.);
return (r, g, b, 0.3);
}
}
// wasserhaushalt
if let Some(serde_json::Value::String(typ)) = attrs.get("gebietstyp") {
match typ.as_str() {
"Gewässer" => return (0., 1., 0., 0.2),
"versickerungsdominiert" => return (0., 1., 0., 0.1),
"verdunstungs- und versickerungsbestimmt" => return (0.5, 1., 0., 0.05),
"verdunstungsdominiert" => return (1.0, 1.0, 0., 0.1),
"verdunstungs- und abflussbestimmt" => return (1.0, 0.5, 0., 0.05),
"abflussdominiert" => return (1., 0., 0., 0.1),
"ausgewogen" => return (0., 0., 0., 0.),
_ => println!("typ: {}", typ),
}
}
// boden_fkt
let mut x = 0.0;
let mut a = 0.0;
if let Some(serde_json::Value::Number(q)) = attrs.get("schutt") {
if let Some(q) = q.as_f64() {
if q > 1. {
println!("schutt: {}", q);
}
x += q / 1.5;
a += 0.1;
}
}
if let Some(serde_json::Value::Number(q)) = attrs.get("versiegelung") {
if let Some(q) = q.as_f64() {
if q > 5. {
println!("versiegelung: {}", q);
}
x += q / 5.0;
a += 0.1;
}
}
let (r, g, b) = hue(x);
return (r, g, b, a);
}
println!("not painted: {:?}", attrs);
(0., 0., 0., 0.)
}

117
server/src/main.rs Normal file
View File

@ -0,0 +1,117 @@
// #![recursion_limit="2048"]
#[macro_use]
extern crate gotham_derive;
use std::sync::{Arc, Mutex};
use gotham::{
handler::assets::FileOptions,
router::builder::{DefineSingleRoute, DrawRoutes},
middleware::state::StateMiddleware,
pipeline::single::single_pipeline,
pipeline::single_middleware,
router::builder::*,
};
use std::io::Cursor;
use gotham::{
helpers::http::response::create_response,
hyper::{Body, Response},
state::{FromState, State},
};
use geo::{Rect, Point};
use serde::Deserialize;
mod area;
mod trees;
#[derive(Clone, StateData)]
pub struct AppState {
pub db: Arc<Mutex<postgres::Client>>,
}
#[derive(Debug, Deserialize, StateData, StaticResponseExtender)]
pub struct IdExtractor {
pub id: String,
}
#[derive(Debug, Deserialize, StateData, StaticResponseExtender)]
pub struct AreaExtractor {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
}
impl AreaExtractor {
fn grow(&self, a: f64) -> Self {
AreaExtractor {
x1: self.x1 - a * self.w(),
y1: self.y1 - a * self.h(),
x2: self.x2 + a * self.w(),
y2: self.y2 + a * self.h(),
}
}
fn to_rect(&self) -> Rect<f64> {
Rect::new((self.x1, self.y1), (self.x2, self.y2))
}
fn w(&self) -> f64 {
self.x2 - self.x1
}
fn h(&self) -> f64 {
self.y2 - self.y1
}
}
#[derive(Debug, Deserialize, StateData, StaticResponseExtender)]
pub struct PointExtractor {
x: f64,
y: f64,
}
impl PointExtractor {
fn to_point(&self) -> Point<f64> {
Point::new(self.x, self.y)
}
}
fn main() {
const DB_URL: &str = "host=10.233.1.2 dbname=treeadvisor user=treeadvisor password=123";
let db = postgres::Client::connect(DB_URL, postgres::NoTls)
.expect("DB");
let state = AppState {
db: Arc::new(Mutex::new(db)),
};
let (chain, pipelines) = single_pipeline(
single_middleware(
StateMiddleware::new(state)
)
);
let router = build_router(chain, pipelines, |route| {
route.get("/heatmap/:x1/:y1/:x2/:y2")
.with_path_extractor::<AreaExtractor>()
.to(trees::get_heatmap);
route.get("/area/:x/:y")
.with_path_extractor::<PointExtractor>()
.to(area::get_details);
route.get("/trees/:x1/:y1/:x2/:y2")
.with_path_extractor::<AreaExtractor>()
.to(trees::get_trees);
route.get("/tree/:id")
.with_path_extractor::<IdExtractor>()
.to(trees::get_tree);
route.get("static/*").to_dir(
FileOptions::new(&"static")
.with_cache_control("no-cache")
.with_gzip(true)
.build()
);
});
gotham::start("0.0.0.0:8400", router);
}

167
server/src/trees.rs Normal file
View File

@ -0,0 +1,167 @@
use std::collections::HashMap;
use std::io::Cursor;
use gotham::{
helpers::http::response::create_response,
hyper::{Body, Response},
state::{FromState, State},
};
use geo::Point;
use serde::Serialize;
use mime::{APPLICATION_JSON, IMAGE_PNG};
use http::StatusCode;
use chrono::NaiveDate;
use cairo::{ImageSurface, Context};
use crate::{AppState, AreaExtractor, IdExtractor};
#[derive(Debug, Serialize)]
struct Tree {
id: String,
coords: [f64; 2],
german: Option<String>,
botanic: Option<String>,
details: Option<HashMap<String, String>>,
planted: Option<NaiveDate>,
}
pub fn get_trees(state: State) -> (State, Response<Body>) {
let pe = AreaExtractor::borrow_from(&state);
let app_state = AppState::borrow_from(&state);
let result = {
let mut db = app_state.db.lock().unwrap();
db.query("SELECT id, coord, botanic, german, planted FROM trees WHERE coord <@ $1::box ORDER BY coord <-> center($1::box) ASC LIMIT 2000", &[
&pe.to_rect()
]).unwrap()
}.into_iter().map(|row| {
let point: Point<f64> = row.get(1);
Tree {
id: row.get(0),
coords: [point.x(), point.y()],
botanic: row.get(2),
details: None,
german: row.get(3),
planted: row.get(4),
}
}).collect::<Vec<_>>();
let body = serde_json::to_string(&result).unwrap();
let res = create_response(&state, StatusCode::OK, APPLICATION_JSON, body);
(state, res)
}
const PFAF_COLS: &[&str] = &[
"Latin name", "Common name", "Family", "Synonyms",
"Known hazards", "Range", "Habitat", "Rating",
"Medicinal Rating", "Habit", "Height", "Width",
"Hardyness", "FrostTender", "In leaf", "Scented",
"Flowering time", "Seed ripens", "Flower Type", "Pollinators",
"Self-fertile", "Nitrogen fixer", "Wildlife", "Soil",
"Well-drained", "pH", "Shade", "Moisture",
"Saline", "Wind", "Edible uses", "Medicinal",
"Uses notes", "In cultivation?", "Cultivation details", "Growth rate",
"Pollution", "Propagation 1", "Poor soil", "Drought",
"Woodland", "Meadow", "Wall", "Acid",
"Alkaline", "Heavy clay", "Cultivars", "Cultivars in cultivation",
"Pull-out", "Last update", "Record checked", "SiteSpecificNotes",
"Deciduous/Evergreen", "Author", "Botanical references"
];
pub fn get_tree(state: State) -> (State, Response<Body>) {
let ie = IdExtractor::borrow_from(&state);
let app_state = AppState::borrow_from(&state);
let result = {
let mut db = app_state.db.lock().unwrap();
let row = db.query("SELECT id, coord, botanic, botanic_pfaf, german, planted FROM trees WHERE id=$1 LIMIT 1", &[&ie.id]).unwrap()
.into_iter()
.next();
if let Some(row) = row {
let botanic_pfaf: &str = row.get(3);
let mut query = format!("SELECT ");
for (i, col) in PFAF_COLS.iter().enumerate() {
query = format!("{}{}\"{}\"::text", query, if i == 0 { "" } else { ", " }, col);
}
query = format!("{} FROM \"PlantsForAFuture\" WHERE \"Latin name\"=$1 LIMIT 1", query);
let pfaf_row = db.query(query.as_str(), &[&botanic_pfaf]).unwrap()
.into_iter()
.next();
pfaf_row.map(|pfaf_row| {
let point: Point<f64> = row.get(1);
let mut details: HashMap<String, String> = HashMap::with_capacity(PFAF_COLS.len());
for (i, col) in PFAF_COLS.iter().enumerate() {
details.insert(col.to_string(), pfaf_row.get(i));
}
Tree {
id: row.get(0),
coords: [point.x(), point.y()],
botanic: row.get(2),
german: row.get(4),
details: Some(details),
planted: row.get(5),
}
})
} else {
None
}
};
let res = match result {
Some(result) => {
let body = serde_json::to_string(&result).unwrap();
create_response(&state, StatusCode::OK, APPLICATION_JSON, body)
}
None =>
create_response(&state, StatusCode::NOT_FOUND, APPLICATION_JSON, "{}"),
};
(state, res)
}
pub fn get_heatmap(state: State) -> (State, Response<Body>) {
let pe = AreaExtractor::borrow_from(&state);
let app_state = AppState::borrow_from(&state);
let result = {
let mut db = app_state.db.lock().unwrap();
db.query("SELECT coord, score FROM trees WHERE coord <@ $1::box AND SCORE IS NOT NULL", &[
&pe.grow(0.2).to_rect()
]).unwrap()
};
const TILE_SIZE: i32 = 256;
// let (w, h) = if pe.w() / 2. > pe.h() {
// (TILE_MAX / 2, (TILE_MAX as f64 * pe.h() / pe.w()) as i32)
// } else {
// (((TILE_MAX as f64 / 2. * pe.w() / pe.h()) as i32), TILE_MAX)
// };
let (w, h) = (TILE_SIZE, TILE_SIZE);
// println!("{}x{} ({}x{})", w, h, pe.w(), pe.h());
let s = ImageSurface::create(cairo::Format::ARgb32, w, h).unwrap();
let ctx = Context::new(&s).unwrap();
ctx.set_antialias(cairo::Antialias::Fast);
for row in result {
let point: Point<f64> = row.get(0);
let score: f64 = row.get(1);
let (r, g, b) = temperature(1. - score);
ctx.set_source_rgba(r, g, b, 0.1);
let radius = 1.5 + 0.03 / pe.w();
// println!("radius: {}", radius);
ctx.arc((point.x() - pe.x1) * (w as f64) / pe.w(), (pe.y2 - point.y()) * (h as f64) / pe.h(),
radius, 0., 2. * core::f64::consts::PI);
ctx.fill().unwrap();
}
let mut buffer = Cursor::new(vec![]);
s.write_to_png(&mut buffer).unwrap();
let res = create_response(&state, StatusCode::OK, IMAGE_PNG, buffer.into_inner());
(state, res)
}
// 0.0: green, 0.5: yellow, 1.0: red
fn temperature(mut x: f64) -> (f64, f64, f64) {
x = x.max(0.).min(1.);
if x < 0.5 {
(2.0 * x, 1.0, 0.0)
} else {
(1.0, 2.0 - 2.0 * x, 0.0)
}
}

151
server/static/app.js Normal file
View File

@ -0,0 +1,151 @@
const TREES_MIN_ZOOM = 17;
var map = L.map('map').setView([51.05, 13.75], TREES_MIN_ZOOM, {
maxZoom: 20,
});
var tiles = L.tileLayer('https://stamen-tiles.a.ssl.fastly.net/terrain/{z}/{x}/{y}.jpg', {
attribution: '&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 17,
}).addTo(map);
var HeatmapLayer = L.TileLayer.extend({
getTileUrl: function (coords) {
var cs = this._tileCoordsToNwSe(coords);
var x1 = Math.min(cs[0].lng, cs[1].lng);
var y1 = Math.min(cs[0].lat, cs[1].lat);
var x2 = Math.max(cs[0].lng, cs[1].lng);
var y2 = Math.max(cs[0].lat, cs[1].lat);
return ["", "heatmap", x1, y1, x2, y2].join("/");
},
});
(new HeatmapLayer({
maxZoom: TREES_MIN_ZOOM - 1,
opacity: 0.4,
})).addTo(map);
var treeIcon = L.icon({
iconUrl: "tree.png",
iconSize: [12, 12],
iconAnchor: [6, 6],
popupAnchor: [0, -6],
});
function treePopup(coords, entry) {
var popup = L.popup()
.setLatLng(coords);
popup.on('add', function() {
var info = {};
function update(newInfo) {
Object.keys(newInfo).forEach(function(k) {
info[k] = newInfo[k];
});
popup.setContent(function() {
var div = document.createElement("div");
var h2 = document.createElement("h2");
h2.textContent = entry.german;
if (entry.age)
h2.textContent += " (" + entry.age + ")";
div.appendChild(h2);
var p = document.createElement("p");
p.textContent = entry.botanic;
div.appendChild(p);
if (info.area) {
p = document.createElement("p");
p.textContent = JSON.stringify(info.area);
div.appendChild(p);
}
if (info.tree) {
p = document.createElement("p");
p.style.maxHeight = "10em";
p.style.overflow = "scroll";
p.textContent = JSON.stringify(info.tree);
div.appendChild(p);
}
return div;
});
}
update({});
fetch(["", "area", coords[1], coords[0]].join("/"))
.then(res => res.json())
.then(data => {
update({ area: data });
});
fetch(["", "tree", entry.id].join("/"))
.then(res => res.json())
.then(data => {
console.log("tree data", data);
update({ tree: data });
});
});
return popup;
}
var trees;
var trees_pending = false;
var visible_trees;
function updateTrees() {
if (map.getZoom() < TREES_MIN_ZOOM) {
if (trees) {
trees.remove();
trees = null;
}
return;
}
var bounds = map.getBounds();
if (!trees) {
trees = L.layerGroup().addTo(map);
visible_trees = {};
} else {
trees.eachLayer(function(marker) {
var ll = marker.getLatLng();
if (ll.lng < bounds.getWest() || ll.lng > bounds.getEast() ||
ll.lat < bounds.getSouth() || ll.lat > bounds.getNorth()) {
delete visible_trees[marker.options.id];
trees.removeLayer(marker);
}
});
}
if (trees_pending) return;
trees_pending = true;
fetch(["", "trees", bounds.getWest(), bounds.getSouth(), bounds.getEast(), bounds.getNorth()].join("/"))
.then(res => res.json())
.then(data => {
if (map.getZoom() < TREES_MIN_ZOOM) {
return;
}
data.forEach(function(entry) {
var coords = [entry.coords[1], entry.coords[0]];
if (entry.planted) {
entry.age = Math.round((Date.now() - Date.parse(entry.planted)) / (365.25 * 24 * 60 * 60 * 1000));
}
if (!visible_trees[entry.id]) {
visible_trees[entry.id] = true;
var marker = L.marker(coords, {
id: entry.id,
title: entry.german,
icon: treeIcon,
}).bindPopup(treePopup(coords, entry));
trees.addLayer(marker);
}
});
trees_pending = false;
})
.catch (error => {
console.error('Error:' + error)
trees_pending = false;
});
}
map.on('moveend', updateTrees);
map.on('zoomend', updateTrees);
updateTrees();

19
server/static/index.html Normal file
View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Dresden Tree Advisor</title>
<link rel="stylesheet" href="leaflet.css" />
<script src="leaflet.js"></script>
<style>
#map { margin: 0; padding: 0; width: 100vw; height: 100vh; }
body { margin: 0; padding: 0; font-family: sans-serif; }
</style>
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
</head>
<body>
<div id="map"></div>
<script src="app.js"></script>
</body>
</html>

640
server/static/leaflet.css Normal file
View File

@ -0,0 +1,640 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg,
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-tile {
will-change: opacity;
}
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline: 0;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-container a.leaflet-active {
outline: 2px solid orange;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font: 12px/1.5 "Helvetica Neue", Arial, Helvetica, sans-serif;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a,
.leaflet-bar a:hover {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path {
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.7);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover {
text-decoration: underline;
}
.leaflet-container .leaflet-control-attribution,
.leaflet-container .leaflet-control-scale {
font-size: 11px;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: #fff;
background: rgba(255, 255, 255, 0.5);
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 19px;
line-height: 1.4;
}
.leaflet-popup-content p {
margin: 18px 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
padding: 4px 4px 0 0;
border: none;
text-align: center;
width: 18px;
height: 14px;
font: 16px/14px Tahoma, Verdana, sans-serif;
color: #c3c3c3;
text-decoration: none;
font-weight: bold;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover {
color: #999;
}
.leaflet-popup-scrolled {
overflow: auto;
border-bottom: 1px solid #ddd;
border-top: 1px solid #ddd;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-popup-tip-container {
margin-top: -1px;
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-clickable {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}

6
server/static/leaflet.js Normal file

File diff suppressed because one or more lines are too long

BIN
server/static/tree.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB