PoC
This commit is contained in:
commit
8b20c84638
|
@ -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));
|
|
@ -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
|
||||
'';
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
}
|
|
@ -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"] }
|
|
@ -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.)
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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: '© <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();
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
File diff suppressed because one or more lines are too long
Binary file not shown.
After Width: | Height: | Size: 9.2 KiB |
Loading…
Reference in New Issue