From 8b20c84638eae3bb05fd77e33cc2897190c85708 Mon Sep 17 00:00:00 2001 From: Astro Date: Thu, 26 Aug 2021 01:13:49 +0200 Subject: [PATCH] PoC --- db.sql | 20 ++ default.nix | 46 +++ import_geojson.ts | 174 +++++++++++ nixos-module.nix | 54 ++++ server/Cargo.toml | 18 ++ server/src/area.rs | 162 ++++++++++ server/src/main.rs | 117 +++++++ server/src/trees.rs | 167 ++++++++++ server/static/app.js | 151 +++++++++ server/static/index.html | 19 ++ server/static/leaflet.css | 640 ++++++++++++++++++++++++++++++++++++++ server/static/leaflet.js | 6 + server/static/tree.png | Bin 0 -> 9393 bytes 13 files changed, 1574 insertions(+) create mode 100644 db.sql create mode 100644 default.nix create mode 100644 import_geojson.ts create mode 100644 nixos-module.nix create mode 100644 server/Cargo.toml create mode 100644 server/src/area.rs create mode 100644 server/src/main.rs create mode 100644 server/src/trees.rs create mode 100644 server/static/app.js create mode 100644 server/static/index.html create mode 100644 server/static/leaflet.css create mode 100644 server/static/leaflet.js create mode 100644 server/static/tree.png diff --git a/db.sql b/db.sql new file mode 100644 index 0000000..548b1cc --- /dev/null +++ b/db.sql @@ -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)); diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..3a18a00 --- /dev/null +++ b/default.nix @@ -0,0 +1,46 @@ +{ pkgs ? import {} }: + +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 + ''; +} diff --git a/import_geojson.ts b/import_geojson.ts new file mode 100644 index 0000000..e4b7406 --- /dev/null +++ b/import_geojson.ts @@ -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 { + 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); +} diff --git a/nixos-module.nix b/nixos-module.nix new file mode 100644 index 0000000..86dd8f7 --- /dev/null +++ b/nixos-module.nix @@ -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 + ''; + }; + }; +} diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..9d4a79a --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "treeadvisor-server" +version = "0.0.0" +authors = ["Astro "] +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"] } diff --git a/server/src/area.rs b/server/src/area.rs new file mode 100644 index 0000000..53be199 --- /dev/null +++ b/server/src/area.rs @@ -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) { + 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) { + 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 = 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.) +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..d881fbb --- /dev/null +++ b/server/src/main.rs @@ -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>, +} + + +#[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 { + 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 { + 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::() + .to(trees::get_heatmap); + route.get("/area/:x/:y") + .with_path_extractor::() + .to(area::get_details); + route.get("/trees/:x1/:y1/:x2/:y2") + .with_path_extractor::() + .to(trees::get_trees); + route.get("/tree/:id") + .with_path_extractor::() + .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); +} diff --git a/server/src/trees.rs b/server/src/trees.rs new file mode 100644 index 0000000..3c5b622 --- /dev/null +++ b/server/src/trees.rs @@ -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, + botanic: Option, + details: Option>, + planted: Option, +} + +pub fn get_trees(state: State) -> (State, Response) { + 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 = 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::>(); + + 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) { + 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 = row.get(1); + let mut details: HashMap = 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) { + 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 = 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) + } +} diff --git a/server/static/app.js b/server/static/app.js new file mode 100644 index 0000000..48c0ba1 --- /dev/null +++ b/server/static/app.js @@ -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: '© OpenStreetMap 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(); diff --git a/server/static/index.html b/server/static/index.html new file mode 100644 index 0000000..67741ac --- /dev/null +++ b/server/static/index.html @@ -0,0 +1,19 @@ + + + + + Dresden Tree Advisor + + + + + + + +
+ + + diff --git a/server/static/leaflet.css b/server/static/leaflet.css new file mode 100644 index 0000000..601476f --- /dev/null +++ b/server/static/leaflet.css @@ -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; + } diff --git a/server/static/leaflet.js b/server/static/leaflet.js new file mode 100644 index 0000000..21f499c --- /dev/null +++ b/server/static/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.7.1, a JS library for interactive maps. http://leafletjs.com + * (c) 2010-2019 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?i(exports):"function"==typeof define&&define.amd?define(["exports"],i):i(t.L={})}(this,function(t){"use strict";function h(t){for(var i,e,n=1,o=arguments.length;n=this.min.x&&e.x<=this.max.x&&i.y>=this.min.y&&e.y<=this.max.y},intersects:function(t){t=O(t);var i=this.min,e=this.max,n=t.min,o=t.max,s=o.x>=i.x&&n.x<=e.x,r=o.y>=i.y&&n.y<=e.y;return s&&r},overlaps:function(t){t=O(t);var i=this.min,e=this.max,n=t.min,o=t.max,s=o.x>i.x&&n.xi.y&&n.y=n.lat&&e.lat<=o.lat&&i.lng>=n.lng&&e.lng<=o.lng},intersects:function(t){t=N(t);var i=this._southWest,e=this._northEast,n=t.getSouthWest(),o=t.getNorthEast(),s=o.lat>=i.lat&&n.lat<=e.lat,r=o.lng>=i.lng&&n.lng<=e.lng;return s&&r},overlaps:function(t){t=N(t);var i=this._southWest,e=this._northEast,n=t.getSouthWest(),o=t.getNorthEast(),s=o.lat>i.lat&&n.lati.lng&&n.lng';var i=t.firstChild;return i.style.behavior="url(#default#VML)",i&&"object"==typeof i.adj}catch(t){return!1}}();function kt(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var Bt={ie:tt,ielt9:it,edge:et,webkit:nt,android:ot,android23:st,androidStock:at,opera:ht,chrome:ut,gecko:lt,safari:ct,phantom:_t,opera12:dt,win:pt,ie3d:mt,webkit3d:ft,gecko3d:gt,any3d:vt,mobile:yt,mobileWebkit:xt,mobileWebkit3d:wt,msPointer:Pt,pointer:Lt,touch:bt,mobileOpera:Tt,mobileGecko:Mt,retina:zt,passiveEvents:Ct,canvas:St,svg:Zt,vml:Et},At=Pt?"MSPointerDown":"pointerdown",It=Pt?"MSPointerMove":"pointermove",Ot=Pt?"MSPointerUp":"pointerup",Rt=Pt?"MSPointerCancel":"pointercancel",Nt={},Dt=!1;function jt(t,i,e,n){function o(t){Ut(t,r)}var s,r,a,h,u,l,c,_;function d(t){t.pointerType===(t.MSPOINTER_TYPE_MOUSE||"mouse")&&0===t.buttons||Ut(t,h)}return"touchstart"===i?(u=t,l=e,c=n,_=p(function(t){t.MSPOINTER_TYPE_TOUCH&&t.pointerType===t.MSPOINTER_TYPE_TOUCH&&Ri(t),Ut(t,l)}),u["_leaflet_touchstart"+c]=_,u.addEventListener(At,_,!1),Dt||(document.addEventListener(At,Wt,!0),document.addEventListener(It,Ht,!0),document.addEventListener(Ot,Ft,!0),document.addEventListener(Rt,Ft,!0),Dt=!0)):"touchmove"===i?(h=e,(a=t)["_leaflet_touchmove"+n]=d,a.addEventListener(It,d,!1)):"touchend"===i&&(r=e,(s=t)["_leaflet_touchend"+n]=o,s.addEventListener(Ot,o,!1),s.addEventListener(Rt,o,!1)),this}function Wt(t){Nt[t.pointerId]=t}function Ht(t){Nt[t.pointerId]&&(Nt[t.pointerId]=t)}function Ft(t){delete Nt[t.pointerId]}function Ut(t,i){for(var e in t.touches=[],Nt)t.touches.push(Nt[e]);t.changedTouches=[t],i(t)}var Vt=Pt?"MSPointerDown":Lt?"pointerdown":"touchstart",qt=Pt?"MSPointerUp":Lt?"pointerup":"touchend",Gt="_leaflet_";var Kt,Yt,Xt,Jt,$t,Qt,ti=fi(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ii=fi(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),ei="webkitTransition"===ii||"OTransition"===ii?ii+"End":"transitionend";function ni(t){return"string"==typeof t?document.getElementById(t):t}function oi(t,i){var e,n=t.style[i]||t.currentStyle&&t.currentStyle[i];return n&&"auto"!==n||!document.defaultView||(n=(e=document.defaultView.getComputedStyle(t,null))?e[i]:null),"auto"===n?null:n}function si(t,i,e){var n=document.createElement(t);return n.className=i||"",e&&e.appendChild(n),n}function ri(t){var i=t.parentNode;i&&i.removeChild(t)}function ai(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function hi(t){var i=t.parentNode;i&&i.lastChild!==t&&i.appendChild(t)}function ui(t){var i=t.parentNode;i&&i.firstChild!==t&&i.insertBefore(t,i.firstChild)}function li(t,i){if(void 0!==t.classList)return t.classList.contains(i);var e=pi(t);return 0this.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,i){this._enforcingBounds=!0;var e=this.getCenter(),n=this._limitCenter(e,this._zoom,N(t));return e.equals(n)||this.panTo(n,i),this._enforcingBounds=!1,this},panInside:function(t,i){var e,n,o=A((i=i||{}).paddingTopLeft||i.padding||[0,0]),s=A(i.paddingBottomRight||i.padding||[0,0]),r=this.getCenter(),a=this.project(r),h=this.project(t),u=this.getPixelBounds(),l=u.getSize().divideBy(2),c=O([u.min.add(o),u.max.subtract(s)]);return c.contains(h)||(this._enforcingBounds=!0,e=a.subtract(h),n=A(h.x+e.x,h.y+e.y),(h.xc.max.x)&&(n.x=a.x-e.x,0c.max.y)&&(n.y=a.y-e.y,0=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,i){for(var e,n=[],o="mouseout"===i||"mouseover"===i,s=t.target||t.srcElement,r=!1;s;){if((e=this._targets[m(s)])&&("click"===i||"preclick"===i)&&!t._simulated&&this._draggableMoved(e)){r=!0;break}if(e&&e.listens(i,!0)){if(o&&!Vi(s,t))break;if(n.push(e),o)break}if(s===this._container)break;s=s.parentNode}return n.length||r||o||!Vi(s,t)||(n=[this]),n},_handleDOMEvent:function(t){var i;this._loaded&&!Ui(t)&&("mousedown"!==(i=t.type)&&"keypress"!==i&&"keyup"!==i&&"keydown"!==i||Pi(t.target||t.srcElement),this._fireDOMEvent(t,i))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,i,e){var n;if("click"===t.type&&((n=h({},t)).type="preclick",this._fireDOMEvent(n,n.type,e)),!t._stopped&&(e=(e||[]).concat(this._findEventTargets(t,i))).length){var o=e[0];"contextmenu"===i&&o.listens(i,!0)&&Ri(t);var s,r={originalEvent:t};"keypress"!==t.type&&"keydown"!==t.type&&"keyup"!==t.type&&(s=o.getLatLng&&(!o._radius||o._radius<=10),r.containerPoint=s?this.latLngToContainerPoint(o.getLatLng()):this.mouseEventToContainerPoint(t),r.layerPoint=this.containerPointToLayerPoint(r.containerPoint),r.latlng=s?o.getLatLng():this.layerPointToLatLng(r.layerPoint));for(var a=0;athis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(i),o=this._getCenterOffset(t)._divideBy(1-1/n);return!(!0!==e.animate&&!this.getSize().contains(o))&&(M(function(){this._moveStart(!0,!1)._animateZoom(t,i,!0)},this),!0)},_animateZoom:function(t,i,e,n){this._mapPane&&(e&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=i,ci(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:i,noUpdate:n}),setTimeout(p(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&_i(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom),M(function(){this._moveEnd(!0)},this))}});function Yi(t){return new Xi(t)}var Xi=S.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var i=this._map;return i&&i.removeControl(this),this.options.position=t,i&&i.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var i=this._container=this.onAdd(t),e=this.getPosition(),n=t._controlCorners[e];return ci(i,"leaflet-control"),-1!==e.indexOf("bottom")?n.insertBefore(i,n.firstChild):n.appendChild(i),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(ri(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",n=document.createElement("div");return n.innerHTML=e,n.firstChild},_addItem:function(t){var i,e=document.createElement("label"),n=this._map.hasLayer(t.layer);t.overlay?((i=document.createElement("input")).type="checkbox",i.className="leaflet-control-layers-selector",i.defaultChecked=n):i=this._createRadioElement("leaflet-base-layers_"+m(this),n),this._layerControlInputs.push(i),i.layerId=m(t.layer),zi(i,"click",this._onInputClick,this);var o=document.createElement("span");o.innerHTML=" "+t.name;var s=document.createElement("div");return e.appendChild(s),s.appendChild(i),s.appendChild(o),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(e),this._checkDisabledLayers(),e},_onInputClick:function(){var t,i,e=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=e.length-1;0<=s;s--)t=e[s],i=this._getLayer(t.layerId).layer,t.checked?n.push(i):t.checked||o.push(i);for(s=0;si.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expand:function(){return this.expand()},_collapse:function(){return this.collapse()}}),$i=Xi.extend({options:{position:"topleft",zoomInText:"+",zoomInTitle:"Zoom in",zoomOutText:"−",zoomOutTitle:"Zoom out"},onAdd:function(t){var i="leaflet-control-zoom",e=si("div",i+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,i+"-in",e,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,i+"-out",e,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),e},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,i,e,n,o){var s=si("a",e,n);return s.innerHTML=t,s.href="#",s.title=i,s.setAttribute("role","button"),s.setAttribute("aria-label",i),Oi(s),zi(s,"click",Ni),zi(s,"click",o,this),zi(s,"click",this._refocusOnMap,this),s},_updateDisabled:function(){var t=this._map,i="leaflet-disabled";_i(this._zoomInButton,i),_i(this._zoomOutButton,i),!this._disabled&&t._zoom!==t.getMinZoom()||ci(this._zoomOutButton,i),!this._disabled&&t._zoom!==t.getMaxZoom()||ci(this._zoomInButton,i)}});Ki.mergeOptions({zoomControl:!0}),Ki.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new $i,this.addControl(this.zoomControl))});var Qi=Xi.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var i="leaflet-control-scale",e=si("div",i),n=this.options;return this._addScales(n,i+"-line",e),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),e},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,i,e){t.metric&&(this._mScale=si("div",i,e)),t.imperial&&(this._iScale=si("div",i,e))},_update:function(){var t=this._map,i=t.getSize().y/2,e=t.distance(t.containerPointToLatLng([0,i]),t.containerPointToLatLng([this.options.maxWidth,i]));this._updateScales(e)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var i=this._getRoundNum(t),e=i<1e3?i+" m":i/1e3+" km";this._updateScale(this._mScale,e,i/t)},_updateImperial:function(t){var i,e,n,o=3.2808399*t;5280Leaflet'},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var i in(t.attributionControl=this)._container=si("div","leaflet-control-attribution"),Oi(this._container),t._layers)t._layers[i].getAttribution&&this.addAttribution(t._layers[i].getAttribution());return this._update(),this._container},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t=[];for(var i in this._attributions)this._attributions[i]&&t.push(i);var e=[];this.options.prefix&&e.push(this.options.prefix),t.length&&e.push(t.join(", ")),this._container.innerHTML=e.join(" | ")}}});Ki.mergeOptions({attributionControl:!0}),Ki.addInitHook(function(){this.options.attributionControl&&(new te).addTo(this)});Xi.Layers=Ji,Xi.Zoom=$i,Xi.Scale=Qi,Xi.Attribution=te,Yi.layers=function(t,i,e){return new Ji(t,i,e)},Yi.zoom=function(t){return new $i(t)},Yi.scale=function(t){return new Qi(t)},Yi.attribution=function(t){return new te(t)};var ie=S.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}});ie.addTo=function(t,i){return t.addHandler(i,this),this};var ee,ne={Events:Z},oe=bt?"touchstart mousedown":"mousedown",se={mousedown:"mouseup",touchstart:"touchend",pointerdown:"touchend",MSPointerDown:"touchend"},re={mousedown:"mousemove",touchstart:"touchmove",pointerdown:"touchmove",MSPointerDown:"touchmove"},ae=E.extend({options:{clickTolerance:3},initialize:function(t,i,e,n){c(this,n),this._element=t,this._dragStartTarget=i||t,this._preventOutline=e},enable:function(){this._enabled||(zi(this._dragStartTarget,oe,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(ae._dragging===this&&this.finishDrag(),Si(this._dragStartTarget,oe,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var i,e;!t._simulated&&this._enabled&&(this._moved=!1,li(this._element,"leaflet-zoom-anim")||ae._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((ae._dragging=this)._preventOutline&&Pi(this._element),xi(),Xt(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=bi(this._element),this._startPoint=new k(i.clientX,i.clientY),this._parentScale=Ti(e),zi(document,re[t.type],this._onMove,this),zi(document,se[t.type],this._onUp,this))))},_onMove:function(t){var i,e;!t._simulated&&this._enabled&&(t.touches&&1i&&(e.push(t[n]),o=n);oi.max.x&&(e|=2),t.yi.max.y&&(e|=8),e}function de(t,i,e,n){var o,s=i.x,r=i.y,a=e.x-s,h=e.y-r,u=a*a+h*h;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-e.x)*(t.y-e.y)/(n.y-e.y)+e.x&&(u=!u);return u||Oe.prototype._containsPoint.call(this,t,!0)}});var Ne=Ce.extend({initialize:function(t,i){c(this,i),this._layers={},t&&this.addData(t)},addData:function(t){var i,e,n,o=g(t)?t:t.features;if(o){for(i=0,e=o.length;iu.x&&(l=s.x+n-u.x+h.x),s.x-l-a.x<0&&(l=s.x-a.x),s.y+e+h.y>u.y&&(c=s.y+e-u.y+h.y),s.y-c-a.y<0&&(c=s.y-a.y),(l||c)&&t.fire("autopanstart").panBy([l,c]))},_onCloseButtonClick:function(t){this._close(),Ni(t)},_getAnchor:function(){return A(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}});Ki.mergeOptions({closePopupOnClick:!0}),Ki.include({openPopup:function(t,i,e){return t instanceof tn||(t=new tn(e).setContent(t)),i&&t.setLatLng(i),this.hasLayer(t)?this:(this._popup&&this._popup.options.autoClose&&this.closePopup(),this._popup=t,this.addLayer(t))},closePopup:function(t){return t&&t!==this._popup||(t=this._popup,this._popup=null),t&&this.removeLayer(t),this}}),Me.include({bindPopup:function(t,i){return t instanceof tn?(c(t,i),(this._popup=t)._source=this):(this._popup&&!i||(this._popup=new tn(i,this)),this._popup.setContent(t)),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t,i){return this._popup&&this._map&&(i=this._popup._prepareOpen(this,t,i),this._map.openPopup(this._popup,i)),this},closePopup:function(){return this._popup&&this._popup._close(),this},togglePopup:function(t){return this._popup&&(this._popup._map?this.closePopup():this.openPopup(t)),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var i=t.layer||t.target;this._popup&&this._map&&(Ni(t),i instanceof Be?this.openPopup(t.layer||t.target,t.latlng):this._map.hasLayer(this._popup)&&this._popup._source===i?this.closePopup():this.openPopup(i,t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}});var en=Qe.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,interactive:!1,opacity:.9},onAdd:function(t){Qe.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&this._source.fire("tooltipopen",{tooltip:this},!0)},onRemove:function(t){Qe.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&this._source.fire("tooltipclose",{tooltip:this},!0)},getEvents:function(){var t=Qe.prototype.getEvents.call(this);return bt&&!this.options.permanent&&(t.preclick=this._close),t},_close:function(){this._map&&this._map.closeTooltip(this)},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=si("div",t)},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var i,e=this._map,n=this._container,o=e.latLngToContainerPoint(e.getCenter()),s=e.layerPointToContainerPoint(t),r=this.options.direction,a=n.offsetWidth,h=n.offsetHeight,u=A(this.options.offset),l=this._getAnchor(),c="top"===r?(i=a/2,h):"bottom"===r?(i=a/2,0):(i="center"===r?a/2:"right"===r?0:"left"===r?a:s.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oe.max.x)||!i.wrapLat&&(t.ye.max.y))return!1}if(!this.options.bounds)return!0;var n=this._tileCoordsToBounds(t);return N(this.options.bounds).overlaps(n)},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var i=this._map,e=this.getTileSize(),n=t.scaleBy(e),o=n.add(e);return[i.unproject(n,t.z),i.unproject(o,t.z)]},_tileCoordsToBounds:function(t){var i=this._tileCoordsToNwSe(t),e=new R(i[0],i[1]);return this.options.noWrap||(e=this._map.wrapLatLngBounds(e)),e},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var i=t.split(":"),e=new k(+i[0],+i[1]);return e.z=+i[2],e},_removeTile:function(t){var i=this._tiles[t];i&&(ri(i.el),delete this._tiles[t],this.fire("tileunload",{tile:i.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){ci(t,"leaflet-tile");var i=this.getTileSize();t.style.width=i.x+"px",t.style.height=i.y+"px",t.onselectstart=a,t.onmousemove=a,it&&this.options.opacity<1&&mi(t,this.options.opacity),ot&&!st&&(t.style.WebkitBackfaceVisibility="hidden")},_addTile:function(t,i){var e=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),p(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&M(p(this._tileReady,this,t,null,o)),vi(o,e),this._tiles[n]={el:o,coords:t,current:!0},i.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,i,e){i&&this.fire("tileerror",{error:i,tile:e,coords:t});var n=this._tileCoordsToKey(t);(e=this._tiles[n])&&(e.loaded=+new Date,this._map._fadeAnimated?(mi(e.el,0),z(this._fadeFrame),this._fadeFrame=M(this._updateOpacity,this)):(e.active=!0,this._pruneTiles()),i||(ci(e.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:e.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),it||!this._map._fadeAnimated?M(this._pruneTiles,this):setTimeout(p(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var i=new k(this._wrapX?o(t.x,this._wrapX):t.x,this._wrapY?o(t.y,this._wrapY):t.y);return i.z=t.z,i},_pxBoundsToTileRange:function(t){var i=this.getTileSize();return new I(t.min.unscaleBy(i).floor(),t.max.unscaleBy(i).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var sn=on.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1},initialize:function(t,i){this._url=t,(i=c(this,i)).detectRetina&&zt&&0')}}catch(t){return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}}(),_n={_initContainer:function(){this._container=si("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(hn.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var i=t._container=cn("shape");ci(i,"leaflet-vml-shape "+(this.options.className||"")),i.coordsize="1 1",t._path=cn("path"),i.appendChild(t._path),this._updateStyle(t),this._layers[m(t)]=t},_addPath:function(t){var i=t._container;this._container.appendChild(i),t.options.interactive&&t.addInteractiveTarget(i)},_removePath:function(t){var i=t._container;ri(i),t.removeInteractiveTarget(i),delete this._layers[m(t)]},_updateStyle:function(t){var i=t._stroke,e=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(i=i||(t._stroke=cn("stroke")),o.appendChild(i),i.weight=n.weight+"px",i.color=n.color,i.opacity=n.opacity,n.dashArray?i.dashStyle=g(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):i.dashStyle="",i.endcap=n.lineCap.replace("butt","flat"),i.joinstyle=n.lineJoin):i&&(o.removeChild(i),t._stroke=null),n.fill?(e=e||(t._fill=cn("fill")),o.appendChild(e),e.color=n.fillColor||n.color,e.opacity=n.fillOpacity):e&&(o.removeChild(e),t._fill=null)},_updateCircle:function(t){var i=t._point.round(),e=Math.round(t._radius),n=Math.round(t._radiusY||e);this._setPath(t,t._empty()?"M0 0":"AL "+i.x+","+i.y+" "+e+","+n+" 0,23592600")},_setPath:function(t,i){t._path.v=i},_bringToFront:function(t){hi(t._container)},_bringToBack:function(t){ui(t._container)}},dn=Et?cn:J,pn=hn.extend({getEvents:function(){var t=hn.prototype.getEvents.call(this);return t.zoomstart=this._onZoomStart,t},_initContainer:function(){this._container=dn("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=dn("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){ri(this._container),Si(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_onZoomStart:function(){this._update()},_update:function(){var t,i,e;this._map._animatingZoom&&this._bounds||(hn.prototype._update.call(this),i=(t=this._bounds).getSize(),e=this._container,this._svgSize&&this._svgSize.equals(i)||(this._svgSize=i,e.setAttribute("width",i.x),e.setAttribute("height",i.y)),vi(e,t.min),e.setAttribute("viewBox",[t.min.x,t.min.y,i.x,i.y].join(" ")),this.fire("update"))},_initPath:function(t){var i=t._path=dn("path");t.options.className&&ci(i,t.options.className),t.options.interactive&&ci(i,"leaflet-interactive"),this._updateStyle(t),this._layers[m(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){ri(t._path),t.removeInteractiveTarget(t._path),delete this._layers[m(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var i=t._path,e=t.options;i&&(e.stroke?(i.setAttribute("stroke",e.color),i.setAttribute("stroke-opacity",e.opacity),i.setAttribute("stroke-width",e.weight),i.setAttribute("stroke-linecap",e.lineCap),i.setAttribute("stroke-linejoin",e.lineJoin),e.dashArray?i.setAttribute("stroke-dasharray",e.dashArray):i.removeAttribute("stroke-dasharray"),e.dashOffset?i.setAttribute("stroke-dashoffset",e.dashOffset):i.removeAttribute("stroke-dashoffset")):i.setAttribute("stroke","none"),e.fill?(i.setAttribute("fill",e.fillColor||e.color),i.setAttribute("fill-opacity",e.fillOpacity),i.setAttribute("fill-rule",e.fillRule||"evenodd")):i.setAttribute("fill","none"))},_updatePoly:function(t,i){this._setPath(t,$(t._parts,i))},_updateCircle:function(t){var i=t._point,e=Math.max(Math.round(t._radius),1),n="a"+e+","+(Math.max(Math.round(t._radiusY),1)||e)+" 0 1,0 ",o=t._empty()?"M0 0":"M"+(i.x-e)+","+i.y+n+2*e+",0 "+n+2*-e+",0 ";this._setPath(t,o)},_setPath:function(t,i){t._path.setAttribute("d",i)},_bringToFront:function(t){hi(t._path)},_bringToBack:function(t){ui(t._path)}});function mn(t){return Zt||Et?new pn(t):null}Et&&pn.include(_n),Ki.include({getRenderer:function(t){var i=(i=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(i)||this.addLayer(i),i},_getPaneRenderer:function(t){if("overlayPane"===t||void 0===t)return!1;var i=this._paneRenderers[t];return void 0===i&&(i=this._createRenderer({pane:t}),this._paneRenderers[t]=i),i},_createRenderer:function(t){return this.options.preferCanvas&&ln(t)||mn(t)}});var fn=Re.extend({initialize:function(t,i){Re.prototype.initialize.call(this,this._boundsToLatLngs(t),i)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=N(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});pn.create=dn,pn.pointsToPath=$,Ne.geometryToLayer=De,Ne.coordsToLatLng=We,Ne.coordsToLatLngs=He,Ne.latLngToCoords=Fe,Ne.latLngsToCoords=Ue,Ne.getFeature=Ve,Ne.asFeature=qe,Ki.mergeOptions({boxZoom:!0});var gn=ie.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){zi(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){Si(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){ri(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),Xt(),xi(),this._startPoint=this._map.mouseEventToContainerPoint(t),zi(document,{contextmenu:Ni,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=si("div","leaflet-zoom-box",this._container),ci(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var i=new I(this._point,this._startPoint),e=i.getSize();vi(this._box,i.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(ri(this._box),_i(this._container,"leaflet-crosshair")),Jt(),wi(),Si(document,{contextmenu:Ni,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){var i;1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(p(this._resetState,this),0),i=new R(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(i).fire("boxzoomend",{boxZoomBounds:i})))},_onKeyDown:function(t){27===t.keyCode&&this._finish()}});Ki.addInitHook("addHandler","boxZoom",gn),Ki.mergeOptions({doubleClickZoom:!0});var vn=ie.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var i=this._map,e=i.getZoom(),n=i.options.zoomDelta,o=t.originalEvent.shiftKey?e-n:e+n;"center"===i.options.doubleClickZoom?i.setZoom(o):i.setZoomAround(t.containerPoint,o)}});Ki.addInitHook("addHandler","doubleClickZoom",vn),Ki.mergeOptions({dragging:!0,inertia:!st,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0});var yn=ie.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new ae(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),ci(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){_i(this._map._container,"leaflet-grab"),_i(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,i=this._map;i._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=N(this._map.options.maxBounds),this._offsetLimit=O(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,i.fire("movestart").fire("dragstart"),i.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var i,e;this._map.options.inertia&&(i=this._lastTime=+new Date,e=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(e),this._times.push(i),this._prunePositions(i)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1i.max.x&&(t.x=this._viscousLimit(t.x,i.max.x)),t.y>i.max.y&&(t.y=this._viscousLimit(t.y,i.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,i=Math.round(t/2),e=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-i+e)%t+i-e,s=(n+i+e)%t-i-e,r=Math.abs(o+e)i.getMaxZoom()&&1l zU=eEc5rfZcZcY&EFg}b@%vmkJZQQL6pne)Erfq#O;K#CSeyYYTPp6k(x9ITm+C7Kn z-HhpzlJ&w09rLZ0pN$PA6CY2m47_>wsJ3PNbmpS|`yBpEO@HnlMB9f5y|8`_D7i3! z!k3^UEc>vFkif69NuS@k_WefsXXMn(!=LxxPad9mo!4jd412;xMC;-^&%U2*mCZNB zCEoFD3Az$*$nsSf@WIbUc3Ez5EVMoH`_CCq4OXf6`RLE5R#HU*Qkgr)-_Frz_p}rH zX7fT&{e4@9<|Pe#4ZD%`N0v+A&w_q+X-mB1=BY)Jl7&gG2d1XaZzL?)=#;!!x#QHn z30bjx_4@rq`*NmKl=sST^6>#UU~aByd>+i=)!hK926Zm*K-b2Cbj1gLd`iuNGE>dplTbzV8prinX# zpde#{Ks>ULL2dPRRsYCS?y^`pi4b}{Ral(9o|;4`lWPwwfp307jjJp>R*k!=a@anv zEGNl6e{9s?(Jl}tDu2Ov}S*#pj4dk;(3xjv}+;p2~3_bfmAM@1b?F z&rgP{(6Sw#!nf7O?#cSoF1e{5Q*Jf0RQc}j!|$X%?#a(hJ&#Hf6fKhSJwMB&rG>5)_&6-RIPg?Do!DCX z+_e4aWYWTcYJu@n6pdh6P0vZ|+%^Kmx0qD;lqSfSiKynj9qr->ZWoCum$WO;W(sQb z<;IQ?*Up-^O*f_J$3DQ&Po$eRQ~A;B5-I@6?Y_R$40WyA&PwEoa6-vnQHJahLb(d59ZM7)7#+T9j# z)kywiVak29@~aREr8PupV>MmL`jPV{3m<{J?Q%BqO2UZv%$9`P@N@+9(J@qjlC)wM z?#?lBb=|gWf5zaJzHqjaA$R9O!$c`g?%11YxQa;cS_gH;e18gW!b|oPq<_9zV{0wl z@?NmsfKjq7Q(*vrA+_;SwZ}+$u%l+{{rhK0HVJseK3l~%dUvJNti8Y8ZJ_Wkr@?_yVyi*Iu(Ge?#Rdwq}nB+Y4trE*ZiRvW~61dPJ30&xAGFFMn^{4T| z)bOc&LaePp7;mn)K=uvp-}HL>Gr=JNYzs5$TWtW5!ee9*OHV~sp6Z!< zGRJn<74B2ZnI69l;9Hfqxb8=L_pU4P>vx6WH7?1V%)2y4rrh>0^#OtSc>L7BN1DCY z%Bqtg#3So$Qa<7#uf&QAzmxWo=Fio233^yNsaCF!O^W`gQDdJiS^Y#X{e|C7HQ}Mi zvs)7RS|tH#x)*>QTOe!v4l{v>_-X51V89gpB6lC8u#U#noPW-qQ=2yRpicL2Pw^Q$ z@c|JuNt0HxB=HgHe8ieYipF}|eA~mL=C4$Z(G%KPdKK@~_sYVD#!NvflfmPal`ARw z>)MADq~Edw#mR&fbe|~tjFY=WoMe}px$VC#fIZ7xzkb4P%tMP|42<k|4oFP%=MUk6g82%aK!SCiy2i2RgZshIZoak-8G zVi-uR_&Ljhmjh=r<3`do$223=e%yGWH+s@Hj7oFrJzL1CTHilbFAMd>dpV+3pzlNE z4>uznq(j^hvHLi7dXLm>BdnAT*ROal_9TNTdsAEEFt^AoND#Xljg-yT>I;eC*HYdFWy!2QD@Ie0-kYl7c0 zU#cZF$|l==^}Eo7#vp<{8^TieShfcM8INbFB!WnckMQ;g(X(G=6oPJ-PTRBQ6`BdR zXfu9#(V!JKDR$a=yv~~bKuVrLQ{F{(E{Zdj`sN7B{Eo@m=n#%=8pzMrP=}IuhM4+2 ztCAjIS-Cwf5>DQ1bj2rJ#o&$pfg(}ZqVxH2&jasw4#1%2xF1Ptk~&D=Fwz)Qf4pi| zun7aLIT0rjt*mDISWWW{#l77mT3l3$l^|N7+L`~xh^tb?Tnd(bUQKLmVf^6vwX4&u zOk@=@oU7E%Lbwgg3>)SI&q>JlyR$eim2ps<@NA6|vc3?~3Yg#g^i9!Piuc3mO)dWff6OyJ zL9%aUeK?hrfr?(HEzPs=H4}qV@8F-PX$pE=(3GSgPb^yYIcaw1tqq*6&O@4c4GJ^5 zm83*q3itJ-w9OSB9YL9|)lnFZV_v@e3DQ);uQBo6>`T*GH|H&FtGb9yox8-}?Gn3F zJBBEvUr(evblYSLbX=q|@ASYqKx=Qt zT15Mj6J1jSxjU=gen$8`vN82aQ#J4G)B7M`)GR`%xm&~Opl%m{pI5OXm8DF?JEMWE@jH@>^nh!k zA~{Vx6VohPI5m86@8QJm z#fC?OCL0y0((?5W6dw%}g)@`$xgvt+_s^?q=^b#`_jid#z#2RW;W^Hm_BePfq^x+w zX(_=wt9ulT{$w1h=}(BeBIb&=?Gj|bT2h@Lb<$T1()1*Equ=bD-i!v!7gerq_*h52ryw z&G3aI&Zs5k59*^U622k|Q0nWe6cL(_f)CWRsXw)#VKVB1#={}%SK4Il56b1O=hNd! zRII1?4xSBRcX*&nY{CzH(&tT$MCAzS#Cjhodc}~{cH)^wd3O(71}WfT4;45F{V#f} zaJ3*SEPa~K9w~lu7s)p|jUA>j1BRWX@MUlWx)M_?1JArFqvvQA8$y_W@SA{?! z`JIO>0VQie84x#kmYPJu>}Ns{bw|0xtV}DFld@lUTat8VI*Q$DmiE>p%Z)G@d4|k< zlI~o^+eD#*StyaP+ppRdIWR9y!z7SxTsGlDDjbjl+O%MS*rIVx4|(6mK63VLy!unr z*8{ZnHUv~5T5=<%dLk0VHFZL>uVPFoS2y?`snY2_>8>zEYDi`0CXx!kiYTv!zlw}@ zQAZL$;v(Af6V$p77Oy4D2S4e8*E@cb*o@wCUZ6oZuB6VVfPZ`vBjdFg_T1=YU)`js zWw(r+4mR-Qnv{IkS2bxAlk=Hhx1D-XCL}+hh~vqlmpCf^hD$jXtc9cRVp9|K$;UC& zZrMVay5ccDY>9z`G{Jm@FMZ<1QX@ab@ujtJ$~C`7-{hLf5i~IvE#$7 zLCuQEvKTEq^{Lw!o)MYWQU}U+_@*<(VLVR+$@WVHh6vY{H%9@DSM8_@*gOd)0b7nD z29Q;KFb~HD3Q0=Z7ji#35>D`1KKgA+(QDkJEs5a?_X5O24|xw=VNPvVH}F|h6Y#bL zH`o0P}&p*+i?a0(7(Q7nQu1_`r^q9$FFp#@%-FM{3y7FkV>YxT^LQ#tzWFVNb!i zU(=dwVU$QUd*JCE6|}K3%%U)i0rtbw< z6oJqU^aE*1kr?mSkMOS+RV4}1OFzu>Bk^Qq-3p{0mhuqs<|=nl8(|i536NdgC9e~8 zWO^8}5_o+wheI}A5BXw+l})+R(x%VZZAvYmOnQhdQJ7HbrN1tb{B54~;P(bP5!St( z@VjSStLJU2+nX4pAE6_?RJk|zk4d)MR(GmUiD{lr5Q@5cppUzG5!*lRO}SLFzY9e! z3^gWvUQr_mSpO8C zu69l`-Y%k`rPNRxZkFj|&Bd2!eB~&6nh~X#6(i?m9V}lzHA+E9%5&$&aL;Bv(=;cH zJmfAc;ubB*aSiJ|=-m`kQ=O9F#o4>p@3Il{Ui69%E^~$QYkxMevV1EF(~=*RjznCz zk{zBY!?*P^PLyDaoJnTYAVqF2$q$62g029Xpbf~Ru=*V4JqhoJ?WySV(XCYi-mV9L zehDJigH}Dnq+-j#EY}3qkuwo4xcUHyV}0Ouodl za0lHYNH>02)`2QO_sU$8H#U4@)AjjOyBxjDGo@VdM64BLRCL8jO;}=dOAAIS_*@TD zIG`P`H8ePpq$y0ya`KDJr!S9MvIp5ZgBE-n8xCt9 z5tHoGjub^fq(^wId1$T)PBB1!L;#M5p0Wlbt#4Gsz6@SIstJ`VL zN(gTf<2Hwj{urj`ngD-cN)6Qh4UhfYix%+9k%WX=O;>7Cz6Uh4@?3cq_?-IGS$FSr z$VMD2+&ahY0>!AYP7qy(E$KWVn%WM!Xx(xuK5ef}rjNi+1y!Fx$O+s76hBtCJa=qx z!8>{#z}vJ4nh03c8ny=ly$SXl@UKk(HKy?SJ^Y*R$cZ48c1GxTyynlIHFIAHMg_dR zTT`YOH%JNTpXE(INuXL;|K}ZLrBNpn4^$PiiOC=Qz?LRFpC9tt`bpB<975g7Z zjGgsgV*Mkw%b8#4{5=q?`JcT1K>v~ZFEAFRp#hgiS-M?@ry?)GcIh8(g|f7>g8#a; z6totxKnNg#Fe^)8pb$dP3Wxv;Spp$23n&bS!mw0eC=>`qBEUc_3&I*IWG!qZ`dc(smT*NB+7W?WPCG}04HD$+Wb@0% zrQmQGZ50VN0e`*?u(`nH&`1OZh1N%*93TmS|a7U2a8 zz`ou5V^lO0h!;t2P%}gCV+d-n z2VNr;c^Q4KfwM@;=YDO;}bu3Yr8iDayeB&$p}}S*v%-*$e)i$X9o9z`7Y34^ zQN0%CD$|~uQ$2czC`Hqv_Q~r}NU^hk$E98rPt<-s7}4~50F6hmcY5Dhc6hlk`Wfyf z(&L6My_MzVsR>hooIQV6iH?Pf6MyuyFnmbSJ#$rcVm{W&N$t&eExKm~I9Ans`mB#n zQm4+vaDScG*=707^d?e1el6Xw(}{QN81vrfu8;3n7E9Os*(r~*g$eg$WSK